From 6015ca0f838e9c77e39be8ef7f144a220ddf9905 Mon Sep 17 00:00:00 2001 From: Hisanari Kikuchi Date: Sun, 4 Oct 2026 22:36:15 +0900 Subject: [PATCH] feat!: position placements horizontally via a col cell offset show()'s opts.col was accepted but silently ignored: virt_lines always render at the window's text-area left edge, so the image always landed there too. The image is drawn out of band at an absolute screen position, so it can be shifted without touching the extmark: compute_placement now adds geometry.col to the text-area left edge's screen column, in both the normal path and the scrolled-off-anchor (topfill) branch. Existing column clipping handles an offset that overhangs or passes the right edge. Closes #9 BREAKING CHANGE: opts.col is now a 0-indexed display-cell offset from the window's text-area left edge, not an anchor byte column, and is validated as a non-negative integer. A negative or fractional col that was previously accepted (and ignored) now raises an argument error. --- README.md | 9 +++ doc/blit.txt | 13 ++-- docs/manual-testing.md | 8 +++ docs/spec/renderer-placement.md | 41 ++++++++++-- lua/blit/renderer.lua | 33 ++++++--- tests/test_renderer.lua | 115 ++++++++++++++++++++++++++++++++ 6 files changed, 197 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 558edd0..7efc728 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,15 @@ general questions go in [Discussions](https://github.com/optiflowic/blit.nvim/di ## Changelog +### Unreleased + +- **Breaking**: `show()`'s `opts.col` now positions the image horizontally, + as a 0-indexed display-cell offset from the window's text-area left edge + ([#9](https://github.com/optiflowic/blit.nvim/issues/9)). It was previously + documented as an anchor byte column and silently ignored; it is now + validated as a non-negative integer, so a negative or fractional `col` + that used to be accepted raises an argument error. + ### v0.2.0 (2026-08-08) - Renderer: a placement scrolled partway past a window edge shows a cropped diff --git a/doc/blit.txt b/doc/blit.txt index 33db9d2..e964e9b 100644 --- a/doc/blit.txt +++ b/doc/blit.txt @@ -67,10 +67,15 @@ show({path}, {opts}) buf buffer to anchor in (default: current buffer of {win}) win window to anchor in (default: current window) lnum 1-indexed anchor line (default: {win}'s cursor line) - col 0-indexed anchor byte column (default: 0). Currently - unused for placement — virt_lines always render at the - window's text-area left edge, independent of the - extmark's column. Reserved for future use. + col 0-indexed horizontal offset of the image's left edge + from the window's text-area left edge, in display cells + (default: 0). A cell count, not a byte column: it does + not depend on the anchor line's text. To align with a + character, pass e.g. + `vim.fn.strdisplaywidth(line:sub(1, byte_col))`. Must be + a non-negative integer. An image pushed partway past + the window's right edge is cropped; pushed entirely + past it, hidden. z_index kitty placement z-index (default: unset) cell_aspect_ratio assumed terminal cell width-px/height-px ratio, used only to derive an omitted {width}/{height} (default: diff --git a/docs/manual-testing.md b/docs/manual-testing.md index 1e98521..5adbc71 100644 --- a/docs/manual-testing.md +++ b/docs/manual-testing.md @@ -25,6 +25,14 @@ WezTerm (and Ghostty when available) before tagging a release. image repositions the first image to follow its shifted `virt_lines` block within about one debounce interval, instead of leaving it stuck at its old position overlapping buffer text (issue #18). +- [ ] `show(path, { width = 10, height = 5, col = 8 })` renders the image + 8 cells to the right of the window's text-area left edge (i.e. past + any number/sign column, aligned with the 9th text cell), inside its + reserved blank rows (issue #9). Scroll it partway off the top of the + window and confirm the cropped tail keeps the same horizontal + offset. Repeat with a `col` large enough that the image overhangs + the window's right edge: the visible part must be a clean + column-cropped slice, not stretched or wrapped onto the next row. - [ ] Scrolling the image fully out of view then back in re-displays it without a visible retransmission delay (cache hit). - [ ] `show()` the same PNG path twice at two different buffer lines diff --git a/docs/spec/renderer-placement.md b/docs/spec/renderer-placement.md index 7818eaf..b7a6ba7 100644 --- a/docs/spec/renderer-placement.md +++ b/docs/spec/renderer-placement.md @@ -62,12 +62,39 @@ unimplemented. Accepted for v0.x. screen row/col, or `row = 0` if that position is not currently rendered at all (scrolled off, inside a closed fold, wrong window). `renderer.lua` calls this with the anchor line and column 1 — always column 1, never -`geometry.col` — to get the anchor's screen column and to test visibility. -Column 1 is used unconditionally because `virt_lines` always render starting -at the window's text-area left edge, independent of the extmark's own -column (the extmark itself is created at a hardcoded column 0 below); -`geometry.col`/`opts.col` is currently unused for placement, reserved for a -future version that supports horizontal positioning some other way. +`geometry.col` — to get the window's text-area left edge and to test +visibility. Column 1 is used unconditionally because `virt_lines` always +render starting at the window's text-area left edge, independent of the +extmark's own column (the extmark itself is created at a hardcoded column 0 +below). + +### Horizontal positioning (issue #9) + +`geometry.col`/`opts.col` is a 0-indexed **display-cell offset** from that +text-area left edge, not a buffer byte column: `screen_col = screenpos(win, +lnum, 1).col + geometry.col`. The `virt_lines` block only reserves blank +rows — the image itself is drawn out of band at an absolute screen position +— so shifting it horizontally needs no change to the extmark at all, only +this one addition. Everything downstream is unchanged: `compute_clip` +already clips the resulting column span against the window's left/right +bounds, so an offset that pushes the image partway past the right edge +shows a column-cropped slice, and one that pushes it entirely past hides +the placement (see "Visibility policy" below). + +A cell offset was chosen over the byte-column meaning `col` was originally +documented with (while it was still unused) because it depends on nothing +but the window: a byte column would have to be converted through the anchor +line's own text (tabs, wide characters, a column past the end of the line), +has no single answer once `'wrap'` puts that column on a later screen row, +and cannot be resolved through `screenpos()` at all in the scrolled-off +`topfill` branch below, where the anchor line is not rendered. A caller that +wants to align with a specific character converts it itself, e.g. +`vim.fn.strdisplaywidth(line:sub(1, byte_col))`, and passes the result. + +The offset is relative to the text area, not to the text: it does not +follow `'nowrap'` horizontal scrolling (`leftcol`). This matches the +reserved `virt_lines` rows themselves, which do not scroll horizontally +either. `show()` validates `col` as a non-negative integer. The reserved `virt_lines` block's first screen row is one past the anchor line's own **last** rendered screen row, not its first — under `'wrap'` a @@ -200,7 +227,7 @@ crop math is identical to the normal path. The column for this branch comes from `screenpos(win, view.topline, 1)` (the line right after the anchor, guaranteed visible whenever this branch is reached) rather than the anchor's own — both render `virt_lines` at the same window text-area left -edge. If `topline` doesn't match exactly or `topfill` is `0`, the block has +edge — plus the same `geometry.col` cell offset as the normal path. If `topline` doesn't match exactly or `topfill` is `0`, the block has genuinely scrolled fully past and the handle is correctly hidden, same as before. diff --git a/lua/blit/renderer.lua b/lua/blit/renderer.lua index 83f927d..99c83e7 100644 --- a/lua/blit/renderer.lua +++ b/lua/blit/renderer.lua @@ -19,9 +19,9 @@ local NAMESPACE = "blit" ---@class blit.Geometry ---@field lnum integer 1-indexed anchor buffer line ----@field col integer 0-indexed anchor byte column; currently unused for ----placement (virt_lines always render at the window's text-area left edge, ----independent of the extmark's column) — reserved for future use +---@field col integer 0-indexed display-cell offset of the placement's left +---edge from the window's text-area left edge (issue #9); independent of the +---anchor line's own text ---@field cols integer target placement width in cell columns ---@field rows integer target placement height in cell rows @@ -451,8 +451,9 @@ local function compute_placement(handle) -- (the same screen column as byte column 1 of any line), independent of -- the extmark's own column — nvim_buf_set_extmark below anchors it at -- column 0 regardless of handle.geometry.col. Column 1 is queried here - -- to match that, not handle.geometry.col (currently unused for - -- placement; see the Geometry class doc comment). + -- to find that left edge; handle.geometry.col is a display-cell offset + -- added on top of it below, not a byte column to query (issue #9; see + -- docs/spec/renderer-placement.md's "Screen coordinate conversion"). local ok, pos = pcall(vim.fn.screenpos, handle.win, handle.geometry.lnum, 1) if not ok then return nil @@ -470,7 +471,7 @@ local function compute_placement(handle) -- bounds.bottom + 1 makes compute_clip below report zero visible rows. local last_row = anchor_last_row(handle.win, handle.buf, handle.geometry.lnum) local screen_row = (last_row > 0 and last_row or bounds.bottom) + 1 - screen_col = pos.col + screen_col = pos.col + handle.geometry.col row_lo, row_hi, vis_rows = compute_clip(screen_row, handle.geometry.rows, bounds.top, bounds.bottom) final_row = screen_row + row_lo @@ -503,7 +504,7 @@ local function compute_placement(handle) if not ok2 or pos2.row <= 0 then return nil end - screen_col = pos2.col + screen_col = pos2.col + handle.geometry.col row_lo = math.max(0, handle.geometry.rows - view.topfill) local raw_visible = handle.geometry.rows - row_lo _, row_hi, vis_rows = compute_clip(bounds.top, raw_visible, bounds.top, bounds.bottom) @@ -1167,6 +1168,12 @@ local function is_positive_integer_or_nil(v) return v == nil or is_positive_integer(v) end +---@param v any +---@return boolean +local function is_non_negative_integer_or_nil(v) + return v == nil or (type(v) == "number" and v == math.floor(v) and v >= 0) +end + ---@param v any ---@return boolean local function is_positive_number_or_nil(v) @@ -1227,9 +1234,9 @@ M.resolve_cell_size = resolve_cell_size ---@field buf? integer defaults to the current buffer of {win} ---@field win? integer defaults to the current window ---@field lnum? integer 1-indexed anchor line, defaults to {win}'s cursor line ----@field col? integer 0-indexed anchor byte column, defaults to 0; currently ----unused for placement (virt_lines always render at the window's text-area ----left edge) — reserved for future use +---@field col? integer 0-indexed display-cell offset of the image's left edge +---from the window's text-area left edge, defaults to 0. A cell count, not a +---byte column: independent of the anchor line's own text. ---@field z_index? integer ---@field max_file_bytes? integer defaults to blit.config.defaults.max_file_bytes ---@field debounce_ms? integer defaults to the last configured value (initially 16) @@ -1256,7 +1263,11 @@ function M.show(path, opts) buf = { opts.buf, "number", true }, win = { opts.win, "number", true }, lnum = { opts.lnum, "number", true }, - col = { opts.col, "number", true }, + col = { + opts.col, + is_non_negative_integer_or_nil, + "a non-negative integer (cell columns), or nil", + }, z_index = { opts.z_index, "number", true }, max_file_bytes = { opts.max_file_bytes, "number", true }, debounce_ms = { opts.debounce_ms, "number", true }, diff --git a/tests/test_renderer.lua b/tests/test_renderer.lua index bc4adf0..84c9694 100644 --- a/tests/test_renderer.lua +++ b/tests/test_renderer.lua @@ -450,6 +450,18 @@ T["show"]["rejects a non-positive width/height"] = function() MiniTest.expect.equality(ok, false) end +T["show"]["rejects a negative or fractional col (issue #9)"] = function() + local buf, win = setup_floating(numbered_lines(10), 20, 10) + for _, col in ipairs({ -1, 1.5 }) do + local ok = pcall( + renderer.show, + tmp_path, + { width = 5, height = 3, buf = buf, win = win, lnum = 2, col = col } + ) + MiniTest.expect.equality(ok, false) + end +end + T["show"]["rejects omitting both width and height"] = function() local buf, win = setup_floating(numbered_lines(10), 20, 10) local ok = pcall(renderer.show, tmp_path, { buf = buf, win = win, lnum = 2 }) @@ -821,6 +833,109 @@ T["redraw"]["hides on TabLeave and restores on TabEnter (issue #16)"] = function MiniTest.expect.equality(show_all:find("i=" .. handle.id, 1, true) ~= nil, true) end +T["redraw"]["horizontal col offset (issue #9)"] = MiniTest.new_set() + +T["redraw"]["horizontal col offset (issue #9)"]["col = 0 places at the text-area left edge"] = function() + local buf, win = setup_floating(numbered_lines(10), 20, 10) + renderer.show(tmp_path, { width = 5, height = 3, buf = buf, win = win, lnum = 2, col = 0 }) + -- anchor screen row 2 -> virt_lines start at row 3; text-area left edge is screen col 1. + local all = table.concat(captured[1], "") + MiniTest.expect.equality(all:find(ESC .. "[3;1H", 1, true) ~= nil, true) +end + +T["redraw"]["horizontal col offset (issue #9)"]["shifts the placement right by col cells"] = function() + local buf, win = setup_floating(numbered_lines(10), 20, 10) + local handle = + renderer.show(tmp_path, { width = 5, height = 3, buf = buf, win = win, lnum = 2, col = 8 }) + MiniTest.expect.equality(handle.visible, true) + local all = table.concat(captured[1], "") + MiniTest.expect.equality(all:find(ESC .. "[3;9H", 1, true) ~= nil, true) + -- cols 9-13 fit inside the 20-col window: unclipped, full width, no crop keys. + MiniTest.expect.equality(all:find(",c=5", 1, true) ~= nil, true) + MiniTest.expect.equality(all:find(",w=", 1, true), nil) +end + +T["redraw"]["horizontal col offset (issue #9)"]["is a cell offset, independent of the anchor line's text"] = function() + -- A tab and a wide character before byte column 8 would move a byte-column + -- anchor; a cell offset must land on the same screen column regardless. + -- (The line is 14 cells wide, so it does not wrap in the 20-col window.) + local buf, win = setup_floating({ "x", "\t日本語", "y" }, 20, 10) + renderer.show(tmp_path, { width = 5, height = 3, buf = buf, win = win, lnum = 2, col = 8 }) + local all = table.concat(captured[1], "") + MiniTest.expect.equality(all:find(ESC .. "[3;9H", 1, true) ~= nil, true) +end + +T["redraw"]["horizontal col offset (issue #9)"]["offsets from the text area, past the number column"] = function() + local buf, win = setup_floating(numbered_lines(10), 20, 10) + vim.wo[win].number = true + vim.wo[win].numberwidth = 4 + renderer.show(tmp_path, { width = 5, height = 3, buf = buf, win = win, lnum = 2, col = 3 }) + -- 4-cell number column -> text area starts at screen col 5; +3 -> col 8. + local all = table.concat(captured[1], "") + MiniTest.expect.equality(all:find(ESC .. "[3;8H", 1, true) ~= nil, true) +end + +T["redraw"]["horizontal col offset (issue #9)"]["overhanging the right edge crops the column span"] = function() + local buf, win = setup_floating(numbered_lines(10), 20, 10) + local handle = + renderer.show(tmp_path, { width = 5, height = 3, buf = buf, win = win, lnum = 2, col = 17 }) + -- screen cols 18-22 vs. window right edge col 20: 3 visible, 2 clipped. + MiniTest.expect.equality(handle.visible, true) + local all = table.concat(captured[1], "") + MiniTest.expect.equality(all:find(ESC .. "[3;18H", 1, true) ~= nil, true) + MiniTest.expect.equality(all:find(",c=3", 1, true) ~= nil, true) + MiniTest.expect.equality(all:find(",w=", 1, true) ~= nil, true) +end + +T["redraw"]["horizontal col offset (issue #9)"]["last cell inside the right edge stays visible"] = function() + local buf, win = setup_floating(numbered_lines(10), 20, 10) + local handle = + renderer.show(tmp_path, { width = 5, height = 3, buf = buf, win = win, lnum = 2, col = 19 }) + MiniTest.expect.equality(handle.visible, true) + local all = table.concat(captured[1], "") + MiniTest.expect.equality(all:find(ESC .. "[3;20H", 1, true) ~= nil, true) + MiniTest.expect.equality(all:find(",c=1", 1, true) ~= nil, true) +end + +T["redraw"]["horizontal col offset (issue #9)"]["entirely past the right edge: transmit-only, not displayed"] = function() + local buf, win = setup_floating(numbered_lines(10), 20, 10) + local handle = + renderer.show(tmp_path, { width = 5, height = 3, buf = buf, win = win, lnum = 2, col = 20 }) + MiniTest.expect.equality(handle.visible, false) + local all = table.concat(captured[1], "") + MiniTest.expect.equality(all:find(ESC .. "7", 1, true), nil) +end + +T["redraw"]["horizontal col offset (issue #9)"]["keeps the offset in the scrolled-off-anchor (topfill) branch"] = function() + local buf, win = setup_floating(numbered_lines(20), 20, 10) + vim.wo[win].scrolloff = 0 + local handle = renderer.show( + tmp_path, + { width = 5, height = 5, buf = buf, win = win, lnum = 1, col = 8, debounce_ms = 5 } + ) + MiniTest.expect.equality(handle.visible, true) + + local ctrl_e = vim.api.nvim_replace_termcodes("", true, true, true) + for _ = 1, 3 do + vim.api.nvim_win_call(win, function() + vim.cmd("normal! " .. ctrl_e) + end) + vim.cmd("redraw") + end + MiniTest.expect.equality(vim.api.nvim_win_call(win, vim.fn.winsaveview).topfill, 3) + + captured = {} + vim.api.nvim_exec_autocmds("WinScrolled", { pattern = tostring(win) }) + vim.wait(500, function() + return #captured > 0 + end) + + MiniTest.expect.equality(handle.visible, true) + local all = table.concat(captured[1], "") + MiniTest.expect.equality(all:find(ESC .. "[1;9H", 1, true) ~= nil, true) + MiniTest.expect.equality(all:find(",r=3", 1, true) ~= nil, true) +end + T["redraw"]["source-rect crop"] = MiniTest.new_set() T["redraw"]["source-rect crop"]["bottom-clipped placement crops instead of hiding"] = function()