Concealment

Status: design fragment (2026-08-29). Slice plan: ../operations/slice-plans/archive/conceal-and-org-links.md.

Anchors: display-line.md (the DisplayLine substrate this extends), cell-grid-renderer.md (the two renderer peers), design.md §5.6 (rich text — this closes the carve-out named there), org-mode.md §7 (the first consumer), paramount goal #1.

Markup that exists to be parsed does not have to be shown. An org link reads [[id:6F398E54-7E63-4492-9EB6-89C8A90E7DD3][Project Kickoff Checklist]] on disk and should read Project Kickoff Checklist on screen. Concealment is the display-time elision that makes that true without touching the file.

Why this exists

design.md:3414 named it and deferred it:

Carved out and kept for v1: concealment — hiding **, [](), heading # markers on lines the cursor is not on. It is what actually makes markdown editing feel rich in Vim, Emacs and Helix alike, it costs no shaped path, and it works identically on both peers. Not yet implemented (no conceal support exists in the tree).

Every reference editor has it — vim's conceallevel, emacs' invisible-text overlays, Helix and Zed's markdown rendering — and what the user carries between them is visual, not grammatical. That puts this under the UX-follows- convention rule: the convention is that markup collapses and the described text remains, and there is nothing to arbitrate about the outcome. What is not settled by convention, and is the whole of this design, is who decides which bytes are concealed and where the elision is applied.

There is a second, sharper reason it can no longer wait. Help buffers already render links, and they do it by stripping [label](url) down to label when the buffer is built, keeping byte ranges as metadata (lattice-help/src/lib.rs:196). That works because a help buffer is synthetic and read-only. It cannot be the answer for a file the user edits: you may not delete text from someone's document to make it look nicer. Once any editable buffer wants rendered links — which org does — display-time elision is the only honest mechanism left.

What conceal is, precisely

source        * See [[id:6F398E54-…][Project Kickoff Checklist]] before Friday.
display       * See Project Kickoff Checklist before Friday.
                    └────────────┬───────────┘
                  the description survives; the target and the
                  brackets around it occupy no display columns

Two properties fall out of the picture, and most of the design is arranging for them:

  1. The display line is shorter than the source line. Every mapping from a source byte to a screen column — cursor, search highlight, selection, decorations, marginalia — must agree about that, or they disagree with each other and the user sees a caret one place and a highlight another.
  2. Which bytes are hidden is a property of the line's text, not of the cursor and not of the parse tree. That is what makes it cheap, and §"Rejected alternatives" is mostly about why the other two candidates are not.

Where the elision is applied

DisplayLine.text is already the final display string — inlays spliced, tabs expanded, whitespace markers substituted (display_matrix.rs:75) — and both renderers are already thin mappers over it. So conceal bakes into text.

This is not a convenience. lattice-ui-tui and lattice-ui-gpui are peers, and the standing cross-renderer rule exists because parity maintained by discipline decays. A renderer-side "skip these columns" would be that discipline: two implementations of the same elision, drifting on the next feature that touches either. Baking into the substrate makes parity structural — a renderer that does nothing new is already correct.

Data model

A conceals list on DisplayLine. Arc<[(u32, u32)]>, sorted and non-overlapping source-byte ranges, each hiding [start, end). The builder coalesces before storing: two overlapping ranges would have their shared width subtracted twice, and every column past them on that line would be wrong.

col_map is unchanged, and an earlier revision of this document was wrong about that. It specified widening col_map to (u32, i32) so inlay insertions and conceal elisions would be "one table, not two". The rationale did not survive contact with the code, and the correction is recorded here rather than left to be re-derived:

  • build_display_row's column counter is documented as "display columns emitted so far (== char count == cell count)", and byte_to_combined_col's own note says callers hand it an already-char-resolved byte. In that space a hidden range removes exactly end - start columns — the width is derivable from the range, so conceal needs no width table at all.
  • conceals has to exist regardless, for the clamp. So the choice was never "one table or two"; it was "two tables, or two tables and a signed rewrite of the first". The two encode different quantities and the removal is derived from the ranges, so they cannot disagree — which was the whole worry.
  • The signed form would additionally have widened col_map through cells_worker, CellRow, cells_paint and ten GPU call sites, for a slice that changes no behaviour.

