Skip to main content

max / quasi

67.3 KB · 1533 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 //! Neither is content, so neither is a [`Screen`] or a
20 //! [`Fragment`](Outcome::Fragment).
21 //!
22 //! Filed separately they both read as new enum members, and that shape is wrong
23 //! because the two compose. Deleting the thing a screen is about goes somewhere
24 //! else *and* says it is gone. A save that fails on something no field can carry
25 //! stays where it is *and* says why. One member cannot be two members, so
26 //! [`Outcome`] holds the three ways to answer with content and the notice sits
27 //! beside it, optional, orthogonal to all three.
28 //!
29 //! [`invalidates`](Response::invalidates) is the third arrival and the one that
30 //! settles the shape: a write that changes a row *and* the count above it
31 //! composes with all three outcomes and with the notice, so it is a fourth
32 //! field rather than a fourth member. Had this stayed an enum it would have
33 //! needed a member per combination.
34 //!
35 //! # Why [`Goto`](Outcome::Goto) takes an [`Action`] and not a [`Destination`]
36 //!
37 //! A redirect has params: back to a list with a filter still applied, back to a
38 //! project on the tab you were reading. A bare address drops them and the app
39 //! rebuilds a query string by hand, which is what [`Action::params`] exists to
40 //! prevent.
41 //!
42 //! [`Action::method`] is meaningless here in the same way it is meaningless for
43 //! a [`Destination::External`], and is left alone for the reason given there: a
44 //! method that is ignored is simpler than two shapes of action.
45 //!
46 //! # A described route answers one [`Outcome`], and assumes a client that runs JS
47 //!
48 //! A route answers once. It does not answer one way for a client that arrived
49 //! with htmx and another way for a client that brought no JS at all. This is a
50 //! property of the vocabulary rather than a limitation of any host: a
51 //! description says what changed, and something has to be running on the other
52 //! end to apply that to part of a page. Progressive enhancement stays an
53 //! Askama concern, in the templates a conversion has not reached.
54 //!
55 //! The position is a promise about who a described surface serves, so the
56 //! evidence behind it travels with it. Measured across the MNW server: 72
57 //! sites branch on whether the request came from htmx, over 23 files, 56 of
58 //! them under `src/routes/api/`. They are three shapes, and only the first is
59 //! progressive enhancement.
60 //!
61 //! 1. **Re-render the whole page with the user's input preserved.** Three route
62 //! files: `src/routes/auth.rs` (login), `pages/email_actions/password.rs`
63 //! (password reset, with the emailed token intact), and
64 //! `pages/public/join_wizard.rs` (username and email preserved). All three
65 //! are public or auth routes. None is under `routes/pages/dashboard/`.
66 //! 2. **Degrade to the error page.** htmx gets a toast or an inline status, a
67 //! plain request gets the error. The dominant shape by far, and the one every
68 //! dashboard site uses, all of them through a single `wizard_validation_toast`
69 //! helper. There is no full-page re-render anywhere in the dashboard.
70 //! 3. **Redirect or hand back a file.** `HX-Redirect` against a plain redirect,
71 //! or a CSV or JSON download. Not a question about assembling a page.
72 //!
73 //! Every route doing real progressive enhancement is public, and the public tier
74 //! stays in Askama, so the position costs nothing where conversion is planned.
75 //!
76 //! The consequence, stated so a later reader does not have to find it: describing
77 //! a **public** page reopens this. A described public page is unreachable without
78 //! JS. That is a product decision to make deliberately on the day it comes up,
79 //! not a bug to file against the router.
80 //!
81 //! MNW's `src/fragment_redirect.rs` is a separate mechanism, sending a direct
82 //! navigation to a fragment endpoint back to its parent page. It is not one of
83 //! the 72 and does not bear on this.
84 //!
85 //! [`Destination`]: crate::Destination
86 //! [`Destination::External`]: crate::Destination::External
87 //! [`Action::method`]: crate::Action::method
88 //! [`Action::params`]: crate::Action::params
89
90 use makeover_layout as layout;
91
92 use crate::request::Request;
93 use crate::screen::{Accepted, Action, Candidate, Node, Screen};
94
95 /// What a route answered with.
96 #[derive(Debug, Clone, PartialEq, Eq)]
97 pub struct Response {
98 /// The content, or the address to go to instead of content.
99 pub outcome: Outcome,
100 /// What to tell the user, if anything. Independent of the outcome.
101 pub notice: Option<Message>,
102 /// Whether this answer is a place, when the derivation cannot tell.
103 ///
104 /// `None` on almost every response, and that is the design. A host derives
105 /// the common cases from what it already has — a read of a route is a
106 /// place, a write and a fragment are not — so a control never has to
107 /// predict what its answer will be. See [`Address`].
108 pub address: Option<Address>,
109 /// The other slots this answer changed, beyond the one it replaced.
110 ///
111 /// Empty on almost every response. See [`Invalidated`], and [`also`] for
112 /// the way to add one.
113 ///
114 /// [`also`]: Self::also
115 pub invalidates: Vec<Invalidated>,
116 }
117
118 /// A slot this answer changed without being aimed at it.
119 ///
120 /// The row you edited is the [`Outcome`]; the count in the header is one of
121 /// these. Both are named by [`Slot::id`](crate::Slot::id), because a slot id is
122 /// the address a description already uses for a region and there is no reason
123 /// for a second naming scheme.
124 ///
125 /// # Why this carries a node and not just an id
126 ///
127 /// A renderer told only that something is stale has two ways to act on it, and
128 /// both are worse. It can ask again, which is a second round trip for a fact
129 /// the router had in hand. Or it can re-derive the region, which means the
130 /// router's view logic runs twice per write and the two runs have to agree.
131 /// Handing over the new contents makes an invalidation the same shape as a
132 /// fragment, which is what it is: one region and what now goes in it.
133 ///
134 /// # What each renderer does with it
135 ///
136 /// A webview swaps it out of band, so the row and the header both move on one
137 /// response. A terminal redraws that panel. An egui frame does nothing,
138 /// because it was going to redraw everything anyway. That spread is the reason
139 /// this says "invalidated" rather than naming a swap: a swap is a DOM idea and
140 /// two of the three renderers have no answer for it.
141 #[derive(Debug, Clone, PartialEq, Eq)]
142 pub struct Invalidated {
143 /// The [`Slot::id`](crate::Slot::id) whose contents are now stale.
144 pub region: String,
145 /// What goes in it instead.
146 pub node: Node,
147 }
148
149 /// Whether an answer is somewhere the user can come back to.
150 ///
151 /// Decision 7's argument, applied to history: the response says it, because the
152 /// router is the only party that knows what it just did. The alternative was a
153 /// flag on [`Action`], decided when the control is rendered, which asks the
154 /// control to predict the answer — and the MNW server has 24 hand-written
155 /// `hx-push-url` uses across 13 files showing how that drifts.
156 ///
157 /// This is the override and not the mechanism. The host derives history from
158 /// the request it is answering, and this is for the two cases derivation cannot
159 /// reach: a fragment that *is* a place (an addressable tab panel, of which the
160 /// server has 32), and a screen that is not (a transient state that should not
161 /// come back on the back button).
162 #[derive(Debug, Clone, PartialEq, Eq)]
163 pub enum Address {
164 /// A new place. This URL enters history.
165 Enters(String),
166 /// A place, replacing the current entry rather than adding one.
167 Replaces(String),
168 /// Not a place. Nothing in the address bar moves.
169 Unchanged,
170 }
171
172 /// The content half of an answer.
173 ///
174 /// [`Goto`](Self::Goto) is not content and sits here anyway, because the three
175 /// are exclusive: a response replaces a screen, or replaces a region, or sends
176 /// the user elsewhere, and never two of those.
177 ///
178 /// # Deliberately not `#[non_exhaustive]`
179 ///
180 /// [`RowPart`](layout::RowPart) took it, and this is the opposite case. A row
181 /// part a renderer does not know can be skipped, and the row is still a row. An
182 /// outcome a host does not know is a request that silently does nothing, and
183 /// `#[non_exhaustive]` is what makes that compile: every adapter grows a
184 /// wildcard arm with nothing sensible to put in it, and a new member reaches
185 /// each of them as a fallback rather than as an error.
186 ///
187 /// So a member added here breaks every host on purpose, which is the point.
188 /// Growing [`Response`] itself stays cheap, because it is a struct.
189 ///
190 /// # Its variants are not the same size, and that is accepted
191 ///
192 /// [`Fragment`](Self::Fragment) carries a whole [`Node`] and
193 /// [`Screen`](Self::Screen) carries a [`Screen`], so the enum is as large as
194 /// the bigger of the two and every outcome pays for it. Crossing clippy's
195 /// threshold was makeover-layout 0.32.0 adding a curve to a field, which is to
196 /// say the margin was already thin.
197 ///
198 /// Boxing the node would fix the ratio and is not obviously an improvement: it
199 /// moves an allocation into every fragment response to save stack on a value
200 /// that is built once per request and consumed immediately, and it is a
201 /// breaking change to every `match` on this enum in every host adapter. Revisit
202 /// if an outcome is ever held in a collection, which is where the size would
203 /// start to be paid more than once.
204 ///
205 /// There was an `#[expect(clippy::large_enum_variant)]` here, and it came off
206 /// when the spread closed rather than because the argument changed. Measured on
207 /// x86-64: the enum is 544 bytes, [`Fragment`](Self::Fragment) is all of it
208 /// through [`Node`], and [`Screen`](Self::Screen) is 360. That is 184 bytes of
209 /// spread, inside the 200 clippy wants, so the lint does not fire and an
210 /// expectation for it is itself a warning. Put it back when the spread opens
211 /// again; the paragraphs above are why it would be an expectation rather than a
212 /// box.
213 ///
214 /// Boxing is still refused, and boxing *the picture* rather than the node is
215 /// refused for a second reason: a control that shows a picture holds one, and
216 /// an `Option<Box<Image>>` puts an allocation in the vocabulary's public
217 /// shape to save stack on a value built once per request and consumed
218 /// immediately.
219 #[derive(Debug, Clone, PartialEq, Eq)]
220 pub enum Outcome {
221 /// The whole screen. A navigation, or an action whose effect is not
222 /// contained by one region.
223 Screen(Screen),
224 /// One region's new contents.
225 Fragment {
226 /// The [`Slot::id`](crate::Slot::id) being replaced.
227 region: String,
228 /// What goes in it.
229 node: Node,
230 },
231 /// Somewhere else. No content, because the destination will answer.
232 ///
233 /// A webview sends a 303 or an `HX-Location`, a terminal pushes a screen,
234 /// egui sets its route. An [`External`](crate::Destination::External)
235 /// destination hands off to the host and nothing comes back, which is what
236 /// opening a file or a mail client is.
237 Goto(Action),
238 /// A screen drawn OVER what is under it, rather than replacing it.
239 ///
240 /// The command palette, the help overlay, an app-modal dialog. Dismissing
241 /// it reveals what was already there, so it is not a navigation and does
242 /// not touch history — which is the whole of what distinguishes it from
243 /// [`Goto`](Self::Goto).
244 ///
245 /// It is a [`Screen`] like any other and needs no second description tree:
246 /// what was missing was never the contents but the way to say "drawn over".
247 /// The way in is usually a [`Chrome`](crate::Chrome) binding, since an
248 /// affordance available from everywhere is what an overlay normally is.
249 /// Usually and not always: a control on one screen can call a route that
250 /// answers this, which is how goingson opens a focus countdown from a row.
251 ///
252 /// **An app answering this declares chrome, even when it declares no
253 /// binding.** A renderer draws the overlay into a container it emits once
254 /// per document, and it emits that container for an app that declares
255 /// chrome. An app that declares none has nowhere to put the answer, and
256 /// what that looks like is a swap that does nothing rather than an error.
257 ///
258 /// Not [`RegionKind::Modal`](crate::RegionKind::Modal), which is a modal a
259 /// screen *contains* and goes when that screen goes. This one belongs to
260 /// the app and outlives any one screen.
261 Over(Screen),
262 /// A screen drawn at a described point on the one under it.
263 ///
264 /// A context menu, a popover, the verbs over a selection: screens that
265 /// belong to the thing they opened at rather than to the app.
266 /// [`Over`](Self::Over) is the app-modal one — it outlives any one screen
267 /// and is drawn over the whole of what is under it — and describing a
268 /// popover as one is what the four measured audiofiles sites were doing.
269 ///
270 /// It is a [`Screen`] like [`Over`](Self::Over) is, for the same reason:
271 /// what was missing is never the contents. Dismissal matches
272 /// [`Over`](Self::Over) too — it reveals what was under it, touches no
273 /// history, and is not a place.
274 ///
275 /// # A row already had this and needed no outcome
276 ///
277 /// [`Row::menu`](crate::Row::menu) is a menu anchored to a row, described
278 /// on the row and drawn beside it by every renderer. That covers a row and
279 /// covers nothing else, which is the hole this fills: audiofiles' selection
280 /// menu acts on the ticked set and its empty-space menu on the region, and
281 /// neither is a row to hang a member off.
282 Anchored {
283 /// What is drawn.
284 screen: Screen,
285 /// What it is drawn at.
286 anchor: Anchor,
287 },
288 /// One field's suggestion list, answering the question that field owns.
289 ///
290 /// [`Field::suggests`](crate::Field::suggests) is the other half: a field
291 /// that owns a list of candidates asks a route for them as the value is
292 /// typed, and this is what the route answers.
293 ///
294 /// # Why this one is described where a [`Consult`](crate::Consult)'s answer
295 /// # is not
296 ///
297 /// An ordinary consult answers a region a description already named, so
298 /// what comes back can be markup for a webview and values for a terminal
299 /// without the route knowing which asked. A suggestion list is not a
300 /// region: it belongs to a control, every renderer draws it in its own
301 /// idiom — a listbox under an input, a popup under a terminal field, a
302 /// dropdown in an egui frame — and a picked entry writes a value back into
303 /// the field. None of that is derivable from markup, so the answer is the
304 /// vocabulary's own currency and each renderer draws it.
305 ///
306 /// [`Candidate`] and not [`Choice`]. A candidate is submitted under one
307 /// string and read under another, which an option is too, and that is not
308 /// the half that differs. **An option and a candidate are submitted the same
309 /// way and read differently.** An option
310 /// is picked out of a set the user can see whole; a candidate is offered
311 /// out of a set nobody can see, so it has to carry what tells it from a row
312 /// that reads alike, and it may carry what picking it does. Both measured
313 /// sites draw that second string today, by hand, in a second span.
314 ///
315 /// A route with nothing to suggest answers an empty list, which every
316 /// renderer draws as no list at all rather than as an empty box.
317 Suggestions {
318 /// The [`Field::name`](crate::Field::name) whose list this is.
319 ///
320 /// The field's own name and not a second id, which is the whole of what
321 /// "the field owns the list" buys. A renderer needing a document id
322 /// derives one from this.
323 ///
324 /// A name no field on the screen carries is a description bug and is
325 /// treated as one everywhere else it can happen: the answer lands
326 /// nowhere and the screen still draws.
327 field: String,
328 /// The candidates, in the order they are offered.
329 options: Vec<Candidate>,
330 },
331 /// A file the viewer keeps, at a destination the host chooses.
332 ///
333 /// The route answers with the file; the host puts it somewhere. **The
334 /// description never names a path.** Tauri opens a save dialog, a browser
335 /// downloads, a terminal writes to the working directory. One description,
336 /// the same reading on every host.
337 ///
338 /// Not the way to ask the reader to NAME a file. This member has the file
339 /// already and is handing it over; a route that wants a destination first,
340 /// with a suggestion in the dialog, asks for
341 /// [`Sought::Save`](Sought::Save) through [`Locate`](Self::Locate). The
342 /// save dialog named above is how a host performs this one, not a way to
343 /// choose where it lands.
344 ///
345 /// # Why the destination is not in the request
346 ///
347 /// It is the exact mirror of the upload ruling: the destination is opaque.
348 /// A description says what may be uploaded
349 /// ([`Field::accept`](crate::Field::accept)), how many
350 /// ([`Field::multiple`](crate::Field::multiple)) and that it reports
351 /// progress, and never where it lands. A save destination is that same
352 /// fact in the other direction and gets the same answer. andcut the same
353 /// way.
354 ///
355 /// # The payload goes through memory
356 ///
357 /// Stated rather than hidden. That is fine for a task database and would
358 /// not be fine for a media library. A streaming variant is a later member
359 /// if a measured site ever needs one; do not pre-build it.
360 File {
361 /// The suggested file name, suffix included: `goingson-2026-08-21.json`.
362 ///
363 /// Suggested and not chosen. A save dialog offers it, a browser puts it
364 /// in `Content-Disposition`, a terminal writes it beside the process.
365 /// A host that already has a name from the user keeps theirs.
366 name: String,
367 /// What kind of file it is.
368 ///
369 /// [`Accepted`], the same type the upload half uses, rather than a
370 /// second way to name a file kind. A [`Type`](Accepted::Type) is the
371 /// one spelling a host can put on the wire as a media type; a
372 /// [`Family`](Accepted::Family) or a [`Suffix`](Accepted::Suffix) says
373 /// less, and a host that needs a media type falls back to
374 /// `application/octet-stream` rather than guessing one from a name.
375 kind: Accepted,
376 /// The file.
377 bytes: Vec<u8>,
378 },
379 /// A place for the app to write into, chosen by the host.
380 ///
381 /// The route says a place is wanted, the host performs the picker, and
382 /// what comes back is every opaque handle it chose, each with a label to
383 /// show. **The description never learns what the host did**, which is the
384 /// same bargain [`File`](Self::File) strikes from the other end: that one
385 /// says what the file is and never where it goes, this one asks for a
386 /// somewhere and never asks what it is.
387 ///
388 /// # Why an outcome and not a field kind
389 ///
390 /// A control-side place picker covers a form and covers nothing else. Half
391 /// the measured sites are the *act* — the four import doors and Locate
392 /// missing files, where picking the folder is the whole of what the reader
393 /// asked for and no form is on screen to hold it. One member reaches both:
394 /// an act answers this and the work runs, a form answers this and the
395 /// handle lands back in the form's own state.
396 ///
397 /// # The bytes are not here, and that is the point
398 ///
399 /// [`File`](Self::File) is one payload that exists when the route answers.
400 /// This is a destination chosen before there is anything to put in it: the
401 /// export picks a folder and then writes hundreds of files into it over a
402 /// long operation it reports progress on. A route is `fn(&S, Request)` and
403 /// sync, so it can neither open a dialog nor stream — which is why the ask
404 /// leaves as an answer and the picking happens outside.
405 Locate(Locating),
406 /// The work was handed off. It is running, and nothing is here yet.
407 ///
408 /// A [`Handler`](crate::Handler) is `fn(&S, Request) -> Result<Response,
409 /// RouteError>` and stays that way, so a write that takes seconds — an
410 /// export, a large import, anything that walks a database or the network —
411 /// cannot be performed inside one without freezing the host that called
412 /// it. The app offloads it, as goingson already did; this is the word for
413 /// the fact that it happened.
414 ///
415 /// # It is the other end of a channel that already exists
416 ///
417 /// Nothing new says the work finished. [`Slot::live`](crate::Slot::live),
418 /// [`Slot::fed_by`](crate::Slot::fed_by) and
419 /// [`Screen::refreshes`](crate::Screen::refreshes) already say "this
420 /// region's contents change without the user, re-ask this route on the
421 /// renderer's cadence", and every renderer implements it. So a region that
422 /// answers this is a region the description already declared live, and what
423 /// lands when the work is done is an ordinary
424 /// [`Fragment`](Self::Fragment) — which is what takes the region back out
425 /// of [`Pending`](layout::Readiness::Pending), by
426 /// [`Screen::replace`](crate::Screen::replace).
427 ///
428 /// A region that declared neither is a description bug of the quiet kind:
429 /// it will say it started and never say anything else. Nothing here can
430 /// catch that, because the region is on a screen this answer does not
431 /// carry.
432 ///
433 /// # Why not simply a fragment saying "working…"
434 ///
435 /// That is what the sites did before there was a word, and it loses the
436 /// axis. [`Readiness`](layout::Readiness) is what a renderer draws its wait
437 /// with — the terminal's "Loading", egui's proportion, the webview's
438 /// `aria-busy` — and a fragment arriving sets it to
439 /// [`Ready`](layout::Readiness::Ready) by definition. A screen cannot then
440 /// tell "this did nothing" from "this started something", which is the
441 /// distinction the vocabulary exists to make sayable.
442 ///
443 /// # What it does not mean
444 ///
445 /// Not progress. Nothing here counts anything, and a route that can count
446 /// says so the ordinary way: the region is fed by an
447 /// [`awaiting`](Action::awaiting) action, and each renderer draws the
448 /// proportion it already knows how to draw.
449 ///
450 /// Not a promise of completion either. If the work fails, what says so is
451 /// the next thing the region is told, the same as for work that succeeded.
452 Started {
453 /// The [`Slot::id`](crate::Slot::id) the work will fill.
454 ///
455 /// A region and not a screen, because the rest of the screen is still
456 /// true: the reader pressed one control and everything they were
457 /// looking at is still there. Named the way
458 /// [`Fragment`](Self::Fragment) names one, and a region that is not
459 /// there is treated the way a fragment's missing region is.
460 region: String,
461 /// What stands there while it runs. "Creating backup…"
462 ///
463 /// A sentence rather than a node, unlike [`Fragment`](Self::Fragment).
464 /// What is being described is a wait, and every renderer already has
465 /// its own way of drawing one; handing it a tree to draw instead would
466 /// be the description deciding how a host shows waiting, which is the
467 /// one thing this vocabulary does not do. A host that draws its wait
468 /// without words is free to ignore it.
469 message: String,
470 },
471 }
472
473 /// What an [`Outcome::Anchored`] is drawn at.
474 ///
475 /// **A described thing, never a point.** That is `600c9e42`'s ruling and it is
476 /// the whole shape of this type: a description says what is on the screen and
477 /// never where, so the anchor names something the description already carries
478 /// and each renderer resolves it with geometry it already owns.
479 /// `quasi_immediate::geometry` is that ruling shipped — it is where this host
480 /// keeps its rects, beside the drawing rather than in the vocabulary.
481 ///
482 /// Three members, one per measured subject. A fourth arrives when a site wants
483 /// one, on the rule every other member here arrived under.
484 #[derive(Debug, Clone, PartialEq, Eq)]
485 pub enum Anchor {
486 /// A region, by [`Slot::id`](crate::Slot::id).
487 ///
488 /// audiofiles' empty-space menu: a press on the part of the browser that is
489 /// not a row, offering what can be done to the region rather than to
490 /// anything in it.
491 Region(String),
492 /// The screen's selection, [`Screen::selection`](crate::Screen::selection).
493 ///
494 /// audiofiles' multi-select menu. The subject is the ticked set, so the
495 /// anchor names it the way [`Act::over`](crate::Act::over) does: by being
496 /// set at all. A screen holds one selection, so there is nothing to name.
497 ///
498 /// A renderer draws it where the set is — near the last ticked row, beside
499 /// the commit run, wherever that host puts it — which is the same latitude
500 /// every anchor gets.
501 Selection,
502 /// A control, by [`Act::id`](crate::Act::id).
503 ///
504 /// The two measured popovers, where pressing a button opens a small screen
505 /// belonging to that button.
506 ///
507 /// # Why the control and not what it calls
508 ///
509 /// Anchoring by the action's destination was the alternative and is
510 /// rejected: it makes the anchor an accident of routing, and two controls
511 /// calling one route would be indistinguishable. So [`Act`](crate::Act)
512 /// gained an id for this, defaulting to `None` — a control nothing anchors
513 /// to needs no name.
514 Control(String),
515 }
516
517 /// What the host is being asked to find.
518 ///
519 /// Four members because the mechanism has four, measured rather than guessed:
520 /// `audiofiles/crates/audiofiles-browser/src/ui/dialog.rs` carries `PickFolder`,
521 /// `PickFile`, `PickFiles` and `SaveFile`. A host maps each onto the picker it
522 /// already has.
523 ///
524 /// Multiplicity is a member rather than a flag, unlike
525 /// [`Field::multiple`](crate::Field::multiple). A field is one question that may
526 /// take more than one answer; these are four different things to ask an
527 /// operating system for, and every host has to branch on which anyway.
528 ///
529 /// See [`Save`](Self::Save): a save dialog and a pick dialog are the same
530 /// dialog on the host and opposite ends of the sentence here, and reading one
531 /// as the other loses the file the reader asked to write.
532 #[derive(Debug, Clone, PartialEq, Eq)]
533 pub enum Sought {
534 /// A folder, which the app then writes into repeatedly.
535 ///
536 /// Five of the seven measured sites. The reader chooses once and the app
537 /// keeps writing there, which is the whole of what made this unsayable: a
538 /// folder outlives the answer that asked for it.
539 Folder,
540 /// One file, for the app to read.
541 File {
542 /// What the picker offers, empty to mean anything.
543 ///
544 /// [`Accepted`] and not a second way of naming a file kind, for
545 /// [`Outcome::File::kind`]'s reason: the upload half already has this
546 /// type and a picker filter is the same fact said to a dialog.
547 accept: Vec<Accepted>,
548 },
549 /// Several files at once.
550 ///
551 /// One ask, one answer: every file the reader picked comes back in a single
552 /// call, because [`Locating::answered`] takes all of them together. That is
553 /// `8a246c02` and it is the member the ruling was about; see [`Picked`]
554 /// for what one call per file costs an import.
555 Files {
556 /// What the picker offers, empty to mean anything.
557 accept: Vec<Accepted>,
558 },
559 /// Somewhere to write one file, named by the reader before it is there.
560 ///
561 /// The save dialog: the reader is naming a file rather than choosing one
562 /// that already exists, and the app writes it afterwards. audiofiles'
563 /// classifier export is the measured site, offering `{export_name}.afcl`
564 /// and letting the reader change it.
565 ///
566 /// # Why this is not [`Outcome::File`] said differently
567 ///
568 /// They are one dialog on the host and opposite ends of the sentence here,
569 /// and it is [`Outcome::Locate`]'s "the bytes are not here" split again.
570 /// [`Outcome::File`] is a payload that exists when the route answers: the
571 /// app says what the file is, the host puts it wherever it puts downloads,
572 /// and the [`name`](Outcome::File::name) it suggests is the app's. This is
573 /// a destination chosen while there is nothing to put in it yet, and the
574 /// name that comes back is the reader's.
575 ///
576 /// # Why it is not a folder plus a name the app picks
577 ///
578 /// Because that is the workaround the ruling refused. A
579 /// [`Folder`](Self::Folder) ask plus a file name the app appends works and
580 /// loses the reason the dialog was opened: the reader names the export.
581 ///
582 /// # What a host that cannot save does
583 ///
584 /// The same as for every other member, and the same rule
585 /// [`Outcome::Locate`] states: refuse where it can be seen. `quasi-http`
586 /// answers 501 with a notice, because a browser can offer a download and
587 /// cannot hand back a destination the app may write into later. A host
588 /// with no dialog but somewhere sensible to write, a terminal with a
589 /// working directory, answers with a path built from
590 /// [`name`](Self::Save::name) through [`safe_file_name`] and says where it
591 /// put it. What none of them may do is stay quiet: the ask came from a
592 /// control the reader pressed.
593 Save {
594 /// The suggested file name, suffix included: `drums-2026-08-25.afcl`.
595 ///
596 /// Suggested and not chosen, the same word [`Outcome::File::name`]
597 /// uses for the same fact. A dialog offers it and the reader may type
598 /// over it; a host writing without asking runs it through
599 /// [`safe_file_name`] first, because it is frequently built from
600 /// something the reader typed earlier.
601 name: String,
602 /// What the picker offers, empty to mean anything.
603 ///
604 /// [`Accepted`] and not a second way of naming a file kind, for
605 /// [`File`](Self::File)'s reason. On this member it is also what a
606 /// dialog appends when the reader types a name with no suffix, where
607 /// the host does that.
608 accept: Vec<Accepted>,
609 },
610 }
611
612 /// One thing the reader picked, and what to call it on screen.
613 ///
614 /// The answer half of the picker, and the reason it is a type at all is that
615 /// there can be more than one of it:
616 /// [`Locating::answered`] takes every pick and builds **one** call, so a
617 /// [`Sought::Files`] ask reaches its route once with all the files rather than
618 /// once per file.
619 ///
620 /// # What one call per file cost
621 ///
622 /// [`answered`](Locating::answered) took a single handle until this arrived,
623 /// and every host half wrote the loop that follows from that. audiofiles' Import
624 /// files door hands every picked path to one `start_files_import(&paths,
625 /// strategy)`, and that batch is a deliberate fix: it keeps the hashing on the
626 /// worker instead of the GUI thread. N single-file imports land every file and
627 /// still regress the thing the batch was for, which is why this is a change to
628 /// the vocabulary rather than a host accumulating answers of its own.
629 ///
630 /// The host-side workaround the ruling refused was a spelling for several paths
631 /// in one handle (a separator, a joined string), which is one host inventing a
632 /// convention every other host would then have to know.
633 ///
634 /// # Why a pair and not two lists
635 ///
636 /// So that a host cannot hand over a handle and somebody else's label. The
637 /// parameters they arrive under are still two names ([`under`] and
638 /// [`labelled`]), and they stay in step because they are written out of the
639 /// same pick: the *n*th [`Params::get_all`](crate::Params::get_all) of one is
640 /// the *n*th of the other.
641 ///
642 /// [`under`]: Locating::under
643 /// [`labelled`]: Locating::labelled
644 #[derive(Debug, Clone, PartialEq, Eq)]
645 pub struct Picked {
646 /// What the host chose, in the host's own spelling.
647 ///
648 /// A path on a desktop, whatever a picker hands back elsewhere. Opaque
649 /// here: this crate never parses it, joins it or checks it, because the
650 /// only party that can read it is the app that asked.
651 pub handle: String,
652 /// The same thing in the reader's words, for a screen to show.
653 ///
654 /// Dropped unless [`Locating::labelled`] names a parameter for it, so a
655 /// host may always pass what it has and never has to ask whether the
656 /// description wanted it.
657 pub label: String,
658 }
659
660 impl Picked {
661 /// A handle and the label to show for it.
662 #[must_use]
663 pub fn new(handle: impl Into<String>, label: impl Into<String>) -> Self {
664 Self {
665 handle: handle.into(),
666 label: label.into(),
667 }
668 }
669 }
670
671 /// A place a route asked for, and where the answer goes.
672 ///
673 /// [`Outcome::Locate`]'s payload, and a struct rather than five inline members
674 /// because the renderers hand it to their hosts whole: `quasi-immediate` and
675 /// `quasi-tui` each drain one of these, the way they drain a file. That is the
676 /// one difference from their `Handed`, which each
677 /// renderer declares for itself — a file is host-side data and needs sanitising
678 /// per host, and an ask is description data that every host reads the same.
679 ///
680 /// # The names are stated, not agreed
681 ///
682 /// [`under`](Self::under) and [`labelled`](Self::labelled) say which parameters
683 /// the answer arrives under, for [`FieldKind::Interval`][interval]'s reason: a
684 /// convention this crate invented would rename somebody's parameter, and the
685 /// two ends of the tree already disagree about affix order. A host never builds
686 /// the call by hand either — [`answered`](Self::answered) does, so the names
687 /// stay inside the crate that stated them.
688 ///
689 /// [interval]: makeover_layout::FieldKind::Interval
690 #[derive(Debug, Clone, PartialEq, Eq)]
691 pub struct Locating {
692 /// What to find.
693 pub sought: Sought,
694 /// What the picker is for, in the reader's words.
695 ///
696 /// A dialog title on every host that has one: "Import folder", "Export
697 /// destination", "Locate missing sample files" are the shipped three. A
698 /// host with no title to set drops it rather than drawing it somewhere of
699 /// its own choosing.
700 pub prompt: String,
701 /// The route the answer goes back to.
702 ///
703 /// The act shape points this at the work — picking the folder *is* the
704 /// import, so the call that lands does the importing. The form shape points
705 /// it at the route that stashes the handle and answers with the region
706 /// redrawn, showing the label beside the Browse control.
707 pub answers: Action,
708 /// The name the handle is sent under.
709 ///
710 /// One name however many handles come back: a [`Sought::Files`] ask
711 /// answered with three files sends the name three times, in pick order, and
712 /// the route reads them with
713 /// [`Params::get_all`](crate::Params::get_all). That is what [`Params`] is
714 /// a list of pairs for, and it is the same shape a checkbox group already
715 /// submits.
716 ///
717 /// [`Params`]: crate::Params
718 pub under: String,
719 /// The name the label is sent under, when the route wants it.
720 ///
721 /// `None` on the act shape, which is the majority: an import door has
722 /// nowhere to show a label and no reason to carry one. `Some` on the form
723 /// shape, which displays the destination back to the reader —
724 /// `ui/export_screens.rs:264-274` draws the path beside the button, and is
725 /// why the label comes back beside the handle rather than the handle alone.
726 ///
727 /// Repeats with [`under`](Self::under) and stays in step with it, one label
728 /// per [`Picked`].
729 pub labelled: Option<String>,
730 }
731
732 impl Locating {
733 /// Ask for a folder.
734 #[must_use]
735 pub fn folder(prompt: impl Into<String>, answers: Action, under: impl Into<String>) -> Self {
736 Self::new(Sought::Folder, prompt, answers, under)
737 }
738
739 /// Ask for whatever this is, answering to that route under that name.
740 #[must_use]
741 pub fn new(
742 sought: Sought,
743 prompt: impl Into<String>,
744 answers: Action,
745 under: impl Into<String>,
746 ) -> Self {
747 Self {
748 sought,
749 prompt: prompt.into(),
750 answers,
751 under: under.into(),
752 labelled: None,
753 }
754 }
755
756 /// Send the label back too, under this name.
757 ///
758 /// What the form shape adds. Chaining rather than an argument for
759 /// [`Field::upload`](crate::Field::upload)'s converse reason: a missing
760 /// accept list is a real choice and has to be argued, and a route with
761 /// nothing to show a label on is the common case.
762 ///
763 /// # It must differ from [`under`](Self::under)
764 ///
765 /// Handle and label go on under their own names, so giving both the same
766 /// name interleaves them: `get_all(under)` then yields handle, label,
767 /// handle, label and the route reads every second value as a path. Caught
768 /// here in debug rather than left to look like a picker that answers
769 /// twice.
770 #[must_use]
771 pub fn showing(mut self, labelled: impl Into<String>) -> Self {
772 let labelled = labelled.into();
773 debug_assert_ne!(
774 labelled, self.under,
775 "a Locating's label name and handle name must differ, or one ask answers both under \
776 the same key and every second value reads as a handle",
777 );
778 self.labelled = Some(labelled);
779 self
780 }
781
782 /// The call the host makes once the reader has picked.
783 ///
784 /// Built here so that no host writes the parameter names itself. Each
785 /// [`Picked`] is put on through [`Action::with`](crate::Action::with), so
786 /// the values land in the bag that action's method says they land in — the
787 /// payload of a write, the address of a read — and a route reads them where
788 /// it reads everything else.
789 ///
790 /// [`label`](Picked::label) is dropped when [`labelled`](Self::labelled) is
791 /// `None`, so a host may pass what it has and never has to ask whether it is
792 /// wanted.
793 ///
794 /// # One call, however many were picked
795 ///
796 /// This takes every pick rather than one, and there is no second method
797 /// that takes one: a host with three files calls this once with three
798 /// [`Picked`]s, and the route reads them with
799 /// [`Params::get_all`](crate::Params::get_all) under [`under`](Self::under).
800 /// Handing the host a single-handle door is what produced the loop this
801 /// replaces, so the door is gone rather than documented against.
802 ///
803 /// A route that would rather work one at a time still can, by iterating
804 /// what it was sent. A route that needs the batch cannot get it back from N
805 /// calls, which is the asymmetry that decides the shape.
806 ///
807 /// # `None`, twice
808 ///
809 /// Nothing to call when the answer names somewhere outside the app, the
810 /// same answer [`Outcome::Goto`] gives an
811 /// [`External`](crate::Destination::External) destination. And nothing to
812 /// call when no pick arrives: a reader who backs out of the dialog has not
813 /// answered, so there is nothing to tell the router about it. A host may
814 /// hand over whatever the picker gave it without checking first.
815 #[must_use]
816 pub fn answered(&self, picked: impl IntoIterator<Item = Picked>) -> Option<Request> {
817 let mut action = self.answers.clone();
818 let mut any = false;
819 for pick in picked {
820 any = true;
821 action = action.with(self.under.as_str(), pick.handle);
822 if let Some(name) = &self.labelled {
823 action = action.with(name.as_str(), pick.label);
824 }
825 }
826 if !any {
827 return None;
828 }
829 let path = action.destination.route()?.to_owned();
830 Some(Request {
831 method: action.method,
832 path,
833 captures: crate::request::Params::new(),
834 payload: action.params,
835 carried: action.carried,
836 })
837 }
838 }
839
840 /// Something to tell the user alongside whatever else the response does.
841 ///
842 /// The same three fields as [`Node::Notice`], because it is the same thing said
843 /// from the other end: that one is a message a screen contains, this is a
844 /// message an answer carries. A renderer that can draw one can draw the other.
845 #[derive(Debug, Clone, PartialEq, Eq)]
846 pub struct Message {
847 /// Transient and stacked, or persistent and in flow.
848 pub kind: layout::Notice,
849 /// What it is saying.
850 pub tone: layout::Tone,
851 /// The message.
852 pub text: String,
853 /// What taking it back calls, when it can be taken back.
854 ///
855 /// The half that is not on [`Act`](crate::Act). Confirming is a question
856 /// asked *before*, and it is a property of the control, so it lives there.
857 /// Undoing is offered *after*, alongside the sentence saying what
858 /// happened, and it needs a second route — which is what made it this
859 /// crate's rather than the vocabulary's, the same split the file-dialog
860 /// finding took.
861 ///
862 /// goingson raises 16 of these and Balanced Breakfast 3, each through a
863 /// helper that builds the toast, the button and a countdown by hand.
864 ///
865 /// No timeout here. How long an undo stays offered is renderer policy, the
866 /// same class of decision as whether a pending region draws a skeleton or a
867 /// spinner, and a description that carried seconds would be naming a value.
868 pub undo: Option<Action>,
869 }
870
871 impl Message {
872 /// What an undo control says.
873 ///
874 /// Named once here rather than by each host that draws one. [`undo`] is an
875 /// address and carries no label, deliberately -- what a control is called is
876 /// copy, and a handler writing "Undo" at every one of goingson's sixteen
877 /// sites is the duplication the member removed. So the word is here, where
878 /// three renderers can only read it.
879 ///
880 /// [`undo`]: Self::undo
881 pub const UNDO: &'static str = "Undo";
882
883 /// The way back as the control a retained-screen host hangs on a notice.
884 ///
885 /// A webview renders a message itself and can put an anchor beside the
886 /// text; a host that keeps a screen converts the message into a
887 /// [`Node::Notice`](crate::Node::Notice), and this is the half of that
888 /// conversion the vocabulary owes it.
889 #[must_use]
890 pub fn undo_act(&self) -> Option<crate::Act> {
891 self.undo
892 .as_ref()
893 .map(|action| crate::Act::new(Self::UNDO, action.clone()))
894 }
895 }
896
897 /// The file name a host can actually write, from the one a description said.
898 ///
899 /// [`Outcome::File::name`] is a suggestion and is frequently built from
900 /// something the user typed — a project title, a search they saved — so by the
901 /// time it reaches a host it is user input. Every host runs it through this
902 /// rather than each one inventing its own rules: a terminal writing beside the
903 /// process must not be handed `../../.ssh/authorized_keys`, and an HTTP host
904 /// must not be handed a newline to put in a header.
905 ///
906 /// What survives: everything except path separators, control characters, and
907 /// the characters Windows refuses in a name. Runs of the rest collapse to a
908 /// single `_`, leading dots go so the file is not hidden and cannot be `..`,
909 /// and an empty result becomes `download`.
910 ///
911 /// Deliberately not an escape or an encoding. A name is shown to a person and
912 /// typed back by one, so a mangled character should look mangled rather than
913 /// look like `%2F`.
914 #[must_use]
915 pub fn safe_file_name(name: &str) -> String {
916 let mut out = String::with_capacity(name.len());
917 for ch in name.chars() {
918 if ch.is_control() || matches!(ch, '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|') {
919 if !out.ends_with('_') {
920 out.push('_');
921 }
922 } else {
923 out.push(ch);
924 }
925 }
926 let trimmed = out.trim_matches(|c: char| c == '.' || c == '_' || c.is_whitespace());
927 if trimmed.is_empty() {
928 "download".to_owned()
929 } else {
930 trimmed.to_owned()
931 }
932 }
933
934 impl Response {
935 /// A whole screen.
936 #[must_use]
937 pub fn screen(screen: Screen) -> Self {
938 Self::from(Outcome::Screen(screen))
939 }
940
941 /// One region's new contents.
942 pub fn fragment(region: impl Into<String>, node: Node) -> Self {
943 Self::from(Outcome::Fragment {
944 region: region.into(),
945 node,
946 })
947 }
948
949 /// Somewhere else instead of content.
950 #[must_use]
951 pub fn goto(action: Action) -> Self {
952 Self::from(Outcome::Goto(action))
953 }
954
955 /// A screen over the one already there. Dismissing it reveals that one.
956 #[must_use]
957 pub fn over(screen: Screen) -> Self {
958 Self::from(Outcome::Over(screen))
959 }
960
961 /// A screen at a described point on the one already there.
962 ///
963 /// The anchored half of [`over`](Self::over). See [`Anchor`] for why what
964 /// it names is a described thing rather than a position.
965 #[must_use]
966 pub fn anchored(screen: Screen, anchor: Anchor) -> Self {
967 Self::from(Outcome::Anchored { screen, anchor })
968 }
969
970 /// What a field's suggestion route answers with.
971 ///
972 /// The field is named by [`Field::name`](crate::Field::name), which is the
973 /// name it sent the typed value under, so a handler answers with the name
974 /// it was asked under and never with an id.
975 pub fn suggestions(field: impl Into<String>, options: Vec<Candidate>) -> Self {
976 Self::from(Outcome::Suggestions {
977 field: field.into(),
978 options,
979 })
980 }
981
982 /// A file the viewer keeps. The host decides where it lands.
983 ///
984 /// `name` is a suggestion, not a path: a description that named a directory
985 /// would be naming one host's filesystem. See [`Outcome::File`].
986 pub fn file(name: impl Into<String>, kind: Accepted, bytes: impl Into<Vec<u8>>) -> Self {
987 Self::from(Outcome::File {
988 name: name.into(),
989 kind,
990 bytes: bytes.into(),
991 })
992 }
993
994 /// Ask the host for a place. Where it asks and what it asks with is the
995 /// host's; what comes back is every handle it chose, each with a label. One
996 /// ask is answered once, however many things the reader picked.
997 ///
998 /// See [`Outcome::Locate`] for why this is an answer rather than a
999 /// control, and [`Locating`] for where the answer goes.
1000 #[must_use]
1001 pub fn locate(locating: Locating) -> Self {
1002 Self::from(Outcome::Locate(locating))
1003 }
1004
1005 /// The work is running and the region is waiting on it.
1006 ///
1007 /// See [`Outcome::Started`]: the handler stays sync, the app keeps its own
1008 /// offload, and what reports the finish is the region's existing
1009 /// [`live`](crate::Slot::live) or
1010 /// [`fed_by`](crate::Slot::fed_by) call.
1011 pub fn started(region: impl Into<String>, message: impl Into<String>) -> Self {
1012 Self::from(Outcome::Started {
1013 region: region.into(),
1014 message: message.into(),
1015 })
1016 }
1017
1018 /// Say something transient on the way. It dismisses itself.
1019 ///
1020 /// How long it stays is the renderer's, not this crate's.: a toast
1021 /// duration is presentation policy, the same class of value as
1022 /// [`Message::undo`]'s missing timeout, so each renderer names it once --
1023 /// `LINGER` in the terminal and egui renderers, and the same number in the
1024 /// webview's clock script. `Notice::Toast` is not advisory: a host that
1025 /// draws one and never takes it away is wrong.
1026 ///
1027 /// [`Message::undo`]: Message::undo
1028 #[must_use]
1029 pub fn toast(self, tone: layout::Tone, text: impl Into<String>) -> Self {
1030 self.saying(layout::Notice::Toast, tone, text)
1031 }
1032
1033 /// Say something persistent on the way. It is dismissed by fixing the cause.
1034 #[must_use]
1035 pub fn banner(self, tone: layout::Tone, text: impl Into<String>) -> Self {
1036 self.saying(layout::Notice::Banner, tone, text)
1037 }
1038
1039 /// Say something, spelling out which kind it is.
1040 ///
1041 /// [`toast`](Self::toast) and [`banner`](Self::banner) are this with the
1042 /// kind chosen, and are what call sites should reach for.
1043 #[must_use]
1044 pub fn saying(
1045 mut self,
1046 kind: layout::Notice,
1047 tone: layout::Tone,
1048 text: impl Into<String>,
1049 ) -> Self {
1050 self.notice = Some(Message {
1051 kind,
1052 tone,
1053 text: text.into(),
1054 undo: None,
1055 });
1056 self
1057 }
1058
1059 /// Offer to take back whatever the notice just said happened.
1060 ///
1061 /// Applies to the notice already on the response, so it follows a
1062 /// [`toast`](Self::toast) or a [`banner`](Self::banner) rather than
1063 /// replacing one. A response with nothing to say has nothing to undo: the
1064 /// sentence is what the offer hangs off, and an undo button with no
1065 /// explanation is a control the user cannot judge.
1066 #[must_use]
1067 pub fn undoable(mut self, action: Action) -> Self {
1068 if let Some(notice) = &mut self.notice {
1069 notice.undo = Some(action);
1070 }
1071 self
1072 }
1073
1074 /// This answer also changed that slot, and here is its new content.
1075 ///
1076 /// Chains, so a write that moves three places says so three times. The
1077 /// order is kept, because a renderer applying them in a different order
1078 /// than the router named them would be inventing a fact.
1079 ///
1080 /// Naming the slot the [`Outcome`] already replaces is not rejected here
1081 /// and not special-cased: a renderer applies what it is given, and a
1082 /// response that says the same region twice is a bug in the handler that a
1083 /// silent drop would hide.
1084 #[must_use]
1085 pub fn also(mut self, region: impl Into<String>, node: Node) -> Self {
1086 self.invalidates.push(Invalidated {
1087 region: region.into(),
1088 node,
1089 });
1090 self
1091 }
1092
1093 /// This answer is a place, at this address.
1094 ///
1095 /// For the answer a derivation cannot reach: a fragment that is a place.
1096 /// A tab panel answers `Response::fragment("tab-content", node)
1097 /// .at("/dashboard#tab-projects")`, which reproduces by construction what
1098 /// the server does by hand today.
1099 #[must_use]
1100 pub fn at(mut self, url: impl Into<String>) -> Self {
1101 self.address = Some(Address::Enters(url.into()));
1102 self
1103 }
1104
1105 /// This answer is a place, and takes the current entry's slot.
1106 ///
1107 /// For a state the back button should skip: a filter applied over a list,
1108 /// a step within a flow. The address moves and history does not grow.
1109 #[must_use]
1110 pub fn replacing(mut self, url: impl Into<String>) -> Self {
1111 self.address = Some(Address::Replaces(url.into()));
1112 self
1113 }
1114
1115 /// This answer is not a place, whatever the derivation would have said.
1116 ///
1117 /// The other half of the override: a read of a route is a place by default,
1118 /// and this is how a transient one says it is not.
1119 #[must_use]
1120 pub fn in_place(mut self) -> Self {
1121 self.address = Some(Address::Unchanged);
1122 self
1123 }
1124
1125 /// The region being replaced, or `None` for a whole screen or a redirect.
1126 ///
1127 /// A webview reads this to set `hx-retarget`. Renderers that repaint
1128 /// wholesale never call it.
1129 #[must_use]
1130 pub fn target(&self) -> Option<&str> {
1131 match &self.outcome {
1132 // An overlay targets no region: it is drawn over the whole of what
1133 // is under it, and the host puts it in its own container.
1134 // A suggestion list is addressed by the field that owns it, and
1135 // turning a field name into a document id is the renderer's
1136 // business rather than this crate's — a terminal has no ids at all.
1137 // `Serves::suggestions_target` is where a webview answers it.
1138 // A file replaces no region either: it is handed to the host
1139 // rather than drawn, and what is on the screen stays there.
1140 // An anchored screen targets no region for the same reason one
1141 // level in: it is drawn at a described point, and turning that
1142 // point into a place to put markup is the renderer's business.
1143 // `Serves::anchored_target` is where a webview answers it.
1144 Outcome::Screen(_)
1145 | Outcome::Goto(_)
1146 | Outcome::Over(_)
1147 | Outcome::Anchored { .. }
1148 // A place being asked for replaces nothing either: the picker is
1149 // the host's furniture and the screen underneath is untouched.
1150 | Outcome::Suggestions { .. }
1151 | Outcome::File { .. }
1152 | Outcome::Locate(_) => None,
1153 // A region being told it is waiting is aimed the way a region being
1154 // given contents is. It is the same region and the same swap; what
1155 // differs is that the contents are a wait rather than an answer.
1156 Outcome::Fragment { region, .. } | Outcome::Started { region, .. } => Some(region),
1157 }
1158 }
1159
1160 /// Where this is sending the user, if it is sending them anywhere.
1161 ///
1162 /// The question a host asks before it looks for a body, because a redirect
1163 /// has none.
1164 #[must_use]
1165 pub fn destination(&self) -> Option<&Action> {
1166 match &self.outcome {
1167 Outcome::Goto(action) => Some(action),
1168 // An overlay sends the user nowhere: dismissing it reveals the
1169 // screen they never left.
1170 Outcome::Screen(_)
1171 | Outcome::Fragment { .. }
1172 | Outcome::Over(_)
1173 | Outcome::Anchored { .. }
1174 | Outcome::Suggestions { .. }
1175 | Outcome::File { .. }
1176 // Asking for a place sends nobody anywhere. Where the answer goes
1177 // afterwards is `Locating::answers`, which is a call the host makes
1178 // once the reader has picked and not a redirect this answer is.
1179 | Outcome::Locate(_)
1180 // Handing work off sends nobody anywhere. The reader stays on the
1181 // screen that is now waiting, which is the whole point of being
1182 // able to say this at all.
1183 | Outcome::Started { .. } => None,
1184 }
1185 }
1186 }
1187
1188 impl From<Outcome> for Response {
1189 fn from(outcome: Outcome) -> Self {
1190 Self {
1191 outcome,
1192 notice: None,
1193 address: None,
1194 invalidates: Vec::new(),
1195 }
1196 }
1197 }
1198
1199 impl From<Screen> for Response {
1200 fn from(screen: Screen) -> Self {
1201 Self::screen(screen)
1202 }
1203 }
1204
1205 #[cfg(test)]
1206 mod tests {
1207 use super::*;
1208 use crate::screen::{Accepted, Candidate};
1209
1210 /// A suggestion list is addressed by the field that owns it, so it names
1211 /// no region and sends the user nowhere. Turning that name into a document
1212 /// id is the renderer's, which is what `target` answering `None` says
1213 /// here.
1214 #[test]
1215 fn a_suggestion_answer_names_no_region_and_no_destination() {
1216 let answer = Response::suggestions("q", vec![Candidate::plain("rust")]);
1217 assert_eq!(answer.target(), None);
1218 assert_eq!(answer.destination(), None);
1219 let Outcome::Suggestions { field, options } = &answer.outcome else {
1220 panic!("suggestions");
1221 };
1222 assert_eq!(field, "q");
1223 assert_eq!(options.len(), 1);
1224 }
1225
1226 /// A file is handed to the host, so it replaces no region and sends the
1227 /// user nowhere. Where it lands is the host's, which is what both `None`s
1228 /// say here.
1229 #[test]
1230 fn a_file_answer_names_no_region_and_no_destination() {
1231 let answer = Response::file(
1232 "goingson-export.json",
1233 Accepted::media_type("application/json"),
1234 b"{}".to_vec(),
1235 );
1236 assert_eq!(answer.target(), None);
1237 assert_eq!(answer.destination(), None);
1238 let Outcome::File { name, kind, bytes } = &answer.outcome else {
1239 panic!("file");
1240 };
1241 assert_eq!(name, "goingson-export.json");
1242 assert_eq!(kind, &Accepted::Type("application/json".into()));
1243 assert_eq!(bytes, b"{}");
1244 }
1245
1246 /// Asking for a place replaces no region and sends the user nowhere: the
1247 /// picker is the host's furniture, and the screen it opens over is still
1248 /// the screen.
1249 #[test]
1250 fn a_locate_answer_names_no_region_and_no_destination() {
1251 let answer = Response::locate(Locating::folder(
1252 "Export destination",
1253 Action::post("/export/destination"),
1254 "handle",
1255 ));
1256 assert_eq!(answer.target(), None);
1257 assert_eq!(answer.destination(), None);
1258 let Outcome::Locate(asking) = &answer.outcome else {
1259 panic!("locate");
1260 };
1261 assert_eq!(asking.sought, Sought::Folder);
1262 assert_eq!(asking.prompt, "Export destination");
1263 assert_eq!(asking.labelled, None);
1264 }
1265
1266 /// The act shape: the call that lands does the work, and carries the handle
1267 /// as the write's payload because that is where `Action::with` puts a
1268 /// value on a write.
1269 #[test]
1270 fn the_handle_lands_in_a_writes_payload() {
1271 let asking = Locating::folder("Import folder", Action::post("/import/open"), "folder");
1272 let call = asking
1273 .answered([Picked::new("/home/max/samples", "samples")])
1274 .expect("a route");
1275 assert_eq!(call.method, crate::Method::Post);
1276 assert_eq!(call.path, "/import/open");
1277 assert_eq!(call.payload.get("folder"), Some("/home/max/samples"));
1278 // Not asked for, so not sent. A host may hand over the label it has
1279 // without asking whether the description wanted one.
1280 assert_eq!(call.payload.get("label"), None);
1281 assert!(call.carried.is_empty());
1282 }
1283
1284 /// The form shape: the label comes back beside the handle, which is what
1285 /// `ui/export_screens.rs` draws next to its Browse button.
1286 #[test]
1287 fn the_form_shape_gets_its_label_back() {
1288 let asking = Locating::folder(
1289 "Export destination",
1290 Action::post("/export/destination"),
1291 "handle",
1292 )
1293 .showing("shown");
1294 let call = asking
1295 .answered([Picked::new("/media/drive/out", "drive/out")])
1296 .expect("a route");
1297 assert_eq!(call.payload.get("handle"), Some("/media/drive/out"));
1298 assert_eq!(call.payload.get("shown"), Some("drive/out"));
1299 }
1300
1301 /// A read's values are its address, so they land in `carried` rather than
1302 /// in the payload. Stated by `Action::with` once and read here, so the two
1303 /// bags cannot drift apart.
1304 #[test]
1305 fn a_read_takes_the_handle_as_its_address() {
1306 let asking = Locating::new(
1307 Sought::Files {
1308 accept: vec![Accepted::suffix(".wav")],
1309 },
1310 "Import files",
1311 Action::get("/import/files"),
1312 "picked",
1313 );
1314 let call = asking
1315 .answered([Picked::new("/tmp/a.wav", "a.wav")])
1316 .expect("a route");
1317 assert_eq!(call.carried.get("picked"), Some("/tmp/a.wav"));
1318 assert!(call.payload.is_empty());
1319 }
1320
1321 /// Every file the reader picked reaches the route in one call, under one
1322 /// name, in the order they were picked. audiofiles' Import files door
1323 /// hands the batch to a single `start_files_import`, and N calls would be
1324 /// N imports.
1325 #[test]
1326 fn several_files_are_one_call_carrying_every_handle() {
1327 let asking = Locating::new(
1328 Sought::Files {
1329 accept: vec![Accepted::suffix(".wav")],
1330 },
1331 "Import files",
1332 Action::post("/import/files"),
1333 "path",
1334 );
1335 let call = asking
1336 .answered([
1337 Picked::new("/tmp/a.wav", "a.wav"),
1338 Picked::new("/tmp/b.wav", "b.wav"),
1339 Picked::new("/tmp/c.wav", "c.wav"),
1340 ])
1341 .expect("a route");
1342 assert_eq!(call.path, "/import/files");
1343 assert_eq!(
1344 call.payload.get_all("path").collect::<Vec<_>>(),
1345 ["/tmp/a.wav", "/tmp/b.wav", "/tmp/c.wav"]
1346 );
1347 }
1348
1349 /// The labels repeat beside the handles and stay in step with them, because
1350 /// both are written out of the same `Picked`.
1351 #[test]
1352 fn every_handle_brings_its_own_label() {
1353 let asking = Locating::new(
1354 Sought::Files { accept: Vec::new() },
1355 "Locate missing sample files",
1356 Action::post("/library/relocate"),
1357 "path",
1358 )
1359 .showing("shown");
1360 let call = asking
1361 .answered([
1362 Picked::new("/tmp/a.wav", "a.wav"),
1363 Picked::new("/tmp/b.wav", "b.wav"),
1364 ])
1365 .expect("a route");
1366 let handles: Vec<_> = call.payload.get_all("path").collect();
1367 let labels: Vec<_> = call.payload.get_all("shown").collect();
1368 assert_eq!(handles, ["/tmp/a.wav", "/tmp/b.wav"]);
1369 assert_eq!(labels, ["a.wav", "b.wav"]);
1370 }
1371
1372 /// A reader who backs out picked nothing, and nothing is not an answer. A
1373 /// host may hand over whatever the picker gave it rather than checking
1374 /// first.
1375 #[test]
1376 fn picking_nothing_is_no_call_at_all() {
1377 let asking = Locating::new(
1378 Sought::Files { accept: Vec::new() },
1379 "Import files",
1380 Action::post("/import/files"),
1381 "path",
1382 );
1383 assert!(asking.answered([]).is_none());
1384 }
1385
1386 /// The save shape carries the name the dialog opens with and what it
1387 /// filters to, and the reader's answer comes back the way every other pick
1388 /// does.
1389 #[test]
1390 fn a_save_ask_carries_a_suggested_name_and_answers_like_any_other() {
1391 let asking = Locating::new(
1392 Sought::Save {
1393 name: "drums-2026-08-25.afcl".into(),
1394 accept: vec![Accepted::suffix(".afcl")],
1395 },
1396 "Export classifier",
1397 Action::post("/classifier/export"),
1398 "path",
1399 );
1400 let answer = Response::locate(asking.clone());
1401 assert_eq!(answer.target(), None);
1402 assert_eq!(answer.destination(), None);
1403 let Sought::Save { name, accept } = &asking.sought else {
1404 panic!("save");
1405 };
1406 assert_eq!(name, "drums-2026-08-25.afcl");
1407 assert_eq!(accept, &[Accepted::Suffix(".afcl".into())]);
1408
1409 let call = asking
1410 .answered([Picked::new("/home/max/exports/drums.afcl", "drums.afcl")])
1411 .expect("a route");
1412 assert_eq!(call.method, crate::Method::Post);
1413 assert_eq!(call.path, "/classifier/export");
1414 assert_eq!(
1415 call.payload.get("path"),
1416 Some("/home/max/exports/drums.afcl")
1417 );
1418 }
1419
1420 /// The name is a suggestion built out of something the reader typed, so a
1421 /// host writing it without a dialog has the same sanitiser the download
1422 /// half has. Stated here so that the two halves cannot answer differently.
1423 #[test]
1424 fn a_suggested_save_name_goes_through_the_same_sanitiser() {
1425 assert_eq!(
1426 safe_file_name("../../.ssh/authorized_keys"),
1427 "ssh_authorized_keys"
1428 );
1429 assert_eq!(
1430 safe_file_name("drums-2026-08-25.afcl"),
1431 "drums-2026-08-25.afcl"
1432 );
1433 }
1434
1435 /// Somewhere outside the app is nowhere to send the answer, so there is no
1436 /// call to make. The same answer `Goto` gives an external destination.
1437 #[test]
1438 fn an_answer_that_goes_outside_the_app_is_no_call_at_all() {
1439 let asking = Locating::folder(
1440 "Somewhere else",
1441 Action::external("https://example.invalid"),
1442 "handle",
1443 );
1444 assert!(asking.answered([Picked::new("/tmp", "tmp")]).is_none());
1445 }
1446
1447 /// The notice composes with a file the same way it composes with the other
1448 /// four, because it is a fourth field rather than a fifth member.
1449 #[test]
1450 fn a_file_answer_can_still_say_something() {
1451 let answer = Response::file("a.csv", Accepted::suffix(".csv"), b"a,b\n".to_vec())
1452 .toast(layout::Tone::Success, "exported");
1453 assert_eq!(
1454 answer.notice.as_ref().map(|say| say.text.as_str()),
1455 Some("exported")
1456 );
1457 }
1458
1459 /// All three anchors survive the round trip, and an anchored answer
1460 /// replaces no region and sends the user nowhere -- it is drawn over what
1461 /// is there, which is `Over`'s bargain at a point.
1462 #[test]
1463 fn an_anchored_answer_carries_its_anchor_and_names_no_region() {
1464 use crate::screen::RegionKind;
1465
1466 for anchor in [
1467 Anchor::Region("browser".into()),
1468 Anchor::Selection,
1469 Anchor::Control("sort".into()),
1470 ] {
1471 let screen =
1472 Screen::sidebar_content("Menu").with(crate::Slot::new("menu", RegionKind::Pane));
1473 let answer = Response::anchored(screen, anchor.clone());
1474
1475 assert_eq!(answer.target(), None);
1476 assert_eq!(answer.destination(), None);
1477 let Outcome::Anchored { anchor: back, .. } = &answer.outcome else {
1478 panic!("anchored");
1479 };
1480 assert_eq!(back, &anchor);
1481 }
1482 }
1483
1484 /// A control carries no name unless one is asked for, which is what makes
1485 /// `Act::id` additive: every control written before the member existed
1486 /// renders exactly as it did.
1487 #[test]
1488 fn a_control_is_unnamed_until_it_is_named() {
1489 use crate::screen::{Act, Action};
1490
1491 let bare = Act::new("Sort", Action::get("/sort"));
1492 assert_eq!(bare.id, None);
1493 assert_eq!(bare.clone().id("sort").id.as_deref(), Some("sort"));
1494 // The name is the only thing it sets. A builder that also moved the
1495 // label or the action would make naming a control a decision rather
1496 // than an address.
1497 let named = bare.clone().id("sort");
1498 assert_eq!(named.label, bare.label);
1499 assert_eq!(named.action, bare.action);
1500 }
1501
1502 /// What every renderer asks before it decides where to draw. One walk here
1503 /// rather than three that can disagree.
1504 #[test]
1505 fn a_screen_answers_whether_it_carries_what_an_anchor_names() {
1506 use crate::screen::{Act, Action, RegionKind, Slot};
1507
1508 let screen = Screen::sidebar_content("Files").with(
1509 Slot::new("browser", RegionKind::Pane)
1510 .with(Node::Act(Act::new("Sort", Action::get("/sort")).id("sort")))
1511 .with(Node::Region(Slot::new("inner", RegionKind::Group))),
1512 );
1513
1514 assert!(screen.anchors(&Anchor::Region("browser".into())));
1515 // At any depth, matching `Screen::slot`.
1516 assert!(screen.anchors(&Anchor::Region("inner".into())));
1517 assert!(screen.anchors(&Anchor::Control("sort".into())));
1518
1519 // Naming what is not there is a description bug, and every renderer
1520 // degrades on this answer rather than refusing.
1521 assert!(!screen.anchors(&Anchor::Region("nowhere".into())));
1522 assert!(!screen.anchors(&Anchor::Control("nothing".into())));
1523 // No selection on this screen, so nothing to anchor to.
1524 assert!(!screen.anchors(&Anchor::Selection));
1525 assert!(
1526 screen
1527 .clone()
1528 .selecting("chosen")
1529 .anchors(&Anchor::Selection)
1530 );
1531 }
1532 }
1533