Skip to content

feat(code_blocks): add block_on_wrap to control block style under wrap#520

Closed
ImmanuelHaffner wants to merge 1 commit into
OXY2DEV:mainfrom
ImmanuelHaffner:feat/code-blocks-block-on-wrap
Closed

feat(code_blocks): add block_on_wrap to control block style under wrap#520
ImmanuelHaffner wants to merge 1 commit into
OXY2DEV:mainfrom
ImmanuelHaffner:feat/code-blocks-block-on-wrap

Conversation

@ImmanuelHaffner

@ImmanuelHaffner ImmanuelHaffner commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

The block renderer pads each line to block_width with inline virtual text. With wrap enabled that trailing padding wraps onto the next screen line, breaking the block, so the renderer currently falls back to the simple style (which highlights the whole screen-line, i.e. full window width).

This PR adds a code_blocks.block_on_wrap option that controls what happens under wrap. It is an enum with three values:

  • "off" (default) — always fall back to the simple style. Behaviour is unchanged from before.
  • "on" — always keep the block style, even under wrap.
  • "adaptive" — keep the block style only when the block fits the window; otherwise fall back to simple.

How "adaptive" works

The adaptive check compares range.col_start + block_width against the window's usable text width (win width minus getwininfo().textoff, which already accounts for the sign, number and fold columns). Since block_width includes the min_width/padding floor, padding-driven overflow triggers the fallback too.

It also accounts for a subtle wrap issue: the fence delimiter rows (```lang / ```) are concealed but their raw columns are still counted by Neovim's wrap engine, on top of the block_width padding. The check adds the widest delimiter's width so those rows don't wrap unnoticed. This biases slightly towards simple (the safe, glitch-free direction) rather than risking a wrapped block.

Performance

The per-line width scan is wrapped in a lazy, memoized compute_widths() closure in both renderers, so it runs at most once per block and is reused by render_block() instead of being recomputed. The cheap disqualifiers (wrap off, "on"/"off", simple style, tab) short-circuit before any width computation, so the scan only runs in the "adaptive"-under-wrap case, and getwininfo is called only there.

Changes

  • New config field code_blocks.block_on_wrap (enum, default "off").
  • Both fenced (markdown.code_block) and indented (markdown.indented_code_block) renderers honour it.
  • Updated help doc (doc/markview.nvim-markdown.txt) and type annotations (lua/markview/types/renderers/markdown.lua).

Notes

  • Default is "off", so existing setups are unaffected.
  • tab-containing code blocks still fall back to simple as before.

This started as a very opinionated change, which is why it stays behind an opt-in option. With "adaptive" it now does the block-vs-simple switch automatically based on whether the block would wrap, so the default "off" stays conservative while "adaptive" gives a consistent, glitch-free experience. Please LMK if this needs improvement or what's blocking. Cheers

@ImmanuelHaffner ImmanuelHaffner marked this pull request as draft July 7, 2026 13:23
The block renderer pads each line to block_width with inline virtual
text. With wrap enabled that trailing padding wraps onto the next screen
line, breaking the block, so the renderer falls back to the simple style.

Add a code_blocks.block_on_wrap option (default "off") to control this:

  - "off"      under wrap, always fall back to the simple style.
  - "on"       always keep the block style, even under wrap.
  - "adaptive" under wrap, keep block only when it fits the window;
               otherwise fall back to simple.

The adaptive check compares range.col_start + block_width against the
window's usable text width (win width minus getwininfo().textoff, which
already accounts for the sign, number and fold columns). Since
block_width includes the min_width/padding floor, padding-driven
overflow triggers the fallback too.

The per-line width scan is wrapped in a lazy, memoized compute_widths()
closure in both the fenced and indented renderers, so it runs at most
once per block and is reused by render_block() instead of recomputed.
Cheap disqualifiers short-circuit before any width computation, so the
scan only runs in the adaptive-under-wrap case.

Applies to both fenced and indented code blocks, and updates the docs
and type annotations accordingly.
@ImmanuelHaffner ImmanuelHaffner force-pushed the feat/code-blocks-block-on-wrap branch from efb3668 to 97ac2a6 Compare July 7, 2026 15:07
@ImmanuelHaffner ImmanuelHaffner changed the title feat(code_blocks): add block_on_wrap to keep block style under wrap feat(code_blocks): add block_on_wrap to control block style under wrap Jul 7, 2026
@ImmanuelHaffner

Copy link
Copy Markdown
Contributor Author

Updated with the adaptive solution

