Skip to content

fix: Remove the extra spaces added to wrapped lines on resize - #531

Open
jsun969 wants to merge 3 commits into
OXY2DEV:mainfrom
jsun969:fix/wrap-tail-indent-and-resize
Open

jsun969 wants to merge 3 commits into
OXY2DEV:mainfrom
jsun969:fix/wrap-tail-indent-and-resize

Conversation

@jsun969

@jsun969 jsun969 commented Oct 8, 2026

Copy link
Copy Markdown

Description

Wrapped lines can end up with extra spaces that shouldn't be there:

  • In a narrow window wrap.fine_wrap() adds a wrap indent even where there is no text left for it, so a stray space(the block quote border) lands in the middle of the text.
  • Those indents are only recomputed on cursor movements & text changes, so after making a window narrower & wide again the spaces stay behind.

Test

Repro: nvim -u test/minimal_init.lua test/wrap_tail_indent.md, then make the window ~40 columns wide & wide again(:vsplit then :only, or drag the terminal pane wider).

Note: test/minimal_init.lua & the test/wrap_tail_indent.md files were only added for my own testing, I can remove them if you don't want them in the project.

Demo

Before (left window):
CleanShot 2026-10-08 at 19 53 05

After (left window):
CleanShot 2026-10-08 at 19 56 19

`wrapcol`(from `virtcol2col()`) is a **byte index**, but it was compared
against `strdisplaywidth()`, a **display width**. And, while `wrap` &
`breakindent` are set, both `strdisplaywidth()` & `virtcol()` are *inflated*
(`72` spaces measure as `104` cells in a `40` column window).

The *"No enough text after indent"* check therefore missed the last wrap of a
line & inserted a wrap indent **before the line's last character**, so the
indent's border/spaces showed up inside the text or pushed the last character
onto its own row,

> quoted quoted quote▋ d
> quoted quoted▋

The checks now stay in byte space(`#line`) & require more than a single
character to be left after the wrap point.
`wrap` indents are placed at the wrap columns of the width they were rendered
at. Buffers were only rendered again on cursor movements, text changes &
`wrap`/`linebreak` option changes, so resizing a window(e.g. narrowing a split
& widening it back) left the indents behind in the middle of a line.

`WinResized` & `VimResized` now render the buffers that are on screen again,
debounced(`50`ms) the same way cursor movements are.
`test/wrap_tail_indent.md` holds block quotes & list items padded to exact byte
lengths(72/73/86/90/88/71) that wrap into the cases `fine_wrap()` used to
mangle, plus controls(heading, paragraph, table, code block) that must render
unchanged, & the steps to check both fixes.

`test/minimal_init.lua` runs the plugin from a checkout, so the fixture can be
checked without `lazy.nvim`,

```sh
nvim -u test/minimal_init.lua test/wrap_tail_indent.md
```
@jsun969

jsun969 commented Oct 8, 2026

Copy link
Copy Markdown
Author

