max / quasi
5 files changed,
+265 insertions,
-28 deletions
| @@ -6197,10 +6197,6 @@ | |||
| 6197 | 6197 | "winnow 1.0.4", | |
| 6198 | 6198 | ] | |
| 6199 | 6199 | ||
| 6200 | - | [[patch.unused]] | |
| 6201 | - | name = "synckit-client" | |
| 6202 | - | version = "0.8.1" | |
| 6203 | - | ||
| 6204 | 6200 | [[patch.unused]] | |
| 6205 | 6201 | name = "quasi-type" | |
| 6206 | 6202 | version = "0.1.0" | |
| @@ -6220,3 +6216,7 @@ | |||
| 6220 | 6216 | [[patch.unused]] | |
| 6221 | 6217 | name = "tagtree" | |
| 6222 | 6218 | version = "0.4.1" | |
| 6219 | + | ||
| 6220 | + | [[patch.unused]] | |
| 6221 | + | name = "synckit-client" | |
| 6222 | + | version = "0.8.1" |
| @@ -28,24 +28,73 @@ | |||
| 28 | 28 | use crate::{Pass, node}; | |
| 29 | 29 | ||
| 30 | 30 | /// Lay a screen's regions out and draw them. | |
| 31 | + | /// | |
| 32 | + | /// Bands take the height they need and get out of the way; which way they get | |
| 33 | + | /// out of is where they were said. Ruled by Max 2026-08-23 (quasicoherent | |
| 34 | + | /// `3725bacf`), picking (b) of two: a band declared before the body sits above | |
| 35 | + | /// it and one declared after it sits below, so a footer is a band at the end | |
| 36 | + | /// rather than a member the vocabulary had to grow. | |
| 37 | + | /// | |
| 38 | + | /// What the ruling settled was a disagreement, not a silence. quasi-webview | |
| 39 | + | /// emits `screen.slots` in declaration order and always has; this renderer and | |
| 40 | + | /// quasi-tui both hoisted every band to the top, so the same description drew | |
| 41 | + | /// two different screens depending on who was drawing it. Order was already | |
| 42 | + | /// load-bearing for the one renderer with live screens. | |
| 31 | 43 | pub(crate) fn screen_regions(pass: &mut Pass<'_>, ui: &mut Ui, screen: &Screen) { | |
| 32 | - | // Bands stack at the top, full width, in the order they were said. A band is | |
| 33 | - | // an arrangement rather than a type -- a page header, a toolbar -- so it | |
| 34 | - | // takes the height it needs and gets out of the way. | |
| 35 | - | for slot in screen | |
| 36 | - | .slots | |
| 37 | - | .iter() | |
| 38 | - | .filter(|slot| matches!(slot.kind, RegionKind::Band)) | |
| 39 | - | { | |
| 44 | + | let (leading, body, trailing) = split(screen); | |
| 45 | + | ||
| 46 | + | for slot in &leading { | |
| 40 | 47 | node::region(pass, ui, slot); | |
| 41 | 48 | } | |
| 42 | 49 | ||
| 43 | - | let body: Vec<&Slot> = screen | |
| 50 | + | // The trailing bands first, from the bottom up, so the body is arranged in | |
| 51 | + | // what is left rather than in all of it. egui gives no way to ask a region | |
| 52 | + | // how tall it will be without drawing it, and a reserved guess would be the | |
| 53 | + | // authored number the whole layer exists to refuse; a bottom-up layout is | |
| 54 | + | // the host's own arithmetic answering the same question exactly. | |
| 55 | + | // | |
| 56 | + | // Reversed, because the first widget in a bottom-up layout is the lowest | |
| 57 | + | // one: the band said last is the band at the bottom. | |
| 58 | + | if trailing.is_empty() { | |
| 59 | + | arranged(pass, ui, screen, &body); | |
| 60 | + | } else { | |
| 61 | + | ui.with_layout(egui::Layout::bottom_up(egui::Align::Min), |ui| { | |
| 62 | + | for slot in trailing.iter().rev() { | |
| 63 | + | node::region(pass, ui, slot); | |
| 64 | + | } | |
| 65 | + | ui.with_layout(egui::Layout::top_down(egui::Align::Min), |ui| { | |
| 66 | + | arranged(pass, ui, screen, &body); | |
| 67 | + | }); | |
| 68 | + | }); | |
| 69 | + | } | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | /// A screen's slots as the bands above the body, the body, and the bands below. | |
| 73 | + | /// | |
| 74 | + | /// The split is at the first region that is not a band. A screen of nothing but | |
| 75 | + | /// bands is all leading, which keeps the shape every description written before | |
| 76 | + | /// the ruling already had. | |
| 77 | + | fn split(screen: &Screen) -> (Vec<&Slot>, Vec<&Slot>, Vec<&Slot>) { | |
| 78 | + | let first_body = screen | |
| 44 | 79 | .slots | |
| 45 | 80 | .iter() | |
| 46 | - | .filter(|slot| !matches!(slot.kind, RegionKind::Band)) | |
| 47 | - | .collect(); | |
| 81 | + | .position(|slot| !matches!(slot.kind, RegionKind::Band)); | |
| 82 | + | let Some(at) = first_body else { | |
| 83 | + | return (screen.slots.iter().collect(), Vec::new(), Vec::new()); | |
| 84 | + | }; | |
| 85 | + | let is_band = |slot: &&Slot| matches!(slot.kind, RegionKind::Band); | |
| 86 | + | ( | |
| 87 | + | screen.slots[..at].iter().collect(), | |
| 88 | + | screen.slots[at..] | |
| 89 | + | .iter() | |
| 90 | + | .filter(|slot| !is_band(slot)) | |
| 91 | + | .collect(), | |
| 92 | + | screen.slots[at..].iter().filter(is_band).collect(), | |
| 93 | + | ) | |
| 94 | + | } | |
| 48 | 95 | ||
| 96 | + | /// The body regions, laid out the way the screen said. | |
| 97 | + | fn arranged(pass: &mut Pass<'_>, ui: &mut Ui, screen: &Screen, body: &[&Slot]) { | |
| 49 | 98 | match screen.arrangement { | |
| 50 | 99 | Arrangement::SidebarContent { share } => { | |
| 51 | 100 | let sidebar = body | |
| @@ -72,7 +121,7 @@ | |||
| 72 | 121 | // rather than refused: the regions are real and the reader can | |
| 73 | 122 | // still use them. | |
| 74 | 123 | None => { | |
| 75 | - | for slot in &body { | |
| 124 | + | for slot in body { | |
| 76 | 125 | scrolled(ui, slot, pass); | |
| 77 | 126 | } | |
| 78 | 127 | } |
| @@ -1393,6 +1393,72 @@ | |||
| 1393 | 1393 | } | |
| 1394 | 1394 | } | |
| 1395 | 1395 | ||
| 1396 | + | /// A screen with a band, a pane and a band, said in that order. | |
| 1397 | + | fn topped_and_tailed() -> Screen { | |
| 1398 | + | Screen::sidebar_content("Test") | |
| 1399 | + | .with(Slot::new("bar", RegionKind::Band).with(Node::text("the toolbar"))) | |
| 1400 | + | .with(Slot::new("main", RegionKind::Pane).with(Node::text("the content"))) | |
| 1401 | + | .with(Slot::new("foot", RegionKind::Band).with(Node::text("the status"))) | |
| 1402 | + | } | |
| 1403 | + | ||
| 1404 | + | #[test] | |
| 1405 | + | fn a_band_said_after_the_body_is_drawn_under_it() { | |
| 1406 | + | // Ruled by Max 2026-08-23 (quasicoherent 3725bacf): where a band was said | |
| 1407 | + | // is which end it belongs to. Before it this renderer hoisted every band, | |
| 1408 | + | // so a footer drew above the content it was the footer of while the same | |
| 1409 | + | // description put it underneath in a webview. | |
| 1410 | + | let mut host = Host::new(); | |
| 1411 | + | let screen = topped_and_tailed(); | |
| 1412 | + | host.settle(&screen); | |
| 1413 | + | ||
| 1414 | + | let bar = host.find("the toolbar"); | |
| 1415 | + | let content = host.find("the content"); | |
| 1416 | + | let foot = host.find("the status"); | |
| 1417 | + | ||
| 1418 | + | assert!( | |
| 1419 | + | bar.y < content.y, | |
| 1420 | + | "the toolbar left the top: {bar:?} {content:?}" | |
| 1421 | + | ); | |
| 1422 | + | assert!( | |
| 1423 | + | content.y < foot.y, | |
| 1424 | + | "the footer is above what it is the footer of: {content:?} {foot:?}" | |
| 1425 | + | ); | |
| 1426 | + | } | |
| 1427 | + | ||
| 1428 | + | #[test] | |
| 1429 | + | fn the_band_said_last_is_the_one_at_the_bottom() { | |
| 1430 | + | // Two trailing bands, which is audiofiles' shell: a migration strip above | |
| 1431 | + | // the status band. A bottom-up layout puts the first widget lowest, so the | |
| 1432 | + | // order they are drawn in is the reverse of the order they were said in, | |
| 1433 | + | // and getting that wrong is invisible until there are two. | |
| 1434 | + | let screen = Screen::sidebar_content("Test") | |
| 1435 | + | .with(Slot::new("main", RegionKind::Pane).with(Node::text("the content"))) | |
| 1436 | + | .with(Slot::new("strip", RegionKind::Band).with(Node::text("the strip"))) | |
| 1437 | + | .with(Slot::new("foot", RegionKind::Band).with(Node::text("the status"))); | |
| 1438 | + | let mut host = Host::new(); | |
| 1439 | + | host.settle(&screen); | |
| 1440 | + | ||
| 1441 | + | let strip = host.find("the strip"); | |
| 1442 | + | let foot = host.find("the status"); | |
| 1443 | + | assert!( | |
| 1444 | + | strip.y < foot.y, | |
| 1445 | + | "the bands are upside down: {strip:?} {foot:?}" | |
| 1446 | + | ); | |
| 1447 | + | } | |
| 1448 | + | ||
| 1449 | + | #[test] | |
| 1450 | + | fn a_screen_of_nothing_but_bands_is_unchanged() { | |
| 1451 | + | // The shape every description written before the ruling had. With no body | |
| 1452 | + | // to be after, every band is a leading one. | |
| 1453 | + | let screen = Screen::sidebar_content("Test") | |
| 1454 | + | .with(Slot::new("one", RegionKind::Band).with(Node::text("first"))) | |
| 1455 | + | .with(Slot::new("two", RegionKind::Band).with(Node::text("second"))); | |
| 1456 | + | let mut host = Host::new(); | |
| 1457 | + | host.settle(&screen); | |
| 1458 | + | ||
| 1459 | + | assert!(host.find("first").y < host.find("second").y); | |
| 1460 | + | } | |
| 1461 | + | ||
| 1396 | 1462 | /// A band whose members were said to share one row. | |
| 1397 | 1463 | fn run_of(fallback: layout::Fallback, members: [(&str, layout::Priority); 3]) -> Screen { | |
| 1398 | 1464 | let mut band = Slot::new("bar", RegionKind::Band).across(fallback); |
| @@ -73,18 +73,35 @@ | |||
| 73 | 73 | let area = measured(screen.measure, area); | |
| 74 | 74 | let mut rest = area; | |
| 75 | 75 | ||
| 76 | - | // Bands stack at the top, full width, in the order they were said. A band | |
| 77 | - | // is an arrangement rather than a type -- a page header, a toolbar -- so it | |
| 78 | - | // takes the rows it needs and gets out of the way. | |
| 79 | - | for slot in screen | |
| 80 | - | .slots | |
| 81 | - | .iter() | |
| 82 | - | .filter(|slot| matches!(slot.kind, RegionKind::Band)) | |
| 83 | - | { | |
| 76 | + | // Bands take the rows they need and get out of the way; which way they get | |
| 77 | + | // out of is where they were said. Ruled by Max 2026-08-23 (quasicoherent | |
| 78 | + | // `3725bacf`), picking (b) of two: a band before the body sits above it and | |
| 79 | + | // a band after it sits below, so a footer is a band at the end rather than | |
| 80 | + | // a member the vocabulary had to grow. quasi-webview has emitted slots in | |
| 81 | + | // declaration order all along; this renderer and quasi-immediate hoisted, | |
| 82 | + | // so the ruling settled a disagreement rather than a silence. | |
| 83 | + | let (leading, trailing) = bands(screen); | |
| 84 | + | for slot in &leading { | |
| 84 | 85 | let used = draw(pass, slot, rest, buf); | |
| 85 | 86 | rest = below(rest, used); | |
| 86 | 87 | } | |
| 87 | 88 | ||
| 89 | + | // The bottom is reserved before the body is laid out, or a pane that fills | |
| 90 | + | // its area would leave the footer nowhere to go. Reserved by asking each | |
| 91 | + | // trailing band how tall it is, which this renderer can do exactly -- | |
| 92 | + | // `height` is the same arithmetic the scroll already trusts -- so nothing | |
| 93 | + | // here is a guess and no number is authored. | |
| 94 | + | let reserved = trailing | |
| 95 | + | .iter() | |
| 96 | + | .map(|slot| height(pass.tui, slot, rest.width)) | |
| 97 | + | .sum::<u16>() | |
| 98 | + | .min(rest.height); | |
| 99 | + | let mut feet = below(rest, rest.height - reserved); | |
| 100 | + | rest = Rect { | |
| 101 | + | height: rest.height - reserved, | |
| 102 | + | ..rest | |
| 103 | + | }; | |
| 104 | + | ||
| 88 | 105 | let body = body_slots(screen); | |
| 89 | 106 | ||
| 90 | 107 | match screen.arrangement { | |
| @@ -124,6 +141,13 @@ | |||
| 124 | 141 | } | |
| 125 | 142 | } | |
| 126 | 143 | ||
| 144 | + | // The trailing bands, in the room kept for them, in the order they were | |
| 145 | + | // said: the band said last is the one at the bottom. | |
| 146 | + | for slot in &trailing { | |
| 147 | + | let used = draw(pass, slot, feet, buf); | |
| 148 | + | feet = below(feet, used); | |
| 149 | + | } | |
| 150 | + | ||
| 127 | 151 | // Modals last and over everything, which is what a modal is. Centred in | |
| 128 | 152 | // half the width, because `Depth::Overlay` says it sits above the page and | |
| 129 | 153 | // says nothing about how much of it to cover. | |
| @@ -188,6 +212,26 @@ | |||
| 188 | 212 | } | |
| 189 | 213 | } | |
| 190 | 214 | ||
| 215 | + | /// A screen's bands, as the ones above the body and the ones below it. | |
| 216 | + | /// | |
| 217 | + | /// The split is at the first region that is not a band or a modal. A screen of | |
| 218 | + | /// nothing but bands is all leading, which is the shape every description | |
| 219 | + | /// written before the ruling already had. | |
| 220 | + | fn bands(screen: &Screen) -> (Vec<&Slot>, Vec<&Slot>) { | |
| 221 | + | let is_band = |slot: &&Slot| matches!(slot.kind, RegionKind::Band); | |
| 222 | + | let first_body = screen | |
| 223 | + | .slots | |
| 224 | + | .iter() | |
| 225 | + | .position(|slot| !matches!(slot.kind, RegionKind::Band | RegionKind::Modal)); | |
| 226 | + | let Some(at) = first_body else { | |
| 227 | + | return (screen.slots.iter().filter(is_band).collect(), Vec::new()); | |
| 228 | + | }; | |
| 229 | + | ( | |
| 230 | + | screen.slots[..at].iter().filter(is_band).collect(), | |
| 231 | + | screen.slots[at..].iter().filter(is_band).collect(), | |
| 232 | + | ) | |
| 233 | + | } | |
| 234 | + | ||
| 191 | 235 | /// Every region the user can see, in the order it is drawn. | |
| 192 | 236 | /// | |
| 193 | 237 | /// What the focus walk reads. A region that is not drawn holds nothing | |
| @@ -209,11 +253,11 @@ | |||
| 209 | 253 | return modals; | |
| 210 | 254 | } | |
| 211 | 255 | ||
| 212 | - | screen | |
| 213 | - | .slots | |
| 214 | - | .iter() | |
| 215 | - | .filter(|slot| matches!(slot.kind, RegionKind::Band)) | |
| 256 | + | let (leading, trailing) = bands(screen); | |
| 257 | + | leading | |
| 258 | + | .into_iter() | |
| 216 | 259 | .chain(body_slots(screen)) | |
| 260 | + | .chain(trailing) | |
| 217 | 261 | .collect() | |
| 218 | 262 | } | |
| 219 | 263 |
| @@ -103,6 +103,84 @@ | |||
| 103 | 103 | rows(&buf) | |
| 104 | 104 | } | |
| 105 | 105 | ||
| 106 | + | /// The row a piece of text was drawn on. | |
| 107 | + | fn row_of(rows: &[String], text: &str) -> Option<usize> { | |
| 108 | + | rows.iter().position(|row| row.contains(text)) | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | #[test] | |
| 112 | + | fn a_band_said_after_the_body_is_drawn_under_it() { | |
| 113 | + | // Ruled by Max 2026-08-23 (quasicoherent 3725bacf): where a band was said | |
| 114 | + | // is which end it belongs to. This renderer hoisted every band before it, | |
| 115 | + | // so a status band drew above the content it belonged under while the same | |
| 116 | + | // description put it underneath in a webview. | |
| 117 | + | let screen = Screen::sidebar_content("Test") | |
| 118 | + | .with(Slot::new("bar", RegionKind::Band).with(Node::text("the toolbar"))) | |
| 119 | + | .with(Slot::new("main", RegionKind::Pane).with(Node::text("the content"))) | |
| 120 | + | .with(Slot::new("foot", RegionKind::Band).with(Node::text("the status"))); | |
| 121 | + | let out = shown(&screen, 40, 12); | |
| 122 | + | ||
| 123 | + | let bar = row_of(&out, "the toolbar").expect("no toolbar drawn"); | |
| 124 | + | let content = row_of(&out, "the content").expect("no content drawn"); | |
| 125 | + | let foot = row_of(&out, "the status").expect("no status drawn"); | |
| 126 | + | assert!(bar < content, "the toolbar left the top: {out:?}"); | |
| 127 | + | assert!( | |
| 128 | + | content < foot, | |
| 129 | + | "the footer is above what it is the footer of: {out:?}" | |
| 130 | + | ); | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | #[test] | |
| 134 | + | fn the_room_a_trailing_band_needs_is_kept_out_of_the_body() { | |
| 135 | + | // The failure a reservation prevents: a pane that fills its area leaves a | |
| 136 | + | // footer nowhere to go, and the band drawn into no rows is a band nobody | |
| 137 | + | // sees. The pane here holds more lines than the screen has. | |
| 138 | + | let mut pane = Slot::new("main", RegionKind::Pane); | |
| 139 | + | for line in 0..30 { | |
| 140 | + | pane = pane.with(Node::text(format!("line {line}"))); | |
| 141 | + | } | |
| 142 | + | let screen = Screen::sidebar_content("Test") | |
| 143 | + | .with(pane) | |
| 144 | + | .with(Slot::new("foot", RegionKind::Band).with(Node::text("the status"))); | |
| 145 | + | let out = shown(&screen, 40, 10); | |
| 146 | + | ||
| 147 | + | assert!( | |
| 148 | + | row_of(&out, "the status").is_some(), | |
| 149 | + | "the body took the footer's rows: {out:?}" | |
| 150 | + | ); | |
| 151 | + | } | |
| 152 | + | ||
| 153 | + | #[test] | |
| 154 | + | fn the_band_said_last_is_the_one_at_the_bottom() { | |
| 155 | + | // Two trailing bands, which is audiofiles' shell: a migration strip above | |
| 156 | + | // the status band. | |
| 157 | + | let screen = Screen::sidebar_content("Test") | |
| 158 | + | .with(Slot::new("main", RegionKind::Pane).with(Node::text("the content"))) | |
| 159 | + | .with(Slot::new("strip", RegionKind::Band).with(Node::text("the strip"))) | |
| 160 | + | .with(Slot::new("foot", RegionKind::Band).with(Node::text("the status"))); | |
| 161 | + | let out = shown(&screen, 40, 12); | |
| 162 | + | ||
| 163 | + | let strip = row_of(&out, "the strip").expect("no strip drawn"); | |
| 164 | + | let foot = row_of(&out, "the status").expect("no status drawn"); | |
| 165 | + | assert!(strip < foot, "the bands are upside down: {out:?}"); | |
| 166 | + | } | |
| 167 | + | ||
| 168 | + | #[test] | |
| 169 | + | fn the_caret_reaches_a_trailing_band_in_the_order_it_is_drawn() { | |
| 170 | + | // The focus walk and the drawing have to agree about where a band is, or | |
| 171 | + | // the caret lights the footer before the content it sits under. | |
| 172 | + | let screen = Screen::sidebar_content("Test") | |
| 173 | + | .with(Slot::new("bar", RegionKind::Band).with(Node::text("the toolbar"))) | |
| 174 | + | .with(Slot::new("main", RegionKind::Pane).with(Node::text("the content"))) | |
| 175 | + | .with(Slot::new("foot", RegionKind::Band).with(Node::text("the status"))); | |
| 176 | + | ||
| 177 | + | let order: Vec<&str> = crate::region::reachable(&screen) | |
| 178 | + | .iter() | |
| 179 | + | .map(|slot| slot.id.as_str()) | |
| 180 | + | .collect(); | |
| 181 | + | assert_eq!(order, ["bar", "main", "foot"]); | |
| 182 | + | } | |
| 183 | + | ||
| 106 | 184 | #[test] | |
| 107 | 185 | fn a_row_draws_its_run_in_the_order_the_description_says_it() { | |
| 108 | 186 | // The property the containment migration bought this renderer: a terminal |