Skip to main content

max / quasi

The terminal draws the row a region said its members share The last renderer with the silent drop. `draw` and `height` both walked `body` and nothing walked `run`, so a description that said `across` and then `beside` contributed members that were never painted, never counted in the region's height, and never reached by the caret. quasi-immediate had the same hole until 98a7276; this is the bigger half, because a terminal holds its own arithmetic. Three things had to exist rather than one. `node::want` measures what a member holds -- a label's characters, a tag's, a meter's line -- which is the width axis `node::height` never needed. `packed` puts members left to right and wraps when what is left of the line cannot hold the next one, so the wrap point is derived at the width the region was given and no breakpoint is authored. And the focus walk visits the row before the body, which is the order the drawing uses: the two disagreeing is how a caret lights a control the reader is not looking at. Menu wraps rather than shedding, and that is this renderer's answer rather than a shortfall. Menu says the shed members stay reachable behind one control; a terminal has no anchored menu to put them behind -- a menu here is a key, as this crate's header has said since it was written -- and a marker showing a count nobody can open is the `by_host` failure exactly. Keeping every member on a second line honours the half that matters and says which half it cannot. Shed does drop by Priority, at the cutoff the region's body already reads. That is the half a webview cannot do at all. Seven tests; five fail with the drawing and the counting removed. The height one is the invisible half: `height` is what the scroll arithmetic trusts, so a row it did not count is a region that scrolls short by however many lines the row took.
Author: Max Johnson <me@maxj.phd> · 2026-08-23 14:18 UTC
Signed with PGP, not checked
Commit: 7fd2630e9fb5dff6e1388b0e4ecea913e185b50f
Parent: 439aa55
4 files changed, +384 insertions, -9 deletions
@@ -375,7 +375,15 @@
375 375 // something that is not on the screen. Recorded rather than papered over:
376 376 // fixing it means the navigation walk knowing the width the drawing knows,
377 377 // which is a wider change than the member that revealed it.
378 - for placed in &slot.body {
378 + // The row the region says its members share, then its body, which is the
379 + // order they are drawn in. The caret and the drawing agreeing about that
380 + // order is the invariant this walk exists to keep.
381 + for placed in slot
382 + .run
383 + .iter()
384 + .flat_map(|run| run.members.iter())
385 + .chain(slot.body.iter())
386 + {
379 387 node_spots(&placed.node, &slot.id, found);
380 388 }
381 389 }
@@ -117,6 +117,89 @@
117 117 }
118 118 }
119 119
120 + /// How much of a row a node wants, in cells.
121 + ///
122 + /// The counterpart to [`height`] for the one axis a column never had to think
123 + /// about. A region that says its members share a row (`Slot::across`) needs to
124 + /// know how wide each one is before it can put two of them side by side, and
125 + /// every measurement here is derived from what the node holds rather than
126 + /// authored: a label's characters, a tag's, a meter's line.
127 + ///
128 + /// [`Want::Rest`] is the honest answer for two different things, and both of
129 + /// them mean "do not try to measure me": a control the description said should
130 + /// absorb what is left ([`layout::Width::Fill`]), and a node whose shape is a
131 + /// block rather than a line -- a list, a table, a form -- which has no business
132 + /// being a member of a row and is given the whole of one if it is.
133 + #[derive(Debug, Clone, Copy, PartialEq, Eq)]
134 + pub(crate) enum Want {
135 + /// This many cells, which is what the node holds.
136 + Cells(u16),
137 + /// Whatever the line has left.
138 + Rest,
139 + }
140 +
141 + /// The cells `node` wants as a member of a row.
142 + pub(crate) fn want(tui: &Tui, node: &Node) -> Want {
143 + /// The cells a line of spans occupies, unwrapped.
144 + fn spans_wide(line: &Line<'_>) -> u16 {
145 + u16::try_from(
146 + line.spans
147 + .iter()
148 + .map(|span| span.content.chars().count())
149 + .sum::<usize>(),
150 + )
151 + .unwrap_or(u16::MAX)
152 + }
153 + fn chars_wide(text: &str) -> u16 {
154 + u16::try_from(text.chars().count()).unwrap_or(u16::MAX)
155 + }
156 +
157 + match node {
158 + Node::Heading { text, .. } | Node::Text { text, .. } | Node::Notice { text, .. } => {
159 + Want::Cells(chars_wide(text))
160 + }
161 + Node::Link { text, .. } => Want::Cells(chars_wide(text)),
162 + // A control with questions on it is not a line: the questions stand
163 + // above it here, which is what `height`'s arm says, so it takes a row
164 + // of its own rather than a place in one.
165 + Node::Act(act) if act.asks.is_empty() => {
166 + Want::Cells(spans_wide(&act_line(tui, act, false)))
167 + }
168 + Node::Token(tag) => Want::Cells(spans_wide(&Line::from(tag_span(tui, tag, false)))),
169 + Node::Figure(figure) => {
170 + Want::Cells(chars_wide(&figure.value) + 1 + chars_wide(&figure.caption))
171 + }
172 + Node::Since { .. } | Node::Until { .. } | Node::Age { .. } => Want::Cells(CLOCK_CELLS),
173 + Node::Select { options, .. } => Want::Cells(spans_wide(&Line::from(select_spans(
174 + tui,
175 + options,
176 + None,
177 + &[],
178 + )))),
179 + Node::Meter(meter) => Want::Cells(spans_wide(&meter_line(tui, meter))),
180 + // What the description said about this one, which is the only place a
181 + // width is stated rather than derived. `Fill` is the search box in a
182 + // toolbar; `Content` and `Fixed` are a box that should be as wide as
183 + // what goes in it, and a terminal has no better number for that than
184 + // the label plus room to type.
185 + Node::Field(field) => match field.width {
186 + layout::Width::Fill => Want::Rest,
187 + _ => Want::Cells(chars_wide(&field.label) + FIELD_BOX_CELLS),
188 + },
189 + _ => Want::Rest,
190 + }
191 + }
192 +
193 + /// The cells a clock readout is given.
194 + ///
195 + /// `height`'s arm has the argument: the readout is sized without asking what it
196 + /// says, or the row would shuffle under the reader once a minute.
197 + const CLOCK_CELLS: u16 = 16;
198 +
199 + /// The cells a box gets beyond its label, when the description did not say the
200 + /// box absorbs the line.
201 + const FIELD_BOX_CELLS: u16 = 12;
202 +
120 203 /// What a node this renderer has not learned yet draws instead of itself.
121 204 ///
122 205 /// `Node` is `#[non_exhaustive]` so that a new member is not a lockstep release
@@ -21,7 +21,7 @@
21 21
22 22 use makeover_layout as layout;
23 23 use makeover_tui::{frame, text};
24 - use quasi_router::{Node, Ranked, RegionKind, Screen, Slot};
24 + use quasi_router::{Node, Ranked, RegionKind, Run, Screen, Slot};
25 25 use ratatui::buffer::Buffer;
26 26 use ratatui::layout::Rect;
27 27 use ratatui::style::{Modifier, Style};
@@ -261,6 +261,124 @@
261 261 .collect()
262 262 }
263 263
264 + /// How a region's row packs at this width: the members on each line, and how
265 + /// tall the line is.
266 + ///
267 + /// Ruling: wiki `layout-room-and-fallback`. **This renderer drew none of a
268 + /// region's row until 2026-08-23**, the way quasi-immediate did not: `draw` and
269 + /// `height` both walked `body`, nothing walked `run`, so a description that
270 + /// said `across` and then `beside` contributed members that were never painted,
271 + /// never counted in the region's height, and never reached by the caret.
272 + ///
273 + /// Packing is left to right, one space between members, wrapping to a new line
274 + /// when what is left of this one cannot hold the next member. Every width is
275 + /// derived -- [`crate::node::want`] measures a member from what it holds -- so
276 + /// nothing here is authored and there is no breakpoint.
277 + ///
278 + /// # What each fallback gets
279 + ///
280 + /// [`Wrap`](layout::Fallback::Wrap) and [`Stack`](layout::Fallback::Stack) keep
281 + /// every member and wrap. They differ in a webview by whether a wrapped member
282 + /// fills its line; a terminal has no such distinction to draw, so both answer
283 + /// alike rather than this renderer inventing one.
284 + ///
285 + /// [`Shed`](layout::Fallback::Shed) drops members by [`layout::Priority`] at
286 + /// the cutoff the region's body already reads, which is the half a webview
287 + /// cannot do at all.
288 + ///
289 + /// [`Menu`](layout::Fallback::Menu) **wraps rather than shedding**, and that is
290 + /// this renderer's answer rather than a shortfall. Menu says the shed members
291 + /// stay reachable behind one control; a terminal has no anchored menu to put
292 + /// them behind -- `quasi-tui`'s own header says a menu here is a key -- and a
293 + /// marker that shows a count nobody can open is the `by_host` failure, a thing
294 + /// drawn, reachable and doing nothing. Keeping every member on a second line
295 + /// honours the half that matters and states the half it cannot.
296 + fn packed<'a>(tui: &Tui, run: &'a Run, width: u16) -> Vec<Vec<(&'a Node, u16)>> {
297 + let kept: Vec<&Ranked> = match run.fallback {
298 + layout::Fallback::Shed => run.kept_at(cutoff(width)),
299 + _ => run.members.iter().collect(),
300 + };
301 +
302 + let mut lines: Vec<Vec<(&Node, u16)>> = Vec::new();
303 + let mut line: Vec<(&Node, u16)> = Vec::new();
304 + let mut left = width;
305 + for placed in kept {
306 + let wants = match crate::node::want(tui, &placed.node) {
307 + crate::node::Want::Cells(cells) => cells.min(width),
308 + crate::node::Want::Rest => left.max(MEMBER_FLOOR).min(width),
309 + };
310 + let gap = u16::from(!line.is_empty());
311 + if !line.is_empty() && wants + gap > left {
312 + lines.push(std::mem::take(&mut line));
313 + left = width;
314 + }
315 + let gap = u16::from(!line.is_empty());
316 + let given = wants.min(left.saturating_sub(gap));
317 + line.push((&placed.node, given));
318 + left = left.saturating_sub(given + gap);
319 + }
320 + if !line.is_empty() {
321 + lines.push(line);
322 + }
323 + lines
324 + }
325 +
326 + /// The narrowest a member is given before the row wraps instead.
327 + ///
328 + /// Only reached by a member that asked for what is left of the line and found
329 + /// almost nothing there. Below this a control is not readable, so it takes the
330 + /// next line whole rather than a sliver of this one.
331 + const MEMBER_FLOOR: u16 = 8;
332 +
333 + /// The rows a region's leading row takes at this width.
334 + fn run_height(tui: &Tui, slot: &Slot, width: u16) -> u16 {
335 + let Some(run) = slot.run.as_ref() else {
336 + return 0;
337 + };
338 + packed(tui, run, width)
339 + .iter()
340 + .map(|line| {
341 + line.iter()
342 + .map(|(node, cells)| crate::node::height(tui, node, *cells))
343 + .max()
344 + .unwrap_or(0)
345 + })
346 + .sum()
347 + }
348 +
349 + /// Draw a region's leading row, and answer the rows it used.
350 + fn run_body(pass: &mut Pass<'_>, slot: &Slot, inner: Rect, buf: &mut Buffer) -> u16 {
351 + let Some(run) = slot.run.as_ref() else {
352 + return 0;
353 + };
354 + let lines = packed(pass.tui, run, inner.width);
355 + let mut used = 0;
356 + for line in lines {
357 + let area = below(inner, used);
358 + if area.height == 0 {
359 + break;
360 + }
361 + let mut column = 0;
362 + let mut tall = 0;
363 + for (node, cells) in line {
364 + let cell = Rect {
365 + x: area.x + column,
366 + width: cells.min(area.width.saturating_sub(column)),
367 + ..area
368 + };
369 + if cell.width == 0 {
370 + break;
371 + }
372 + tall = tall.max(crate::node::draw(pass, node, cell, buf));
373 + // One space between members, which is the only separator a
374 + // terminal row needs and the same one a strip of labels uses.
375 + column += cell.width + 1;
376 + }
377 + used += tall.max(1);
378 + }
379 + used.min(inner.height)
380 + }
381 +
264 382 /// The rows a region wants at `width`.
265 383 pub(crate) fn height(tui: &Tui, slot: &Slot, width: u16) -> u16 {
266 384 let inner = width.saturating_sub(2);
@@ -295,7 +413,7 @@
295 413 };
296 414
297 415 // Two rows for the frame, when the region has one.
298 - body + filled + if framed(slot.kind.depth()) { 2 } else { 0 }
416 + run_height(tui, slot, inner) + body + filled + if framed(slot.kind.depth()) { 2 } else { 0 }
299 417 }
300 418
301 419 /// Draw one region, and answer the rows it used.
@@ -307,7 +425,12 @@
307 425
308 426 if area.width == 0 || area.height == 0 {
309 427 if !pending {
310 - for placed in &slot.body {
428 + for placed in slot
429 + .run
430 + .iter()
431 + .flat_map(|run| run.members.iter())
432 + .chain(slot.body.iter())
433 + {
311 434 crate::node::draw(pass, &placed.node, area, buf);
312 435 }
313 436 }
@@ -351,11 +474,17 @@
351 474 // blocks and then stopped, whatever the host had to put in it. The
352 475 // ordering is the arrangement `Containment::Opaque` describes -- a heading
353 476 // the description owns above a canvas it does not.
354 - let mut used = if slot.showing.selective() {
355 - showing_body(pass, slot, inner, buf)
356 - } else {
357 - body(pass, slot, &slot.body, inner, buf)
358 - };
477 + // The row the description said its members share, above the body. Above,
478 + // because that is the order the webview emits them in and the order a
479 + // toolbar over a list reads in.
480 + let row = run_body(pass, slot, inner, buf);
481 + let rest = below(inner, row);
482 + let mut used = row
483 + + if slot.showing.selective() {
484 + showing_body(pass, slot, rest, buf)
485 + } else {
486 + body(pass, slot, &slot.body, rest, buf)
487 + };
359 488
360 489 if let RegionKind::Bespoke { .. } = slot.kind
361 490 && let Some(fill) = tui.fill(&slot.id)
@@ -103,6 +103,161 @@
103 103 rows(&buf)
104 104 }
105 105
106 + /// A band whose members were said to share one row.
107 + fn run_of(fallback: layout::Fallback, members: &[(&str, layout::Priority)]) -> Screen {
108 + let mut band = Slot::new("bar", RegionKind::Band).across(fallback);
109 + for (label, priority) in members {
110 + band = band.beside(
111 + Node::Act(Act::new(*label, Action::post(format!("/{label}")))),
112 + *priority,
113 + );
114 + }
115 + Screen::sidebar_content("Test").with(band)
116 + }
117 +
118 + /// The three toolbar controls, all essential.
119 + const THREE: [(&str, layout::Priority); 3] = [
120 + ("Import", layout::Priority::Essential),
121 + ("Export", layout::Priority::Essential),
122 + ("Settings", layout::Priority::Essential),
123 + ];
124 +
125 + #[test]
126 + fn a_member_of_a_run_is_drawn_at_all() {
127 + // The defect: `draw` and `height` both walked `body` and nothing walked
128 + // `run`, so a description that said `across` and then `beside` contributed
129 + // members that were never painted and never counted. Silent, and the same
130 + // shape quasi-immediate had until quasi@98a7276.
131 + let out = shown(&run_of(layout::Fallback::Wrap, &THREE), 60, 6);
132 + for label in ["Import", "Export", "Settings"] {
133 + assert!(
134 + out.iter().any(|row| row.contains(label)),
135 + "{label} was never drawn: {out:?}"
136 + );
137 + }
138 + }
139 +
140 + #[test]
141 + fn a_run_puts_its_members_across_rather_than_down() {
142 + let out = shown(&run_of(layout::Fallback::Wrap, &THREE), 60, 6);
143 + let row = out
144 + .iter()
145 + .find(|row| row.contains("Import"))
146 + .expect("no row holds the first member");
147 + assert!(
148 + row.contains("Export") && row.contains("Settings"),
149 + "the members went down the screen instead of across it: {out:?}"
150 + );
151 + let (first, second) = (row.find("Import").unwrap(), row.find("Export").unwrap());
152 + assert!(first < second, "the members are out of order: {row:?}");
153 + }
154 +
155 + #[test]
156 + fn a_run_wraps_when_the_line_cannot_hold_the_next_member() {
157 + // Derived from what the members hold, at the width the region was given.
158 + // No breakpoint is authored anywhere in this path.
159 + let out = shown(&run_of(layout::Fallback::Wrap, &THREE), 16, 6);
160 + let lines: Vec<&String> = out
161 + .iter()
162 + .filter(|row| THREE.iter().any(|(label, _)| row.contains(label)))
163 + .collect();
164 + assert!(lines.len() > 1, "a narrow row did not wrap: {out:?}");
165 + for label in ["Import", "Export", "Settings"] {
166 + assert!(
167 + out.iter().any(|row| row.contains(label)),
168 + "wrapping lost {label}: {out:?}"
169 + );
170 + }
171 + }
172 +
173 + #[test]
174 + fn a_region_is_as_tall_as_its_row_plus_its_body() {
175 + // The half that is invisible until something scrolls: `height` is what the
176 + // scroll arithmetic trusts, so a row it did not count is a region that
177 + // scrolls short by however many lines the row took.
178 + let bare = Slot::new("bar", RegionKind::Band).with(Node::text("body"));
179 + let rowed = Slot::new("bar", RegionKind::Band)
180 + .with(Node::text("body"))
181 + .across(layout::Fallback::Wrap)
182 + .beside(
183 + Node::Act(Act::new("Import", Action::post("/import"))),
184 + layout::Priority::Essential,
185 + );
186 +
187 + let tui = tui();
188 + assert_eq!(
189 + crate::region::height(&tui, &rowed, 60),
190 + crate::region::height(&tui, &bare, 60) + 1,
191 + "the row was not counted"
192 + );
193 + }
194 +
195 + #[test]
196 + fn a_run_that_sheds_drops_by_priority_and_keeps_the_essential() {
197 + let members = [
198 + ("Import", layout::Priority::Essential),
199 + ("Export", layout::Priority::Secondary),
200 + ("Settings", layout::Priority::Optional),
201 + ];
202 + let screen = run_of(layout::Fallback::Shed, &members);
203 +
204 + let narrow = shown(&screen, 30, 6);
205 + assert!(narrow.iter().any(|row| row.contains("Import")));
206 + assert!(
207 + !narrow.iter().any(|row| row.contains("Export")),
208 + "a shed row kept what it said it would drop: {narrow:?}"
209 + );
210 +
211 + // And nothing is dropped where there is room, or the cutoff would be a
212 + // permanent narrowing rather than a measurement.
213 + let wide = shown(&screen, 120, 6);
214 + assert!(wide.iter().any(|row| row.contains("Settings")), "{wide:?}");
215 + }
216 +
217 + #[test]
218 + fn a_run_that_menus_keeps_every_member_because_a_terminal_has_nowhere_to_put_them() {
219 + // This renderer's answer rather than a shortfall, and the header says so: a
220 + // menu here is a key, and a marker showing a count nobody can open is a
221 + // control drawn, reachable and doing nothing.
222 + let members = [
223 + ("Import", layout::Priority::Essential),
224 + ("Export", layout::Priority::Secondary),
225 + ("Settings", layout::Priority::Optional),
226 + ];
227 + let narrow = shown(&run_of(layout::Fallback::Menu, &members), 30, 6);
228 + for label in ["Import", "Export", "Settings"] {
229 + assert!(
230 + narrow.iter().any(|row| row.contains(label)),
231 + "Menu shed {label} with nowhere to put it: {narrow:?}"
232 + );
233 + }
234 + }
235 +
236 + #[test]
237 + fn the_caret_reaches_a_row_s_members_before_the_body() {
238 + // The invariant holding the two walks together: the caret lights the
239 + // control the drawing put first, or Enter calls something the reader is not
240 + // looking at.
241 + let screen = Screen::sidebar_content("Test").with(
242 + Slot::new("bar", RegionKind::Band)
243 + .across(layout::Fallback::Wrap)
244 + .beside(
245 + Node::Act(Act::new("Import", Action::post("/import"))),
246 + layout::Priority::Essential,
247 + )
248 + .with(Node::Act(Act::new("Below", Action::post("/below")))),
249 + );
250 +
251 + let reached: Vec<String> = crate::focus::reaches(&screen)
252 + .iter()
253 + .filter_map(|reach| match &reach.spot {
254 + Spot::Act { action, .. } => action.destination.route().map(ToOwned::to_owned),
255 + _ => None,
256 + })
257 + .collect();
258 + assert_eq!(reached, ["/import", "/below"]);
259 + }
260 +
106 261 /// The row a piece of text was drawn on.
107 262 fn row_of(rows: &[String], text: &str) -> Option<usize> {
108 263 rows.iter().position(|row| row.contains(text))