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()