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 | //! 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 crateRequest; |
| 93 | use crate; |
| 94 | |
| 95 | /// What a route answered with. |
| 96 | |
| 97 | |
| 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: , |
| 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: , |
| 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: , |
| 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 | |
| 142 | |
| 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 | |
| 163 | |
| 164 | /// A new place. This URL enters history. |
| 165 | Enters, |
| 166 | /// A place, replacing the current entry rather than adding one. |
| 167 | Replaces, |
| 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 | |
| 220 | |
| 221 | /// The whole screen. A navigation, or an action whose effect is not |
| 222 | /// contained by one region. |
| 223 | 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, |
| 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, |
| 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: , |
| 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: , |
| 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, |
| 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 | |
| 485 | |
| 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, |
| 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, |
| 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 | |
| 533 | |
| 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: , |
| 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: , |
| 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: , |
| 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 | |
| 645 | |
| 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 | |
| 661 | /// A handle and the label to show for it. |
| 662 | |
| 663 | |
| 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 | |
| 691 | |
| 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: , |
| 730 | |
| 731 | |
| 732 | |
| 733 | /// Ask for a folder. |
| 734 | |
| 735 | |
| 736 | Selfnew |
| 737 | |
| 738 | |
| 739 | /// Ask for whatever this is, answering to that route under that name. |
| 740 | |
| 741 | |
| 742 | sought: Sought, |
| 743 | prompt: impl , |
| 744 | answers: Action, |
| 745 | under: impl , |
| 746 | |
| 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 | |
| 771 | |
| 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; |
| 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 | |
| 816 | |
| 817 | let mut action = self.answers.clone; |
| 818 | let mut any = false; |
| 819 | for pick in picked |
| 820 | any = true; |
| 821 | action = action.with; |
| 822 | if let Some = &self.labelled |
| 823 | action = action.with; |
| 824 | |
| 825 | |
| 826 | if !any |
| 827 | return None; |
| 828 | |
| 829 | let path = action.destination.route?.to_owned; |
| 830 | Some |
| 831 | method: action.method, |
| 832 | path, |
| 833 | captures: cratenew, |
| 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 | |
| 846 | |
| 847 | /// Transient and stacked, or persistent and in flow. |
| 848 | pub kind: Notice, |
| 849 | /// What it is saying. |
| 850 | pub tone: 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: , |
| 869 | |
| 870 | |
| 871 | |
| 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 | |
| 890 | |
| 891 | self.undo |
| 892 | .as_ref |
| 893 | .map |
| 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 | |
| 915 | |
| 916 | let mut out = Stringwith_capacity; |
| 917 | for ch in name.chars |
| 918 | if ch.is_control || matches! |
| 919 | if !out.ends_with |
| 920 | out.push; |
| 921 | |
| 922 | else |
| 923 | out.push; |
| 924 | |
| 925 | |
| 926 | let trimmed = out.trim_matches; |
| 927 | if trimmed.is_empty |
| 928 | "download".to_owned |
| 929 | else |
| 930 | trimmed.to_owned |
| 931 | |
| 932 | |
| 933 | |
| 934 | |
| 935 | /// A whole screen. |
| 936 | |
| 937 | |
| 938 | Selffrom |
| 939 | |
| 940 | |
| 941 | /// One region's new contents. |
| 942 | |
| 943 | Selffrom |
| 944 | region: region.into, |
| 945 | node, |
| 946 | |
| 947 | |
| 948 | |
| 949 | /// Somewhere else instead of content. |
| 950 | |
| 951 | |
| 952 | Selffrom |
| 953 | |
| 954 | |
| 955 | /// A screen over the one already there. Dismissing it reveals that one. |
| 956 | |
| 957 | |
| 958 | Selffrom |
| 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 | |
| 966 | |
| 967 | Selffrom |
| 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 | |
| 976 | Selffrom |
| 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 | |
| 987 | Selffrom |
| 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 | |
| 1001 | |
| 1002 | Selffrom |
| 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 | |
| 1012 | Selffrom |
| 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 | |
| 1029 | |
| 1030 | self.saying |
| 1031 | |
| 1032 | |
| 1033 | /// Say something persistent on the way. It is dismissed by fixing the cause. |
| 1034 | |
| 1035 | |
| 1036 | self.saying |
| 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 | |
| 1044 | |
| 1045 | mut self, |
| 1046 | kind: Notice, |
| 1047 | tone: Tone, |
| 1048 | text: impl , |
| 1049 | |
| 1050 | self.notice = Some |
| 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 | |
| 1067 | |
| 1068 | if let Some = &mut self.notice |
| 1069 | notice.undo = Some; |
| 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 | |
| 1085 | |
| 1086 | self.invalidates.push |
| 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 | |
| 1100 | |
| 1101 | self.address = Some; |
| 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 | |
| 1110 | |
| 1111 | self.address = Some; |
| 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 | |
| 1120 | |
| 1121 | self.address = Some; |
| 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 | |
| 1130 | |
| 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 | Screen |
| 1145 | | Goto |
| 1146 | | Over |
| 1147 | | 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 | | Suggestions |
| 1151 | | File |
| 1152 | | 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 | Fragment | Started => Some, |
| 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 | |
| 1165 | |
| 1166 | match &self.outcome |
| 1167 | Goto => Some, |
| 1168 | // An overlay sends the user nowhere: dismissing it reveals the |
| 1169 | // screen they never left. |
| 1170 | Screen |
| 1171 | | Fragment |
| 1172 | | Over |
| 1173 | | Anchored |
| 1174 | | Suggestions |
| 1175 | | 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 | | 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 | | Started => None, |
| 1184 | |
| 1185 | |
| 1186 | |
| 1187 | |
| 1188 | |
| 1189 | |
| 1190 | Self |
| 1191 | outcome, |
| 1192 | notice: None, |
| 1193 | address: None, |
| 1194 | invalidates: Vecnew, |
| 1195 | |
| 1196 | |
| 1197 | |
| 1198 | |
| 1199 | |
| 1200 | |
| 1201 | Selfscreen |
| 1202 | |
| 1203 | |
| 1204 | |
| 1205 | |
| 1206 | |
| 1207 | use *; |
| 1208 | use crate; |
| 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 | |
| 1215 | |
| 1216 | let answer = suggestions; |
| 1217 | assert_eq!; |
| 1218 | assert_eq!; |
| 1219 | let Suggestions = &answer.outcome else |
| 1220 | panic!; |
| 1221 | ; |
| 1222 | assert_eq!; |
| 1223 | assert_eq!; |
| 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 | |
| 1230 | |
| 1231 | let answer = file |
| 1232 | "goingson-export.json", |
| 1233 | media_type, |
| 1234 | b"{}".to_vec, |
| 1235 | ; |
| 1236 | assert_eq!; |
| 1237 | assert_eq!; |
| 1238 | let File = &answer.outcome else |
| 1239 | panic!; |
| 1240 | ; |
| 1241 | assert_eq!; |
| 1242 | assert_eq!; |
| 1243 | assert_eq!; |
| 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 | |
| 1250 | |
| 1251 | let answer = locate |
| 1252 | "Export destination", |
| 1253 | post, |
| 1254 | "handle", |
| 1255 | ; |
| 1256 | assert_eq!; |
| 1257 | assert_eq!; |
| 1258 | let Locate = &answer.outcome else |
| 1259 | panic!; |
| 1260 | ; |
| 1261 | assert_eq!; |
| 1262 | assert_eq!; |
| 1263 | assert_eq!; |
| 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 | |
| 1270 | |
| 1271 | let asking = folder; |
| 1272 | let call = asking |
| 1273 | .answered |
| 1274 | .expect; |
| 1275 | assert_eq!; |
| 1276 | assert_eq!; |
| 1277 | assert_eq!; |
| 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!; |
| 1281 | assert!; |
| 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 | |
| 1287 | |
| 1288 | let asking = folder |
| 1289 | "Export destination", |
| 1290 | post, |
| 1291 | "handle", |
| 1292 | |
| 1293 | .showing; |
| 1294 | let call = asking |
| 1295 | .answered |
| 1296 | .expect; |
| 1297 | assert_eq!; |
| 1298 | assert_eq!; |
| 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 | |
| 1305 | |
| 1306 | let asking = new |
| 1307 | Files |
| 1308 | accept: vec!, |
| 1309 | , |
| 1310 | "Import files", |
| 1311 | get, |
| 1312 | "picked", |
| 1313 | ; |
| 1314 | let call = asking |
| 1315 | .answered |
| 1316 | .expect; |
| 1317 | assert_eq!; |
| 1318 | assert!; |
| 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 | |
| 1326 | |
| 1327 | let asking = new |
| 1328 | Files |
| 1329 | accept: vec!, |
| 1330 | , |
| 1331 | "Import files", |
| 1332 | post, |
| 1333 | "path", |
| 1334 | ; |
| 1335 | let call = asking |
| 1336 | .answered |
| 1337 | new, |
| 1338 | new, |
| 1339 | new, |
| 1340 | ] |
| 1341 | .expect; |
| 1342 | assert_eq!; |
| 1343 | assert_eq! |
| 1344 | call.payload.get_all., |
| 1345 | |
| 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 | |
| 1352 | |
| 1353 | let asking = new |
| 1354 | Files , |
| 1355 | "Locate missing sample files", |
| 1356 | post, |
| 1357 | "path", |
| 1358 | |
| 1359 | .showing; |
| 1360 | let call = asking |
| 1361 | .answered |
| 1362 | new, |
| 1363 | new, |
| 1364 | ] |
| 1365 | .expect; |
| 1366 | let handles: = call.payload.get_all.collect; |
| 1367 | let labels: = call.payload.get_all.collect; |
| 1368 | assert_eq!; |
| 1369 | assert_eq!; |
| 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 | |
| 1376 | |
| 1377 | let asking = new |
| 1378 | Files , |
| 1379 | "Import files", |
| 1380 | post, |
| 1381 | "path", |
| 1382 | ; |
| 1383 | assert!; |
| 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 | |
| 1390 | |
| 1391 | let asking = new |
| 1392 | Save |
| 1393 | name: "drums-2026-08-25.afcl".into, |
| 1394 | accept: vec!, |
| 1395 | , |
| 1396 | "Export classifier", |
| 1397 | post, |
| 1398 | "path", |
| 1399 | ; |
| 1400 | let answer = locate; |
| 1401 | assert_eq!; |
| 1402 | assert_eq!; |
| 1403 | let Save = &asking.sought else |
| 1404 | panic!; |
| 1405 | ; |
| 1406 | assert_eq!; |
| 1407 | assert_eq!; |
| 1408 | |
| 1409 | let call = asking |
| 1410 | .answered |
| 1411 | .expect; |
| 1412 | assert_eq!; |
| 1413 | assert_eq!; |
| 1414 | assert_eq! |
| 1415 | call.payload.get, |
| 1416 | Some |
| 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 | |
| 1424 | |
| 1425 | assert_eq! |
| 1426 | safe_file_name, |
| 1427 | "ssh_authorized_keys" |
| 1428 | ; |
| 1429 | assert_eq! |
| 1430 | safe_file_name, |
| 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 | |
| 1438 | |
| 1439 | let asking = folder |
| 1440 | "Somewhere else", |
| 1441 | external, |
| 1442 | "handle", |
| 1443 | ; |
| 1444 | assert!; |
| 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 | |
| 1450 | |
| 1451 | let answer = file |
| 1452 | .toast; |
| 1453 | assert_eq! |
| 1454 | answer.notice.as_ref.map, |
| 1455 | Some |
| 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 | |
| 1463 | |
| 1464 | use crateRegionKind; |
| 1465 | |
| 1466 | for anchor in |
| 1467 | Region, |
| 1468 | Selection, |
| 1469 | Control, |
| 1470 | ] |
| 1471 | let screen = |
| 1472 | sidebar_content.with; |
| 1473 | let answer = anchored; |
| 1474 | |
| 1475 | assert_eq!; |
| 1476 | assert_eq!; |
| 1477 | let Anchored = &answer.outcome else |
| 1478 | panic!; |
| 1479 | ; |
| 1480 | assert_eq!; |
| 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 | |
| 1488 | |
| 1489 | use crate; |
| 1490 | |
| 1491 | let bare = new; |
| 1492 | assert_eq!; |
| 1493 | assert_eq!; |
| 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; |
| 1498 | assert_eq!; |
| 1499 | assert_eq!; |
| 1500 | |
| 1501 | |
| 1502 | /// What every renderer asks before it decides where to draw. One walk here |
| 1503 | /// rather than three that can disagree. |
| 1504 | |
| 1505 | |
| 1506 | use crate; |
| 1507 | |
| 1508 | let screen = sidebar_content.with |
| 1509 | new |
| 1510 | .with |
| 1511 | .with, |
| 1512 | ; |
| 1513 | |
| 1514 | assert!; |
| 1515 | // At any depth, matching `Screen::slot`. |
| 1516 | assert!; |
| 1517 | assert!; |
| 1518 | |
| 1519 | // Naming what is not there is a description bug, and every renderer |
| 1520 | // degrades on this answer rather than refusing. |
| 1521 | assert!; |
| 1522 | assert!; |
| 1523 | // No selection on this screen, so nothing to anchor to. |
| 1524 | assert!; |
| 1525 | assert! |
| 1526 | screen |
| 1527 | .clone |
| 1528 | .selecting |
| 1529 | .anchors |
| 1530 | ; |
| 1531 | |
| 1532 | |
| 1533 |