From a3c0e1c532f6fca650c08e1faf30104b599c094d Mon Sep 17 00:00:00 2001 From: Charlie Gruenwald Date: Mon, 11 May 2026 20:51:35 -0600 Subject: [PATCH] feat(renderer): account for inline virtual text when positioning images MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a plugin like render-markdown.nvim injects inline virtual text at the start of an image row (e.g. heading indent), image.nvim renders the image flush at column 0, sitting under the virt_text instead of under the displayed position of the link. Detect inline virt_text extmarks at the image row and offset absolute_x by their summed display width. The adjustment is guarded by original_x == 0: when the link sits at a non-zero buffer column, treesitter's node:range already reports the indented column and no further shift is needed. Includes: - tests/renderer/inline_virt_text_offset_spec.lua — 5 cases covering the shift, the non-affected paths (no virt_text / x>0 / eol virt_text), and multi-chunk virt_text width summation. - tests/checklists/inline_virt_text_offset.md — manual checklist for visual verification with render-markdown.nvim. --- lua/image/renderer.lua | 24 +++ tests/checklists/inline_virt_text_offset.md | 57 ++++++ .../renderer/inline_virt_text_offset_spec.lua | 184 ++++++++++++++++++ 3 files changed, 265 insertions(+) create mode 100644 tests/checklists/inline_virt_text_offset.md create mode 100644 tests/renderer/inline_virt_text_offset_spec.lua diff --git a/lua/image/renderer.lua b/lua/image/renderer.lua index ae1ad8a..2c6fe8b 100644 --- a/lua/image/renderer.lua +++ b/lua/image/renderer.lua @@ -326,6 +326,30 @@ local render = function(image) -- apply render_offset_top except for floating windows or during partial scroll local is_floating = window and window.is_floating or false if not is_floating and not is_partial_scroll then absolute_y = absolute_y + (image.render_offset_top or 0) end + + -- account for inline virtual text (e.g. render-markdown indent) at the image row. + -- only when x=0, since treesitter node:range() already includes virt text offsets + -- for non-zero positions. + if original_x == 0 and image.buffer then + local ok_marks, extmarks = pcall( + vim.api.nvim_buf_get_extmarks, + image.buffer, + -1, + { original_y, 0 }, + { original_y, 0 }, + { details = true } + ) + if ok_marks then + for _, mark in ipairs(extmarks) do + local details = mark[4] + if details and details.virt_text and details.virt_text_pos == "inline" then + for _, chunk in ipairs(details.virt_text) do + absolute_x = absolute_x + vim.fn.strdisplaywidth(chunk[1]) + end + end + end + end + end end -- clear out of bounds images diff --git a/tests/checklists/inline_virt_text_offset.md b/tests/checklists/inline_virt_text_offset.md new file mode 100644 index 0000000..6289b35 --- /dev/null +++ b/tests/checklists/inline_virt_text_offset.md @@ -0,0 +1,57 @@ +# Inline virtual text offset checklist + +This file demonstrates the inline-virt-text-offset behavior. Open it in +Neovim with image.nvim and a plugin that injects inline virtual text at the +start of indented content — e.g. +[render-markdown.nvim](https://github.com/MeanderingProgrammer/render-markdown.nvim) +with `indent.enabled = true` and `per_level = 4`. + +Without the fix, all images render flush at column 0 (under the indent +virt_text rather than under the alt-text bracket). With the fix, each image's +left edge sits at the visually-indented position of its `![` opener. + +## Level 1 — control (no indent virt_text) + +Image link starts at column 0, no inline virt_text at the row. +Should render at column 0 with or without the patch. + +![h1-control](../test_data/100x100.png) + +--- + +## Level 2 — 4-cell indent virt_text + +Content under an H2 gets one `per_level` of inline virt_text. Image link +starts at buffer column 0 (`original_x = 0`); the fix shifts the rendered +image right by 4 cells. + +![h2-shifted-by-4](../test_data/100x100.png) + +### Level 3 — 8-cell virt_text + +![h3-shifted-by-8](../test_data/100x100.png) + +#### Level 4 — 12-cell virt_text + +![h4-shifted-by-12](../test_data/100x100.png) + +--- + +## Indented (x > 0) — guard skips the adjustment + +When the image link sits at a non-zero buffer column (real spaces in the +buffer), the patch's `original_x == 0` guard intentionally bypasses the +adjustment. Treesitter's node:range already reports the indented column +for these, so they need no further shift. + +- bullet with image: ![list-l1](../test_data/100x100.png) + - nested with image: ![list-l2](../test_data/100x100.png) + +## What to verify + +- [ ] Level 1 image renders at column 0. +- [ ] Level 2 image's left edge sits at column 4 (under the `![`). +- [ ] Level 3 image at column 8. +- [ ] Level 4 image at column 12. +- [ ] List-item images render flush under their respective `![` openers, + unchanged from previous behavior. diff --git a/tests/renderer/inline_virt_text_offset_spec.lua b/tests/renderer/inline_virt_text_offset_spec.lua new file mode 100644 index 0000000..fe12221 --- /dev/null +++ b/tests/renderer/inline_virt_text_offset_spec.lua @@ -0,0 +1,184 @@ +local notify = vim.notify +vim.notify = function() end + +local renderer = require("image/renderer") +local utils = require("image/utils") + +vim.notify = notify + +describe("renderer inline virtual text offset", function() + local originals + local ns + + local make_image = function(opts) + local calls = {} + local window = vim.api.nvim_get_current_win() + local buffer = vim.api.nvim_get_current_buf() + + local state = { + options = { + scale_factor = 1, + window_overlap_clear_enabled = false, + window_overlap_clear_ft_ignore = {}, + }, + images = {}, + backend = { + features = { crop = true }, + clear = function() end, + render = function(_, x, y, width, height) + calls[#calls + 1] = { x = x, y = y, width = width, height = height } + end, + }, + processor = {}, + tmp_dir = "/tmp", + } + + local image = { + id = opts.id or "test-image", + path = "test.png", + original_path = "test.png", + image_width = 5, + image_height = 5, + window = window, + buffer = buffer, + global_state = state, + geometry = { x = opts.x or 0, y = opts.y or 4, width = 5, height = 5 }, + rendered_geometry = {}, + render_offset_top = 0, + is_rendered = false, + } + + state.images[image.id] = image + return image, calls + end + + local setup_window_mocks = function() + local window = vim.api.nvim_get_current_win() + local buffer = vim.api.nvim_get_current_buf() + + utils.term.get_size = function() + return { cell_width = 1, cell_height = 1, screen_cols = 80, screen_rows = 40 } + end + + utils.window.get_window = function() + return { + id = window, + buffer = buffer, + is_visible = true, + is_floating = false, + masks = {}, + width = 80, + height = 40, + rect = { top = 0, right = 80, bottom = 40, left = 0 }, + } + end + + vim.fn.getwininfo = function() + return { { botline = 20, topline = 1, wincol = 1, winrow = 1, textoff = 0 } } + end + + -- normal in-viewport case: screenpos returns a valid position + vim.fn.screenpos = function(_, line, col) + return { row = line, col = col } + end + end + + before_each(function() + originals = { + get_size = utils.term.get_size, + get_window = utils.window.get_window, + getwininfo = vim.fn.getwininfo, + screenpos = vim.fn.screenpos, + } + ns = vim.api.nvim_create_namespace("test_inline_virt_text") + -- ensure buffer has enough lines for our extmark rows + local buf = vim.api.nvim_get_current_buf() + local lines = {} + for _ = 1, 20 do + lines[#lines + 1] = "" + end + vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines) + end) + + after_each(function() + utils.term.get_size = originals.get_size + utils.window.get_window = originals.get_window + vim.fn.getwininfo = originals.getwininfo + vim.fn.screenpos = originals.screenpos + local buf = vim.api.nvim_get_current_buf() + vim.api.nvim_buf_clear_namespace(buf, ns, 0, -1) + renderer.clear_cache_for_path("test.png") + end) + + it("does not shift non-indented image when no inline virt_text is present", function() + setup_window_mocks() + local image, calls = make_image({ x = 0, y = 4 }) + + assert.is_true(renderer.render(image)) + assert.are.same(1, #calls) + -- screenpos returns col=1 for col input=1 (x=0 + 1), absolute_x = col - 1 = 0 + assert.are.same(0, calls[1].x) + end) + + it("shifts non-indented image right by inline virt_text width at its row", function() + setup_window_mocks() + local buf = vim.api.nvim_get_current_buf() + -- inject 4-cell inline virt_text at row 4, col 0 (matches render-markdown indent) + vim.api.nvim_buf_set_extmark(buf, ns, 4, 0, { + virt_text = { { " ", "Normal" } }, + virt_text_pos = "inline", + }) + local image, calls = make_image({ x = 0, y = 4 }) + + assert.is_true(renderer.render(image)) + assert.are.same(1, #calls) + assert.are.same(4, calls[1].x) + end) + + it("does not shift indented image (x>0) even when virt_text is present", function() + setup_window_mocks() + local buf = vim.api.nvim_get_current_buf() + vim.api.nvim_buf_set_extmark(buf, ns, 4, 0, { + virt_text = { { " ", "Normal" } }, + virt_text_pos = "inline", + }) + local image, calls = make_image({ x = 3, y = 4 }) + + assert.is_true(renderer.render(image)) + assert.are.same(1, #calls) + -- screenpos returns col = x+1 = 4, absolute_x = 4 - 1 = 3, no virt_text shift + assert.are.same(3, calls[1].x) + end) + + it("ignores non-inline virt_text (e.g. eol) at the image row", function() + setup_window_mocks() + local buf = vim.api.nvim_get_current_buf() + vim.api.nvim_buf_set_extmark(buf, ns, 4, 0, { + virt_text = { { " ", "Normal" } }, + virt_text_pos = "eol", + }) + local image, calls = make_image({ x = 0, y = 4 }) + + assert.is_true(renderer.render(image)) + assert.are.same(1, #calls) + assert.are.same(0, calls[1].x) + end) + + it("sums multiple inline virt_text chunks at the image row", function() + setup_window_mocks() + local buf = vim.api.nvim_get_current_buf() + vim.api.nvim_buf_set_extmark(buf, ns, 4, 0, { + virt_text = { + { " ", "Normal" }, + { "▎ ", "Normal" }, + }, + virt_text_pos = "inline", + }) + local image, calls = make_image({ x = 0, y = 4 }) + + assert.is_true(renderer.render(image)) + assert.are.same(1, #calls) + -- " " is 2 cells, "▎ " is 2 cells → total 4 + assert.are.same(4, calls[1].x) + end) +end)