Skip to main content

max / quasi

Make a list a table that declared no columns, and delete Node::List Max ruled it on 2026-09-06, finishing what the row merge started: the model was unified a day earlier and the node was still two. `List { rows, more }` was `Table { columns, rows, more }` with the columns left out, so that is what it becomes. No `look` member and no flag beside the rows: whether a table declared columns is a fact it already carried, and `columns.is_empty()` is the whole of the test. A row keyed by `CellKey::Role` answers the default column set; a table that names no columns of its own is a table using that set. **A list still emits `<ul>`.** The model is unified and the presentation is not, which was the objection to this change and is answered rather than overruled: a feed of items is a list to a screen reader, and announcing it as a one-column table would be a worse document. Every renderer picks its arrangement from the column list and emits exactly what it emitted before. The webview's HTML assertions are unchanged and green. Three latent defects fell out, all of the same shape -- two arms that had to agree and did not: - `View::seed` walked `Node::List` alone, so an egui table arriving with rows already ticked seeded nothing and drew its ticks empty. - The terminal's focus walk pushed no stop for a table's pager while `draw_table` has drawn one the whole time, so a paged table's Prev and Next were on screen and unreachable. Same class as `27f2331e` one node over. - `Node::names` and `Node::clocks_into` hand-rolled `Row::names` and `Row::clocks_into` in their table arms while the list arms called them. `Timeline` stays a node of its own. What it adds is a `Placement` on every entry, which is a fact a `Row` does not carry and a column cannot supply. The grammar keeps both `list {` and `table {`. Unlike wave 3's return types those are the bodies rather than a second name for one, and they differ in what they accept: a table body takes `column` members and a list body does not. 27 test blocks green, clippy clean with -D warnings.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session
https://claude.ai/code/session_01P8ostB2UmZJGj5WjSHRSot
Author: Max Johnson <me@maxj.phd> · 2026-09-07 01:23 UTC
Signed with PGP, not checked
Commit: ea3410b404c7328c273401ff20cd30d0e09bb1e6
Parent: ba35823
12 files changed, +199 insertions, -147 deletions
@@ -1518,7 +1518,11 @@
1518 1518 #(#statements)*
1519 1519 let mut #rest = ::std::option::Option::None;
1520 1520 #(#more)*
1521 - ::quasi_router::Node::List { rows: #rows, more: #rest }
1521 + ::quasi_router::Node::Table {
1522 + columns: ::std::vec::Vec::new(),
1523 + rows: #rows,
1524 + more: #rest,
1525 + }
1522 1526 }))
1523 1527 }
1524 1528
@@ -146,7 +146,6 @@
146 146 Node::Field(_)
147 147 | Node::Region(_)
148 148 | Node::Form { .. }
149 - | Node::List { .. }
150 149 | Node::Table { .. }
151 150 | Node::Timeline { .. }
152 151 | Node::Stats { .. } => container(pass, ui, node),
@@ -301,7 +300,6 @@
301 300 | Node::StandIn { .. }
302 301 | Node::Field(_)
303 302 | Node::Form { .. }
304 - | Node::List { .. }
305 303 | Node::Table { .. }
306 304 | Node::Timeline { .. }
307 305 | Node::Stats { .. }
@@ -695,7 +693,15 @@
695 693 form(pass, ui, fields, submit, action);
696 694 }
697 695
698 - Node::List { rows, more, .. } => {
696 + // **A table that declared no columns is a list, and draws as one.** One
697 + // node since the 2026-09-06 collapse; the guard is where the two
698 + // arrangements part company in this renderer, and the arm below is the
699 + // grid. Both hold the same `Row`.
700 + Node::Table {
701 + columns,
702 + rows,
703 + more,
704 + } if columns.is_empty() => {
699 705 // A row a shut branch covers is not drawn at all, which is the
700 706 // whole of what folding is. `quasi_router::folded` reads it, so a
701 707 // window and a terminal fold the same rows.
@@ -1572,7 +1578,7 @@
1572 1578 //
1573 1579 // **The rect is the `horizontal`'s own response, not `ui.min_rect()`.** That
1574 1580 // was the rect until 2026-08-17, and every row of a list is drawn into one
1575 - // shared `Ui` -- `Node::List` loops `list_row` over it -- so `min_rect` grew
1581 + // shared `Ui` -- a column-less table loops `list_row` over it -- so `min_rect` grew
1576 1582 // with each row and the fourth row's target covered the first four. Four
1577 1583 // overlapping rects, one pointer, and the row that answered was whichever
1578 1584 // egui hit last rather than the one under the cursor. The `horizontal`
@@ -1260,7 +1260,8 @@
1260 1260 ])],
1261 1261 more: Some(quasi_router::Rest::more(1, Action::get("/samples?from=1"))),
1262 1262 })
1263 - .with(Node::List {
1263 + .with(Node::Table {
1264 + columns: Vec::new(),
1264 1265 rows: vec![quasi_router::Row::new("one"), quasi_router::Row::new("two")],
1265 1266 more: Some(quasi_router::Rest::more(2, Action::get("/rows?from=2"))),
1266 1267 })
@@ -542,10 +542,14 @@
542 542 /// Applied once on arrival rather than read on every draw: after this the
543 543 /// user's ticks are the truth, and a description that kept overriding them
544 544 /// would undo a tick the moment anything redrew.
545 + ///
546 + /// This walked `Node::List` alone until the 2026-09-06 collapse, so a
547 + /// *table* arriving with rows already ticked seeded nothing and the ticks
548 + /// drew empty. One node now, and one walk reaches both arrangements.
545 549 pub fn seed(&mut self, screen: &Screen) {
546 550 for slot in &screen.slots {
547 551 for placed in &slot.body {
548 - if let Node::List { rows, .. } = &placed.node {
552 + if let Node::Table { rows, .. } = &placed.node {
549 553 for row in rows {
550 554 if let (Some(true), Some(value)) = (row.selected, row.value.as_ref()) {
551 555 self.ticked.insert(value.clone());
@@ -169,9 +169,9 @@
169 169 ///
170 170 /// Implemented for every element rather than for a bucket of them, which is the
171 171 /// difference between this and the two-tier shape the review also costed. Two
172 - /// tiers leave [`Node::List`], [`Node::Table`] and [`Node::Form`] in neither,
173 - /// so all three become special-cased members with hardcoded children and the
174 - /// enumeration problem moves up a level. [`Containment::Collection`] states
172 + /// tiers leave [`Node::Table`] and [`Node::Form`] in neither, so both become
173 + /// special-cased members with hardcoded children and the enumeration problem
174 + /// moves up a level. [`Containment::Collection`] states
175 175 /// outright what that shape has to special-case.
176 176 pub trait Element {
177 177 /// What this may hold. Most of the vocabulary is a label and takes the
@@ -226,8 +226,7 @@
226 226 // collection of one.
227 227 Self::Field(field) => field.containment(),
228 228 Self::Form { .. } => Containment::Collection(Of::Fields),
229 - Self::List { .. } => Containment::Collection(Of::Rows),
230 - // Rows, the same as a list. What a timeline changes is *where* each
229 + // Rows, the same as a table. What a timeline changes is *where* each
231 230 // row goes, and a placement is no more content than a table's
232 231 // columns are: it says how the collection is arranged, not what is
233 232 // in it. So this is `Of::Rows` rather than a kind of its own, and a
@@ -349,10 +348,6 @@
349 348 submit: "Save".into(),
350 349 fields: Vec::new(),
351 350 },
352 - Node::List {
353 - rows: Vec::new(),
354 - more: None,
355 - },
356 351 Node::Table {
357 352 columns: Vec::new(),
358 353 rows: Vec::new(),
@@ -1877,7 +1877,7 @@
1877 1877 ///
1878 1878 /// # What it is not
1879 1879 ///
1880 - /// Not a [`Node::List`] of forms, which is N submits. This is one submit
1880 + /// Not a list of forms, which is N submits. This is one submit
1881 1881 /// carrying N values under one question, which is what makes the two hard parts
1882 1882 /// hard: the names have to come back apart, and a refusal has to be able to say
1883 1883 /// *which* answer is wrong.
@@ -5532,7 +5532,6 @@
5532 5532 .any(|placed| match &placed.node {
5533 5533 Node::Region(slot) => slot.chooses(),
5534 5534 Node::Table { rows, .. } => rows.iter().any(|row| row.chosen.is_some()),
5535 - Node::List { rows, .. } => rows.iter().any(|row| row.chosen.is_some()),
5536 5535 _ => false,
5537 5536 })
5538 5537 }
@@ -6928,8 +6927,8 @@
6928 6927 /// One cell of a table row.
6929 6928 ///
6930 6929 /// A cell was a `String` until then, so a table whose rows carry a control
6931 - /// could not be described at all and had to become a
6932 - /// [`Node::List`], losing its column headers — which is what the MNW server's
6930 + /// could not be described at all and had to become a column-less table,
6931 + /// losing its headers -- which is what the MNW server's
6933 6932 /// SSH-keys tab did, and why it read worse than the Askama original it replaced.
6934 6933 ///
6935 6934 /// # Why the acts sit on the cell and not on the row
@@ -6989,8 +6988,8 @@
6989 6988 /// [`key`](Self::key) saying which column it answers to.
6990 6989 ///
6991 6990 /// A cell was a `String` before that, so a table whose rows carry a control
6992 - /// could not be described at all and had to become a [`Node::List`], losing its
6993 - /// column headers, which is what the MNW server's SSH-keys tab did and why it
6991 + /// could not be described at all and had to become a column-less table, losing
6992 + /// its headers, which is what the MNW server's SSH-keys tab did and why it
6994 6993 /// read worse than the Askama original it replaced.
6995 6994 ///
6996 6995 /// # Why the acts sit on the cell and not on the row
@@ -7984,29 +7983,14 @@
7984 7983 /// The questions, in order.
7985 7984 fields: Vec<Field>,
7986 7985 },
7987 - /// Rows of the same kind of thing.
7988 - List {
7989 - /// The rows, in order.
7990 - rows: Vec<Row>,
7991 - /// What is not shown, if anything is.
7992 - ///
7993 - /// A described list of the first 50 of 400 tasks was indistinguishable
7994 - /// from a described list of 50 tasks, so each app grew its own answer:
7995 - /// goingson a 159-line pagination manager with two consumers that had
7996 - /// each written it separately first, Balanced Breakfast four
7997 - /// `loadMore` sites. Two idioms for one fact, and the fact is what
7998 - /// belongs here — how much more there is and how to ask for it.
7999 - /// Whether that becomes numbered pages, a load-more button or an
8000 - /// infinite scroll is the renderer's.
8001 - more: Option<Rest>,
8002 - },
8003 7986 /// Rows with named columns.
8004 7987 ///
8005 7988 /// # Which containers carry a [`Rest`], and which do not
8006 7989 ///
8007 7990 /// A container whose contents came from a query that can be partial carries
8008 - /// one. That is this and [`List`](Self::List), and nothing else in this
8009 - /// enum: [`Timeline`](Self::Timeline) is bounded by its `Track`, so more of
7991 + /// one. That is this one -- in both its arrangements, since a list is this
7992 + /// node with no columns -- and nothing else in this enum:
7993 + /// [`Timeline`](Self::Timeline) is bounded by its `Track`, so more of
8010 7994 /// it is a different window and that is navigation rather than paging;
8011 7995 /// [`Stats`](Self::Stats) is a fixed set of figures; [`Region`](Self::Region)
8012 7996 /// holds nodes rather than rows. Written down so the next container to
@@ -8018,18 +8002,44 @@
8018 8002 /// could not tell the control belonged to the table above it.
8019 8003 Table {
8020 8004 /// The columns, in order. A cell keyed by position answers these in order.
8005 + ///
8006 + /// **Empty is a list.** That is the whole of what a list is since the
8007 + /// 2026-09-06 collapse, and it is a fact the rows already carried rather
8008 + /// than a flag added beside them: a row whose cells are keyed by
8009 + /// [`CellKey::Role`] answers the default column set, and a table that
8010 + /// declared no columns of its own is a table using that set. So there is
8011 + /// no `look` member and no second variant to keep in step.
8012 + ///
8013 + /// A renderer reads it to choose an arrangement, not to choose a
8014 + /// meaning. `columns.is_empty()` draws the flowed, one-per-line form a
8015 + /// list has always drawn -- `<ul>` in a webview -- and a declared column
8016 + /// list draws a grid. Both are the same node holding the same rows.
8021 8017 columns: Vec<Column>,
8022 8018 /// The rows, in order.
8023 8019 rows: Vec<Row>,
8024 8020 /// What is not shown, if anything is.
8021 + ///
8022 + /// A described list of the first 50 of 400 tasks was indistinguishable
8023 + /// from a described list of 50 tasks, so each app grew its own answer:
8024 + /// goingson a 159-line pagination manager with two consumers that had
8025 + /// each written it separately first, Balanced Breakfast four
8026 + /// `loadMore` sites. Two idioms for one fact, and the fact is what
8027 + /// belongs here -- how much more there is and how to ask for it.
8028 + /// Whether that becomes numbered pages, a load-more button or an
8029 + /// infinite scroll is the renderer's.
8025 8030 more: Option<Rest>,
8026 8031 },
8027 8032 /// Rows placed by when they happen, rather than in order.
8028 8033 ///
8029 - /// The third of the three ways this vocabulary says "several of the same
8034 + /// The second of the two ways this vocabulary says "several of the same
8030 8035 /// kind of thing", and the last one to arrive.
8031 - /// [`List`](Self::List) puts them in order, [`Table`](Self::Table) lines
8032 - /// their parts up in columns, and this one puts them on a clock.
8036 + /// [`Table`](Self::Table) puts them in order and, when it declares columns,
8037 + /// lines their parts up under headings; this one puts them on a clock.
8038 + ///
8039 + /// It was the third of three until the 2026-09-06 collapse, when a list
8040 + /// stopped being a node of its own. A timeline stayed: what it adds is
8041 + /// [`layout::Placement`] on every entry, which is a fact a `Row` does not
8042 + /// carry and a column cannot supply.
8033 8043 ///
8034 8044 /// A row here is an ordinary [`Row`] and gets no new members: the item
8035 8045 /// bodies on goingson's day view are a title, a time, a tag and a tone,
@@ -8090,8 +8100,8 @@
8090 8100 /// and a renderer handed one at a time cannot tell it is looking at a set.
8091 8101 /// The objection to that is real and is answered by what is already here: a
8092 8102 /// node whose value is its grouping sounds like a layout instruction, and
8093 - /// [`List`](Self::List) and [`Table`](Self::Table) have been exactly that
8094 - /// since the beginning without anyone calling them one.
8103 + /// [`Table`](Self::Table) has been exactly that since the beginning without
8104 + /// anyone calling it one.
8095 8105 ///
8096 8106 /// # Why the action is here and not on the figure
8097 8107 ///
@@ -8270,14 +8280,10 @@
8270 8280 // The markup is opaque and holds nothing this crate can find; what
8271 8281 // is walked is the app's own nodes inside the scope.
8272 8282 Self::Canvas(canvas) => canvas.within.iter().find_map(|node| node.holds(name)),
8273 - Self::List { rows, .. } => rows
8274 - .iter()
8275 - .find_map(|row| row.cells.iter().find_map(|cell| cell.content.iter().find_map(|n| n.holds(name)))),
8276 - Self::Table { rows, .. } => rows.iter().find_map(|cells| {
8277 - cells
8278 - .cells
8283 + Self::Table { rows, .. } => rows.iter().find_map(|row| {
8284 + row.cells
8279 8285 .iter()
8280 - .find_map(|cell| cell.content.iter().find_map(|part| part.holds(name)))
8286 + .find_map(|cell| cell.content.iter().find_map(|node| node.holds(name)))
8281 8287 }),
8282 8288 Self::Timeline { entries, .. } => entries.iter().find_map(|placed| {
8283 8289 placed
@@ -8325,7 +8331,7 @@
8325 8331 }
8326 8332 Self::Form { fields, .. } => found.extend(fields),
8327 8333 Self::Region(slot) => found.extend(slot.questions()),
8328 - Self::List { rows, .. } => {
8334 + Self::Table { rows, .. } => {
8329 8335 for row in rows {
8330 8336 for cell in &row.cells {
8331 8337 for node in &cell.content {
@@ -8334,15 +8340,6 @@
8334 8340 }
8335 8341 }
8336 8342 }
8337 - Self::Table { rows, .. } => {
8338 - for cells in rows {
8339 - for cell in &cells.cells {
8340 - for part in &cell.content {
8341 - part.questions(found);
8342 - }
8343 - }
8344 - }
8345 - }
8346 8343 Self::Timeline { entries, .. } => {
8347 8344 for placed in entries {
8348 8345 for cell in &placed.row.cells {
@@ -8393,14 +8390,7 @@
8393 8390 // A control inside the markup is the creator's and carries no id
8394 8391 // this crate handed out, so only the app's own nodes are walked.
8395 8392 Self::Canvas(canvas) => canvas.within.iter().any(|node| node.names(id)),
8396 - Self::List { rows, .. } => rows.iter().any(|row| row.names(id)),
8397 - Self::Table { rows, .. } => rows.iter().any(|cells| {
8398 - cells.menu.iter().any(|act| act.id.as_deref() == Some(id))
8399 - || cells
8400 - .cells
8401 - .iter()
8402 - .any(|cell| cell.content.iter().any(|part| part.names(id)))
8403 - }),
8393 + Self::Table { rows, .. } => rows.iter().any(|row| row.names(id)),
8404 8394 Self::Timeline { entries, .. } => entries.iter().any(|placed| placed.row.names(id)),
8405 8395 // Everything with no control under it. Written out rather than left
8406 8396 // to a wildcard, so a node kind that gains one stops compiling here.
@@ -8644,8 +8634,15 @@
8644 8634 }
8645 8635
8646 8636 /// A list of rows.
8637 + ///
8638 + /// A [`Table`](Self::Table) that declares no columns, which is what a list
8639 + /// is since the 2026-09-06 collapse. The constructor stays because "a list
8640 + /// of rows" is what the caller means and `Table { columns: vec![], .. }` is
8641 + /// how the vocabulary spells it, not something every caller should have to
8642 + /// spell.
8647 8643 pub fn list(rows: impl IntoIterator<Item = Row>) -> Self {
8648 - Self::List {
8644 + Self::Table {
8645 + columns: Vec::new(),
8649 8646 rows: rows.into_iter().collect(),
8650 8647 more: None,
8651 8648 }
@@ -8653,13 +8650,13 @@
8653 8650
8654 8651 /// The same list, saying there is more of it.
8655 8652 ///
8656 - /// A no-op on anything that is not a [`Self::List`], which is the one place
8653 + /// A no-op on anything that is not a [`Self::Table`], which is the one place
8657 8654 /// this file allows that: the alternative is a constructor taking rows and a
8658 8655 /// `Rest` together, and every call site that has no more rows then passes a
8659 8656 /// `None` to say so.
8660 8657 #[must_use]
8661 8658 pub fn and_more(mut self, rest: Rest) -> Self {
8662 - if let Self::List { more, .. } = &mut self {
8659 + if let Self::Table { more, .. } = &mut self {
8663 8660 *more = Some(rest);
8664 8661 }
8665 8662 self
@@ -8845,21 +8842,14 @@
8845 8842 return;
8846 8843 }
8847 8844 match self {
8848 - Self::List { rows, .. } => {
8849 - for row in rows {
8850 - row.clocks_into(found);
8851 - }
8852 - }
8853 8845 Self::Timeline { entries, .. } => {
8854 8846 for placed in entries {
8855 8847 placed.row.clocks_into(found);
8856 8848 }
8857 8849 }
8858 8850 Self::Table { rows, .. } => {
8859 - for cell in rows.iter().flat_map(|row| &row.cells) {
8860 - for part in &cell.content {
8861 - part.clocks_into(found);
8862 - }
8851 + for row in rows {
8852 + row.clocks_into(found);
8863 8853 }
8864 8854 }
8865 8855 Self::StandIn { .. } => {}
@@ -11223,7 +11213,8 @@
11223 11213 )),
11224 11214 );
11225 11215 let screen = Screen::new("Task", layout::Arrangement::sidebar_content()).with(
11226 - Slot::group("body").with(Node::List {
11216 + Slot::group("body").with(Node::Table {
11217 + columns: Vec::new(),
11227 11218 rows: vec![row],
11228 11219 more: None,
11229 11220 }),
@@ -591,31 +591,6 @@
591 591 });
592 592 }
593 593
594 - Node::List { rows, more } => {
595 - // A row a shut branch covers is not on the screen, so it is not
596 - // reachable and takes no stop. The drawing makes the same reading
597 - // from the same function, which is what keeps the caret on the row
598 - // it is painted on.
599 - for (_, row) in crate::outline::showing(rows, local.view()) {
600 - push_row(row, region, local, found);
601 - }
602 - if let Some(rest) = more {
603 - // Prev, then the pages, then Next -- `node::rest_pieces`' own
604 - // order, which is the order the line is drawn in and the order
605 - // the webview prints them. Read off that function rather than
606 - // written out again, because the drawing claims one position
607 - // per reachable piece and a second list here is a second answer
608 - // to how many there are.
609 - for piece in crate::node::rest_pieces(rest) {
610 - if let Some(action) = piece.action {
611 - push!(Spot::More {
612 - action: action.clone(),
613 - });
614 - }
615 - }
616 - }
617 - }
618 -
619 594 // Reachable exactly as a list's rows are. A placement changes where a
620 595 // row is drawn, not whether it can be reached, and the terminal draws
621 596 // these in the order given -- so tab order and reading order agree
@@ -630,8 +605,26 @@
630 605 // cell is reached by stepping into the row rather than by tabbing to
631 606 // it. See [`Spot::Row`]'s `inside` for why the layout leaves no other
632 607 // way, and [`inside`] for the walk.
633 - Node::Table { rows, .. } => {
608 + Node::Table {
609 + columns,
610 + rows,
611 + more,
612 + } => {
613 + // A row a shut branch covers is not on the screen, so it is not
614 + // reachable and takes no stop. The drawing makes the same reading
615 + // from the same function, which is what keeps the caret on the row
616 + // it is painted on.
634 617 for (_, cells) in crate::outline::showing(rows, local.view()) {
618 + // **How a row is entered is the one thing the two arrangements
619 + // disagree about.** A list row's controls are stops of their
620 + // own, walked straight after the row; a table row's cells are
621 + // reached by stepping *into* the row, because the grid leaves
622 + // no other way. One node since the 2026-09-06 collapse, and
623 + // this is where it forks.
624 + if columns.is_empty() {
625 + push_row(cells, region, local, found);
626 + continue;
627 + }
635 628 // A tickable row is reachable even when nothing opens it: the
636 629 // tick is the affordance, and a row that draws a box a reader
637 630 // cannot reach is the dead affordance `5f2b8753` was filed for,
@@ -665,6 +658,28 @@
665 658 });
666 659 }
667 660 }
661 +
662 + // Prev, then the pages, then Next -- `node::rest_pieces`' own
663 + // order, which is the order the line is drawn in and the order the
664 + // webview prints them. Read off that function rather than written
665 + // out again, because the drawing claims one position per reachable
666 + // piece and a second list here is a second answer to how many there
667 + // are.
668 + //
669 + // This was the list arm's alone until the collapse, and `draw_table`
670 + // has drawn a table's pager the whole time. So a paged table's Prev
671 + // and Next were on the screen and the caret could not land on
672 + // either. Same class as `27f2331e` one node over: drawn by one pass
673 + // and unknown to the other.
674 + if let Some(rest) = more {
675 + for piece in crate::node::rest_pieces(rest) {
676 + if let Some(action) = piece.action {
677 + push!(Spot::More {
678 + action: action.clone(),
679 + });
680 + }
681 + }
682 + }
668 683 }
669 684
670 685 Node::Region(slot) => slot_spots(slot, local, found),