TUI layout walker over a transient parsed Markdown tree (com.blockether.vis.internal.render/->ast).
Pure data: IR -> vector of lines. Each line is a vector of styled runs. The screen layer turns runs into ANSI / Lanterna cells.
Why a dedicated walker (and not ir/render :plain):
Canonical-IR invariants we rely on (see internal.render docstring):
:span / raw bodies of :code / :c / :kbd;:span;[:br {}];Run shape: {:text String ; never empty, never contains '\n' :style #{:bold :italic :code :dim :link :heading :marker :quote} :href String? ; present iff :link in style :node any?} ; opaque node identity for click/select
Public API:
(ast->lines ir width) ; total walker
(ast->lines ir width opts)
Opts:
:heading-prefix? bool ; unused: H1/H2 get an underline rule and
; H3-H6 a gutter mark (see heading->lines)
:code-fence? bool ; render ``` lines around code blocks (default false)
:max-lines int ; hard cap (default unlimited)
TUI layout walker over a transient parsed Markdown tree (`com.blockether.vis.internal.render/->ast`).
Pure data: IR -> vector of lines. Each line is a vector of styled
runs. The screen layer turns runs into ANSI / Lanterna cells.
Why a dedicated walker (and not `ir/render :plain`):
- We need styling metadata per character run (bold / italic / inline
code background / link target) so the TUI can paint colours and
attach click + selection regions.
- We need word-wrap at an arbitrary terminal width with hanging
indent inside lists and quotes.
- We need stable per-node identifiers so selection / hover / click
can map a glyph back to its IR node.
Canonical-IR invariants we rely on (see internal.render docstring):
- text only inside `:span` / raw bodies of `:code` / `:c` / `:kbd`;
- no '\n' inside `:span`;
- hard breaks are explicit `[:br {}]`;
- attrs map present on every vector node.
Run shape:
{:text String ; never empty, never contains '\n'
:style #{:bold :italic :code :dim :link :heading :marker :quote}
:href String? ; present iff :link in style
:node any?} ; opaque node identity for click/select
Public API:
(ast->lines ir width) ; total walker
(ast->lines ir width opts)
Opts:
:heading-prefix? bool ; unused: H1/H2 get an underline rule and
; H3-H6 a gutter mark (see `heading->lines`)
:code-fence? bool ; render ``` lines around code blocks (default false)
:max-lines int ; hard cap (default unlimited)(ast->entries ir width)(ast->entries ir width opts)Drop-in replacement for the legacy render/markdown->entries.
Returns a vector of {:line :meta} maps where :line is the
sentinel-prefixed string the bubble painter consumes, and :meta
is per-line click-region metadata. nil :meta for ordinary content
lines.
:mode selects the marker set:
:answer (default) — answer-zone PUA chars (answer-bg paint)
:thinking — thinking-zone PUA chars (iter-header-bg + italic)
:channel — in-place channel/tool IR; plain paragraphs use no
background marker, structural rows keep explicit styling
:justify? full-justifies the prose: every line the wrapper broke on
overflow (:wrap?) is stretched flush to both margins, while paragraph- and
block-terminal lines stay ragged-right (stretching a four-word last line is
the mega-hole bug) and code lines are never touched — their columns ARE the
content.
This is the IR-side analogue of markdown->entries. Every
bubble rendering path that used to parse the rendered markdown
back into entries should call this directly on the canonical IR
— no markdown round-trip.
Drop-in replacement for the legacy `render/markdown->entries`.
Returns a vector of `{:line :meta}` maps where `:line` is the
sentinel-prefixed string the bubble painter consumes, and `:meta`
is per-line click-region metadata. `nil` `:meta` for ordinary content
lines.
`:mode` selects the marker set:
`:answer` (default) — answer-zone PUA chars (answer-bg paint)
`:thinking` — thinking-zone PUA chars (iter-header-bg + italic)
`:channel` — in-place channel/tool IR; plain paragraphs use no
background marker, structural rows keep explicit styling
`:justify?` full-justifies the prose: every line the wrapper broke on
overflow (`:wrap?`) is stretched flush to both margins, while paragraph- and
block-terminal lines stay ragged-right (stretching a four-word last line is
the mega-hole bug) and code lines are never touched — their columns ARE the
content.
This is the IR-side analogue of `markdown->entries`. Every
bubble rendering path that used to parse the rendered markdown
back into entries should call this directly on the canonical IR
— no markdown round-trip.(ast->inline-sentinel-string ir)Flatten a canonical IR into a single sentinel-wrapped inline string
suitable for chrome-row labels. NO block markers — just inline
sentinels (INLINE_BOLD_ON/OFF, INLINE_CODE_ON/OFF, etc.) wrapping
styled runs. Hard breaks [:br] become spaces (single line
contract). Used by chrome labels and other surfaces that previously
ran through markdown->inline.
Flatten a canonical IR into a single sentinel-wrapped inline string suitable for chrome-row labels. NO block markers — just inline sentinels (`INLINE_BOLD_ON/OFF`, `INLINE_CODE_ON/OFF`, etc.) wrapping styled runs. Hard breaks `[:br]` become spaces (single line contract). Used by chrome labels and other surfaces that previously ran through `markdown->inline`.
(ast->lines input width)(ast->lines input width opts)Walk canonical IR (or any input that ir/->ast accepts) at a given
terminal width and return a vector of styled lines.
Walk canonical IR (or any input that `ir/->ast` accepts) at a given terminal `width` and return a vector of styled lines.
(ast->lines-tail input width tail-n)(ast->lines-tail input width tail-n opts)Render only the last tail-n styled lines of the IR.
Walks the top-level blocks BACKWARD, accumulating a cheap
per-block line estimate (text-chars / content-width), until
accumulated estimate ≥ tail-n * 2 (slack covers blank-collapse
ast->lines and (take-last tail-n).Per-frame cost = O(visible-tail), independent of total body
length. The semantically-correct path for an auto-scrolled
tail-pinned bubble (vs the broken :max-lines cap which would
give the FIRST tail-n).
tail-n must be positive. If the body has fewer total estimated
lines than the budget, this falls back to a full walk.
Render only the last `tail-n` styled lines of the IR. Walks the top-level blocks BACKWARD, accumulating a cheap per-block line estimate (text-chars / content-width), until accumulated estimate ≥ `tail-n * 2` (slack covers blank-collapse + per-block trailing blanks). Then renders ONLY those tail blocks via the normal `ast->lines` and `(take-last tail-n)`. Per-frame cost = O(visible-tail), independent of total body length. The semantically-correct path for an auto-scrolled tail-pinned bubble (vs the broken `:max-lines` cap which would give the FIRST tail-n). `tail-n` must be positive. If the body has fewer total estimated lines than the budget, this falls back to a full walk.
(ast->lines-window input width start num)(ast->lines-window input width start num opts)Render only rows [start, start+num) of the IR.
Internally walks blocks forward with :max-lines (start + num + slack) so the walker short-circuits via the A1 blocks->lines
path - work is O(start + num), not O(body). Output is
bit-identical to (subvec (ast->lines ir w opts) start (+ start num)) when both are in range; result is clamped if the body has
fewer lines.
Use cases:
start = 0: equivalent to (:max-lines num) head-cap.start > 0: scrolled into a long bubble; only render the
window. Note: skipping is still done by the walker producing
start lines then discarding them - true zero-cost prefix
skip would require per-block estimate-based skipping which
this fn does not do.num must be positive. start must be non-negative.
Render only rows `[start, start+num)` of the IR.
Internally walks blocks forward with `:max-lines (start + num +
slack)` so the walker short-circuits via the A1 `blocks->lines`
path - work is O(start + num), not O(body). Output is
bit-identical to `(subvec (ast->lines ir w opts) start (+ start
num))` when both are in range; result is clamped if the body has
fewer lines.
Use cases:
- `start = 0`: equivalent to `(:max-lines num)` head-cap.
- `start > 0`: scrolled into a long bubble; only render the
window. Note: skipping is still done by the walker producing
`start` lines then discarding them - true zero-cost prefix
skip would require per-block estimate-based skipping which
this fn does not do.
`num` must be positive. `start` must be non-negative.(ast->sentinel-strings ir width)(ast->sentinel-strings ir width opts)One-shot helper: canonical IR → vector of sentinel-prefixed strings
ready for the existing bubble painter. Composes ast->lines with
the sentinel adapter. :mode (:answer, :thinking, or :channel)
selects the marker set.
One-shot helper: canonical IR → vector of sentinel-prefixed strings ready for the existing bubble painter. Composes `ast->lines` with the sentinel adapter. `:mode` (`:answer`, `:thinking`, or `:channel`) selects the marker set.
(justify-line-runs runs width)Full-justify ONE soft-wrapped walker line's runs to width display columns
by widening the inter-word whitespace INSIDE its text runs. The styled twin
of primitives/justify-line, run on the IR before the runs are flattened to
inline sentinels (so PUA style markers can never be counted as text).
The gap arithmetic is NOT re-implemented here. The line's content text is
handed to lanterna's TerminalTextUtils/justifyLine (through
p/justify-line) and the widened gaps it hands back are copied onto the runs
in order, so the TUI justifies on the SAME grapheme/EAW-aware column engine
that measured and wrapped the line — not on a second, subtly different one.
Only CONTENT gaps stretch: leading structural runs — a list :marker
(-, •, 1.) or the pure-whitespace hanging indent of a continuation
row — keep their exact width, else justification blows a hole right after
the bullet (- foo). So lanterna is only ever shown the content slice,
justified to width MINUS that prefix.
The runs come back untouched when there is nothing to stretch (no gap, or no
slack because an over-wide atom was force-fit past width), when the line is
not ALREADY near-full (at slack >= gap-count every single gap would grow by
at least a column, which is the river, not justification — lanterna itself
has no such cap, that policy is ours), and whenever the justified text is not
word-for-word the text we sent.
This is the ONE justifier in the TUI: components routes both its plain and
its styled rows through it (a plain string is a single run), so no surface
keeps a second copy of the gap arithmetic.
Full-justify ONE soft-wrapped walker line's `runs` to `width` display columns by widening the inter-word whitespace INSIDE its text runs. The styled twin of `primitives/justify-line`, run on the IR before the runs are flattened to inline sentinels (so PUA style markers can never be counted as text). The gap arithmetic is NOT re-implemented here. The line's content text is handed to lanterna's `TerminalTextUtils/justifyLine` (through `p/justify-line`) and the widened gaps it hands back are copied onto the runs in order, so the TUI justifies on the SAME grapheme/EAW-aware column engine that measured and wrapped the line — not on a second, subtly different one. Only CONTENT gaps stretch: leading structural runs — a list `:marker` (`- `, `• `, `1. `) or the pure-whitespace hanging indent of a continuation row — keep their exact width, else justification blows a hole right after the bullet (`- foo`). So lanterna is only ever shown the content slice, justified to `width` MINUS that prefix. The runs come back untouched when there is nothing to stretch (no gap, or no slack because an over-wide atom was force-fit past `width`), when the line is not ALREADY near-full (at `slack >= gap-count` every single gap would grow by at least a column, which is the river, not justification — lanterna itself has no such cap, that policy is ours), and whenever the justified text is not word-for-word the text we sent. This is the ONE justifier in the TUI: `components` routes both its plain and its styled rows through it (a plain string is a single run), so no surface keeps a second copy of the gap arithmetic.
(lines->plain lines)Concatenate the text of every run in lines. Useful for tests +
clipboard fallback (preferred clipboard path: ir/render :markdown).
Concatenate the text of every run in `lines`. Useful for tests + clipboard fallback (preferred clipboard path: `ir/render :markdown`).
(lines->sentinel-strings lines)(lines->sentinel-strings lines opts)Convert walker output (vector of {:runs :block-tag :block-level?}
maps) into the painter's sentinel-prefixed string contract. Each
line: <block-marker><inline-sentinel-wrapped body>. :mode
selects the marker set (:answer default, :thinking, or :channel).
Convert walker output (vector of `{:runs :block-tag :block-level?}`
maps) into the painter's sentinel-prefixed string contract. Each
line: `<block-marker><inline-sentinel-wrapped body>`. `:mode`
selects the marker set (`:answer` default, `:thinking`, or `:channel`).cljdoc builds & hosts documentation for Clojure/Script libraries
| Ctrl+k | Jump to recent docs |
| ← | Move to previous article |
| → | Move to next article |
| Ctrl+/ | Jump to the search field |