Skip to main content

max / quasi

quasi-tui: draw a rich node's blocks, not only its emphasis A heading inside a rich node came through at the weight of the prose around it, which the last pass called a limit of the medium. It was not: docengine 0.7 says which block a run is in, and a cell can answer all of them. Six markdown heading levels onto the three layout::Heading has, because a cell has one size and only so much colour. Items take a bullet and quotes a margin marker, placed at the head of a line off the break the previous run ended with. The block decides the ground and the marks go on top, so a struck word in a heading is struck and a heading.
Author: Max Johnson <me@maxj.phd> · 2026-08-12 16:55 UTC
Signed with PGP, not checked
Commit: d3148109f7d1341de44e4aaea7e59b51d36fb57f
Parent: 45932b6
3 files changed, +116 insertions, -14 deletions
M Cargo.lock +9 -9
@@ -786,7 +786,7 @@
786 786
787 787 [[package]]
788 788 name = "docengine"
789 - version = "0.6.0"
789 + version = "0.7.0"
790 790 dependencies = [
791 791 "ammonia",
792 792 "pulldown-cmark",
@@ -5159,6 +5159,14 @@
5159 5159 source = "registry+https://github.com/rust-lang/crates.io-index"
5160 5160 checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
5161 5161
5162 + [[patch.unused]]
5163 + name = "synckit-client"
5164 + version = "0.8.0"
5165 +
5166 + [[patch.unused]]
5167 + name = "synckit-config"
5168 + version = "0.2.0"
5169 +
5162 5170 [[patch.unused]]
5163 5171 name = "kberg"
5164 5172 version = "0.1.0"
@@ -5170,11 +5178,3 @@
5170 5178 [[patch.unused]]
5171 5179 name = "tagtree"
5172 5180 version = "0.4.0"
5173 -
5174 - [[patch.unused]]
5175 - name = "synckit-client"
5176 - version = "0.8.0"
5177 -
5178 - [[patch.unused]]
5179 - name = "synckit-config"
5180 - version = "0.2.0"
@@ -349,16 +349,63 @@
349 349 Style::default().fg(tui.theme().content_primary)
350 350 }
351 351
352 - /// Markdown source as spans: the words, each under the marks that were over it.
352 + /// Markdown source as spans: the words, each under the marks that were over it
353 + /// and in the shape of the block it came from.
353 354 ///
354 355 /// `base` is what the prose takes where the source said nothing, so the same
355 356 /// function serves a rich node standing alone and one sitting inside a row's
356 357 /// run, where the part's role has already decided the colour.
357 358 fn rich_spans(tui: &Tui, source: &str, base: Style) -> Vec<Span<'static>> {
358 - docengine::render_runs(source)
359 - .into_iter()
360 - .map(|run| Span::styled(run.text, mark(tui, base, run.emphasis)))
361 - .collect()
359 + let mut spans = Vec::new();
360 + // A marker belongs at the head of a line and nowhere else, and a run knows
361 + // its block but not its position. The separator runs are what carry the
362 + // breaks, so the run before this one is what says whether a line just
363 + // started.
364 + let mut starting = true;
365 + for run in docengine::render_runs(source) {
366 + if starting && let Some(marker) = marker(run.block) {
367 + spans.push(Span::styled(
368 + marker,
369 + Style::default().fg(tui.theme().content_muted),
370 + ));
371 + }
372 + starting = run.text.ends_with('\n');
373 + let style = style_of(tui, base, &run);
374 + spans.push(Span::styled(run.text, style));
375 + }
376 + spans
377 + }
378 +
379 + /// What a block puts in front of its first line, where a webview would have used
380 + /// a bullet glyph or an indent.
381 + ///
382 + /// The description carries no marker of its own, deliberately: what a bullet
383 + /// looks like is the renderer's answer, and this is a terminal's.
384 + fn marker(block: docengine::Block) -> Option<&'static str> {
385 + match block {
386 + docengine::Block::Item => Some("- "),
387 + docengine::Block::Quote => Some("> "),
388 + docengine::Block::Prose | docengine::Block::Heading(_) => None,
389 + }
390 + }
391 +
392 + /// One run's block and marks as a style over `base`.
393 + ///
394 + /// The block decides the ground the run is drawn on and the marks are added to
395 + /// it, which is the order a stylesheet uses: a heading with `**bold**` inside it
396 + /// is bold on top of heading weight rather than instead of it.
397 + fn style_of(tui: &Tui, base: Style, run: &docengine::TextRun) -> Style {
398 + let ground = match run.block {
399 + // The three markdown levels a terminal can tell apart, which is as many
400 + // as `layout::Heading` has: a rich node's `######` and its `###` land in
401 + // the same place because a cell has one size and only so much colour.
402 + docengine::Block::Heading(1) => tui.heading(layout::Heading::Page),
403 + docengine::Block::Heading(2) => tui.heading(layout::Heading::Section),
404 + docengine::Block::Heading(_) => tui.heading(layout::Heading::Subsection),
405 + docengine::Block::Quote => Style::default().fg(tui.theme().content_secondary),
406 + docengine::Block::Prose | docengine::Block::Item => base,
407 + };
408 + mark(tui, ground, run.emphasis)
362 409 }
363 410
364 411 /// One run's marks as a style over `base`.
@@ -168,6 +168,61 @@
168 168 assert_eq!(buf[(16, 0)].bg, prose, "and the prose after it should");
169 169 }
170 170
171 + #[test]
172 + fn a_heading_inside_a_rich_node_is_drawn_heavier_than_the_prose_under_it() {
173 + // The gap this closed: `render_plain` handed over a heading's text at the
174 + // weight of everything around it, so a rich node's structure was gone by
175 + // the time a cell saw it. Weight is a thing a cell has.
176 + let buf = buffer(&Node::rich("# Title\n\nBody."), 40, 4);
177 + let out = rows(&buf);
178 + assert_eq!(out[0], "Title");
179 + assert_eq!(out[2], "Body.");
180 + assert_eq!(marked(&buf, 0, Modifier::BOLD), "Title");
181 + assert_eq!(marked(&buf, 2, Modifier::BOLD), "");
182 + }
183 +
184 + #[test]
185 + fn a_deep_heading_reads_as_the_shallowest_a_terminal_can_tell_apart() {
186 + // Six markdown levels onto the three `layout::Heading` has. A cell has one
187 + // size and only so much colour, so `###` and `######` land together rather
188 + // than inventing distinctions nothing can draw.
189 + let third = buffer(&Node::rich("### Third"), 40, 2);
190 + let sixth = buffer(&Node::rich("###### Sixth"), 40, 2);
191 + assert_eq!(third[(0, 0)].fg, sixth[(0, 0)].fg);
192 + // And not the same as the prose it sits above, or the level bought nothing.
193 + let prose = buffer(&Node::rich("Third"), 40, 2);
194 + assert_ne!(third[(0, 0)].fg, prose[(0, 0)].fg);
195 + }
196 +
197 + #[test]
198 + fn a_list_gets_the_bullet_the_description_refused_to_carry() {
199 + // docengine says "this run is an item" and stops there, because what a
200 + // bullet looks like is the renderer's answer. This is a terminal's.
201 + let out = drawn(&Node::rich("- one\n- two"), 40, 4);
202 + assert_eq!(out[0], "- one");
203 + assert_eq!(out[1], "- two");
204 + }
205 +
206 + #[test]
207 + fn a_quote_is_marked_in_the_margin_and_only_on_its_first_line() {
208 + // A marker belongs at the head of a line and nowhere else. A run knows its
209 + // block but not its position, so the marker is placed off the break the
210 + // previous run ended with.
211 + let out = drawn(&Node::rich("> quoted\n\nafter"), 40, 4);
212 + assert_eq!(out[0], "> quoted");
213 + assert_eq!(out[2], "after");
214 + }
215 +
216 + #[test]
217 + fn emphasis_inside_a_heading_is_added_to_its_weight_rather_than_swapped_for_it() {
218 + // The order a stylesheet uses: the block decides the ground and the marks
219 + // go on top. A struck word in a heading is struck AND a heading.
220 + let buf = buffer(&Node::rich("## Ship ~~later~~"), 40, 2);
221 + assert_eq!(rows(&buf)[0], "Ship later");
222 + assert_eq!(marked(&buf, 0, Modifier::BOLD), "Ship later");
223 + assert_eq!(marked(&buf, 0, Modifier::CROSSED_OUT), "later");
224 + }
225 +
171 226 #[test]
172 227 fn a_rich_block_keeps_the_breaks_the_author_wrote() {
173 228 // The reason a rich node cannot go through `draw_line`: that one wraps a