The only thing signed would have bought is cchar-style replacement expressed as a net delta, and that is deferred below — and would want (start, end, replacement_width) rather than a sign anyway.

One shared function, in lattice-cells. source_byte_to_display_col(byte, inlay_offsets, conceals) (coords.rs). Three carriers hold these tables — CellRow, DisplayLine, and the GPU peer's per-row arrays — and before conceal each had its own copy of a four-line loop, which was survivable with one term in the sum. With two it is not: an elision the cursor agrees with and the search highlight does not is a caret sitting off its own match, in whichever copy was not updated.

The clamp then falls out of the arithmetic instead of being a special case. Subtracting only the hidden width lying strictly before byte yields, for a byte inside a range, exactly that range's start column:

col = byte
    + Σ extra_cols  for every inlay breakpoint at or before byte
    − Σ (min(end, byte) − start)  for every conceal range starting before byte

A conceal axis on MatrixVersion. Painting-class, not rebuild-class — the distinction indent's doc comment already draws (version.rs:57): axes that change display text gate painting, so a version mismatch drops the viewport to raw text for one frame. That is the correct degradation here and it is the same frame the mode transition was going to repaint anyway.

Styling what survives (OL.1)

A rule may carry a slot — a capture or theme-element name — and what it paints is the visible remainder of each match.

That unit is the whole design. Picking it rather than a named capture group is what lets one declaration serve both org link forms:

[[target][description]]   hides `[[target][` and `]]`  → styles `description`
[[target]]                hides `[[` and `]]`          → styles `target`

Semantically: what is left on screen after concealing is the link. So the mechanism that decides where a link is also decides what to paint. Two mechanisms could disagree about that; one cannot — and the disagreement is not hypothetical, since a style landing on hidden bytes looks correct in a span dump and paints nothing at all. conceal_style_spans and conceal_spans partition the match, and a test asserts exactly that.

Per rule, never per language. Conceal is general and most rules hide punctuation that means nothing alone; only a rule whose remainder is a thing declares a style. Adding a conceal rule for something else therefore cannot accidentally paint it.

Resolved at registration, through name_to_style_with_theme — the same path a highlights.scm capture name takes. So a concealed link is coloured by the same vocabulary as everything else and follows a :colorscheme swap exactly as a heading does. A name that resolves to nothing conceals without painting: an unregistered theme element is a normal transient state, not a reason to lose the elision.

A conceal style beats the grammar's at paint time. It is the more specific claim — the rule matched a whole concrete construct, where a grammar capture may span the line. A headline's @text.title.N covers the entire item, so without the override a link inside a headline would paint as heading text and stay invisible as a link. Emacs shows org-link in headlines too.

Because they cannot be. The pinned tree-sitter-org (219c0b2) has no link node — links are undifferentiated expr tokens, the same situation agenda.rs records for inline timestamps. A highlights.scm rule naming an absent node does not merely fail to match: it fails to compile, and a failed query compile fails the whole language registration, so .org stops resolving entirely. That was tried and reverted. A newer upstream grammar does model link / link_desc; bumping to it is the alternative if the styling ever needs to be finer-grained than "the visible remainder".

Cursor and coordinates

Motion semantics are deliberately unchanged. l across a concealed link walks the source bytes; the caret sits at the link's start column until it leaves the concealed range, then resumes. This is vim's behaviour under conceallevel, so it is what the muscle memory expects.

