From cc26e82fe5772804ad0161c9e33d0ccbe73b445e Mon Sep 17 00:00:00 2001 From: delphinus Date: Thu, 24 Sep 2026 12:00:32 +0900 Subject: [PATCH] fix(preview): keep rendered lines inside the window ContentBuilder wraps the text at `max_width` and prepends the indent afterwards, so a line can be `max_width + #indent` wide. The window-sized renders set `max_width` to the window's usable width, which let lines run up to two columns past the right edge. 'wrap' then put their last one or two characters on a screen row of their own at column 0, so a wrapped list item looked as if its text had lost its hanging indent. Which lines overflow depends on where the wrap points fall, so the float and the pager showed it on different lines. Take the indent off the window width in both places that size `max_width` from the window: binding a window, and the WinResized handler. tests/content_width_test.lua renders a document with a wrapped checkbox item and a wrapped nested item in toggle, split and pager at four widths and checks that no line is wider than the window. 8 of its 12 cases fail before this change. Co-Authored-By: Claude Opus 5.5 (1M context) --- lua/md-render/preview.lua | 16 +++++- tests/content_width_test.lua | 97 ++++++++++++++++++++++++++++++++++++ 2 files changed, 111 insertions(+), 2 deletions(-) create mode 100644 tests/content_width_test.lua diff --git a/lua/md-render/preview.lua b/lua/md-render/preview.lua index 215fc39..6818ed0 100644 --- a/lua/md-render/preview.lua +++ b/lua/md-render/preview.lua @@ -29,6 +29,18 @@ local function usable_win_width(win) return math.max(1, total - textoff) end +--- The `max_width` to render at for a window. ContentBuilder wraps the text +--- at `max_width` and then prepends `indent`, so the indent has to come off +--- the window width; otherwise lines run up to its width past the edge and +--- 'wrap' folds their last characters onto a row of their own. +---@param win integer +---@param indent? string defaults to ContentBuilder's `" "` +---@return integer +local function content_width(win, indent) + local indent_w = vim.api.nvim_strwidth(indent or " ") + return math.max(1, math.min(usable_win_width(win) - indent_w, DEFAULT_MAX_WIDTH)) +end + --- Parse simple YAML frontmatter lines into key-value pairs ---@param fm_lines string[] ---@return {key: string, value: string}[] @@ -503,7 +515,7 @@ end function Session:bind_window(win) self.win = win if not self._explicit_max_width then - local win_width = math.min(usable_win_width(win), DEFAULT_MAX_WIDTH) + local win_width = content_width(win, self.opts.indent) if win_width ~= (self.opts.max_width or DEFAULT_MAX_WIDTH) then self.opts.max_width = win_width self:rebuild() @@ -1752,7 +1764,7 @@ local function install_win_resize_handler(session) local win = render_wins[1] if not vim.api.nvim_win_is_valid(win) then return end - local win_width = math.min(usable_win_width(win), DEFAULT_MAX_WIDTH) + local win_width = content_width(win, session.opts.indent) if win_width == (session.opts.max_width or DEFAULT_MAX_WIDTH) then return end session.opts.max_width = win_width diff --git a/tests/content_width_test.lua b/tests/content_width_test.lua new file mode 100644 index 0000000..2a31d11 --- /dev/null +++ b/tests/content_width_test.lua @@ -0,0 +1,97 @@ +-- Test that rendered lines fit the window they are shown in. ContentBuilder +-- wraps at `max_width` and prepends the indent afterwards, so a window-sized +-- `max_width` let lines run past the edge by the indent's width, and 'wrap' +-- put their last characters on a screen row of their own at column 0. +-- Run: nvim --headless -u NONE --noplugin -l tests/content_width_test.lua + +package.path = vim.fn.getcwd() .. "/lua/?.lua;" .. vim.fn.getcwd() .. "/lua/?/init.lua;" .. package.path + +local preview = require "md-render.preview" + +local pass_count = 0 +local fail_count = 0 + +local function assert_eq(actual, expected, msg) + if vim.deep_equal(actual, expected) then + pass_count = pass_count + 1 + else + fail_count = fail_count + 1 + print("FAIL: " .. msg) + print(" expected: " .. vim.inspect(expected)) + print(" actual: " .. vim.inspect(actual)) + end +end + +local function test(name, fn) + local ok, err = pcall(fn) + if not ok then + fail_count = fail_count + 1 + print("ERROR: " .. name .. ": " .. tostring(err)) + end +end + +-- Mixed CJK and ASCII so that the wrap points land at odd and even columns. +local DOC = { + "# Title", + "", + "- [x] " .. string.rep("あいうえお abc ", 8), + " - " .. string.rep("かきくけこ `x = 1` ", 8), + "", + string.rep("さしすせそ def ", 10), +} + +local function setup_md_buffer() + vim.cmd "silent! only" + local buf = vim.api.nvim_create_buf(false, false) + vim.bo[buf].filetype = "markdown" + vim.api.nvim_buf_set_name(buf, "/tmp/md-render-content-width-test-" .. buf .. ".md") + vim.api.nvim_buf_set_lines(buf, 0, -1, false, DOC) + vim.api.nvim_win_set_buf(0, buf) + return buf +end + +--- Lines of the window's buffer wider than its text area. +local function overflowing(win) + local width = vim.api.nvim_win_get_width(win) - vim.fn.getwininfo(win)[1].textoff + local out = {} + for _, line in ipairs(vim.api.nvim_buf_get_lines(vim.api.nvim_win_get_buf(win), 0, -1, false)) do + if vim.api.nvim_strwidth(line) > width then table.insert(out, line) end + end + return out +end + +local function render_win() + for _, w in ipairs(vim.api.nvim_tabpage_list_wins(0)) do + if vim.bo[vim.api.nvim_win_get_buf(w)].filetype == "md-render" then return w end + end +end + +for _, columns in ipairs { 40, 51, 60, 73 } do + test("toggle at " .. columns .. " columns", function() + vim.o.columns = columns + local source = setup_md_buffer() + preview.toggle() + assert_eq(overflowing(vim.api.nvim_get_current_win()), {}, "toggle lines fit at " .. columns .. " columns") + preview.toggle() + pcall(vim.api.nvim_buf_delete, source, { force = true }) + end) + + test("split at " .. columns .. " columns", function() + vim.o.columns = columns + local source = setup_md_buffer() + preview.split { mods = { vertical = false } } + assert_eq(overflowing(render_win()), {}, "split lines fit at " .. columns .. " columns") + pcall(vim.api.nvim_buf_delete, source, { force = true }) + end) + + test("pager at " .. columns .. " columns", function() + vim.o.columns = columns + local source = setup_md_buffer() + preview.show_pager() + assert_eq(overflowing(render_win()), {}, "pager lines fit at " .. columns .. " columns") + pcall(vim.api.nvim_buf_delete, source, { force = true }) + end) +end + +print(string.format("content_width_test: %d passed, %d failed", pass_count, fail_count)) +if fail_count > 0 then os.exit(1) end