diff --git a/README.md b/README.md index 8498111..31f1a8b 100644 --- a/README.md +++ b/README.md @@ -129,6 +129,7 @@ Defaults inside popup windows: - `` — promote to new tab - `os` — open stack view - `` — toggle zoom (maximize top popup) +- `h/j/k/l` — navigate to adjacent split window Defaults in stack view: diff --git a/doc/peekstack.txt b/doc/peekstack.txt index 8fe0c06..d55ac17 100644 --- a/doc/peekstack.txt +++ b/doc/peekstack.txt @@ -505,6 +505,7 @@ Default keymaps inside popup windows: `` Promote to new tab `os` Toggle the stack view panel `` Toggle zoom (maximize top popup) + `h/j/k/l` Navigate to the adjacent split window Stack view keymaps: diff --git a/lua/peekstack/core/stack.lua b/lua/peekstack/core/stack.lua index f2002ef..e2a2305 100644 --- a/lua/peekstack/core/stack.lua +++ b/lua/peekstack/core/stack.lua @@ -37,6 +37,26 @@ local suppress_win_events = false local popup_by_id = {} ---@type table local popup_by_winid = {} +---@param winid? integer +---@return integer +local get_root_winid + +---@param model PeekstackPopupModel +---@return integer? +local function resolve_ephemeral_root_winid(model) + if not model or not model.origin then + return nil + end + local origin_winid = model.origin.winid + if type(origin_winid) ~= "number" or not vim.api.nvim_win_is_valid(origin_winid) then + return nil + end + local ok_root, root_winid = pcall(get_root_winid, origin_winid) + if ok_root and type(root_winid) == "number" and vim.api.nvim_win_is_valid(root_winid) then + return root_winid + end + return nil +end ---@param model PeekstackPopupModel local function unindex_popup(model) @@ -145,7 +165,7 @@ local function lookup_by_winid(winid) for _, item in pairs(ephemerals) do if item.winid == winid then - index_popup(item, nil) + index_popup(item, resolve_ephemeral_root_winid(item)) return popup_by_winid[winid] end end @@ -161,7 +181,7 @@ end ---@param model PeekstackPopupModel local function register_ephemeral(model) ephemerals[model.id] = model - index_popup(model, nil) + index_popup(model, resolve_ephemeral_root_winid(model)) end ---@param id integer @@ -180,7 +200,7 @@ local function find_ephemeral(id) return id, ephemerals[id] end local entry = lookup_by_winid(id) - if entry and entry.root_winid == nil then + if entry and entry.popup and entry.popup.ephemeral then return entry.popup.id, entry.popup end return nil @@ -190,7 +210,7 @@ end --- floating window, walk through the stacks to find its origin window instead. ---@param winid? integer ---@return integer -local function get_root_winid(winid) +get_root_winid = function(winid) local wid = winid or vim.api.nvim_get_current_win() local win_cfg = vim.api.nvim_win_get_config(wid) if win_cfg.relative == "" then @@ -988,6 +1008,14 @@ function M.focused_id(winid) return stack.focused_id end +--- Public wrapper for get_root_winid – used by keymaps to resolve the +--- non-floating owner window for the current (or given) window. +---@param winid? integer +---@return integer +function M.get_root_winid(winid) + return get_root_winid(winid) +end + --- Reset all stacks (for testing). function M._reset() stacks = {} diff --git a/lua/peekstack/ui/keymaps.lua b/lua/peekstack/ui/keymaps.lua index 2ca0eab..888055f 100644 --- a/lua/peekstack/ui/keymaps.lua +++ b/lua/peekstack/ui/keymaps.lua @@ -15,6 +15,19 @@ local function map(bufnr, lhs, rhs, desc) vim.keymap.set("n", lhs, rhs, { buffer = bufnr, nowait = true, silent = true, desc = desc }) end +--- Navigate from a popup to an adjacent split window. +--- Moves focus back to the root (non-floating) window first, then executes +--- wincmd in the given direction. +---@param direction string one of "h", "j", "k", "l" +local function nav_to_split(direction) + local stack = require("peekstack.core.stack") + local root = stack.get_root_winid() + if root and vim.api.nvim_win_is_valid(root) then + vim.api.nvim_set_current_win(root) + end + vim.api.nvim_cmd({ cmd = "wincmd", args = { direction } }, {}) +end + --- Resolve the current popup by looking up its id in the stack. --- This avoids holding a stale reference to a popup object. ---@param popup_id integer @@ -94,6 +107,22 @@ function M.apply_popup(popup) local stack = require("peekstack.core.stack") stack.toggle_zoom() end, "Peekstack zoom") + + -- Window navigation: h/j/k/l to move from popup to adjacent split. + -- These are hardcoded (not in ui.keys) because they restore standard Vim + -- window-navigation behaviour that floating windows would otherwise break. + map(popup.bufnr, "h", function() + nav_to_split("h") + end, "Peekstack navigate left") + map(popup.bufnr, "j", function() + nav_to_split("j") + end, "Peekstack navigate down") + map(popup.bufnr, "k", function() + nav_to_split("k") + end, "Peekstack navigate up") + map(popup.bufnr, "l", function() + nav_to_split("l") + end, "Peekstack navigate right") end return M diff --git a/tests/popup_source_mode_spec.lua b/tests/popup_source_mode_spec.lua index dbc2810..b84736a 100644 --- a/tests/popup_source_mode_spec.lua +++ b/tests/popup_source_mode_spec.lua @@ -134,6 +134,68 @@ describe("popup source mode", function() vim.fn.delete(temp) end) + it("installs hjkl navigation keymaps on copy-mode popups", function() + local loc = make_location() + local model = popup.create(loc, { buffer_mode = "copy" }) + assert.is_not_nil(model) + assert.is_true(has_buffer_map(model.bufnr, "h")) + assert.is_true(has_buffer_map(model.bufnr, "j")) + assert.is_true(has_buffer_map(model.bufnr, "k")) + assert.is_true(has_buffer_map(model.bufnr, "l")) + popup.close(model) + end) + + it("l navigates from popup to adjacent split", function() + -- Create a vertical split so there are two windows. + vim.api.nvim_cmd({ cmd = "vsplit" }, {}) + local left_win = vim.api.nvim_get_current_win() + -- Move to the right split. + vim.api.nvim_cmd({ cmd = "wincmd", args = { "l" } }, {}) + local right_win = vim.api.nvim_get_current_win() + assert.is_not.equals(left_win, right_win) + + -- Open a popup anchored to the right split. + local loc = make_location() + local model = stack.push(loc) + assert.is_not_nil(model) + -- Focus the popup (simulates user entering the floating window). + vim.api.nvim_set_current_win(model.winid) + assert.equals(model.winid, vim.api.nvim_get_current_win()) + + -- Execute the h keymap callback: should land on the left split. + vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes("h", true, false, true), "x", false) + assert.equals(left_win, vim.api.nvim_get_current_win()) + + -- Cleanup + stack.close(model.id) + vim.api.nvim_set_current_win(right_win) + vim.api.nvim_cmd({ cmd = "close" }, {}) + end) + + it("does not install hjkl navigation keymaps on source-mode popups", function() + local temp = vim.fn.tempname() .. ".lua" + vim.fn.writefile({ "print('peekstack')" }, temp) + vim.api.nvim_cmd({ cmd = "edit", args = { temp } }, {}) + local source_bufnr = vim.api.nvim_get_current_buf() + + local model = popup.create({ + uri = vim.uri_from_fname(temp), + range = { start = { line = 0, character = 0 }, ["end"] = { line = 0, character = 0 } }, + provider = "test", + }, { + buffer_mode = "source", + }) + + assert.is_not_nil(model) + assert.is_false(has_buffer_map(source_bufnr, "h")) + assert.is_false(has_buffer_map(source_bufnr, "j")) + assert.is_false(has_buffer_map(source_bufnr, "k")) + assert.is_false(has_buffer_map(source_bufnr, "l")) + + popup.close(model) + vim.fn.delete(temp) + end) + it("deletes copy-mode scratch buffer when render.open fails", function() local render = require("peekstack.ui.render") local loc = make_location() diff --git a/tests/quick_peek_spec.lua b/tests/quick_peek_spec.lua index a6e8052..35f08d8 100644 --- a/tests/quick_peek_spec.lua +++ b/tests/quick_peek_spec.lua @@ -113,6 +113,34 @@ describe("peekstack.quick_peek", function() assert.is_nil(stack._ephemerals()[model.id]) end) + it("keeps split navigation root for quick peek popups", function() + local location = { + uri = vim.uri_from_bufnr(0), + range = { start = { line = 0, character = 0 }, ["end"] = { line = 0, character = 10 } }, + provider = "test", + } + + vim.api.nvim_cmd({ cmd = "vsplit" }, {}) + vim.api.nvim_cmd({ cmd = "vsplit" }, {}) + vim.api.nvim_cmd({ cmd = "wincmd", args = { "h" } }, {}) + vim.api.nvim_cmd({ cmd = "wincmd", args = { "h" } }, {}) + vim.api.nvim_cmd({ cmd = "wincmd", args = { "l" } }, {}) + local middle_win = vim.api.nvim_get_current_win() + vim.api.nvim_cmd({ cmd = "wincmd", args = { "l" } }, {}) + local right_win = vim.api.nvim_get_current_win() + + vim.api.nvim_set_current_win(middle_win) + local model = stack.push(location, { stack = false }) + assert.is_not_nil(model) + vim.api.nvim_set_current_win(model.winid) + + vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes("l", true, false, true), "x", false) + assert.equals(right_win, vim.api.nvim_get_current_win()) + + assert.is_true(stack.close(model.id)) + vim.api.nvim_cmd({ cmd = "only" }, {}) + end) + it("should handle normal peek mode", function() local location = { uri = vim.uri_from_bufnr(0),