Skip to main content

max / quasi

A guarded cell empties its column rather than removing it MNW's own residual test found this, which is the check earning its keep: the filled residual dropped a `<div>` the renderer keeps. A table column emits its block for every row whether or not a cell answers it, so a guarded cell's branch has to cover what the CELL wrote and not the block around it. Covering the block deleted a column and shifted every one after it. makeover-webview 0.77.0 reports both halves per column, and the mark says which it meant: a member that is there or is not covers what it wrote, and a member repeated or replaced covers the whole of it. The second case is refused rather than approximated. A cell holding one piece of text makes its column write `cell-value`, so guarding it varies the block's class as well as its contents -- two places, and a branch is one. That is loud at derivation, on the build machine, naming the dispatch as the remedy. The filler would not have caught it: it would have served every row whichever class the staged render happened to have. Both are fixtures now, including the refusal, because MNW found this and the bench did not.
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-08 21:51 UTC
Signed with PGP, not checked
Commit: f9863c4cbed69796b4d3b1ae5f38feac36d8081b
Parent: c8a2fc4
6 files changed, +205 insertions, -13 deletions
M Cargo.lock +2 -2
@@ -2705,9 +2705,9 @@
2705 2705
2706 2706 [[package]]
2707 2707 name = "makeover-webview"
2708 - version = "0.76.0"
2708 + version = "0.77.0"
2709 2709 source = "registry+https://github.com/rust-lang/crates.io-index"
2710 - checksum = "48d1b43ae36a7bb7377fc3d01c6c8485ff9c285a7bf2d332c7bb97145e73de9c"
2710 + checksum = "5157138240216922199759f4c9c0547b363e388c57888aa1e46ca46fb38f062e"
2711 2711 dependencies = [
2712 2712 "makeover-geometry",
2713 2713 "makeover-layout",
@@ -41,7 +41,7 @@
41 41 makeover-layout = "0.44"
42 42 # The renderer's own escaper, so the staged path fills a hole exactly the way
43 43 # `Node::Text` does and the guarantee is not quietly reimplemented.
44 - makeover-webview = "0.76"
44 + makeover-webview = "0.77"
45 45 makeover-tui = { version = "0.44", features = ["theme"] }
46 46 # Only to load a bundled theme file. `makeover_tui::Theme` is `#[non_exhaustive]`,
47 47 # so `Theme::from_theme` is the one way to get one, the same reason quasi-tui's
@@ -16,7 +16,7 @@
16 16 quasi-router = { path = "../quasi-router", version = "0.109" }
17 17 quasi-http = { path = "../quasi-http", version = "0.109" }
18 18 makeover-layout = "0.44"
19 - makeover-webview = "0.76"
19 + makeover-webview = "0.77"
20 20 # `Node::Rich` carries markdown source and this is what turns it into markup.
21 21 # Sanitising comes with it, which is why the node can carry what a user typed.
22 22 #
@@ -16,6 +16,7 @@
16 16
17 17 use quasi_declare::declare;
18 18 use quasi_router::Node;
19 + use quasi_router::screen::Tag;
19 20
20 21 use crate::fixture::{Buyer, Rows, Shared};
21 22
@@ -86,6 +87,71 @@
86 87 }
87 88 }
88 89
90 + declare! {
91 + /// The same table with the guarded cell holding text rather than a token.
92 + ///
93 + /// Refused rather than compiled, and the refusal is the point. A cell whose
94 + /// content is one piece of text makes its column write `cell-value`, so a
95 + /// guard on it varies the block's class AND the block's contents. Those are
96 + /// two places and a branch is one, so the derivation says so instead of
97 + /// compiling the half it can see and serving a row with a stray class.
98 + #[staged]
99 + pub(crate) shape steered_table(buyers: &[Buyer]) -> Node;
100 +
101 + table {
102 + column "Username" {
103 + width Content;
104 + }
105 + column "Note" {
106 + width Content;
107 + }
108 +
109 + for buyer in buyers.iter() {
110 + cells {
111 + cell at "Username" buyer.username.clone();
112 + cell at "Note" buyer.spent.clone() unless buyer.spent.is_empty();
113 + }
114 + }
115 + }
116 + }
117 +
118 + declare! {
119 + /// A table whose last column is answered by only some rows.
120 + ///
121 + /// The case MNW's repository listing is, and the one that says why a cell's
122 + /// placement has two halves. A column emits its `<div>` for every row
123 + /// whether or not a cell answers it, so a guarded cell's branch covers what
124 + /// the cell WROTE and not the block around it. Covering the block deletes a
125 + /// column the row still draws, and every row after it shifts one place
126 + /// left.
127 + #[staged]
128 + pub(crate) shape sparse_table(buyers: &[Buyer]) -> Node;
129 +
130 + table {
131 + column "Username" {
132 + width Content;
133 + }
134 + column "Note" {
135 + width Content;
136 + }
137 +
138 + for buyer in buyers.iter() {
139 + cells {
140 + cell at "Username" buyer.username.clone();
141 + // A token rather than the text itself, and that is the whole of
142 + // why this one compiles. A cell holding one piece of text makes
143 + // its column write `cell-value`, so guarding it would vary the
144 + // block's class as well as its contents -- two places, and a
145 + // branch is one. `quasi-webview` refuses that case by name; see
146 + // the test below.
147 + cell at "Note" "" unless buyer.spent.is_empty() {
148 + token Tag::badge(buyer.spent.clone());
149 + }
150 + }
151 + }
152 + }
153 + }
154 +
89 155 declare! {
90 156 /// The creators this reader has shared an email with.
91 157 #[staged]
@@ -327,6 +393,54 @@
327 393 }
328 394
329 395 /// The derivation found structure, rather than one flat run of markup.
396 + /// The case a branch cannot hold is refused where it is derived.
397 + ///
398 + /// On the build machine, where a wrong residual is still cheap. The filler
399 + /// would not have caught it: it would have served every row the class the
400 + /// staged render happened to have.
401 + #[test]
402 + #[should_panic(expected = "two places at once")]
403 + fn a_guarded_cell_that_steers_its_column_is_refused() {
404 + let _ = quasi_webview::stage::derive(&Webview::new(), steered_table_staged);
405 + }
406 +
407 + /// A guarded cell empties its column rather than removing it.
408 + ///
409 + /// The bug this caught, stated as a test: the filled residual has to equal
410 + /// the renderer's own output for a row that answers the column and one that
411 + /// does not, and the two differ by the contents of a `<div>` that is
412 + /// present either way.
413 + #[test]
414 + fn a_guarded_cell_leaves_its_column_standing() {
415 + let webview = Webview::new();
416 + let residual = quasi_webview::stage::derive(&Webview::new(), sparse_table_staged);
417 + let rows = vec![
418 + Buyer {
419 + username: "ada".into(),
420 + email: String::new(),
421 + purchases: String::new(),
422 + spent: "$4".into(),
423 + last_purchase: String::new(),
424 + },
425 + Buyer {
426 + username: "bea".into(),
427 + email: String::new(),
428 + purchases: String::new(),
429 + spent: String::new(),
430 + last_purchase: String::new(),
431 + },
432 + ];
433 + assert_eq!(
434 + webview.fragment(&sparse_table(&rows)),
435 + sparse_table_serve(&residual, &rows),
436 + );
437 +
438 + // And the column is still there in both rows, which is the property the
439 + // whole split is for.
440 + let filled = sparse_table_serve(&residual, &rows);
441 + assert_eq!(filled.matches("col-Note").count(), 3, "{filled}");
442 + }
443 +
330 444 #[test]
331 445 fn the_residual_has_a_branch_and_a_loop_in_it() {
332 446 let residual = residual();
@@ -2697,8 +2697,16 @@
2697 2697 // repeats in the same row would be found twice.
2698 2698 let mut placed = Vec::new();
2699 2699 makeover_webview::list::cells_html_placed(columns, &emitted, opts, out, &mut placed);
2700 - for block in &placed {
2701 - marked.wrote(block.start, block.end);
2700 + for (at, one) in placed.iter().enumerate() {
2701 + // `parts` is what makes the column write `cell-value`, and it is
2702 + // read off the cell. So where it is set, the block's own class
2703 + // depends on the cell being there.
2704 + let steers = parts.get(at).copied().flatten().is_some();
2705 + marked.wrote_within(
2706 + (one.block.start, one.block.end),
2707 + (one.content.start, one.content.end),
2708 + steers,
2709 + );
2702 2710 }
2703 2711 } else {
2704 2712 cells_html_into(columns, &emitted, opts, out);
@@ -52,7 +52,7 @@
52 52 mod recording {
53 53 use std::cell::RefCell;
54 54
55 - use quasi_router::stage::{Cover, Mark, Marks};
55 + use quasi_router::stage::{Cover, Mark, Marks, Varies};
56 56
57 57 thread_local! {
58 58 static COVERS: RefCell<Option<Vec<Cover>>> = const { RefCell::new(None) };
@@ -92,7 +92,18 @@
92 92 /// region's members have the frame wrapper between them, and a menu comes
93 93 /// after markup that belongs to neither. A range says what one member wrote
94 94 /// and claims nothing about the gaps.
95 - pub struct Cursor(Option<Vec<(usize, usize)>>);
95 + pub struct Cursor(Option<Vec<Member>>);
96 +
97 + /// One member's bounds, and the part of them it wrote itself.
98 + #[derive(Clone, Copy)]
99 + struct Member {
100 + whole: (usize, usize),
101 + /// What this member contributed, where its container drew the rest.
102 + /// `None` when the member wrote everything between its bounds.
103 + written: Option<(usize, usize)>,
104 + /// Whether what the container drew around it depends on it.
105 + steers: bool,
106 + }
96 107
97 108 impl Cursor {
98 109 /// Start watching a container's members.
@@ -114,7 +125,11 @@
114 125 /// A member is about to be written.
115 126 pub fn starts(&mut self, out: &str) {
116 127 if let Some(members) = self.0.as_mut() {
117 - members.push((out.len(), out.len()));
128 + members.push(Member {
129 + whole: (out.len(), out.len()),
130 + written: None,
131 + steers: false,
132 + });
118 133 }
119 134 }
120 135
@@ -123,7 +138,7 @@
123 138 if let Some(members) = self.0.as_mut()
124 139 && let Some(last) = members.last_mut()
125 140 {
126 - last.1 = out.len();
141 + last.whole.1 = out.len();
127 142 }
128 143 }
129 144
@@ -133,7 +148,39 @@
133 148 /// told afterwards where each piece went.
134 149 pub fn wrote(&mut self, at: usize, to: usize) {
135 150 if let Some(members) = self.0.as_mut() {
136 - members.push((at, to));
151 + members.push(Member {
152 + whole: (at, to),
153 + written: None,
154 + steers: false,
155 + });
156 + }
157 + }
158 +
159 + /// A member whose surroundings are drawn by its container.
160 + ///
161 + /// A table column emits its `<div>` whether or not a cell answers it,
162 + /// so the two halves cover different questions and the mark says which
163 + /// it meant. A member that is there or is not covers `written`, since
164 + /// the block stands either way; a member replaced by another covers
165 + /// `whole`, since the replacement writes the block's class too.
166 + /// `steers` is whether the container's own markup for this member
167 + /// depends on the member: a table cell holding one piece of text makes
168 + /// its column write `cell-value`, and a cell that is absent makes it
169 + /// write nothing. Where that is true a guard cannot be one span at all,
170 + /// and [`close`](Self::close) says so rather than compiling the half it
171 + /// can see.
172 + pub fn wrote_within(
173 + &mut self,
174 + block: (usize, usize),
175 + content: (usize, usize),
176 + steers: bool,
177 + ) {
178 + if let Some(members) = self.0.as_mut() {
179 + members.push(Member {
180 + whole: block,
181 + written: Some(content),
182 + steers,
183 + });
137 184 }
138 185 }
139 186
@@ -166,12 +213,35 @@
166 213 members.len()
167 214 );
168 215 };
216 + // A guard covers what the members WROTE, because whatever their
217 + // container drew around them it draws either way. Anything else
218 + // covers the whole of them: a loop repeats the surroundings with
219 + // the body, and an arm replaces them.
220 + let (at, to) = match varies {
221 + Varies::Absent => {
222 + assert!(
223 + !(first.steers || last.steers),
224 + "mark {scope:x}/{id} guards a member whose container writes \
225 + different markup around it depending on the member, so what \
226 + a request decides is two places at once and a branch is one. \
227 + A table cell holding one piece of text is the case: its \
228 + column writes `cell-value` for it and nothing for a cell \
229 + that is absent. Say it as a dispatch, which replaces the \
230 + whole of what the column wrote."
231 + );
232 + (
233 + first.written.unwrap_or(first.whole).0,
234 + last.written.unwrap_or(last.whole).1,
235 + )
236 + }
237 + Varies::Repeated | Varies::Arm { .. } => (first.whole.0, last.whole.1),
238 + };
169 239 record(Cover {
170 240 scope,
171 241 id,
172 242 varies,
173 - at: first.0,
174 - to: last.1,
243 + at,
244 + to,
175 245 });
176 246 }
177 247 }