The alternative — teaching motions to skip concealed bytes — would put display state into the grammar. Motions are the public command API (paramount #3), they are shared with operators, counts, macros and plugins, and making them depend on what is currently visible would mean dw recorded in a macro replays differently under a different conceallevel. The narrow win is not worth that.

Declaring the rules

The rules are contributed, not hardcoded. language.wit's spec grows:

/// One display-time elision rule. The host compiles the pattern once at
/// language registration and matches it against each display line during a
/// matrix rebuild.
record conceal-rule {
    /// Regex matched against a single line. Anchoring is the rule's
    /// business; the host adds none.
    pattern: string,
    /// 1-based capture-group indices whose spans are hidden. Group 0 (the
    /// whole match) is rejected at registration — a rule that hides its
    /// entire match is a deletion, not a concealment, and is almost
    /// always a mistake in the pattern.
    hide: list<u32>,
}

Org's two rules, which are the whole of its link rendering:

(\[\[[^]]+\]\[)[^]]+(\]\])     hide [1, 2]   described link
(\[\[)([^]]+)(\]\])            hide [1, 3]   bare link — target stays visible

The bare-link rule leaves the target on screen on purpose: emacs does the same, and hiding a link whose only text is its target would leave nothing to click.

The host learns "hide these capture groups of this pattern". It does not learn what an org link is — paramount #2 holds by construction, and markdown's **, __ and [text](url) are the same shape with no further host work.

Reveal is one line, not the buffer (CL.1)

Reveal is scoped to the cursor's line. Vim's concealcursor does the same, for the same reason: you reveal in order to edit what is under the cursor, and the other links on screen have no reason to turn back into [[id:…][…]].

H.4 originally expressed reveal as an empty rule set for the whole buffer. That made the version axis and the built rows agree by construction — a real property, on the wrong unit.

Why this is not simply a flag. Concealed bytes are omitted at build time — build_display_row emits them "nowhere and in no display column" — so DisplayLine.text does not contain them and revealing a line means rebuilding that line. The cursor moves on every keystroke, so folding the reveal line into MatrixVersion would cost:

cursor move, today47 ns (cells_worker_cache_hit)
cursor move, if versioned~1.5 ms (window rebuild) — ≈45 ms/s under a held key

So the reveal line rides beside the version, not in it:

  • DisplayMatrix::reveal_line records what the matrix was built with;
  • the cache-hit gate compares it, so a moved reveal is not a false hit;
  • try_incremental_reveal_build rebuilds only the two rows whose conceal state changed — the line left and the line arrived at — Arc-reusing every other row and every untouched chunk.

This inverts H.4's rule that revealing moves the conceal axis, and the inversion is the point: the axis is the cache-hit key, and a key that moves with the cursor is not a key. A buffer whose language declares no rules short-circuits — the rows would rebuild byte-identical — but still records the move, because otherwise the gate re-fires every tick.

The trigger remains Insert/Replace on the active pane. Vim's default concealcursor= also reveals the cursor line in Normal mode; that is a separate choice and has not been made.

Scoping and invalidation

