From cc7bfeb2e30e14802fba0ece8e9cf85d8e8b77bc Mon Sep 17 00:00:00 2001 From: Denny Huang Date: Mon, 26 Oct 2026 23:53:39 +0800 Subject: [PATCH] fix(preview): account for wrapped rows when filling the window Capping the topline by buffer line count moves the cursor to the bottom when following wrapped content already fills the screen. Measure the remaining screen rows, stopping before later buffer lines once a window is filled, and use the existing native EOF alignment only when those rows would leave a gap. Keep the original centering path when enough screen rows remain. Add EOF and attached-UI wrapping regressions alongside the upstream tests. Upstream-PR: https://github.com/delphinus/md-render.nvim/pull/80 (cherry picked from commit 86c7c956839faa31ef7a055667531166bce64a79) --- lua/md-render/preview.lua | 17 +++++++--- tests/preview_scroll_test.lua | 63 +++++++++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+), 5 deletions(-) diff --git a/lua/md-render/preview.lua b/lua/md-render/preview.lua index 476430e..42c5330 100644 --- a/lua/md-render/preview.lua +++ b/lua/md-render/preview.lua @@ -521,12 +521,19 @@ function Session:scroll_to_source_line(source_cursor_line) target = math.max(1, math.min(target, buf_lines)) local win_height = vim.api.nvim_win_get_height(self.win) local top = math.max(0, target - 1 - math.floor(win_height / 2)) - -- Centering must not scroll past the end: a document that fits the window - -- would lose its first lines and leave the bottom rows empty. Each line - -- takes at least one row, so this still fills the window when lines wrap. - top = math.min(top, math.max(0, buf_lines - win_height)) vim.api.nvim_win_call(self.win, function() - vim.fn.winrestview { topline = top + 1 } + -- Wrapped lines can fill the window even when there are fewer buffer lines. + local remaining = vim.api.nvim_win_text_height(self.win, { + start_row = top, + end_row = buf_lines - 1, + max_height = win_height, + }).all + if remaining < win_height then + vim.fn.cursor(buf_lines, 1) + vim.cmd "normal! zb" + else + vim.fn.winrestview { topline = top + 1 } + end end) vim.api.nvim_win_set_cursor(self.win, { target, 0 }) end diff --git a/tests/preview_scroll_test.lua b/tests/preview_scroll_test.lua index 2ef9ca5..9eb2e2d 100644 --- a/tests/preview_scroll_test.lua +++ b/tests/preview_scroll_test.lua @@ -30,14 +30,19 @@ local function open(lines, cursor_line) vim.fn.writefile(lines, path) vim.cmd("edit " .. path) vim.api.nvim_win_set_cursor(0, { cursor_line, 0 }) + local source_win = vim.api.nvim_get_current_win() preview.show() local win = vim.api.nvim_get_current_win() local r = { topline = vim.fn.line "w0", height = vim.api.nvim_win_get_height(win), lines = vim.api.nvim_buf_line_count(0), + rows = vim.api.nvim_win_text_height(win, {}).all, + cursor = vim.api.nvim_win_get_cursor(win)[1], + cursor_row = vim.fn.winline(), } vim.api.nvim_win_close(win, true) + r.source_cursor = vim.api.nvim_win_get_cursor(source_win)[1] vim.cmd "bwipeout!" vim.fn.delete(path) return r @@ -60,7 +65,65 @@ do local r = open(lines, 199) assert_eq(r.topline > 1, true, "the cursor line in the middle of a long document should be scrolled to") assert_eq(r.topline <= r.lines - r.height + 1, true, "the last line should not leave empty rows below it") + + -- Near EOF, earlier text fills the rows that centering would leave empty. + r = open(lines, 397) + assert_eq(r.rows, r.lines, "the EOF fixture should not wrap") + assert_eq(r.cursor, 397, "the EOF adjustment should preserve the mapped cursor") + assert_eq(r.cursor_row + r.lines - r.cursor, r.height, "the last line should fill the float's last screen row") + assert_eq(r.source_cursor, 397, "opening and closing the float should preserve the source cursor") +end + +-- An attached UI verifies native wrapping and the actual following text on screen. +local child = vim.fn.jobstart( + { vim.v.progpath, "--embed", "--headless", "-n", "-u", "NONE", "--noplugin", "-i", "NONE" }, + { rpc = true } +) +local function child_lua(code) + return vim.rpcrequest(child, "nvim_exec_lua", code, {}) end +local ok, err = pcall(function() + vim.rpcrequest(child, "nvim_ui_attach", 120, 40, { rgb = true }) + child_lua [[ + package.path = vim.fn.getcwd() .. "/lua/?.lua;" .. vim.fn.getcwd() .. "/lua/?/init.lua;" .. package.path + require("md-render.text_size").setup { enabled = false } + vim.o.scrolloff, vim.o.laststatus = 0, 2 + local lines = {} + for i = 1, 13 do + lines[#lines + 1] = ("Para%02d "):format(i) .. string.rep("x", 145) + lines[#lines + 1] = "" + end + vim.bo.filetype = "markdown" + vim.api.nvim_buf_set_lines(0, 0, -1, false, lines) + vim.api.nvim_win_set_cursor(0, {21, 0}) + _G.source_win = vim.api.nvim_get_current_win() + _G.source_buf = vim.api.nvim_get_current_buf() + _G.source_tick = vim.api.nvim_buf_get_changedtick(source_buf) + require("md-render.preview").show_tab() + ]] + vim.rpcrequest(child, "nvim_command", "redraw!") + child_lua [[ + local win, buf = vim.api.nvim_get_current_win(), vim.api.nvim_get_current_buf() + local session = require("md-render.preview")._sessions[buf] + assert(vim.api.nvim_win_text_height(win, {}).all > vim.api.nvim_buf_line_count(buf), "fixture must wrap on screen") + assert(vim.api.nvim_win_get_cursor(win)[1] == session:source_to_rendered(21), "mapped cursor changed") + local pos + for line, text in ipairs(vim.api.nvim_buf_get_lines(buf, 0, -1, false)) do + local col = text:find("Para12", 1, true) + if col then pos = vim.fn.screenpos(win, line, col); break end + end + assert(pos and pos.row > 0, "the paragraph after the cursor is off screen") + local cells = {} + for col = pos.col, pos.col + 5 do cells[#cells + 1] = vim.fn.screenstring(pos.row, col) end + assert(table.concat(cells) == "Para12", "the following paragraph was not drawn") + vim.api.nvim_win_close(win, true) + assert(vim.api.nvim_win_get_buf(source_win) == source_buf, "closing the preview lost the source") + assert(vim.api.nvim_win_get_cursor(source_win)[1] == 21, "source cursor changed") + assert(vim.api.nvim_buf_get_changedtick(source_buf) == source_tick, "source text changed") + ]] +end) +vim.fn.jobstop(child) +assert_eq(ok, true, "wrapped paragraphs stay visible: " .. tostring(err)) print(string.format("\n%d passed, %d failed", pass_count, fail_count)) if fail_count > 0 then os.exit(1) end