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
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 9 additions & 4 deletions doc/blit.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
8 changes: 8 additions & 0 deletions docs/manual-testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
41 changes: 34 additions & 7 deletions docs/spec/renderer-placement.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.

Expand Down
33 changes: 22 additions & 11 deletions lua/blit/renderer.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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 },
Expand Down
115 changes: 115 additions & 0 deletions tests/test_renderer.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
Expand Down Expand Up @@ -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("<C-e>", 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()
Expand Down
Loading