Skip to main content

max / quasi

Make a list a one-column table, and merge the row and cell types Wave 0 of the vocabulary collapse. Row and Cells were two structs carrying eleven members each, nine of them the same fact under the same name, and ten of their fourteen builders were the same method written twice. They were never two things: Cells' own doc said "one row of a table", and every member that was not content argued in its own doc that it "takes no column". What actually differed was one field, how the content is addressed. A list's parts were keyed by role and a table's values by column, which the wiki note construction-holds-the-invariant already identified as the same operation, a lookup into a declared key set. The only difference was who declares the keys. Now a list declares its own, so there is no difference left. Cells is an alias for Row. Part is merged into Cell, which gains a key saying which column it answers to, and holds a run rather than a single node. Cell's flow and priority become overrides that fall back to the key, which is the defaulting chain Part::worth already implemented for roles. RowPart's six variants are reinterpreted as the default column set, unedited, so makeover-layout, makeover-webview and makeover-touch need no change and no publish, and `list { row "x" { secondary "y" } }` still says what it said. The private staging vector Cells used for named cells is gone. A named cell now sits in the row with the others as CellKey::Named and Table::row resolves it, so there is one collection instead of two and a mechanism fewer. Both debug assertions from d41d00a survive: a cell naming no column, and two columns sharing a name. Cell::span is defined and unread, so the 2D work adds spanning without a second breaking change. The renderers are moved only far enough to compile. Collapsing their duplicated per-container functions is waves 1a to 1c, which need a green base to run in parallel from, which is why the mechanical half is here. Whole workspace suite green, clippy clean apart from one pre-existing wildcard in quasi-declare.
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-05 21:42 UTC
Signed with PGP, not checked
Commit: 194cded6365e5b2a58a82b106c6d33e9a9dc3880
Parent: 03d05a2
11 files changed, +573 insertions, -357 deletions
@@ -41,7 +41,7 @@
41 41 use makeover_layout as layout;
42 42 use makeover_webview::form::escape_into;
43 43 use quasi_http::Serves;
44 - use quasi_router::screen::{Act, Cell, Cells, Column};
44 + use quasi_router::screen::{Act, Cell, Column, Row};
45 45 use quasi_router::{Action, Node, RegionKind, Slot};
46 46 use quasi_webview::Webview;
47 47
@@ -209,7 +209,7 @@
209 209 ],
210 210 rows: (0..d.buyer_rows())
211 211 .map(|r| {
212 - Cells::new([
212 + Row::cells([
213 213 Cell::new(d.buyer(r, B::Username))
214 214 .activate(Action::get(format!("/u/{}", d.buyer(r, B::Username)))),
215 215 Cell::new(d.buyer(r, B::Email))
@@ -236,7 +236,7 @@
236 236 ],
237 237 rows: (0..d.shared_rows())
238 238 .map(|r| {
239 - Cells::new([
239 + Row::cells([
240 240 Cell::new(d.shared(r, S::Name))
241 241 .activate(Action::get(format!("/u/{}", d.shared(r, S::Username)))),
242 242 Cell::acts([Act::new(
@@ -1551,8 +1551,8 @@
1551 1551 // problem rather than a worth problem, and `budget` below is the
1552 1552 // answer to it.
1553 1553 let spacing = ui.spacing().item_spacing.x;
1554 - for (index, part) in row.parts.iter().enumerate() {
1555 - let after = tail_width(ui, pass.immediate, &row.parts[index + 1..], spacing);
1554 + for (index, part) in row.cells.iter().enumerate() {
1555 + let after = tail_width(ui, pass.immediate, &row.cells[index + 1..], spacing);
1556 1556 row_part(pass, ui, part, after);
1557 1557 }
1558 1558 });
@@ -1672,10 +1672,10 @@
1672 1672 /// rather than parts. Same rule and the same reason: it is where the row's name
1673 1673 /// already is.
1674 1674 fn cells_name(row: &quasi_router::Cells) -> String {
1675 - row.values
1675 + row.cells
1676 1676 .iter()
1677 1677 .find_map(|cell| {
1678 - let said = row_leaf_text(&cell.parts);
1678 + let said = row_leaf_text(&cell.content);
1679 1679 (!said.is_empty()).then_some(said)
1680 1680 })
1681 1681 .unwrap_or_default()
@@ -1700,7 +1700,11 @@
1700 1700 /// leaves. The first textual part is what `Row::new` takes and what a reader
1701 1701 /// would call the row, so there is nothing to invent here.
1702 1702 fn row_name(row: &quasi_router::Row) -> String {
1703 - let parts: Vec<Node> = row.parts.iter().map(|part| part.node.clone()).collect();
1703 + let parts: Vec<Node> = row
1704 + .cells
1705 + .iter()
1706 + .flat_map(|cell| cell.content.iter().cloned())
1707 + .collect();
1704 1708 row_leaf_text(&parts)
1705 1709 }
1706 1710
@@ -1710,24 +1714,30 @@
1710 1714 /// where it sits in the run and the node says what it is. That is the property
1711 1715 /// the containment migration bought every renderer, and it is why a row does not
1712 1716 /// need a second switch over member types here.
1713 - fn row_part(pass: &mut Pass<'_>, ui: &mut Ui, part: &quasi_router::Part, after: Option<f32>) {
1717 + fn row_part(pass: &mut Pass<'_>, ui: &mut Ui, part: &quasi_router::Cell, after: Option<f32>) {
1714 1718 // The cap, settled by `7bfb554a`: a run is a line, and a part takes the
1715 1719 // lines its flow allows and no more. Only the text leaves need it -- a
1716 1720 // token, a control and a meter are single-line widgets already, and putting
1717 1721 // them through a galley would be saying something about them that the
1718 1722 // description did not.
1719 - match &part.node {
1720 - Node::Text { text, .. } => capped(pass.immediate, ui, text, part.flow, after),
1721 - Node::Rich { source, .. } => {
1722 - capped(
1723 - pass.immediate,
1724 - ui,
1725 - &docengine::render_plain(source),
1726 - part.flow,
1727 - after,
1728 - );
1723 + // A cell holds a run since the 2026-09-05 collapse, so this walks it. The
1724 + // flow is the cell's, which is the column's unless the description narrowed
1725 + // it, and it applies to every text leaf in the run.
1726 + let flow = part.room();
1727 + for node in &part.content {
1728 + match node {
1729 + Node::Text { text, .. } => capped(pass.immediate, ui, text, flow, after),
1730 + Node::Rich { source, .. } => {
1731 + capped(
1732 + pass.immediate,
1733 + ui,
1734 + &docengine::render_plain(source),
1735 + flow,
1736 + after,
1737 + );
1738 + }
1739 + other => draw(pass, ui, other),
1729 1740 }
1730 - _ => draw(pass, ui, &part.node),
1731 1741 }
1732 1742 }
1733 1743
@@ -1741,7 +1751,7 @@
1741 1751 fn tail_width(
1742 1752 ui: &Ui,
1743 1753 immediate: &Immediate,
1744 - rest: &[quasi_router::Part],
1754 + rest: &[quasi_router::Cell],
1745 1755 spacing: f32,
1746 1756 ) -> Option<f32> {
1747 1757 if rest.is_empty() {
@@ -1754,9 +1764,22 @@
1754 1764 Some(total)
1755 1765 }
1756 1766
1757 - /// The width a part wants when nothing is squeezing it.
1758 - fn intrinsic_width(ui: &Ui, immediate: &Immediate, part: &quasi_router::Part) -> Option<f32> {
1759 - match &part.node {
1767 + /// The width a cell wants when nothing is squeezing it.
1768 + ///
1769 + /// A cell holds a run since the 2026-09-05 collapse, so this is the sum of its
1770 + /// leaves. `None` if any leaf cannot say, which is the same answer the single
1771 + /// node gave before: a run containing something unmeasurable is unmeasurable.
1772 + fn intrinsic_width(ui: &Ui, immediate: &Immediate, part: &quasi_router::Cell) -> Option<f32> {
1773 + let mut total = 0.0;
1774 + for node in &part.content {
1775 + total += leaf_width(ui, immediate, node)?;
1776 + }
1777 + Some(total)
1778 + }
1779 +
1780 + /// The width one leaf wants when nothing is squeezing it.
1781 + fn leaf_width(ui: &Ui, immediate: &Immediate, node: &Node) -> Option<f32> {
1782 + match node {
1760 1783 Node::Text { text, .. } => Some(text_width(ui, text)),
1761 1784 Node::Rich { source, .. } => Some(text_width(ui, &docengine::render_plain(source))),
1762 1785 // A token is its words plus `token_padding` on both sides, which is
@@ -2091,7 +2114,7 @@
2091 2114 let Some(index) = columns.iter().position(|c| c.name == column.name) else {
2092 2115 break 'cell;
2093 2116 };
2094 - let Some(cell) = row.values.get(index) else {
2117 + let Some(cell) = row.cells.get(index) else {
2095 2118 break 'cell;
2096 2119 };
2097 2120 // The outline, in the first column and nowhere else. A table
@@ -2119,7 +2142,7 @@
2119 2142 };
2120 2143 ui.label(RichText::new(sign).monospace().color(colour));
2121 2144 }
2122 - for node in &cell.parts {
2145 + for node in &cell.content {
2123 2146 // A cell's contents are ordinary nodes, but a press inside one
2124 2147 // cannot reach the pass from here. Only the two that carry an
2125 2148 // address are collected; everything else draws.
@@ -9,7 +9,7 @@
9 9 use makeover_immediate::Palette;
10 10 use quasi_router::{
11 11 Act, Action, Address, Chrome, Consult, Field, Frame, Message, Method, Node, Outcome,
12 - RegionKind, Request, Response, Run, Screen, Slot, layout,
12 + RegionKind, Request, Response, Row, Run, Screen, Slot, layout,
13 13 };
14 14
15 15 use crate::view::Asking;
@@ -94,7 +94,7 @@
94 94 Node::list([quasi_router::Row::new("One")]),
95 95 Node::Table {
96 96 columns: vec![quasi_router::Column::new("Name")],
97 - rows: vec![quasi_router::Cells::new([quasi_router::Cell::new("One")])],
97 + rows: vec![quasi_router::Row::cells([quasi_router::Cell::new("One")])],
98 98 more: None,
99 99 },
100 100 Node::Timeline {
@@ -1066,14 +1066,14 @@
1066 1066 // The node this renderer declined to draw until 2026-08-14. The narrowing
1067 1067 // and the tracks are makeover-immediate's; what is asserted here is that a
1068 1068 // described table reaches them at all, with a cell holding an ordinary node.
1069 - use quasi_router::{Cell, Cells, Column};
1069 + use quasi_router::{Cell, Column};
1070 1070
1071 1071 let columns = vec![Column::new("name"), Column::new("bpm")];
1072 1072 let rows = vec![
1073 - Cells::new(["kick.wav", "120"]),
1073 + Row::cells(["kick.wav", "120"]),
1074 1074 // A cell holding a control rather than a value, which is what
1075 1075 // `CellPart` exists to separate and what a file list actually has.
1076 - Cells::new([
1076 + Row::cells([
1077 1077 Cell::new("snare.wav"),
1078 1078 Cell::acts([Act::new("Play", Action::post("/play"))]),
1079 1079 ]),
@@ -1137,15 +1137,15 @@
1137 1137 // `Node::Table` and this renderer is what draws it. The collecting slot the
1138 1138 // menu uses is separate from the one `activate` uses, so this also covers
1139 1139 // the case where a row carries both.
1140 - use quasi_router::{Cells, Column};
1140 + use quasi_router::Column;
1141 1141
1142 1142 let screen = screen_of([Node::Table {
1143 1143 columns: vec![Column::new("Name"), Column::new("BPM")],
1144 1144 rows: vec![
1145 - Cells::new(["kick.wav", "120"])
1145 + Row::cells(["kick.wav", "120"])
1146 1146 .activate(Action::post("/files/1/open"))
1147 1147 .offers(Act::new("Preview", Action::post("/files/1/play"))),
1148 - Cells::new(["snare.wav", "140"]),
1148 + Row::cells(["snare.wav", "140"]),
1149 1149 ],
1150 1150 more: None,
1151 1151 }]);
@@ -1166,13 +1166,13 @@
1166 1166 // because this renderer holds the set: how many are in it, and that a
1167 1167 // control over none of them should not fire. Drawn rather than hidden, so
1168 1168 // the affordance stays on screen and a reader learns bulk actions exist.
1169 - use quasi_router::{Cells, Column};
1169 + use quasi_router::Column;
1170 1170
1171 1171 let screen = Screen::sidebar_content("Tasks").selecting("chosen").with(
1172 1172 Slot::new("main", RegionKind::Pane)
1173 1173 .with(Node::Table {
1174 1174 columns: vec![Column::new("title")],
1175 - rows: vec![Cells::new(["First"]).ticking("t-1", false)],
1175 + rows: vec![Row::cells(["First"]).ticking("t-1", false)],
1176 1176 more: None,
1177 1177 })
1178 1178 .with(Node::Act(
@@ -1197,13 +1197,13 @@
1197 1197 // so this renderer adds one. The assertion is that the added column does
1198 1198 // not disturb the described ones: a two-column table with ticks still
1199 1199 // reaches both of its own cells.
1200 - use quasi_router::{Cells, Column};
1200 + use quasi_router::Column;
1201 1201
1202 1202 let screen = screen_of([Node::Table {
1203 1203 columns: vec![Column::new("name"), Column::new("bpm")],
1204 1204 rows: vec![
1205 - Cells::new(["kick.wav", "120"]).ticking("k", false),
1206 - Cells::new(["snare.wav", "140"]).ticking("s", true),
1205 + Row::cells(["kick.wav", "120"]).ticking("k", false),
1206 + Row::cells(["snare.wav", "140"]).ticking("s", true),
1207 1207 ],
1208 1208 more: None,
1209 1209 }]);
@@ -1253,7 +1253,7 @@
1253 1253 quasi_router::Column::new("Kind").priority(layout::Priority::Secondary),
1254 1254 quasi_router::Column::new("Added").priority(layout::Priority::Optional),
1255 1255 ],
1256 - rows: vec![quasi_router::Cells::new([
1256 + rows: vec![quasi_router::Row::cells([
1257 1257 "kick.wav",
1258 1258 "sample",
1259 1259 "2026-08-12",
@@ -2035,13 +2035,13 @@
2035 2035 // member was read into a parameter both callers passed `None` for, so the
2036 2036 // count went undrawn, the press over nothing went through, and what it sent
2037 2037 // travelled under the selection's name rather than under `Node::TICKED`.
2038 - use quasi_router::{Cells, Column};
2038 + use quasi_router::Column;
2039 2039
2040 2040 let screen = Screen::sidebar_content("Tasks").selecting("chosen").with(
2041 2041 Slot::new("main", RegionKind::Pane)
2042 2042 .with(Node::Table {
2043 2043 columns: vec![Column::new("title")],
2044 - rows: vec![Cells::new(["First"]).ticking("t-1", false)],
2044 + rows: vec![Row::cells(["First"]).ticking("t-1", false)],
2045 2045 more: None,
2046 2046 })
2047 2047 .with(Node::Act(
@@ -2072,13 +2072,13 @@
2072 2072 // `033ff3ca`. The box stands beside the control here rather than behind a
2073 2073 // disclosure, so what is asserted is the payload: the value the box holds
2074 2074 // and the set the verb acts over, in one call.
2075 - use quasi_router::{Cells, Column, Field};
2075 + use quasi_router::{Column, Field};
2076 2076
2077 2077 let screen = Screen::sidebar_content("Items").selecting("chosen").with(
2078 2078 Slot::new("main", RegionKind::Pane)
2079 2079 .with(Node::Table {
2080 2080 columns: vec![Column::new("title")],
2081 - rows: vec![Cells::new(["First"]).ticking("i-1", false)],
2081 + rows: vec![Row::cells(["First"]).ticking("i-1", false)],
2082 2082 more: None,
2083 2083 })
2084 2084 .with(Node::Act(
@@ -2171,15 +2171,15 @@
2171 2171 // `Node::Table`. `makeover_immediate::table` answers a `Ui` per cell and no
2172 2172 // row-wide rect, so the menu hangs off every cell of the row, and the
2173 2173 // second column is what proves it rather than the first.
2174 - use quasi_router::{Cells, Column};
2174 + use quasi_router::Column;
2175 2175
2176 2176 let screen = screen_of([Node::Table {
2177 2177 columns: vec![Column::new("Name"), Column::new("BPM")],
2178 2178 rows: vec![
2179 - Cells::new(["kick.wav", "120"])
2179 + Row::cells(["kick.wav", "120"])
2180 2180 .activate(Action::post("/files/1/open"))
2181 2181 .offers(Act::new("Preview", Action::post("/files/1/play"))),
2182 - Cells::new(["snare.wav", "140"]).activate(Action::post("/files/2/open")),
2182 + Row::cells(["snare.wav", "140"]).activate(Action::post("/files/2/open")),
2183 2183 ],
2184 2184 more: None,
2185 2185 }]);
@@ -2199,13 +2199,13 @@
2199 2199
2200 2200 #[test]
2201 2201 fn a_table_row_without_a_menu_still_opens() {
2202 - use quasi_router::{Cells, Column};
2202 + use quasi_router::Column;
2203 2203
2204 2204 let screen = screen_of([Node::Table {
2205 2205 columns: vec![Column::new("Name")],
2206 2206 rows: vec![
2207 - Cells::new(["kick.wav"]).activate(Action::post("/files/1/open")),
2208 - Cells::new(["snare.wav"]).activate(Action::post("/files/2/open")),
2207 + Row::cells(["kick.wav"]).activate(Action::post("/files/1/open")),
2208 + Row::cells(["snare.wav"]).activate(Action::post("/files/2/open")),
2209 2209 ],
2210 2210 more: None,
2211 2211 }]);
@@ -2809,10 +2809,11 @@
2809 2809 draw(
2810 2810 &screen_of([Node::Table {
2811 2811 columns: vec![quasi_router::Column::new("Task")],
2812 - rows: vec![quasi_router::Cells::new([
2812 + rows: vec![quasi_router::Row::cells([
2813 2813 cell,
2814 2814 quasi_router::Cell {
2815 - parts: vec![Node::since(started)],
2815 + content: vec![Node::since(started)],
2816 + ..quasi_router::Cell::default()
2816 2817 },
2817 2818 ])],
2818 2819 more: None,
@@ -3428,7 +3429,7 @@
3428 3429 quasi_router::Column::new("Tempo"),
3429 3430 ],
3430 3431 rows: vec![
3431 - quasi_router::Cells::new(vec![
3432 + quasi_router::Row::cells(vec![
3432 3433 quasi_router::Cell::new("kick.wav"),
3433 3434 quasi_router::Cell::new("90"),
3434 3435 ])
@@ -3450,7 +3451,7 @@
3450 3451 fn a_table_row_that_only_lists_claims_nothing() {
3451 3452 let screen = screen_of([Node::Table {
3452 3453 columns: vec![quasi_router::Column::new("Name")],
3453 - rows: vec![quasi_router::Cells::new(vec![quasi_router::Cell::new(
3454 + rows: vec![quasi_router::Row::cells(vec![quasi_router::Cell::new(
3454 3455 "kick.wav",
3455 3456 )])],
3456 3457 more: None,
@@ -3884,12 +3885,12 @@
3884 3885 quasi_router::Column::new("Size"),
3885 3886 ],
3886 3887 rows: vec![
3887 - quasi_router::Cells::new([
3888 + quasi_router::Row::cells([
3888 3889 quasi_router::Cell::new("kick.wav"),
3889 3890 quasi_router::Cell::new("2.1 MB"),
3890 3891 ])
3891 3892 .ticking("1", false),
3892 - quasi_router::Cells::new([
3893 + quasi_router::Row::cells([
3893 3894 quasi_router::Cell::new("snare.wav"),
3894 3895 quasi_router::Cell::new("1.4 MB"),
3895 3896 ])
@@ -49,7 +49,7 @@
49 49 //! closed set, which is what `makeover_layout::Intent` already is.
50 50
51 51 use crate::screen::{
52 - Act, Cell, Cells, Choice, Column, Field, Figure, Meter, Node, Prose, RegionKind, Row, Slot, Tag,
52 + Act, Cell, Choice, Column, Field, Figure, Meter, Node, Prose, RegionKind, Row, Slot, Tag,
53 53 };
54 54
55 55 /// What an element may hold.
@@ -277,11 +277,8 @@
277 277 }
278 278 }
279 279
280 - impl Element for Cells {
281 - fn containment(&self) -> Containment {
282 - Containment::Collection(Of::Cells)
283 - }
284 - }
280 + // `Element for Cells` was deleted 2026-09-05: Cells is an alias for Row,
281 + // so the impl for Row is the impl for both.
285 282
286 283 impl Element for Cell {
287 284 fn containment(&self) -> Containment {
@@ -163,11 +163,11 @@
163 163 };
164 164 pub use crate::router::{Handler, Router};
165 165 pub use crate::screen::{
166 - Accepted, Act, Action, Adds, Answer, CUTOFFS, Candidate, Canvas, Cell, Cells, Choice, Choosing,
167 - Clock, Column, Consult, Curve, Destination, Discovery, Document, Feed, FeedKind, Field, Figure,
168 - Held, Image, Instance, Jump, Meter, Node, Outline, Part, Placed, Prefill, Progress, Prose,
169 - Question, Ranked, RegionKind, Repeat, Repeating, Replaces, Rest, Reveal, Richness, Row, Run,
170 - Screen, Slot, SocialKind, Table, Tag, ThemeChoice, Trust, folded, folded_by,
166 + Accepted, Act, Action, Adds, Answer, CUTOFFS, Candidate, Canvas, Cell, CellKey, Cells, Choice,
167 + Choosing, Clock, Column, Consult, Curve, Destination, Discovery, Document, Feed, FeedKind,
168 + Field, Figure, Held, Image, Instance, Jump, Meter, Node, Outline, Placed, Prefill, Progress,
169 + Prose, Question, Ranked, RegionKind, Repeat, Repeating, Replaces, Rest, Reveal, Richness, Row,
170 + Run, Screen, Slot, SocialKind, Table, Tag, ThemeChoice, Trust, folded, folded_by,
171 171 writable_root_attr,
172 172 };
173 173
@@ -6212,52 +6212,12 @@
6212 6212 }
6213 6213 }
6214 6214
6215 - /// One part of a row's run, and the role it takes.
6215 + /// `Part` was merged into [`Cell`] on 2026-09-05.
6216 6216 ///
6217 - /// A cell's run entries carry no role because their kind already says which
6218 - /// part they are: text is the value, a [`Node::Link`] is the link, a
6219 - /// [`Node::Token`] is a chip, a [`Node::Act`] is a control. A row's
6220 - /// `primary`, `secondary` and `meta` are three *text* roles, and kind cannot
6221 - /// tell those apart, so a row says which one it means.
6222 - ///
6223 - /// The role is a style role and nothing else. [`layout::RowPart`] is unchanged
6224 - /// by the containment model: it says how a part is drawn, not what may sit in
6225 - /// it, and that is the half of it worth keeping.
6226 - #[derive(Debug, Clone, PartialEq, Eq)]
6227 - pub struct Part {
6228 - /// Which of the row's roles this part takes.
6229 - pub role: layout::RowPart,
6230 - /// What is in it. A leaf, since a row is an inline run.
6231 - pub node: Node,
6232 - /// How much vertical room it may take.
6233 - ///
6234 - /// [`layout::Flow::Tight`] by default, which is one line and is what every
6235 - /// part did before this field existed. [`Row::relaxed`] is how a call site
6236 - /// asks for two, and the two apps that had written a two-line clamp into
6237 - /// their own stylesheets are why it exists.
6238 - ///
6239 - /// On the part rather than on the row: BB clamps a feed row's *title* while
6240 - /// its excerpt wraps freely underneath, so a row-wide setting would have
6241 - /// been wrong at the only site that asked for it.
6242 - pub flow: layout::Flow,
6243 - /// What it is worth when the run does not fit.
6244 - ///
6245 - /// `None` means the description did not say, and the role answers instead
6246 - /// ([`layout::RowPart::priority`]). An `Option` rather than a defaulted
6247 - /// value because there is no global default worth having: `Optional` would
6248 - /// make every part droppable and `Essential` would make the ladder inert,
6249 - /// and only the role knows which a part meant. [`Part::worth`] is what a
6250 - /// renderer should read.
6251 - pub priority: Option<layout::Priority>,
6252 - }
6253 -
6254 - impl Part {
6255 - /// What this part is worth, said or inherited from its role.
6256 - #[must_use]
6257 - pub fn worth(&self) -> layout::Priority {
6258 - self.priority.unwrap_or_else(|| self.role.priority())
6259 - }
6260 - }
6217 + /// A part was a cell that carried its own role because a list had no columns to
6218 + /// carry it. Now a list declares columns like a table does, so the role is
6219 + /// [`CellKey::Role`] and there is one type. `Part::worth` is [`Cell::priority`]
6220 + /// falling back to its key.
6261 6221
6262 6222 /// One row of a list.
6263 6223 ///
@@ -6329,12 +6289,19 @@
6329 6289
6330 6290 #[derive(Debug, Clone, PartialEq, Eq, Default)]
6331 6291 pub struct Row {
6332 - /// What is in the row, in order.
6292 + /// What is in the row, in order, one entry per column it says anything in.
6333 6293 ///
6334 6294 /// Built by [`Row::new`], [`secondary`](Row::secondary),
6335 - /// [`meta`](Row::meta), [`token`](Row::token), [`act`](Row::act) and
6336 - /// [`meter`](Row::meter).
6337 - pub parts: Vec<Part>,
6295 + /// [`meta`](Row::meta), [`token`](Row::token), [`act`](Row::act),
6296 + /// [`meter`](Row::meter) for the default column set, and by
6297 + /// [`cells`](Row::cells), [`at`](Row::at) and [`cell`](Row::cell) for a
6298 + /// declared one.
6299 + ///
6300 + /// A cell says which column it answers to through [`Cell::key`], so a row
6301 + /// is no longer two collections with a private staging vector between
6302 + /// them. A cell keyed [`CellKey::Named`] is unresolved until
6303 + /// [`Table::row`] sees it.
6304 + pub cells: Vec<Cell>,
6338 6305 /// The route that selects this row, if selecting it does anything.
6339 6306 pub activate: Option<Action>,
6340 6307 /// Whether this is the row the detail side is currently showing.
@@ -6496,6 +6463,23 @@
6496 6463 ///
6497 6464 /// [`Region::showing_at_most_one`]: Region::showing_at_most_one
6498 6465 pub open: Option<bool>,
6466 + /// Which side of a change this row is on, when the table is a diff.
6467 + ///
6468 + /// Decision `19d7602d` (2026-09-02, option d). A diff is a table of lines,
6469 + /// and the only thing the vocabulary was missing was a way for a line to
6470 + /// say whether it was added, removed or unchanged. So it is a member here
6471 + /// rather than a `Node::Diff` carrying git's data model into a vocabulary
6472 + /// shared by a task manager and a sample browser.
6473 + ///
6474 + /// `None` is not [`Change::Context`]. `None` means this table is not a diff
6475 + /// and no renderer should tint it; `Some(Context)` means it is a diff and
6476 + /// this line did not change. Two facts, and a table of ordinary rows must
6477 + /// not read as a diff whose every line is context.
6478 + ///
6479 + /// What a renderer does with it is [`Change`]'s `Intent` impl and its own
6480 + /// palette. A terminal with two colours to spend gets the same three
6481 + /// answers a browser does.
6482 + pub change: Option<layout::Change>,
6499 6483 }
6500 6484
6501 6485 impl Row {
@@ -6507,14 +6491,15 @@
6507 6491 pub fn new(primary: impl Into<String>) -> Self {
6508 6492 let primary = primary.into();
6509 6493 Self {
6510 - parts: if primary.is_empty() {
6494 + cells: if primary.is_empty() {
6511 6495 Vec::new()
6512 6496 } else {
6513 - vec![Part {
6514 - role: layout::RowPart::Primary,
6515 - node: Node::text(primary),
6516 - flow: layout::Flow::default(),
6497 + vec![Cell {
6498 + key: CellKey::Role(layout::RowPart::Primary),
6499 + content: vec![Node::text(primary)],
6500 + flow: None,
6517 6501 priority: None,
6502 + span: 1,
6518 6503 }]
6519 6504 },
6520 6505 ..Self::default()
@@ -6629,11 +6614,12 @@
6629 6614 /// Add a token, chaining.
6630 6615 #[must_use]
6631 6616 pub fn token(mut self, tag: Tag) -> Self {
6632 - self.parts.push(Part {
6633 - role: layout::RowPart::Tokens,
6634 - node: Node::Token(tag),
6635 - flow: layout::Flow::default(),
6617 + self.cells.push(Cell {
6618 + key: CellKey::Role(layout::RowPart::Tokens),
6619 + content: vec![Node::Token(tag)],
6620 + flow: None,
6636 6621 priority: None,
6622 + span: 1,
6637 6623 });
6638 6624 self
6639 6625 }
@@ -6716,11 +6702,12 @@
6716 6702 /// A control acting on this row.
6717 6703 #[must_use]
6718 6704 pub fn act(mut self, act: Act) -> Self {
6719 - self.parts.push(Part {
6720 - role: layout::RowPart::Actions,
6721 - node: Node::Act(act),
6722 - flow: layout::Flow::default(),
6705 + self.cells.push(Cell {
6706 + key: CellKey::Role(layout::RowPart::Actions),
6707 + content: vec![Node::Act(act)],
6708 + flow: None,
6723 6709 priority: None,
6710 + span: 1,
6724 6711 });
6725 6712 self
6726 6713 }
@@ -6750,11 +6737,12 @@
6750 6737 "a row is an inline run and holds leaves; {node:?} holds {:?}",
6751 6738 node.containment()
6752 6739 );
6753 - self.parts.push(Part {
6754 - role,
6755 - node,
6756 - flow: layout::Flow::default(),
6740 + self.cells.push(Cell {
6741 + key: CellKey::Role(role),
6742 + content: vec![node],
6743 + flow: None,
6757 6744 priority: None,
6745 + span: 1,
6758 6746 });
6759 6747 self
6760 6748 }
@@ -6775,8 +6763,8 @@
6775 6763 /// costs each renderer is in that type's docs.
6776 6764 #[must_use]
6777 6765 pub fn relaxed(mut self) -> Self {
6778 - if let Some(part) = self.parts.last_mut() {
6779 - part.flow = layout::Flow::Relaxed;
6766 + if let Some(part) = self.cells.last_mut() {
6767 + part.flow = Some(layout::Flow::Relaxed);
6780 6768 }
6781 6769 self
6782 6770 }
@@ -6790,7 +6778,7 @@
6790 6778 /// A no-op on an empty run rather than a panic, for `relaxed`'s reason.
6791 6779 #[must_use]
6792 6780 pub fn worth(mut self, priority: layout::Priority) -> Self {
6793 - if let Some(part) = self.parts.last_mut() {
6781 + if let Some(part) = self.cells.last_mut() {
6794 6782 part.priority = Some(priority);
6795 6783 }
6796 6784 self
@@ -6803,23 +6791,82 @@
6803 6791 /// Building a row that calls `.meta` twice meant the second one won when
6804 6792 /// `meta` was an `Option`, and it still does.
6805 6793 fn set(&mut self, role: layout::RowPart, node: Node) {
6806 - match self.parts.iter_mut().find(|part| part.role == role) {
6807 - Some(part) => part.node = node,
6808 - None => self.parts.push(Part {
6809 - role,
6810 - node,
6811 - flow: layout::Flow::default(),
6794 + match self
6795 + .cells
6796 + .iter_mut()
6797 + .find(|cell| cell.key == CellKey::Role(role))
6798 + {
6799 + Some(cell) => cell.content = vec![node],
6800 + None => self.cells.push(Cell {
6801 + key: CellKey::Role(role),
6802 + content: vec![node],
6803 + flow: None,
6812 6804 priority: None,
6805 + span: 1,
6813 6806 }),
6814 6807 }
6815 6808 }
6816 6809
6810 + /// A row of a declared table, its cells in column order.
6811 + ///
6812 + /// Was `Cells::new` before the 2026-09-05 collapse. It is a separate
6813 + /// constructor from [`new`](Self::new) rather than an overload of it
6814 + /// because the two say different things: `Row::new` names the primary
6815 + /// column of the default set, and this answers a column list positionally.
6816 + #[must_use]
6817 + pub fn cells(values: impl IntoIterator<Item = impl Into<Cell>>) -> Self {
6818 + Self {
6819 + cells: values
6820 + .into_iter()
6821 + .enumerate()
6822 + .map(|(at, cell)| {
6823 + let mut cell = cell.into();
6824 + cell.key = CellKey::Column(at);
6825 + cell
6826 + })
6827 + .collect(),
6828 + ..Self::default()
6829 + }
6830 + }
6831 +
6832 + /// A cell naming the column it sits in, chaining.
6833 + ///
6834 + /// Safer than counting to a column in every way but one, which is why
6835 + /// [`Table::row`] carries a debug assertion: a name no column has is
6836 + /// dropped, so a typo renders an empty column rather than failing to
6837 + /// compile. The row and the column list are usually written in different
6838 + /// functions, so nothing above `Table::row` has both in hand to check.
6839 + #[must_use]
6840 + pub fn at(mut self, column: impl Into<String>, cell: impl Into<Cell>) -> Self {
6841 + let mut cell = cell.into();
6842 + cell.key = CellKey::Named(column.into());
6843 + self.cells.push(cell);
6844 + self
6845 + }
6846 +
6847 + /// A cell in the next column along, chaining.
6848 + #[must_use]
6849 + pub fn cell(mut self, cell: impl Into<Cell>) -> Self {
6850 + let at = self.cells.len();
6851 + let mut cell = cell.into();
6852 + cell.key = CellKey::Column(at);
6853 + self.cells.push(cell);
6854 + self
6855 + }
6856 +
6857 + /// Which side of a change this row is on, when the table is a diff.
6858 + #[must_use]
6859 + pub const fn changed(mut self, change: layout::Change) -> Self {
6860 + self.change = Some(change);
6861 + self
6862 + }
6863 +
6817 6864 /// The parts taking one role, in order.
6818 6865 pub fn role(&self, role: layout::RowPart) -> impl Iterator<Item = &Node> {
6819 - self.parts
6866 + self.cells
6820 6867 .iter()
6821 - .filter(move |part| part.role == role)
6822 - .map(|part| &part.node)
6868 + .filter(move |cell| cell.key == CellKey::Role(role))
6869 + .flat_map(|cell| cell.content.iter())
6823 6870 }
6824 6871
6825 6872 /// The row's primary text.
@@ -6852,7 +6899,10 @@
6852 6899 #[must_use]
6853 6900 pub fn names(&self, id: &str) -> bool {
6854 6901 self.menu.iter().any(|act| act.id.as_deref() == Some(id))
6855 - || self.parts.iter().any(|part| part.node.names(id))
6902 + || self
6903 + .cells
6904 + .iter()
6905 + .any(|cell| cell.content.iter().any(|node| node.names(id)))
6856 6906 }
6857 6907
6858 6908 pub fn acts(&self) -> impl Iterator<Item = &Act> {
@@ -6865,8 +6915,10 @@
6865 6915
6866 6916 /// Every kind of time-derived readout in this row's run, added to `found`.
6867 6917 fn clocks_into(&self, found: &mut BTreeSet<Clock>) {
6868 - for part in &self.parts {
6869 - part.node.clocks_into(found);
6918 + for cell in &self.cells {
6919 + for node in &cell.content {
6920 + node.clocks_into(found);
6921 + }
6870 6922 }
6871 6923 }
6872 6924
@@ -6907,21 +6959,112 @@
6907 6959 /// A `Vec<Act>` and not a node: the ruling that a row holds no nodes holds
6908 6960 /// here for the same reason. Acts carry their own tone, state and
6909 6961 /// confirmation, and that is the whole of what these cells hold.
6910 - #[derive(Debug, Clone, PartialEq, Eq, Default)]
6962 + /// Which column a cell answers to.
6963 + ///
6964 + /// The 2026-09-05 collapse: a list's parts were addressed by role and a table's
6965 + /// values by column, and those were the same operation under two spellings, a
6966 + /// lookup into a declared key set. The only difference was who declares the
6967 + /// keys, and now that a list declares its own the difference is gone.
6968 + #[derive(Debug, Clone, PartialEq, Eq)]
6969 + pub enum CellKey {
6970 + /// A column of the default set, which is what a container that declares no
6971 + /// columns gets.
6972 + ///
6973 + /// [`layout::RowPart`]'s six variants are that set. A container spelled as
6974 + /// a list is a table over them, which is why `list { row "x" { secondary
6975 + /// "y" } }` still says what it always said.
6976 + Role(layout::RowPart),
6977 + /// A declared column, by position, already resolved.
6978 + Column(usize),
6979 + /// A declared column, by name, pending resolution by [`Table::row`].
6980 + ///
6981 + /// This replaces the private staging vector `Cells` used to carry. A named
6982 + /// cell now sits in the row with the others and says it is unresolved,
6983 + /// rather than living in a second collection that had to be drained.
6984 + Named(String),
6985 + }
6986 +
6987 + impl Default for CellKey {
6988 + fn default() -> Self {
6989 + Self::Role(layout::RowPart::Primary)
6990 + }
6991 + }
6992 +
6993 + /// One cell: a run of leaf nodes, addressed by a key.
6994 + ///
6995 + /// **The 2026-09-05 collapse merged `Part` into this type.** A part was a cell
6996 + /// that carried its own role because a list had no columns to carry it; a cell
6997 + /// was a part whose column carried the role instead. One type now, with
6998 + /// [`key`](Self::key) saying which column it answers to.
6999 + ///
7000 + /// A cell was a `String` before that, so a table whose rows carry a control
7001 + /// could not be described at all and had to become a [`Node::List`], losing its
7002 + /// column headers, which is what the MNW server's SSH-keys tab did and why it
7003 + /// read worse than the Askama original it replaced.
7004 + ///
7005 + /// # Why the acts sit on the cell and not on the row
7006 + ///
7007 + /// Counted across MNW's templates, 30 table rows carry a control. 25 put it
7008 + /// alone in the last cell, which a row-level `actions` list would have covered.
7009 + /// The other five put it *beside a value*: `project_content`'s position cell is
7010 + /// the number plus two reorder arrows, `project_synckit`'s slug cell is the slug
7011 + /// plus "Set slug", `promo_codes_list`'s use count is a number that is itself
7012 + /// the button opening the redemptions. A row-level list renders as an appended
7013 + /// cell and cannot say any of those, and neither can an actions *column*, since
7014 + /// a column is a column. The control belongs where it actually is.
7015 + #[derive(Debug, Clone, PartialEq, Eq)]
6911 7016 pub struct Cell {
6912 7017 /// What is in it, in order.
6913 7018 ///
6914 - /// An inline run: every part is a leaf, so the whole cell is drawable on
7019 + /// An inline run: every entry is a leaf, so the whole cell is drawable on
6915 7020 /// one wrapped line without a renderer knowing what is in it. That is the
6916 - /// bound, and [`Cell::part`] is where it is enforced.
7021 + /// bound, and [`Cell::part`] is where it is enforced. It is also what
7022 + /// [`CellKey::Role`] leans on: several tokens in one row are one cell
7023 + /// holding several [`Node::Token`]s, not several cells fighting for a key.
6917 7024 ///
6918 7025 /// The run is what makes a meter in a cell or a figure in a cell sayable
6919 7026 /// without a member each.
7027 + pub content: Vec<Node>,
7028 + /// Which column this cell answers to.
7029 + pub key: CellKey,
7030 + /// How much vertical room it may take, or `None` for its column's.
6920 7031 ///
6921 - /// [`Cell::new`], [`tag`](Cell::tag), [`token`](Cell::token),
6922 - /// [`acts`](Cell::acts), [`act`](Cell::act) and
6923 - /// [`activate`](Cell::activate) build the run.
6924 - pub parts: Vec<Node>,
7032 + /// An override rather than a duplicate: the column states the default and a
7033 + /// cell may narrow it. BB clamps a feed row's title while its excerpt wraps
7034 + /// freely underneath, which is two columns rather than one override, but a
7035 + /// long value in one row of an otherwise tight column is the case that
7036 + /// keeps this here.
7037 + pub flow: Option<layout::Flow>,
7038 + /// What it is worth when the row does not fit, or `None` for its column's.
7039 + ///
7040 + /// `None` means the description did not say, and the key answers instead:
7041 + /// [`layout::RowPart::priority`] for a role, [`Column::priority`] for a
7042 + /// declared column. There is no global default worth having, which is why
7043 + /// this is an `Option` rather than a defaulted value.
7044 + pub priority: Option<layout::Priority>,
7045 + /// How many columns this cell covers. 1 is one column.
7046 + ///
7047 + /// **Defined and unread until the 2D work (`b1d4c5d7`).** It is here so
7048 + /// spanning does not cost a second breaking change; nothing honours it yet,
7049 + /// and a renderer meeting a value above 1 today should draw it as 1.
7050 + pub span: u16,
7051 + }
7052 +
7053 + impl Default for Cell {
7054 + /// An empty cell in the primary column, covering one column.
7055 + ///
7056 + /// `span` is 1 rather than 0 here, which is why this is written out: a
7057 + /// derived `Default` would produce a cell covering no columns, and nothing
7058 + /// downstream would say so.
7059 + fn default() -> Self {
7060 + Self {
7061 + content: Vec::new(),
7062 + key: CellKey::default(),
7063 + flow: None,
7064 + priority: None,
7065 + span: 1,
7066 + }
7067 + }
6925 7068 }
6926 7069
6927 7070 impl Cell {
@@ -6933,7 +7076,11 @@
6933 7076 pub fn new(value: impl Into<String>) -> Self {
6934 7077 let value = value.into();
6935 7078 Self {
6936 - parts: if value.is_empty() {
7079 + key: CellKey::default(),
7080 + flow: None,
7081 + priority: None,
7082 + span: 1,
7083 + content: if value.is_empty() {
6937 7084 Vec::new()
6938 7085 } else {
6939 7086 vec![Node::text(value)]
@@ -6947,28 +7094,36 @@
6947 7094 /// token would say the same thing and reads as an oversight.
6948 7095 pub fn tag(tag: Tag) -> Self {
6949 7096 Self {
6950 - parts: vec![Node::Token(tag)],
7097 + key: CellKey::default(),
7098 + flow: None,
7099 + priority: None,
7100 + span: 1,
7101 + content: vec![Node::Token(tag)],
6951 7102 }
6952 7103 }
6953 7104
6954 7105 /// A tag in this cell, chaining.
6955 7106 #[must_use]
6956 7107 pub fn token(mut self, tag: Tag) -> Self {
6957 - self.parts.push(Node::Token(tag));
7108 + self.content.push(Node::Token(tag));
6958 7109 self
6959 7110 }
6960 7111
6961 7112 /// A cell holding controls and no text.
6962 7113 pub fn acts(actions: impl IntoIterator<Item = Act>) -> Self {
6963 7114 Self {
6964 - parts: actions.into_iter().map(Node::Act).collect(),
7115 + key: CellKey::default(),
7116 + flow: None,
7117 + priority: None,
7118 + span: 1,
7119 + content: actions.into_iter().map(Node::Act).collect(),
6965 7120 }
6966 7121 }
6967 7122
6968 7123 /// A control in this cell, chaining.
6969 7124 #[must_use]
6970 7125 pub fn act(mut self, act: Act) -> Self {
6971 - self.parts.push(Node::Act(act));
7126 + self.content.push(Node::Act(act));
6972 7127 self
6973 7128 }
6974 7129
@@ -6982,7 +7137,7 @@
6982 7137 /// supplier before this existed. goingson's task list is the site.
6983 7138 #[must_use]
6984 7139 pub fn meter(mut self, meter: Meter) -> Self {
6985 - self.parts.push(Node::Meter(meter));
7140 + self.content.push(Node::Meter(meter));
6986 7141 self
6987 7142 }
6988 7143
@@ -6999,7 +7154,7 @@
6999 7154 #[must_use]
7000 7155 pub fn activate(mut self, action: Action) -> Self {
7001 7156 if let Some(first) = self
7002 - .parts
7157 + .content
7003 7158 .iter_mut()
7004 7159 .find(|part| matches!(part, Node::Text { .. }))
7005 7160 && let Node::Text { text, .. } = first
@@ -7033,17 +7188,48 @@
7033 7188 {:?}",
7034 7189 node.containment()
7035 7190 );
7036 - self.parts.push(node);
7191 + self.content.push(node);
7037 7192 self
7038 7193 }
7039 7194
7195 + /// What this cell is worth when the row does not fit.
7196 + ///
Lines truncated
@@ -58,7 +58,7 @@
58 58
59 59 use makeover_layout as layout;
60 60 use quasi_router::{
61 - Act, Action, Cells, Chrome, Consult, Field, Frame, Node, Part, Prefill, Row, Screen, Slot,
61 + Act, Action, Cells, Chrome, Consult, Field, Frame, Node, Prefill, Row, Screen, Slot,
62 62 };
63 63
64 64 use crate::Local;
@@ -661,7 +661,7 @@
661 661 inside: inside(cells)
662 662 .into_iter()
663 663 .filter_map(|(column, part)| {
664 - inside_spot(&cells.values[column].parts[part])
664 + inside_spot(&cells.cells[column].content[part])
665 665 })
666 666 .collect(),
667 667 });
@@ -724,8 +724,8 @@
724 724 /// [`Cell::parts`]: quasi_router::Cell
725 725 pub(crate) fn inside(cells: &Cells) -> Vec<(usize, usize)> {
726 726 let mut found = Vec::new();
727 - for (column, cell) in cells.values.iter().enumerate() {
728 - for (part, node) in cell.parts.iter().enumerate() {
727 + for (column, cell) in cells.cells.iter().enumerate() {
728 + for (part, node) in cell.content.iter().enumerate() {
729 729 if inside_spot(node).is_some() {
730 730 found.push((column, part));
731 731 }
@@ -960,7 +960,9 @@
960 960 },
961 961 });
962 962 }
963 - for Part { node, .. } in &row.parts {
964 - node_spots(node, region, local, found);
963 + for cell in &row.cells {
964 + for node in &cell.content {
965 + node_spots(node, region, local, found);
966 + }
965 967 }
966 968 }
@@ -14,8 +14,7 @@
14 14 use std::time::SystemTime;
15 15
16 16 use quasi_router::{
17 - Act, Action, Cell, Cells, Clock, Field, Figure, Image, Meter, Node, Outline, Part, Rest, Row,
18 - Tag,
17 + Act, Action, Cell, Cells, Clock, Field, Figure, Image, Meter, Node, Outline, Rest, Row, Tag,
19 18 };
20 19 use ratatui::buffer::Buffer;
21 20 use ratatui::layout::Rect;
@@ -645,13 +644,15 @@
645 644
646 645 /// Claim whatever the row's own run carries, one answer per part.
647 646 fn claim_parts(pass: &mut Pass<'_>, row: &Row) -> Vec<bool> {
648 - row.parts
647 + row.cells
649 648 .iter()
650 - .map(|Part { node, .. }| match node {
651 - Node::Act(act) => claim_act(pass, act),
652 - Node::Link { .. } => pass.claim(),
653 - Node::Token(tag) => claim_tag(pass, tag),
654 - _ => false,
649 + .map(|cell| {
650 + cell.content.iter().any(|node| match node {
651 + Node::Act(act) => claim_act(pass, act),
652 + Node::Link { .. } => pass.claim(),
653 + Node::Token(tag) => claim_tag(pass, tag),
654 + _ => false,
655 + })
655 656 })
656 657 .collect()
657 658 }
@@ -862,20 +863,26 @@
862 863 /// for the callers that are measuring rather than drawing.
863 864 /// The run as a line, from the parts a caller decided to keep.
864 865 ///
865 - /// `kept` holds indices into `row.parts`, so focus stays keyed to the part it
866 + /// `kept` holds indices into `row.cells`, so focus stays keyed to the part it
866 867 /// was claimed for even when parts before it were dropped. Claims happen before
867 868 /// layout and must not be renumbered by it.
868 869 fn row_line_of(tui: &Tui, row: &Row, focus: &[bool], kept: &[usize]) -> Line<'static> {
869 870 let mut spans = Vec::new();
870 871 for &index in kept {
871 - let Some(Part { role, node, .. }) = row.parts.get(index) else {
872 + let Some(cell) = row.cells.get(index) else {
872 873 continue;
873 874 };
874 875 if !spans.is_empty() {
875 876 spans.push(Span::raw(" "));
876 877 }
877 878 let focused = focus.get(index).copied().unwrap_or(false);
878 - spans.extend(inline_spans(tui, node, part_style(tui, *role), focused));
879 + let style = match &cell.key {
880 + quasi_router::CellKey::Role(role) => part_style(tui, *role),
881 + _ => part_style(tui, quasi_router::layout::RowPart::Primary),
882 + };
883 + for node in &cell.content {
884 + spans.extend(inline_spans(tui, node, style, focused));
885 + }
879 886 }
880 887 // `Row::menu` is not drawn, and that is the description's own instruction:
881 888 // a menu is reached by right-click on a pointer host, long-press on a touch
@@ -885,12 +892,12 @@
885 892
886 893 /// Every part, in order: what a row draws when there is room for all of it.
887 894 fn kept_all(row: &Row) -> Vec<usize> {
888 - (0..row.parts.len()).collect()
895 + (0..row.cells.len()).collect()
889 896 }
890 897
891 898 /// The parts that survive a run too tall for its cap.
892 899 ///
893 - /// Drops by [`quasi_router::Part::worth`], least valuable first, and only when
900 + /// Drops by [`quasi_router::Cell::worth`], least valuable first, and only when
894 901 /// dropping earns something: if the run still does not fit with every droppable
895 902 /// part gone, the whole run comes back and the cap cuts it. That is the rule
896 903 /// that keeps this honest. Dropping a badge to make room for a title that
@@ -922,9 +929,9 @@
922 929 let mut kept = all.clone();
923 930 for tier in [layout::Priority::Optional, layout::Priority::Secondary] {
924 931 kept.retain(|&index| {
925 - row.parts
926 - .get(index)
927 - .is_none_or(|part| matches!(part.node, Node::Act(_)) || part.worth() != tier)
932 + row.cells.get(index).is_none_or(|cell| {
933 + cell.content.iter().any(|n| matches!(n, Node::Act(_))) || cell.worth() != tier
934 + })
928 935 });
929 936 if fits(&kept) {
930 937 return kept;
@@ -971,9 +978,9 @@
971 978 /// `layout::Flow::lines` owns the numbers, so a tier added upstream arrives
972 979 /// here without this function being edited.
973 980 fn row_lines(row: &Row) -> u16 {
974 - row.parts
981 + row.cells
975 982 .iter()
976 - .map(|part| u16::from(part.flow.lines()))
983 + .map(|cell| u16::from(cell.room().lines()))
977 984 .max()
978 985 .unwrap_or(1)
979 986 .max(1)
@@ -1733,88 +1740,88 @@
1733 1740 fallback: 12,
1734 1741 };
1735 1742
1736 - let body: Vec<Vec<table::Cell<'_>>> = rows
1737 - .iter()
1738 - .enumerate()
1739 - .map(|(index, cells)| {
1740 - let mut row: Vec<table::Cell<'_>> =
1741 - Vec::with_capacity(columns.len() + usize::from(gutter));
1742 - if gutter {
1743 - // The set the view is holding decides it, not the description,
1744 - // which is the rule `39057019` settled for a field and
1745 - // `draw_gutter` follows for a list row: the description says
1746 - // what arrived and the view says what the user has done since.
1747 - // A redraw reading the description would undo the tick the
1748 - // moment anything else on the screen changed.
1749 - let drawn = match (cells.selected, cells.value.as_deref()) {
1750 - (Some(_), Some(value)) if pass.view.is_ticked(value) => "[x]",
1751 - (Some(_), _) => "[ ]",
1752 - // A live selection is the description's own answer and not
1753 - // the view's, which is the whole difference from the two
1754 - // above: the app holds the set, so a redraw reading the
1755 - // description is reading the truth rather than undoing it.
1756 - (None, _) if cells.chosen == Some(true) => " * ",
1757 - (None, _) => "",
1758 - };
1759 - row.push(table::Cell::new(TICK_COLUMN, Line::from(drawn)));
1760 - }
1761 - row.extend(columns.iter().zip(&cells.values).enumerate().map(
1762 - |(at, (column, cell))| {
1763 - // The lit part, and only in the row the caret is on:
1764 - // `lit` is already `None` unless this row is focused, so
1765 - // the column test is all that is left to do here.
1766 - let within = lit.filter(|(col, _)| Some(index) == focused && *col == at);
1767 - let mut line = cell_line(tui, cell, within.map(|(_, part)| part));
1768 - // The outline, in the first column and nowhere else. A
1769 - // table has no gutter to indent in and the indent is not a
1770 - // value in the grid, so it rides in front of the row's
1771 - // leading text -- which is the cell the eye reads the
1772 - // hierarchy from anyway.
1773 - if at == 0 && branches {
1774 - let mark = match cells.open {
1775 - Some(described) => {
1776 - let open = pass.view.open(&cells.key(), described);
1777 - format!("{} ", crate::outline::chevron(open))
1778 - }
1779 - None => " ".to_string(),
1780 - };
1781 - let indent = " ".repeat(
1782 - usize::from(cells.depth.level) * usize::from(crate::outline::STEP),
1783 - );
1784 - line.spans.insert(0, Span::raw(format!("{indent}{mark}")));
1785 - }
1786 - // Which side of a change this line is on, when the table is
1787 - // a diff. `19d7602d`. A marker in front of the row rather
1788 - // than a tint behind it, which is what every terminal diff
1789 - // has always done and what a reader already reads: the sign
1790 - // survives a monochrome terminal, a colour does not, and
1791 - // this renderer has no per-row background to spend anyway.
1792 - //
1793 - // The colour goes on beside it, off `Change`'s own intent,
1794 - // so a terminal with status colours gets both.
1795 - if at == 0
1796 - && let Some(change) = cells.change
1797 - {
1798 - let (sign, style) = match change {
1799 - layout::Change::Added => {
1800 - ("+", Style::default().fg(tui.theme().status_success))
1801 - }
1802 - layout::Change::Removed => {
1803 - ("-", Style::default().fg(tui.theme().status_danger))
1804 - }
1805 - // Including a kind this renderer has not learned:
1806 - // an unchanged line, which is the reading that
1807 - // draws the text and loses only the sign.
1808 - _ => (" ", Style::default()),
1809 - };
1810 - line.spans.insert(0, Span::styled(sign, style));
1811 - }
1812 - table::Cell::new(column.name.as_str(), line).part(cell_part(cell))
1813 - },
1814 - ));
1815 - row
1816 - })
1817 - .collect();
1743 + let body: Vec<Vec<table::Cell<'_>>> =
1744 + rows.iter()
1745 + .enumerate()
1746 + .map(|(index, cells)| {
1747 + let mut row: Vec<table::Cell<'_>> =
1748 + Vec::with_capacity(columns.len() + usize::from(gutter));
1749 + if gutter {
1750 + // The set the view is holding decides it, not the description,
1751 + // which is the rule `39057019` settled for a field and
1752 + // `draw_gutter` follows for a list row: the description says
1753 + // what arrived and the view says what the user has done since.
1754 + // A redraw reading the description would undo the tick the
1755 + // moment anything else on the screen changed.
1756 + let drawn = match (cells.selected, cells.value.as_deref()) {
1757 + (Some(_), Some(value)) if pass.view.is_ticked(value) => "[x]",
1758 + (Some(_), _) => "[ ]",
1759 + // A live selection is the description's own answer and not
1760 + // the view's, which is the whole difference from the two
1761 + // above: the app holds the set, so a redraw reading the
1762 + // description is reading the truth rather than undoing it.
1763 + (None, _) if cells.chosen == Some(true) => " * ",
1764 + (None, _) => "",
1765 + };
1766 + row.push(table::Cell::new(TICK_COLUMN, Line::from(drawn)));
1767 + }
1768 + row.extend(columns.iter().zip(&cells.cells).enumerate().map(
1769 + |(at, (column, cell))| {
1770 + // The lit part, and only in the row the caret is on:
1771 + // `lit` is already `None` unless this row is focused, so
1772 + // the column test is all that is left to do here.
1773 + let within = lit.filter(|(col, _)| Some(index) == focused && *col == at);
1774 + let mut line = cell_line(tui, cell, within.map(|(_, part)| part));
1775 + // The outline, in the first column and nowhere else. A
1776 + // table has no gutter to indent in and the indent is not a
1777 + // value in the grid, so it rides in front of the row's
1778 + // leading text -- which is the cell the eye reads the
1779 + // hierarchy from anyway.
1780 + if at == 0 && branches {
1781 + let mark = match cells.open {
1782 + Some(described) => {
1783 + let open = pass.view.open(&cells.key(), described);
1784 + format!("{} ", crate::outline::chevron(open))
1785 + }
1786 + None => " ".to_string(),
1787 + };
1788 + let indent = " ".repeat(
1789 + usize::from(cells.depth.level) * usize::from(crate::outline::STEP),
1790 + );
1791 + line.spans.insert(0, Span::raw(format!("{indent}{mark}")));
1792 + }
1793 + // Which side of a change this line is on, when the table is
1794 + // a diff. `19d7602d`. A marker in front of the row rather
1795 + // than a tint behind it, which is what every terminal diff
1796 + // has always done and what a reader already reads: the sign
1797 + // survives a monochrome terminal, a colour does not, and
1798 + // this renderer has no per-row background to spend anyway.
1799 + //
1800 + // The colour goes on beside it, off `Change`'s own intent,
1801 + // so a terminal with status colours gets both.
1802 + if at == 0
1803 + && let Some(change) = cells.change
1804 + {
1805 + let (sign, style) = match change {
1806 + layout::Change::Added => {
1807 + ("+", Style::default().fg(tui.theme().status_success))
1808 + }
1809 + layout::Change::Removed => {
1810 + ("-", Style::default().fg(tui.theme().status_danger))
1811 + }
1812 + // Including a kind this renderer has not learned:
1813 + // an unchanged line, which is the reading that
1814 + // draws the text and loses only the sign.
1815 + _ => (" ", Style::default()),
1816 + };
1817 + line.spans.insert(0, Span::styled(sign, style));
1818 + }
1819 + table::Cell::new(column.name.as_str(), line).part(cell_part(cell))
1820 + },
1821 + ));
1822 + row
1823 + })
1824 + .collect();
1818 1825
1819 1826 let widget = table::table(&named, &body, &sizing, &tui.table, area.width);
1820 1827 let height = table_height(columns, rows.len()).min(area.height);
@@ -1845,7 +1852,7 @@
1845 1852 /// style, so the reader can see which of a row's buttons Enter would press.
1846 1853 fn cell_line(tui: &Tui, cell: &Cell, lit: Option<usize>) -> Line<'static> {
1847 1854 let mut spans = Vec::new();
1848 - for (at, part) in cell.parts.iter().enumerate() {
1855 + for (at, part) in cell.content.iter().enumerate() {
1849 1856 if !spans.is_empty() {
1850 1857 spans.push(Span::raw(" "));
1851 1858 }
@@ -1866,17 +1873,21 @@
1866 1873 /// A control wins, then a link, then a chip, then the value: a cell whose last
1867 1874 /// word is a button should not be painted as prose.
1868 1875 fn cell_part(cell: &Cell) -> layout::CellPart {
1869 - if cell.parts.iter().any(|part| matches!(part, Node::Act(_))) {
1876 + if cell.content.iter().any(|part| matches!(part, Node::Act(_))) {
1870 1877 return layout::CellPart::Actions;
1871 1878 }
1872 1879 if cell
1873 - .parts
1880 + .content
1874 1881 .iter()
1875 1882 .any(|part| matches!(part, Node::Link { .. }))
1876 1883 {
1877 1884 return layout::CellPart::Link;
1878 1885 }
1879 - if cell.parts.iter().any(|part| matches!(part, Node::Token(_))) {
1886 + if cell
1887 + .content
1888 + .iter()
1889 + .any(|part| matches!(part, Node::Token(_)))
1890 + {
1880 1891 return layout::CellPart::Tokens;
1881 1892 }
1882 1893 layout::CellPart::Value
@@ -8,8 +8,8 @@
8 8 use makeover_layout as layout;
9 9 use makeover_tui::{Fidelity, Theme};
10 10 use quasi_router::{
11 - Act, Action, Candidate, Cell, Cells, Choice, Column, Consult, Field, Figure, Meter, Node,
12 - RegionKind, Row, Run, Screen, Slot, Tag,
11 + Act, Action, Candidate, Cell, Choice, Column, Consult, Field, Figure, Meter, Node, RegionKind,
12 + Row, Run, Screen, Slot, Tag,
13 13 };
14 14 use ratatui::buffer::Buffer;
15 15 use ratatui::layout::Rect;
@@ -159,7 +159,7 @@
159 159 Node::list([Row::new("One")]),
160 160 Node::Table {
161 161 columns: vec![Column::new("Name")],
162 - rows: vec![Cells::new([Cell::new("One")])],
162 + rows: vec![Row::cells([Cell::new("One")])],
163 163 more: None,
164 164 },
165 165 Node::Timeline {
@@ -767,7 +767,7 @@
767 767 Column::new("Name").priority(layout::Priority::Essential),
768 768 Column::new("Added").priority(layout::Priority::Optional),
769 769 ],
770 - rows: vec![Cells::new(["kick.wav", "2026-08-12"])],
770 + rows: vec![Row::cells(["kick.wav", "2026-08-12"])],
771 771 more: None,
772 772 };
773 773
@@ -859,7 +859,7 @@
859 859 .and_more(Rest::more(2, Action::get("/more"))),
860 860 Node::Table {
861 861 columns: vec![Column::new("Name")],
862 - rows: vec![Cells::new(["one"]).activate(Action::get("/row"))],
862 + rows: vec![Row::cells(["one"]).activate(Action::get("/row"))],
863 863 more: None,
864 864 },
865 865 ]);
@@ -2135,8 +2135,8 @@
2135 2135 .with(Node::Table {
2136 2136 columns: vec![quasi_router::Column::new("title")],
2137 2137 rows: vec![
2138 - Cells::new(["First"]).ticking("t-1", false),
2139 - Cells::new(["Second"]).ticking("t-2", false),
2138 + Row::cells(["First"]).ticking("t-1", false),
2139 + Row::cells(["Second"]).ticking("t-2", false),
2140 2140 ],
2141 2141 more: None,
2142 2142 })
@@ -2170,8 +2170,8 @@
2170 2170 Node::Table {
2171 2171 columns: vec![Column::new("Name")],
2172 2172 rows: vec![
2173 - Cells::new(["kick.wav"]).offers(Act::new("Preview", Action::post("/files/1/play"))),
2174 - Cells::new(["snare.wav"]),
2173 + Row::cells(["kick.wav"]).offers(Act::new("Preview", Action::post("/files/1/play"))),
2174 + Row::cells(["snare.wav"]),
2175 2175 ],
2176 2176 more: None,
2177 2177 },
@@ -2204,8 +2204,8 @@
2204 2204 .with(Node::Table {
2205 2205 columns: vec![Column::new("title")],
2206 2206 rows: vec![
2207 - Cells::new(["First"]).ticking("t-1", false),
2208 - Cells::new(["Second"]).ticking("t-2", false),
2207 + Row::cells(["First"]).ticking("t-1", false),
2208 + Row::cells(["Second"]).ticking("t-2", false),
2209 2209 ],
2210 2210 more: None,
2211 2211 })
@@ -2845,7 +2845,7 @@
2845 2845 Column::new("Kind").priority(layout::Priority::Secondary),
2846 2846 Column::new("Added").priority(layout::Priority::Optional),
2847 2847 ],
2848 - rows: vec![Cells::new(["kick.wav", "sample", "2026-08-12"])],
2848 + rows: vec![Row::cells(["kick.wav", "sample", "2026-08-12"])],
2849 2849 more: Some(Rest::more(1, Action::get("/samples?from=1"))),
2850 2850 })
2851 2851 .with(Node::List {
@@ -4098,7 +4098,7 @@
4098 4098 /// an Edit and a Remove in its last cell.
4099 4099 fn table_with_controls() -> Screen {
4100 4100 let row = |id: &str, name: &str| {
4101 - Cells::new([
4101 + Row::cells([
4102 4102 Cell::new(name),
4103 4103 Cell::acts([
4104 4104 Act::new("Edit", Action::post(format!("/files/{id}/edit"))),
@@ -4211,7 +4211,7 @@
4211 4211 let mut runtime = Runtime::new(Screen::sidebar_content("Files").with(
4212 4212 Slot::new("main", RegionKind::Pane).with(Node::Table {
4213 4213 columns: vec![Column::new("name"), Column::new("")],
4214 - rows: vec![Cells::new([
4214 + rows: vec![Row::cells([
4215 4215 Cell::new("kick.wav"),
4216 4216 Cell::acts([Act::new("Remove", Action::post("/files/1/remove"))]),
4217 4217 ])],
@@ -4295,7 +4295,7 @@
4295 4295 Slot::new("main", RegionKind::Pane).with(Node::Table {
4296 4296 columns: vec![Column::new("name"), Column::new("")],
4297 4297 rows: vec![
4298 - Cells::new([
4298 + Row::cells([
4299 4299 Cell::new("kick.wav"),
4300 4300 Cell::acts([
4301 4301 Act::new("Restore", Action::post("/files/1/restore")).disabled(),
@@ -4327,7 +4327,7 @@
4327 4327 Slot::new("main", RegionKind::Pane)
4328 4328 .with(Node::Table {
4329 4329 columns: vec![Column::new("name"), Column::new("")],
4330 - rows: vec![Cells::new([
4330 + rows: vec![Row::cells([
4331 4331 Cell::new("kick.wav"),
4332 4332 Cell::acts([Act::new("Remove", Action::post("/files/1/remove"))]),
4333 4333 ])],