Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 12 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<C-j>` / `<C-k>`, paste with `<Enter>`, copy to the system clipboard with `y`, delete with `dd`
- Works from Normal, Insert, and Visual modes
- Optional JSON persistence between sessions
Expand Down Expand Up @@ -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. `<leader>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) |
| `<Up>` / `<Down>` | Same as `k` / `j` |
| `<C-j>` / `<C-k>` | Move the **selected entry** down / up in history order (reorder) |
| `<Enter>` | Paste the selected entry and close |
Expand Down Expand Up @@ -112,14 +115,16 @@ require("clipring").setup({
reorder_up_mapping = "<C-k>",
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
preview_syntax = true, -- highlight code in the preview when a language is detected
})
```

**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. `"<leader>y"`) or multiple (`{ "<leader>y", "<M-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.
Expand All @@ -136,7 +141,7 @@ If `<C-j>` / `<C-k>` 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)
})
```
Expand Down Expand Up @@ -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 / `<C-w>` behavior
- **ui** — picker from insert, navigation, wrap-around selection, fixed list layout, preview resize/restore, conditional preview, syntax highlighting, clipboard copy, which-key / `<C-w>` behavior
- **yank** — `TextYankPost` capture
- **setup** — `open_mapping` registration

Expand Down
85 changes: 52 additions & 33 deletions lua/clipring/ui.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -617,16 +647,6 @@ local function attach_keymaps()
map("<C-W>", 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)
Expand All @@ -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, {
Expand All @@ -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, {
Expand Down
67 changes: 67 additions & 0 deletions tests/ui_spec.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down