Skip to content

Commit ca91301

Browse files
authored
Merge pull request #20 from mhiro2/feat/add-split-window-navigation-from-popup
feat(ui): Add split window navigation from popup via `<C-w>hjkl`
2 parents 2a35364 + 642a392 commit ca91301

6 files changed

Lines changed: 153 additions & 4 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,7 @@ Defaults inside popup windows:
129129
- `<C-t>` — promote to new tab
130130
- `<leader>os` — open stack view
131131
- `<C-z>` — toggle zoom (maximize top popup)
132+
- `<C-w>h/j/k/l` — navigate to adjacent split window
132133

133134
Defaults in stack view:
134135

doc/peekstack.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -505,6 +505,7 @@ Default keymaps inside popup windows:
505505
`<C-t>` Promote to new tab
506506
`<leader>os` Toggle the stack view panel
507507
`<C-z>` Toggle zoom (maximize top popup)
508+
`<C-w>h/j/k/l` Navigate to the adjacent split window
508509

509510
Stack view keymaps:
510511

lua/peekstack/core/stack.lua

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,26 @@ local suppress_win_events = false
3737
local popup_by_id = {}
3838
---@type table<integer, PeekstackPopupLookupEntry>
3939
local popup_by_winid = {}
40+
---@param winid? integer
41+
---@return integer
42+
local get_root_winid
43+
44+
---@param model PeekstackPopupModel
45+
---@return integer?
46+
local function resolve_ephemeral_root_winid(model)
47+
if not model or not model.origin then
48+
return nil
49+
end
50+
local origin_winid = model.origin.winid
51+
if type(origin_winid) ~= "number" or not vim.api.nvim_win_is_valid(origin_winid) then
52+
return nil
53+
end
54+
local ok_root, root_winid = pcall(get_root_winid, origin_winid)
55+
if ok_root and type(root_winid) == "number" and vim.api.nvim_win_is_valid(root_winid) then
56+
return root_winid
57+
end
58+
return nil
59+
end
4060

4161
---@param model PeekstackPopupModel
4262
local function unindex_popup(model)
@@ -145,7 +165,7 @@ local function lookup_by_winid(winid)
145165

146166
for _, item in pairs(ephemerals) do
147167
if item.winid == winid then
148-
index_popup(item, nil)
168+
index_popup(item, resolve_ephemeral_root_winid(item))
149169
return popup_by_winid[winid]
150170
end
151171
end
@@ -161,7 +181,7 @@ end
161181
---@param model PeekstackPopupModel
162182
local function register_ephemeral(model)
163183
ephemerals[model.id] = model
164-
index_popup(model, nil)
184+
index_popup(model, resolve_ephemeral_root_winid(model))
165185
end
166186

167187
---@param id integer
@@ -180,7 +200,7 @@ local function find_ephemeral(id)
180200
return id, ephemerals[id]
181201
end
182202
local entry = lookup_by_winid(id)
183-
if entry and entry.root_winid == nil then
203+
if entry and entry.popup and entry.popup.ephemeral then
184204
return entry.popup.id, entry.popup
185205
end
186206
return nil
@@ -190,7 +210,7 @@ end
190210
--- floating window, walk through the stacks to find its origin window instead.
191211
---@param winid? integer
192212
---@return integer
193-
local function get_root_winid(winid)
213+
get_root_winid = function(winid)
194214
local wid = winid or vim.api.nvim_get_current_win()
195215
local win_cfg = vim.api.nvim_win_get_config(wid)
196216
if win_cfg.relative == "" then
@@ -988,6 +1008,14 @@ function M.focused_id(winid)
9881008
return stack.focused_id
9891009
end
9901010

1011+
--- Public wrapper for get_root_winid – used by keymaps to resolve the
1012+
--- non-floating owner window for the current (or given) window.
1013+
---@param winid? integer
1014+
---@return integer
1015+
function M.get_root_winid(winid)
1016+
return get_root_winid(winid)
1017+
end
1018+
9911019
--- Reset all stacks (for testing).
9921020
function M._reset()
9931021
stacks = {}

lua/peekstack/ui/keymaps.lua

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,19 @@ local function map(bufnr, lhs, rhs, desc)
1515
vim.keymap.set("n", lhs, rhs, { buffer = bufnr, nowait = true, silent = true, desc = desc })
1616
end
1717

18+
--- Navigate from a popup to an adjacent split window.
19+
--- Moves focus back to the root (non-floating) window first, then executes
20+
--- wincmd in the given direction.
21+
---@param direction string one of "h", "j", "k", "l"
22+
local function nav_to_split(direction)
23+
local stack = require("peekstack.core.stack")
24+
local root = stack.get_root_winid()
25+
if root and vim.api.nvim_win_is_valid(root) then
26+
vim.api.nvim_set_current_win(root)
27+
end
28+
vim.api.nvim_cmd({ cmd = "wincmd", args = { direction } }, {})
29+
end
30+
1831
--- Resolve the current popup by looking up its id in the stack.
1932
--- This avoids holding a stale reference to a popup object.
2033
---@param popup_id integer
@@ -94,6 +107,22 @@ function M.apply_popup(popup)
94107
local stack = require("peekstack.core.stack")
95108
stack.toggle_zoom()
96109
end, "Peekstack zoom")
110+
111+
-- Window navigation: <C-w>h/j/k/l to move from popup to adjacent split.
112+
-- These are hardcoded (not in ui.keys) because they restore standard Vim
113+
-- window-navigation behaviour that floating windows would otherwise break.
114+
map(popup.bufnr, "<C-w>h", function()
115+
nav_to_split("h")
116+
end, "Peekstack navigate left")
117+
map(popup.bufnr, "<C-w>j", function()
118+
nav_to_split("j")
119+
end, "Peekstack navigate down")
120+
map(popup.bufnr, "<C-w>k", function()
121+
nav_to_split("k")
122+
end, "Peekstack navigate up")
123+
map(popup.bufnr, "<C-w>l", function()
124+
nav_to_split("l")
125+
end, "Peekstack navigate right")
97126
end
98127

