Skip to main content

max / quasi

Draw a described table The node this renderer declined. The narrowing, the header carets and the tracks are makeover-immediate's; what is here is the walk turning a described cell into the nodes inside it, plus the two facts a Screen adds over a Column: the address a row opens and the address a heading reorders by. A cell is a run of nodes, so drawing one is `draw` again -- the property the containment migration bought every renderer, and why a button in a cell needs no special case. It forced a split of the walk. makeover-immediate draws a cell through a closure holding the Ui and the renderer, and a closure cannot also hold the pass mutably, so the nodes carrying no address moved into `leaf` which takes only the renderer. `draw` stays exhaustive over Node with no catch-all: the guarantee that a new member stops the build lives there, and both halves are private splits of one walk. A table described with no columns draws nothing rather than panicking, which egui_extras would: makeover-immediate answers None for it and this is the path that takes it. Breaking, since Sizing and the leaf split change what a consumer sees: 0.1.0 -> 0.2.0.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-14 13:56 UTC
Signed with PGP, not checked
Commit: 4e82ad9d84ccbcd14dc1fcbbb3e62ae90bc5ca72
Parent: 7a98331
4 files changed, +262 insertions, -87 deletions
M Cargo.lock +9 -9
@@ -3052,7 +3052,7 @@
3052 3052
3053 3053 [[package]]
3054 3054 name = "quasi-immediate"
3055 - version = "0.1.0"
3055 + version = "0.2.0"
3056 3056 dependencies = [
3057 3057 "docengine",
3058 3058 "egui",
@@ -5514,14 +5514,6 @@
5514 5514 source = "registry+https://github.com/rust-lang/crates.io-index"
5515 5515 checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
5516 5516
5517 - [[patch.unused]]
5518 - name = "synckit-client"
5519 - version = "0.8.0"
5520 -
5521 - [[patch.unused]]
5522 - name = "synckit-config"
5523 - version = "0.2.0"
5524 -
5525 5517 [[patch.unused]]
5526 5518 name = "kberg"
5527 5519 version = "0.1.0"
@@ -5537,3 +5529,11 @@
5537 5529 [[patch.unused]]
5538 5530 name = "tagtree"
5539 5531 version = "0.4.0"
5532 +
5533 + [[patch.unused]]
5534 + name = "synckit-client"
5535 + version = "0.8.0"
5536 +
5537 + [[patch.unused]]
5538 + name = "synckit-config"
5539 + version = "0.2.0"
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "quasi-immediate"
3 - version = "0.1.0"
3 + version = "0.2.0"
4 4 edition = "2024"
5 5 description = "The immediate-mode renderer for quasi-router: a described screen in, an egui frame out. Immediate mode is the constraint that matters, not the library."
6 6 license = "MIT"
@@ -16,14 +16,34 @@
16 16 //! What is left here is what a `Screen` adds over a node: the address a control
17 17 //! carries, the values a form gathers, and the selection a row's tick joins.
18 18 //! None of that is `makeover-layout`'s, so none of it can be down there.
19 + //!
20 + //! # The walk is split in two, and the table is why
21 + //!
22 + //! [`draw`] is exhaustive over `Node` and dispatches to [`leaf`] for the nodes
23 + //! that carry no address, and to [`container`] for the ones that hold others or
24 + //! reach a screen's own facts.
25 + //!
26 + //! The split is forced rather than tidy: `makeover_immediate::table` draws a cell
27 + //! through a closure that already holds the `Ui` and the renderer, and a closure
28 + //! cannot also hold the pass mutably. So a cell draws its ordinary nodes through
29 + //! `leaf`, and collects the two that carry an address to fire after the table
30 + //! has finished with the borrow.
19 31
20 32 use egui::{RichText, Ui};
21 33 use makeover_immediate::widget;
22 34 use makeover_immediate::{Filling, field, frame};
23 35 use quasi_router::layout;
24 - use quasi_router::{Act, Action, Node, Params, Row, Slot};
36 + use quasi_router::{Act, Action, Cells, Node, Params, Row, Slot};
25 37
26 - use crate::Pass;
38 + use crate::{Immediate, Pass};
39 +
40 + /// What a column is assumed to need when nothing measured it.
41 + ///
42 + /// `Sizing::lengths` is how an app says a column's longest value is wider than
43 + /// its name, and a described table carries no such measurement: the description
44 + /// says what a column *is*, not how long its contents turned out. So every
45 + /// column falls back to this, and `egui_extras` sizes the remainder.
46 + const CELL_WIDTH: f32 = 120.0;
27 47
28 48 /// Draw one node.
29 49 ///
@@ -35,37 +55,15 @@
35 55 /// it describes.
36 56 pub(crate) fn draw(pass: &mut Pass<'_>, ui: &mut Ui, node: &Node) {
37 57 match node {
38 - Node::Heading { level, text } => {
39 - let size = ui.text_style_height(&egui::TextStyle::Body)
40 - * match level {
41 - layout::Heading::Page => 1.6,
42 - layout::Heading::Section => 1.3,
43 - layout::Heading::Subsection => 1.1,
44 - };
45 - ui.label(
46 - RichText::new(text)
47 - .size(size)
48 - .strong()
49 - .color(pass.immediate.palette.content),
50 - );
51 - }
52 -
53 - Node::Text { text, .. } => {
54 - ui.label(RichText::new(text).color(pass.immediate.palette.content));
55 - }
56 -
57 - // Markdown arrives as source, so that every renderer answers it its own
58 - // way. This one has no rich text of its own worth the name, so it takes
59 - // the plain rendering: `**bold**` reads as `bold` rather than as four
60 - // characters of syntax, which is the outcome `Node::Text` would have
61 - // given anyway and is the honest floor until egui grows a markdown
62 - // widget worth adopting.
63 - Node::Rich { source, .. } => {
64 - ui.label(
65 - RichText::new(docengine::render_plain(source))
66 - .color(pass.immediate.palette.content),
67 - );
68 - }
58 + // The nodes that carry no address. Split out so a table cell can
59 + // draw them: the cell closure holds the `Ui` and cannot also hold
60 + // the pass mutably, and these need nothing but the palette.
61 + Node::Heading { .. }
62 + | Node::Text { .. }
63 + | Node::Rich { .. }
64 + | Node::Figure(_)
65 + | Node::Notice { .. }
66 + | Node::Meter(_) => leaf(pass.immediate, ui, node),
69 67
70 68 Node::Act(act) => {
71 69 act_node(pass, ui, act, None);
@@ -82,15 +80,6 @@
82 80 }
83 81 }
84 82
85 - Node::Figure(figure) => {
86 - widget::figure(
87 - ui,
88 - &figure.as_layout(),
89 - &pass.immediate.palette,
90 - &pass.immediate.widget,
91 - );
92 - }
93 -
94 83 Node::Token(tag) => {
95 84 let pressed = widget::token(
96 85 ui,
@@ -108,22 +97,6 @@
108 97 }
109 98 }
110 99
111 - Node::Notice { tone, text, .. } => {
112 - // The tone carries it, and the surface says it is a thing set on
113 - // the page rather than part of the flow. Where a toast lands
114 - // against a banner is renderer policy and this renderer has one
115 - // place to put either, which is where the caller drew it.
116 - frame(
117 - ui,
118 - layout::Depth::Raised,
119 - &pass.immediate.palette,
120 - pass.immediate.frame,
121 - |ui| {
122 - ui.label(RichText::new(text).color(pass.immediate.palette.tone(*tone)));
123 - },
124 - );
125 - }
126 -
127 100 Node::StandIn { message, act, .. } => {
128 101 ui.label(RichText::new(message).color(pass.immediate.palette.content_muted));
129 102 if let Some(act) = act {
@@ -135,6 +108,86 @@
135 108 }
136 109 }
137 110
111 + /// The nodes that carry no address.
112 + ///
113 + /// Everything here needs the palette and nothing else, which is what makes a
114 + /// table cell able to draw one: the cell closure already holds the `Ui` and the
115 + /// renderer, and cannot also hold the pass mutably.
116 + ///
117 + /// The catch-all is unreachable through [`draw`], which is exhaustive and sends
118 + /// only these here. It is a private split of one walk rather than a second walk,
119 + /// so the guarantee that a new `Node` member stops the build lives up there.
120 + fn leaf(immediate: &Immediate, ui: &mut Ui, node: &Node) {
121 + match node {
122 + Node::Heading { level, text } => {
123 + let size = ui.text_style_height(&egui::TextStyle::Body)
124 + * match level {
125 + layout::Heading::Page => 1.6,
126 + layout::Heading::Section => 1.3,
127 + layout::Heading::Subsection => 1.1,
128 + };
129 + ui.label(
130 + RichText::new(text)
131 + .size(size)
132 + .strong()
133 + .color(immediate.palette.content),
134 + );
135 + }
136 +
137 + Node::Text { text, .. } => {
138 + ui.label(RichText::new(text).color(immediate.palette.content));
139 + }
140 +
141 + // Markdown arrives as source, so that every renderer answers it its own
142 + // way. This one has no rich text of its own worth the name, so it takes
143 + // the plain rendering: `**bold**` reads as `bold` rather than as four
144 + // characters of syntax, which is the outcome `Node::Text` would have
145 + // given anyway and is the honest floor until egui grows a markdown
146 + // widget worth adopting.
147 + Node::Rich { source, .. } => {
148 + ui.label(
149 + RichText::new(docengine::render_plain(source)).color(immediate.palette.content),
150 + );
151 + }
152 +
153 + Node::Figure(figure) => {
154 + widget::figure(
155 + ui,
156 + &figure.as_layout(),
157 + &immediate.palette,
158 + &immediate.widget,
159 + );
160 + }
161 +
162 + Node::Notice { tone, text, .. } => {
163 + // The tone carries it, and the surface says it is a thing set on
164 + // the page rather than part of the flow. Where a toast lands
165 + // against a banner is renderer policy and this renderer has one
166 + // place to put either, which is where the caller drew it.
167 + frame(
168 + ui,
169 + layout::Depth::Raised,
170 + &immediate.palette,
171 + immediate.frame,
172 + |ui| {
173 + ui.label(RichText::new(text).color(immediate.palette.tone(*tone)));
174 + },
175 + );
176 + }
177 +
178 + Node::Meter(meter) => {
179 + widget::meter(
180 + ui,
181 + &meter.as_layout(),
182 + &immediate.palette,
183 + &immediate.widget,
184 + );
185 + }
186 +
187 + _ => unreachable!("an addressed node reached the leaf walk"),
188 + }
189 + }
190 +
138 191 /// The nodes that hold other nodes, or that a screen's own facts reach into.
139 192 ///
140 193 /// Split from [`draw`] where the line fell naturally rather than to satisfy a
@@ -177,16 +230,8 @@
177 230 }
178 231 }
179 232
180 - Node::Table { .. } => {
181 - // Deliberately not drawn yet, and said rather than skipped
182 - // silently. `makeover_immediate::table` draws one, and what it
183 - // takes is a body closure per cell; wiring a described table's
184 - // cells through that is the next node, not a line here. A caller
185 - // meeting this sees nothing and should file it rather than wonder.
186 - ui.label(
187 - RichText::new("(a described table is not drawn by this renderer yet)")
188 - .color(pass.immediate.palette.content_muted),
189 - );
233 + Node::Table { columns, rows } => {
234 + table(pass, ui, columns, rows);
190 235 }
191 236
192 237 Node::Select {
@@ -198,15 +243,6 @@
198 243 select(pass, ui, options, chosen.as_deref(), action.as_ref());
199 244 }
200 245
201 - Node::Meter(meter) => {
202 - widget::meter(
203 - ui,
204 - &meter.as_layout(),
205 - &pass.immediate.palette,
206 - &pass.immediate.widget,
207 - );
208 - }
209 -
210 246 Node::Stats { figures } => {
211 247 // Across rather than down, which is the one thing a strip says: a
212 248 // terminal stacks them because it has no width to spare, and a
@@ -410,6 +446,109 @@
410 446 });
411 447 }
412 448
449 + /// A described table.
450 + ///
451 + /// The narrowing, the header carets and the tracks are all
452 + /// `makeover_immediate::table`'s; what is here is the walk that turns a
453 + /// described cell into the nodes inside it, and the two facts a `Screen` adds
454 + /// over a `Column`: the address a row opens, and the address a heading reorders
455 + /// by.
456 + ///
457 + /// **A cell is a run of nodes, so drawing one is [`draw`] again.** That is the
458 + /// property the containment migration bought every renderer, and it is why a
459 + /// button in a cell needs no special case here: it is a `Node::Act` like any
460 + /// other, and it fires through the same `Pass`.
461 + fn table(pass: &mut Pass<'_>, ui: &mut Ui, columns: &[quasi_router::Column], rows: &[Cells]) {
462 + let borrowed: Vec<layout::Column<'_>> = columns.iter().map(|c| c.as_layout()).collect();
463 +
464 + // Every row's `current` flag, read by index. `Body::selected` takes a
465 + // predicate rather than a set, so an app whose selection is a range does not
466 + // have to build a collection to be asked.
467 + let current = |at: usize| rows.get(at).is_some_and(|row| row.current);
468 + let body = makeover_immediate::table::Body {
469 + rows: rows.len(),
470 + selected: Some(&current),
471 + scroll_to: None,
472 + };
473 + let sizing = makeover_immediate::table::Sizing {
474 + lengths: &[],
475 + fallback: CELL_WIDTH,
476 + };
477 +
478 + // What the user pressed, collected rather than fired inside the closure:
479 + // the closure holds `&mut Ui` and the pass at once, and firing needs the
480 + // pass mutably.
481 + let mut fired: Option<(Action, Params)> = None;
482 +
483 + let reordered = makeover_immediate::table::table(
484 + ui,
485 + &borrowed,
486 + &body,
487 + &sizing,
488 + &pass.immediate.palette,
489 + &pass.immediate.table,
490 + |ui, column, at| {
491 + let Some(row) = rows.get(at) else { return };
492 + let Some(index) = columns.iter().position(|c| c.name == column.name) else {
493 + return;
494 + };
495 + let Some(cell) = row.values.get(index) else {
496 + return;
497 + };
498 + for node in &cell.parts {
499 + // A cell's contents are ordinary nodes, but a press inside one
500 + // cannot reach the pass from here. Only the two that carry an
501 + // address are collected; everything else draws.
502 + match node {
503 + Node::Act(act) if act.state != Some(layout::State::Disabled) => {
504 + if widget::act(
505 + ui,
506 + &act.as_layout(),
507 + &pass.immediate.palette,
508 + &pass.immediate.widget,
509 + )
510 + .clicked()
511 + {
512 + fired = Some((act.action.clone(), Params::new()));
513 + }
514 + }
515 + Node::Link { text, action } => {
516 + if ui
517 + .link(RichText::new(text).color(pass.immediate.palette.action))
518 + .clicked()
519 + {
520 + fired = Some((action.clone(), Params::new()));
521 + }
522 + }
523 + other => leaf(pass.immediate, ui, other),
524 + }
525 + }
526 + // Opening the row itself, from whichever cell was clicked. A table
527 + // row has no single element to hang it on the way a list row hangs
528 + // it on its primary text.
529 + if let Some(action) = &row.activate
530 + && ui.response().clicked()
531 + {
532 + fired = Some((action.clone(), Params::new()));
533 + }
534 + },
535 + );
536 +
537 + if let Some((action, payload)) = fired {
538 + pass.fire(&action, payload, None);
539 + }
540 +
541 + // A heading that was pressed reorders by that column, and the column says
542 + // what that calls. `sortable` is `reorder.is_some()`, so a column with no
543 + // address answers no press.
544 + if let Some(column) = reordered
545 + && let Some(described) = columns.iter().find(|c| c.name == column.name)
546 + && let Some(action) = &described.reorder
547 + {
548 + pass.fire(action, Params::new(), None);
549 + }
550 + }
551 +
413 552 /// A region, drawn as the surface its kind names.
414 553 pub(crate) fn region(pass: &mut Pass<'_>, ui: &mut Ui, slot: &Slot) {
415 554 // Readiness first: a region that is not ready has nothing to draw and says
@@ -386,3 +386,39 @@
386 386 "the host had nowhere to put it"
387 387 );
388 388 }
389 +
390 + #[test]
391 + fn a_described_table_draws_its_columns_and_cells() {
392 + // The node this renderer declined to draw until 2026-08-14. The narrowing
393 + // and the tracks are makeover-immediate's; what is asserted here is that a
394 + // described table reaches them at all, with a cell holding an ordinary node.
395 + use quasi_router::{Cell, Cells, Column};
396 +
397 + let columns = vec![Column::new("name"), Column::new("bpm")];
398 + let rows = vec![
399 + Cells::new(["kick.wav", "120"]),
400 + // A cell holding a control rather than a value, which is what
401 + // `CellPart` exists to separate and what a file list actually has.
402 + Cells::new([
403 + Cell::new("snare.wav"),
404 + Cell::acts([Act::new("Play", Action::post("/play"))]),
405 + ]),
406 + ];
407 +
408 + let mut view = View::new();
409 + let screen = screen_of([Node::Table { columns, rows }]);
410 + draw(&screen, &mut view);
411 + }
412 +
413 + #[test]
414 + fn a_table_with_no_columns_draws_nothing_rather_than_panicking() {
415 + // `egui_extras` panics on a table with no tracks, and a description with no
416 + // columns is reachable. makeover-immediate answers `None` for that case and
417 + // this is the assertion that the described path takes it.
418 + let mut view = View::new();
419 + let screen = screen_of([Node::Table {
420 + columns: Vec::new(),
421 + rows: Vec::new(),
422 + }]);
423 + draw(&screen, &mut view);
424 + }