Skip to main content

max / quasi

quasi-tui derives the same chrome, and a view holds which child is up The sixth thing a browser was quietly providing. A description says a region shows one child at a time and which it started on; where the reader has moved to since is this renderer's, exactly as scroll is, so View gains shown by slot id. c0b63ea9's terminal half was filed as a drawing change and never was one. Until Showing existed a terminal had no way to learn that a stack of pictures was meant to be one picture, so it honestly drew the stack. Now it draws one frame and < Prev > 2 / 3 < Next > under it, or a strip of labels above the pane they open -- the same derivation the webview makes, from the same two questions, with neither renderer reading a widget's name. A test changes the name and asserts the picture is identical. Left and Right move the region the caret is in, falling back to the first such region on the screen. The fallback is not a convenience: a carousel holds pictures, so nothing in one is reachable and focus can never be inside it.
Author: Max Johnson <me@maxj.phd> · 2026-08-14 22:33 UTC
Signed with PGP, not checked
Commit: 1e7d9c5c4878ee1c8c7c226c217728329300ede3
Parent: a7bc26b
5 files changed, +418 insertions, -21 deletions
M Cargo.lock +8 -8
@@ -5514,6 +5514,14 @@
5514 5514 source = "registry+https://github.com/rust-lang/crates.io-index"
5515 5515 checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
5516 5516
5517 + [[patch.unused]]
5518 + name = "synckit-client"
5519 + version = "0.8.0"
5520 +
5521 + [[patch.unused]]
5522 + name = "synckit-config"
5523 + version = "0.2.0"
5524 +
5517 5525 [[patch.unused]]
5518 5526 name = "kberg"
5519 5527 version = "0.1.0"
@@ -5530,14 +5538,6 @@
5530 5538 name = "tagtree"
5531 5539 version = "0.4.0"
5532 5540
5533 - [[patch.unused]]
5534 - name = "synckit-client"
5535 - version = "0.8.0"
5536 -
5537 - [[patch.unused]]
5538 - name = "synckit-config"
5539 - version = "0.2.0"
5540 -
5541 5541 [[patch.unused]]
5542 5542 name = "makeover-build"
5543 5543 version = "0.32.0"
@@ -21,10 +21,11 @@
21 21
22 22 use makeover_layout as layout;
23 23 use makeover_tui::{frame, text};
24 - use quasi_router::{RegionKind, Screen, Slot};
24 + use quasi_router::{Node, RegionKind, Screen, Slot};
25 25 use ratatui::buffer::Buffer;
26 26 use ratatui::layout::Rect;
27 27 use ratatui::style::{Modifier, Style};
28 + use ratatui::text::Span;
28 29
29 30 use crate::{Pass, Tui, below};
30 31
@@ -185,11 +186,24 @@
185 186 /// The rows a region wants at `width`.
186 187 pub(crate) fn height(tui: &Tui, slot: &Slot, width: u16) -> u16 {
187 188 let inner = width.saturating_sub(2);
188 - let body: u16 = slot
189 - .body
190 - .iter()
191 - .map(|node| crate::node::height(tui, node, inner))
192 - .sum();
189 + // A region showing one child at a time is as tall as its tallest child plus
190 + // the row that moves between them. Tallest rather than current, because this
191 + // has no `View` and so cannot know which child is up -- an over-estimate,
192 + // which for the one caller (scroll arithmetic) errs toward letting a region
193 + // scroll slightly further than it needs to rather than cutting it off.
194 + let body: u16 = if slot.showing.selective() {
195 + slot.body
196 + .iter()
197 + .map(|node| crate::node::height(tui, node, inner))
198 + .max()
199 + .unwrap_or(0)
200 + + 1
201 + } else {
202 + slot.body
203 + .iter()
204 + .map(|node| crate::node::height(tui, node, inner))
205 + .sum()
206 + };
193 207 // Two rows for the frame, when the region has one.
194 208 body + if framed(slot.kind.depth()) { 2 } else { 0 }
195 209 }
@@ -243,7 +257,11 @@
243 257 // them is the host's to draw, and this renderer has no fill mechanism to
244 258 // offer it. That is a gap rather than a decline: `Webview::with_fill` has
245 259 // no counterpart here.
246 - let used = body(pass, slot, inner, buf);
260 + let used = if slot.showing.selective() {
261 + showing_body(pass, slot, inner, buf)
262 + } else {
263 + body(pass, slot, &slot.body, inner, buf)
264 + };
247 265
248 266 // `Slot::id` is not drawn anywhere. It is a fragment address, and a
249 267 // terminal redraws rather than swapping, so it costs nothing and says
@@ -251,6 +269,107 @@
251 269 used + if framed(depth) { 2 } else { 0 }
252 270 }
253 271
272 + /// A region showing one child at a time, and the chrome that moves between them.
273 + ///
274 + /// The same derivation quasi-webview makes and for the same reason: nothing here
275 + /// reads [`RegionKind::Widget`]'s name. A carousel, a tab group and a disclosure
276 + /// are one region that shows some of its children, and which idiom comes out
277 + /// falls out of what the children carry.
278 + ///
279 + /// This is what `c0b63ea9`'s terminal half was waiting for. It was filed as a
280 + /// drawing change and it never was one -- until [`layout::Showing`] existed a
281 + /// terminal had no way to learn that a stack of pictures was meant to be one
282 + /// picture, so it honestly drew the stack.
283 + ///
284 + /// # The chrome is one row, and it is in flow
285 + ///
286 + /// `< Prev > 2 / 3 < Next >` under the content, or a strip of labels above it.
287 + /// Max chose the row over the overlaid arrows a browser had been drawing,
288 + /// 2026-08-14, and the reason it ports is the reason it was chosen: a terminal
289 + /// cannot honestly overlay anything, and a dot strip has no form here at all.
290 + fn showing_body(pass: &mut Pass<'_>, slot: &Slot, inner: Rect, buf: &mut Buffer) -> u16 {
291 + let at = pass.view.shown(slot);
292 + let labels = slot.labels();
293 + let mut used = 0;
294 +
295 + // A strip sits above the panes it opens; a counter row sits under the
296 + // content it counts. The folder semantic, and the same placement the
297 + // webview derives.
298 + if !labels.is_empty() {
299 + used += text::draw_spans(
300 + &showing_spans(pass.tui, &labels, at),
301 + below(inner, used),
302 + buf,
303 + );
304 + }
305 +
306 + // One child, or none at all: `Showing::AtMostOne` closed is the only way to
307 + // reach `None` here, and drawing nothing is what closed means.
308 + if let Some(index) = at
309 + && index < slot.body.len()
310 + {
311 + used += body(
312 + pass,
313 + slot,
314 + &slot.body[index..=index],
315 + below(inner, used),
316 + buf,
317 + );
318 + }
319 +
320 + if labels.is_empty() {
321 + used += text::draw_spans(
322 + &counter_spans(pass.tui, at, slot.body.len()),
323 + below(inner, used),
324 + buf,
325 + );
326 + }
327 +
328 + used.min(inner.height)
329 + }
330 +
331 + /// A strip of labels, the current one lit.
332 + ///
333 + /// `select_spans`' styling, deliberately: a derived tab strip and a described
334 + /// one are the same thing on the screen, and two spellings of it would drift
335 + /// the way `tabs` and `tab` did in the webview.
336 + fn showing_spans(tui: &Tui, labels: &[&str], at: Option<usize>) -> Vec<Span<'static>> {
337 + let mut spans = Vec::new();
338 + for (index, label) in labels.iter().enumerate() {
339 + if !spans.is_empty() {
340 + spans.push(Span::raw(" "));
341 + }
342 + let picked = at == Some(index);
343 + let style = if picked {
344 + Style::default()
345 + .fg(tui.theme().selection_on)
346 + .bg(tui.theme().action_primary)
347 + } else {
348 + Style::default().fg(tui.theme().content_secondary)
349 + };
350 + spans.push(Span::styled(format!(" {label} "), style));
351 + }
352 + spans
353 + }
354 +
355 + /// Previous, where you are, next.
356 + ///
357 + /// The position reads back one step, which is `picture-caption`'s claim in the
358 + /// other renderer: it says where you are among the children and it is not one
359 + /// of them. Zero when a dismissible region is closed, which is a true statement
360 + /// about how many of its children are showing.
361 + fn counter_spans(tui: &Tui, at: Option<usize>, total: usize) -> Vec<Span<'static>> {
362 + let control = Style::default().fg(tui.theme().content_secondary);
363 + vec![
364 + Span::styled("< Prev >", control),
365 + Span::styled(
366 + format!(" {} / {total} ", at.map_or(0, |index| index + 1)),
367 + Style::default().fg(tui.theme().content_muted),
368 + ),
369 + Span::styled("< Next >", control),
370 + ]
371 + }
372 +
254 373 /// A region's contents, at the offset the view is holding it at.
255 374 ///
256 375 /// Scrolling is the runtime's and the clipping is the drawing's, and this is
@@ -265,18 +384,17 @@
265 384 /// The offset is clamped here rather than in [`crate::View`], because how far a
266 385 /// region can scroll is how tall it is at the width it was given, and the width
267 386 /// is not known until this point.
268 - fn body(pass: &mut Pass<'_>, slot: &Slot, inner: Rect, buf: &mut Buffer) -> u16 {
387 + fn body(pass: &mut Pass<'_>, slot: &Slot, nodes: &[Node], inner: Rect, buf: &mut Buffer) -> u16 {
269 388 let offset = pass.view.scroll(&slot.id);
270 389 if offset == 0 {
271 390 let mut used = 0;
272 - for node in &slot.body {
391 + for node in nodes {
273 392 used += crate::node::draw(pass, node, below(inner, used), buf);
274 393 }
275 394 return used.min(inner.height);
276 395 }
277 396
278 - let content: u16 = slot
279 - .body
397 + let content: u16 = nodes
280 398 .iter()
281 399 .map(|node| crate::node::height(pass.tui, node, inner.width))
282 400 .sum();
@@ -288,7 +406,7 @@
288 406 };
289 407 let mut scratch = Buffer::empty(tall);
290 408 let mut used = 0;
291 - for node in &slot.body {
409 + for node in nodes {
292 410 used += crate::node::draw(pass, node, below(tall, used), &mut scratch);
293 411 }
294 412
@@ -42,7 +42,7 @@
42 42
43 43 use makeover_layout as layout;
44 44 use quasi_router::{
45 - Action, Chrome, Message, Method, Node, Outcome, Params, Request, Response, Screen,
45 + Action, Chrome, Message, Method, Node, Outcome, Params, Request, Response, Screen, Slot,
46 46 };
47 47 use ratatui::buffer::Buffer;
48 48 use ratatui::layout::Rect;
@@ -78,6 +78,10 @@
78 78 PageUp,
79 79 /// A screen's worth forwards.
80 80 PageDown,
81 + /// Back one child, in a region showing one at a time.
82 + Left,
83 + /// On one child, in a region showing one at a time.
84 + Right,
81 85 }
82 86
83 87 /// What the host should do about a key.
@@ -343,6 +347,21 @@
343 347 Step::Idle
344 348 }
345 349
350 + Key::Left | Key::Right => {
351 + // The region the caret is in, when that region shows one child
352 + // at a time, and otherwise the first one on the screen that
353 + // does. The fallback is not a convenience: a carousel's frames
354 + // are pictures, so there is nothing reachable inside one and
355 + // focus can never be in it. Without this the one widget that
356 + // asked for these keys could not be reached by them.
357 + if let Some(slot) = self.moving(&reaches) {
358 + let steps = if matches!(key, Key::Right) { 1 } else { -1 };
359 + let slot = slot.clone();
360 + self.view.show_by(&slot, steps);
361 + }
362 + Step::Idle
363 + }
364 +
346 365 // An overlay first: Escape closes what is on top before it goes
347 366 // back, which is what Escape means everywhere else it is bound.
348 367 Key::Escape => {
@@ -580,9 +599,48 @@
580 599 Key::PageUp => "pageup".into(),
581 600 Key::PageDown => "pagedown".into(),
582 601 Key::Backspace => "backspace".into(),
602 + Key::Left => "left".into(),
603 + Key::Right => "right".into(),
583 604 })
584 605 }
585 606
607 + /// The region the arrow keys move, if the screen has one.
608 + ///
609 + /// The one the caret is in when that region shows one child at a time, and
610 + /// otherwise the first such region in draw order. Two rules rather than one
611 + /// because focus is not always a usable answer here: a carousel holds
612 + /// pictures, nothing in it is reachable, and a rule that only ever asked
613 + /// where the caret was would leave the widget that wanted these keys unable
614 + /// to be reached by them.
615 + ///
616 + /// A screen with two of these and no focus in either moves the first, which
617 + /// is arbitrary and is said out loud rather than hidden. Nothing in the tree
618 + /// has two yet; the screen that does is the one that will want a reachable
619 + /// control instead, and that is a `Spot` rather than a rule here.
620 + fn moving<'a>(&'a self, reaches: &[crate::focus::Reach]) -> Option<&'a Slot> {
621 + let here = reaches
622 + .get(self.view.focus())
623 + .and_then(|reach| self.find(&reach.region))
624 + .filter(|slot| slot.showing.selective());
625 + here.or_else(|| self.screen.slots.iter().find_map(Self::selective))
626 + }
627 +
628 + /// This slot or the first under it that shows one child at a time.
629 + fn selective(slot: &Slot) -> Option<&Slot> {
630 + if slot.showing.selective() {
631 + return Some(slot);
632 + }
633 + slot.body.iter().find_map(|node| match node {
634 + Node::Region(inner) => Self::selective(inner),
635 + _ => None,
636 + })
637 + }
638 +
639 + /// The slot under this address, anywhere on the screen.
640 + fn find(&self, region: &str) -> Option<&Slot> {
641 + self.screen.slots.iter().find_map(|slot| slot.find(region))
642 + }
643 +
586 644 /// Close the overlay on top, if there is one.
587 645 ///
588 646 /// The layer underneath comes back exactly as it was left: its own focus,
@@ -1353,3 +1353,172 @@
1353 1353 );
1354 1354 assert!(!runtime.overlaid());
1355 1355 }
1356 +
1357 + /// A carousel: three captioned frames, the first up, no label anywhere.
1358 + fn gallery() -> Slot {
1359 + Slot::widget("shots", "carousel")
1360 + .extend((0..3).map(|n| {
1361 + Node::Image(quasi_router::Picture::new(
1362 + format!("/shot-{n}.png"),
1363 + format!("shot {n}"),
1364 + ))
1365 + }))
1366 + .showing_one(0)
1367 + }
1368 +
1369 + /// Draw a screen with a view that has been moved.
1370 + fn with_view(screen: &Screen, view: &View, width: u16, height: u16) -> Vec<String> {
1371 + let area = Rect::new(0, 0, width, height);
1372 + let mut buf = Buffer::empty(area);
1373 + tui().screen(screen, view, area, &mut buf);
1374 + rows(&buf)
1375 + }
1376 +
1377 + #[test]
1378 + fn a_carousel_is_one_frame_and_a_row_rather_than_a_stack() {
1379 + // `c0b63ea9`'s terminal half, and it was never a drawing change: until
1380 + // `Showing` existed a terminal had no way to learn that a stack of pictures
1381 + // was meant to be one picture, so it honestly drew the stack.
1382 + let screen = Screen::list_detail("Product", false).with(gallery());
1383 + let out = shown(&screen, 90, 12).join("\n");
1384 +
1385 + assert!(out.contains("shot 0"), "{out}");
1386 + assert!(!out.contains("shot 1"), "{out}");
1387 + assert!(out.contains("< Prev >"), "{out}");
1388 + assert!(out.contains("1 / 3"), "{out}");
1389 + assert!(out.contains("< Next >"), "{out}");
1390 + }
1391 +
1392 + #[test]
1393 + fn the_row_is_under_the_frame_and_overlays_nothing() {
1394 + // The reason the chrome ports at all. A terminal cannot honestly overlay
1395 + // anything, so the arrows a browser drew over the picture had no form here
1396 + // -- and Max had already called them cluttered in the browser.
1397 + let screen = Screen::list_detail("Product", false).with(gallery());
1398 + let out = shown(&screen, 90, 12);
1399 +
1400 + let frame = out.iter().position(|row| row.contains("shot 0"));
1401 + let row = out.iter().position(|row| row.contains("< Prev >"));
1402 + assert!(frame < row, "{out:?}");
1403 + }
1404 +
1405 + #[test]
1406 + fn a_terminal_draws_the_same_chrome_without_knowing_what_a_carousel_is() {
1407 + // The whole design in one assertion: the widget's name is changed and the
1408 + // chrome is identical, because nothing in this renderer reads it.
1409 + let named = Screen::list_detail("Product", false).with(gallery());
1410 + let mut other = gallery();
1411 + other.kind = RegionKind::Widget {
1412 + name: "lookbook".into(),
1413 + };
1414 + let unnamed = Screen::list_detail("Product", false).with(other);
1415 +
1416 + assert_eq!(shown(&named, 90, 12), shown(&unnamed, 90, 12));
1417 + }
1418 +
1419 + #[test]
1420 + fn the_arrow_keys_move_a_carousel_the_caret_can_never_be_inside() {
1421 + // A frame is a picture, so nothing in a carousel is reachable and focus can
1422 + // never land in it. That is why `moving` falls back to the first such region
1423 + // rather than only ever asking where the caret is.
1424 + let screen = Screen::list_detail("Product", false).with(gallery());
1425 + let mut view = View::new();
1426 +
1427 + let slot = screen.slots[0]
1428 + .find("shots")
1429 + .expect("the carousel is there");
1430 + view.show_by(slot, 1);
1431 + let out = with_view(&screen, &view, 90, 12).join("\n");
1432 +
1433 + assert!(out.contains("shot 1"), "{out}");
1434 + assert!(out.contains("2 / 3"), "{out}");
1435 + }
1436 +
1437 + #[test]
1438 + fn moving_past_the_last_frame_wraps() {
1439 + // `View::advance`'s reason: a terminal has nothing to show you that you are
1440 + // at the end, so a next key that stops dead reads as a broken key.
1441 + let screen = Screen::list_detail("Product", false).with(gallery());
1442 + let slot = screen.slots[0]
1443 + .find("shots")
1444 + .expect("the carousel is there");
1445 + let mut view = View::new();
1446 +
1447 + view.show_by(slot, -1);
1448 + assert_eq!(view.shown(slot), Some(2));
1449 + view.show_by(slot, 1);
1450 + assert_eq!(view.shown(slot), Some(0));
1451 + }
1452 +
1453 + #[test]
1454 + fn labelled_children_draw_a_strip_above_the_pane_it_opens() {
1455 + // `6af6810e`. A tab group had a kind and no way to say which tab was open or
1456 + // what it was called, so this drew the first and used the slot id as a
1457 + // heading. Both halves are answered by the same member.
1458 + let screen = Screen::list_detail("Project", false).with(
1459 + Slot::new("detail", RegionKind::TabGroup)
1460 + .with(Node::Region(
1461 + Slot::new("overview", RegionKind::Pane)
1462 + .label("Overview")
1463 + .with(Node::text("the summary")),
1464 + ))
1465 + .with(Node::Region(
1466 + Slot::new("files", RegionKind::Pane)
1467 + .label("Files")
1468 + .with(Node::text("the files")),
1469 + ))
1470 + .showing_one(1),
1471 + );
1472 + let out = shown(&screen, 60, 16);
1473 + let joined = out.join("\n");
1474 +
1475 + assert!(joined.contains("Overview"), "{joined}");
1476 + assert!(joined.contains("Files"), "{joined}");
1477 + // The open tab's pane, and only it.
1478 + assert!(joined.contains("the files"), "{joined}");
1479 + assert!(!joined.contains("the summary"), "{joined}");
1480 + // A strip, not a counter row.
1481 + assert!(!joined.contains("< Prev >"), "{joined}");
1482 +
1483 + let strip = out.iter().position(|row| row.contains("Overview"));
1484 + let pane = out.iter().position(|row| row.contains("the files"));
1485 + assert!(strip < pane, "{out:?}");
1486 + }
1487 +
1488 + #[test]
1489 + fn a_closed_disclosure_draws_its_name_and_nothing_under_it() {
1490 + // `871e7f21`, which turns out to be `AtMostOne` and not a member of its own.
1491 + let disclosure = |shown: Option<usize>| {
1492 + Screen::list_detail("Item", false).with(
1493 + Slot::widget("more", "disclosure")
1494 + .with(Node::Region(
1495 + Slot::new("body", RegionKind::Pane)
1496 + .label("Technical details")
1497 + .with(Node::text("the rest")),
1498 + ))
1499 + .showing_at_most_one(shown),
1500 + )
1501 + };
1502 +
1503 + let closed = shown(&disclosure(None), 60, 12).join("\n");
1504 + assert!(closed.contains("Technical details"), "{closed}");
1505 + assert!(!closed.contains("the rest"), "{closed}");
1506 +
1507 + let open = shown(&disclosure(Some(0)), 60, 12).join("\n");
1508 + assert!(open.contains("the rest"), "{open}");
1509 + }
1510 +
1511 + #[test]
1512 + fn a_region_showing_everything_draws_what_it_always_drew() {
1513 + // The additive claim, checked from the other renderer's side too. Nothing
1514 + // written before `Showing` existed changes.
1515 + let screen = Screen::list_detail("Tasks", false).with(
1516 + Slot::new("main", RegionKind::Pane)
1517 + .with(Node::text("first"))
1518 + .with(Node::text("second")),
1519 + );
1520 + let out = shown(&screen, 40, 12).join("\n");
1521 +
1522 + assert!(out.contains("first") && out.contains("second"), "{out}");
1523 + assert!(!out.contains("< Prev >"), "{out}");
1524 + }
@@ -90,6 +90,19 @@
90 90 /// screen is a new set. Which set it is does not need storing: the screen
91 91 /// beside this one says.
92 92 ticked: BTreeSet<String>,
93 + /// Which child each region is showing, by
94 + /// [`Slot::id`](quasi_router::Slot::id).
95 + ///
96 + /// `4dcd241b`, and the sixth of the same discovery. A description says a
97 + /// region shows one of its children at a time and says which one it started
98 + /// on; where the reader has moved to since is this renderer's, exactly as
99 + /// [`scroll`](Self::scroll) is. A browser owns this too and never made
100 + /// anyone notice, because moving a carousel there is a class on an element
101 + /// the document already holds.
102 + ///
103 + /// Absent means the description's own answer still stands, which is what
104 + /// makes an untouched screen draw what the handler said.
105 + shown: BTreeMap<String, usize>,
93 106 }
94 107
95 108 impl View {
@@ -197,6 +210,44 @@
197 210 self.scroll.insert(region.to_string(), rows);
198 211 }
199 212
213 + /// Which child a region is showing, given what its description says.
214 + ///
215 + /// [`scroll`](Self::scroll)'s shape with one difference: a scroll has an
216 + /// obvious zero and this does not, so the description's own answer is the
217 + /// floor rather than the top of the region.
218 + #[must_use]
219 + pub fn shown(&self, slot: &quasi_router::Slot) -> Option<usize> {
220 + match self.shown.get(&slot.id) {
221 + Some(at) => Some((*at).min(slot.body.len().saturating_sub(1))),
222 + None => slot.current(),
223 + }
224 + }
225 +
226 + /// Move a region to another of its children, wrapping at both ends.
227 + ///
228 + /// Wrapping for [`advance`](Self::advance)'s reason: a terminal has nothing
229 + /// to show you that you are at the last frame, so a next key that stops
230 + /// dead reads as a broken key rather than as the end of the gallery.
231 + ///
232 + /// A closed dismissible region opens on its first child, which is the only
233 + /// reading of "next" that does anything from closed.
234 + pub fn show_by(&mut self, slot: &quasi_router::Slot, steps: isize) {
235 + let count = slot.body.len();
236 + if count == 0 {
237 + return;
238 + }
239 + let at = match self.shown(slot) {
240 + Some(at) => (at as isize + steps).rem_euclid(count as isize) as usize,
241 + None => 0,
242 + };
243 + self.shown.insert(slot.id.clone(), at);
244 + }
245 +
246 + /// Show a particular child of a region.
247 + pub fn show(&mut self, region: &str, at: usize) {
248 + self.shown.insert(region.to_string(), at);
249 + }
250 +
200 251 /// Whether this value is ticked.
201 252 #[must_use]
202 253 pub fn is_ticked(&self, value: &str) -> bool {
@@ -254,6 +305,7 @@
254 305 self.edits.clear();
255 306 self.scroll.clear();
256 307 self.ticked.clear();
308 + self.shown.clear();
257 309 self.focus = 0;
258 310 }
259 311