btv.decor
btv.decor.provider(spec)
btv.decor.provider { name, bufs?, on_range }: register a viewport decoration
provider. on_range(ctx, publish) is called off the frame, once per visible-range
change of a matching window, with a snapshot
ctx = { win, buf, top, bot, lines, filetype, buftype, gen }
(top/bot are 0-based inclusive buffer rows; lines is exactly that slice;
gen is the viewport generation a publish carries back). bufs scopes the
provider: bufs.filetype = { "lua", "rust" } runs it only in those filetypes,
bufs.buftype = { "quickfix" } only in those buffer kinds, bufs.buf = id only in a
specific buffer (constraints AND together); omitted ⇒ every buffer (the engine skips
non-matching windows). The provider calls
publish(marks) with a list of marks shaped like an extmark: row/col (positional
{ row, col, ... } or named) plus any option btv.buf.set_extmark takes — they
are validated and lowered by the same code, so a provider draws the full decoration
vocabulary, not just highlights:
publish {
{ row, 0, end_col = 4, hl = "Comment" }, -- a highlight span
{ row, 0, sign_text = ">>", sign_hl_group = "DiffAdd" }, -- a gutter sign
{ row, 0, virt_text = { { " 3 days ago", "Comment" } } }, -- inline blame
{ row, 0, line_hl_group = "DiffChange" }, -- a line background
}
hl is the decor-native shorthand for hl_group. A mark that would draw nothing —
carrying no hl_group, virt_text, virt_lines, sign_text, line_hl_group,
line_fill or gutter_text — fails loud rather than silently painting nothing, and
so does a mark with an unknown key.
Defined in decor.lua.
btv.decor.invalidate(opts)
btv.decor.invalidate([opts]): tell the engine a provider has new content to draw,
and re-dispatch it. A provider is normally woken by the viewport signal — scroll,
resize, or an edit to the visible slice — so a change in the data it draws from
(git blame that came back off a promise, an LSP response, a palette or setting the
user just changed) would otherwise not repaint until the user happened to move: a
rainbow-bracket provider whose colours were swapped keeps painting the old ones
until you scroll past them. This is that missing edge — it marks the windows in
scope, and the next engine pass re-runs on_range there with a fresh snapshot and
a fresh generation token.
The scope (omitted ⇒ every visible window):
btv.decor.invalidate() -- every visible window
btv.decor.invalidate({ buf = bufnr }) -- every window showing that buffer (0 = current)
btv.decor.invalidate({ win = winid }) -- exactly that window (0 = current)
Pass buf for the usual case: a provider’s data is per-buffer, and the same buffer
can be open in several splits, each of which needs its own viewport re-run. Passing
both buf and win is an error (they are alternative scopes, not a conjunction).
Scoped to the window, not to the provider: every provider matching the window runs again, exactly as it would on a scroll. A publish still in flight from the run this supersedes is dropped by the generation check, so nothing is lost.
It is a HINT, not a repaint: like everything else a plugin hands the decoration
engine, the ask is optimistic and the engine decides when it is served. Repeated
asks for the same window coalesce, and each window is served at most ONCE per pass —
so a provider that asks to be re-run in response to its own run cannot spin the
editor, it just paces to the next pass. Nothing is dropped; an ask stays outstanding
until it is served. (Asking from inside your own on_range is still pointless work
rather than an error: you already hold the ctx, so publish what you want drawn
from the run you are in.)
The re-dispatch happens on the current pass, off the frame like every other dispatch — no redraw is forced and no Lua runs at frame time.
Defined in decor.lua.
btv.decor.expr(src)
btv.decor.expr(src): install a frame-time paint block over every visible
line, or clear it with nil. The pure sibling of btv.decor.provider.
src is a string of Lua source — a function body, not a value — because
the block runs in the bounded compute sandbox: a second, pure VM with a
wall-clock deadline, no editor state and no btv.*. A closure cannot cross
between VMs, so the source crosses instead and is compiled there. It is a body
rather than a single expression because a per-line paint loops over the matches
on the line, so it ends in its own return.
Two names are in scope, and the block returns a list of spans to highlight:
line the text of the line being painted
lnum its 1-based line number
Each span is { first, last, group } — 1-based inclusive columns, which is
what string.find hands back, so a match drops straight in:
btv.decor.expr([[
local out, i = {}, 1
while true do
local s, e = line:find("TODO", i, true)
if not s then break end
out[#out + 1] = { s, e, "Todo" }
i = e + 1
end
return out
]])
btv.decor.expr(nil) -- stop painting
Returning an empty list (or nil) declines the line. A malformed span fails
loud rather than silently not painting.
A span can draw more than a colour. Beside the three positional slots it takes named keys, each the scalar form of the extmark key it lowers into:
virt_text text drawn beside the line
virt_hl its highlight group
virt_pos where it draws: "eol" (the default), "inline", "overlay", "right_align"
hl_mode how it merges with the cell under it: "replace" (default), "combine", "blend"
virt_lines a whole extra screen row, or a list of them
virt_lines_hl their highlight group
virt_lines_above draw them above the line instead of below
sign_text a 1-2 cell glyph in the gutter
sign_hl its highlight group
line_hl a highlight group backing the whole line
line_fill a glyph repeated from the end of the line to the right edge
line_fill_hl its highlight group
priority where the span sits in the paint stack (default 4096)
-- a badge after the match, and a gutter mark on its line
btv.decor.expr([[
local s, e = line:find("FIXME", 1, true)
if not s then return {} end
return { { s, e, "Todo", virt_text = " <- fix me", virt_hl = "Comment",
sign_text = ">>", sign_hl = "DiagnosticWarn" } }
]])
-- a heading drawn above every `## ` line, and a rule out to the right edge
btv.decor.expr([[
if not line:find("^## ") then return {} end
return { { virt_lines = line:gsub("^## ", ""), virt_lines_hl = "Title",
virt_lines_above = true, line_fill = "-", line_fill_hl = "NonText" } }
]])
The columns and the group are each optional when the span carries a
decoration: a sign or a line background anchors on the line, not on a stretch
of it, so { sign_text = ">>" } is a whole span. What is refused is a span that
draws nothing — no group and no decoration — and a qualifier with nothing to
qualify (virt_hl without virt_text, sign_hl without sign_text), because
both are half-written spans that would otherwise vanish in silence.
priority belongs to the span rather than to its decoration, so a plain
highlight takes one too: below the treesitter layer (priority = 90) a paint
colours only what the syntax left plain, above the default it covers a
diagnostic’s underline.
Which one to use. btv.decor.provider is the general surface: full Lua,
async, any editor state, and the extmark vocabulary at full width (per-chunk
styling, which a span — one run per key — cannot express). It runs off the
frame, so its marks land on the next one. btv.decor.expr can only see the line
it was handed — and in exchange it runs during the frame, so a paint that is a
pure function of the text (indent guides, colour swatches, trailing whitespace, a
keyword badge) appears in the same frame as the edit or scroll, with no
round trip and no flicker.
It is evaluated over the visible rows of each window, memoized on the viewport, so a steady screen makes no calls at all. A block that errors, exceeds its deadline, or returns a malformed span reports once and is then uninstalled.
Defined in decor.lua.