@ImmanuelHaffner ImmanuelHaffner marked this pull request as ready for review July 7, 2026 15:15
---@field min_width? integer Minimum width of the code block.
---@field pad_amount? integer Number of `pad_char`s to add on the left & right side of the code block.
---@field pad_char? string Character used as padding.
---@field block_on_wrap? "off" | "on" | "adaptive" Behaviour of the `block` style under `wrap`. `"off"`(default) uses `simple`; `"on"` always keeps `block`; `"adaptive"` keeps `block` only when it fits the window.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't really understand why we need literal strings as possible field values. Lua doesn't have enums, no point in trying to replicate it.

It's better to do it like older plugins using integer values instead.

Also fields with multiple possible values should span multiple lines,

---@field block_on_wrap?
---| 0 Default
---| 1 Enabled
---| 2 Automatic

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

---@field label_direction? "left" | "right"

is the precedent that justified this

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Did you actually look at the code or are you just copy pasting from your agent?

1 == "on", 0 == "off", that's used by other programs. People can understand that. I can't say 0 == "left", 1 == "right" since nobody uses that.

Using arbitrary strings as values tends to not stick in the long term. I would much rather not deal with spelling mistakes from end users or issues asking why only 2 values are getting checked etc.

local delim_extra = math.max(#item.text[1], #item.text[#item.text]);

return (item.range.col_start + width + delim_extra) > usable;
else

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be an else if as this is now "off" | "adaptive" | String instead of the original type.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

else if what condition? This is the fall through case of neither on nor adaptive

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since we are explicitly supporting 3 values, we should only match them. The project already has enough dead code that I can't refactor simply cause someone might be using the exception as the default behavior, I would much rather not have more code added to that pile.

if mode == "on" then
return false;
elseif mode == "adaptive" then
local wininfo = vim.fn.getwininfo(win)[1];

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getwininfo() is too performance intensive. It is not suitable for large buffers.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

First, it's in the adaptive mode and only opt-in.
Second, i use markview daily in 10k+ LOC buffers and it works well.

That said. what alternative do you suggest?

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is no alternatives(at the moment) which is why this option wasn't provided. You can't correctly get a line's final width without having performance cost.

Even if you are using getwininfo() once every code block you are still constantly calling the API. And it tends to not work on text outside of the visible window area, so it leaves edge cases.


Also, LOC isn't what you should be measuring, the complexity & depth of the AST is what you should be looking at when doing performance optimizations.

@OXY2DEV

OXY2DEV commented Jul 7, 2026

Copy link
Copy Markdown
Owner

I am still not getting the incentive to use this option.

@ImmanuelHaffner ImmanuelHaffner left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The purpose of this PR is to get the visually appealing block-style render of code blocks that you already implemented for nowrap inside a wrap buffer.

While on enforcement leads to visual glitches if the code block is too wide for the window, adaptive solves this by adaptively switching over to simple for code blocks that are too wide. No visual glitches.

local delim_extra = math.max(#item.text[1], #item.text[#item.text]);

return (item.range.col_start + width + delim_extra) > usable;
else

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

else if what condition? This is the fall through case of neither on nor adaptive

---@field min_width? integer Minimum width of the code block.
---@field pad_amount? integer Number of `pad_char`s to add on the left & right side of the code block.
---@field pad_char? string Character used as padding.
---@field block_on_wrap? "off" | "on" | "adaptive" Behaviour of the `block` style under `wrap`. `"off"`(default) uses `simple`; `"on"` always keeps `block`; `"adaptive"` keeps `block` only when it fits the window.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

---@field label_direction? "left" | "right"

is the precedent that justified this

if mode == "on" then
return false;
elseif mode == "adaptive" then
local wininfo = vim.fn.getwininfo(win)[1];

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

First, it's in the adaptive mode and only opt-in.
Second, i use markview daily in 10k+ LOC buffers and it works well.

That said. what alternative do you suggest?

@OXY2DEV

OXY2DEV commented Jul 7, 2026

Copy link
Copy Markdown
Owner

@ImmanuelHaffner You do realize that the right border of the code block bleeds onto the next line when using wrap, right?

Unless there's a way to fix that this option will only work sometimes. People have different window sizes, what works on your setup might not work on other's setup.

@ImmanuelHaffner

Copy link
Copy Markdown
Contributor Author

Per-call latency for vim.fn.getwininfo(win):

Measured over 1M invocations.

Statistic Value
Mean 4291.0 ns (~4.29 µs)
Stddev 6408.5 ns (149.3% of mean)
Median 3283.0 ns (~3.28 µs)
p99 25025.0 ns (~25.0 µs)
min 2544.0 ns
max 3,587,877 ns (~3.59 ms)

getwininfo() is too performance intensive. It is not suitable for large buffers.

Where do you get this from? The p99 is 25µs, that's unnoticeable for any human.

@ImmanuelHaffner

ImmanuelHaffner commented Jul 8, 2026

Copy link
Copy Markdown
Contributor Author

Related to

getwininfo() is too performance intensive. It is not suitable for large buffers.

I have created #521

@ImmanuelHaffner

ImmanuelHaffner commented Jul 8, 2026

Copy link
Copy Markdown
Contributor Author

You do realize that the right border of the code block bleeds onto the next line when using wrap, right?

I cannot repro that. I am resizing the window and witness the code block rendering switching adaptively from simple to block and back as i resize in 1 column at a time steps. I also tested with changing set signcolumn. None shows a visual glitch or bleed. Surely when the window is too narrow, lines wrap. But at that time code blocks are already rendered with simple and the whole code block renders nicely still.

@OXY2DEV

OXY2DEV commented Jul 8, 2026

Copy link
Copy Markdown
Owner

Per-call latency for vim.fn.getwininfo(win):

Measured over 1M invocations.
Statistic Value
Mean 4291.0 ns (~4.29 µs)
Stddev 6408.5 ns (149.3% of mean)
Median 3283.0 ns (~3.28 µs)
p99 25025.0 ns (~25.0 µs)
min 2544.0 ns
max 3,587,877 ns (~3.59 ms)

getwininfo() is too performance intensive. It is not suitable for large buffers.

Where do you get this from? The p99 is 25µs, that's unnoticeable for any human.

Yeah, I got it confused with a separate issue.

@OXY2DEV

OXY2DEV commented Jul 8, 2026

Copy link
Copy Markdown
Owner

You do realize that the right border of the code block bleeds onto the next line when using wrap, right?

I cannot repro that. I am resizing the window and witness the code block rendering switching adaptively from simple to block and back as i resize in 1 column at a time steps. I also tested with changing set signcolumn. None shows a visual glitch or bleed. Surely when the window is too narrow, lines wrap. But at that time code blocks are already rendered with simple and the whole code block renders nicely still.

Screenshot 2026-07-08 at 3 42 51 PM

The markdown block is being incorrectly rendered when using 80,81 columns terminal size.
The c block does sometimes render as simple even though it has enough space.

@OXY2DEV OXY2DEV closed this in 41ecde1 Jul 8, 2026
@OXY2DEV

OXY2DEV commented Jul 8, 2026

Copy link
Copy Markdown
Owner

The style option can now be a function,

    style = function (buf)
        if vim.o.wrap then
            return "simple";
        end

        local win = require("markview.utils").buf_getwin(buf);
        return vim.wo[win].wrap == true and "simple" or "block";
    end,

This should be much better than adding a new option as users can choose to add whatever logic they want instead of some fixed rules. So, I am closing this PR in favor of it.

@ImmanuelHaffner

Copy link
Copy Markdown
Contributor Author

The c block does sometimes render as simple even though it has enough space.

Yes, this is because it's hard to determine accurately when the window width would have been sufficient. This is a conservative fallback to simple.

The markdown block is being incorrectly rendered when using 80,81 columns terminal size.

Cannot repro, renders fine over all window widths and heights and different sign columns.

This should be much better than adding a new option as users can choose to add whatever logic they want instead of some fixed rules. So, I am closing this PR in favor of it.

Well, ok, but that's rather orthogonal to the adaptive solution that I am building.
Why do you keep closing all my PRs so eagerly without allowing for discussion and finding consensus?

@OXY2DEV

OXY2DEV commented Jul 8, 2026

Copy link
Copy Markdown
Owner

Why do you keep closing all my PRs so eagerly without allowing for discussion and finding consensus?

There a few reasons actually.

One of the things that I learned from open source is knowing when to say no. Not everything needs to be a part of the project.

At the end of the day, I have to maintain the project, not the ones who submit the PR. So, I have to pick what I can or can't maintain in the long run.

If I accept every single PR, soon the codebase will become alien to me. Which means that it would become hard for me to trace & fix bugs.


The wrap table PR didn't get merged as it wasn't working on my machine. It also had large changes on basically all parts of the workflow which is not only hard to review, it's also impossible for me to check for bugs caused by it.

The blockquote continuation PR was closed as it was trying to solve a problem that wasn't even there.

The hl_mode PR also couldn't be reproduced on my machine.

The visual_width PR wasn't solving the underlying issue simply due to technical limitations.


Every rejected PR has some reason behind it. And I am not sure how a discussion would've lead to a different outcome.

Also, AI PRs are a bit of a red flag cause unless someone re-checks I have no way to verify how good/correct it is. AI can find breaking bugs, what it can't do is figure out if a certain piece of code is causing issues with another piece of code. And whenever it makes some changes, it tends to also add unnecessary changes with them. All of that makes it harder for me to read through the entire thing.

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