//! The screen tree: what a route answers with. //! //! Decision 3 on the wiki note puts this in quasi rather than in //! `makeover-layout`, for two reasons. The tree will churn while the router is //! being proven against a second host, and churning it here costs nothing where //! churning it there is a breaking release against three adopters. And a route //! is an address, which is the one thing `makeover-layout`'s deferral rule says //! it never names. //! //! # What this crate adds, and what it does not //! //! It adds three things: an [`Action`], which is an address; a [`Slot`], which //! is a region with a name a fragment can be aimed at; and ownership. //! //! Everything else is `makeover-layout`'s. Every member of [`Node`] composes a //! vocabulary that already exists there, and that is the admission test for a //! new one: if the thing being drawn has no name in the description layer, it //! does not get a node here, it gets a [`Region::Bespoke`] or it gets named //! there first. Without that rule this file becomes a widget library, which is //! the failure `makeover-layout` was extracted to prevent. //! //! # Why these are owned when the description layer is borrowed //! //! `makeover-layout`'s structs borrow, because a description is built, read //! once and dropped inside one frame. A router's answer outlives its handler by //! construction: it is returned from a function, and its text is usually built //! from state rather than found in it. So [`Field`], [`Choice`] and [`Column`] //! have owned mirrors here, each with a conversion back, and the conversion is //! what keeps them from drifting: adding a field over there stops the mirror //! compiling over here. use makeover_layout as layout; use crate::containment::{Containment, Element}; use crate::request::{Method, Params}; /// Where an action goes. /// /// Added 2026-08-08, found by the goingson contacts screen. A contact's social /// handle and custom field both carry a URL that points out of the app /// entirely, and until this existed there was nothing to say about it: an /// action was a route, a route is something this app answers, and an address /// somewhere else is not. The port put the URL in the row's trailing text, /// which made it something to copy rather than something to follow. /// /// # Why this and not a separate link node /// /// Both were on the table. A destination keeps one concept where there would /// have been two, and the cost is that every renderer now branches: a webview /// emits an anchor rather than a button, and a terminal has to decide whether /// it can open a browser or should show the address. That branch is honest /// work, and it is work each renderer must do anyway once external addresses /// exist at all. /// /// What it must never become is a guess. The renderer branches on this enum and /// never on the shape of the string, because "starts with https" is how a route /// named `/https-setup` ends up opening a browser. #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub enum Destination { /// A path this app's router answers. Route(String), /// An address outside the app. Nothing here will ever call it. External(String), } /// An empty route rather than an empty external address, so a half-built /// [`Action`] is something this app would answer rather than somewhere it would /// send a user. Same reasoning as [`Method`]'s default being the safe verb. /// /// Written out because `#[default]` only applies to unit variants. impl Default for Destination { fn default() -> Self { Self::Route(String::new()) } } impl Destination { /// The route path, if it is one. /// /// `None` for an external address, which is the answer a host wants when it /// is deciding whether it can dispatch something. #[must_use] pub fn route(&self) -> Option<&str> { match self { Self::Route(path) => Some(path), Self::External(_) => None, } } /// The address as written, whichever kind it is. /// /// For rendering only. A host deciding whether to dispatch wants /// [`route`](Self::route), which cannot hand back something uncallable. #[must_use] pub fn as_str(&self) -> &str { let (Self::Route(address) | Self::External(address)) = self; address } /// Whether it leaves the app. #[must_use] pub const fn is_external(&self) -> bool { matches!(self, Self::External(_)) } } /// An address a control calls when it acts. /// /// Decision 2: an action is a route. The webview emits this as an `hx-get` or /// `hx-post`, the terminal binds a key to it, egui calls it directly. All three /// are calling the same path with the same verb. /// /// Since 2026-08-08 a route is not the only thing it can be: see /// [`Destination`]. Decision 2 still holds for everything the app answers, and /// an external address is the case it never covered. /// /// It does not carry a target. What a response replaces is the *response's* /// business, per decision 7, because the router is the only party that knows /// what it just changed. #[derive(Debug, Clone, PartialEq, Eq, Default)] pub struct Action { /// Asking or telling. /// /// Meaningless for a [`Destination::External`], which is nobody's route to /// answer. Left on the struct rather than moved inside `Destination` /// because a method that is ignored is simpler than two shapes of action. pub method: Method, /// Where it goes. pub destination: Destination, /// Values the control sends that are not in the path. /// /// A webview emits these as `hx-vals`; a terminal passes them straight /// through. Here so that no app hand-builds a query string, which is where /// escaping bugs live. /// /// Empty on a read. A read has nothing to send: its values are its address, /// so [`Action::with`] puts them in [`Self::carried`] instead. See /// [`Request`](crate::Request) for the whole of why the two are separate. pub params: Params, /// The view this control was offered under. /// /// A filtered list sends its filters on every control it draws, so that /// pressing one answers with the list you were looking at rather than with a /// default. Kept apart from [`Self::params`] because a screen that filters /// on `status` and also writes a `status` would otherwise have one name for /// two things, and the handler would read whichever landed first. /// /// Emitted as the query string, on the address itself, which is where a view /// belongs: the link is then the view, and a middle-click reaches the same /// place the control does. pub carried: Params, /// The region this call's answer replaces, when the responder cannot say. /// /// Normally nothing sets this and nothing should: a described route answers /// with a `Response::Fragment` naming the region it changed, `quasi-http` /// turns that into the transport's retarget header, and the router is the /// only party that knows what it just changed. That is decision 7 and it is /// unchanged. /// /// **Decision 7 assumes the responder is described, and a control may call /// a route that is not.** Every write on the MNW server's dashboard goes to /// a plain API route that quasi never sees and that answers with a status or /// a hand-rendered fragment. Those routes cannot name a region, so if the /// control does not either, nobody does: the answer lands wherever the /// transport's default puts it, which for htmx is inside the button that was /// pressed. That is not a second party deciding one thing. It is the only /// party that can decide, because the other one is outside the description /// layer. /// /// So: leave it unset when calling a described route, and set it when /// calling something else. A screen that sets it against a described route /// is overriding an answer that already knew better, and that is the misuse /// decision 7 was guarding against. pub replaces: Option, /// The name to keep the answer under, when the answer is a file. /// /// `Some` means the response is not a view: nothing swaps, and the reader /// ends up holding a file called this. A webview makes that a browser /// download; a terminal writes it to disk; either way the screen said what /// it meant rather than a class name on a button implying it. /// /// Counted before adding it. Nine sites in the MNW server: five CSV export /// buttons across four dashboard templates, a sixth in the item-sales tab's /// own script, and three anchors carrying a `download` attribute. Six of the /// nine are writes, which is what makes this a property of the action rather /// than a kind of destination: a write cannot be a plain link, so the host /// has to be told, and until now it was told by /// `data-action="exportCsvButton"` plus two positional arguments. /// /// Independent of [`method`](Self::method). A read that saves is an anchor /// the browser downloads instead of navigating to; a write that saves has to /// be performed and then handed to the reader. Both are the same sentence /// here and differ only in the emitting. pub saves: Option, } impl Action { /// A read. pub fn get(path: impl Into) -> Self { Self { method: Method::Get, destination: Destination::Route(path.into()), params: Params::new(), carried: Params::new(), saves: None, replaces: None, } } /// A write. pub fn post(path: impl Into) -> Self { Self { method: Method::Post, destination: Destination::Route(path.into()), params: Params::new(), carried: Params::new(), saves: None, replaces: None, } } /// A write that removes what is at the address. /// /// `61e1b069`. Reach for it when the route the app already answers is a /// `DELETE`, not to editorialise about what a `POST` means: the verb here /// exists to address an interface, and a route that deletes over `POST` is /// still [`post`](Self::post). pub fn delete(path: impl Into) -> Self { Self { method: Method::Delete, destination: Destination::Route(path.into()), params: Params::new(), carried: Params::new(), saves: None, replaces: None, } } /// A write that replaces what is at the address. pub fn put(path: impl Into) -> Self { Self { method: Method::Put, destination: Destination::Route(path.into()), params: Params::new(), carried: Params::new(), saves: None, replaces: None, } } /// Somewhere outside the app. /// /// [`Method::Get`], because following a link asks and does not tell, and a /// host that ignores the method loses nothing by it. pub fn external(url: impl Into) -> Self { Self { method: Method::Get, destination: Destination::External(url.into()), params: Params::new(), carried: Params::new(), saves: None, replaces: None, } } /// Put this call's answer into the region with this id. /// /// For a route the description layer does not serve. See /// [`replaces`](Self::replaces) before reaching for it. #[must_use] pub fn replacing(mut self, region: impl Into) -> Self { self.replaces = Some(region.into()); self } /// Keep the answer as a file with this name, rather than showing it. #[must_use] pub fn saving(mut self, filename: impl Into) -> Self { self.saves = Some(filename.into()); self } /// The route this calls, if it calls one. #[must_use] pub fn route(&self) -> Option<&str> { self.destination.route() } /// Send a value along with the call. /// /// On a write this is the payload: what the control is telling the route. /// On a read it is the address, because a read sends nothing and its values /// are where it goes — so this lands in [`Self::carried`] rather than in /// [`Self::params`], and a read's two bags are never both populated. /// /// That is what keeps the rule one sentence at the reading end: a filter is /// in `carried` whichever verb offered it. #[must_use] pub fn with(mut self, name: impl Into, value: impl Into) -> Self { if self.method.mutates() { self.params.insert(name, value); } else { self.carried.insert(name, value); } self } /// Keep this control pointed at the view it was offered under. /// /// What a filtered screen puts on every control it draws. Distinct from /// [`Self::with`] on a write, and the same thing as it on a read. #[must_use] pub fn carrying(mut self, name: impl Into, value: impl Into) -> Self { self.carried.insert(name, value); self } } /// A small labelled thing: a badge, a chip, a tag. /// /// Its own struct as of 2026-08-08, having been the inline payload of /// [`Node::Token`]. Extracted because a row can carry these now /// ([`Row::tokens`], against `makeover-layout`'s `RowPart::Tokens`), and the /// alternative was defining the same five fields twice and watching them drift. /// /// The tone rides on the tag rather than on whatever holds it, which is what /// lets a strip of them say different things: a neutral type and an amber /// status, side by side in one row. /// /// No `Hash`, because it can hold an [`Action`], which holds [`Params`], which /// is a `Vec`. Same derive set as `Action` for that reason. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Tag { /// Whether it answers a click, and whether it can be removed. pub kind: layout::Token, /// What it says. pub label: String, /// What it is saying. pub tone: layout::Tone, /// Whether it is currently held down. Only meaningful for a chip. pub latched: bool, /// What clicking it calls, if it answers a click. pub action: Option, } impl Tag { /// A neutral badge: it says something and answers nothing. pub fn badge(label: impl Into) -> Self { Self { kind: layout::Token::Badge, label: label.into(), tone: layout::Tone::Neutral, latched: false, action: None, } } /// A chip that calls a route when clicked. /// /// Not removable. A removable chip is a different control with a different /// affordance, so it says so rather than being inferred from carrying an /// action. pub fn chip(label: impl Into, action: Action) -> Self { Self { kind: layout::Token::Chip { removable: false }, label: label.into(), tone: layout::Tone::Neutral, latched: false, action: Some(action), } } /// Set what it is saying. #[must_use] pub const fn tone(mut self, tone: layout::Tone) -> Self { self.tone = tone; self } /// Hold it down. Only meaningful for a chip. #[must_use] pub const fn latched(mut self, latched: bool) -> Self { self.latched = latched; self } } /// One option offered by a field, owned. /// /// The borrowed original is `makeover-layout`'s [`layout::Choice`]. Two strings /// rather than one for the reason recorded there: the submitted value and the /// read label are different facts, and every renderer that collapsed them has /// had to un-collapse them later. #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct Choice { /// What is submitted. pub value: String, /// What is read. pub label: String, } impl Choice { /// An option whose submitted value is also its label. pub fn plain(value: impl Into) -> Self { let value = value.into(); Self { label: value.clone(), value, } } /// An option that reads differently from what it submits. pub fn new(value: impl Into, label: impl Into) -> Self { Self { value: value.into(), label: label.into(), } } /// Borrow as the description layer's own type. #[must_use] pub fn as_layout(&self) -> layout::Choice<'_> { layout::Choice { value: &self.value, label: &self.label, } } } /// A picture and where it is. /// /// Called `Picture` rather than `Image` because `layout::Image` is the /// description half and the two are in scope together constantly. The same /// dodge [`Tag`] makes for `layout::Token`. /// /// The split is `layout::Image`'s: [`src`](Self::src) is an address and lives /// here, everything about what the picture *is* lives there. #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct Picture { /// Where the picture is. A URL, or whatever the host resolves. /// /// Never interpreted here. A renderer escapes it for wherever it is putting /// it, the way it does every other app-supplied string. pub src: String, /// What the picture says, for anything not showing it. /// /// Empty means decorative. `layout::Image` carries the argument for why /// this is a `String` and not an `Option`. pub alt: String, /// A visible line under it, where the app wants one. pub caption: Option, /// How it sits in the box it is given. pub fit: layout::Fit, /// The picture's own dimensions, where the app knows them. /// /// `layout::Image::intrinsic` carries the argument. The short form: without /// it a renderer cannot hold the picture's place, so the picture takes no /// room until it arrives and then shoves the page down. pub intrinsic: Option, /// Whether the picture is needed with the screen, or can arrive later. pub loading: layout::Loading, } impl Picture { /// A picture at a source, carrying its own proportions. pub fn new(src: impl Into, alt: impl Into) -> Self { Self { src: src.into(), alt: alt.into(), caption: None, fit: layout::Fit::Natural, intrinsic: None, loading: layout::Loading::Eager, } } /// The picture's own dimensions, so a renderer can hold its place. #[must_use] pub const fn intrinsic(mut self, width: u32, height: u32) -> Self { self.intrinsic = Some(layout::Extent::new(width, height)); self } /// This picture is not on screen yet; it can arrive when it is near. #[must_use] pub const fn lazy(mut self) -> Self { self.loading = layout::Loading::Lazy; self } /// A visible line under it. #[must_use] pub fn caption(mut self, caption: impl Into) -> Self { self.caption = Some(caption.into()); self } /// How it sits in its box. #[must_use] pub const fn fit(mut self, fit: layout::Fit) -> Self { self.fit = fit; self } /// Borrow as the description layer's own type. #[must_use] pub fn as_layout(&self) -> layout::Image<'_> { layout::Image { alt: &self.alt, caption: self.caption.as_deref(), fit: self.fit, intrinsic: self.intrinsic, loading: self.loading, } } } /// One figure with a caption, owned. /// /// The borrowed original is [`layout::Figure`], and everything it says applies: /// the value is text because only the app knows whether the number is a /// percentage, a duration or a ratio, and the tone is carried because no /// renderer can work out that a streak of zero is worth colouring. /// /// What it calls, if it calls anything, is not here. That is an address, which /// `makeover-layout` never names, and it rides beside the figure in /// [`Node::Stats`] the way [`Row::activate`] rides beside a row's parts. #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct Figure { /// The number, formatted the way the app means it to read. pub value: String, /// What it counts. The caption under the value. pub caption: String, /// How the value has moved, if the app is tracking that. /// /// Mirrors `layout::Figure::change`, added there at 0.13.0. Text for the /// same reason [`value`](Self::value) is: only the app knows whether a move /// reads as `+12.5%`, `+3` or `2x`. /// /// This is what [`tone`](Self::tone) was for. Counted before adding it: the /// MNW server has four screens whose stat card is a label, a value and a /// delta, and on all four the delta is the toned part while the number /// itself is an ordinary fact. Without it the delta folds into the caption, /// which loses the tone and turns a second smaller line into a longer first /// one. pub change: Option, /// What the figure means. [`layout::Tone::Neutral`] is an ordinary fact. /// /// Applies to [`change`](Self::change) where there is one, and to the value /// where there is not. The renderer decides which element that lands on. pub tone: layout::Tone, } impl Figure { /// A figure that is an ordinary fact. pub fn new(value: impl Into, caption: impl Into) -> Self { Self { value: value.into(), caption: caption.into(), change: None, tone: layout::Tone::Neutral, } } /// How the value has moved. #[must_use] pub fn change(mut self, change: impl Into) -> Self { self.change = Some(change.into()); self } /// What the figure means. #[must_use] pub const fn tone(mut self, tone: layout::Tone) -> Self { self.tone = tone; self } /// Borrow as the description layer's own type. #[must_use] pub fn as_layout(&self) -> layout::Figure<'_> { layout::Figure { value: &self.value, caption: &self.caption, change: self.change.as_deref(), tone: self.tone, } } } /// What a list has that it is not showing. /// /// Deliberately not virtual scrolling, which is the neighbouring thing and is /// not a description concern: goingson's `virtual-scroller.js` windows rows the /// app already holds, which is a renderer performance technique. This is a fact /// about the data — there are rows that were never fetched — and only the thing /// that fetched them knows it. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Rest { /// How many more there are, when that is known. /// /// `None` is honest and common: a query that asked for 51 to find out /// whether there were more than 50 knows that there are, and not how many. /// A renderer with a count can say "50 of 400" and one without can still /// offer the way forward. pub remaining: Option, /// What asking for more calls. pub action: Action, } impl Rest { /// There is more, reached this way, and the count is not known. #[must_use] pub const fn more(action: Action) -> Self { Self { remaining: None, action, } } /// How many more there are. #[must_use] pub const fn remaining(mut self, remaining: u32) -> Self { self.remaining = Some(remaining); self } } /// Prose in a row part: what it says, and whether it is markdown. /// /// `secondary` has always been a `String`, and three call sites had markdown to /// put in it: the goingson projects card's description, the mail list's body /// preview, and a contact's note next. Each put the **source** in, so a row read /// `**Ships Q3.** See [the brief](https://...)` where the screen it stands in /// for reads the sentence. Flattening at the call site fixes what the user sees /// and loses the fact on the way: a renderer receiving the row cannot tell text /// an author typed from markdown somebody already flattened, so it cannot decide /// for itself, and the flattening is copied per site. /// /// This is [`Meter`]'s answer, not [`Node`]'s. The row still holds no node -- /// the 2026-08-08 ruling, and the door through which a description becomes a /// templating language -- it holds a two-case value saying which of two things /// its string is. A webview renders the markdown inline, a terminal can emit /// bold, and a renderer that wants neither flattens it, each from the same /// description. /// /// [`Text`](Self::Text) is the default in every sense: `From<&str>` and /// `From` both produce it, so `.secondary("...")` means what it always /// meant and no existing call site changes. #[derive(Debug, Clone, PartialEq, Eq)] pub enum Prose { /// Text as written. A renderer escapes it and draws it, and nothing in it /// is markup however it is punctuated. Text(String), /// Markdown source, carried as source for the reason [`Node::Rich`] does: /// every renderer has an honest answer because each renders it its own way, /// and nothing here is markup a renderer has to trust. Rich(String), } impl Prose { /// Markdown, to be rendered by whoever draws it. pub fn rich(source: impl Into) -> Self { Self::Rich(source.into()) } /// The string, whichever case this is. /// /// For a renderer that treats both the same, and for a test that does not /// care. A renderer that draws this without looking at the case is drawing /// markdown as text, which is the bug this type exists to make visible /// rather than impossible. #[must_use] pub fn source(&self) -> &str { match self { Self::Text(text) | Self::Rich(text) => text, } } /// Whether there is anything to draw. #[must_use] pub fn is_empty(&self) -> bool { self.source().is_empty() } } impl From for Prose { fn from(text: String) -> Self { Self::Text(text) } } impl From<&str> for Prose { fn from(text: &str) -> Self { Self::Text(text.to_owned()) } } impl From<&String> for Prose { fn from(text: &String) -> Self { Self::Text(text.clone()) } } /// How much of a set is done, owned. /// /// The borrowed original is [`layout::Meter`], which arrived at 0.10.0 for this. /// Before it, a screen with a progress bar concatenated the two numbers into its /// heading — "Subtasks 3/7" — which keeps both facts and loses the reading, the /// same way a toned status badge read as prose before [`Row::tokens`]. /// /// Its own struct as of 0.11.0, having been the inline payload of /// [`Node::Meter`]. Extracted for the reason [`Tag`] was: a row can carry one /// now ([`Row::meter`], against `makeover-layout`'s `RowPart::Proportion`), and /// the alternative was defining the same four fields twice and watching them /// drift. /// /// Carries the pair rather than a percentage for the reason [`layout::Meter`] /// gives: a bar that is full because it landed exactly and one that is full /// because it ran over are the same width and not the same fact. /// /// This is a proportion of a set and not the progress of an operation. A running /// timer or a fetch is imperative and live, and a screen is described once per /// answer; [`layout::Readiness::Pending`] and a [`layout::Notice::Toast`] are /// what those get. #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct Meter { /// How much is done. May exceed [`total`](Self::total). pub done: u32, /// How much there is to do. pub total: u32, /// What the proportion means. No renderer can derive this. pub tone: layout::Tone, /// What is being counted: "subtasks", "tasks". The noun, not the ratio. pub label: Option, } impl Meter { /// A proportion, untoned and unlabelled. #[must_use] pub const fn new(done: u32, total: u32) -> Self { Self { done, total, tone: layout::Tone::Neutral, label: None, } } /// What the proportion means. #[must_use] pub const fn tone(mut self, tone: layout::Tone) -> Self { self.tone = tone; self } /// What is being counted. The noun, not the ratio. #[must_use] pub fn label(mut self, label: impl Into) -> Self { self.label = Some(label.into()); self } /// Borrow as the description layer's own type. #[must_use] pub fn as_layout(&self) -> layout::Meter<'_> { layout::Meter { done: self.done, total: self.total, tone: self.tone, label: self.label.as_deref(), } } } /// One field of a form, owned. /// /// The borrowed original is [`layout::Field`], and everything it says about /// what a field carries applies unchanged, with one addition that does not /// travel down to it: [`value`](Self::value). /// /// # Why the value lives here and not in `makeover-layout` /// /// `1c4a66a4`, decided 2026-08-09. [`layout::Field`] refuses to carry the /// current value, and that refusal is right: an immediate-mode renderer writes /// through a `&mut String` the app owns, and a terminal keeps an edit buffer, /// so a description carrying a live value would need a way to write it back and /// would then be a form model. /// /// What is carried here is not a live value. It is what to re-offer after a /// submission was refused, and it has [`error`](Self::error)'s lifecycle rather /// than a live value's: per-submission, one way, supplied by whoever validated, /// gone on the next request. `error` already sits in this struct on exactly /// those terms. /// /// The reason it is this crate's field and not the vocabulary's is that only a /// stateless request and response destroys the value. In egui and in a terminal /// the buffer never went anywhere, so nothing is lost and there is nothing to /// re-offer. This is the layer where the loss happens, so this is the layer that /// repairs it. /// /// # A field's described state is its value, and the caret is the renderer's /// /// `d52884b0`, decided 2026-08-12. Nothing here carries a caret position, and /// nothing in [`layout::FieldKind`] does either. A description names the field /// and, where it has one, its completion source. Where the caret sits is how a /// renderer decides what to offer from that source. /// /// The question came from goingson's `search.js`, whose completion list depends /// on which token the caret is inside rather than on the value: it reads /// `selectionStart`, listens for caret moves that change nothing else, and /// writes the caret back when a suggestion is applied. That is a real /// dependency, and it still does not belong here. A caret is where the user is /// pointing inside a control, the same class of fact as a scroll offset and a /// focus position, and this stack already puts those in the renderer's view /// rather than in the description (`quasi-tui`'s `View`). /// /// Growing this struct to (value, caret) was rejected: it is the most-consumed /// member in the vocabulary, every renderer would owe it an answer, and a /// terminal's answer would be a second cursor concept beside the one the runtime /// already holds. The measured demand was one file. /// /// Reversible if a second consumer appears that needs the caret described rather /// than held, such as a completion that has to survive a fragment swap. That is /// a member here and a cascade, the same shape as every other addition. /// /// No `Hash`, for the reason [`Tag`] has none: it can hold an [`Action`], which /// holds [`Params`], which is a `Vec`. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Field { /// What kind of value it takes. pub kind: layout::FieldKind, /// The name the value is submitted under, and the name the handler reads /// back out of [`Params`]. pub name: String, /// What the user is asked for. pub label: String, /// Standing help. pub hint: Option, /// What is currently wrong with the value. Supplied by whoever validated; /// nothing here decides that a value is wrong. pub error: Option, /// Ghost text shown while the field is empty. pub placeholder: Option, /// The options offered, in order. Empty for kinds that offer none. pub options: Vec, /// Whether the form refuses to submit without it. pub required: bool, /// The longest the value may be, in characters. /// /// The borrowed original's [`layout::Field::max_length`], and everything it /// says applies: the description carries the rule, the renderer emits its /// host's idiom, and deciding a value is wrong stays with whoever validated. pub max_length: Option, /// The lowest value accepted, written the way the host writes one. pub min: Option, /// The highest value accepted. See [`min`](Self::min). pub max: Option, /// Whether the field lives behind a "more options" disclosure. pub extended: bool, /// What to put back in the box: what was submitted, when a submission was /// refused and the form is being offered again. /// /// `None` on a first showing, which is every form that is not answering a /// refusal. A checkbox is here by presence, the way HTML submits one: a /// value means ticked and `None` means not. /// /// A [`layout::FieldKind::Secret`] never gets one. [`Field::value`] refuses /// to set it and every renderer refuses to emit it, so the guarantee does /// not rest on either alone. pub value: Option, /// What changing this calls, for a control that writes on its own rather /// than waiting for a submit. /// /// `14612ed8`. A field inside a [`Node::Form`] submits with the form and /// needs nothing here. A settings toggle is the other kind: there is no /// submit, and changing the control *is* the write. goingson had 13 of these /// and reached them through `dispatch.js`, 109 lines of its own event /// plumbing, because nothing in the description could say it. No version of /// spinning up an app quickly has each app hand-rolling a dispatcher. /// /// The route receives the value under this field's [`name`](Self::name), /// which is the same name a submit would have sent it under. Nothing else /// changes about the field. pub changes: Option, } impl Field { /// A plain optional field of the given kind. pub fn new(kind: layout::FieldKind, name: impl Into, label: impl Into) -> Self { Self { kind, name: name.into(), label: label.into(), hint: None, error: None, placeholder: None, options: Vec::new(), required: false, max_length: None, min: None, max: None, extended: false, value: None, changes: None, } } /// Changing this writes, without waiting for a submit. #[must_use] pub fn changes(mut self, action: Action) -> Self { self.changes = Some(action); self } /// A select offering the given options. pub fn select(name: impl Into, label: impl Into, options: Vec) -> Self { Self { options, ..Self::new(layout::FieldKind::Select, name, label) } } /// A radio group offering the given options. pub fn radio(name: impl Into, label: impl Into, options: Vec) -> Self { Self { options, ..Self::new(layout::FieldKind::Radio, name, label) } } /// The form refuses to submit without it. #[must_use] pub fn required(mut self) -> Self { self.required = true; self } /// Standing help, shown whether or not anything is wrong. #[must_use] pub fn hint(mut self, hint: impl Into) -> Self { self.hint = Some(hint.into()); self } /// What is wrong with the value now. #[must_use] pub fn error(mut self, error: impl Into) -> Self { self.error = Some(error.into()); self } /// Whether the field is currently reporting a problem. #[must_use] pub fn invalid(&self) -> bool { self.error.is_some() } /// Put this back in the box when the form is offered again. /// /// A [`layout::FieldKind::Secret`] keeps `None` whatever it is handed. A /// password that comes back down the wire is a password in a page, in a /// proxy log and in a browser cache, and the field kind exists to say so. /// Silently rather than by a `Result`, because there is no answer a caller /// could give that would make echoing it right. #[must_use] pub fn value(mut self, value: impl Into) -> Self { if self.kind != layout::FieldKind::Secret { self.value = Some(value.into()); } self } /// Re-offer whatever was submitted under this field's name. /// /// What a refused write calls, with the [`Params`](crate::Params) it was /// refusing. A name with nothing under it stays empty, which is what an /// unticked checkbox and an untouched box both are. #[must_use] pub fn refilled(self, params: &crate::Params) -> Self { match params.get(&self.name) { Some(value) => { let value = value.to_owned(); self.value(value) } None => self, } } /// Read this field as the description layer's own type. /// /// A callback rather than a return, because [`layout::Field`] holds its /// options as a slice and ours holds them as owned values, so the borrowed /// slice has to live somewhere for the duration of the read. Building it /// here means one allocation at the renderer's boundary instead of the /// borrow leaking into every caller's signature. pub fn with_layout(&self, f: impl FnOnce(layout::Field<'_>) -> R) -> R { let options: Vec> = self.options.iter().map(Choice::as_layout).collect(); f(layout::Field { kind: self.kind, name: &self.name, label: &self.label, hint: self.hint.as_deref(), error: self.error.as_deref(), placeholder: self.placeholder.as_deref(), options: &options, required: self.required, max_length: self.max_length, min: self.min.as_deref(), max: self.max.as_deref(), extended: self.extended, }) } } /// One column of a table, owned. /// /// The borrowed original is [`layout::Column`]. The `name` is both the heading /// and the address a cell is found by, which is what replaces addressing /// columns by position. /// No `Hash`, for the reason [`Tag`] and [`Field`] have none: it can hold an /// [`Action`], which holds [`Params`], which is a `Vec`. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Column { /// The heading, and the name the cell is addressed by. pub name: String, /// How much room it asks for. pub width: layout::Width, /// What it is worth when room runs out. pub priority: layout::Priority, /// Which way the table is ordered by this column, if it is. pub sorted: Option, /// What pressing this heading calls. /// /// `ce620871`. makeover-layout carries `Column::sortable`, a bare bool, /// because it cannot name an address; here the address *is* the /// sortability, so the two collapse into one field and cannot disagree. /// [`as_layout`](Self::as_layout) sets the bool from whether this is here. /// /// Reordering a table is a control that writes with no surrounding submit, /// which is `14612ed8`'s shape, and the renderer treats it the same way. pub reorder: Option, } impl Column { /// A column that absorbs slack and drops after the optional ones. pub fn new(name: impl Into) -> Self { Self { name: name.into(), width: layout::Width::Fill, priority: layout::Priority::Secondary, sorted: None, reorder: None, } } /// Pressing this heading reorders the table. #[must_use] pub fn reorder(mut self, action: Action) -> Self { self.reorder = Some(action); self } /// The table is currently ordered by this column, this way. #[must_use] pub const fn sorted(mut self, sort: layout::Sort) -> Self { self.sorted = Some(sort); self } /// Set how much room it asks for. #[must_use] pub fn width(mut self, width: layout::Width) -> Self { self.width = width; self } /// Set what it is worth when room runs out. #[must_use] pub fn priority(mut self, priority: layout::Priority) -> Self { self.priority = priority; self } /// Borrow as the description layer's own type. #[must_use] pub fn as_layout(&self) -> layout::Column<'_> { layout::Column { name: &self.name, width: self.width, priority: self.priority, sortable: self.reorder.is_some(), sorted: self.sorted, } } } /// Which region this is, owned. /// /// The borrowed original is [`layout::Region`], and only one member borrows: /// [`layout::Region::Bespoke`] carries a name the app owns and this crate never /// interprets. #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub enum RegionKind { /// A full-width strip with a title slot and an actions cluster. Band, /// A persistent column beside the content, holding navigation. Sidebar, /// A region of content with its own scroll. Pane, /// Two panes side by side, the left choosing what the right shows. Split, /// Peer regions across, all of them equals. /// /// A kanban board. Distinct from [`Split`](Self::Split), whose two panes /// stand in master-detail: these choose nothing about each other. Carries /// no count and no width -- the children say how many, and peers are equal /// by definition. See [`layout::Region::Columns`]. /// /// The children are ordinary regions, reached as [`Node::Region`], so a /// renderer that lays nothing across still draws every column. A terminal /// stacking them vertically is honouring this. Columns, /// A set of panes, one visible at a time, with tabs above. TabGroup, /// Content over a scrim, taking input until dismissed. /// /// A modal this screen *contains*, which is how a confirmation is drawn: it /// arrives with the screen and goes when the screen goes. The app-level one /// is [`Outcome::Over`](crate::Outcome::Over), which draws a whole screen /// over whatever is under it and is reachable from screens that know /// nothing about it. Modal, /// A place, and nothing else. The app fills it per host. /// /// Decision 4: the renderer hands the space over and the app puts a JS /// component, an egui closure or a TUI widget in it. The rejected /// alternative was giving the placeholder its own route and fetching a /// fragment for it, which is uniform on paper and wrong in currency: a byte /// payload is not what egui or a terminal wants. Bespoke { /// What the app calls it. Never interpreted here. name: String, }, /// A named assembly of things the description already says. /// /// The third tier, and the one member that is a name *and* contents. See /// [`layout::Region::Widget`] for what separates it from the two either /// side of it; the short form is that a primitive has to be drawable by /// every host from scratch and a bespoke carries nothing under it, and a /// carousel is neither. /// /// The body is the assembly and it is ordinary description: a renderer that /// does not recognise the name walks it and draws primitives, which is why /// naming one costs no renderer release. Contrast /// [`Bespoke`](Self::Bespoke), whose body a renderer can draw but whose /// *fill* only the host has. Widget { /// What the assembly is called. Never interpreted here, and a renderer /// is free not to know it. name: String, }, } impl RegionKind { /// Borrow as the description layer's own type. #[must_use] pub fn as_layout(&self) -> layout::Region<'_> { match self { Self::Band => layout::Region::Band, Self::Sidebar => layout::Region::Sidebar, Self::Pane => layout::Region::Pane, Self::Split => layout::Region::Split, Self::Columns => layout::Region::Columns, Self::TabGroup => layout::Region::TabGroup, Self::Modal => layout::Region::Modal, Self::Bespoke { name } => layout::Region::Bespoke { name }, Self::Widget { name } => layout::Region::Widget { name }, } } /// Whether the description can say anything about the contents. #[must_use] pub fn described(&self) -> bool { self.as_layout().described() } /// How the region sits on what is behind it. #[must_use] pub fn depth(&self) -> layout::Depth { self.as_layout().depth() } } /// A named region, and the thing a fragment is aimed at. /// /// The name is what decision 7 needs and [`layout::Region`] deliberately does /// not have: two panes in a split are both `Pane`, so the kind cannot be an /// address. A webview maps the id onto `hx-target`; egui and the terminal /// ignore it and redraw, which costs them nothing because they were redrawing /// anyway. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Slot { /// The address. Unique within a screen, and stable across responses, or a /// fragment lands nowhere. pub id: String, /// Which region it is. pub kind: RegionKind, /// Whether the region's own content is here or on its way. /// /// The loading axis, and only that. Emptiness is *not* said here, which /// looks like the obvious place for it and is not: a column with a heading /// and no rows is a region that has content — the heading — and a list that /// has none. Marking the region empty would hide the heading with it. See /// [`Node::StandIn`]. pub readiness: layout::Readiness, /// What is in it. /// /// Blocks, regions included, which is the nesting that was always accepted: /// a region inside a region is a nested rect on every host. Leaves are /// admitted too, and deliberately -- a fact under a heading is a /// [`Node::Text`] straight in a pane, and it is the commonest thing in the /// tree. /// /// # Why there is no bound here /// /// [`Cell::part`] and [`Row::part`] assert that what they are handed is a /// leaf, and this does not, which looks like an oversight and is the model /// working. The ladder forbids reaching *up*: a run may not hold a block, /// because a run has to be drawable on one wrapped line. A block holding a /// leaf is going down, and going down is what containment is for. There is /// no upward violation for [`with`](Self::with) to catch, so an assertion /// here would be a runtime check that can never fire. /// /// A region whose whole content is one badge is the case that made this /// look like a question. It is describable, and it should be: a status pane /// is a real screen. Whether it is a *good* screen is a judgement about /// that screen rather than a property of the vocabulary, and the bound is /// not the place to hold opinions about taste. pub body: Vec, /// How many of [`body`](Self::body) are visible at once. /// /// `4dcd241b`. [`layout::Showing::All`] by default, which is what every /// region did before this field existed, so a description written against /// the previous version says the same thing. /// /// This is the kind. The two fields below are the current answer and the /// per-child name, and they are here rather than in `makeover-layout` for /// the reason [`Node::Select`]'s `chosen` is: a layer that defers every /// address does not hold what is picked either. pub showing: layout::Showing, /// Which child is up, when only one of them is. /// /// Meaningless under [`layout::Showing::All`] and ignored there. Read /// through [`current`](Self::current) rather than directly, which is where /// an index past the end of the body is dealt with. pub shown: Option, /// What this region is called, when something above it is showing one child /// at a time. /// /// The tab's name, and the whole of what separates a tab strip from a /// prev/next row: a renderer draws the strip when the children carry these /// and the row when they do not. A carousel's frames are [`Node::Image`] and /// have nowhere to put one, which is correct rather than a gap — a frame has /// a caption, not a tab name. pub label: Option, } impl Slot { /// An empty region under this address. pub fn new(id: impl Into, kind: RegionKind) -> Self { Self { id: id.into(), kind, readiness: layout::Readiness::Ready, body: Vec::new(), showing: layout::Showing::All, shown: None, label: None, } } /// A place the app fills itself. pub fn bespoke(id: impl Into, name: impl Into) -> Self { Self::new(id, RegionKind::Bespoke { name: name.into() }) } /// A named assembly, whose body says what it is made of. /// /// The body is not optional in spirit, though nothing here enforces it: a /// widget with an empty body is a [`bespoke`](Self::bespoke) that has /// mislaid its host fill, and a renderer that does not know the name will /// draw nothing at all. Assemble it out of members the description already /// has, the way [`layout::Region::Widget`] describes. pub fn widget(id: impl Into, name: impl Into) -> Self { Self::new(id, RegionKind::Widget { name: name.into() }) } /// Add a node, chaining. #[must_use] pub fn with(mut self, node: Node) -> Self { self.body.push(node); self } /// Add several nodes, chaining. #[must_use] pub fn extend(mut self, nodes: impl IntoIterator) -> Self { self.body.extend(nodes); self } /// The content is on its way rather than here. #[must_use] pub fn pending(mut self) -> Self { self.readiness = layout::Readiness::Pending; self } /// Show one child at a time, starting at this one. /// /// The carousel and the tab group, which are one thing said twice: whether /// a host draws a strip of names or a prev/next row falls out of whether /// the children carry a [`label`](Self::label), never out of the widget's /// name. #[must_use] pub fn showing_one(mut self, shown: usize) -> Self { self.showing = layout::Showing::One; self.shown = Some(shown); self } /// Show one child or none, starting closed unless a child is named. /// /// Disclosure. `None` is the closed state and is a legal resting place, /// which is the whole of what separates this from /// [`showing_one`](Self::showing_one). #[must_use] pub fn showing_at_most_one(mut self, shown: Option) -> Self { self.showing = layout::Showing::AtMostOne; self.shown = shown; self } /// Name this region, for when something above it shows one child at a time. #[must_use] pub fn label(mut self, label: impl Into) -> Self { self.label = Some(label.into()); self } /// Which child to draw, once [`shown`](Self::shown) is read against the body. /// /// `None` means draw them all, which is both [`layout::Showing::All`] and a /// closed disclosure — the two cases differ in what chrome sits around them /// and not in what a renderer does with the body, so they answer the same /// here. /// /// An index past the end is clamped rather than refused. A description /// pointing at a frame that is not there is a bug in the app, and a renderer /// that answers it by drawing nothing reports it as a region that vanished, /// which is the hardest kind of bug to find from what is on the screen. /// [`layout::Share::percent`] clamps for the same reason. #[must_use] pub fn current(&self) -> Option { if self.body.is_empty() { return None; } let last = self.body.len() - 1; match self.showing { layout::Showing::One => Some(self.shown.unwrap_or(0).min(last)), layout::Showing::AtMostOne => self.shown.map(|shown| shown.min(last)), layout::Showing::All => None, // `Showing` is `#[non_exhaustive]`, so this arm is compulsory even // with every member above it named. Drawing the whole body is the // right default for a member this crate has not been taught yet: // more content rather than less, which is how every other unknown // in this vocabulary degrades. _ => None, } } /// The children's names, when they have them. /// /// Empty unless *every* child is a named region, which is the test a /// renderer applies before drawing a strip: a strip with a hole in it is /// worse than the prev/next row it would otherwise have drawn, and a /// half-labelled body is an app bug rather than a third idiom. #[must_use] pub fn labels(&self) -> Vec<&str> { let named: Vec<&str> = self .body .iter() .filter_map(|node| match node { Node::Region(slot) => slot.label.as_deref(), _ => None, }) .collect(); if named.len() == self.body.len() { named } else { Vec::new() } } /// This slot, or the first slot under this address anywhere inside it. #[must_use] pub fn find(&self, id: &str) -> Option<&Self> { if self.id == id { return Some(self); } self.body.iter().find_map(|node| match node { Node::Region(slot) => slot.find(id), _ => None, }) } /// The mutable half of [`find`](Self::find). /// /// Same walk, and it has to be a second function rather than the same one /// generic over mutability: a `&mut` borrow of `self` cannot be handed to /// the recursive call and kept, which is what `find_map` does on the shared /// side. fn find_mut(&mut self, id: &str) -> Option<&mut Self> { if self.id == id { return Some(self); } self.body.iter_mut().find_map(|node| match node { Node::Region(slot) => slot.find_mut(id), _ => None, }) } } /// A control that calls a route. /// /// A button, a link and a menu item are the same thing to a description: a /// label, an address, and how loudly it is saying it. Which of the three a /// renderer draws is a renderer decision. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Act { /// What it is called. pub label: String, /// What it calls. pub action: Action, /// What it is saying. [`layout::Tone::Danger`] is what marks the button /// that destroys something. pub tone: layout::Tone, /// Focused, disabled, or neither. pub state: Option, /// What to ask before doing it, if it should be asked. /// /// `524a63fe`. Destructiveness is a property of the action, known where the /// action is described, and until this existed every app expressed it by /// calling a JS helper at the call site: goingson has 33 such calls across /// four helpers and Balanced Breakfast 5. /// /// The prompt only. The word on the agreeing button is /// [`label`](Self::label), because it already is — goingson's `confirmDelete` /// passes `confirmText: 'Delete'` for an act labelled "Delete" — and a /// second string would be the same word twice with a chance to disagree. /// [`tone`](Self::tone) already says whether the dialog is a dangerous one. /// /// `Region::Modal` names the box a confirmation appears in and does not name /// the pattern. This is the pattern: a webview raises a dialog, a touch host /// an action sheet, a terminal a y/n line, and none of them is a route to a /// modal screen and back, which is a different interaction. pub confirm: Option, /// The key that reaches it, written the way a user would say it. /// /// `2daea915`. An `Act` had a label and a destination and nothing said which /// key gets there, so goingson's 279-line `keyboard.js` holds the table /// beside the description, and the help overlay that lists the shortcuts is /// a second hand-written copy that can drift from it. /// /// A terminal makes the case sharper than a webview does: there the key *is* /// the affordance, so a description that cannot name one cannot describe the /// screen's primary interaction at all. /// /// Text rather than a modelled chord — "n", "ctrl+k", "?" — because the /// vocabulary of keys is the host's and a description that modelled it would /// be naming one host's keyboard. A renderer that does not know a name /// ignores it, which is what a webview does with a key a terminal wants. /// /// Screen-scoped, because a screen is what this describes. An app-wide /// shortcut belongs to the app and is not a fact about any one screen: /// that is [`Chrome::bindings`](crate::Chrome::bindings), held beside the /// router rather than inside any answer. A renderer matches those first, so /// a screen cannot capture the key that opens the palette. pub key: Option, /// The [`Screen::selection`] this acts on, if it acts on one. /// /// `5f2b8753`. This is what makes a commit control readable: "Archive" over /// a selection is a different sentence from "Archive" on a row, and until /// this existed the difference lived in whichever JS gathered the checked /// boxes. /// /// Every ticked [`Row::value`] is sent under [`Node::TICKED`], repeated /// once per member. Repeated rather than joined, because a name appearing /// many times is what [`Params::get_all`] is for and a delimiter would have /// to be one no value can contain. /// /// # The name does not select between sets yet, and cannot /// /// A screen holds one selection ([`Screen::selection`]), so being set at /// all is what makes a control a commit control, and the name is what makes /// it *readable* — "Archive" over `chosen` is a different sentence from /// "Archive" on a row. /// /// Matching it against the screen's name was the first shape and it does /// not work, because a renderer does not always have the screen: an /// [`Outcome::Fragment`] replaces a region and carries no screen at all, so /// a webview rendering one would have had to guess and a terminal, which /// keeps the screen beside it, would not. The two hosts would then disagree /// about a typo, which is exactly the drift this vocabulary exists to stop. /// So both read it the same way, and the name starts choosing between sets /// on the day [`Screen::selection`] becomes a map. /// /// [`Params::get_all`]: crate::Params::get_all /// [`Outcome::Fragment`]: crate::Outcome::Fragment pub over: Option, } impl Act { /// A neutral control calling this route. pub fn new(label: impl Into, action: Action) -> Self { Self { label: label.into(), action, tone: layout::Tone::Neutral, state: None, confirm: None, key: None, over: None, } } /// This acts on the screen's selection, by name. /// /// The commit half of a staged tick. See [`over`](Self::over) for what /// reaches the handler, and [`Screen::selection`] for why a tick stages /// rather than writes. #[must_use] pub fn over(mut self, selection: impl Into) -> Self { self.over = Some(selection.into()); self } /// Ask this before doing it. #[must_use] pub fn confirm(mut self, prompt: impl Into) -> Self { self.confirm = Some(prompt.into()); self } /// The key that reaches it. #[must_use] pub fn key(mut self, key: impl Into) -> Self { self.key = Some(key.into()); self } /// Set what it is saying. #[must_use] pub fn tone(mut self, tone: layout::Tone) -> Self { self.tone = tone; self } /// Present, visible, and not answering. #[must_use] pub fn disabled(mut self) -> Self { self.state = Some(layout::State::Disabled); self } /// Whether the control currently answers input. #[must_use] pub fn interactive(&self) -> bool { !self .state .is_some_and(layout::State::suppresses_interaction) } /// Borrow as the description layer's own type. /// /// [`action`](Self::action) and [`confirm`](Self::confirm) do not survive /// the crossing, and that is what the two layers disagree about rather than /// an oversight. An address is quasi's — every host follows one differently /// — and a confirmation is a question asked after the press, so it belongs /// to whoever is holding the interaction. What is left is what a renderer /// needs to *draw* the control, which is all `layout::Act` claims to be. #[must_use] pub fn as_layout(&self) -> layout::Act<'_> { layout::Act { label: &self.label, key: self.key.as_deref(), tone: self.tone, state: self.state, } } } /// One part of a row's run, and the role it takes. /// /// A cell's run entries carry no role because their kind already says which /// part they are: text is the value, a [`Node::Link`] is the link, a /// [`Node::Token`] is a chip, a [`Node::Act`] is a control. A row's /// `primary`, `secondary` and `meta` are three *text* roles, and kind cannot /// tell those apart, so a row says which one it means. /// /// The role is a style role and nothing else. [`layout::RowPart`] is unchanged /// by the containment model: it says how a part is drawn, not what may sit in /// it, and that is the half of it worth keeping. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Part { /// Which of the row's roles this part takes. pub role: layout::RowPart, /// What is in it. A leaf, since a row is an inline run. pub node: Node, } /// One row of a list. /// /// # The run /// /// A row's content is an inline run of [`Part`]s, in the order the description /// says them, the same way a [`Cell`]'s is. It was six members before /// `1786cb94` -- `primary`, `secondary`, `meta`, `tokens`, `actions`, `meter` /// -- each of which arrived as a counted-sites argument, a member here, a /// [`layout::RowPart`] variant and a release: `RowPart::Tokens` at /// makeover-layout 0.9.0 for a badge in a row, `RowPart::Proportion` at 0.11.0 /// for a bar in one. A link in a row was simply not sayable, and a figure in /// one was not either. Under the run both are already sayable and cost nothing. /// /// The bound is that every part is a leaf, so a row is drawable on one wrapped /// line without a renderer knowing what is in it. [`Row::part`] is where that /// bites at a call site. /// /// Order is the description's. The old members were drawn in a fixed sequence /// whatever order they were built in, so a row that wanted a tag between two /// facts got the tag hoisted to the end; now it draws where it was put. /// /// # What stayed a field /// /// [`activate`](Self::activate), [`current`](Self::current), /// [`selected`](Self::selected), [`menu`](Self::menu) and /// [`toggle`](Self::toggle) are facts *about* the row rather than content in /// it. A run of things on a line is not where "this row is the one the detail /// pane is showing" belongs. /// /// # The cost /// /// [`primary()`](Self::primary) is no longer guaranteed to be one string, which /// is what let a constrained renderer right-align a row cheaply. It answers the /// text of the primary parts joined, and a row built the ordinary way still has /// exactly one. /// A row and when it happens. /// /// The pairing [`Node::Timeline`] is made of. Deliberately a pair rather than /// members on [`Row`]: a row does not become a different kind of thing by /// being placed, and every list, table and detail pane in the tree would /// otherwise carry two integers it has no use for. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Placed { /// Where it sits on the axis, and for how long. pub placement: layout::Placement, /// The thing itself, said the ordinary way. pub row: Row, } impl Placed { /// A row at a start and a duration, both in minutes. #[must_use] pub const fn new(at: u16, minutes: u16, row: Row) -> Self { Self { placement: layout::Placement::new(at, minutes), row, } } /// Whether this and another cover any of the same time. /// /// Forwarded so a renderer laying out collisions does not reach through to /// the placement and, in doing so, decide for itself what overlapping /// means. #[must_use] pub const fn overlaps(&self, other: &Self) -> bool { self.placement.overlaps(other.placement) } } #[derive(Debug, Clone, PartialEq, Eq, Default)] pub struct Row { /// What is in the row, in order. /// /// Built by the same constructors that named the old members: /// [`Row::new`], [`secondary`](Row::secondary), [`meta`](Row::meta), /// [`token`](Row::token), [`act`](Row::act) and [`meter`](Row::meter) all /// still mean what they meant, so no builder call site moved. pub parts: Vec, /// The route that selects this row, if selecting it does anything. pub activate: Option, /// Whether this is the row the detail side is currently showing. /// /// Named `selected` until 2026-08-08, which was one word doing two jobs. /// This one is the app's own pointer into a set: what a list-detail /// arrangement highlights because its pane is showing it, and what a /// webview says with `aria-current`. The user's tick is /// [`selected`](Self::selected), and conflating them meant a screen with /// bulk actions could not describe its checkboxes at all. pub current: bool, /// Whether the user has ticked this row, and whether they can. /// /// Three states in one field, which is why it is not a `bool`. `None` means /// the row is not selectable and no affordance should be drawn; `Some(false)` /// means it can be ticked and is not; `Some(true)` means it is. A plain bool /// cannot tell "not ticked" from "not tickable", so every renderer would /// have had to be told selectability some other way, and each would have /// picked a different way. /// /// This is the user's selection, as distinct from /// [`current`](Self::current). goingson's contacts and tasks screens both /// drive bulk actions from it. pub selected: Option, /// Everything else that can be done to this row. /// /// `5e02fbce`. The [`Actions`](layout::RowPart::Actions) parts of the run /// are what the row shows; this is /// what it *offers*, reached by right-click on a pointer host, long-press on /// a touch one, and a key in a terminal. That split is the whole reason it /// belongs in the description rather than in a renderer: one description has /// to become a context menu, an action sheet and a key-driven menu, and no /// single renderer can be the place where it is said. /// /// goingson opens one at 14 sites and Balanced Breakfast at 9, on top of /// 680 lines of generic menu machinery between `components.js` and /// `context-menus.js`. /// /// A field rather than a role in the run, because a menu is not on the /// line. The run is what the row draws; this is what it holds back until /// the host asks, and no renderer draws it in sequence with the primary. pub menu: Vec, /// What ticking this row calls, if ticking it is the write. /// /// `14612ed8`, part of it. [`selected`](Self::selected) says whether the row /// is ticked and whether it can be, and that was the whole story for a bulk /// checkbox, whose tick is client state feeding a later action. A checklist /// is the other case: the tick *is* the write, and it is the only affordance /// the screen offers for it. Described without this, the port drew the tick /// inert and put the toggle on a button beside it, which is a user clicking /// a button next to a checkbox that ignores clicks. /// /// Two fields rather than a `Selection` struct, matching how /// [`activate`](Self::activate) sits beside [`current`](Self::current): /// state and behaviour are separate facts about the row. They do have to /// agree — a `toggle` with no [`selected`](Self::selected) is a route on a /// control nothing draws — and [`Row::toggling`] is the constructor that /// makes them agree. pub toggle: Option, /// What this row's tick contributes to the screen's selection. /// /// `5f2b8753`. [`selected`](Self::selected) says the row can be ticked; /// this says what ticking it *means*, which is the half that was missing. /// A set of ticks with nothing in them is not a selection, so a renderer /// holding [`Screen::selection`] holds these. /// /// `value` rather than `id`, matching [`Choice::value`]: throughout this /// vocabulary it is the word for what a control contributes when it is /// chosen, and a row's tick is the same kind of fact. /// /// A selectable row without one is the dead affordance this member exists /// to end, and [`Row::ticking`] is the constructor that cannot produce it. /// It is not enforced here, for [`toggle`](Self::toggle)'s reason: a /// description layer that refused to hold a half-built row would refuse it /// at the moment the app is still building it. /// /// [`Choice::value`]: Choice::value /// [`Screen::selection`]: Screen::selection pub value: Option, } impl Row { /// A row with only its primary text. /// /// An empty string is an empty run rather than a run holding an empty /// string, so `Row::new("")` and [`Row::default`] are the same value. Same /// rule as [`Cell::new`], and for the same reason. pub fn new(primary: impl Into) -> Self { let primary = primary.into(); Self { parts: if primary.is_empty() { Vec::new() } else { vec![Part { role: layout::RowPart::Primary, node: Node::text(primary), }] }, ..Self::default() } } /// Something else that can be done to this row, not shown inline. #[must_use] pub fn offers(mut self, act: Act) -> Self { self.menu.push(act); self } /// How much of this row's set is done. #[must_use] pub fn meter(mut self, meter: Meter) -> Self { self.set(layout::RowPart::Proportion, Node::Meter(meter)); self } /// A tick that is the write, in the state it is currently in. /// /// Sets [`selected`](Self::selected) and [`toggle`](Self::toggle) together, /// because a route on a tick nothing draws is the one way the two fields can /// disagree. A checklist item is what this is for; a bulk checkbox sets /// `selected` alone and keeps its meaning as client state. #[must_use] pub fn toggling(mut self, ticked: bool, action: Action) -> Self { self.selected = Some(ticked); self.toggle = Some(action); self } /// Supporting text under the primary. #[must_use] pub fn secondary(mut self, text: impl Into) -> Self { let node = match text.into() { Prose::Text(text) => Node::text(text), Prose::Rich(source) => Node::rich(source), }; self.set(layout::RowPart::Secondary, node); self } /// A short trailing fact. #[must_use] pub fn meta(mut self, text: impl Into) -> Self { self.set(layout::RowPart::Meta, Node::text(text)); self } /// Add a token, chaining. #[must_use] pub fn token(mut self, tag: Tag) -> Self { self.parts.push(Part { role: layout::RowPart::Tokens, node: Node::Token(tag), }); self } /// Make the row tickable, and say whether it is ticked. /// /// A row is not selectable until something says so, which is what keeps a /// checkbox off every list in the app. /// /// Says nothing about what the tick contributes, so on a screen with a /// [`selection`](Screen::selection) it draws a box that joins no set. Reach /// for [`ticking`](Self::ticking) instead; this stays for the screens whose /// tick is the write, beside [`toggling`](Self::toggling). #[must_use] pub const fn selectable(mut self, ticked: bool) -> Self { self.selected = Some(ticked); self } /// Make the row tickable under this value, and say whether it is ticked. /// /// Sets [`selected`](Self::selected) and [`value`](Self::value) together, /// which is the pair a screen's [`selection`](Screen::selection) needs. /// The two halves exist separately for [`toggling`](Self::toggling)'s /// reason — state and identity are different facts about the row — and /// this is the constructor that stops them being written apart. #[must_use] pub fn ticking(mut self, value: impl Into, ticked: bool) -> Self { self.selected = Some(ticked); self.value = Some(value.into()); self } /// The route selecting this row. #[must_use] pub fn activate(mut self, action: Action) -> Self { self.activate = Some(action); self } /// A control acting on this row. #[must_use] pub fn act(mut self, act: Act) -> Self { self.parts.push(Part { role: layout::RowPart::Actions, node: Node::Act(act), }); self } /// Anything in this row, under the role it takes. /// /// The general form the constructors above are shorthands for, and the /// point of the model: a link in a row and a figure in a row became /// sayable at once, where each was previously a /// [`layout::RowPart`] variant, a member here, a renderer arm and a /// release. /// /// Appends rather than replacing, so a row can hold two of a role. The /// named constructors keep the single-valued roles single-valued, which is /// what their call sites already meant. /// /// # Panics /// /// If the node is not a leaf. A row is an inline run, so what goes in it /// has to be drawable on one wrapped line without the renderer knowing what /// it is -- the constrained-consumer bound, biting at a call site rather /// than in a doc comment. Same assertion as [`Cell::part`]. #[must_use] pub fn part(mut self, role: layout::RowPart, node: Node) -> Self { assert!( node.containment() == Containment::Text, "a row is an inline run and holds leaves; {node:?} holds {:?}", node.containment() ); self.parts.push(Part { role, node }); self } /// Set the one part taking a role, replacing it if it is already there. /// /// For the roles that are single-valued at every call site that has ever /// existed: the primary, the supporting line, the trailing fact, the bar. /// Building a row that calls `.meta` twice meant the second one won when /// `meta` was an `Option`, and it still does. fn set(&mut self, role: layout::RowPart, node: Node) { match self.parts.iter_mut().find(|part| part.role == role) { Some(part) => part.node = node, None => self.parts.push(Part { role, node }), } } /// The parts taking one role, in order. pub fn role(&self, role: layout::RowPart) -> impl Iterator { self.parts .iter() .filter(move |part| part.role == role) .map(|part| &part.node) } /// The row's primary text. /// /// What every consumer of the old `primary` member wanted. A row built the /// ordinary way has one primary part and answers its string; one that was /// given two answers both, joined, in order. #[must_use] pub fn primary(&self) -> String { self.role(layout::RowPart::Primary) .filter_map(|node| match node { Node::Text { text, .. } | Node::Link { text, .. } => Some(text.as_str()), _ => None, }) .collect::>() .join(" ") } /// The controls the row shows. /// /// The same service [`primary`](Self::primary) does, for the member the run /// replaced. `actions` was a `Vec` before the run, and every consumer /// that read it now writes the same three lines: filter the run by role, /// match the one node kind that can be there, and collect. goingson wrote /// them twice in one file the day the member went away. /// /// Not what the row *offers*: that is [`menu`](Self::menu), which is held /// back until the host asks for it and is not on the line. pub fn acts(&self) -> impl Iterator { self.role(layout::RowPart::Actions) .filter_map(|node| match node { Node::Act(act) => Some(act), _ => None, }) } /// The tags the row shows. /// /// [`acts`](Self::acts)' counterpart, for the same reason. pub fn tokens(&self) -> impl Iterator { self.role(layout::RowPart::Tokens) .filter_map(|node| match node { Node::Token(tag) => Some(tag), _ => None, }) } } /// One cell of a table row. /// /// `022f0c59`, decided 2026-08-10. A cell was a `String` until then, so a table /// whose rows carry a control could not be described at all and had to become a /// [`Node::List`], losing its column headers — which is what the MNW server's /// SSH-keys tab did, and why it read worse than the Askama original it replaced. /// /// # Why the acts sit on the cell and not on the row /// /// Counted across MNW's templates, 30 table rows carry a control. 25 put it /// alone in the last cell, which a row-level `actions` list would have covered. /// The other five put it *beside a value*: `project_content`'s position cell is /// the number plus two reorder arrows, `project_synckit`'s slug cell is the slug /// plus "Set slug", `promo_codes_list`'s use count is a number that is itself /// the button opening the redemptions. A row-level list renders as an appended /// cell and cannot say any of those, and neither can an actions *column*, since /// a column is a column. The control belongs where it actually is. /// /// An empty [`value`](Self::value) with acts is the common case, and /// [`Cell::acts`] is the constructor for it. That is the trailing actions cell /// the markup already writes an empty `` for. /// /// A `Vec` and not a node: the 2026-08-08 ruling that a row holds no nodes /// holds here for the same reason. Acts carry their own tone, state and /// confirmation, and that is the whole of what these cells hold. #[derive(Debug, Clone, PartialEq, Eq, Default)] pub struct Cell { /// What is in it, in order. /// /// An inline run: every part is a leaf, so the whole cell is drawable on /// one wrapped line without a renderer knowing what is in it. That is the /// bound, and [`Cell::part`] is where it is enforced. /// /// This was four members -- `value`, `tokens`, `actions`, `activate` -- /// added one release at a time as each pairing was argued for on counted /// sites. `022f0c59` added two of them at once. That trajectory is what /// decided the containment model: a meter in a cell and a figure in a cell /// were simply not sayable, and each would have been a fifth and sixth /// member. Under the run they are already sayable and cost nothing. /// /// The constructors that named the old members are still here and still /// mean what they meant, so no call site moved: [`Cell::new`], /// [`tag`](Cell::tag), [`token`](Cell::token), [`acts`](Cell::acts), /// [`act`](Cell::act) and [`activate`](Cell::activate) build the run. pub parts: Vec, } impl Cell { /// A cell holding text. /// /// An empty string is an empty run rather than a run holding an empty /// string, so an actions-only cell built through [`acts`](Self::acts) and /// one built as `Cell::new("").act(..)` are the same value. pub fn new(value: impl Into) -> Self { let value = value.into(); Self { parts: if value.is_empty() { Vec::new() } else { vec![Node::text(value)] }, } } /// A cell holding one tag and no text. /// /// What a status column is: the cell is the badge. `Cell::new("")` with a /// token would say the same thing and reads as an oversight. pub fn tag(tag: Tag) -> Self { Self { parts: vec![Node::Token(tag)], } } /// A tag in this cell, chaining. #[must_use] pub fn token(mut self, tag: Tag) -> Self { self.parts.push(Node::Token(tag)); self } /// A cell holding controls and no text. pub fn acts(actions: impl IntoIterator) -> Self { Self { parts: actions.into_iter().map(Node::Act).collect(), } } /// A control in this cell, chaining. #[must_use] pub fn act(mut self, act: Act) -> Self { self.parts.push(Node::Act(act)); self } /// Where this cell's value goes. /// /// The value becomes the link. A cell with no value and an `activate` is a /// link with nothing to press, so give it text. /// /// Under the run this rewrites the leading text into a [`Node::Link`] /// rather than setting a member beside it, which is the same fact said once /// instead of as a pair of fields that could disagree. A cell with no text /// to link gains nothing, because a link with no label is a control nothing /// draws. #[must_use] pub fn activate(mut self, action: Action) -> Self { if let Some(first) = self .parts .iter_mut() .find(|part| matches!(part, Node::Text { .. })) && let Node::Text { text, .. } = first { *first = Node::Link { text: std::mem::take(text), action, }; } self } /// Anything in this cell, chaining. /// /// The general form the five constructors above are shorthands for, and the /// whole point of the model: a meter in a cell, a figure in a cell and a /// second linked value in a cell all became sayable at once, where each was /// previously a member, three renderer arms and a release. /// /// # Panics /// /// If the node is not a leaf. A cell is an inline run, so what goes in it /// has to be drawable on one wrapped line without the renderer knowing what /// it is -- that is the constrained-consumer bound, and this is where it /// bites at a call site rather than in a doc comment. #[must_use] pub fn part(mut self, node: Node) -> Self { assert!( node.containment() == Containment::Text, "a cell is an inline run and holds leaves; {node:?} holds \ {:?}", node.containment() ); self.parts.push(node); self } /// The cell's text, with the parts that are not text left out. /// /// What every consumer of the old `value` member wanted. A cell that is one /// string answers that string; one that mixes answers the text between its /// tags and controls, in order. #[must_use] pub fn text(&self) -> String { self.parts .iter() .filter_map(|part| match part { Node::Text { text, .. } | Node::Link { text, .. } => Some(text.as_str()), _ => None, }) .collect::>() .join(" ") } /// Whether anything in this cell answers a click. /// /// A badge is not one: it says something and answers nothing, which is why /// this asks the tag rather than counting tags. #[must_use] pub fn carries_control(&self) -> bool { self.parts.iter().any(|part| match part { Node::Act(_) | Node::Link { .. } => true, Node::Token(tag) => tag.kind.interactive() && tag.action.is_some(), _ => false, }) } } impl From for Cell { fn from(value: String) -> Self { Self::new(value) } } impl From<&str> for Cell { fn from(value: &str) -> Self { Self::new(value) } } /// One row of a table. /// /// Cells are positional against the table's columns, and the table is the only /// place that pairing is made. A renderer narrowing the table drops columns by /// [`layout::Priority`] and drops the cells at the same indices, which is why /// the two live in one node rather than one per row. #[derive(Debug, Clone, PartialEq, Eq, Default)] pub struct Cells { /// One entry per column, in the table's column order. pub values: Vec, /// The route that opens this row. pub activate: Option, /// Whether this is the row currently being shown elsewhere. /// /// The same fact [`Row::current`] carries, under the same name. It was /// `selected` until 2026-08-08, which is the word that decision retired: /// the app's pointer and the user's tick are two things, and one word for /// both is how every renderer ends up guessing which was meant. `Row` was /// renamed and this was missed, so it kept the ambiguous word while /// emitting `aria-current` from it. /// /// There is deliberately no tick here to go with it. `Row` grew one because /// goingson's contact cards have a bulk checkbox; no table asks for one, and /// a member added because its sibling has it is a member with no consumer to /// tell us what it should mean. pub current: bool, } impl Cells { /// A row of cells in column order. /// /// Takes anything that becomes a [`Cell`], so a row of plain text is still /// `Cells::new(["kick.wav", "2.1 MB"])` and a row with a control mixes the /// two: `Cells::new([Cell::new(name), Cell::acts([remove])])`. pub fn new(values: impl IntoIterator>) -> Self { Self { values: values.into_iter().map(Into::into).collect(), activate: None, current: false, } } /// The route that opens this row. #[must_use] pub fn activate(mut self, action: Action) -> Self { self.activate = Some(action); self } } /// A thing on a screen. /// /// Every member composes something `makeover-layout` already names, and that is /// the admission test for a new one. A node with no counterpart there means the /// vocabulary is missing a word, and the fix is to add the word rather than to /// add a widget here. #[derive(Debug, Clone, PartialEq, Eq)] pub enum Node { /// A title, at one of three depths in the heading tree. Heading { /// How far down the tree it sits. level: layout::Heading, /// The text. text: String, }, /// Prose, with a tone. Text { /// The text. text: String, /// What it is saying. [`layout::Tone::Neutral`] is ordinary content. tone: layout::Tone, }, /// Prose the author wrote in markdown. /// /// `25822137`, decided 2026-08-09. What is carried is the **source**, never /// markup, which is the property that lets this exist at all. Every renderer /// has an honest answer because each renders the source its own way: a /// webview through a markdown-to-HTML pass, a terminal through /// markdown-to-ANSI, egui through its own. A `Node::Html` would have handed /// every one of them a string it could not honour, and would have broken /// [`Node::Text`]'s escaping guarantee for every consumer rather than the /// one that asked. That refusal stands; this is not it. /// /// Sanitising is the renderer's, at the point markup is produced, for the /// reason escaping already is: this holds text a user typed, and a /// description that sanitised would be deciding what a host can draw. /// /// Not available inside a [`Row`]: a row part holds no node, by the /// 2026-08-08 ruling. What a row can hold is [`Prose`], which carries the /// same markdown source under the same reasoning without being a node, so /// the projects card no longer keeps its raw markdown in `secondary`. Rich { /// The markdown, as written. source: String, }, /// A control that calls a route. Act(Act), /// Text that goes somewhere. /// /// The containment model predicted this before anything asked for it: the /// composability matrix had a `Link` row whose "as a `Node`" cell was a /// dash, and the only way to say it was [`Cell::activate`], a member on one /// container. Once a cell is a run of leaves, the run needs a leaf that /// means "this text is a link" or the thing stops being sayable at all. /// /// Distinct from [`Act`](Self::Act), and the difference is what the reader /// sees rather than what the route does. An act is a control drawn as one, /// which is right for `Edit` and wrong for a title: making every linked /// value a button would put a row of bevels down the first column of half a /// dashboard. Both call a route; only one of them looks like a button. Link { /// What it says. text: String, /// Where it goes. action: Action, }, /// One figure, on a line. /// /// The second thing the containment model found, and it found it by /// refusing: a cell is a run of leaves, [`Stats`](Self::Stats) is a /// collection, so putting a figure in a cell failed the bound rather than /// quietly working. That is the check doing its job -- the matrix had /// "figure in a cell" as a dash nobody had attempted, and the dash turns /// out to have been hiding a missing member rather than a missing renderer /// arm. /// /// Not a duplicate of a one-element [`Stats`](Self::Stats), and the /// difference is the claim being made. A strip says "this is a row of /// tiles", which is why the set is the node there: a renderer handed one /// tile at a time cannot tell it is looking at a set. This says "this /// number sits on this line", where the run is already the grouping and /// there is nothing for a set to add. A dashboard strip of one is still a /// strip; a revenue column is not. Figure(Figure), /// A picture, at a source this crate holds and the description does not. /// /// The [`Act`](Self::Act) split, and `layout::Image`'s own docs carry the /// argument: an address is not the description's to hold, so the shape and /// the alt text live there and the URL lives here. /// /// A leaf, so it may sit in a run the way [`Link`](Self::Link) does. What /// it may *not* do is stand in for a region: a picture is one thing on the /// page, and a gallery of them is a widget assembled out of several. Image(Picture), /// A small labelled thing sitting inside something else. Token(Tag), /// Something the app is telling the user, unprompted. Notice { /// Transient and stacked, or persistent and in flow. kind: layout::Notice, /// What it is saying. tone: layout::Tone, /// The message. text: String, }, /// What stands where content would be, when there is none. /// /// `703f4cd2`. goingson draws one at 27 sites across 12 files and Balanced /// Breakfast at 9, and the class families had already drifted into /// `empty-state--error` against `error-state` for the same fact. Every one /// of those sites substitutes markup where a list would go, which is what /// makes this a node. /// /// # Why not on the region /// /// It was on [`Slot`] first, and a real screen killed it: the project /// dashboard's columns are a heading and a list, and a column with no rows /// is a region that has content and a list that has none. Marking the /// region empty took the heading down with the rows. The emptiness belongs /// to the thing that is empty. /// /// [`Slot::readiness`] keeps the loading axis and only that, which is what /// `aria-busy` is about. /// /// # The state is the vocabulary's and the sentence is not /// /// `makeover-layout` names the four states because "nothing here yet" and /// "this broke" mean the same thing in every app that will have them. "No /// projects yet" is content, and so is the button under it, so both are /// here. A [`layout::Readiness::Ready`] renders nothing at all: the state /// that shows content has no stand-in to draw. StandIn { /// Which of the states this is standing in for. state: layout::Readiness, /// The sentence. "No projects yet", "Failed to load events". message: String, /// The way out, if there is one. "Add your first project", "Try again". /// /// 2 of goingson's 27 have one and 25 say a sentence and stop, which is /// why it is optional rather than a second required string. act: Option, }, /// One control, standing on its own. /// /// `14612ed8`. A [`Form`](Self::Form) is a set of questions asked together /// and answered at once. A settings screen is not that: goingson's is /// sections with headings between them, each holding one control that writes /// as soon as it changes, and wrapping those in a form would describe markup /// that is not there and a submit that does not exist. /// /// Almost always carries a [`Field::changes`], because a control with no /// form around it and no route on it collects a value nothing reads. /// /// Boxed because it is the only member holding a whole struct by value, and /// [`Field`] is the largest one here — every other member holds a `Vec`, a /// `String` or a small enum. Unboxed it decides the size of every [`Node`] /// in every list, and of the [`Response`](crate::Response) that carries one. Field(Box), /// Fields, and the route that submits them. Form { /// Where the answers go. Almost always a [`Method::Post`]. action: Action, /// What the submit control is called. submit: String, /// The questions, in order. fields: Vec, }, /// Rows of the same kind of thing. List { /// The rows, in order. rows: Vec, /// What is not shown, if anything is. /// /// `346567f9`. A described list of the first 50 of 400 tasks was /// indistinguishable from a described list of 50 tasks, so each app /// grew its own answer: goingson a 159-line pagination manager with two /// consumers that had each written it separately first, Balanced /// Breakfast four `loadMore` sites. Two idioms for one fact, and the /// fact is what belongs here — how much more there is and how to ask /// for it. Whether that becomes numbered pages, a load-more button or /// an infinite scroll is the renderer's. more: Option, }, /// Rows with named columns. Table { /// The columns, in order. Cells are positional against these. columns: Vec, /// The rows, in order. rows: Vec, }, /// Rows placed by when they happen, rather than in order. /// /// The third of the three ways this vocabulary says "several of the same /// kind of thing", and the last one to arrive. /// [`List`](Self::List) puts them in order, [`Table`](Self::Table) lines /// their parts up in columns, and this one puts them on a clock. /// /// A row here is an ordinary [`Row`] and gets no new members: the item /// bodies on goingson's day view are a title, a time, a tag and a tone, /// which the vocabulary already said. What it could not say is *where the /// row sits*, and that is [`layout::Placement`] — a start and a duration, /// two integers, which is the whole of what the timeline refusal was /// pricing as a component library. See `makeover-layout` 0.24.0. /// /// # What a renderer owes it /// /// Draw the span, put each row at its placement, and lay overlapping rows /// so both can be read. That last part is presentation and deliberately /// unspecified: a webview puts them in columns, a terminal may stack them /// with a marker, and neither is wrong. /// [`layout::Placement::overlaps`] is how a renderer finds the pairs /// without the description declaring them. /// /// # What it is not /// /// Not a calendar and not a kanban board. Both were refused alongside the /// timeline and neither has been measured; whoever needs one counts the /// members it is missing rather than reaching for this. Timeline { /// The axis: its window, its granularity, how often it labels itself. track: layout::Track, /// What sits on it, each with where it sits. /// /// Not sorted here, and a renderer must not assume it is. Sorting by /// start is presentation for anything that draws top to bottom, and /// meaningless for anything that does not. entries: Vec, /// A moment worth bringing into view, if any. /// /// "Show me 09:00" rather than a scroll offset in pixels. goingson's JS /// hardcodes `targetHour = 9` inside the renderer, which is the shape /// this replaces: the app knows the interesting hour, the renderer /// knows how to get there. /// /// `None` means the renderer chooses, which is usually the span's /// start. focus: Option, }, /// A control that picks between things. Select { /// Segmented, toggle, or tabs. kind: layout::Selector, /// What is on offer, and what each one calls if it calls something of /// its own. /// /// The tuple is [`Stats`](Self::Stats)' shape and it is here for the /// same reason, stated there: `makeover-layout` cannot name an action /// at all, so an address rides beside the described thing rather than /// inside it. [`Choice::as_layout`] hands back a value and a label and /// nothing else. /// /// It is what a tab strip needs. The MNW server's dashboard-user shell /// has fifteen tabs and fifteen routes; one strip-level action with the /// value substituted in cannot address them, and building the route by /// convention would put route construction in a renderer. /// /// An option carrying `None` falls back to /// [`action`](Self::Select::action) with its value under /// [`Self::SELECTED`], which is what every option did before the tuple, /// so a segmented control and a toggle are unchanged in meaning. options: Vec<(Choice, Option)>, /// Which option is currently picked, by its /// [`value`](Choice::value). chosen: Option, /// What picking an option calls, for the options that name nothing /// themselves. The picked value is sent under [`Self::SELECTED`]. action: Option, }, /// How much of a set is done. Meter(Meter), /// A value with a caption, several of them as one strip. /// /// `93c6a174`. Against `makeover-layout`'s [`layout::Figure`], which arrived /// at 0.11.0 for this. The dashboard shape: a large value over a small /// caption, several in a row. goingson had five of them across five screens /// with five class vocabularies for the one shape, and the port had been /// making each out of a [`Row`] with the caption as `primary` and the figure /// as `meta`, which reads backwards — a row's primary slot means the thing /// itself, and here the thing is the number. /// /// # Why the set is the node and not each figure /// /// Four tiles in a strip and four tiles down a column are different things, /// and a renderer handed one at a time cannot tell it is looking at a set. /// The objection to that is real and is answered by what is already here: a /// node whose value is its grouping sounds like a layout instruction, and /// [`List`](Self::List) and [`Table`](Self::Table) have been exactly that /// since the beginning without anyone calling them one. /// /// # Why the action is here and not on the figure /// /// One of goingson's five is a control — sync's "Not Applied: 3" opens the /// list. `makeover-layout` cannot name an action at all, so the figure it /// describes carries none, and this pairs the description with the address /// the same way [`Row`] pairs its parts with [`Row::activate`]. Stats { /// The figures, in order, and what each one calls if it calls anything. figures: Vec<(Figure, Option)>, }, /// A region inside a region. Region(Slot), } impl Node { /// The parameter name a [`Node::Select`] sends its picked value under. /// /// Named once here rather than agreed by convention between each renderer /// and each handler, which is how a value arrives under `tab` in one screen /// and `selected` in the next. pub const SELECTED: &'static str = "value"; /// The parameter name an [`Act::over`] sends each ticked value under. /// /// [`SELECTED`](Self::SELECTED)'s sibling, named here for the same reason: /// a convention agreed separately by each renderer and each handler is a /// convention that holds until one of them is written by someone else. /// /// Distinct from `SELECTED` rather than shared with it, because the two /// carry different counts. A [`Select`](Self::Select) sends one value and a /// handler reads it with [`Params::get`]; a selection sends however many /// are ticked, including none, and a handler reads it with /// [`Params::get_all`]. One name for both would make "the one thing picked" /// and "the first of the things ticked" the same read. /// /// [`Params::get`]: crate::Params::get /// [`Params::get_all`]: crate::Params::get_all pub const TICKED: &'static str = "ticked"; /// A page title. pub fn page(text: impl Into) -> Self { Self::Heading { level: layout::Heading::Page, text: text.into(), } } /// A section title. pub fn section(text: impl Into) -> Self { Self::Heading { level: layout::Heading::Section, text: text.into(), } } /// Ordinary prose. pub fn text(text: impl Into) -> Self { Self::Text { text: text.into(), tone: layout::Tone::Neutral, } } /// Prose written in markdown. pub fn rich(source: impl Into) -> Self { Self::Rich { source: source.into(), } } /// A control calling a route. pub fn act(label: impl Into, action: Action) -> Self { Self::Act(Act::new(label, action)) } /// A persistent message, dismissed by fixing what caused it. pub fn banner(tone: layout::Tone, text: impl Into) -> Self { Self::Notice { kind: layout::Notice::Banner, tone, text: text.into(), } } /// A transient message that dismisses itself. pub fn toast(tone: layout::Tone, text: impl Into) -> Self { Self::Notice { kind: layout::Notice::Toast, tone, text: text.into(), } } /// A list of rows. pub fn list(rows: impl IntoIterator) -> Self { Self::List { rows: rows.into_iter().collect(), more: None, } } /// The same list, saying there is more of it. /// /// A no-op on anything that is not a [`Self::List`], which is the one place /// this file allows that: the alternative is a constructor taking rows and a /// `Rest` together, and every call site that has no more rows then passes a /// `None` to say so. #[must_use] pub fn and_more(mut self, rest: Rest) -> Self { if let Self::List { more, .. } = &mut self { *more = Some(rest); } self } /// A proportion of a set, untoned and unlabelled. #[must_use] pub const fn meter(done: u32, total: u32) -> Self { Self::Meter(Meter::new(done, total)) } /// Nothing here yet. pub fn empty(message: impl Into) -> Self { Self::StandIn { state: layout::Readiness::Empty, message: message.into(), act: None, } } /// This did not load. pub fn failed(message: impl Into) -> Self { Self::StandIn { state: layout::Readiness::Failed, message: message.into(), act: None, } } /// The same stand-in, with a way out of it. /// /// A no-op on anything else, for the reason [`Self::and_more`] is one. #[must_use] pub fn offering(mut self, way_out: Act) -> Self { if let Self::StandIn { act, .. } = &mut self { *act = Some(way_out); } self } /// One control on its own, outside any form. pub fn field(field: Field) -> Self { Self::Field(Box::new(field)) } /// A strip of figures, none of which answers a click. pub fn stats(figures: impl IntoIterator) -> Self { Self::Stats { figures: figures.into_iter().map(|figure| (figure, None)).collect(), } } } /// A whole screen. /// /// [`Arrangement`](layout::Arrangement) is `makeover-layout`'s, and there are /// two of them because our apps have two: goingson is list-detail, Balanced /// Breakfast is sidebar plus content. Naming a third before an app has one is /// how a description becomes a framework. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Screen { /// What the screen is called. A window title, a tab title, a page heading. pub title: String, /// How the regions are laid out. pub arrangement: layout::Arrangement, /// The regions, in order. pub slots: Vec, /// Messages raised by whatever produced this screen. /// /// Separate from the slots because a notice belongs to the screen rather /// than to a place in it: which region a toast stacks in is the renderer's /// question, and a handler answering it would be describing a webview. pub notices: Vec, /// How this screen is found, shared and indexed. /// /// Not an `Option`. The default is meaningful — a screen nobody said /// anything about is an indexable website — and an `Option` would make /// "nobody said" and "indexable" two spellings of one thing. pub discovery: Discovery, /// The name of the set this screen's ticks go into, if it holds one. /// /// `5f2b8753`. [`Row::selected`] said a row could be ticked and nothing /// said what the tick was *for*, so the tick had nowhere to go: a webview /// hid the hole because the browser owns a checkbox's checked state, and /// every app then wrote its own JS to gather the boxes back up. A terminal /// could not hide it. It drew the `[ ]`, bound the key, and the key did /// nothing, which is worse than not drawing the box. /// /// So the screen names the set, each [`Row::value`] is what that row's tick /// contributes, and [`Act::over`] is how a control says it acts on the /// whole of it. The renderer holds the set the way `quasi-tui` already /// holds an edit buffer and a scroll offset, and the commit control reads /// it by name. /// /// # Ticking never writes /// /// Wiki `explicit-commit-affordance`, the general rule: a change that /// happens with no obvious indication is confusing, so a tick stages and /// the commit control is what locks it in. [`Row::toggle`] describes the /// other thing — screens where the tick *is* the write — and is left alone /// here rather than removed, because stopping those screens is work in the /// apps that have them. /// /// # One set per screen /// /// A screen with two independent sets has not been measured. Naming one is /// the smallest thing that closes the hole, and the field grows to a map /// when an app turns up wanting two, on the same rule every other member /// here arrived under. /// /// [`Row::selected`]: Row::selected /// [`Row::value`]: Row::value /// [`Act::over`]: Act::over pub selection: Option, /// How wide this screen's content runs. /// /// `0eccff0d`. Measured in the MNW server, where 69 of 72 templates carry /// one of three mutually exclusive CSS classes for it and nothing described /// it, so the choice lived in the template rather than in the screen. /// /// Beside [`arrangement`](Self::arrangement) and answering the level above /// it: that one divides the screen's width between regions, this says how /// much of the window the screen takes in the first place. Both are the /// description's, which is what answering `e0fd485e` and `0eccff0d` /// together settled. /// /// Not an `Option`, for [`discovery`](Self::discovery)'s reason. The /// default is meaningful -- a screen nobody said anything about uses the /// window it was given -- and an `Option` would make "nobody said" and /// "the whole width" two spellings of one thing. pub measure: layout::Measure, } /// How a screen is found, shared and indexed. /// /// Not presentation, which is why it is here and not in `makeover-layout`: a /// terminal ignores every field, the same way it ignores [`Slot::id`]. It is an /// address-and-identity fact, and that is the line that put [`Action`] in this /// crate rather than in the vocabulary. /// /// Measured before it was added. Every `og:*` value in the MNW server's 37 /// templates is one of four things interpolated from the entity the screen is /// about: a title, a summary sentence, an image URL, or the screen's own /// address. None of them needed knowledge only a handler has, which is what /// made this the screen's to say rather than the host's. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Discovery { /// Whether a crawler should index this screen. /// /// Defaults to indexable, because most screens are and a default that hides /// pages is a default that hides the bug. The six screens saying otherwise /// are purchased-content pages, and this field is why that is a fact the /// type carries rather than a line in a template that a conversion can drop /// in silence. pub indexable: bool, /// The sentence a link preview shows. [`Screen::title`] is the title. pub summary: Option, /// The image a link preview shows, as an absolute URL. pub image: Option, /// What kind of thing this screen is about. pub kind: SocialKind, /// The canonical address, when the screen answers at more than one. pub canonical: Option, } impl Default for Discovery { /// Indexable, and nothing else claimed. /// /// Written out rather than derived, and the reason is the one field that /// matters: `bool::default()` is `false`, so a derived impl would deindex /// every screen that never mentioned the subject, silently, and the failure /// would show up as traffic rather than as a test. fn default() -> Self { Self { indexable: true, summary: None, image: None, kind: SocialKind::Website, canonical: None, } } } /// What kind of thing a screen is about. /// /// The six the server actually emits, and no more. Naming a seventh before a /// screen has one is how a description becomes a framework, which is the /// argument [`Arrangement`](layout::Arrangement) is held to two screens by. /// /// `#[non_exhaustive]`, because a seventh arriving should not be a lockstep /// event across every renderer that spells one. The match below stays /// exhaustive: within this crate the attribute does not apply, and a wildcard /// here would only hide a member added without a spelling. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] #[non_exhaustive] pub enum SocialKind { /// A page. The default, and four of the server's screens. #[default] Website, /// Something written, with an author and a date. Article, /// A person or an account. Profile, /// Something for sale. Product, /// A video. Video, /// A piece of music. Song, } impl SocialKind { /// What this is spelled as in `og:type`. /// /// Named here rather than agreed between each renderer and each host, which /// is how one screen ends up `video.other` and the next `video`. #[must_use] pub const fn as_str(self) -> &'static str { match self { Self::Website => "website", Self::Article => "article", Self::Profile => "profile", Self::Product => "product", Self::Video => "video.other", Self::Song => "music.song", } } } impl Screen { /// An empty screen with the given arrangement. pub fn new(title: impl Into, arrangement: layout::Arrangement) -> Self { Self { title: title.into(), arrangement, slots: Vec::new(), notices: Vec::new(), discovery: Discovery::default(), selection: None, measure: layout::Measure::default(), } } /// How wide this screen's content runs, chaining. /// /// See [`measure`](Self::measure). [`Measure::Wide`](layout::Measure::Wide) /// is the default and does not need saying. #[must_use] pub const fn measured(mut self, measure: layout::Measure) -> Self { self.measure = measure; self } /// This screen holds a set of ticks under this name, chaining. /// /// The rows that join it say so with [`Row::ticking`], and the control that /// acts on it with [`Act::over`]. See [`selection`](Self::selection). #[must_use] pub fn selecting(mut self, name: impl Into) -> Self { self.selection = Some(name.into()); self } /// Whether a crawler should index this screen, chaining. #[must_use] pub fn indexed(mut self, indexable: bool) -> Self { self.discovery.indexable = indexable; self } /// The sentence a link preview shows, chaining. #[must_use] pub fn summarised(mut self, text: impl Into) -> Self { self.discovery.summary = Some(text.into()); self } /// The image a link preview shows, chaining. An absolute URL. #[must_use] pub fn illustrated(mut self, url: impl Into) -> Self { self.discovery.image = Some(url.into()); self } /// What kind of thing this screen is about, chaining. #[must_use] pub fn about(mut self, kind: SocialKind) -> Self { self.discovery.kind = kind; self } /// The address this screen should be known by, chaining. #[must_use] pub fn canonical_at(mut self, url: impl Into) -> Self { self.discovery.canonical = Some(url.into()); self } /// A list that chooses what the detail beside it shows. pub fn list_detail(title: impl Into, tabbed: bool) -> Self { Self::new(title, layout::Arrangement::list_detail(tabbed)) } /// Navigation down the side, content filling the rest. pub fn sidebar_content(title: impl Into) -> Self { Self::new(title, layout::Arrangement::sidebar_content()) } /// Add a region, chaining. #[must_use] pub fn with(mut self, slot: Slot) -> Self { self.slots.push(slot); self } /// Raise a message on this screen, chaining. /// /// # Panics /// /// If the node is not a [`Node::Notice`]. The field is typed as a [`Node`] /// so a renderer walks one kind of thing, and this is the constructor that /// keeps that from meaning anything can go in it. #[must_use] pub fn saying(mut self, notice: Node) -> Self { assert!( matches!(notice, Node::Notice { .. }), "Screen::saying takes a Node::Notice" ); self.notices.push(notice); self } /// The slot under this address, at any depth. #[must_use] pub fn slot(&self, id: &str) -> Option<&Slot> { self.slots.iter().find_map(|slot| slot.find(id)) } /// Apply a fragment: put `node` in the region under `region`, replacing /// whatever was there. Returns whether the region was found. /// /// This is what a host holding a `Screen` does with /// [`Outcome::Fragment`](crate::Outcome::Fragment). A webview host needs /// none of it -- `quasi-http` turns the same outcome into an `hx-retarget` /// header and the browser performs the swap against a document it already /// has -- but a host that retains the description rather than the markup /// has nothing between the fragment and the tree. /// /// It lives here and not in a host because applying a fragment is surgery /// on this crate's own type. A host writing it means every retained-screen /// host writes it separately and each picks its own answer for the three /// decisions below, which is the thing this crate's no-host-imports rule /// exists to prevent. /// /// **A region that is not there answers `false`, not a panic.** The caller /// is the one that can act on it: a host can fall back to a redraw, and a /// test can assert it. What is worth avoiding is the silent no-op, because /// a miss means a route naming a slot that no longer exists, and that is a /// description bug rather than a rendering one. /// /// **It replaces rather than appends.** `Outcome::Fragment` is one region's /// new contents, which is the whole reason it can be smaller than a screen. /// /// **The region becomes [`Ready`](layout::Readiness::Ready).** A fragment /// arriving is the content arriving, so a slot marked /// [`Pending`](layout::Readiness::Pending) while it was in flight stops /// being pending here. Emptiness is a different axis and rides on the node: /// a [`Node::StandIn`] carries its own state, and replacing with one is a /// region that is ready and has nothing to show. pub fn replace(&mut self, region: &str, node: Node) -> bool { let Some(slot) = self.slots.iter_mut().find_map(|slot| slot.find_mut(region)) else { return false; }; slot.body.clear(); slot.body.push(node); slot.readiness = layout::Readiness::Ready; true } } #[cfg(test)] mod tests { use super::*; fn frames(count: usize) -> Vec { (0..count) .map(|n| { Node::Image(Picture::new( format!("/frame-{n}.png"), format!("frame {n}"), )) }) .collect() } #[test] fn a_region_shows_everything_until_it_says_otherwise() { // The default has to be the old behaviour, or every description written // before this field existed changes meaning when it arrives. let pane = Slot::new("content", RegionKind::Pane).extend(frames(3)); assert_eq!(pane.showing, layout::Showing::All); assert_eq!(pane.current(), None); } #[test] fn a_carousel_with_no_stated_frame_is_on_its_first() { // `Showing::One` says exactly one is up, so there is no honest reading // of a missing index other than the first. A renderer never has to // decide this for itself, which is the point of the method. let mut carousel = Slot::widget("shots", "carousel").extend(frames(3)); carousel.showing = layout::Showing::One; assert_eq!(carousel.current(), Some(0)); } #[test] fn a_frame_past_the_end_clamps_rather_than_vanishing() { // An out-of-range index is an app bug either way. Clamping reports it as // a carousel stuck on its last frame, which is findable; drawing nothing // reports it as a region that disappeared, which is not. let carousel = Slot::widget("shots", "carousel") .extend(frames(3)) .showing_one(9); assert_eq!(carousel.current(), Some(2)); // And an empty body has no frame to clamp to. assert_eq!( Slot::widget("shots", "carousel").showing_one(0).current(), None ); } #[test] fn a_closed_disclosure_is_the_one_selective_region_showing_nothing() { let closed = Slot::widget("details", "disclosure") .extend(frames(1)) .showing_at_most_one(None); let open = Slot::widget("details", "disclosure") .extend(frames(1)) .showing_at_most_one(Some(0)); assert_eq!(closed.current(), None); assert_eq!(open.current(), Some(0)); // Closed and `Showing::All` answer the same here on purpose: they differ // in the chrome around the body, not in what a renderer does with it. assert!(closed.showing.selective()); } #[test] fn labels_are_all_or_nothing() { // A strip with a hole in it is worse than the prev/next row it would // have replaced, so a half-labelled body gets the row. let tabs = Slot::new("detail", RegionKind::TabGroup) .with(Node::Region( Slot::new("overview", RegionKind::Pane).label("Overview"), )) .with(Node::Region( Slot::new("files", RegionKind::Pane).label("Files"), )); assert_eq!(tabs.labels(), ["Overview", "Files"]); let half = tabs .clone() .with(Node::Region(Slot::new("history", RegionKind::Pane))); assert!(half.labels().is_empty()); } #[test] fn a_carousels_frames_carry_no_label_and_that_is_the_switch() { // Which idiom a renderer draws falls out of this rather than out of the // widget's name. A frame has a caption; only a region has a tab name. let carousel = Slot::widget("shots", "carousel") .extend(frames(3)) .showing_one(1); assert!(carousel.labels().is_empty()); assert_eq!(carousel.current(), Some(1)); } }