From 0fd740f4321361beae6199a1ce3425f9fa401ac7 Mon Sep 17 00:00:00 2001 From: Alejandro Espinoza Date: Tue, 9 Jun 2026 19:46:47 -0600 Subject: [PATCH] fix(ui): stabilize picker layout and preview across navigation. Keep the history list fixed while only the preview pane resizes; preserve the preview buffer when hiding so wrap-around j/k and whitespace entries restore correctly. Document layout and wrap behavior in the README. Co-authored-by: Cursor --- README.md | 19 ++++++---- lua/clipring/ui.lua | 85 +++++++++++++++++++++++++++------------------ tests/ui_spec.lua | 67 +++++++++++++++++++++++++++++++++++ 3 files changed, 131 insertions(+), 40 deletions(-) diff --git a/README.md b/README.md index 66fc5ce..eeae2a0 100644 --- a/README.md +++ b/README.md @@ -16,8 +16,9 @@ Minimal yank history for Neovim — a lightweight Lua plugin inspired by YankRin ## Features - Automatic capture of every yank -- Floating popup history (`:ClipRing`) with an auto-sizing multiline preview pane -- Preview pane shown only when there is content to display; optional syntax highlighting for code +- Floating popup history (`:ClipRing`) with a fixed history list and an auto-sizing preview pane +- Preview pane shown only when the selected entry has content; optional syntax highlighting for code +- `j` / `k` wrap at the ends of the list (newest ↔ oldest) - Navigate with `j` / `k`, reorder with `` / ``, paste with ``, copy to the system clipboard with `y`, delete with `dd` - Works from Normal, Insert, and Visual modes - Optional JSON persistence between sessions @@ -63,13 +64,15 @@ With a minimal `lazy.nvim` / `packer.nvim` setup, Neovim loads the plugin from ` | `:ClipRing` | Always available (no keymap required) | | Your `open_mapping` | After you set one in `setup()` (e.g. `y`) | -The picker opens as two side-by-side floats when there are yanks to show: a **history list** (height follows entry count) and a **preview pane** that resizes to fit the selected entry. Code yanks are syntax-highlighted when ClipRing can detect a language (markdown ` ```lang ` fences, shebangs, or simple heuristics). With an empty ring, only the list is shown. +The picker opens as two side-by-side floats when there are yanks to show. The **history list** stays in a fixed position (only its height changes as you add or remove entries); the **preview pane** on the right resizes to fit the selected entry. Selection wraps circularly with `j` / `k` (from the last entry back to the first, and vice versa). + +Code yanks are syntax-highlighted when ClipRing can detect a language (markdown ` ```lang ` fences, shebangs, or simple heuristics). The preview is hidden for whitespace-only entries and returns when you move to an entry with real content. With an empty ring, only the list is shown. ### Inside the picker | Key | Action | |-----|--------| -| `j` / `k` or `J` / `K` | Move selection up / down | +| `j` / `k` or `J` / `K` | Move selection up / down (wraps at the ends) | | `` / `` | Same as `k` / `j` | | `` / `` | Move the **selected entry** down / up in history order (reorder) | | `` | Paste the selected entry and close | @@ -112,7 +115,7 @@ require("clipring").setup({ reorder_up_mapping = "", copy_mapping = "y", - -- Layout (list and preview auto-size within these limits) + -- Layout (list position is fixed; preview auto-sizes within these limits) picker_width = 80, -- total inner width; 0 = nearly full editor width picker_max_height = 18, -- max height for list and preview preview_max_lines = 16, -- max lines per entry in the preview pane @@ -120,6 +123,8 @@ require("clipring").setup({ }) ``` +**Layout** — `picker_width` sets the overall picker footprint. The history list keeps the same screen position while the picker is open; only the preview pane changes size. `preview_max_width` (advanced) caps preview width and also anchors the initial list placement when set. + **`open_mapping`** — set a string (e.g. `"y"`) or multiple (`{ "y", "" }`) to open ClipRing from Normal, Visual, and Insert. Leave unset or `nil` to use only `:ClipRing`. Use `false` to clear a keymap after a previous `setup()`. Omit `reorder_down_mapping` / `reorder_up_mapping` / `copy_mapping` to keep the defaults above. Set any of them to `false` to turn off that binding. @@ -136,7 +141,7 @@ If `` / `` conflict with global maps (e.g. `:move`), use different key require("clipring").setup({ min_length = 1, -- ignore yanks shorter than this (chars) preview_length = 80, -- max chars in each one-line list label - preview_max_width = 120, -- cap preview width; 0 = up to screen edge (default) + preview_max_width = 120, -- cap preview width; 0 = content width up to screen edge (default) list_width = 0, -- fixed list width in columns; 0 = auto (recommended) }) ``` @@ -166,7 +171,7 @@ Coverage today: - **ring** — add, dedupe, max size, remove, reorder - **preview_syntax** — fence stripping, language detection, heuristics - **paste** — visual capture (`v` / `'<`), charwise replace vs append, insert-mode paste at saved cursor -- **ui** — picker from insert, navigation, reorder keys, auto-size layout, conditional preview, multiline preview, syntax highlighting, clipboard copy, which-key / `` behavior +- **ui** — picker from insert, navigation, wrap-around selection, fixed list layout, preview resize/restore, conditional preview, syntax highlighting, clipboard copy, which-key / `` behavior - **yank** — `TextYankPost` capture - **setup** — `open_mapping` registration diff --git a/lua/clipring/ui.lua b/lua/clipring/ui.lua index d4eb4ad..3be36d9 100644 --- a/lua/clipring/ui.lua +++ b/lua/clipring/ui.lua @@ -80,6 +80,25 @@ local function set_buf_lines(buf, lines) vim.api.nvim_buf_set_option(buf, "modifiable", false) end +---@param bufhidden? string +local function create_readonly_buf(name, filetype, bufhidden) + local buf = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_name(buf, name) + vim.api.nvim_buf_set_option(buf, "bufhidden", bufhidden or "wipe") + vim.api.nvim_buf_set_option(buf, "filetype", filetype) + vim.api.nvim_buf_set_option(buf, "swapfile", false) + vim.api.nvim_buf_set_option(buf, "modifiable", false) + return buf +end + +local function ensure_preview_buf() + if state.preview_buf and vim.api.nvim_buf_is_valid(state.preview_buf) then + return state.preview_buf + end + state.preview_buf = create_readonly_buf("clipring://preview", "clipring_preview", "hide") + return state.preview_buf +end + local function refresh_list_buffer() if not state.list_buf or not vim.api.nvim_buf_is_valid(state.list_buf) then return @@ -108,6 +127,9 @@ local function refresh_list_buffer() if #all > 0 then vim.api.nvim_buf_clear_namespace(state.list_buf, ns, 0, -1) vim.api.nvim_buf_add_highlight(state.list_buf, ns, "CursorLine", state.index - 1, 0, -1) + if state.list_win and vim.api.nvim_win_is_valid(state.list_win) then + pcall(vim.api.nvim_win_set_cursor, state.list_win, { state.index, 0 }) + end end end @@ -148,12 +170,12 @@ local function apply_preview_filetype(filetype) local buf = state.preview_buf vim.api.nvim_buf_set_option(buf, "modifiable", true) + if vim.treesitter and vim.treesitter.stop then + pcall(vim.treesitter.stop, buf) + end vim.api.nvim_buf_set_option(buf, "filetype", filetype) if filetype == "clipring_preview" then vim.api.nvim_buf_set_option(buf, "syntax", "off") - if vim.treesitter and vim.treesitter.stop then - pcall(vim.treesitter.stop, buf) - end else vim.api.nvim_buf_set_option(buf, "syntax", "on") if vim.treesitter and vim.treesitter.language and vim.treesitter.start then @@ -180,9 +202,7 @@ local function preview_should_show() end local function refresh_preview_buffer() - if not state.preview_buf or not vim.api.nvim_buf_is_valid(state.preview_buf) then - return - end + ensure_preview_buf() local all = ring.get_all() if #all == 0 then @@ -239,6 +259,16 @@ local function clamp_preview_width(width) return math.max(PREVIEW_MIN_WIDTH, math.min(width, max_on_screen)) end +--- Stable preview width for initial list placement (list stays put; preview resizes). +local function preview_footprint_width() + local opts = config.get() + if opts.preview_max_width and opts.preview_max_width > 0 then + return opts.preview_max_width + end + local total = opts.picker_width > 0 and opts.picker_width or (vim.o.columns - 8) + return math.max(PREVIEW_MIN_WIDTH, math.min(math.floor(total * 0.45), total - 36)) +end + --- Height of the history list from entry count. local function list_height_for_count(count) local opts = config.get() @@ -359,21 +389,24 @@ end local function hide_preview_window() if state.preview_win and vim.api.nvim_win_is_valid(state.preview_win) then - vim.api.nvim_win_close(state.preview_win, true) + -- Do not force-close: preview buf uses bufhidden=hide and must survive for reuse. + vim.api.nvim_win_close(state.preview_win, false) end state.preview_win = nil end local function show_preview_window() - if not state.preview_buf or not vim.api.nvim_buf_is_valid(state.preview_buf) then - return - end + ensure_preview_buf() refresh_preview_buffer() - local entry = ring.get(state.index) - local pw, ph = preview_size_for_entry(entry) - pw = clamp_preview_width(pw) - if not state.preview_win or not vim.api.nvim_win_is_valid(state.preview_win) then + if state.preview_win and not vim.api.nvim_win_is_valid(state.preview_win) then + state.preview_win = nil + end + + if not state.preview_win then + local entry = ring.get(state.index) + local pw, ph = preview_size_for_entry(entry) + pw = clamp_preview_width(pw) state.preview_win = vim.api.nvim_open_win(state.preview_buf, false, vim.tbl_extend("force", float_opts, { width = pw, height = ph, @@ -388,9 +421,6 @@ end local function sync_preview_visibility() local show = preview_should_show() - local entry = ring.get(state.index) - local initial_pw = show and preview_size_for_entry(entry) or nil - apply_list_layout(list_layout(initial_pw, show)) if show then show_preview_window() else @@ -617,16 +647,6 @@ local function attach_keymaps() map("", block_window_prefix, "ClipRing: disable window switch") end -local function create_readonly_buf(name, filetype) - local buf = vim.api.nvim_create_buf(false, true) - vim.api.nvim_buf_set_name(buf, name) - vim.api.nvim_buf_set_option(buf, "bufhidden", "wipe") - vim.api.nvim_buf_set_option(buf, "filetype", filetype) - vim.api.nvim_buf_set_option(buf, "swapfile", false) - vim.api.nvim_buf_set_option(buf, "modifiable", false) - return buf -end - ---@param opts table|nil ---@field from_insert boolean|nil set when the open keymap runs in Insert mode function M.open(opts) @@ -652,12 +672,10 @@ function M.open(opts) state.index = 1 state.list_buf = create_readonly_buf("clipring://history", "clipring") - state.preview_buf = create_readonly_buf("clipring://preview", "clipring_preview") + state.preview_buf = create_readonly_buf("clipring://preview", "clipring_preview", "hide") - local show_preview = preview_should_show() - local entry = show_preview and ring.get(state.index) or nil - local initial_pw = show_preview and preview_size_for_entry(entry) or nil - local layout = list_layout(initial_pw, show_preview) + local has_entries = ring.count() > 0 + local layout = list_layout(preview_footprint_width(), has_entries) set_list_layout_state(layout) state.list_win = vim.api.nvim_open_win(state.list_buf, true, vim.tbl_extend("force", float_opts, { @@ -671,7 +689,8 @@ function M.open(opts) configure_float_win(state.list_win) - if show_preview then + if preview_should_show() then + local entry = ring.get(state.index) local pw, ph = preview_size_for_entry(entry) pw = clamp_preview_width(pw) state.preview_win = vim.api.nvim_open_win(state.preview_buf, false, vim.tbl_extend("force", float_opts, { diff --git a/tests/ui_spec.lua b/tests/ui_spec.lua index 20f2c82..b6e0bb7 100644 --- a/tests/ui_spec.lua +++ b/tests/ui_spec.lua @@ -135,6 +135,73 @@ describe("clipring.ui", function() ui.close() end) + it("keeps the list window fixed while navigating and resizing preview", function() + ring.clear() + ring.add({ "short" }, "v") + ring.add({ "alpha", "beta", "gamma", "delta", "epsilon" }, "V") + ui.open() + local list_win = vim.fn.bufwinid(h.find_clipring_buf()) + local col_before = vim.api.nvim_win_get_config(list_win).col + local row_before = vim.api.nvim_win_get_config(list_win).row + assert.is_not_nil(h.clipring_preview_win()) + feed_clipring("j") + assert.are.equal(col_before, vim.api.nvim_win_get_config(list_win).col) + assert.are.equal(row_before, vim.api.nvim_win_get_config(list_win).row) + assert.is_not_nil(h.clipring_preview_win()) + ui.close() + end) + + it("shows preview again when moving from a whitespace-only entry to real content", function() + ring.clear() + ring.add({ "visible", "lines" }, "V") + ring.add({ " " }, "v") + ui.open() + assert.is_nil(h.clipring_preview_win()) + feed_clipring("j") + assert.is_not_nil(h.clipring_preview_win()) + local preview_buf = h.find_clipring_preview_buf() + assert.same({ " visible", " lines" }, vim.api.nvim_buf_get_lines(preview_buf, 0, -1, false)) + ui.close() + end) + + it("keeps preview when wrapping from last entry to first and back", function() + ring.clear() + for i = 1, 5 do + ring.add({ "entry-" .. i }, "v") + end + ui.open() + for _ = 1, 4 do + feed_clipring("j") + end + assert.are.equal(5, h.clipring_selected_line(h.find_clipring_buf())) + assert.is_not_nil(h.clipring_preview_win()) + feed_clipring("j") + assert.are.equal(1, h.clipring_selected_line(h.find_clipring_buf())) + assert.is_not_nil(h.clipring_preview_win()) + feed_clipring("k") + assert.are.equal(5, h.clipring_selected_line(h.find_clipring_buf())) + assert.is_not_nil(h.clipring_preview_win()) + ui.close() + end) + + it("restores preview after toggling through whitespace-only entries", function() + ring.clear() + ring.add({ "keep", "preview" }, "V") + ring.add({ " " }, "v") + ring.add({ "first" }, "v") + ui.open() + assert.is_not_nil(h.clipring_preview_win()) + feed_clipring("j") -- whitespace + assert.is_nil(h.clipring_preview_win()) + feed_clipring("j") -- back to keep preview + assert.is_not_nil(h.clipring_preview_win()) + feed_clipring("k") -- whitespace again + assert.is_nil(h.clipring_preview_win()) + feed_clipring("k") -- first + assert.is_not_nil(h.clipring_preview_win()) + ui.close() + end) + it("truncates long previews with a more-lines indicator", function() require("clipring.config").setup({ max_entries = 20,