max / quasi
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
3 files changed,
+133 insertions,
-1 deletion
| @@ -724,7 +724,6 @@ | |||
| 724 | 724 | [[package]] | |
| 725 | 725 | name = "docengine" | |
| 726 | 726 | version = "0.5.0" | |
| 727 | - | source = "git+https://makenot.work/git/max/docengine.git#ae55a5a5b0d0a45160ea7f234e6d3a72e4efd8f9" | |
| 728 | 727 | dependencies = [ | |
| 729 | 728 | "ammonia", | |
| 730 | 729 | "pulldown-cmark", | |
| @@ -4888,3 +4887,23 @@ | |||
| 4888 | 4887 | version = "1.0.23" | |
| 4889 | 4888 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4890 | 4889 | checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" | |
| 4890 | + | ||
| 4891 | + | [[patch.unused]] | |
| 4892 | + | name = "synckit-client" | |
| 4893 | + | version = "0.8.0" | |
| 4894 | + | ||
| 4895 | + | [[patch.unused]] | |
| 4896 | + | name = "synckit-config" | |
| 4897 | + | version = "0.2.0" | |
| 4898 | + | ||
| 4899 | + | [[patch.unused]] | |
| 4900 | + | name = "kberg" | |
| 4901 | + | version = "0.1.0" | |
| 4902 | + | ||
| 4903 | + | [[patch.unused]] | |
| 4904 | + | name = "painhours" | |
| 4905 | + | version = "0.1.0" | |
| 4906 | + | ||
| 4907 | + | [[patch.unused]] | |
| 4908 | + | name = "tagtree" | |
| 4909 | + | version = "0.4.0" |
| @@ -273,6 +273,62 @@ | |||
| 273 | 273 | assert!(screen.slot("pane-b").is_none()); | |
| 274 | 274 | } | |
| 275 | 275 | ||
| 276 | + | #[test] | |
| 277 | + | fn a_fragment_replaces_a_top_level_regions_contents() { | |
| 278 | + | let mut screen = Screen::sidebar_content("Home").with( | |
| 279 | + | Slot::new("content", RegionKind::Pane) | |
| 280 | + | .with(Node::text("first")) | |
| 281 | + | .with(Node::text("second")), | |
| 282 | + | ); | |
| 283 | + | ||
| 284 | + | assert!(screen.replace("content", Node::text("after"))); | |
| 285 | + | ||
| 286 | + | let slot = screen.slot("content").expect("the region is still there"); | |
| 287 | + | // Replaced, not appended: a fragment is one region's new contents, | |
| 288 | + | // which is the whole reason it can be smaller than a screen. | |
| 289 | + | assert_eq!(slot.body, vec![Node::text("after")]); | |
| 290 | + | } | |
| 291 | + | ||
| 292 | + | #[test] | |
| 293 | + | fn a_fragment_reaches_a_region_nested_inside_another() { | |
| 294 | + | let mut screen = Screen::new("Tabs", Arrangement::ListDetail { tabbed: true }).with( | |
| 295 | + | Slot::new("tabs", RegionKind::TabGroup) | |
| 296 | + | .with(Node::Region(Slot::new("pane-a", RegionKind::Pane))), | |
| 297 | + | ); | |
| 298 | + | ||
| 299 | + | assert!(screen.replace("pane-a", Node::text("loaded"))); | |
| 300 | + | ||
| 301 | + | let pane = screen.slot("pane-a").expect("the nested region"); | |
| 302 | + | assert_eq!(pane.body, vec![Node::text("loaded")]); | |
| 303 | + | // The region it is inside keeps its own body, which still holds the | |
| 304 | + | // nested region rather than having been replaced by it. | |
| 305 | + | let tabs = screen.slot("tabs").expect("the outer region"); | |
| 306 | + | assert_eq!(tabs.body.len(), 1); | |
| 307 | + | } | |
| 308 | + | ||
| 309 | + | #[test] | |
| 310 | + | fn a_pending_region_stops_being_pending_when_its_content_arrives() { | |
| 311 | + | let mut screen = | |
| 312 | + | Screen::sidebar_content("Home").with(Slot::new("content", RegionKind::Pane).pending()); | |
| 313 | + | ||
| 314 | + | assert!(screen.replace("content", Node::text("here"))); | |
| 315 | + | ||
| 316 | + | let slot = screen.slot("content").expect("the region"); | |
| 317 | + | assert_eq!(slot.readiness, layout::Readiness::Ready); | |
| 318 | + | } | |
| 319 | + | ||
| 320 | + | #[test] | |
| 321 | + | fn a_fragment_for_a_region_that_is_not_there_answers_false() { | |
| 322 | + | let mut screen = | |
| 323 | + | Screen::sidebar_content("Home").with(Slot::new("content", RegionKind::Pane)); | |
| 324 | + | ||
| 325 | + | // Not a panic and not a silent no-op: a miss means a route naming a | |
| 326 | + | // slot that no longer exists, so the caller is the one that can act on | |
| 327 | + | // it -- redraw, or fail a test. | |
| 328 | + | assert!(!screen.replace("detail", Node::text("nowhere"))); | |
| 329 | + | assert!(screen.slot("content").expect("untouched").body.is_empty()); | |
| 330 | + | } | |
| 331 | + | ||
| 276 | 332 | #[test] | |
| 277 | 333 | fn an_owned_field_borrows_back_as_the_description_layers_own() { | |
| 278 | 334 | let field = Field::select( |
| @@ -1114,6 +1114,22 @@ | |||
| 1114 | 1114 | _ => None, | |
| 1115 | 1115 | }) | |
| 1116 | 1116 | } | |
| 1117 | + | ||
| 1118 | + | /// The mutable half of [`find`](Self::find). | |
| 1119 | + | /// | |
| 1120 | + | /// Same walk, and it has to be a second function rather than the same one | |
| 1121 | + | /// generic over mutability: a `&mut` borrow of `self` cannot be handed to | |
| 1122 | + | /// the recursive call and kept, which is what `find_map` does on the shared | |
| 1123 | + | /// side. | |
| 1124 | + | fn find_mut(&mut self, id: &str) -> Option<&mut Self> { | |
| 1125 | + | if self.id == id { | |
| 1126 | + | return Some(self); | |
| 1127 | + | } | |
| 1128 | + | self.body.iter_mut().find_map(|node| match node { | |
| 1129 | + | Node::Region(slot) => slot.find_mut(id), | |
| 1130 | + | _ => None, | |
| 1131 | + | }) | |
| 1132 | + | } | |
| 1117 | 1133 | } | |
| 1118 | 1134 | ||
| 1119 | 1135 | /// A control that calls a route. | |
| @@ -2145,4 +2161,45 @@ | |||
| 2145 | 2161 | pub fn slot(&self, id: &str) -> Option<&Slot> { | |
| 2146 | 2162 | self.slots.iter().find_map(|slot| slot.find(id)) | |
| 2147 | 2163 | } | |
| 2164 | + | ||
| 2165 | + | /// Apply a fragment: put `node` in the region under `region`, replacing | |
| 2166 | + | /// whatever was there. Returns whether the region was found. | |
| 2167 | + | /// | |
| 2168 | + | /// This is what a host holding a `Screen` does with | |
| 2169 | + | /// [`Outcome::Fragment`](crate::Outcome::Fragment). A webview host needs | |
| 2170 | + | /// none of it -- `quasi-http` turns the same outcome into an `hx-retarget` | |
| 2171 | + | /// header and the browser performs the swap against a document it already | |
| 2172 | + | /// has -- but a host that retains the description rather than the markup | |
| 2173 | + | /// has nothing between the fragment and the tree. | |
| 2174 | + | /// | |
| 2175 | + | /// It lives here and not in a host because applying a fragment is surgery | |
| 2176 | + | /// on this crate's own type. A host writing it means every retained-screen | |
| 2177 | + | /// host writes it separately and each picks its own answer for the three | |
| 2178 | + | /// decisions below, which is the thing this crate's no-host-imports rule | |
| 2179 | + | /// exists to prevent. | |
| 2180 | + | /// | |
| 2181 | + | /// **A region that is not there answers `false`, not a panic.** The caller | |
| 2182 | + | /// is the one that can act on it: a host can fall back to a redraw, and a | |
| 2183 | + | /// test can assert it. What is worth avoiding is the silent no-op, because | |
| 2184 | + | /// a miss means a route naming a slot that no longer exists, and that is a | |
| 2185 | + | /// description bug rather than a rendering one. | |
| 2186 | + | /// | |
| 2187 | + | /// **It replaces rather than appends.** `Outcome::Fragment` is one region's | |
| 2188 | + | /// new contents, which is the whole reason it can be smaller than a screen. | |
| 2189 | + | /// | |
| 2190 | + | /// **The region becomes [`Ready`](layout::Readiness::Ready).** A fragment | |
| 2191 | + | /// arriving is the content arriving, so a slot marked | |
| 2192 | + | /// [`Pending`](layout::Readiness::Pending) while it was in flight stops | |
| 2193 | + | /// being pending here. Emptiness is a different axis and rides on the node: | |
| 2194 | + | /// a [`Node::StandIn`] carries its own state, and replacing with one is a | |
| 2195 | + | /// region that is ready and has nothing to show. | |
| 2196 | + | pub fn replace(&mut self, region: &str, node: Node) -> bool { | |
| 2197 | + | let Some(slot) = self.slots.iter_mut().find_map(|slot| slot.find_mut(region)) else { | |
| 2198 | + | return false; | |
| 2199 | + | }; | |
| 2200 | + | slot.body.clear(); | |
| 2201 | + | slot.body.push(node); | |
| 2202 | + | slot.readiness = layout::Readiness::Ready; | |
| 2203 | + | true | |
| 2204 | + | } | |
| 2148 | 2205 | } |