Skip to content

Commit 4061533

Browse files
authored
Merge pull request #16 from mhiro2/fix/toggle-visibility-suppress-events
feat(stack): Add stack-wide popup visibility toggle with safe restore
2 parents 00551ac + e8ae5bf commit 4061533

6 files changed

Lines changed: 259 additions & 13 deletions

File tree

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,9 @@ Using lazy.nvim:
7171

7272
-- Marks: browse buffer marks (requires marks provider enabled)
7373
vim.keymap.set("n", "<leader>pm", function() peekstack.peek.marks_buffer() end)
74+
75+
-- Utility: temporarily hide/show all popups in current stack
76+
vim.keymap.set("n", "<leader>ph", "<cmd>PeekstackToggleVisibility<cr>", { desc = "Peekstack: toggle visibility" })
7477
end,
7578
}
7679
```
@@ -109,6 +112,7 @@ Built-in provider names:
109112
- `:PeekstackRestorePopup` — restore the last closed popup (undo close)
110113
- `:PeekstackRestoreAllPopups` — restore all closed popups
111114
- `:PeekstackCloseAll` — close all popups in the current stack
115+
- `:PeekstackToggleVisibility` — temporarily hide/show all popups in the current stack
112116
- `:PeekstackHistory` — show popup history and select to restore
113117
- `:PeekstackQuickPeek [provider]` — quick peek without stacking (default: `lsp.definition`, accepts any registered provider)
114118

doc/peekstack.txt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -291,6 +291,12 @@ Commands are registered after `setup()` is called.
291291
:PeekstackCloseAll *:PeekstackCloseAll*
292292
Close all popups in the current stack.
293293

294+
:PeekstackToggleVisibility *:PeekstackToggleVisibility*
295+
Temporarily hide or show all popups in the current stack. When hidden,
296+
popup windows are closed but the stack state is preserved. Toggling
297+
again recreates the windows in their original layout. Pushing a new
298+
popup while hidden automatically restores visibility.
299+
294300
:PeekstackHistory *:PeekstackHistory*
295301
Show popup history and select an entry to restore.
296302

lua/peekstack/commands.lua

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ local COMMAND_NAMES = {
1313
"PeekstackHistory",
1414
"PeekstackCloseAll",
1515
"PeekstackQuickPeek",
16+
"PeekstackToggleVisibility",
1617
}
1718

1819
---@param session PeekstackSession|table
@@ -173,6 +174,13 @@ function M.setup()
173174
require("peekstack.core.stack").close_all()
174175
end, {})
175176

177+
vim.api.nvim_create_user_command("PeekstackToggleVisibility", function()
178+
local toggled = require("peekstack.core.stack").toggle_visibility()
179+
if not toggled then
180+
notify.info("No popups in the current stack")
181+
end
182+
end, {})
183+
176184
vim.api.nvim_create_user_command("PeekstackQuickPeek", function(opts)
177185
local provider = opts.args and opts.args ~= "" and opts.args or "lsp.definition"
178186
require("peekstack").peek(provider, { mode = "quick" })

lua/peekstack/core/stack.lua

Lines changed: 107 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ local stacks = {}
2828
local ephemerals = {}
2929
---@type table<integer, boolean>
3030
local stack_view_wins = {}
31+
--- Guard flag: when true, WinClosed/BufWipeout handlers skip popup removal.
32+
local suppress_win_events = false
3133
---@class PeekstackPopupLookupEntry
3234
---@field popup PeekstackPopupModel
3335
---@field root_winid integer?
@@ -277,6 +279,10 @@ function M.push(location, opts)
277279
end
278280

279281
local stack = ensure_stack()
282+
-- Auto-show hidden stack before pushing a new popup
283+
if stack.hidden then
284+
M.toggle_visibility(stack.root_winid)
285+
end
280286
local model = popup.create(location, create_opts)
281287
if not model then
282288
return nil
@@ -531,6 +537,30 @@ function M.focus_by_id(id, winid)
531537
return false
532538
end
533539

540+
---Re-create a popup window for an existing stack item.
541+
---@param item PeekstackPopupModel
542+
---@param stack PeekstackStackModel
543+
---@return PeekstackPopupModel?
544+
local function reopen_popup(item, stack)
545+
local reopen_opts = {
546+
buffer_mode = item.buffer_mode or "copy",
547+
origin_winid = stack.root_winid,
548+
parent_popup_id = item.parent_popup_id,
549+
}
550+
if not item.title_chunks then
551+
reopen_opts.title = item.title
552+
end
553+
local model = popup.create(item.location, reopen_opts)
554+
if not model then
555+
return nil
556+
end
557+
model.id = item.id
558+
model.pinned = item.pinned or false
559+
vim.b[model.bufnr].peekstack_popup_id = model.id
560+
vim.w[model.winid].peekstack_popup_id = model.id
561+
return model
562+
end
563+
534564
---Re-open a popup by id when its window is gone.
535565
---@param id integer
536566
---@param winid? integer
@@ -540,22 +570,10 @@ function M.reopen_by_id(id, winid)
540570
local stack = ensure_stack(winid)
541571
for idx, item in ipairs(stack.popups) do
542572
if item.id == id then
543-
local reopen_opts = {
544-
buffer_mode = item.buffer_mode or "copy",
545-
origin_winid = stack.root_winid,
546-
parent_popup_id = item.parent_popup_id,
547-
}
548-
if not item.title_chunks then
549-
reopen_opts.title = item.title
550-
end
551-
local model = popup.create(item.location, reopen_opts)
573+
local model = reopen_popup(item, stack)
552574
if not model then
553575
return nil
554576
end
555-
model.id = item.id
556-
model.pinned = item.pinned or false
557-
vim.b[model.bufnr].peekstack_popup_id = model.id
558-
vim.w[model.winid].peekstack_popup_id = model.id
559577
unindex_popup(item)
560578
stack.popups[idx] = model
561579
index_popup(model, stack.root_winid)
@@ -601,6 +619,9 @@ end
601619
---@param winid integer
602620
function M.handle_win_closed(winid)
603621
deps()
622+
if suppress_win_events then
623+
return
624+
end
604625
if stack_view_wins[winid] then
605626
stack_view_wins[winid] = nil
606627
return
@@ -688,6 +709,9 @@ end
688709
---@param bufnr integer
689710
function M.handle_buf_wipeout(bufnr)
690711
deps()
712+
if suppress_win_events then
713+
return
714+
end
691715
for id, item in pairs(ephemerals) do
692716
if item.bufnr == bufnr then
693717
unregister_ephemeral(id)
@@ -820,11 +844,80 @@ function M.reflow_all()
820844
end
821845
end
822846

847+
---Toggle visibility of all popups in the current stack.
848+
---When hidden, popup windows are closed but models remain in the stack.
849+
---When shown, popup windows are recreated from the stored models.
850+
---@param winid? integer
851+
---@return boolean
852+
function M.toggle_visibility(winid)
853+
deps()
854+
local stack = ensure_stack(winid)
855+
if #stack.popups == 0 then
856+
return false
857+
end
858+
859+
if not stack.hidden then
860+
-- Move focus back to root window before hiding
861+
if vim.api.nvim_win_is_valid(stack.root_winid) then
862+
vim.api.nvim_set_current_win(stack.root_winid)
863+
end
864+
-- Close all popup windows but keep models in the stack.
865+
-- Suppress WinClosed/BufWipeout handlers to prevent them from
866+
-- removing popups that we intend to keep in the stack.
867+
suppress_win_events = true
868+
for _, item in ipairs(stack.popups) do
869+
popup.close(item)
870+
unindex_popup(item)
871+
item.winid = nil
872+
end
873+
suppress_win_events = false
874+
stack.hidden = true
875+
else
876+
-- Recreate all popup windows
877+
for idx, item in ipairs(stack.popups) do
878+
local model = reopen_popup(item, stack)
879+
if model then
880+
stack.popups[idx] = model
881+
index_popup(model, stack.root_winid)
882+
end
883+
end
884+
layout.reflow(stack)
885+
stack.hidden = false
886+
-- Restore focus to the previously focused popup
887+
if stack.focused_id then
888+
M.focus_by_id(stack.focused_id, stack.root_winid)
889+
end
890+
end
891+
return true
892+
end
893+
894+
---Check whether the current stack is hidden.
895+
---@param winid? integer
896+
---@return boolean
897+
function M.is_hidden(winid)
898+
local stack = ensure_stack(winid)
899+
return stack.hidden == true
900+
end
901+
823902
--- Close all popups in the current (or given) window's stack.
824903
---@param winid? integer
825904
function M.close_all(winid)
826905
deps()
827906
local stack = ensure_stack(winid)
907+
-- When hidden, windows are already closed; just clear hidden state
908+
-- and record history for each popup.
909+
if stack.hidden then
910+
for idx = #stack.popups, 1, -1 do
911+
local item = stack.popups[idx]
912+
emit_popup_event("PeekstackClose", item, stack.root_winid)
913+
history.push_entry(stack, history.build_entry(item, idx))
914+
unindex_popup(item)
915+
table.remove(stack.popups, idx)
916+
end
917+
stack.hidden = false
918+
stack.focused_id = nil
919+
return
920+
end
828921
for idx = #stack.popups, 1, -1 do
829922
local item = stack.popups[idx]
830923
feedback.highlight_origin(item.origin)
@@ -855,6 +948,7 @@ function M._reset()
855948
stack_view_wins = {}
856949
popup_by_id = {}
857950
popup_by_winid = {}
951+
suppress_win_events = false
858952
end
859953

860954
---Get ephemeral popups (for testing).

lua/peekstack/types.lua

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@
6262
---@field history PeekstackHistoryEntry[]
6363
---@field layout_state any
6464
---@field focused_id integer?
65+
---@field hidden boolean?
6566

6667
---@class PeekstackUserEventData
6768
---@field event string

tests/toggle_visibility_spec.lua

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
local stack = require("peekstack.core.stack")
2+
local config = require("peekstack.config")
3+
local events = require("peekstack.core.events")
4+
local helpers = require("tests.helpers")
5+
6+
describe("stack.toggle_visibility", function()
7+
before_each(function()
8+
stack._reset()
9+
config.setup({})
10+
events.setup()
11+
end)
12+
13+
after_each(function()
14+
local s = stack.current_stack()
15+
-- Restore visibility so windows can be closed normally
16+
if s.hidden then
17+
stack.toggle_visibility()
18+
end
19+
for i = #s.popups, 1, -1 do
20+
stack.close(s.popups[i].id)
21+
end
22+
stack._reset()
23+
end)
24+
25+
it("returns false on empty stack", function()
26+
assert.is_false(stack.toggle_visibility())
27+
assert.is_false(stack.is_hidden())
28+
end)
29+
30+
it("hides all popup windows", function()
31+
local loc = helpers.make_location()
32+
local m1 = stack.push(loc)
33+
local m2 = stack.push(loc)
34+
assert.is_not_nil(m1)
35+
assert.is_not_nil(m2)
36+
37+
assert.is_true(stack.toggle_visibility())
38+
assert.is_true(stack.is_hidden())
39+
40+
-- Windows should be gone but popups remain in the stack
41+
local s = stack.current_stack()
42+
assert.equals(2, #s.popups)
43+
for _, item in ipairs(s.popups) do
44+
assert.is_nil(item.winid)
45+
end
46+
end)
47+
48+
it("restores popup windows on second toggle", function()
49+
local loc = helpers.make_location()
50+
local m1 = stack.push(loc)
51+
local m2 = stack.push(loc)
52+
assert.is_not_nil(m1)
53+
assert.is_not_nil(m2)
54+
55+
-- Hide
56+
stack.toggle_visibility()
57+
assert.is_true(stack.is_hidden())
58+
59+
-- Show
60+
assert.is_true(stack.toggle_visibility())
61+
assert.is_false(stack.is_hidden())
62+
63+
local s = stack.current_stack()
64+
assert.equals(2, #s.popups)
65+
for _, item in ipairs(s.popups) do
66+
assert.is_not_nil(item.winid)
67+
assert.is_true(vim.api.nvim_win_is_valid(item.winid))
68+
end
69+
end)
70+
71+
it("preserves popup ids across hide/show cycle", function()
72+
local loc = helpers.make_location()
73+
local m1 = stack.push(loc)
74+
local m2 = stack.push(loc)
75+
76+
local id1 = m1.id
77+
local id2 = m2.id
78+
79+
stack.toggle_visibility()
80+
stack.toggle_visibility()
81+
82+
local s = stack.current_stack()
83+
assert.equals(id1, s.popups[1].id)
84+
assert.equals(id2, s.popups[2].id)
85+
end)
86+
87+
it("auto-shows when push is called while hidden", function()
88+
local loc = helpers.make_location()
89+
stack.push(loc)
90+
91+
stack.toggle_visibility()
92+
assert.is_true(stack.is_hidden())
93+
94+
-- Pushing should auto-show
95+
local m2 = stack.push(loc)
96+
assert.is_not_nil(m2)
97+
assert.is_false(stack.is_hidden())
98+
99+
local s = stack.current_stack()
100+
assert.equals(2, #s.popups)
101+
for _, item in ipairs(s.popups) do
102+
assert.is_not_nil(item.winid)
103+
assert.is_true(vim.api.nvim_win_is_valid(item.winid))
104+
end
105+
end)
106+
107+
it("close_all works while hidden", function()
108+
local loc = helpers.make_location()
109+
stack.push(loc)
110+
stack.push(loc)
111+
112+
stack.toggle_visibility()
113+
assert.is_true(stack.is_hidden())
114+
115+
stack.close_all()
116+
assert.is_false(stack.is_hidden())
117+
118+
local s = stack.current_stack()
119+
assert.equals(0, #s.popups)
120+
end)
121+
122+
it("does not leak popups to history when hiding", function()
123+
local loc = helpers.make_location()
124+
stack.push(loc)
125+
stack.push(loc)
126+
127+
local history_before = #stack.history_list()
128+
stack.toggle_visibility()
129+
local history_after = #stack.history_list()
130+
131+
assert.equals(history_before, history_after)
132+
end)
133+
end)

0 commit comments

Comments
 (0)