//! What a route answers with, what it says should be replaced, and what it says //! to the user on the way. //! //! Decision 7 on the wiki note. A response names the region it replaces, because //! the router is the only party that knows what it just changed, so it is the //! party that should say. //! //! The webview maps a fragment onto `hx-target` and `hx-swap`, which is the //! thing htmx exists to do, and it is the reason a full-body swap per action is //! not the design: list screens are exactly where losing scroll and focus //! hurts. egui and the terminal ignore the target and redraw everything, which //! costs them nothing because they were redrawing anyway. //! //! The rejected alternative was one return type plus a renderer diffing markup //! against the DOM. That is a virtual DOM, and htmx was chosen to avoid one. //! //! # Why this is a struct and not one enum //! //! It was one enum until 2026-08-09, and two findings arrived together that it //! could not hold: a write had nowhere to say "go and look over there instead" //! (`80afd652`), and nowhere to say "saved" (`a92ecb1e`). Neither is content, so //! neither is a [`Screen`] or a [`Fragment`](Outcome::Fragment). //! //! Filed separately they both read as new enum members, and that shape is wrong //! because the two compose. Deleting the thing a screen is about goes somewhere //! else *and* says it is gone. A save that fails on something no field can carry //! stays where it is *and* says why. One member cannot be two members, so //! [`Outcome`] holds the three ways to answer with content and the notice sits //! beside it, optional, orthogonal to all three. //! //! [`invalidates`](Response::invalidates) is the third arrival and the one that //! settles the shape: a write that changes a row *and* the count above it //! composes with all three outcomes and with the notice, so it is a fourth //! field rather than a fourth member. Had this stayed an enum it would have //! needed a member per combination. //! //! # Why [`Goto`](Outcome::Goto) takes an [`Action`] and not a [`Destination`] //! //! A redirect has params: back to a list with a filter still applied, back to a //! project on the tab you were reading. A bare address drops them and the app //! rebuilds a query string by hand, which is what [`Action::params`] exists to //! prevent. //! //! [`Action::method`] is meaningless here in the same way it is meaningless for //! a [`Destination::External`], and is left alone for the reason given there: a //! method that is ignored is simpler than two shapes of action. //! //! [`Destination`]: crate::Destination //! [`Destination::External`]: crate::Destination::External //! [`Action::method`]: crate::Action::method //! [`Action::params`]: crate::Action::params use makeover_layout as layout; use crate::screen::{Action, Node, Screen}; /// What a route answered with. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Response { /// The content, or the address to go to instead of content. pub outcome: Outcome, /// What to tell the user, if anything. Independent of the outcome. pub notice: Option, /// Whether this answer is a place, when the derivation cannot tell. /// /// `None` on almost every response, and that is the design. A host derives /// the common cases from what it already has — a read of a route is a /// place, a write and a fragment are not — so a control never has to /// predict what its answer will be. See [`Address`]. pub address: Option
, /// The other slots this answer changed, beyond the one it replaced. /// /// Empty on almost every response. See [`Invalidated`], and [`also`] for /// the way to add one. /// /// [`also`]: Self::also pub invalidates: Vec, } /// A slot this answer changed without being aimed at it. /// /// The row you edited is the [`Outcome`]; the count in the header is one of /// these. Both are named by [`Slot::id`](crate::Slot::id), because a slot id is /// the address a description already uses for a region and there is no reason /// for a second naming scheme. /// /// # Why this carries a node and not just an id /// /// A renderer told only that something is stale has two ways to act on it, and /// both are worse. It can ask again, which is a second round trip for a fact /// the router had in hand. Or it can re-derive the region, which means the /// router's view logic runs twice per write and the two runs have to agree. /// Handing over the new contents makes an invalidation the same shape as a /// fragment, which is what it is: one region and what now goes in it. /// /// # What each renderer does with it /// /// A webview swaps it out of band, so the row and the header both move on one /// response. A terminal redraws that panel. An egui frame does nothing, /// because it was going to redraw everything anyway. That spread is the reason /// this says "invalidated" rather than naming a swap: a swap is a DOM idea and /// two of the three renderers have no answer for it. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Invalidated { /// The [`Slot::id`](crate::Slot::id) whose contents are now stale. pub region: String, /// What goes in it instead. pub node: Node, } /// Whether an answer is somewhere the user can come back to. /// /// Decision 7's argument, applied to history: the response says it, because the /// router is the only party that knows what it just did. The alternative was a /// flag on [`Action`], decided when the control is rendered, which asks the /// control to predict the answer — and the MNW server has 24 hand-written /// `hx-push-url` uses across 13 files showing how that drifts. /// /// This is the override and not the mechanism. The host derives history from /// the request it is answering, and this is for the two cases derivation cannot /// reach: a fragment that *is* a place (an addressable tab panel, of which the /// server has 32), and a screen that is not (a transient state that should not /// come back on the back button). #[derive(Debug, Clone, PartialEq, Eq)] pub enum Address { /// A new place. This URL enters history. Enters(String), /// A place, replacing the current entry rather than adding one. Replaces(String), /// Not a place. Nothing in the address bar moves. Unchanged, } /// The content half of an answer. /// /// [`Goto`](Self::Goto) is not content and sits here anyway, because the three /// are exclusive: a response replaces a screen, or replaces a region, or sends /// the user elsewhere, and never two of those. /// /// # Deliberately not `#[non_exhaustive]` /// /// [`RowPart`](layout::RowPart) took it, and this is the opposite case. A row /// part a renderer does not know can be skipped, and the row is still a row. An /// outcome a host does not know is a request that silently does nothing, and /// `#[non_exhaustive]` is what makes that compile: every adapter grows a /// wildcard arm with nothing sensible to put in it, and a new member reaches /// each of them as a fallback rather than as an error. /// /// So a member added here breaks every host on purpose, which is the point. /// Growing [`Response`] itself stays cheap, because it is a struct. #[derive(Debug, Clone, PartialEq, Eq)] pub enum Outcome { /// The whole screen. A navigation, or an action whose effect is not /// contained by one region. Screen(Screen), /// One region's new contents. Fragment { /// The [`Slot::id`](crate::Slot::id) being replaced. region: String, /// What goes in it. node: Node, }, /// Somewhere else. No content, because the destination will answer. /// /// A webview sends a 303 or an `HX-Location`, a terminal pushes a screen, /// egui sets its route. An [`External`](crate::Destination::External) /// destination hands off to the host and nothing comes back, which is what /// opening a file or a mail client is. Goto(Action), /// A screen drawn OVER what is under it, rather than replacing it. /// /// The command palette, the help overlay, an app-modal dialog. Dismissing /// it reveals what was already there, so it is not a navigation and does /// not touch history — which is the whole of what distinguishes it from /// [`Goto`](Self::Goto). /// /// It is a [`Screen`] like any other and needs no second description tree: /// what was missing was never the contents but the way to say "drawn over". /// The way in is usually a [`Chrome`](crate::Chrome) binding, since an /// affordance available from everywhere is what an overlay normally is. /// /// Not [`RegionKind::Modal`](crate::RegionKind::Modal), which is a modal a /// screen *contains* and goes when that screen goes. This one belongs to /// the app and outlives any one screen. Over(Screen), } /// Something to tell the user alongside whatever else the response does. /// /// The same three fields as [`Node::Notice`], because it is the same thing said /// from the other end: that one is a message a screen contains, this is a /// message an answer carries. A renderer that can draw one can draw the other. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Message { /// Transient and stacked, or persistent and in flow. pub kind: layout::Notice, /// What it is saying. pub tone: layout::Tone, /// The message. pub text: String, /// What taking it back calls, when it can be taken back. /// /// `524a63fe`, the half that is not on [`Act`](crate::Act). Confirming is a /// question asked *before*, and it is a property of the control, so it lives /// there. Undoing is offered *after*, alongside the sentence saying what /// happened, and it needs a second route — which is what made it this /// crate's rather than the vocabulary's, the same split the file-dialog /// finding took. /// /// goingson raises 16 of these and Balanced Breakfast 3, each through a /// helper that builds the toast, the button and a countdown by hand. /// /// No timeout here. How long an undo stays offered is renderer policy, the /// same class of decision as whether a pending region draws a skeleton or a /// spinner, and a description that carried seconds would be naming a value. pub undo: Option, } impl Response { /// A whole screen. #[must_use] pub fn screen(screen: Screen) -> Self { Self::from(Outcome::Screen(screen)) } /// One region's new contents. pub fn fragment(region: impl Into, node: Node) -> Self { Self::from(Outcome::Fragment { region: region.into(), node, }) } /// Somewhere else instead of content. #[must_use] pub fn goto(action: Action) -> Self { Self::from(Outcome::Goto(action)) } /// A screen over the one already there. Dismissing it reveals that one. #[must_use] pub fn over(screen: Screen) -> Self { Self::from(Outcome::Over(screen)) } /// Say something transient on the way. It dismisses itself. #[must_use] pub fn toast(self, tone: layout::Tone, text: impl Into) -> Self { self.saying(layout::Notice::Toast, tone, text) } /// Say something persistent on the way. It is dismissed by fixing the cause. #[must_use] pub fn banner(self, tone: layout::Tone, text: impl Into) -> Self { self.saying(layout::Notice::Banner, tone, text) } /// Say something, spelling out which kind it is. /// /// [`toast`](Self::toast) and [`banner`](Self::banner) are this with the /// kind chosen, and are what call sites should reach for. #[must_use] pub fn saying( mut self, kind: layout::Notice, tone: layout::Tone, text: impl Into, ) -> Self { self.notice = Some(Message { kind, tone, text: text.into(), undo: None, }); self } /// Offer to take back whatever the notice just said happened. /// /// Applies to the notice already on the response, so it follows a /// [`toast`](Self::toast) or a [`banner`](Self::banner) rather than /// replacing one. A response with nothing to say has nothing to undo: the /// sentence is what the offer hangs off, and an undo button with no /// explanation is a control the user cannot judge. #[must_use] pub fn undoable(mut self, action: Action) -> Self { if let Some(notice) = &mut self.notice { notice.undo = Some(action); } self } /// This answer also changed that slot, and here is its new content. /// /// Chains, so a write that moves three places says so three times. The /// order is kept, because a renderer applying them in a different order /// than the router named them would be inventing a fact. /// /// Naming the slot the [`Outcome`] already replaces is not rejected here /// and not special-cased: a renderer applies what it is given, and a /// response that says the same region twice is a bug in the handler that a /// silent drop would hide. #[must_use] pub fn also(mut self, region: impl Into, node: Node) -> Self { self.invalidates.push(Invalidated { region: region.into(), node, }); self } /// This answer is a place, at this address. /// /// For the answer a derivation cannot reach: a fragment that is a place. /// A tab panel answers `Response::fragment("tab-content", node) /// .at("/dashboard#tab-projects")`, which reproduces by construction what /// the server does by hand today. #[must_use] pub fn at(mut self, url: impl Into) -> Self { self.address = Some(Address::Enters(url.into())); self } /// This answer is a place, and takes the current entry's slot. /// /// For a state the back button should skip: a filter applied over a list, /// a step within a flow. The address moves and history does not grow. #[must_use] pub fn replacing(mut self, url: impl Into) -> Self { self.address = Some(Address::Replaces(url.into())); self } /// This answer is not a place, whatever the derivation would have said. /// /// The other half of the override: a read of a route is a place by default, /// and this is how a transient one says it is not. #[must_use] pub fn in_place(mut self) -> Self { self.address = Some(Address::Unchanged); self } /// The region being replaced, or `None` for a whole screen or a redirect. /// /// A webview reads this to set `hx-retarget`. Renderers that repaint /// wholesale never call it. #[must_use] pub fn target(&self) -> Option<&str> { match &self.outcome { // An overlay targets no region: it is drawn over the whole of what // is under it, and the host puts it in its own container. Outcome::Screen(_) | Outcome::Goto(_) | Outcome::Over(_) => None, Outcome::Fragment { region, .. } => Some(region), } } /// Where this is sending the user, if it is sending them anywhere. /// /// The question a host asks before it looks for a body, because a redirect /// has none. #[must_use] pub fn destination(&self) -> Option<&Action> { match &self.outcome { Outcome::Goto(action) => Some(action), // An overlay sends the user nowhere: dismissing it reveals the // screen they never left. Outcome::Screen(_) | Outcome::Fragment { .. } | Outcome::Over(_) => None, } } } impl From for Response { fn from(outcome: Outcome) -> Self { Self { outcome, notice: None, address: None, invalidates: Vec::new(), } } } impl From for Response { fn from(screen: Screen) -> Self { Self::screen(screen) } }