//! What a route answers with, what it says should be replaced, and what it says //! to the user on the way. //! //! Decision 7 on the wiki note. A response names the region it replaces, because //! the router is the only party that knows what it just changed, so it is the //! party that should say. //! //! The webview maps a fragment onto `hx-target` and `hx-swap`, which is the //! thing htmx exists to do, and it is the reason a full-body swap per action is //! not the design: list screens are exactly where losing scroll and focus //! hurts. egui and the terminal ignore the target and redraw everything, which //! costs them nothing because they were redrawing anyway. //! //! The rejected alternative was one return type plus a renderer diffing markup //! against the DOM. That is a virtual DOM, and htmx was chosen to avoid one. //! //! # Why this is a struct and not one enum //! //! Neither is content, so neither is a [`Screen`] or a //! [`Fragment`](Outcome::Fragment). //! //! Filed separately they both read as new enum members, and that shape is wrong //! because the two compose. Deleting the thing a screen is about goes somewhere //! else *and* says it is gone. A save that fails on something no field can carry //! stays where it is *and* says why. One member cannot be two members, so //! [`Outcome`] holds the three ways to answer with content and the notice sits //! beside it, optional, orthogonal to all three. //! //! [`invalidates`](Response::invalidates) is the third arrival and the one that //! settles the shape: a write that changes a row *and* the count above it //! composes with all three outcomes and with the notice, so it is a fourth //! field rather than a fourth member. Had this stayed an enum it would have //! needed a member per combination. //! //! # Why [`Goto`](Outcome::Goto) takes an [`Action`] and not a [`Destination`] //! //! A redirect has params: back to a list with a filter still applied, back to a //! project on the tab you were reading. A bare address drops them and the app //! rebuilds a query string by hand, which is what [`Action::params`] exists to //! prevent. //! //! [`Action::method`] is meaningless here in the same way it is meaningless for //! a [`Destination::External`], and is left alone for the reason given there: a //! method that is ignored is simpler than two shapes of action. //! //! # A described route answers one [`Outcome`], and assumes a client that runs JS //! //! A route answers once. It does not answer one way for a client that arrived //! with htmx and another way for a client that brought no JS at all. This is a //! property of the vocabulary rather than a limitation of any host: a //! description says what changed, and something has to be running on the other //! end to apply that to part of a page. Progressive enhancement stays an //! Askama concern, in the templates a conversion has not reached. //! //! The position is a promise about who a described surface serves, so the //! evidence behind it travels with it. Measured across the MNW server: 72 //! sites branch on whether the request came from htmx, over 23 files, 56 of //! them under `src/routes/api/`. They are three shapes, and only the first is //! progressive enhancement. //! //! 1. **Re-render the whole page with the user's input preserved.** Three route //! files: `src/routes/auth.rs` (login), `pages/email_actions/password.rs` //! (password reset, with the emailed token intact), and //! `pages/public/join_wizard.rs` (username and email preserved). All three //! are public or auth routes. None is under `routes/pages/dashboard/`. //! 2. **Degrade to the error page.** htmx gets a toast or an inline status, a //! plain request gets the error. The dominant shape by far, and the one every //! dashboard site uses, all of them through a single `wizard_validation_toast` //! helper. There is no full-page re-render anywhere in the dashboard. //! 3. **Redirect or hand back a file.** `HX-Redirect` against a plain redirect, //! or a CSV or JSON download. Not a question about assembling a page. //! //! Every route doing real progressive enhancement is public, and the public tier //! stays in Askama, so the position costs nothing where conversion is planned. //! //! The consequence, stated so a later reader does not have to find it: describing //! a **public** page reopens this. A described public page is unreachable without //! JS. That is a product decision to make deliberately on the day it comes up, //! not a bug to file against the router. //! //! MNW's `src/fragment_redirect.rs` is a separate mechanism, sending a direct //! navigation to a fragment endpoint back to its parent page. It is not one of //! the 72 and does not bear on this. //! //! [`Destination`]: crate::Destination //! [`Destination::External`]: crate::Destination::External //! [`Action::method`]: crate::Action::method //! [`Action::params`]: crate::Action::params use makeover_layout as layout; use crate::request::Request; use crate::screen::{Accepted, Action, Candidate, Node, Screen}; /// What a route answered with. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Response { /// The content, or the address to go to instead of content. pub outcome: Outcome, /// What to tell the user, if anything. Independent of the outcome. pub notice: Option, /// Whether this answer is a place, when the derivation cannot tell. /// /// `None` on almost every response, and that is the design. A host derives /// the common cases from what it already has — a read of a route is a /// place, a write and a fragment are not — so a control never has to /// predict what its answer will be. See [`Address`]. pub address: Option
, /// The other slots this answer changed, beyond the one it replaced. /// /// Empty on almost every response. See [`Invalidated`], and [`also`] for /// the way to add one. /// /// [`also`]: Self::also pub invalidates: Vec, } /// A slot this answer changed without being aimed at it. /// /// The row you edited is the [`Outcome`]; the count in the header is one of /// these. Both are named by [`Slot::id`](crate::Slot::id), because a slot id is /// the address a description already uses for a region and there is no reason /// for a second naming scheme. /// /// # Why this carries a node and not just an id /// /// A renderer told only that something is stale has two ways to act on it, and /// both are worse. It can ask again, which is a second round trip for a fact /// the router had in hand. Or it can re-derive the region, which means the /// router's view logic runs twice per write and the two runs have to agree. /// Handing over the new contents makes an invalidation the same shape as a /// fragment, which is what it is: one region and what now goes in it. /// /// # What each renderer does with it /// /// A webview swaps it out of band, so the row and the header both move on one /// response. A terminal redraws that panel. An egui frame does nothing, /// because it was going to redraw everything anyway. That spread is the reason /// this says "invalidated" rather than naming a swap: a swap is a DOM idea and /// two of the three renderers have no answer for it. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Invalidated { /// The [`Slot::id`](crate::Slot::id) whose contents are now stale. pub region: String, /// What goes in it instead. pub node: Node, } /// Whether an answer is somewhere the user can come back to. /// /// Decision 7's argument, applied to history: the response says it, because the /// router is the only party that knows what it just did. The alternative was a /// flag on [`Action`], decided when the control is rendered, which asks the /// control to predict the answer — and the MNW server has 24 hand-written /// `hx-push-url` uses across 13 files showing how that drifts. /// /// This is the override and not the mechanism. The host derives history from /// the request it is answering, and this is for the two cases derivation cannot /// reach: a fragment that *is* a place (an addressable tab panel, of which the /// server has 32), and a screen that is not (a transient state that should not /// come back on the back button). #[derive(Debug, Clone, PartialEq, Eq)] pub enum Address { /// A new place. This URL enters history. Enters(String), /// A place, replacing the current entry rather than adding one. Replaces(String), /// Not a place. Nothing in the address bar moves. Unchanged, } /// The content half of an answer. /// /// [`Goto`](Self::Goto) is not content and sits here anyway, because the three /// are exclusive: a response replaces a screen, or replaces a region, or sends /// the user elsewhere, and never two of those. /// /// # Deliberately not `#[non_exhaustive]` /// /// [`RowPart`](layout::RowPart) took it, and this is the opposite case. A row /// part a renderer does not know can be skipped, and the row is still a row. An /// outcome a host does not know is a request that silently does nothing, and /// `#[non_exhaustive]` is what makes that compile: every adapter grows a /// wildcard arm with nothing sensible to put in it, and a new member reaches /// each of them as a fallback rather than as an error. /// /// So a member added here breaks every host on purpose, which is the point. /// Growing [`Response`] itself stays cheap, because it is a struct. /// /// # Its variants are not the same size, and that is accepted /// /// [`Fragment`](Self::Fragment) carries a whole [`Node`] and /// [`Screen`](Self::Screen) carries a [`Screen`], so the enum is as large as /// the bigger of the two and every outcome pays for it. Crossing clippy's /// threshold was makeover-layout 0.32.0 adding a curve to a field, which is to /// say the margin was already thin. /// /// Boxing the node would fix the ratio and is not obviously an improvement: it /// moves an allocation into every fragment response to save stack on a value /// that is built once per request and consumed immediately, and it is a /// breaking change to every `match` on this enum in every host adapter. Revisit /// if an outcome is ever held in a collection, which is where the size would /// start to be paid more than once. /// /// There was an `#[expect(clippy::large_enum_variant)]` here, and it came off /// when the spread closed rather than because the argument changed. Measured on /// x86-64: the enum is 544 bytes, [`Fragment`](Self::Fragment) is all of it /// through [`Node`], and [`Screen`](Self::Screen) is 360. That is 184 bytes of /// spread, inside the 200 clippy wants, so the lint does not fire and an /// expectation for it is itself a warning. Put it back when the spread opens /// again; the paragraphs above are why it would be an expectation rather than a /// box. /// /// Boxing is still refused, and boxing *the picture* rather than the node is /// refused for a second reason: a control that shows a picture holds one, and /// an `Option>` puts an allocation in the vocabulary's public /// shape to save stack on a value built once per request and consumed /// immediately. #[derive(Debug, Clone, PartialEq, Eq)] pub enum Outcome { /// The whole screen. A navigation, or an action whose effect is not /// contained by one region. Screen(Screen), /// One region's new contents. Fragment { /// The [`Slot::id`](crate::Slot::id) being replaced. region: String, /// What goes in it. node: Node, }, /// Somewhere else. No content, because the destination will answer. /// /// A webview sends a 303 or an `HX-Location`, a terminal pushes a screen, /// egui sets its route. An [`External`](crate::Destination::External) /// destination hands off to the host and nothing comes back, which is what /// opening a file or a mail client is. Goto(Action), /// A screen drawn OVER what is under it, rather than replacing it. /// /// The command palette, the help overlay, an app-modal dialog. Dismissing /// it reveals what was already there, so it is not a navigation and does /// not touch history — which is the whole of what distinguishes it from /// [`Goto`](Self::Goto). /// /// It is a [`Screen`] like any other and needs no second description tree: /// what was missing was never the contents but the way to say "drawn over". /// The way in is usually a [`Chrome`](crate::Chrome) binding, since an /// affordance available from everywhere is what an overlay normally is. /// Usually and not always: a control on one screen can call a route that /// answers this, which is how goingson opens a focus countdown from a row. /// /// **An app answering this declares chrome, even when it declares no /// binding.** A renderer draws the overlay into a container it emits once /// per document, and it emits that container for an app that declares /// chrome. An app that declares none has nowhere to put the answer, and /// what that looks like is a swap that does nothing rather than an error. /// /// Not [`RegionKind::Modal`](crate::RegionKind::Modal), which is a modal a /// screen *contains* and goes when that screen goes. This one belongs to /// the app and outlives any one screen. Over(Screen), /// A screen drawn at a described point on the one under it. /// /// A context menu, a popover, the verbs over a selection: screens that /// belong to the thing they opened at rather than to the app. /// [`Over`](Self::Over) is the app-modal one — it outlives any one screen /// and is drawn over the whole of what is under it — and describing a /// popover as one is what the four measured audiofiles sites were doing. /// /// It is a [`Screen`] like [`Over`](Self::Over) is, for the same reason: /// what was missing is never the contents. Dismissal matches /// [`Over`](Self::Over) too — it reveals what was under it, touches no /// history, and is not a place. /// /// # A row already had this and needed no outcome /// /// [`Row::menu`](crate::Row::menu) is a menu anchored to a row, described /// on the row and drawn beside it by every renderer. That covers a row and /// covers nothing else, which is the hole this fills: audiofiles' selection /// menu acts on the ticked set and its empty-space menu on the region, and /// neither is a row to hang a member off. Anchored { /// What is drawn. screen: Screen, /// What it is drawn at. anchor: Anchor, }, /// One field's suggestion list, answering the question that field owns. /// /// [`Field::suggests`](crate::Field::suggests) is the other half: a field /// that owns a list of candidates asks a route for them as the value is /// typed, and this is what the route answers. /// /// # Why this one is described where a [`Consult`](crate::Consult)'s answer /// # is not /// /// An ordinary consult answers a region a description already named, so /// what comes back can be markup for a webview and values for a terminal /// without the route knowing which asked. A suggestion list is not a /// region: it belongs to a control, every renderer draws it in its own /// idiom — a listbox under an input, a popup under a terminal field, a /// dropdown in an egui frame — and a picked entry writes a value back into /// the field. None of that is derivable from markup, so the answer is the /// vocabulary's own currency and each renderer draws it. /// /// [`Candidate`] and not [`Choice`]. A candidate is submitted under one /// string and read under another, which an option is too, and that is not /// the half that differs. **An option and a candidate are submitted the same /// way and read differently.** An option /// is picked out of a set the user can see whole; a candidate is offered /// out of a set nobody can see, so it has to carry what tells it from a row /// that reads alike, and it may carry what picking it does. Both measured /// sites draw that second string today, by hand, in a second span. /// /// A route with nothing to suggest answers an empty list, which every /// renderer draws as no list at all rather than as an empty box. Suggestions { /// The [`Field::name`](crate::Field::name) whose list this is. /// /// The field's own name and not a second id, which is the whole of what /// "the field owns the list" buys. A renderer needing a document id /// derives one from this. /// /// A name no field on the screen carries is a description bug and is /// treated as one everywhere else it can happen: the answer lands /// nowhere and the screen still draws. field: String, /// The candidates, in the order they are offered. options: Vec, }, /// A file the viewer keeps, at a destination the host chooses. /// /// The route answers with the file; the host puts it somewhere. **The /// description never names a path.** Tauri opens a save dialog, a browser /// downloads, a terminal writes to the working directory. One description, /// the same reading on every host. /// /// Not the way to ask the reader to NAME a file. This member has the file /// already and is handing it over; a route that wants a destination first, /// with a suggestion in the dialog, asks for /// [`Sought::Save`](Sought::Save) through [`Locate`](Self::Locate). The /// save dialog named above is how a host performs this one, not a way to /// choose where it lands. /// /// # Why the destination is not in the request /// /// It is the exact mirror of the upload ruling: the destination is opaque. /// A description says what may be uploaded /// ([`Field::accept`](crate::Field::accept)), how many /// ([`Field::multiple`](crate::Field::multiple)) and that it reports /// progress, and never where it lands. A save destination is that same /// fact in the other direction and gets the same answer. andcut the same /// way. /// /// # The payload goes through memory /// /// Stated rather than hidden. That is fine for a task database and would /// not be fine for a media library. A streaming variant is a later member /// if a measured site ever needs one; do not pre-build it. File { /// The suggested file name, suffix included: `goingson-2026-08-21.json`. /// /// Suggested and not chosen. A save dialog offers it, a browser puts it /// in `Content-Disposition`, a terminal writes it beside the process. /// A host that already has a name from the user keeps theirs. name: String, /// What kind of file it is. /// /// [`Accepted`], the same type the upload half uses, rather than a /// second way to name a file kind. A [`Type`](Accepted::Type) is the /// one spelling a host can put on the wire as a media type; a /// [`Family`](Accepted::Family) or a [`Suffix`](Accepted::Suffix) says /// less, and a host that needs a media type falls back to /// `application/octet-stream` rather than guessing one from a name. kind: Accepted, /// The file. bytes: Vec, }, /// A place for the app to write into, chosen by the host. /// /// The route says a place is wanted, the host performs the picker, and /// what comes back is every opaque handle it chose, each with a label to /// show. **The description never learns what the host did**, which is the /// same bargain [`File`](Self::File) strikes from the other end: that one /// says what the file is and never where it goes, this one asks for a /// somewhere and never asks what it is. /// /// # Why an outcome and not a field kind /// /// A control-side place picker covers a form and covers nothing else. Half /// the measured sites are the *act* — the four import doors and Locate /// missing files, where picking the folder is the whole of what the reader /// asked for and no form is on screen to hold it. One member reaches both: /// an act answers this and the work runs, a form answers this and the /// handle lands back in the form's own state. /// /// # The bytes are not here, and that is the point /// /// [`File`](Self::File) is one payload that exists when the route answers. /// This is a destination chosen before there is anything to put in it: the /// export picks a folder and then writes hundreds of files into it over a /// long operation it reports progress on. A route is `fn(&S, Request)` and /// sync, so it can neither open a dialog nor stream — which is why the ask /// leaves as an answer and the picking happens outside. Locate(Locating), /// The work was handed off. It is running, and nothing is here yet. /// /// A [`Handler`](crate::Handler) is `fn(&S, Request) -> Result` and stays that way, so a write that takes seconds — an /// export, a large import, anything that walks a database or the network — /// cannot be performed inside one without freezing the host that called /// it. The app offloads it, as goingson already did; this is the word for /// the fact that it happened. /// /// # It is the other end of a channel that already exists /// /// Nothing new says the work finished. [`Slot::live`](crate::Slot::live), /// [`Slot::fed_by`](crate::Slot::fed_by) and /// [`Screen::refreshes`](crate::Screen::refreshes) already say "this /// region's contents change without the user, re-ask this route on the /// renderer's cadence", and every renderer implements it. So a region that /// answers this is a region the description already declared live, and what /// lands when the work is done is an ordinary /// [`Fragment`](Self::Fragment) — which is what takes the region back out /// of [`Pending`](layout::Readiness::Pending), by /// [`Screen::replace`](crate::Screen::replace). /// /// A region that declared neither is a description bug of the quiet kind: /// it will say it started and never say anything else. Nothing here can /// catch that, because the region is on a screen this answer does not /// carry. /// /// # Why not simply a fragment saying "working…" /// /// That is what the sites did before there was a word, and it loses the /// axis. [`Readiness`](layout::Readiness) is what a renderer draws its wait /// with — the terminal's "Loading", egui's proportion, the webview's /// `aria-busy` — and a fragment arriving sets it to /// [`Ready`](layout::Readiness::Ready) by definition. A screen cannot then /// tell "this did nothing" from "this started something", which is the /// distinction the vocabulary exists to make sayable. /// /// # What it does not mean /// /// Not progress. Nothing here counts anything, and a route that can count /// says so the ordinary way: the region is fed by an /// [`awaiting`](Action::awaiting) action, and each renderer draws the /// proportion it already knows how to draw. /// /// Not a promise of completion either. If the work fails, what says so is /// the next thing the region is told, the same as for work that succeeded. Started { /// The [`Slot::id`](crate::Slot::id) the work will fill. /// /// A region and not a screen, because the rest of the screen is still /// true: the reader pressed one control and everything they were /// looking at is still there. Named the way /// [`Fragment`](Self::Fragment) names one, and a region that is not /// there is treated the way a fragment's missing region is. region: String, /// What stands there while it runs. "Creating backup…" /// /// A sentence rather than a node, unlike [`Fragment`](Self::Fragment). /// What is being described is a wait, and every renderer already has /// its own way of drawing one; handing it a tree to draw instead would /// be the description deciding how a host shows waiting, which is the /// one thing this vocabulary does not do. A host that draws its wait /// without words is free to ignore it. message: String, }, } /// What an [`Outcome::Anchored`] is drawn at. /// /// **A described thing, never a point.** That is `600c9e42`'s ruling and it is /// the whole shape of this type: a description says what is on the screen and /// never where, so the anchor names something the description already carries /// and each renderer resolves it with geometry it already owns. /// `quasi_immediate::geometry` is that ruling shipped — it is where this host /// keeps its rects, beside the drawing rather than in the vocabulary. /// /// Three members, one per measured subject. A fourth arrives when a site wants /// one, on the rule every other member here arrived under. #[derive(Debug, Clone, PartialEq, Eq)] pub enum Anchor { /// A region, by [`Slot::id`](crate::Slot::id). /// /// audiofiles' empty-space menu: a press on the part of the browser that is /// not a row, offering what can be done to the region rather than to /// anything in it. Region(String), /// The screen's selection, [`Screen::selection`](crate::Screen::selection). /// /// audiofiles' multi-select menu. The subject is the ticked set, so the /// anchor names it the way [`Act::over`](crate::Act::over) does: by being /// set at all. A screen holds one selection, so there is nothing to name. /// /// A renderer draws it where the set is — near the last ticked row, beside /// the commit run, wherever that host puts it — which is the same latitude /// every anchor gets. Selection, /// A control, by [`Act::id`](crate::Act::id). /// /// The two measured popovers, where pressing a button opens a small screen /// belonging to that button. /// /// # Why the control and not what it calls /// /// Anchoring by the action's destination was the alternative and is /// rejected: it makes the anchor an accident of routing, and two controls /// calling one route would be indistinguishable. So [`Act`](crate::Act) /// gained an id for this, defaulting to `None` — a control nothing anchors /// to needs no name. Control(String), } /// What the host is being asked to find. /// /// Four members because the mechanism has four, measured rather than guessed: /// `audiofiles/crates/audiofiles-browser/src/ui/dialog.rs` carries `PickFolder`, /// `PickFile`, `PickFiles` and `SaveFile`. A host maps each onto the picker it /// already has. /// /// Multiplicity is a member rather than a flag, unlike /// [`Field::multiple`](crate::Field::multiple). A field is one question that may /// take more than one answer; these are four different things to ask an /// operating system for, and every host has to branch on which anyway. /// /// See [`Save`](Self::Save): a save dialog and a pick dialog are the same /// dialog on the host and opposite ends of the sentence here, and reading one /// as the other loses the file the reader asked to write. #[derive(Debug, Clone, PartialEq, Eq)] pub enum Sought { /// A folder, which the app then writes into repeatedly. /// /// Five of the seven measured sites. The reader chooses once and the app /// keeps writing there, which is the whole of what made this unsayable: a /// folder outlives the answer that asked for it. Folder, /// One file, for the app to read. File { /// What the picker offers, empty to mean anything. /// /// [`Accepted`] and not a second way of naming a file kind, for /// [`Outcome::File::kind`]'s reason: the upload half already has this /// type and a picker filter is the same fact said to a dialog. accept: Vec, }, /// Several files at once. /// /// One ask, one answer: every file the reader picked comes back in a single /// call, because [`Locating::answered`] takes all of them together. That is /// `8a246c02` and it is the member the ruling was about; see [`Picked`] /// for what one call per file costs an import. Files { /// What the picker offers, empty to mean anything. accept: Vec, }, /// Somewhere to write one file, named by the reader before it is there. /// /// The save dialog: the reader is naming a file rather than choosing one /// that already exists, and the app writes it afterwards. audiofiles' /// classifier export is the measured site, offering `{export_name}.afcl` /// and letting the reader change it. /// /// # Why this is not [`Outcome::File`] said differently /// /// They are one dialog on the host and opposite ends of the sentence here, /// and it is [`Outcome::Locate`]'s "the bytes are not here" split again. /// [`Outcome::File`] is a payload that exists when the route answers: the /// app says what the file is, the host puts it wherever it puts downloads, /// and the [`name`](Outcome::File::name) it suggests is the app's. This is /// a destination chosen while there is nothing to put in it yet, and the /// name that comes back is the reader's. /// /// # Why it is not a folder plus a name the app picks /// /// Because that is the workaround the ruling refused. A /// [`Folder`](Self::Folder) ask plus a file name the app appends works and /// loses the reason the dialog was opened: the reader names the export. /// /// # What a host that cannot save does /// /// The same as for every other member, and the same rule /// [`Outcome::Locate`] states: refuse where it can be seen. `quasi-http` /// answers 501 with a notice, because a browser can offer a download and /// cannot hand back a destination the app may write into later. A host /// with no dialog but somewhere sensible to write, a terminal with a /// working directory, answers with a path built from /// [`name`](Self::Save::name) through [`safe_file_name`] and says where it /// put it. What none of them may do is stay quiet: the ask came from a /// control the reader pressed. Save { /// The suggested file name, suffix included: `drums-2026-08-25.afcl`. /// /// Suggested and not chosen, the same word [`Outcome::File::name`] /// uses for the same fact. A dialog offers it and the reader may type /// over it; a host writing without asking runs it through /// [`safe_file_name`] first, because it is frequently built from /// something the reader typed earlier. name: String, /// What the picker offers, empty to mean anything. /// /// [`Accepted`] and not a second way of naming a file kind, for /// [`File`](Self::File)'s reason. On this member it is also what a /// dialog appends when the reader types a name with no suffix, where /// the host does that. accept: Vec, }, } /// One thing the reader picked, and what to call it on screen. /// /// The answer half of the picker, and the reason it is a type at all is that /// there can be more than one of it: /// [`Locating::answered`] takes every pick and builds **one** call, so a /// [`Sought::Files`] ask reaches its route once with all the files rather than /// once per file. /// /// # What one call per file cost /// /// [`answered`](Locating::answered) took a single handle until this arrived, /// and every host half wrote the loop that follows from that. audiofiles' Import /// files door hands every picked path to one `start_files_import(&paths, /// strategy)`, and that batch is a deliberate fix: it keeps the hashing on the /// worker instead of the GUI thread. N single-file imports land every file and /// still regress the thing the batch was for, which is why this is a change to /// the vocabulary rather than a host accumulating answers of its own. /// /// The host-side workaround the ruling refused was a spelling for several paths /// in one handle (a separator, a joined string), which is one host inventing a /// convention every other host would then have to know. /// /// # Why a pair and not two lists /// /// So that a host cannot hand over a handle and somebody else's label. The /// parameters they arrive under are still two names ([`under`] and /// [`labelled`]), and they stay in step because they are written out of the /// same pick: the *n*th [`Params::get_all`](crate::Params::get_all) of one is /// the *n*th of the other. /// /// [`under`]: Locating::under /// [`labelled`]: Locating::labelled #[derive(Debug, Clone, PartialEq, Eq)] pub struct Picked { /// What the host chose, in the host's own spelling. /// /// A path on a desktop, whatever a picker hands back elsewhere. Opaque /// here: this crate never parses it, joins it or checks it, because the /// only party that can read it is the app that asked. pub handle: String, /// The same thing in the reader's words, for a screen to show. /// /// Dropped unless [`Locating::labelled`] names a parameter for it, so a /// host may always pass what it has and never has to ask whether the /// description wanted it. pub label: String, } impl Picked { /// A handle and the label to show for it. #[must_use] pub fn new(handle: impl Into, label: impl Into) -> Self { Self { handle: handle.into(), label: label.into(), } } } /// A place a route asked for, and where the answer goes. /// /// [`Outcome::Locate`]'s payload, and a struct rather than five inline members /// because the renderers hand it to their hosts whole: `quasi-immediate` and /// `quasi-tui` each drain one of these, the way they drain a file. That is the /// one difference from their `Handed`, which each /// renderer declares for itself — a file is host-side data and needs sanitising /// per host, and an ask is description data that every host reads the same. /// /// # The names are stated, not agreed /// /// [`under`](Self::under) and [`labelled`](Self::labelled) say which parameters /// the answer arrives under, for [`FieldKind::Interval`][interval]'s reason: a /// convention this crate invented would rename somebody's parameter, and the /// two ends of the tree already disagree about affix order. A host never builds /// the call by hand either — [`answered`](Self::answered) does, so the names /// stay inside the crate that stated them. /// /// [interval]: makeover_layout::FieldKind::Interval #[derive(Debug, Clone, PartialEq, Eq)] pub struct Locating { /// What to find. pub sought: Sought, /// What the picker is for, in the reader's words. /// /// A dialog title on every host that has one: "Import folder", "Export /// destination", "Locate missing sample files" are the shipped three. A /// host with no title to set drops it rather than drawing it somewhere of /// its own choosing. pub prompt: String, /// The route the answer goes back to. /// /// The act shape points this at the work — picking the folder *is* the /// import, so the call that lands does the importing. The form shape points /// it at the route that stashes the handle and answers with the region /// redrawn, showing the label beside the Browse control. pub answers: Action, /// The name the handle is sent under. /// /// One name however many handles come back: a [`Sought::Files`] ask /// answered with three files sends the name three times, in pick order, and /// the route reads them with /// [`Params::get_all`](crate::Params::get_all). That is what [`Params`] is /// a list of pairs for, and it is the same shape a checkbox group already /// submits. /// /// [`Params`]: crate::Params pub under: String, /// The name the label is sent under, when the route wants it. /// /// `None` on the act shape, which is the majority: an import door has /// nowhere to show a label and no reason to carry one. `Some` on the form /// shape, which displays the destination back to the reader — /// `ui/export_screens.rs:264-274` draws the path beside the button, and is /// why the label comes back beside the handle rather than the handle alone. /// /// Repeats with [`under`](Self::under) and stays in step with it, one label /// per [`Picked`]. pub labelled: Option, } impl Locating { /// Ask for a folder. #[must_use] pub fn folder(prompt: impl Into, answers: Action, under: impl Into) -> Self { Self::new(Sought::Folder, prompt, answers, under) } /// Ask for whatever this is, answering to that route under that name. #[must_use] pub fn new( sought: Sought, prompt: impl Into, answers: Action, under: impl Into, ) -> Self { Self { sought, prompt: prompt.into(), answers, under: under.into(), labelled: None, } } /// Send the label back too, under this name. /// /// What the form shape adds. Chaining rather than an argument for /// [`Field::upload`](crate::Field::upload)'s converse reason: a missing /// accept list is a real choice and has to be argued, and a route with /// nothing to show a label on is the common case. /// /// # It must differ from [`under`](Self::under) /// /// Handle and label go on under their own names, so giving both the same /// name interleaves them: `get_all(under)` then yields handle, label, /// handle, label and the route reads every second value as a path. Caught /// here in debug rather than left to look like a picker that answers /// twice. #[must_use] pub fn showing(mut self, labelled: impl Into) -> Self { let labelled = labelled.into(); debug_assert_ne!( labelled, self.under, "a Locating's label name and handle name must differ, or one ask answers both under \ the same key and every second value reads as a handle", ); self.labelled = Some(labelled); self } /// The call the host makes once the reader has picked. /// /// Built here so that no host writes the parameter names itself. Each /// [`Picked`] is put on through [`Action::with`](crate::Action::with), so /// the values land in the bag that action's method says they land in — the /// payload of a write, the address of a read — and a route reads them where /// it reads everything else. /// /// [`label`](Picked::label) is dropped when [`labelled`](Self::labelled) is /// `None`, so a host may pass what it has and never has to ask whether it is /// wanted. /// /// # One call, however many were picked /// /// This takes every pick rather than one, and there is no second method /// that takes one: a host with three files calls this once with three /// [`Picked`]s, and the route reads them with /// [`Params::get_all`](crate::Params::get_all) under [`under`](Self::under). /// Handing the host a single-handle door is what produced the loop this /// replaces, so the door is gone rather than documented against. /// /// A route that would rather work one at a time still can, by iterating /// what it was sent. A route that needs the batch cannot get it back from N /// calls, which is the asymmetry that decides the shape. /// /// # `None`, twice /// /// Nothing to call when the answer names somewhere outside the app, the /// same answer [`Outcome::Goto`] gives an /// [`External`](crate::Destination::External) destination. And nothing to /// call when no pick arrives: a reader who backs out of the dialog has not /// answered, so there is nothing to tell the router about it. A host may /// hand over whatever the picker gave it without checking first. #[must_use] pub fn answered(&self, picked: impl IntoIterator) -> Option { let mut action = self.answers.clone(); let mut any = false; for pick in picked { any = true; action = action.with(self.under.as_str(), pick.handle); if let Some(name) = &self.labelled { action = action.with(name.as_str(), pick.label); } } if !any { return None; } let path = action.destination.route()?.to_owned(); Some(Request { method: action.method, path, captures: crate::request::Params::new(), payload: action.params, carried: action.carried, }) } } /// Something to tell the user alongside whatever else the response does. /// /// The same three fields as [`Node::Notice`], because it is the same thing said /// from the other end: that one is a message a screen contains, this is a /// message an answer carries. A renderer that can draw one can draw the other. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Message { /// Transient and stacked, or persistent and in flow. pub kind: layout::Notice, /// What it is saying. pub tone: layout::Tone, /// The message. pub text: String, /// What taking it back calls, when it can be taken back. /// /// The half that is not on [`Act`](crate::Act). Confirming is a question /// asked *before*, and it is a property of the control, so it lives there. /// Undoing is offered *after*, alongside the sentence saying what /// happened, and it needs a second route — which is what made it this /// crate's rather than the vocabulary's, the same split the file-dialog /// finding took. /// /// goingson raises 16 of these and Balanced Breakfast 3, each through a /// helper that builds the toast, the button and a countdown by hand. /// /// No timeout here. How long an undo stays offered is renderer policy, the /// same class of decision as whether a pending region draws a skeleton or a /// spinner, and a description that carried seconds would be naming a value. pub undo: Option, } impl Message { /// What an undo control says. /// /// Named once here rather than by each host that draws one. [`undo`] is an /// address and carries no label, deliberately -- what a control is called is /// copy, and a handler writing "Undo" at every one of goingson's sixteen /// sites is the duplication the member removed. So the word is here, where /// three renderers can only read it. /// /// [`undo`]: Self::undo pub const UNDO: &'static str = "Undo"; /// The way back as the control a retained-screen host hangs on a notice. /// /// A webview renders a message itself and can put an anchor beside the /// text; a host that keeps a screen converts the message into a /// [`Node::Notice`](crate::Node::Notice), and this is the half of that /// conversion the vocabulary owes it. #[must_use] pub fn undo_act(&self) -> Option { self.undo .as_ref() .map(|action| crate::Act::new(Self::UNDO, action.clone())) } } /// The file name a host can actually write, from the one a description said. /// /// [`Outcome::File::name`] is a suggestion and is frequently built from /// something the user typed — a project title, a search they saved — so by the /// time it reaches a host it is user input. Every host runs it through this /// rather than each one inventing its own rules: a terminal writing beside the /// process must not be handed `../../.ssh/authorized_keys`, and an HTTP host /// must not be handed a newline to put in a header. /// /// What survives: everything except path separators, control characters, and /// the characters Windows refuses in a name. Runs of the rest collapse to a /// single `_`, leading dots go so the file is not hidden and cannot be `..`, /// and an empty result becomes `download`. /// /// Deliberately not an escape or an encoding. A name is shown to a person and /// typed back by one, so a mangled character should look mangled rather than /// look like `%2F`. #[must_use] pub fn safe_file_name(name: &str) -> String { let mut out = String::with_capacity(name.len()); for ch in name.chars() { if ch.is_control() || matches!(ch, '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|') { if !out.ends_with('_') { out.push('_'); } } else { out.push(ch); } } let trimmed = out.trim_matches(|c: char| c == '.' || c == '_' || c.is_whitespace()); if trimmed.is_empty() { "download".to_owned() } else { trimmed.to_owned() } } impl Response { /// A whole screen. #[must_use] pub fn screen(screen: Screen) -> Self { Self::from(Outcome::Screen(screen)) } /// One region's new contents. pub fn fragment(region: impl Into, node: Node) -> Self { Self::from(Outcome::Fragment { region: region.into(), node, }) } /// Somewhere else instead of content. #[must_use] pub fn goto(action: Action) -> Self { Self::from(Outcome::Goto(action)) } /// A screen over the one already there. Dismissing it reveals that one. #[must_use] pub fn over(screen: Screen) -> Self { Self::from(Outcome::Over(screen)) } /// A screen at a described point on the one already there. /// /// The anchored half of [`over`](Self::over). See [`Anchor`] for why what /// it names is a described thing rather than a position. #[must_use] pub fn anchored(screen: Screen, anchor: Anchor) -> Self { Self::from(Outcome::Anchored { screen, anchor }) } /// What a field's suggestion route answers with. /// /// The field is named by [`Field::name`](crate::Field::name), which is the /// name it sent the typed value under, so a handler answers with the name /// it was asked under and never with an id. pub fn suggestions(field: impl Into, options: Vec) -> Self { Self::from(Outcome::Suggestions { field: field.into(), options, }) } /// A file the viewer keeps. The host decides where it lands. /// /// `name` is a suggestion, not a path: a description that named a directory /// would be naming one host's filesystem. See [`Outcome::File`]. pub fn file(name: impl Into, kind: Accepted, bytes: impl Into>) -> Self { Self::from(Outcome::File { name: name.into(), kind, bytes: bytes.into(), }) } /// Ask the host for a place. Where it asks and what it asks with is the /// host's; what comes back is every handle it chose, each with a label. One /// ask is answered once, however many things the reader picked. /// /// See [`Outcome::Locate`] for why this is an answer rather than a /// control, and [`Locating`] for where the answer goes. #[must_use] pub fn locate(locating: Locating) -> Self { Self::from(Outcome::Locate(locating)) } /// The work is running and the region is waiting on it. /// /// See [`Outcome::Started`]: the handler stays sync, the app keeps its own /// offload, and what reports the finish is the region's existing /// [`live`](crate::Slot::live) or /// [`fed_by`](crate::Slot::fed_by) call. pub fn started(region: impl Into, message: impl Into) -> Self { Self::from(Outcome::Started { region: region.into(), message: message.into(), }) } /// Say something transient on the way. It dismisses itself. /// /// How long it stays is the renderer's, not this crate's.: a toast /// duration is presentation policy, the same class of value as /// [`Message::undo`]'s missing timeout, so each renderer names it once -- /// `LINGER` in the terminal and egui renderers, and the same number in the /// webview's clock script. `Notice::Toast` is not advisory: a host that /// draws one and never takes it away is wrong. /// /// [`Message::undo`]: Message::undo #[must_use] pub fn toast(self, tone: layout::Tone, text: impl Into) -> Self { self.saying(layout::Notice::Toast, tone, text) } /// Say something persistent on the way. It is dismissed by fixing the cause. #[must_use] pub fn banner(self, tone: layout::Tone, text: impl Into) -> Self { self.saying(layout::Notice::Banner, tone, text) } /// Say something, spelling out which kind it is. /// /// [`toast`](Self::toast) and [`banner`](Self::banner) are this with the /// kind chosen, and are what call sites should reach for. #[must_use] pub fn saying( mut self, kind: layout::Notice, tone: layout::Tone, text: impl Into, ) -> Self { self.notice = Some(Message { kind, tone, text: text.into(), undo: None, }); self } /// Offer to take back whatever the notice just said happened. /// /// Applies to the notice already on the response, so it follows a /// [`toast`](Self::toast) or a [`banner`](Self::banner) rather than /// replacing one. A response with nothing to say has nothing to undo: the /// sentence is what the offer hangs off, and an undo button with no /// explanation is a control the user cannot judge. #[must_use] pub fn undoable(mut self, action: Action) -> Self { if let Some(notice) = &mut self.notice { notice.undo = Some(action); } self } /// This answer also changed that slot, and here is its new content. /// /// Chains, so a write that moves three places says so three times. The /// order is kept, because a renderer applying them in a different order /// than the router named them would be inventing a fact. /// /// Naming the slot the [`Outcome`] already replaces is not rejected here /// and not special-cased: a renderer applies what it is given, and a /// response that says the same region twice is a bug in the handler that a /// silent drop would hide. #[must_use] pub fn also(mut self, region: impl Into, node: Node) -> Self { self.invalidates.push(Invalidated { region: region.into(), node, }); self } /// This answer is a place, at this address. /// /// For the answer a derivation cannot reach: a fragment that is a place. /// A tab panel answers `Response::fragment("tab-content", node) /// .at("/dashboard#tab-projects")`, which reproduces by construction what /// the server does by hand today. #[must_use] pub fn at(mut self, url: impl Into) -> Self { self.address = Some(Address::Enters(url.into())); self } /// This answer is a place, and takes the current entry's slot. /// /// For a state the back button should skip: a filter applied over a list, /// a step within a flow. The address moves and history does not grow. #[must_use] pub fn replacing(mut self, url: impl Into) -> Self { self.address = Some(Address::Replaces(url.into())); self } /// This answer is not a place, whatever the derivation would have said. /// /// The other half of the override: a read of a route is a place by default, /// and this is how a transient one says it is not. #[must_use] pub fn in_place(mut self) -> Self { self.address = Some(Address::Unchanged); self } /// The region being replaced, or `None` for a whole screen or a redirect. /// /// A webview reads this to set `hx-retarget`. Renderers that repaint /// wholesale never call it. #[must_use] pub fn target(&self) -> Option<&str> { match &self.outcome { // An overlay targets no region: it is drawn over the whole of what // is under it, and the host puts it in its own container. // A suggestion list is addressed by the field that owns it, and // turning a field name into a document id is the renderer's // business rather than this crate's — a terminal has no ids at all. // `Serves::suggestions_target` is where a webview answers it. // A file replaces no region either: it is handed to the host // rather than drawn, and what is on the screen stays there. // An anchored screen targets no region for the same reason one // level in: it is drawn at a described point, and turning that // point into a place to put markup is the renderer's business. // `Serves::anchored_target` is where a webview answers it. Outcome::Screen(_) | Outcome::Goto(_) | Outcome::Over(_) | Outcome::Anchored { .. } // A place being asked for replaces nothing either: the picker is // the host's furniture and the screen underneath is untouched. | Outcome::Suggestions { .. } | Outcome::File { .. } | Outcome::Locate(_) => None, // A region being told it is waiting is aimed the way a region being // given contents is. It is the same region and the same swap; what // differs is that the contents are a wait rather than an answer. Outcome::Fragment { region, .. } | Outcome::Started { region, .. } => Some(region), } } /// Where this is sending the user, if it is sending them anywhere. /// /// The question a host asks before it looks for a body, because a redirect /// has none. #[must_use] pub fn destination(&self) -> Option<&Action> { match &self.outcome { Outcome::Goto(action) => Some(action), // An overlay sends the user nowhere: dismissing it reveals the // screen they never left. Outcome::Screen(_) | Outcome::Fragment { .. } | Outcome::Over(_) | Outcome::Anchored { .. } | Outcome::Suggestions { .. } | Outcome::File { .. } // Asking for a place sends nobody anywhere. Where the answer goes // afterwards is `Locating::answers`, which is a call the host makes // once the reader has picked and not a redirect this answer is. | Outcome::Locate(_) // Handing work off sends nobody anywhere. The reader stays on the // screen that is now waiting, which is the whole point of being // able to say this at all. | Outcome::Started { .. } => None, } } } impl From for Response { fn from(outcome: Outcome) -> Self { Self { outcome, notice: None, address: None, invalidates: Vec::new(), } } } impl From for Response { fn from(screen: Screen) -> Self { Self::screen(screen) } } #[cfg(test)] mod tests { use super::*; use crate::screen::{Accepted, Candidate}; /// A suggestion list is addressed by the field that owns it, so it names /// no region and sends the user nowhere. Turning that name into a document /// id is the renderer's, which is what `target` answering `None` says /// here. #[test] fn a_suggestion_answer_names_no_region_and_no_destination() { let answer = Response::suggestions("q", vec![Candidate::plain("rust")]); assert_eq!(answer.target(), None); assert_eq!(answer.destination(), None); let Outcome::Suggestions { field, options } = &answer.outcome else { panic!("suggestions"); }; assert_eq!(field, "q"); assert_eq!(options.len(), 1); } /// A file is handed to the host, so it replaces no region and sends the /// user nowhere. Where it lands is the host's, which is what both `None`s /// say here. #[test] fn a_file_answer_names_no_region_and_no_destination() { let answer = Response::file( "goingson-export.json", Accepted::media_type("application/json"), b"{}".to_vec(), ); assert_eq!(answer.target(), None); assert_eq!(answer.destination(), None); let Outcome::File { name, kind, bytes } = &answer.outcome else { panic!("file"); }; assert_eq!(name, "goingson-export.json"); assert_eq!(kind, &Accepted::Type("application/json".into())); assert_eq!(bytes, b"{}"); } /// Asking for a place replaces no region and sends the user nowhere: the /// picker is the host's furniture, and the screen it opens over is still /// the screen. #[test] fn a_locate_answer_names_no_region_and_no_destination() { let answer = Response::locate(Locating::folder( "Export destination", Action::post("/export/destination"), "handle", )); assert_eq!(answer.target(), None); assert_eq!(answer.destination(), None); let Outcome::Locate(asking) = &answer.outcome else { panic!("locate"); }; assert_eq!(asking.sought, Sought::Folder); assert_eq!(asking.prompt, "Export destination"); assert_eq!(asking.labelled, None); } /// The act shape: the call that lands does the work, and carries the handle /// as the write's payload because that is where `Action::with` puts a /// value on a write. #[test] fn the_handle_lands_in_a_writes_payload() { let asking = Locating::folder("Import folder", Action::post("/import/open"), "folder"); let call = asking .answered([Picked::new("/home/max/samples", "samples")]) .expect("a route"); assert_eq!(call.method, crate::Method::Post); assert_eq!(call.path, "/import/open"); assert_eq!(call.payload.get("folder"), Some("/home/max/samples")); // Not asked for, so not sent. A host may hand over the label it has // without asking whether the description wanted one. assert_eq!(call.payload.get("label"), None); assert!(call.carried.is_empty()); } /// The form shape: the label comes back beside the handle, which is what /// `ui/export_screens.rs` draws next to its Browse button. #[test] fn the_form_shape_gets_its_label_back() { let asking = Locating::folder( "Export destination", Action::post("/export/destination"), "handle", ) .showing("shown"); let call = asking .answered([Picked::new("/media/drive/out", "drive/out")]) .expect("a route"); assert_eq!(call.payload.get("handle"), Some("/media/drive/out")); assert_eq!(call.payload.get("shown"), Some("drive/out")); } /// A read's values are its address, so they land in `carried` rather than /// in the payload. Stated by `Action::with` once and read here, so the two /// bags cannot drift apart. #[test] fn a_read_takes_the_handle_as_its_address() { let asking = Locating::new( Sought::Files { accept: vec![Accepted::suffix(".wav")], }, "Import files", Action::get("/import/files"), "picked", ); let call = asking .answered([Picked::new("/tmp/a.wav", "a.wav")]) .expect("a route"); assert_eq!(call.carried.get("picked"), Some("/tmp/a.wav")); assert!(call.payload.is_empty()); } /// Every file the reader picked reaches the route in one call, under one /// name, in the order they were picked. audiofiles' Import files door /// hands the batch to a single `start_files_import`, and N calls would be /// N imports. #[test] fn several_files_are_one_call_carrying_every_handle() { let asking = Locating::new( Sought::Files { accept: vec![Accepted::suffix(".wav")], }, "Import files", Action::post("/import/files"), "path", ); let call = asking .answered([ Picked::new("/tmp/a.wav", "a.wav"), Picked::new("/tmp/b.wav", "b.wav"), Picked::new("/tmp/c.wav", "c.wav"), ]) .expect("a route"); assert_eq!(call.path, "/import/files"); assert_eq!( call.payload.get_all("path").collect::>(), ["/tmp/a.wav", "/tmp/b.wav", "/tmp/c.wav"] ); } /// The labels repeat beside the handles and stay in step with them, because /// both are written out of the same `Picked`. #[test] fn every_handle_brings_its_own_label() { let asking = Locating::new( Sought::Files { accept: Vec::new() }, "Locate missing sample files", Action::post("/library/relocate"), "path", ) .showing("shown"); let call = asking .answered([ Picked::new("/tmp/a.wav", "a.wav"), Picked::new("/tmp/b.wav", "b.wav"), ]) .expect("a route"); let handles: Vec<_> = call.payload.get_all("path").collect(); let labels: Vec<_> = call.payload.get_all("shown").collect(); assert_eq!(handles, ["/tmp/a.wav", "/tmp/b.wav"]); assert_eq!(labels, ["a.wav", "b.wav"]); } /// A reader who backs out picked nothing, and nothing is not an answer. A /// host may hand over whatever the picker gave it rather than checking /// first. #[test] fn picking_nothing_is_no_call_at_all() { let asking = Locating::new( Sought::Files { accept: Vec::new() }, "Import files", Action::post("/import/files"), "path", ); assert!(asking.answered([]).is_none()); } /// The save shape carries the name the dialog opens with and what it /// filters to, and the reader's answer comes back the way every other pick /// does. #[test] fn a_save_ask_carries_a_suggested_name_and_answers_like_any_other() { let asking = Locating::new( Sought::Save { name: "drums-2026-08-25.afcl".into(), accept: vec![Accepted::suffix(".afcl")], }, "Export classifier", Action::post("/classifier/export"), "path", ); let answer = Response::locate(asking.clone()); assert_eq!(answer.target(), None); assert_eq!(answer.destination(), None); let Sought::Save { name, accept } = &asking.sought else { panic!("save"); }; assert_eq!(name, "drums-2026-08-25.afcl"); assert_eq!(accept, &[Accepted::Suffix(".afcl".into())]); let call = asking .answered([Picked::new("/home/max/exports/drums.afcl", "drums.afcl")]) .expect("a route"); assert_eq!(call.method, crate::Method::Post); assert_eq!(call.path, "/classifier/export"); assert_eq!( call.payload.get("path"), Some("/home/max/exports/drums.afcl") ); } /// The name is a suggestion built out of something the reader typed, so a /// host writing it without a dialog has the same sanitiser the download /// half has. Stated here so that the two halves cannot answer differently. #[test] fn a_suggested_save_name_goes_through_the_same_sanitiser() { assert_eq!( safe_file_name("../../.ssh/authorized_keys"), "ssh_authorized_keys" ); assert_eq!( safe_file_name("drums-2026-08-25.afcl"), "drums-2026-08-25.afcl" ); } /// Somewhere outside the app is nowhere to send the answer, so there is no /// call to make. The same answer `Goto` gives an external destination. #[test] fn an_answer_that_goes_outside_the_app_is_no_call_at_all() { let asking = Locating::folder( "Somewhere else", Action::external("https://example.invalid"), "handle", ); assert!(asking.answered([Picked::new("/tmp", "tmp")]).is_none()); } /// The notice composes with a file the same way it composes with the other /// four, because it is a fourth field rather than a fifth member. #[test] fn a_file_answer_can_still_say_something() { let answer = Response::file("a.csv", Accepted::suffix(".csv"), b"a,b\n".to_vec()) .toast(layout::Tone::Success, "exported"); assert_eq!( answer.notice.as_ref().map(|say| say.text.as_str()), Some("exported") ); } /// All three anchors survive the round trip, and an anchored answer /// replaces no region and sends the user nowhere -- it is drawn over what /// is there, which is `Over`'s bargain at a point. #[test] fn an_anchored_answer_carries_its_anchor_and_names_no_region() { use crate::screen::RegionKind; for anchor in [ Anchor::Region("browser".into()), Anchor::Selection, Anchor::Control("sort".into()), ] { let screen = Screen::sidebar_content("Menu").with(crate::Slot::new("menu", RegionKind::Pane)); let answer = Response::anchored(screen, anchor.clone()); assert_eq!(answer.target(), None); assert_eq!(answer.destination(), None); let Outcome::Anchored { anchor: back, .. } = &answer.outcome else { panic!("anchored"); }; assert_eq!(back, &anchor); } } /// A control carries no name unless one is asked for, which is what makes /// `Act::id` additive: every control written before the member existed /// renders exactly as it did. #[test] fn a_control_is_unnamed_until_it_is_named() { use crate::screen::{Act, Action}; let bare = Act::new("Sort", Action::get("/sort")); assert_eq!(bare.id, None); assert_eq!(bare.clone().id("sort").id.as_deref(), Some("sort")); // The name is the only thing it sets. A builder that also moved the // label or the action would make naming a control a decision rather // than an address. let named = bare.clone().id("sort"); assert_eq!(named.label, bare.label); assert_eq!(named.action, bare.action); } /// What every renderer asks before it decides where to draw. One walk here /// rather than three that can disagree. #[test] fn a_screen_answers_whether_it_carries_what_an_anchor_names() { use crate::screen::{Act, Action, RegionKind, Slot}; let screen = Screen::sidebar_content("Files").with( Slot::new("browser", RegionKind::Pane) .with(Node::Act(Act::new("Sort", Action::get("/sort")).id("sort"))) .with(Node::Region(Slot::new("inner", RegionKind::Group))), ); assert!(screen.anchors(&Anchor::Region("browser".into()))); // At any depth, matching `Screen::slot`. assert!(screen.anchors(&Anchor::Region("inner".into()))); assert!(screen.anchors(&Anchor::Control("sort".into()))); // Naming what is not there is a description bug, and every renderer // degrades on this answer rather than refusing. assert!(!screen.anchors(&Anchor::Region("nowhere".into()))); assert!(!screen.anchors(&Anchor::Control("nothing".into()))); // No selection on this screen, so nothing to anchor to. assert!(!screen.anchors(&Anchor::Selection)); assert!( screen .clone() .selecting("chosen") .anchors(&Anchor::Selection) ); } }