max / quasi
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
- Claude-Session
- https://claude.ai/code/session_01P8ostB2UmZJGj5WjSHRSot
6 files changed,
+205 insertions,
-13 deletions
| @@ -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 | } |