Skip to main content

max / quasi

quasi-tui: draw a rich node's emphasis instead of flattening it Node::Rich went through render_plain, so a webview drew **ship it** bold and a terminal drew the words. Bold is a thing a cell can be, so that was a gap in the shared markdown crate rather than a limit of the terminal; docengine 0.6 grew render_runs and this reads it. Strong, italic and struck take the modifier a terminal already has. Code has no modifier to take -- every cell is monospace -- and takes the sunken surface instead. The wrap is now one implementation rather than three. draw_line had its own copy, which drew the space before a struck word struck: it took the separator's style from the word after it instead of from the whitespace it replaced. Invisible while every run was one style, visible the moment a rich node put marks inside a row.
Author: Max Johnson <me@maxj.phd> · 2026-08-12 16:39 UTC
Signed with PGP, not checked
Commit: 45932b6297b611b26efd53ce5852d95752134b2d
Parent: e150b4e
5 files changed, +289 insertions, -103 deletions
M Cargo.lock +1 -1
@@ -786,7 +786,7 @@
786 786
787 787 [[package]]
788 788 name = "docengine"
789 - version = "0.5.0"
789 + version = "0.6.0"
790 790 dependencies = [
791 791 "ammonia",
792 792 "pulldown-cmark",
@@ -23,8 +23,10 @@
23 23 makeover-tui = { version = "0.15.0", features = ["theme"] }
24 24 ratatui = { version = "0.30", default-features = false }
25 25 # `Node::Rich` carries markdown source. A terminal has no markup to hand it to,
26 - # so it takes the text: `render_plain` is docengine's own answer for exactly
27 - # this, and it is the third preset the same node already has two of.
26 + # so it takes the runs: `render_runs` is the words with the marks that were over
27 + # them still attached, which is what a cell can paint and a string could not
28 + # carry. Grown in docengine for this caller, so the markdown parsing stays in
29 + # the one crate that exists to do it.
28 30 docengine = { git = "https://makenot.work/git/max/docengine.git" }
29 31
30 32 [dev-dependencies]
@@ -20,7 +20,9 @@
20 20 Node::Heading { text: content, .. } | Node::Text { text: content, .. } => {
21 21 text::height(content, width)
22 22 }
23 - Node::Rich { source } => text::height(&docengine::render_plain(source), width),
23 + Node::Rich { source } => {
24 + text::spans_height(&rich_spans(tui, source, rich_base(tui)), width)
25 + }
24 26 Node::Act(act) => text::line_height(&act_line(tui, act), width),
25 27 Node::Link { text: label, .. } => text::height(label, width),
26 28 Node::Token(tag) => text::line_height(&Line::from(tag_span(tui, tag)), width),
@@ -75,17 +77,17 @@
75 77 } => text::draw(content, tui.tone(*tone), area, buf),
76 78
77 79 // Markdown source, and a terminal has no markup to hand it to. It takes
78 - // the text, which is docengine's own answer for this and the third
79 - // preset the same node already has two of. What is lost is the
80 - // emphasis: a webview draws `**ship it**` bold and this draws the words.
81 - // Bold is a thing a cell can be, so this is a gap in the shared
82 - // markdown crate rather than a limit of the terminal, and it is filed.
83 - Node::Rich { source } => text::draw(
84 - &docengine::render_plain(source),
85 - Style::default().fg(tui.theme().content_primary),
86 - area,
87 - buf,
88 - ),
80 + // the runs: the words, each still carrying the marks that were over it,
81 + // which is the answer docengine grew for exactly this caller. A webview
82 + // draws `**ship it**` bold and so does this.
83 + //
84 + // What is still lost is block structure. A heading inside a rich node
85 + // comes through as its text at the weight of the prose around it,
86 + // because `render_runs` carries inline marks and nothing else, and a
87 + // terminal has no second type size to spend on the difference anyway.
88 + Node::Rich { source } => {
89 + text::draw_spans(&rich_spans(tui, source, rich_base(tui)), area, buf)
90 + }
89 91
90 92 Node::Act(act) => text::draw_line(&act_line(tui, act), area, buf),
91 93
@@ -317,7 +319,7 @@
317 319 };
318 320 vec![Span::styled(text.clone(), style)]
319 321 }
320 - Node::Rich { source } => vec![Span::styled(docengine::render_plain(source), inherited)],
322 + Node::Rich { source } => rich_spans(tui, source, inherited),
321 323 Node::Token(tag) => vec![tag_span(tui, tag)],
322 324 Node::Act(act) => act_line(tui, act).spans,
323 325 Node::Link { text, .. } => vec![Span::styled(
@@ -341,6 +343,51 @@
341 343 }
342 344 }
343 345
346 + /// The style a rich node's unmarked prose takes when it stands on its own,
347 + /// rather than inside a run that has already picked one.
348 + fn rich_base(tui: &Tui) -> Style {
349 + Style::default().fg(tui.theme().content_primary)
350 + }
351 +
352 + /// Markdown source as spans: the words, each under the marks that were over it.
353 + ///
354 + /// `base` is what the prose takes where the source said nothing, so the same
355 + /// function serves a rich node standing alone and one sitting inside a row's
356 + /// run, where the part's role has already decided the colour.
357 + 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()
362 + }
363 +
364 + /// One run's marks as a style over `base`.
365 + ///
366 + /// Three of the four are the modifier a terminal already has for them. Code is
367 + /// the one with no modifier to take -- every cell is monospace, so the thing a
368 + /// webview says with a typeface cannot be said that way here -- and it takes
369 + /// the sunken surface instead, which is what the theme has for "this is set
370 + /// into the page rather than on it".
371 + fn mark(tui: &Tui, base: Style, emphasis: docengine::Emphasis) -> Style {
372 + if emphasis.is_plain() {
373 + return base;
374 + }
375 + let mut style = base;
376 + if emphasis.strong {
377 + style = style.add_modifier(Modifier::BOLD);
378 + }
379 + if emphasis.italic {
380 + style = style.add_modifier(Modifier::ITALIC);
381 + }
382 + if emphasis.struck {
383 + style = style.add_modifier(Modifier::CROSSED_OUT);
384 + }
385 + if emphasis.code {
386 + style = style.bg(tui.theme().surface_sunken);
387 + }
388 + style
389 + }
390 +
344 391 /// A tag as one span.
345 392 fn tag_span(tui: &Tui, tag: &Tag) -> Span<'static> {
346 393 let style = tui.tone(tag.tone);
@@ -13,6 +13,7 @@
13 13 };
14 14 use ratatui::buffer::Buffer;
15 15 use ratatui::layout::Rect;
16 + use ratatui::style::Modifier;
16 17
17 18 use crate::Tui;
18 19
@@ -45,11 +46,29 @@
45 46 }
46 47
47 48 /// Draw one node into a buffer of this size.
48 - fn drawn(node: &Node, width: u16, height: u16) -> Vec<String> {
49 + fn buffer(node: &Node, width: u16, height: u16) -> Buffer {
49 50 let area = Rect::new(0, 0, width, height);
50 51 let mut buf = Buffer::empty(area);
51 52 tui().node(node, area, &mut buf);
52 - rows(&buf)
53 + buf
54 + }
55 +
56 + /// Draw one node into a buffer of this size.
57 + fn drawn(node: &Node, width: u16, height: u16) -> Vec<String> {
58 + rows(&buffer(node, width, height))
59 + }
60 +
61 + /// The characters on row `y` whose cells carry `modifier`.
62 + ///
63 + /// The exception to the note at the top of this file, and a narrow one. A
64 + /// colour is the theme's answer, but bold is not: no theme decides which words
65 + /// in a paragraph are emphasised, so which cells carry it is this renderer's
66 + /// claim and the only way to assert it is to read it.
67 + fn marked(buf: &Buffer, y: u16, modifier: Modifier) -> String {
68 + (0..buf.area.width)
69 + .filter(|x| buf[(*x, y)].modifier.contains(modifier))
70 + .map(|x| buf[(x, y)].symbol())
71 + .collect()
53 72 }
54 73
55 74 /// Draw a whole screen.
@@ -116,13 +135,77 @@
116 135 }
117 136
118 137 #[test]
119 - fn markdown_becomes_words_and_loses_its_emphasis() {
120 - // A finding, asserted so it stays visible: the node carries source so every
121 - // renderer can answer it its own way, and a terminal's own way is currently
122 - // to drop the emphasis a cell could have carried as bold.
123 - let out = drawn(&Node::rich("**ship it** now"), 40, 2);
124 - assert!(out[0].contains("ship it now"), "{out:?}");
125 - assert!(!out[0].contains('*'), "{out:?}");
138 + fn markdown_keeps_its_emphasis_and_loses_its_syntax() {
139 + // The node carries source so every renderer can answer it its own way, and
140 + // a terminal's own way is the run's marks on the cell: `**ship it**` is the
141 + // words in bold, not the words with the asterisks still on them and not the
142 + // words with the emphasis thrown away.
143 + let buf = buffer(&Node::rich("**ship it** now"), 40, 2);
144 + let out = rows(&buf);
145 + assert_eq!(out[0], "ship it now");
146 + assert_eq!(marked(&buf, 0, Modifier::BOLD), "ship it");
147 + }
148 +
149 + #[test]
150 + fn each_inline_mark_reaches_the_cell_that_has_it() {
151 + let buf = buffer(&Node::rich("*lean* and ~~gone~~"), 40, 2);
152 + assert_eq!(rows(&buf)[0], "lean and gone");
153 + assert_eq!(marked(&buf, 0, Modifier::ITALIC), "lean");
154 + assert_eq!(marked(&buf, 0, Modifier::CROSSED_OUT), "gone");
155 + }
156 +
157 + #[test]
158 + fn a_code_span_is_set_into_the_page_rather_than_marked() {
159 + // Every cell is monospace, so the one thing a webview says with a typeface
160 + // is the one mark a terminal cannot repeat. It takes the sunken surface
161 + // instead. Asserted as a difference and not as a colour: which colour is
162 + // the theme's answer, that there is one is this renderer's.
163 + let buf = buffer(&Node::rich("run `cargo build` first"), 40, 2);
164 + assert_eq!(rows(&buf)[0], "run cargo build first");
165 + let prose = buf[(0, 0)].bg;
166 + let code = buf[(4, 0)].bg;
167 + assert_ne!(code, prose, "a code span should not sit on the page");
168 + assert_eq!(buf[(16, 0)].bg, prose, "and the prose after it should");
169 + }
170 +
171 + #[test]
172 + fn a_rich_block_keeps_the_breaks_the_author_wrote() {
173 + // The reason a rich node cannot go through `draw_line`: that one wraps a
174 + // run that is one line by construction, and two paragraphs run together
175 + // read as one sentence that does not parse.
176 + let out = drawn(&Node::rich("one\n\ntwo"), 40, 4);
177 + assert_eq!(out[0], "one");
178 + assert_eq!(out[1], "");
179 + assert_eq!(out[2], "two");
180 + }
181 +
182 + #[test]
183 + fn a_rich_node_inside_a_row_keeps_its_emphasis_too() {
184 + // The other path into the same runs. A row's parts are one line of spans,
185 + // so this goes through `inline_spans` rather than the block wrap, and the
186 + // marks have to survive both.
187 + let buf = buffer(
188 + &Node::list([Row::new("Ship it").part(layout::RowPart::Meta, Node::rich("**now**"))]),
189 + 40,
190 + 3,
191 + );
192 + // Two spaces in the run, one on the row: `draw_line` breaks on whitespace,
193 + // so the gap between two parts is a separator and not a measure.
194 + assert_eq!(rows(&buf)[0], "Ship it now");
195 + assert_eq!(marked(&buf, 0, Modifier::BOLD), "now");
196 + }
197 +
198 + #[test]
199 + fn a_rich_block_wraps_without_losing_which_words_were_marked() {
200 + // The wrap breaks a run across rows, so the marks have to travel with the
201 + // words rather than with the run they arrived in.
202 + let buf = buffer(&Node::rich("plain **one two three** plain"), 12, 4);
203 + let out = rows(&buf);
204 + assert_eq!(out[0], "plain one");
205 + assert_eq!(out[1], "two three");
206 + assert_eq!(out[2], "plain");
207 + assert_eq!(marked(&buf, 0, Modifier::BOLD), "one");
208 + assert_eq!(marked(&buf, 1, Modifier::BOLD), "two three");
126 209 }
127 210
128 211 #[test]
@@ -14,60 +14,119 @@
14 14 use ratatui::buffer::Buffer;
15 15 use ratatui::layout::Rect;
16 16 use ratatui::style::Style;
17 - use ratatui::text::Line;
17 + use ratatui::text::{Line, Span};
18 18
19 - /// Break `text` into lines no wider than `width`.
19 + /// Break `spans` into lines no wider than `width`, keeping each word under the
20 + /// style it arrived with.
20 21 ///
21 22 /// Breaks on whitespace, and breaks inside a word only when the word cannot fit
22 23 /// on a line of its own. A word longer than the whole width is the case that
23 24 /// has no good answer; cutting it is the least bad one, because the alternative
24 25 /// is a line wider than the region and a buffer that swallows the overflow
25 26 /// silently.
26 - pub(crate) fn wrap(text: &str, width: u16) -> Vec<String> {
27 + ///
28 + /// The one wrap in this crate. [`wrap`] is this with a single style over the
29 + /// whole string, rather than a second implementation that would be free to
30 + /// disagree with it about how many rows a paragraph takes -- and a disagreement
31 + /// there is a node drawing over the one under it.
32 + pub(crate) fn wrap_spans(spans: &[Span<'_>], width: u16) -> Vec<Line<'static>> {
27 33 if width == 0 {
28 34 return Vec::new();
29 35 }
30 36 let width = width as usize;
31 - let mut lines = Vec::new();
37 + let mut lines: Vec<Vec<Span<'static>>> = Vec::new();
38 + let mut line: Vec<Span<'static>> = Vec::new();
39 + let mut column = 0usize;
40 + // The style of the whitespace last passed over, held until a word turns up
41 + // to need a separator before it. Kept rather than taken from the word,
42 + // because the space between `*lean*` and `~~gone~~` belongs to the plain
43 + // run that held it: a strikethrough that starts one cell early is drawn
44 + // through a space the author never struck.
45 + let mut separator: Option<Style> = None;
32 46
33 - // Authored breaks are breaks. A description that put a newline in a string
34 - // meant it, and rewrapping across it would join two paragraphs.
35 - for paragraph in text.split('\n') {
36 - let mut line = String::new();
37 - for word in paragraph.split_whitespace() {
38 - let mut word = word;
39 - // A word too long for any line, cut to fit rather than overflowed.
40 - while word.chars().count() > width {
41 - if !line.is_empty() {
47 + for span in spans {
48 + let mut rest: &str = span.content.as_ref();
49 + while !rest.is_empty() {
50 + let gap = rest
51 + .find(|c: char| !c.is_whitespace())
52 + .unwrap_or(rest.len());
53 + if gap > 0 {
54 + // Authored breaks are breaks. A description that put a newline
55 + // in a string meant it, and rewrapping across it would join two
56 + // paragraphs.
57 + for _ in 0..rest[..gap].matches('\n').count() {
42 58 lines.push(std::mem::take(&mut line));
59 + column = 0;
60 + }
61 + separator = Some(span.style);
62 + rest = &rest[gap..];
63 + continue;
64 + }
65 +
66 + let end = rest.find(char::is_whitespace).unwrap_or(rest.len());
67 + let (mut word, after) = rest.split_at(end);
68 + rest = after;
69 +
70 + // A word too long for any line, cut to fit rather than overflowed.
71 + // The case has no good answer; cutting is the least bad one,
72 + // because the alternative is a line wider than the region and a
73 + // buffer that swallows the overflow silently.
74 + while word.chars().count() > width {
75 + if column > 0 {
76 + lines.push(std::mem::take(&mut line));
77 + column = 0;
43 78 }
44 79 let cut = word
45 80 .char_indices()
46 81 .nth(width)
47 82 .map_or(word.len(), |(index, _)| index);
48 - lines.push(word[..cut].to_string());
83 + lines.push(vec![Span::styled(word[..cut].to_string(), span.style)]);
49 84 word = &word[cut..];
50 85 }
51 - let room = width - line.chars().count();
52 - let wanted = word.chars().count() + usize::from(!line.is_empty());
53 - if wanted > room && !line.is_empty() {
54 - lines.push(std::mem::take(&mut line));
55 - }
56 - if !line.is_empty() {
57 - line.push(' ');
58 - }
59 - line.push_str(word);
60 - }
61 - lines.push(line);
62 - }
63 86
64 - // An empty string is no rows rather than one blank one, so a node with
65 - // nothing to say costs nothing. A blank line inside a paragraph survives,
87 + let room = width - column;
88 + let wanted = word.chars().count() + usize::from(column > 0);
89 + if wanted > room && column > 0 {
90 + lines.push(std::mem::take(&mut line));
91 + column = 0;
92 + }
93 + // Leading whitespace on a line is the wrap's own business and not
94 + // the author's, so a separator is drawn only between two words that
95 + // ended up on the same row.
96 + if column > 0 {
97 + line.push(Span::styled(" ", separator.unwrap_or(span.style)));
98 + column += 1;
99 + }
100 + separator = None;
101 + column += word.chars().count();
102 + line.push(Span::styled(word.to_string(), span.style));
103 + }
104 + }
105 + lines.push(line);
106 +
107 + // Nothing to say is no rows rather than one blank one, so a node with an
108 + // empty string costs nothing. A blank line inside a paragraph survives,
66 109 // because that one was authored.
67 110 if lines.len() == 1 && lines[0].is_empty() {
68 111 return Vec::new();
69 112 }
70 - lines
113 + lines.into_iter().map(Line::from).collect()
114 + }
115 +
116 + /// Break `text` into lines no wider than `width`.
117 + ///
118 + /// [`wrap_spans`] under one style, flattened back to strings for the callers
119 + /// that have no styles to keep.
120 + pub(crate) fn wrap(text: &str, width: u16) -> Vec<String> {
121 + wrap_spans(&[Span::raw(text.to_string())], width)
122 + .into_iter()
123 + .map(|line| {
124 + line.spans
125 + .iter()
126 + .map(|span| span.content.as_ref())
127 + .collect()
128 + })
129 + .collect()
71 130 }
72 131
73 132 /// The rows `text` takes at `width`.
@@ -75,6 +134,11 @@
75 134 u16::try_from(wrap(text, width).len()).unwrap_or(u16::MAX)
76 135 }
77 136
137 + /// The rows `spans` take at `width`, wrapped as a block.
138 + pub(crate) fn spans_height(spans: &[Span<'_>], width: u16) -> u16 {
139 + u16::try_from(wrap_spans(spans, width).len()).unwrap_or(u16::MAX)
140 + }
141 +
78 142 /// Draw wrapped text at the top of `area`, and answer the rows it used.
79 143 pub(crate) fn draw(text: &str, style: Style, area: Rect, buf: &mut Buffer) -> u16 {
80 144 let mut used = 0;
@@ -88,60 +152,50 @@
88 152 used
89 153 }
90 154
155 + /// Draw wrapped spans at the top of `area`, and answer the rows they used.
156 + ///
157 + /// The block counterpart to [`draw_line`]: that one takes a run that is one
158 + /// line by construction and wraps it because it might not fit, and this one
159 + /// takes a run with authored breaks in it and keeps them.
160 + pub(crate) fn draw_spans(spans: &[Span<'_>], area: Rect, buf: &mut Buffer) -> u16 {
161 + let mut used = 0;
162 + for line in wrap_spans(spans, area.width) {
163 + if used >= area.height {
164 + break;
165 + }
166 + let mut column = 0u16;
167 + for span in &line.spans {
168 + let room = area.width.saturating_sub(column) as usize;
169 + if room == 0 {
170 + break;
171 + }
172 + buf.set_stringn(
173 + area.x + column,
174 + area.y + used,
175 + &span.content,
176 + room,
177 + span.style,
178 + );
179 + column += u16::try_from(span.content.chars().count().min(room)).unwrap_or(u16::MAX);
180 + }
181 + used += 1;
182 + }
183 + used
184 + }
185 +
91 186 /// Draw a line of spans at the top of `area`, wrapping onto further rows.
92 187 ///
93 - /// Spans carry their own styles, so this cannot go through [`wrap`]: the break
94 - /// has to be found without losing which span each word came from. It breaks
95 - /// between spans and, inside a span, on whitespace.
188 + /// A run that is one line by construction -- a row's parts, a control, a meter
189 + /// -- rather than a block that may carry breaks of its own. It is the same wrap
190 + /// either way, and was its own implementation until a rich node started putting
191 + /// styled runs inside a row: the separate copy drew the space before a struck
192 + /// word struck, because it took the separator's style from the word after it
193 + /// instead of from the whitespace it replaced.
96 194 pub(crate) fn draw_line(line: &Line<'_>, area: Rect, buf: &mut Buffer) -> u16 {
97 - let mut row = 0;
98 - let mut column = 0u16;
99 -
100 - for span in &line.spans {
101 - for word in span.content.split_whitespace() {
102 - let wanted = u16::try_from(word.chars().count()).unwrap_or(u16::MAX);
103 - let space = u16::from(column > 0);
104 - if column + space + wanted > area.width && column > 0 {
105 - row += 1;
106 - column = 0;
107 - }
108 - if row >= area.height {
109 - return area.height;
110 - }
111 - if column > 0 {
112 - buf.set_stringn(area.x + column, area.y + row, " ", 1, span.style);
113 - column += 1;
114 - }
115 - let room = area.width.saturating_sub(column) as usize;
116 - buf.set_stringn(area.x + column, area.y + row, word, room, span.style);
117 - column += wanted.min(area.width.saturating_sub(column));
118 - }
119 - }
120 -
121 - if column > 0 || row > 0 { row + 1 } else { 0 }
195 + draw_spans(&line.spans, area, buf)
122 196 }
123 197
124 198 /// The rows a line of spans takes at `width`.
125 199 pub(crate) fn line_height(line: &Line<'_>, width: u16) -> u16 {
126 - if width == 0 {
127 - return 0;
128 - }
129 - let mut rows = 0u16;
130 - let mut column = 0u16;
131 - let mut any = false;
132 -
133 - for span in &line.spans {
134 - for word in span.content.split_whitespace() {
135 - any = true;
136 - let wanted = u16::try_from(word.chars().count()).unwrap_or(u16::MAX);
137 - let space = u16::from(column > 0);
138 - if column + space + wanted > width && column > 0 {
139 - rows += 1;
140 - column = 0;
141 - }
142 - column += space + wanted;
143 - }
144 - }
145 -
146 - if any { rows + 1 } else { 0 }
200 + spans_height(&line.spans, width)
147 201 }