diff --git a/README.md b/README.md index fecf419..cf97725 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,9 @@ Using lazy.nvim: -- Marks: browse buffer marks (requires marks provider enabled) vim.keymap.set("n", "pm", function() peekstack.peek.marks_buffer() end) + + -- Utility: temporarily hide/show all popups in current stack + vim.keymap.set("n", "ph", "PeekstackToggleVisibility", { desc = "Peekstack: toggle visibility" }) end, } ``` @@ -109,6 +112,7 @@ Built-in provider names: - `:PeekstackRestorePopup` — restore the last closed popup (undo close) - `:PeekstackRestoreAllPopups` — restore all closed popups - `:PeekstackCloseAll` — close all popups in the current stack +- `:PeekstackToggleVisibility` — temporarily hide/show all popups in the current stack - `:PeekstackHistory` — show popup history and select to restore - `:PeekstackQuickPeek [provider]` — quick peek without stacking (default: `lsp.definition`, accepts any registered provider) diff --git a/doc/peekstack.txt b/doc/peekstack.txt index fc974c8..5f48b32 100644 --- a/doc/peekstack.txt +++ b/doc/peekstack.txt @@ -291,6 +291,12 @@ Commands are registered after `setup()` is called. :PeekstackCloseAll *:PeekstackCloseAll* Close all popups in the current stack. +:PeekstackToggleVisibility *:PeekstackToggleVisibility* + Temporarily hide or show all popups in the current stack. When hidden, + popup windows are closed but the stack state is preserved. Toggling + again recreates the windows in their original layout. Pushing a new + popup while hidden automatically restores visibility. + :PeekstackHistory *:PeekstackHistory* Show popup history and select an entry to restore. diff --git a/lua/peekstack/commands.lua b/lua/peekstack/commands.lua index 83e0b5a..34ed31c 100644 --- a/lua/peekstack/commands.lua +++ b/lua/peekstack/commands.lua @@ -13,6 +13,7 @@ local COMMAND_NAMES = { "PeekstackHistory", "PeekstackCloseAll", "PeekstackQuickPeek", + "PeekstackToggleVisibility", } ---@param session PeekstackSession|table @@ -173,6 +174,13 @@ function M.setup() require("peekstack.core.stack").close_all() end, {}) + vim.api.nvim_create_user_command("PeekstackToggleVisibility", function() + local toggled = require("peekstack.core.stack").toggle_visibility() + if not toggled then + notify.info("No popups in the current stack") + end + end, {}) + vim.api.nvim_create_user_command("PeekstackQuickPeek", function(opts) local provider = opts.args and opts.args ~= "" and opts.args or "lsp.definition" require("peekstack").peek(provider, { mode = "quick" }) diff --git a/lua/peekstack/core/stack.lua b/lua/peekstack/core/stack.lua index f495a0b..c7021e2 100644 --- a/lua/peekstack/core/stack.lua +++ b/lua/peekstack/core/stack.lua @@ -28,6 +28,8 @@ local stacks = {} local ephemerals = {} ---@type table local stack_view_wins = {} +--- Guard flag: when true, WinClosed/BufWipeout handlers skip popup removal. +local suppress_win_events = false ---@class PeekstackPopupLookupEntry ---@field popup PeekstackPopupModel ---@field root_winid integer? @@ -277,6 +279,10 @@ function M.push(location, opts) end local stack = ensure_stack() + -- Auto-show hidden stack before pushing a new popup + if stack.hidden then + M.toggle_visibility(stack.root_winid) + end local model = popup.create(location, create_opts) if not model then return nil @@ -531,6 +537,30 @@ function M.focus_by_id(id, winid) return false end +---Re-create a popup window for an existing stack item. +---@param item PeekstackPopupModel +---@param stack PeekstackStackModel +---@return PeekstackPopupModel? +local function reopen_popup(item, stack) + local reopen_opts = { + buffer_mode = item.buffer_mode or "copy", + origin_winid = stack.root_winid, + parent_popup_id = item.parent_popup_id, + } + if not item.title_chunks then + reopen_opts.title = item.title + end + local model = popup.create(item.location, reopen_opts) + if not model then + return nil + end + model.id = item.id + model.pinned = item.pinned or false + vim.b[model.bufnr].peekstack_popup_id = model.id + vim.w[model.winid].peekstack_popup_id = model.id + return model +end + ---Re-open a popup by id when its window is gone. ---@param id integer ---@param winid? integer @@ -540,22 +570,10 @@ function M.reopen_by_id(id, winid) local stack = ensure_stack(winid) for idx, item in ipairs(stack.popups) do if item.id == id then - local reopen_opts = { - buffer_mode = item.buffer_mode or "copy", - origin_winid = stack.root_winid, - parent_popup_id = item.parent_popup_id, - } - if not item.title_chunks then - reopen_opts.title = item.title - end - local model = popup.create(item.location, reopen_opts) + local model = reopen_popup(item, stack) if not model then return nil end - model.id = item.id - model.pinned = item.pinned or false - vim.b[model.bufnr].peekstack_popup_id = model.id - vim.w[model.winid].peekstack_popup_id = model.id unindex_popup(item) stack.popups[idx] = model index_popup(model, stack.root_winid) @@ -601,6 +619,9 @@ end ---@param winid integer function M.handle_win_closed(winid) deps() + if suppress_win_events then + return + end if stack_view_wins[winid] then stack_view_wins[winid] = nil return @@ -688,6 +709,9 @@ end ---@param bufnr integer function M.handle_buf_wipeout(bufnr) deps() + if suppress_win_events then + return + end for id, item in pairs(ephemerals) do if item.bufnr == bufnr then unregister_ephemeral(id) @@ -820,11 +844,80 @@ function M.reflow_all() end end +---Toggle visibility of all popups in the current stack. +---When hidden, popup windows are closed but models remain in the stack. +---When shown, popup windows are recreated from the stored models. +---@param winid? integer +---@return boolean +function M.toggle_visibility(winid) + deps() + local stack = ensure_stack(winid) + if #stack.popups == 0 then + return false + end + + if not stack.hidden then + -- Move focus back to root window before hiding + if vim.api.nvim_win_is_valid(stack.root_winid) then + vim.api.nvim_set_current_win(stack.root_winid) + end + -- Close all popup windows but keep models in the stack. + -- Suppress WinClosed/BufWipeout handlers to prevent them from + -- removing popups that we intend to keep in the stack. + suppress_win_events = true + for _, item in ipairs(stack.popups) do + popup.close(item) + unindex_popup(item) + item.winid = nil + end + suppress_win_events = false + stack.hidden = true + else + -- Recreate all popup windows + for idx, item in ipairs(stack.popups) do + local model = reopen_popup(item, stack) + if model then + stack.popups[idx] = model + index_popup(model, stack.root_winid) + end + end + layout.reflow(stack) + stack.hidden = false + -- Restore focus to the previously focused popup + if stack.focused_id then + M.focus_by_id(stack.focused_id, stack.root_winid) + end + end + return true +end + +---Check whether the current stack is hidden. +---@param winid? integer +---@return boolean +function M.is_hidden(winid) + local stack = ensure_stack(winid) + return stack.hidden == true +end + --- Close all popups in the current (or given) window's stack. ---@param winid? integer function M.close_all(winid) deps() local stack = ensure_stack(winid) + -- When hidden, windows are already closed; just clear hidden state + -- and record history for each popup. + if stack.hidden then + for idx = #stack.popups, 1, -1 do + local item = stack.popups[idx] + emit_popup_event("PeekstackClose", item, stack.root_winid) + history.push_entry(stack, history.build_entry(item, idx)) + unindex_popup(item) + table.remove(stack.popups, idx) + end + stack.hidden = false + stack.focused_id = nil + return + end for idx = #stack.popups, 1, -1 do local item = stack.popups[idx] feedback.highlight_origin(item.origin) @@ -855,6 +948,7 @@ function M._reset() stack_view_wins = {} popup_by_id = {} popup_by_winid = {} + suppress_win_events = false end ---Get ephemeral popups (for testing). diff --git a/lua/peekstack/types.lua b/lua/peekstack/types.lua index ffbead7..b2cd982 100644 --- a/lua/peekstack/types.lua +++ b/lua/peekstack/types.lua @@ -62,6 +62,7 @@ ---@field history PeekstackHistoryEntry[] ---@field layout_state any ---@field focused_id integer? +---@field hidden boolean? ---@class PeekstackUserEventData ---@field event string diff --git a/tests/toggle_visibility_spec.lua b/tests/toggle_visibility_spec.lua new file mode 100644 index 0000000..b1d5c92 --- /dev/null +++ b/tests/toggle_visibility_spec.lua @@ -0,0 +1,133 @@ +local stack = require("peekstack.core.stack") +local config = require("peekstack.config") +local events = require("peekstack.core.events") +local helpers = require("tests.helpers") + +describe("stack.toggle_visibility", function() + before_each(function() + stack._reset() + config.setup({}) + events.setup() + end) + + after_each(function() + local s = stack.current_stack() + -- Restore visibility so windows can be closed normally + if s.hidden then + stack.toggle_visibility() + end + for i = #s.popups, 1, -1 do + stack.close(s.popups[i].id) + end + stack._reset() + end) + + it("returns false on empty stack", function() + assert.is_false(stack.toggle_visibility()) + assert.is_false(stack.is_hidden()) + end) + + it("hides all popup windows", function() + local loc = helpers.make_location() + local m1 = stack.push(loc) + local m2 = stack.push(loc) + assert.is_not_nil(m1) + assert.is_not_nil(m2) + + assert.is_true(stack.toggle_visibility()) + assert.is_true(stack.is_hidden()) + + -- Windows should be gone but popups remain in the stack + local s = stack.current_stack() + assert.equals(2, #s.popups) + for _, item in ipairs(s.popups) do + assert.is_nil(item.winid) + end + end) + + it("restores popup windows on second toggle", function() + local loc = helpers.make_location() + local m1 = stack.push(loc) + local m2 = stack.push(loc) + assert.is_not_nil(m1) + assert.is_not_nil(m2) + + -- Hide + stack.toggle_visibility() + assert.is_true(stack.is_hidden()) + + -- Show + assert.is_true(stack.toggle_visibility()) + assert.is_false(stack.is_hidden()) + + local s = stack.current_stack() + assert.equals(2, #s.popups) + for _, item in ipairs(s.popups) do + assert.is_not_nil(item.winid) + assert.is_true(vim.api.nvim_win_is_valid(item.winid)) + end + end) + + it("preserves popup ids across hide/show cycle", function() + local loc = helpers.make_location() + local m1 = stack.push(loc) + local m2 = stack.push(loc) + + local id1 = m1.id + local id2 = m2.id + + stack.toggle_visibility() + stack.toggle_visibility() + + local s = stack.current_stack() + assert.equals(id1, s.popups[1].id) + assert.equals(id2, s.popups[2].id) + end) + + it("auto-shows when push is called while hidden", function() + local loc = helpers.make_location() + stack.push(loc) + + stack.toggle_visibility() + assert.is_true(stack.is_hidden()) + + -- Pushing should auto-show + local m2 = stack.push(loc) + assert.is_not_nil(m2) + assert.is_false(stack.is_hidden()) + + local s = stack.current_stack() + assert.equals(2, #s.popups) + for _, item in ipairs(s.popups) do + assert.is_not_nil(item.winid) + assert.is_true(vim.api.nvim_win_is_valid(item.winid)) + end + end) + + it("close_all works while hidden", function() + local loc = helpers.make_location() + stack.push(loc) + stack.push(loc) + + stack.toggle_visibility() + assert.is_true(stack.is_hidden()) + + stack.close_all() + assert.is_false(stack.is_hidden()) + + local s = stack.current_stack() + assert.equals(0, #s.popups) + end) + + it("does not leak popups to history when hiding", function() + local loc = helpers.make_location() + stack.push(loc) + stack.push(loc) + + local history_before = #stack.history_list() + stack.toggle_visibility() + local history_after = #stack.history_list() + + assert.equals(history_before, history_after) + end) +end)