Here is a temporary fix in config without changing the source code:

	config = function (_, opts)
		require("markview").setup(opts);

		-- Workaround for upstream: markview recomputes its wrap indents only on render
		-- triggers (cursor move, TextChanged, OptionSet wrap/linebreak), never on resize.
		-- The indent is inserted as `virt_text_pos = "inline"` extmarks at the wrap columns
		-- of whatever width it rendered at, so after narrowing a window (e.g. a split) and
		-- widening it back, stray indent/border text stays stuck mid-line. Re-render the
		-- visible buffers on resize so the marks are placed at the current wrap columns.
		local timer = vim.uv.new_timer();

		vim.api.nvim_create_autocmd({ "WinResized", "VimResized" }, {
			group = vim.api.nvim_create_augroup("UserMarkviewResizeReflow", { clear = true }),
			desc = "Re-render markview preview so wrap indents match the new window width",
			callback = function ()
				-- Resizes arrive in bursts (mouse drag, per-step terminal resize).
				timer:stop();
				timer:start(50, 0, vim.schedule_wrap(function ()
					local actions = require("markview.actions");
					local state = require("markview.state");

					if not state.enabled() then
						return;
					end

					for _, buf in ipairs(state.get_enabled_buffers()) do
						if vim.api.nvim_buf_is_valid(buf) and vim.fn.win_findbuf(buf)[1] then
							if actions.in_preview_mode() then
								require("markview").render(buf);
							else
								actions.clear(buf);
							end
						end
					end
				end));
			end,
		});

		-- Workaround for upstream: `wrap.fine_wrap()` compares the *byte* index of a wrap
		-- point against a *display width* (`strdisplaywidth()`/`virtcol()`), and both of
		-- those are inflated when `wrap` + `breakindent` are set (72 spaces measure as 104
		-- cells). Its guards therefore miss the last wrap of a line and insert a wrap
		-- indent *before the final character*, so the indent's border/spaces stick out in
		-- the middle of the text (`> quoted quote▋ d`, `> quoted quoted▋ `).
		-- A wrap indent only makes sense when more than one character follows it, so drop
		-- those marks - only the ones carrying this call's own indent text, not marks from
		-- other renderers - and only when at most the line's last character follows them.
		local wrap = require("markview.wrap");
		local fine_wrap = wrap.fine_wrap;

		-- Text of a `virt_text` chunk list, highlighting ignored (highlight groups may
		-- come back as strings or tables, the leading text never does).
		local function virt_text_text (chunks)
			local out = {};

			for i, chunk in ipairs(chunks or {}) do
				out[i] = chunk[1] or "";
			end

			return table.concat(out);
		end

		if fine_wrap then
			wrap.fine_wrap = function (buffer, win, row, ns, indent)
				fine_wrap(buffer, win, row, ns, indent);

				local line = vim.api.nvim_buf_get_lines(buffer, row, row + 1, false)[1];

				if not line then
					return;
				end

				-- 1-based index of the last non-blank byte.
				local last = #line;

				while last > 0 and line:sub(last, last):match("%s") do
					last = last - 1;
				end

				local indent_text = virt_text_text(indent);

				for _, mark in ipairs(vim.api.nvim_buf_get_extmarks(buffer, ns, { row, 0 }, { row, -1 }, { details = true })) do
					-- `mark[3]` is 0-based, so `mark[3] + 1` is the 1-based insertion point.
					if mark[4].virt_text_pos == "inline"
						and virt_text_text(mark[4].virt_text) == indent_text
						and (mark[3] + 1) >= last then
						vim.api.nvim_buf_del_extmark(buffer, ns, mark[1]);
					end
				end
			end;
		end
	end,

@OXY2DEV

OXY2DEV commented Oct 8, 2026

Copy link
Copy Markdown
Owner

@jsun969 I have already tried similar thing(s), a heuristic/assumption based approach doesn't cover all cases. I encourage you to read the explanation why it isn't working, before chugging this down AI's throat.

For example,
If vim.o.columns is 81 & the statuscolumn is using 9 columns you get a wrapping like this,

Screenshot_2026-10-08-15-57-39-60_84d3000e3f4017145260f7618db1d683

As you can see, the decorations aren't getting correctly applied to the wrapped line.

Screenshot_2026-10-08-15-57-59-79_84d3000e3f4017145260f7618db1d683

Same issue persists for up to 3 characters.

Screenshot_2026-10-08-15-58-12-61_84d3000e3f4017145260f7618db1d683

On the 4th character the calculation becomes accurate again.


My implementation tries to add as many wrap indentations as possible. On the other hand, your implementation tries to add as few wrap indentations instead.

Both of these work based on assumption of where a line should get wrapped. Unfortunately, a lot of options can change it.


So, unless you can get this data from the editor(e.g. via ffi or wait for Neovim to implement it) it would have inaccuracies. So, you should try getting the wrap locations themselves, instead of trying to force guessing them.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants