Skip to main content

max / quasi

Screen::replace, so a host that keeps the description can apply a fragment Outcome::Fragment had exactly one consumer: quasi-http turns it into an hx-retarget header and the browser swaps against a document it already has. Screen::slot and Slot::find are read-only, so a host holding a Screen rather than markup had nothing between a fragment and its tree, and both current hosts are webview hosts so neither ever noticed. It belongs to the router because applying a fragment is surgery on the router's own type. A host writing it means every retained-screen host writes it separately and each picks its own answer for the three decisions, which is what the no-host-imports rule exists to prevent. The three, recorded beside the function: a region that is not there answers false rather than panicking, because a miss means a route naming a slot that no longer exists and only the caller can act on that; it replaces rather than appends, since a fragment is one region's new contents; and the region ends Ready, because a fragment arriving is the content arriving. Emptiness rides on the node instead, where Node::StandIn already carries it. find_mut is a second walk rather than the same one generalised: a &mut borrow of self cannot be handed to the recursive call and kept, which is what find_map does on the shared side. quasi-http is untouched. It holds no Screen to replace.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-12 14:56 UTC
Signed with PGP, not checked
Commit: ba8b35187958b5ee6e67aa301f0952fc53ca483e
Parent: 79ad55f
3 files changed, +133 insertions, -1 deletion
M Cargo.lock +20 -1
@@ -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 }