max / quasi
| 1 | //! What a route answers with, what it says should be replaced, and what it says |
| 2 | //! to the user on the way. |
| 3 | //! |
| 4 | //! Decision 7 on the wiki note. A response names the region it replaces, because |
| 5 | //! the router is the only party that knows what it just changed, so it is the |
| 6 | //! party that should say. |
| 7 | //! |
| 8 | //! The webview maps a fragment onto `hx-target` and `hx-swap`, which is the |
| 9 | //! thing htmx exists to do, and it is the reason a full-body swap per action is |
| 10 | //! not the design: list screens are exactly where losing scroll and focus |
| 11 | //! hurts. egui and the terminal ignore the target and redraw everything, which |
| 12 | //! costs them nothing because they were redrawing anyway. |
| 13 | //! |
| 14 | //! The rejected alternative was one return type plus a renderer diffing markup |
| 15 | //! against the DOM. That is a virtual DOM, and htmx was chosen to avoid one. |
| 16 | //! |
| 17 | //! # Why this is a struct and not one enum |
| 18 | //! |
| 19 | //! It was one enum until 2026-08-09, and two findings arrived together that it |
| 20 | //! could not hold: a write had nowhere to say "go and look over there instead" |
| 21 | //! (`80afd652`), and nowhere to say "saved" (`a92ecb1e`). Neither is content, so |
| 22 | //! neither is a [`Screen`] or a [`Fragment`](Outcome::Fragment). |
| 23 | //! |
| 24 | //! Filed separately they both read as new enum members, and that shape is wrong |
| 25 | //! because the two compose. Deleting the thing a screen is about goes somewhere |
| 26 | //! else *and* says it is gone. A save that fails on something no field can carry |
| 27 | //! stays where it is *and* says why. One member cannot be two members, so |
| 28 | //! [`Outcome`] holds the three ways to answer with content and the notice sits |
| 29 | //! beside it, optional, orthogonal to all three. |
| 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 | //! |
| 37 | //! # Why [`Goto`](Outcome::Goto) takes an [`Action`] and not a [`Destination`] |
| 38 | //! |
| 39 | //! A redirect has params: back to a list with a filter still applied, back to a |
| 40 | //! project on the tab you were reading. A bare address drops them and the app |
| 41 | //! rebuilds a query string by hand, which is what [`Action::params`] exists to |
| 42 | //! prevent. |
| 43 | //! |
| 44 | //! [`Action::method`] is meaningless here in the same way it is meaningless for |
| 45 | //! a [`Destination::External`], and is left alone for the reason given there: a |
| 46 | //! method that is ignored is simpler than two shapes of action. |
| 47 | //! |
| 48 | //! [`Destination`]: crate::Destination |
| 49 | //! [`Destination::External`]: crate::Destination::External |
| 50 | //! [`Action::method`]: crate::Action::method |
| 51 | //! [`Action::params`]: crate::Action::params |
| 52 | |
| 53 | use makeover_layout as layout; |
| 54 | |
| 55 | use crate; |
| 56 | |
| 57 | /// What a route answered with. |
| 58 | |
| 59 | |
| 60 | /// The content, or the address to go to instead of content. |
| 61 | pub outcome: Outcome, |
| 62 | /// What to tell the user, if anything. Independent of the outcome. |
| 63 | pub notice: , |
| 64 | /// Whether this answer is a place, when the derivation cannot tell. |
| 65 | /// |
| 66 | /// `None` on almost every response, and that is the design. A host derives |
| 67 | /// the common cases from what it already has — a read of a route is a |
| 68 | /// place, a write and a fragment are not — so a control never has to |
| 69 | /// predict what its answer will be. See [`Address`]. |
| 70 | pub 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: , |
| 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 | |
| 104 | |
| 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, |
| 109 | |
| 110 | |
| 111 | /// Whether an answer is somewhere the user can come back to. |
| 112 | /// |
| 113 | /// Decision 7's argument, applied to history: the response says it, because the |
| 114 | /// router is the only party that knows what it just did. The alternative was a |
| 115 | /// flag on [`Action`], decided when the control is rendered, which asks the |
| 116 | /// control to predict the answer — and the MNW server has 24 hand-written |
| 117 | /// `hx-push-url` uses across 13 files showing how that drifts. |
| 118 | /// |
| 119 | /// This is the override and not the mechanism. The host derives history from |
| 120 | /// the request it is answering, and this is for the two cases derivation cannot |
| 121 | /// reach: a fragment that *is* a place (an addressable tab panel, of which the |
| 122 | /// server has 32), and a screen that is not (a transient state that should not |
| 123 | /// come back on the back button). |
| 124 | |
| 125 | |
| 126 | /// A new place. This URL enters history. |
| 127 | Enters, |
| 128 | /// A place, replacing the current entry rather than adding one. |
| 129 | Replaces, |
| 130 | /// Not a place. Nothing in the address bar moves. |
| 131 | Unchanged, |
| 132 | |
| 133 | |
| 134 | /// The content half of an answer. |
| 135 | /// |
| 136 | /// [`Goto`](Self::Goto) is not content and sits here anyway, because the three |
| 137 | /// are exclusive: a response replaces a screen, or replaces a region, or sends |
| 138 | /// the user elsewhere, and never two of those. |
| 139 | /// |
| 140 | /// # Deliberately not `#[non_exhaustive]` |
| 141 | /// |
| 142 | /// [`RowPart`](layout::RowPart) took it, and this is the opposite case. A row |
| 143 | /// part a renderer does not know can be skipped, and the row is still a row. An |
| 144 | /// outcome a host does not know is a request that silently does nothing, and |
| 145 | /// `#[non_exhaustive]` is what makes that compile: every adapter grows a |
| 146 | /// wildcard arm with nothing sensible to put in it, and a new member reaches |
| 147 | /// each of them as a fallback rather than as an error. |
| 148 | /// |
| 149 | /// So a member added here breaks every host on purpose, which is the point. |
| 150 | /// Growing [`Response`] itself stays cheap, because it is a struct. |
| 151 | |
| 152 | |
| 153 | /// The whole screen. A navigation, or an action whose effect is not |
| 154 | /// contained by one region. |
| 155 | Screen, |
| 156 | /// One region's new contents. |
| 157 | Fragment |
| 158 | /// The [`Slot::id`](crate::Slot::id) being replaced. |
| 159 | region: String, |
| 160 | /// What goes in it. |
| 161 | node: Node, |
| 162 | , |
| 163 | /// Somewhere else. No content, because the destination will answer. |
| 164 | /// |
| 165 | /// A webview sends a 303 or an `HX-Location`, a terminal pushes a screen, |
| 166 | /// egui sets its route. An [`External`](crate::Destination::External) |
| 167 | /// destination hands off to the host and nothing comes back, which is what |
| 168 | /// opening a file or a mail client is. |
| 169 | Goto, |
| 170 | /// A screen drawn OVER what is under it, rather than replacing it. |
| 171 | /// |
| 172 | /// The command palette, the help overlay, an app-modal dialog. Dismissing |
| 173 | /// it reveals what was already there, so it is not a navigation and does |
| 174 | /// not touch history — which is the whole of what distinguishes it from |
| 175 | /// [`Goto`](Self::Goto). |
| 176 | /// |
| 177 | /// It is a [`Screen`] like any other and needs no second description tree: |
| 178 | /// what was missing was never the contents but the way to say "drawn over". |
| 179 | /// The way in is usually a [`Chrome`](crate::Chrome) binding, since an |
| 180 | /// affordance available from everywhere is what an overlay normally is. |
| 181 | /// |
| 182 | /// Not [`RegionKind::Modal`](crate::RegionKind::Modal), which is a modal a |
| 183 | /// screen *contains* and goes when that screen goes. This one belongs to |
| 184 | /// the app and outlives any one screen. |
| 185 | Over, |
| 186 | |
| 187 | |
| 188 | /// Something to tell the user alongside whatever else the response does. |
| 189 | /// |
| 190 | /// The same three fields as [`Node::Notice`], because it is the same thing said |
| 191 | /// from the other end: that one is a message a screen contains, this is a |
| 192 | /// message an answer carries. A renderer that can draw one can draw the other. |
| 193 | |
| 194 | |
| 195 | /// Transient and stacked, or persistent and in flow. |
| 196 | pub kind: Notice, |
| 197 | /// What it is saying. |
| 198 | pub tone: Tone, |
| 199 | /// The message. |
| 200 | pub text: String, |
| 201 | /// What taking it back calls, when it can be taken back. |
| 202 | /// |
| 203 | /// `524a63fe`, the half that is not on [`Act`](crate::Act). Confirming is a |
| 204 | /// question asked *before*, and it is a property of the control, so it lives |
| 205 | /// there. Undoing is offered *after*, alongside the sentence saying what |
| 206 | /// happened, and it needs a second route — which is what made it this |
| 207 | /// crate's rather than the vocabulary's, the same split the file-dialog |
| 208 | /// finding took. |
| 209 | /// |
| 210 | /// goingson raises 16 of these and Balanced Breakfast 3, each through a |
| 211 | /// helper that builds the toast, the button and a countdown by hand. |
| 212 | /// |
| 213 | /// No timeout here. How long an undo stays offered is renderer policy, the |
| 214 | /// same class of decision as whether a pending region draws a skeleton or a |
| 215 | /// spinner, and a description that carried seconds would be naming a value. |
| 216 | pub undo: , |
| 217 | |
| 218 | |
| 219 | |
| 220 | /// A whole screen. |
| 221 | |
| 222 | |
| 223 | Selffrom |
| 224 | |
| 225 | |
| 226 | /// One region's new contents. |
| 227 | |
| 228 | Selffrom |
| 229 | region: region.into, |
| 230 | node, |
| 231 | |
| 232 | |
| 233 | |
| 234 | /// Somewhere else instead of content. |
| 235 | |
| 236 | |
| 237 | Selffrom |
| 238 | |
| 239 | |
| 240 | /// A screen over the one already there. Dismissing it reveals that one. |
| 241 | |
| 242 | |
| 243 | Selffrom |
| 244 | |
| 245 | |
| 246 | /// Say something transient on the way. It dismisses itself. |
| 247 | |
| 248 | |
| 249 | self.saying |
| 250 | |
| 251 | |
| 252 | /// Say something persistent on the way. It is dismissed by fixing the cause. |
| 253 | |
| 254 | |
| 255 | self.saying |
| 256 | |
| 257 | |
| 258 | /// Say something, spelling out which kind it is. |
| 259 | /// |
| 260 | /// [`toast`](Self::toast) and [`banner`](Self::banner) are this with the |
| 261 | /// kind chosen, and are what call sites should reach for. |
| 262 | |
| 263 | |
| 264 | mut self, |
| 265 | kind: Notice, |
| 266 | tone: Tone, |
| 267 | text: impl , |
| 268 | |
| 269 | self.notice = Some |
| 270 | kind, |
| 271 | tone, |
| 272 | text: text.into, |
| 273 | undo: None, |
| 274 | ; |
| 275 | self |
| 276 | |
| 277 | |
| 278 | /// Offer to take back whatever the notice just said happened. |
| 279 | /// |
| 280 | /// Applies to the notice already on the response, so it follows a |
| 281 | /// [`toast`](Self::toast) or a [`banner`](Self::banner) rather than |
| 282 | /// replacing one. A response with nothing to say has nothing to undo: the |
| 283 | /// sentence is what the offer hangs off, and an undo button with no |
| 284 | /// explanation is a control the user cannot judge. |
| 285 | |
| 286 | |
| 287 | if let Some = &mut self.notice |
| 288 | notice.undo = Some; |
| 289 | |
| 290 | self |
| 291 | |
| 292 | |
| 293 | /// This answer also changed that slot, and here is its new content. |
| 294 | /// |
| 295 | /// Chains, so a write that moves three places says so three times. The |
| 296 | /// order is kept, because a renderer applying them in a different order |
| 297 | /// than the router named them would be inventing a fact. |
| 298 | /// |
| 299 | /// Naming the slot the [`Outcome`] already replaces is not rejected here |
| 300 | /// and not special-cased: a renderer applies what it is given, and a |
| 301 | /// response that says the same region twice is a bug in the handler that a |
| 302 | /// silent drop would hide. |
| 303 | |
| 304 | |
| 305 | self.invalidates.push |
| 306 | region: region.into, |
| 307 | node, |
| 308 | ; |
| 309 | self |
| 310 | |
| 311 | |
| 312 | /// This answer is a place, at this address. |
| 313 | /// |
| 314 | /// For the answer a derivation cannot reach: a fragment that is a place. |
| 315 | /// A tab panel answers `Response::fragment("tab-content", node) |
| 316 | /// .at("/dashboard#tab-projects")`, which reproduces by construction what |
| 317 | /// the server does by hand today. |
| 318 | |
| 319 | |
| 320 | self.address = Some; |
| 321 | self |
| 322 | |
| 323 | |
| 324 | /// This answer is a place, and takes the current entry's slot. |
| 325 | /// |
| 326 | /// For a state the back button should skip: a filter applied over a list, |
| 327 | /// a step within a flow. The address moves and history does not grow. |
| 328 | |
| 329 | |
| 330 | self.address = Some; |
| 331 | self |
| 332 | |
| 333 | |
| 334 | /// This answer is not a place, whatever the derivation would have said. |
| 335 | /// |
| 336 | /// The other half of the override: a read of a route is a place by default, |
| 337 | /// and this is how a transient one says it is not. |
| 338 | |
| 339 | |
| 340 | self.address = Some; |
| 341 | self |
| 342 | |
| 343 | |
| 344 | /// The region being replaced, or `None` for a whole screen or a redirect. |
| 345 | /// |
| 346 | /// A webview reads this to set `hx-retarget`. Renderers that repaint |
| 347 | /// wholesale never call it. |
| 348 | |
| 349 | |
| 350 | match &self.outcome |
| 351 | // An overlay targets no region: it is drawn over the whole of what |
| 352 | // is under it, and the host puts it in its own container. |
| 353 | Screen | Goto | Over => None, |
| 354 | Fragment => Some, |
| 355 | |
| 356 | |
| 357 | |
| 358 | /// Where this is sending the user, if it is sending them anywhere. |
| 359 | /// |
| 360 | /// The question a host asks before it looks for a body, because a redirect |
| 361 | /// has none. |
| 362 | |
| 363 | |
| 364 | match &self.outcome |
| 365 | Goto => Some, |
| 366 | // An overlay sends the user nowhere: dismissing it reveals the |
| 367 | // screen they never left. |
| 368 | Screen | Fragment | Over => None, |
| 369 | |
| 370 | |
| 371 | |
| 372 | |
| 373 | |
| 374 | |
| 375 | Self |
| 376 | outcome, |
| 377 | notice: None, |
| 378 | address: None, |
| 379 | invalidates: Vecnew, |
| 380 | |
| 381 | |
| 382 | |
| 383 | |
| 384 | |
| 385 | |
| 386 | Selfscreen |
| 387 | |
| 388 | |
| 389 |