max / quasi
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
13 files changed,
+411 insertions,
-35 deletions
| @@ -5163,6 +5163,10 @@ | |||
| 5163 | 5163 | name = "kberg" | |
| 5164 | 5164 | version = "0.1.0" | |
| 5165 | 5165 | ||
| 5166 | + | [[patch.unused]] | |
| 5167 | + | name = "ops-status" | |
| 5168 | + | version = "0.1.0" | |
| 5169 | + | ||
| 5166 | 5170 | [[patch.unused]] | |
| 5167 | 5171 | name = "painhours" | |
| 5168 | 5172 | version = "0.1.0" |
| @@ -213,19 +213,32 @@ | |||
| 213 | 213 | .as_ref() | |
| 214 | 214 | .map(|notice| htmx::notice_trigger(notice.kind, notice.tone, ¬ice.text)); | |
| 215 | 215 | let mut response = match answer.outcome { | |
| 216 | + | // An invalidation is not applied to a whole screen, and that is | |
| 217 | + | // not a case being dropped. Every slot is being replaced | |
| 218 | + | // already, so an out-of-band copy would be a second element | |
| 219 | + | // carrying an id the document now has twice. A handler that | |
| 220 | + | // says `.also()` on a screen is stating something the answer | |
| 221 | + | // already made true. | |
| 216 | 222 | Outcome::Screen(screen) => body(render, 200, render.screen(&screen), None), | |
| 217 | 223 | Outcome::Fragment { region, node } => { | |
| 218 | 224 | // The router said what it changed, so the client is told | |
| 219 | 225 | // rather than left to infer it from which element was | |
| 220 | 226 | // clicked. The webview renderer owes every slot an `id` | |
| 221 | 227 | // matching its `Slot::id` for this to land. | |
| 222 | - | body( | |
| 223 | - | render, | |
| 224 | - | 200, | |
| 225 | - | render.fragment(&node), | |
| 226 | - | Some(format!("#{region}")), | |
| 227 | - | ) | |
| 228 | + | // | |
| 229 | + | // The other slots the answer changed ride along behind the | |
| 230 | + | // targeted one, each named rather than aimed. Appended in | |
| 231 | + | // the order the router gave them, because a renderer | |
| 232 | + | // reordering them would be inventing a fact the response | |
| 233 | + | // did not state. | |
| 234 | + | let mut markup = render.fragment(&node); | |
| 235 | + | for stale in &answer.invalidates { | |
| 236 | + | markup.push_str(&render.invalidated(&stale.region, &stale.node)); | |
| 237 | + | } | |
| 238 | + | body(render, 200, markup, Some(format!("#{region}"))) | |
| 228 | 239 | } | |
| 240 | + | // Nor to a redirect, which has no body to carry one. The | |
| 241 | + | // destination answers next and answers with everything. | |
| 229 | 242 | Outcome::Goto(action) => redirect(&action), | |
| 230 | 243 | }; | |
| 231 | 244 | if let Some(trigger) = trigger |
| @@ -45,6 +45,31 @@ | |||
| 45 | 45 | /// The inside of one region. | |
| 46 | 46 | fn fragment(&self, node: &Node) -> String; | |
| 47 | 47 | ||
| 48 | + | /// One region's new contents, carried beside the region being replaced. | |
| 49 | + | /// | |
| 50 | + | /// [`Response::invalidates`](quasi_router::Response::invalidates): the row | |
| 51 | + | /// the write was aimed at is the [`fragment`](Self::fragment), and the | |
| 52 | + | /// count in the header is one of these. Both travel on one answer, so the | |
| 53 | + | /// two never disagree and no second request is made for a fact the router | |
| 54 | + | /// already had. | |
| 55 | + | /// | |
| 56 | + | /// Unlike the other two this carries the region's id, because an | |
| 57 | + | /// out-of-band update is applied by name rather than by where it was | |
| 58 | + | /// aimed. A fragment does not need one: the router said where it goes in | |
| 59 | + | /// [`RETARGET`](crate::htmx::RETARGET). | |
| 60 | + | /// | |
| 61 | + | /// # Why it defaults to nothing | |
| 62 | + | /// | |
| 63 | + | /// A renderer answering something other than markup has no out-of-band | |
| 64 | + | /// channel to put this in, the same reason [`content_type`](Self::content_type) | |
| 65 | + | /// is overridable. Returning nothing is that renderer saying so, and it is | |
| 66 | + | /// the honest default: a JSON client is told what changed by the payload it | |
| 67 | + | /// already parses. | |
| 68 | + | fn invalidated(&self, region: &str, node: &Node) -> String { | |
| 69 | + | let _ = (region, node); | |
| 70 | + | String::new() | |
| 71 | + | } | |
| 72 | + | ||
| 48 | 73 | /// What the document says it is. | |
| 49 | 74 | /// | |
| 50 | 75 | /// Overridable for a renderer answering something other than HTML, which is |
| @@ -24,6 +24,10 @@ | |||
| 24 | 24 | other => format!("other:{other:?}"), | |
| 25 | 25 | } | |
| 26 | 26 | } | |
| 27 | + | ||
| 28 | + | fn invalidated(&self, region: &str, node: &Node) -> String { | |
| 29 | + | format!("[oob:{region}:{}]", self.fragment(node)) | |
| 30 | + | } | |
| 27 | 31 | } | |
| 28 | 32 | ||
| 29 | 33 | /// Decode a request built from its parts. | |
| @@ -516,3 +520,78 @@ | |||
| 516 | 520 | ); | |
| 517 | 521 | assert_eq!(history(&response), (None, None)); | |
| 518 | 522 | } | |
| 523 | + | ||
| 524 | + | #[test] | |
| 525 | + | fn a_write_naming_two_slots_carries_both_behind_the_one_it_replaced() { | |
| 526 | + | // The row the write was aimed at, and the count above it that also moved. | |
| 527 | + | // Both on one answer, in the order the router named them, so the two can | |
| 528 | + | // never disagree and no second request is made for a fact it already had. | |
| 529 | + | let response = respond( | |
| 530 | + | &Spy, | |
| 531 | + | Ok(Response::fragment("row-7", Node::text("Done")) | |
| 532 | + | .also("task-count", Node::text("4 left")) | |
| 533 | + | .also("sidebar-badge", Node::text("4"))), | |
| 534 | + | &asked("POST", "/tasks/7/done"), | |
| 535 | + | ); | |
| 536 | + | ||
| 537 | + | let body = String::from_utf8(response.body().clone()).expect("the spy answers text"); | |
| 538 | + | assert_eq!( | |
| 539 | + | body, | |
| 540 | + | "text:Done[oob:task-count:text:4 left][oob:sidebar-badge:text:4]" | |
| 541 | + | ); | |
| 542 | + | ||
| 543 | + | // The retarget still names one target. A set of invalidations is a | |
| 544 | + | // different fact, and conflating them was the rejected option. | |
| 545 | + | assert_eq!( | |
| 546 | + | response | |
| 547 | + | .headers() | |
| 548 | + | .get(super::htmx::RETARGET) | |
| 549 | + | .map(|value| value.to_str().expect("a slot id is ascii")), | |
| 550 | + | Some("#row-7") | |
| 551 | + | ); | |
| 552 | + | } | |
| 553 | + | ||
| 554 | + | #[test] | |
| 555 | + | fn a_whole_screen_and_a_redirect_carry_no_out_of_band_copy() { | |
| 556 | + | // Not a case being dropped. A screen replaces every slot already, so an | |
| 557 | + | // out-of-band copy would put a second element into the document under an | |
| 558 | + | // id it now has twice; a redirect has no body at all. | |
| 559 | + | let screen = Screen::sidebar_content("Tasks").with(Slot::new("main", RegionKind::Pane)); | |
| 560 | + | let swapped = respond( | |
| 561 | + | &Spy, | |
| 562 | + | Ok(Response::screen(screen).also("task-count", Node::text("4 left"))), | |
| 563 | + | &asked("GET", "/tasks"), | |
| 564 | + | ); | |
| 565 | + | let body = String::from_utf8(swapped.body().clone()).expect("the spy answers text"); | |
| 566 | + | assert_eq!(body, "screen:Tasks"); | |
| 567 | + | ||
| 568 | + | let sent = respond( | |
| 569 | + | &Spy, | |
| 570 | + | Ok(Response::goto(Action::get("/tasks")).also("task-count", Node::text("4 left"))), | |
| 571 | + | &asked("POST", "/tasks/7/delete"), | |
| 572 | + | ); | |
| 573 | + | assert!(sent.body().is_empty()); | |
| 574 | + | } | |
| 575 | + | ||
| 576 | + | #[test] | |
| 577 | + | fn a_renderer_that_does_not_answer_markup_drops_an_invalidation() { | |
| 578 | + | // The default on `Serves::invalidated`, which is the honest answer for a | |
| 579 | + | // renderer with no out-of-band channel rather than an oversight: a JSON | |
| 580 | + | // client learns what changed from the payload it already parses. | |
| 581 | + | struct Json; | |
| 582 | + | impl Serves for Json { | |
| 583 | + | fn screen(&self, _: &Screen) -> String { | |
| 584 | + | "{}".to_owned() | |
| 585 | + | } | |
| 586 | + | fn fragment(&self, _: &Node) -> String { | |
| 587 | + | "{}".to_owned() | |
| 588 | + | } | |
| 589 | + | } | |
| 590 | + | ||
| 591 | + | let response = respond( | |
| 592 | + | &Json, | |
| 593 | + | Ok(Response::fragment("row-7", Node::text("Done")).also("count", Node::text("4"))), | |
| 594 | + | &asked("POST", "/tasks/7/done"), | |
| 595 | + | ); | |
| 596 | + | assert_eq!(response.body(), b"{}"); | |
| 597 | + | } |
| @@ -114,7 +114,7 @@ | |||
| 114 | 114 | pub use crate::containment::{Containment, Element, Level, Of}; | |
| 115 | 115 | pub use crate::error::{Class, RouteError}; | |
| 116 | 116 | pub use crate::request::{Method, Params, Request}; | |
| 117 | - | pub use crate::response::{Address, Message, Outcome, Response}; | |
| 117 | + | pub use crate::response::{Address, Invalidated, Message, Outcome, Response}; | |
| 118 | 118 | pub use crate::router::{Handler, Router}; | |
| 119 | 119 | pub use crate::screen::{ | |
| 120 | 120 | Act, Action, Cell, Cells, Choice, Column, Destination, Discovery, Field, Figure, Meter, Node, |
| @@ -28,6 +28,12 @@ | |||
| 28 | 28 | //! [`Outcome`] holds the three ways to answer with content and the notice sits | |
| 29 | 29 | //! beside it, optional, orthogonal to all three. | |
| 30 | 30 | //! | |
| 31 | + | //! [`invalidates`](Response::invalidates) is the third arrival and the one that | |
| 32 | + | //! settles the shape: a write that changes a row *and* the count above it | |
| 33 | + | //! composes with all three outcomes and with the notice, so it is a fourth | |
| 34 | + | //! field rather than a fourth member. Had this stayed an enum it would have | |
| 35 | + | //! needed a member per combination. | |
| 36 | + | //! | |
| 31 | 37 | //! # Why [`Goto`](Outcome::Goto) takes an [`Action`] and not a [`Destination`] | |
| 32 | 38 | //! | |
| 33 | 39 | //! A redirect has params: back to a list with a filter still applied, back to a | |
| @@ -62,6 +68,44 @@ | |||
| 62 | 68 | /// place, a write and a fragment are not — so a control never has to | |
| 63 | 69 | /// predict what its answer will be. See [`Address`]. | |
| 64 | 70 | pub address: Option<Address>, | |
| 71 | + | /// The other slots this answer changed, beyond the one it replaced. | |
| 72 | + | /// | |
| 73 | + | /// Empty on almost every response. See [`Invalidated`], and [`also`] for | |
| 74 | + | /// the way to add one. | |
| 75 | + | /// | |
| 76 | + | /// [`also`]: Self::also | |
| 77 | + | pub invalidates: Vec<Invalidated>, | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | /// A slot this answer changed without being aimed at it. | |
| 81 | + | /// | |
| 82 | + | /// The row you edited is the [`Outcome`]; the count in the header is one of | |
| 83 | + | /// these. Both are named by [`Slot::id`](crate::Slot::id), because a slot id is | |
| 84 | + | /// the address a description already uses for a region and there is no reason | |
| 85 | + | /// for a second naming scheme. | |
| 86 | + | /// | |
| 87 | + | /// # Why this carries a node and not just an id | |
| 88 | + | /// | |
| 89 | + | /// A renderer told only that something is stale has two ways to act on it, and | |
| 90 | + | /// both are worse. It can ask again, which is a second round trip for a fact | |
| 91 | + | /// the router had in hand. Or it can re-derive the region, which means the | |
| 92 | + | /// router's view logic runs twice per write and the two runs have to agree. | |
| 93 | + | /// Handing over the new contents makes an invalidation the same shape as a | |
| 94 | + | /// fragment, which is what it is: one region and what now goes in it. | |
| 95 | + | /// | |
| 96 | + | /// # What each renderer does with it | |
| 97 | + | /// | |
| 98 | + | /// A webview swaps it out of band, so the row and the header both move on one | |
| 99 | + | /// response. A terminal redraws that panel. An egui frame does nothing, | |
| 100 | + | /// because it was going to redraw everything anyway. That spread is the reason | |
| 101 | + | /// this says "invalidated" rather than naming a swap: a swap is a DOM idea and | |
| 102 | + | /// two of the three renderers have no answer for it. | |
| 103 | + | #[derive(Debug, Clone, PartialEq, Eq)] | |
| 104 | + | pub struct Invalidated { | |
| 105 | + | /// The [`Slot::id`](crate::Slot::id) whose contents are now stale. | |
| 106 | + | pub region: String, | |
| 107 | + | /// What goes in it instead. | |
| 108 | + | pub node: Node, | |
| 65 | 109 | } | |
| 66 | 110 | ||
| 67 | 111 | /// Whether an answer is somewhere the user can come back to. | |
| @@ -224,6 +268,25 @@ | |||
| 224 | 268 | self | |
| 225 | 269 | } | |
| 226 | 270 | ||
| 271 | + | /// This answer also changed that slot, and here is its new content. | |
| 272 | + | /// | |
| 273 | + | /// Chains, so a write that moves three places says so three times. The | |
| 274 | + | /// order is kept, because a renderer applying them in a different order | |
| 275 | + | /// than the router named them would be inventing a fact. | |
| 276 | + | /// | |
| 277 | + | /// Naming the slot the [`Outcome`] already replaces is not rejected here | |
| 278 | + | /// and not special-cased: a renderer applies what it is given, and a | |
| 279 | + | /// response that says the same region twice is a bug in the handler that a | |
| 280 | + | /// silent drop would hide. | |
| 281 | + | #[must_use] | |
| 282 | + | pub fn also(mut self, region: impl Into<String>, node: Node) -> Self { | |
| 283 | + | self.invalidates.push(Invalidated { | |
| 284 | + | region: region.into(), | |
| 285 | + | node, | |
| 286 | + | }); | |
| 287 | + | self | |
| 288 | + | } | |
| 289 | + | ||
| 227 | 290 | /// This answer is a place, at this address. | |
| 228 | 291 | /// | |
| 229 | 292 | /// For the answer a derivation cannot reach: a fragment that is a place. | |
| @@ -287,6 +350,7 @@ | |||
| 287 | 350 | outcome, | |
| 288 | 351 | notice: None, | |
| 289 | 352 | address: None, | |
| 353 | + | invalidates: Vec::new(), | |
| 290 | 354 | } | |
| 291 | 355 | } | |
| 292 | 356 | } |
| @@ -18,7 +18,7 @@ | |||
| 18 | 18 | ||
| 19 | 19 | use quasi_router::{Node, RegionKind, Response, RouteError, Router, Screen, Slot}; | |
| 20 | 20 | ||
| 21 | - | use super::{Context, Protocol, Serves, Served, serve}; | |
| 21 | + | use super::{Context, Protocol, Served, Serves, serve}; | |
| 22 | 22 | ||
| 23 | 23 | /// An app with nothing in it. The router is what is under test. | |
| 24 | 24 | struct App; |
| @@ -81,7 +81,9 @@ | |||
| 81 | 81 | ||
| 82 | 82 | let tui = pass.tui; | |
| 83 | 83 | match node { | |
| 84 | - | Node::Heading { level, text: title } => text::draw(title, tui.style().heading(*level), area, buf), | |
| 84 | + | Node::Heading { level, text: title } => { | |
| 85 | + | text::draw(title, tui.style().heading(*level), area, buf) | |
| 86 | + | } | |
| 85 | 87 | ||
| 86 | 88 | Node::Text { | |
| 87 | 89 | text: content, | |
| @@ -112,7 +114,12 @@ | |||
| 112 | 114 | // address is the runtime's to follow when the link has focus. | |
| 113 | 115 | Node::Link { text: label, .. } => { | |
| 114 | 116 | let focused = pass.claim(); | |
| 115 | - | text::draw(label, tui.style().focused(focused, link_style(tui)), area, buf) | |
| 117 | + | text::draw( | |
| 118 | + | label, | |
| 119 | + | tui.style().focused(focused, link_style(tui)), | |
| 120 | + | area, | |
| 121 | + | buf, | |
| 122 | + | ) | |
| 116 | 123 | } | |
| 117 | 124 | ||
| 118 | 125 | Node::Token(tag) => { | |
| @@ -216,7 +223,8 @@ | |||
| 216 | 223 | ); | |
| 217 | 224 | used + text::draw( | |
| 218 | 225 | &label, | |
| 219 | - | tui.style().focused(focused, Style::default().fg(tui.theme().action_primary)), | |
| 226 | + | tui.style() | |
| 227 | + | .focused(focused, Style::default().fg(tui.theme().action_primary)), | |
| 220 | 228 | below(area, used), | |
| 221 | 229 | buf, | |
| 222 | 230 | ) | |
| @@ -359,7 +367,8 @@ | |||
| 359 | 367 | area.y, | |
| 360 | 368 | tick, | |
| 361 | 369 | 3, | |
| 362 | - | tui.style().focused(focused, Style::default().fg(tui.theme().content_secondary)), | |
| 370 | + | tui.style() | |
| 371 | + | .focused(focused, Style::default().fg(tui.theme().content_secondary)), | |
| 363 | 372 | ); | |
| 364 | 373 | return; | |
| 365 | 374 | } | |
| @@ -369,7 +378,8 @@ | |||
| 369 | 378 | area.y, | |
| 370 | 379 | ">", | |
| 371 | 380 | 1, | |
| 372 | - | tui.style().focused(focused, Style::default().fg(tui.theme().action_primary)), | |
| 381 | + | tui.style() | |
| 382 | + | .focused(focused, Style::default().fg(tui.theme().action_primary)), | |
| 373 | 383 | ); | |
| 374 | 384 | } | |
| 375 | 385 | } | |
| @@ -607,9 +617,7 @@ | |||
| 607 | 617 | widget::Held::Text(held) | |
| 608 | 618 | }; | |
| 609 | 619 | ||
| 610 | - | field.with_layout(|described| { | |
| 611 | - | widget::field(tui.style(), &described, held, focused, area, buf) | |
| 612 | - | }) | |
| 620 | + | field.with_layout(|described| widget::field(tui.style(), &described, held, focused, area, buf)) | |
| 613 | 621 | } | |
| 614 | 622 | ||
| 615 | 623 | /// A tabs strip, a segmented control and a toggle, all as one row of labels. |
| @@ -328,10 +328,15 @@ | |||
| 328 | 328 | outcome, | |
| 329 | 329 | notice, | |
| 330 | 330 | address, | |
| 331 | + | invalidates, | |
| 331 | 332 | } = response; | |
| 332 | 333 | self.saying = notice.or(self.saying.take()); | |
| 333 | 334 | ||
| 334 | 335 | match outcome { | |
| 336 | + | // Invalidations are not applied to a whole screen, matching what an | |
| 337 | + | // HTTP host does with them and for the same reason: every region is | |
| 338 | + | // being replaced already, so naming one of them again says nothing | |
| 339 | + | // the new screen does not. | |
| 335 | 340 | Outcome::Screen(screen) => { | |
| 336 | 341 | self.remember(request, address.as_ref()); | |
| 337 | 342 | self.screen = screen; | |
| @@ -345,11 +350,34 @@ | |||
| 345 | 350 | // `Screen::replace` describes, and a terminal can say so | |
| 346 | 351 | // rather than swallowing it: the region it named is gone, and | |
| 347 | 352 | // drawing nothing would look like a control that does nothing. | |
| 353 | + | // | |
| 354 | + | // The slots the answer invalidated go in the same way. On a | |
| 355 | + | // terminal that is the whole of what invalidation means: the | |
| 356 | + | // next frame redraws everything, so putting the new contents | |
| 357 | + | // on the screen is putting them in front of the user. What a | |
| 358 | + | // webview needs an out-of-band swap for, this gets for free. | |
| 359 | + | let mut missing: Vec<String> = Vec::new(); | |
| 348 | 360 | if !self.screen.replace(®ion, node) { | |
| 361 | + | missing.push(region); | |
| 362 | + | } | |
| 363 | + | for stale in invalidates { | |
| 364 | + | if !self.screen.replace(&stale.region, stale.node) { | |
| 365 | + | missing.push(stale.region); | |
| 366 | + | } | |
| 367 | + | } | |
| 368 | + | if !missing.is_empty() { | |
| 369 | + | // One message naming all of them, rather than a banner per | |
| 370 | + | // region where only the last would survive. | |
| 371 | + | let named = missing | |
| 372 | + | .iter() | |
| 373 | + | .map(|region| format!("`{region}`")) | |
| 374 | + | .collect::<Vec<_>>() | |
| 375 | + | .join(", "); | |
| 376 | + | let subject = if missing.len() == 1 { "is" } else { "are" }; | |
| 349 | 377 | self.saying = Some(Message { | |
| 350 | 378 | kind: layout::Notice::Banner, | |
| 351 | 379 | tone: layout::Tone::Danger, | |
| 352 | - | text: format!("nothing on this screen is called `{region}`"), | |
| 380 | + | text: format!("nothing on this screen {subject} called {named}"), | |
| 353 | 381 | undo: None, | |
| 354 | 382 | }); | |
| 355 | 383 | } |