99128
return M

tests/popup_source_mode_spec.lua

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,68 @@ describe("popup source mode", function()
134134
vim.fn.delete(temp)
135135
end)
136136

137+
it("installs <C-w>hjkl navigation keymaps on copy-mode popups", function()
138+
local loc = make_location()
139+
local model = popup.create(loc, { buffer_mode = "copy" })
140+
assert.is_not_nil(model)
141+
assert.is_true(has_buffer_map(model.bufnr, "<C-W>h"))
142+
assert.is_true(has_buffer_map(model.bufnr, "<C-W>j"))
143+
assert.is_true(has_buffer_map(model.bufnr, "<C-W>k"))
144+
assert.is_true(has_buffer_map(model.bufnr, "<C-W>l"))
145+
popup.close(model)
146+
end)
147+
148+
it("<C-w>l navigates from popup to adjacent split", function()
149+
-- Create a vertical split so there are two windows.
150+
vim.api.nvim_cmd({ cmd = "vsplit" }, {})
151+
local left_win = vim.api.nvim_get_current_win()
152+
-- Move to the right split.
153+
vim.api.nvim_cmd({ cmd = "wincmd", args = { "l" } }, {})
154+
local right_win = vim.api.nvim_get_current_win()
155+
assert.is_not.equals(left_win, right_win)
156+
157+
-- Open a popup anchored to the right split.
158+
local loc = make_location()
159+
local model = stack.push(loc)
160+
assert.is_not_nil(model)
161+
-- Focus the popup (simulates user entering the floating window).
162+
vim.api.nvim_set_current_win(model.winid)
163+
assert.equals(model.winid, vim.api.nvim_get_current_win())
164+
165+
-- Execute the <C-w>h keymap callback: should land on the left split.
166+
vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes("<C-w>h", true, false, true), "x", false)
167+
assert.equals(left_win, vim.api.nvim_get_current_win())
168+
169+
-- Cleanup
170+
stack.close(model.id)
171+
vim.api.nvim_set_current_win(right_win)
172+
vim.api.nvim_cmd({ cmd = "close" }, {})
173+
end)
174+
175+
it("does not install <C-w>hjkl navigation keymaps on source-mode popups", function()
176+
local temp = vim.fn.tempname() .. ".lua"
177+
vim.fn.writefile({ "print('peekstack')" }, temp)
178+
vim.api.nvim_cmd({ cmd = "edit", args = { temp } }, {})
179+
local source_bufnr = vim.api.nvim_get_current_buf()
180+
181+
local model = popup.create({
182+
uri = vim.uri_from_fname(temp),
183+
range = { start = { line = 0, character = 0 }, ["end"] = { line = 0, character = 0 } },
184+
provider = "test",
185+
}, {
186+
buffer_mode = "source",
187+
})
188+
189+
assert.is_not_nil(model)
190+
assert.is_false(has_buffer_map(source_bufnr, "<C-W>h"))
191+
assert.is_false(has_buffer_map(source_bufnr, "<C-W>j"))
192+
assert.is_false(has_buffer_map(source_bufnr, "<C-W>k"))
193+
assert.is_false(has_buffer_map(source_bufnr, "<C-W>l"))
194+
195+
popup.close(model)
196+
vim.fn.delete(temp)
197+
end)
198+
137199
it("deletes copy-mode scratch buffer when render.open fails", function()
138200
local render = require("peekstack.ui.render")
139201
local loc = make_location()

tests/quick_peek_spec.lua

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,34 @@ describe("peekstack.quick_peek", function()
113113
assert.is_nil(stack._ephemerals()[model.id])
114114
end)
115115

116+
it("keeps split navigation root for quick peek popups", function()
117+
local location = {
118+
uri = vim.uri_from_bufnr(0),
119+
range = { start = { line = 0, character = 0 }, ["end"] = { line = 0, character = 10 } },
120+
provider = "test",
121+
}
122+
123+
vim.api.nvim_cmd({ cmd = "vsplit" }, {})
124+
vim.api.nvim_cmd({ cmd = "vsplit" }, {})
125+
vim.api.nvim_cmd({ cmd = "wincmd", args = { "h" } }, {})
126+
vim.api.nvim_cmd({ cmd = "wincmd", args = { "h" } }, {})
127+
vim.api.nvim_cmd({ cmd = "wincmd", args = { "l" } }, {})
128+
local middle_win = vim.api.nvim_get_current_win()
129+
vim.api.nvim_cmd({ cmd = "wincmd", args = { "l" } }, {})
130+
local right_win = vim.api.nvim_get_current_win()
131+
132+
vim.api.nvim_set_current_win(middle_win)
133+
local model = stack.push(location, { stack = false })
134+
assert.is_not_nil(model)
135+
vim.api.nvim_set_current_win(model.winid)
136+
137+
vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes("<C-w>l", true, false, true), "x", false)
138+
assert.equals(right_win, vim.api.nvim_get_current_win())
139+
140+
assert.is_true(stack.close(model.id))
141+
vim.api.nvim_cmd({ cmd = "only" }, {})
142+
end)
143+
116144
it("should handle normal peek mode", function()
117145
local location = {
118146
uri = vim.uri_from_bufnr(0),

0 commit comments

Comments
 (0)