diff --git a/lua/image/init.lua b/lua/image/init.lua index f547fb6..b2a4883 100644 --- a/lua/image/init.lua +++ b/lua/image/init.lua @@ -295,7 +295,7 @@ api.setup = function(options) -- auto-clear on buffer / window close vim.api.nvim_create_autocmd({ "BufLeave", "WinClosed", "TabEnter" }, { group = group, - callback = function() -- auto-clear images when windows and buffers change + callback = function(event) -- auto-clear images when windows and buffers change -- bail if not enabled if not state.enabled then return end @@ -313,26 +313,33 @@ api.setup = function(options) local window_ok, is_valid_window = pcall(vim.api.nvim_win_is_valid, current_image.window) if not window_ok or not is_valid_window then current_image:clear() - return + goto continue end local is_window_in_current_tab = windows_in_current_tab_map[current_image.window] if not is_window_in_current_tab then - current_image:clear() - return + -- The window still exists: hide its images without forgetting them. + current_image:clear(true) + goto continue end if current_image.buffer then local buf_ok, is_valid_buffer = pcall(vim.api.nvim_buf_is_valid, current_image.buffer) if not buf_ok or not is_valid_buffer then current_image:clear() - return + goto continue end local is_buffer_in_window = vim.api.nvim_win_get_buf(current_image.window) == current_image.buffer - if not is_buffer_in_window then current_image:clear() end + if not is_buffer_in_window then + current_image:clear() + goto continue + end end + + if event.event == "TabEnter" then current_image:render() end end + ::continue:: end end) end, diff --git a/tests/core/tab_switch_spec.lua b/tests/core/tab_switch_spec.lua new file mode 100644 index 0000000..a4eb384 --- /dev/null +++ b/tests/core/tab_switch_spec.lua @@ -0,0 +1,174 @@ +local unload_image_modules = function() + for name in pairs(package.loaded) do + if name == "image" or name:match("^image/") then package.loaded[name] = nil end + end +end + +describe("tab switching with API images", function() + local api + local original_tab + local original_buffer + local saved_modules + local autocmd_ids + local tmp_dir + + local flush = function() + vim.wait(20, function() + return false + end) + end + + local make_image = function(id, opts) + local img = assert(api.from_file( + "tests/test_data/100x100.png", + vim.tbl_extend("force", { + id = id, + window = vim.api.nvim_get_current_win(), + buffer = vim.api.nvim_get_current_buf(), + x = 0, + y = 0, + width = 5, + height = 3, + }, opts or {}) + )) + tmp_dir = img.global_state.tmp_dir + img:render() + assert.is_true(vim.wait(200, function() + return img.is_rendered + end)) + return img + end + + before_each(function() + saved_modules = {} + for name, module in pairs(package.loaded) do + if name == "image" or name:match("^image/") then saved_modules[name] = module end + end + unload_image_modules() + autocmd_ids = {} + for _, autocmd in ipairs(vim.api.nvim_get_autocmds({})) do + if autocmd.id then autocmd_ids[autocmd.id] = true end + end + original_tab = vim.api.nvim_get_current_tabpage() + original_buffer = vim.api.nvim_get_current_buf() + vim.cmd("tabnew") + + -- Keep real windows, autocommands, Image objects, renderer, and Kitty backend. + -- Only terminal I/O and ImageMagick are replaced for headless CI. + package.loaded["image/utils/term"] = { + get_size = function() + return { cell_width = 8, cell_height = 16, screen_cols = 80, screen_rows = 24 } + end, + get_tty = function() end, + } + package.loaded["image/backends/kitty/helpers"] = setmetatable({}, { + __index = function() + return function() end + end, + }) + package.loaded["image/processors/magick_cli"] = { + get_format = function() + return "png" + end, + get_dimensions = function() + return { width = 100, height = 100 } + end, + transform = function(path, _, output_path, complete) + assert(vim.uv.fs_copyfile(path, output_path)) + complete({ ok = true, path = output_path }) + end, + } + local integrations = {} + for _, name in ipairs({ "markdown", "asciidoc", "typst", "neorg", "syslang", "html", "css", "org" }) do + integrations[name] = { enabled = false } + end + api = require("image") + api.setup({ backend = "kitty", processor = "magick_cli", integrations = integrations }) + end) + + after_each(function() + api.disable() + flush() + for _, autocmd in ipairs(vim.api.nvim_get_autocmds({})) do + if autocmd.id and not autocmd_ids[autocmd.id] then pcall(vim.api.nvim_del_autocmd, autocmd.id) end + end + vim.api.nvim_set_decoration_provider(vim.api.nvim_create_namespace("image.nvim"), {}) + vim.api.nvim_set_current_tabpage(original_tab) + vim.cmd("silent tabonly!") + vim.api.nvim_set_current_buf(original_buffer) + if tmp_dir then vim.fn.delete(tmp_dir, "rf") end + tmp_dir = nil + unload_image_modules() + for name, module in pairs(saved_modules) do + package.loaded[name] = module + end + end) + + it("retains and restores a bound image and its virtual padding when returning to its tab", function() + local img = make_image("tab-image", { with_virtual_padding = true }) + vim.cmd("tabnew") + flush() + assert.is_false(img.is_rendered) + assert.are.same({ img }, api.get_images()) + + vim.cmd("tabclose") + flush() + assert.is_true(img.is_rendered) + assert.are.same({ img }, api.get_images()) + local marks = + vim.api.nvim_buf_get_extmarks(img.buffer, img.global_state.extmarks_namespace, 0, -1, { details = true }) + assert.are.same(1, #marks) + assert.is_true(#marks[1][4].virt_lines > 0) + end) + + it("restores an image bound to a window without a buffer", function() + local img = make_image("window-only", { buffer = false }) + vim.cmd("tabnew") + flush() + assert.is_false(img.is_rendered) + assert.are.same({ img }, api.get_images()) + vim.cmd("tabclose") + flush() + assert.is_true(img.is_rendered) + end) + + it("hides every image in the old tab and restores only the current tab", function() + local first_tab = vim.api.nvim_get_current_tabpage() + local images = { make_image("first"), make_image("second"), make_image("third") } + vim.cmd("tabnew") + flush() + for _, img in ipairs(images) do + assert.is_false(img.is_rendered) + end + assert.are.same(3, #api.get_images()) + local other = make_image("other-tab") + + vim.api.nvim_set_current_tabpage(first_tab) + flush() + for _, img in ipairs(images) do + assert.is_true(img.is_rendered) + end + assert.is_false(other.is_rendered) + assert.are.same(4, #api.get_images()) + end) + + it("removes every image whose window has been closed", function() + for i = 1, 6 do + make_image("image-" .. i) + end + vim.cmd("tabclose") + flush() + assert.are.same({}, api.get_images()) + end) + + it("does not restore an image explicitly cleared by the caller", function() + local img = make_image("cleared") + img:clear() + vim.cmd("tabnew") + flush() + vim.cmd("tabclose") + flush() + assert.is_false(img.is_rendered) + assert.are.same({}, api.get_images()) + end) +end)