Skip to main content

max / quasi

Make a table own its columns, and let a cell name the one it sits in Column::name has always documented itself as "the heading, and the name the cell is addressed by ... what replaces addressing columns by position", while Cells stayed positional against the column order. The file held both answers and position was the one being used. Position is what makes a conditional cell dangerous. git_repos builds a visibility cell only for an owner by pushing onto a Vec, and the column list is built under a second, separate conditional; the two have to agree and nothing checks that they do. A cell that is only sometimes there shifts every cell after it, which is why the declarative form needed a rule about emitting conditional cells in matched pairs. Table::new takes the columns, so a row is never resolved against a column list that does not exist, the way Run::new holds a shared row's fallback. Cells::at names a column; Table::row resolves the names against the columns it owns. An unnamed column is empty in that row and an unknown name is dropped, so a row whose arity depends on a runtime flag is an ordinary row. Cells::current and Cells::menu are the two properties that were reachable only by assigning the field, which meant falling out of the chain to say them. Positional rows still work and are left alone by resolution.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session
https://claude.ai/code/session_01MptwXZ8k65v19rFmdGAyki
Author: Max Johnson <me@maxj.phd> · 2026-09-03 03:13 UTC
Signed with PGP, not checked
Commit: 16702060985f0c86520aed0b8ace4ba9af0927cf
Parent: b93f352
3 files changed, +236 insertions, -9 deletions
@@ -166,7 +166,8 @@
166 166 Clock, Column, Consult, Curve, Destination, Discovery, Document, Feed, FeedKind, Field, Figure,
167 167 Held, Image, Instance, Jump, Meter, Node, Outline, Part, Placed, Prefill, Progress, Prose,
168 168 Question, Ranked, RegionKind, Repeat, Repeating, Replaces, Rest, Reveal, Richness, Row, Run,
169 - Screen, Slot, SocialKind, Tag, ThemeChoice, Trust, folded, folded_by, writable_root_attr,
169 + Screen, Slot, SocialKind, Table, Tag, ThemeChoice, Trust, folded, folded_by,
170 + writable_root_attr,
170 171 };
171 172
172 173 #[cfg(test)]
@@ -6938,7 +6938,17 @@
6938 6938 #[derive(Debug, Clone, PartialEq, Eq, Default)]
6939 6939 pub struct Cells {
6940 6940 /// One entry per column, in the table's column order.
6941 + ///
6942 + /// Filled either directly by [`Cells::new`] or by [`Table::row`] resolving
6943 + /// [`at`](Cells::at)'s named cells against the columns it owns.
6941 6944 pub values: Vec<Cell>,
6945 + /// Cells that named their column instead of taking a position.
6946 + ///
6947 + /// Private, and empty once [`Table::row`] has resolved them into
6948 + /// [`values`](Self::values). A row that names its columns is not a row
6949 + /// until a table has told it what the columns are, which is why this
6950 + /// cannot be reached from outside.
6951 + placed: Vec<(String, Cell)>,
6942 6952 /// The route that opens this row.
6943 6953 pub activate: Option<Action>,
6944 6954 /// Whether this is the row currently being shown elsewhere.
@@ -7110,6 +7120,7 @@
7110 7120 pub fn new(values: impl IntoIterator<Item = impl Into<Cell>>) -> Self {
7111 7121 Self {
7112 7122 values: values.into_iter().map(Into::into).collect(),
7123 + placed: Vec::new(),
7113 7124 activate: None,
7114 7125 current: false,
7115 7126 chosen: None,
@@ -7122,6 +7133,49 @@
7122 7133 }
7123 7134 }
7124 7135
7136 + /// Put a cell in the column of this name.
7137 + ///
7138 + /// The alternative to [`new`](Self::new)'s column order, and the one that
7139 + /// survives a cell that is only sometimes there. A row built by position
7140 + /// has to emit every cell to keep the count right, so a conditional cell
7141 + /// is written as a matched pair and a mistake in the pair silently shifts
7142 + /// every column after it. Named, an absent cell is an empty cell in its
7143 + /// own column and the shift cannot be written.
7144 + ///
7145 + /// [`Column::name`] already called itself "the name the cell is addressed
7146 + /// by"; this is the constructor that makes that true.
7147 + ///
7148 + /// Resolved by [`Table::row`], which is the only thing that knows the
7149 + /// columns. Naming a column the table does not have is dropped there
7150 + /// rather than here, for the same reason: this end cannot see them.
7151 + #[must_use]
7152 + pub fn at(mut self, column: impl Into<String>, cell: impl Into<Cell>) -> Self {
7153 + self.placed.push((column.into(), cell.into()));
7154 + self
7155 + }
7156 +
7157 + /// This is the row being shown elsewhere.
7158 + ///
7159 + /// Was reachable only by assigning the field, which is why a row built by
7160 + /// a chain had to fall out of the chain to say it. [`Row`] carries the same
7161 + /// fact under the same name.
7162 + #[must_use]
7163 + pub const fn current(mut self, current: bool) -> Self {
7164 + self.current = current;
7165 + self
7166 + }
7167 +
7168 + /// Offer these acts on the row itself, rather than in a cell.
7169 + ///
7170 + /// The plural of [`offers`](Self::offers), and the other half of the pair
7171 + /// that was assignment-only. A context menu is a property of the row and
7172 + /// belongs in the chain that builds it.
7173 + #[must_use]
7174 + pub fn menu(mut self, acts: impl IntoIterator<Item = Act>) -> Self {
7175 + self.menu.extend(acts);
7176 + self
7177 + }
7178 +
7125 7179 /// The route that opens this row.
7126 7180 #[must_use]
7127 7181 pub fn activate(mut self, action: Action) -> Self {
@@ -7222,6 +7276,102 @@
7222 7276 }
7223 7277 }
7224 7278
7279 + /// A table: the columns, and the rows that answer to them.
7280 + ///
7281 + /// The columns arrive with the table, so a row is never built against a column
7282 + /// list that does not exist yet. That is the rule [`Run::new`] holds for a
7283 + /// shared row's fallback, here for the same reason: a table's invariant is that
7284 + /// cells line up with columns, and the point a row is put in is the only place
7285 + /// with enough context to keep that true.
7286 + ///
7287 + /// # Why a cell is addressed by name
7288 + ///
7289 + /// [`Column`] documented its `name` as "the heading, and the name the cell is
7290 + /// addressed by ... what replaces addressing columns by position" while
7291 + /// [`Cells`] stayed positional, so the file held both answers at once. Position
7292 + /// is the one that loses. A cell that appears on some rows and not others
7293 + /// shifts every cell after it, so a conditional cell had to be written as a
7294 + /// matched pair and nothing but care kept the pair matched. Under
7295 + /// [`Cells::at`] an absent cell is an empty cell in its own column, and a row
7296 + /// whose arity depends on a runtime flag is an ordinary row.
7297 + ///
7298 + /// A row built by [`Cells::new`] is still positional and still works. The two
7299 + /// are not mixed in one row: if a row names any column, the names are the row.
7300 + #[derive(Debug, Clone, PartialEq, Eq, Default)]
7301 + pub struct Table {
7302 + columns: Vec<Column>,
7303 + rows: Vec<Cells>,
7304 + more: Option<Rest>,
7305 + }
7306 +
7307 + impl Table {
7308 + /// A table with these columns and no rows yet.
7309 + pub fn new(columns: impl IntoIterator<Item = Column>) -> Self {
7310 + Self {
7311 + columns: columns.into_iter().collect(),
7312 + rows: Vec::new(),
7313 + more: None,
7314 + }
7315 + }
7316 +
7317 + /// Add a row, resolving any cell that named its column.
7318 + ///
7319 + /// A named cell whose column this table does not have is dropped: the
7320 + /// column list is the table's statement of what a row may say, and a row
7321 + /// saying more than that is answered by the columns rather than by
7322 + /// widening them. A column no cell named is empty in this row.
7323 + #[must_use]
7324 + pub fn row(mut self, mut row: Cells) -> Self {
7325 + if !row.placed.is_empty() {
7326 + let placed = std::mem::take(&mut row.placed);
7327 + row.values = self
7328 + .columns
7329 + .iter()
7330 + .map(|column| {
7331 + placed
7332 + .iter()
7333 + .find(|(name, _)| *name == column.name)
7334 + .map_or_else(|| Cell::new(String::new()), |(_, cell)| cell.clone())
7335 + })
7336 + .collect();
7337 + }
7338 + self.rows.push(row);
7339 + self
7340 + }
7341 +
7342 + /// Add several rows, chaining.
7343 + #[must_use]
7344 + pub fn rows(mut self, rows: impl IntoIterator<Item = Cells>) -> Self {
7345 + for row in rows {
7346 + self = self.row(row);
7347 + }
7348 + self
7349 + }
7350 +
7351 + /// Say what is not shown, and how to ask for it.
7352 + #[must_use]
7353 + pub fn more(mut self, rest: Rest) -> Self {
7354 + self.more = Some(rest);
7355 + self
7356 + }
7357 +
7358 + /// The columns, in order.
7359 + #[must_use]
7360 + pub fn columns(&self) -> &[Column] {
7361 + &self.columns
7362 + }
7363 + }
7364 +
7365 + impl From<Table> for Node {
7366 + fn from(table: Table) -> Self {
7367 + Self::Table {
7368 + columns: table.columns,
7369 + rows: table.rows,
7370 + more: table.more,
7371 + }
7372 + }
7373 + }
7374 +
7225 7375 /// A row that says where it sits in a hierarchy.
7226 7376 ///
7227 7377 /// [`Row`] and [`Cells`] carry [`depth`](Row::depth) and [`open`](Row::open)
@@ -9346,6 +9496,86 @@
9346 9496
9347 9497 #[cfg(test)]
9348 9498 mod tests {
9499 + use super::{Cell, Cells, Column, Node, Table};
9500 +
9501 + /// The whole reason a cell names its column.
9502 + ///
9503 + /// `git_repos` builds a visibility cell only for an owner, by pushing onto
9504 + /// a `Vec` whose length has to agree with a column list built under a
9505 + /// second, separate conditional. Named, the two cannot disagree: the row
9506 + /// answers the columns the table has.
9507 + #[test]
9508 + fn a_conditional_cell_does_not_shift_the_columns_after_it() {
9509 + let columns = || {
9510 + [
9511 + Column::new("Name"),
9512 + Column::new("Visibility"),
9513 + Column::new("Description"),
9514 + ]
9515 + };
9516 + let row = |is_owner: bool| {
9517 + let mut cells = Cells::default()
9518 + .at("Name", Cell::new("quasi"))
9519 + .at("Description", Cell::new("the app stack"));
9520 + if is_owner {
9521 + cells = cells.at("Visibility", Cell::new("public"));
9522 + }
9523 + cells
9524 + };
9525 +
9526 + let owner = Table::new(columns()).row(row(true));
9527 + let stranger = Table::new(columns()).row(row(false));
9528 +
9529 + let read = |table: &Table| {
9530 + table.rows[0]
9531 + .values
9532 + .iter()
9533 + .map(Cell::text)
9534 + .collect::<Vec<_>>()
9535 + };
9536 +
9537 + assert_eq!(read(&owner), ["quasi", "public", "the app stack"]);
9538 + // The description stays in its own column rather than sliding left.
9539 + assert_eq!(read(&stranger), ["quasi", "", "the app stack"]);
9540 + }
9541 +
9542 + /// A column nothing named is empty, and a name no column has is dropped.
9543 + #[test]
9544 + fn the_columns_decide_what_a_row_may_say() {
9545 + let table = Table::new([Column::new("Name")]).row(
9546 + Cells::default()
9547 + .at("Name", Cell::new("kick.wav"))
9548 + .at("Nonexistent", Cell::new("ignored")),
9549 + );
9550 +
9551 + assert_eq!(table.rows[0].values.len(), 1);
9552 + assert_eq!(table.rows[0].values[0].text(), "kick.wav");
9553 + }
9554 +
9555 + /// A row built by position still works and is untouched by resolution.
9556 + #[test]
9557 + fn a_positional_row_is_left_alone() {
9558 + let table = Table::new([Column::new("Name"), Column::new("Size")])
9559 + .row(Cells::new(["kick.wav", "2.1 MB"]));
9560 +
9561 + assert_eq!(table.rows[0].values.len(), 2);
9562 + assert_eq!(table.rows[0].values[1].text(), "2.1 MB");
9563 + }
9564 +
9565 + /// The table is the node, so nothing reaches for the variant by hand.
9566 + #[test]
9567 + fn a_table_becomes_its_node() {
9568 + let node = Node::from(Table::new([Column::new("Name")]).row(Cells::new(["kick.wav"])));
9569 +
9570 + match node {
9571 + Node::Table { columns, rows, .. } => {
9572 + assert_eq!(columns.len(), 1);
9573 + assert_eq!(rows.len(), 1);
9574 + }
9575 + other => panic!("expected a table, got {other:?}"),
9576 + }
9577 + }
9578 +
9349 9579 use super::*;
9350 9580
9351 9581 fn frames(count: usize) -> Vec<Node> {
@@ -1240,10 +1240,7 @@
1240 1240 // have to know which it is reading.
1241 1241 let html = fragment(&Node::Table {
1242 1242 columns: vec![Column::new("Name").width(layout::Width::Fill)],
1243 - rows: vec![Cells {
1244 - current: true,
1245 - ..Cells::new(["kick.wav"])
1246 - }],
1243 + rows: vec![Cells::new(["kick.wav"]).current(true)],
1247 1244 more: None,
1248 1245 });
1249 1246 assert!(html.contains("aria-current=\"true\""));
@@ -6399,10 +6396,9 @@
6399 6396 // Ticked and unticked, for `table-select` and
6400 6397 // `table-row-selected`.
6401 6398 Cells::new(["kick.wav", "2.1 MB"]).ticking("kick", false),
6402 - Cells {
6403 - current: true,
6404 - ..Cells::new(["snare.wav", "1.4 MB"]).ticking("snare", true)
6405 - },
6399 + Cells::new(["snare.wav", "1.4 MB"])
6400 + .ticking("snare", true)
6401 + .current(true),
6406 6402 // Chosen, for `table-row-chosen`.
6407 6403 Cells::new(["tom.wav", "0.9 MB"]).choosing("tom", true),
6408 6404 // Two acts, so the row carries a menu.