Skip to main content

max / quasi

15.8 KB · 389 lines History Blame Raw
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::screen::{Action, Node, Screen};
56
57 /// What a route answered with.
58 #[derive(Debug, Clone, PartialEq, Eq)]
59 pub struct Response {
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: Option<Message>,
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: 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,
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 #[derive(Debug, Clone, PartialEq, Eq)]
125 pub enum Address {
126 /// A new place. This URL enters history.
127 Enters(String),
128 /// A place, replacing the current entry rather than adding one.
129 Replaces(String),
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 #[derive(Debug, Clone, PartialEq, Eq)]
152 pub enum Outcome {
153 /// The whole screen. A navigation, or an action whose effect is not
154 /// contained by one region.
155 Screen(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(Action),
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(Screen),
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 #[derive(Debug, Clone, PartialEq, Eq)]
194 pub struct Message {
195 /// Transient and stacked, or persistent and in flow.
196 pub kind: layout::Notice,
197 /// What it is saying.
198 pub tone: layout::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: Option<Action>,
217 }
218
219 impl Response {
220 /// A whole screen.
221 #[must_use]
222 pub fn screen(screen: Screen) -> Self {
223 Self::from(Outcome::Screen(screen))
224 }
225
226 /// One region's new contents.
227 pub fn fragment(region: impl Into<String>, node: Node) -> Self {
228 Self::from(Outcome::Fragment {
229 region: region.into(),
230 node,
231 })
232 }
233
234 /// Somewhere else instead of content.
235 #[must_use]
236 pub fn goto(action: Action) -> Self {
237 Self::from(Outcome::Goto(action))
238 }
239
240 /// A screen over the one already there. Dismissing it reveals that one.
241 #[must_use]
242 pub fn over(screen: Screen) -> Self {
243 Self::from(Outcome::Over(screen))
244 }
245
246 /// Say something transient on the way. It dismisses itself.
247 #[must_use]
248 pub fn toast(self, tone: layout::Tone, text: impl Into<String>) -> Self {
249 self.saying(layout::Notice::Toast, tone, text)
250 }
251
252 /// Say something persistent on the way. It is dismissed by fixing the cause.
253 #[must_use]
254 pub fn banner(self, tone: layout::Tone, text: impl Into<String>) -> Self {
255 self.saying(layout::Notice::Banner, tone, text)
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 #[must_use]
263 pub fn saying(
264 mut self,
265 kind: layout::Notice,
266 tone: layout::Tone,
267 text: impl Into<String>,
268 ) -> Self {
269 self.notice = Some(Message {
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 #[must_use]
286 pub fn undoable(mut self, action: Action) -> Self {
287 if let Some(notice) = &mut self.notice {
288 notice.undo = Some(action);
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 #[must_use]
304 pub fn also(mut self, region: impl Into<String>, node: Node) -> Self {
305 self.invalidates.push(Invalidated {
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 #[must_use]
319 pub fn at(mut self, url: impl Into<String>) -> Self {
320 self.address = Some(Address::Enters(url.into()));
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 #[must_use]
329 pub fn replacing(mut self, url: impl Into<String>) -> Self {
330 self.address = Some(Address::Replaces(url.into()));
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 #[must_use]
339 pub fn in_place(mut self) -> Self {
340 self.address = Some(Address::Unchanged);
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 #[must_use]
349 pub fn target(&self) -> Option<&str> {
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 Outcome::Screen(_) | Outcome::Goto(_) | Outcome::Over(_) => None,
354 Outcome::Fragment { region, .. } => Some(region),
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 #[must_use]
363 pub fn destination(&self) -> Option<&Action> {
364 match &self.outcome {
365 Outcome::Goto(action) => Some(action),
366 // An overlay sends the user nowhere: dismissing it reveals the
367 // screen they never left.
368 Outcome::Screen(_) | Outcome::Fragment { .. } | Outcome::Over(_) => None,
369 }
370 }
371 }
372
373 impl From<Outcome> for Response {
374 fn from(outcome: Outcome) -> Self {
375 Self {
376 outcome,
377 notice: None,
378 address: None,
379 invalidates: Vec::new(),
380 }
381 }
382 }
383
384 impl From<Screen> for Response {
385 fn from(screen: Screen) -> Self {
386 Self::screen(screen)
387 }
388 }
389