The concealed set is a function of (the language's rules, the modal state).

Modal stateLinks render
Insert, Replaceraw — the source text, exactly as on disk
Normal, Visual, Select, Operator-pending, Command, Search, Promptconcealed

Insert reveals buffer-wide, not just the cursor's line. This was chosen explicitly over the vim concealcursor default, and the cost is real and worth stating rather than burying: entering Insert repaints every visible line that carries a concealed range, so i is a viewport-wide visual event rather than a one-line one.

Two things make it acceptable. It is caused — the user pressed i, and the UX contract's target is unrequested pixel change, not requested mode change. And it is precedented: :set list already repaints the viewport on a whitespace- marker toggle through the same class of axis. The mental model it buys — Normal is the reading view, Insert is the editing view — is worth one repaint at a mode boundary that is already a visual event.

SourceEffect
Editmatrix rebuild → conceal recomputed over the covered lines
Modal state crossing the insert/non-insert boundaryconceal axis bump → viewport rebuild
Language registration / plugin teardownrules recompiled; axis bump
Buffer whose language declares no rulesnothing at all — no bump, no match, no cost

That last row is load-bearing. Mode changes happen constantly and in every buffer; a conceal axis that bumped globally would put a viewport rebuild on every i in every Rust file in the editor. The axis is gated on the buffer's language having compiled rules, so a buffer with none never enters the path.

Per-frame renderer work

None. Conceal is resolved during the matrix rebuild, which is O(viewport lines) and off the UI thread. Both renderers read text + runs exactly as they do today.

The matching cost is one regex pass per rebuilt display line per rule. Org has two rules; a 200-column line against a linear-time engine is nanoseconds, and it happens on rebuild, never per frame. The bench (heuristic #5) records rebuild time for a viewport of link-dense org against the same viewport with rules disabled, so the axis's cost is visible in benchmarks.md rather than asserted here.

Failure behaviour

  • A pattern that does not compile is logged at warn once at registration and skipped; the language's other rules still apply. A plugin does not lose its whole language over one bad regex.
  • A hide index naming a group the pattern does not have is skipped the same way, at registration — not per line, where it would log at rebuild rate.
  • hide: [0] is refused at registration with the reason, per the WIT comment above.
  • Rules are capped per language (a small fixed bound) and each pattern is length-bounded. Not because the engine backtracks — it does not — but because an unbounded rule list turns a rebuild into a linear scan of someone else's configuration.
  • Overlapping matches: the union of concealed ranges is taken, sorted and coalesced before the line is built. Two rules hiding overlapping spans produce one hidden span, never a double-elision that would corrupt the column map.
  • A stale or absent matrix already drops the renderers to plain rope text. Links are raw for that frame and concealed on the next publish — the correct degradation, and invisible beside the fallback already happening.

Rejected alternatives

A tree-sitter @conceal capture. This is how Helix and Neovim do it, and it is the option to beat. It fails here twice. First, concretely: tree-sitter-org does not model links at all — there is no link rule in its grammar, and [[id:X][Title]] is undifferentiated expr tokens inside item or paragraph. There is nothing to capture. Second, and the reason this would stay wrong even if the grammar were extended: the tree is absent during a reparse, so tree-driven conceal would flicker between concealed and raw as the user types. That is a pixel change to content the user did not edit, which is a standing veto. links.rs already recorded the same reasoning for not reading the tree when it finds links.

A plugin conceal seam. A guest returning ranges for a line range would be more expressive — arbitrary logic could decide what to hide. It costs a WASM crossing per viewport rebuild (~25 µs, tolerable) and a new seam (real surface), and buys expressiveness nothing on the roadmap wants: every conceal rule in sight, in org and in markdown, is a function of the line's own text. Heuristic #1 cuts both ways and cuts against this one: the bigger mechanism is not the better design when no consumer needs the extra power.

Renderer-side elision. Cheapest to write, and it makes TUI/GPUI parity a matter of discipline rather than structure. Rejected under the standing cross-renderer rule for exactly that reason.

Stripping at buffer build, as help does. Correct for synthetic read-only buffers and unavailable here: the buffer is the user's file.

Paramount-goal alignment

#1 Performance. No per-frame work and no new UI-thread work. The added cost is a bounded regex pass during an off-thread rebuild that was already happening, gated off entirely for languages with no rules. Benched by name.

#2 Extensibility. The host gains a rule evaluator, not a rule. Org contributes org's patterns through the existing language seam; markdown will contribute markdown's; neither is named in the host.

#3 Vim modal editing. Motions are untouched, deliberately — see §Cursor and coordinates. The mode-scoped reveal is itself modal-editing-shaped: Normal reads, Insert edits.

#4 Asynchronicity. Conceal resolves inside the existing off-thread matrix build and publishes with it. No new wake, no new staleness axis beyond the version stamp the matrix already carries.

Deferred

  • Replacement characters (vim's cchar). Hiding a span and substituting one glyph — a markdown horizontal rule as , a checkbox as . conceal-rule would grow a parallel replace-with list. Not built because every rule in the first two consumers is pure elision, and the WIT is regenerated from lattice-wit (WT.2), so adding the field later reaches guests by regeneration rather than by hand.
  • Per-rule reveal policy. Today the insert/non-insert split is global. A rule that wants different behaviour — always concealed, or revealed on the cursor line only — would carry its own policy. No consumer has asked.
  • Soft wrap. wrap_width is 0 today (display-line.md). A concealed range spanning a wrap point is a question that cannot be answered before wrapping exists.
  • Conceal in the gutter and in virtual rows. Both read the display line through the same helpers, so they follow automatically for elision; a replacement glyph with a different width would need re-checking.