Skip to main content

max / quasi

105.1 KB · 2680 lines History Blame Raw
1 //! The screen tree: what a route answers with.
2 //!
3 //! Decision 3 on the wiki note puts this in quasi rather than in
4 //! `makeover-layout`, for two reasons. The tree will churn while the router is
5 //! being proven against a second host, and churning it here costs nothing where
6 //! churning it there is a breaking release against three adopters. And a route
7 //! is an address, which is the one thing `makeover-layout`'s deferral rule says
8 //! it never names.
9 //!
10 //! # What this crate adds, and what it does not
11 //!
12 //! It adds three things: an [`Action`], which is an address; a [`Slot`], which
13 //! is a region with a name a fragment can be aimed at; and ownership.
14 //!
15 //! Everything else is `makeover-layout`'s. Every member of [`Node`] composes a
16 //! vocabulary that already exists there, and that is the admission test for a
17 //! new one: if the thing being drawn has no name in the description layer, it
18 //! does not get a node here, it gets a [`Region::Bespoke`] or it gets named
19 //! there first. Without that rule this file becomes a widget library, which is
20 //! the failure `makeover-layout` was extracted to prevent.
21 //!
22 //! # Why these are owned when the description layer is borrowed
23 //!
24 //! `makeover-layout`'s structs borrow, because a description is built, read
25 //! once and dropped inside one frame. A router's answer outlives its handler by
26 //! construction: it is returned from a function, and its text is usually built
27 //! from state rather than found in it. So [`Field`], [`Choice`] and [`Column`]
28 //! have owned mirrors here, each with a conversion back, and the conversion is
29 //! what keeps them from drifting: adding a field over there stops the mirror
30 //! compiling over here.
31
32 use makeover_layout as layout;
33
34 use crate::containment::{Containment, Element};
35 use crate::request::{Method, Params};
36
37 /// Where an action goes.
38 ///
39 /// Added 2026-08-08, found by the goingson contacts screen. A contact's social
40 /// handle and custom field both carry a URL that points out of the app
41 /// entirely, and until this existed there was nothing to say about it: an
42 /// action was a route, a route is something this app answers, and an address
43 /// somewhere else is not. The port put the URL in the row's trailing text,
44 /// which made it something to copy rather than something to follow.
45 ///
46 /// # Why this and not a separate link node
47 ///
48 /// Both were on the table. A destination keeps one concept where there would
49 /// have been two, and the cost is that every renderer now branches: a webview
50 /// emits an anchor rather than a button, and a terminal has to decide whether
51 /// it can open a browser or should show the address. That branch is honest
52 /// work, and it is work each renderer must do anyway once external addresses
53 /// exist at all.
54 ///
55 /// What it must never become is a guess. The renderer branches on this enum and
56 /// never on the shape of the string, because "starts with https" is how a route
57 /// named `/https-setup` ends up opening a browser.
58 #[derive(Debug, Clone, PartialEq, Eq, Hash)]
59 pub enum Destination {
60 /// A path this app's router answers.
61 Route(String),
62 /// An address outside the app. Nothing here will ever call it.
63 External(String),
64 }
65
66 /// An empty route rather than an empty external address, so a half-built
67 /// [`Action`] is something this app would answer rather than somewhere it would
68 /// send a user. Same reasoning as [`Method`]'s default being the safe verb.
69 ///
70 /// Written out because `#[default]` only applies to unit variants.
71 impl Default for Destination {
72 fn default() -> Self {
73 Self::Route(String::new())
74 }
75 }
76
77 impl Destination {
78 /// The route path, if it is one.
79 ///
80 /// `None` for an external address, which is the answer a host wants when it
81 /// is deciding whether it can dispatch something.
82 #[must_use]
83 pub fn route(&self) -> Option<&str> {
84 match self {
85 Self::Route(path) => Some(path),
86 Self::External(_) => None,
87 }
88 }
89
90 /// The address as written, whichever kind it is.
91 ///
92 /// For rendering only. A host deciding whether to dispatch wants
93 /// [`route`](Self::route), which cannot hand back something uncallable.
94 #[must_use]
95 pub fn as_str(&self) -> &str {
96 let (Self::Route(address) | Self::External(address)) = self;
97 address
98 }
99
100 /// Whether it leaves the app.
101 #[must_use]
102 pub const fn is_external(&self) -> bool {
103 matches!(self, Self::External(_))
104 }
105 }
106
107 /// An address a control calls when it acts.
108 ///
109 /// Decision 2: an action is a route. The webview emits this as an `hx-get` or
110 /// `hx-post`, the terminal binds a key to it, egui calls it directly. All three
111 /// are calling the same path with the same verb.
112 ///
113 /// Since 2026-08-08 a route is not the only thing it can be: see
114 /// [`Destination`]. Decision 2 still holds for everything the app answers, and
115 /// an external address is the case it never covered.
116 ///
117 /// It does not carry a target. What a response replaces is the *response's*
118 /// business, per decision 7, because the router is the only party that knows
119 /// what it just changed.
120 #[derive(Debug, Clone, PartialEq, Eq, Default)]
121 pub struct Action {
122 /// Asking or telling.
123 ///
124 /// Meaningless for a [`Destination::External`], which is nobody's route to
125 /// answer. Left on the struct rather than moved inside `Destination`
126 /// because a method that is ignored is simpler than two shapes of action.
127 pub method: Method,
128 /// Where it goes.
129 pub destination: Destination,
130 /// Values the control sends that are not in the path.
131 ///
132 /// A webview emits these as `hx-vals`; a terminal passes them straight
133 /// through. Here so that no app hand-builds a query string, which is where
134 /// escaping bugs live.
135 ///
136 /// Empty on a read. A read has nothing to send: its values are its address,
137 /// so [`Action::with`] puts them in [`Self::carried`] instead. See
138 /// [`Request`](crate::Request) for the whole of why the two are separate.
139 pub params: Params,
140 /// The view this control was offered under.
141 ///
142 /// A filtered list sends its filters on every control it draws, so that
143 /// pressing one answers with the list you were looking at rather than with a
144 /// default. Kept apart from [`Self::params`] because a screen that filters
145 /// on `status` and also writes a `status` would otherwise have one name for
146 /// two things, and the handler would read whichever landed first.
147 ///
148 /// Emitted as the query string, on the address itself, which is where a view
149 /// belongs: the link is then the view, and a middle-click reaches the same
150 /// place the control does.
151 pub carried: Params,
152 /// The region this call's answer replaces, when the responder cannot say.
153 ///
154 /// Normally nothing sets this and nothing should: a described route answers
155 /// with a `Response::Fragment` naming the region it changed, `quasi-http`
156 /// turns that into the transport's retarget header, and the router is the
157 /// only party that knows what it just changed. That is decision 7 and it is
158 /// unchanged.
159 ///
160 /// **Decision 7 assumes the responder is described, and a control may call
161 /// a route that is not.** Every write on the MNW server's dashboard goes to
162 /// a plain API route that quasi never sees and that answers with a status or
163 /// a hand-rendered fragment. Those routes cannot name a region, so if the
164 /// control does not either, nobody does: the answer lands wherever the
165 /// transport's default puts it, which for htmx is inside the button that was
166 /// pressed. That is not a second party deciding one thing. It is the only
167 /// party that can decide, because the other one is outside the description
168 /// layer.
169 ///
170 /// So: leave it unset when calling a described route, and set it when
171 /// calling something else. A screen that sets it against a described route
172 /// is overriding an answer that already knew better, and that is the misuse
173 /// decision 7 was guarding against.
174 pub replaces: Option<String>,
175 /// The name to keep the answer under, when the answer is a file.
176 ///
177 /// `Some` means the response is not a view: nothing swaps, and the reader
178 /// ends up holding a file called this. A webview makes that a browser
179 /// download; a terminal writes it to disk; either way the screen said what
180 /// it meant rather than a class name on a button implying it.
181 ///
182 /// Counted before adding it. Nine sites in the MNW server: five CSV export
183 /// buttons across four dashboard templates, a sixth in the item-sales tab's
184 /// own script, and three anchors carrying a `download` attribute. Six of the
185 /// nine are writes, which is what makes this a property of the action rather
186 /// than a kind of destination: a write cannot be a plain link, so the host
187 /// has to be told, and until now it was told by
188 /// `data-action="exportCsvButton"` plus two positional arguments.
189 ///
190 /// Independent of [`method`](Self::method). A read that saves is an anchor
191 /// the browser downloads instead of navigating to; a write that saves has to
192 /// be performed and then handed to the reader. Both are the same sentence
193 /// here and differ only in the emitting.
194 pub saves: Option<String>,
195 }
196
197 impl Action {
198 /// A read.
199 pub fn get(path: impl Into<String>) -> Self {
200 Self {
201 method: Method::Get,
202 destination: Destination::Route(path.into()),
203 params: Params::new(),
204 carried: Params::new(),
205 saves: None,
206 replaces: None,
207 }
208 }
209
210 /// A write.
211 pub fn post(path: impl Into<String>) -> Self {
212 Self {
213 method: Method::Post,
214 destination: Destination::Route(path.into()),
215 params: Params::new(),
216 carried: Params::new(),
217 saves: None,
218 replaces: None,
219 }
220 }
221
222 /// A write that removes what is at the address.
223 ///
224 /// `61e1b069`. Reach for it when the route the app already answers is a
225 /// `DELETE`, not to editorialise about what a `POST` means: the verb here
226 /// exists to address an interface, and a route that deletes over `POST` is
227 /// still [`post`](Self::post).
228 pub fn delete(path: impl Into<String>) -> Self {
229 Self {
230 method: Method::Delete,
231 destination: Destination::Route(path.into()),
232 params: Params::new(),
233 carried: Params::new(),
234 saves: None,
235 replaces: None,
236 }
237 }
238
239 /// A write that replaces what is at the address.
240 pub fn put(path: impl Into<String>) -> Self {
241 Self {
242 method: Method::Put,
243 destination: Destination::Route(path.into()),
244 params: Params::new(),
245 carried: Params::new(),
246 saves: None,
247 replaces: None,
248 }
249 }
250
251 /// Somewhere outside the app.
252 ///
253 /// [`Method::Get`], because following a link asks and does not tell, and a
254 /// host that ignores the method loses nothing by it.
255 pub fn external(url: impl Into<String>) -> Self {
256 Self {
257 method: Method::Get,
258 destination: Destination::External(url.into()),
259 params: Params::new(),
260 carried: Params::new(),
261 saves: None,
262 replaces: None,
263 }
264 }
265
266 /// Put this call's answer into the region with this id.
267 ///
268 /// For a route the description layer does not serve. See
269 /// [`replaces`](Self::replaces) before reaching for it.
270 #[must_use]
271 pub fn replacing(mut self, region: impl Into<String>) -> Self {
272 self.replaces = Some(region.into());
273 self
274 }
275
276 /// Keep the answer as a file with this name, rather than showing it.
277 #[must_use]
278 pub fn saving(mut self, filename: impl Into<String>) -> Self {
279 self.saves = Some(filename.into());
280 self
281 }
282
283 /// The route this calls, if it calls one.
284 #[must_use]
285 pub fn route(&self) -> Option<&str> {
286 self.destination.route()
287 }
288
289 /// Send a value along with the call.
290 ///
291 /// On a write this is the payload: what the control is telling the route.
292 /// On a read it is the address, because a read sends nothing and its values
293 /// are where it goes — so this lands in [`Self::carried`] rather than in
294 /// [`Self::params`], and a read's two bags are never both populated.
295 ///
296 /// That is what keeps the rule one sentence at the reading end: a filter is
297 /// in `carried` whichever verb offered it.
298 #[must_use]
299 pub fn with(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
300 if self.method.mutates() {
301 self.params.insert(name, value);
302 } else {
303 self.carried.insert(name, value);
304 }
305 self
306 }
307
308 /// Keep this control pointed at the view it was offered under.
309 ///
310 /// What a filtered screen puts on every control it draws. Distinct from
311 /// [`Self::with`] on a write, and the same thing as it on a read.
312 #[must_use]
313 pub fn carrying(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
314 self.carried.insert(name, value);
315 self
316 }
317 }
318
319 /// A small labelled thing: a badge, a chip, a tag.
320 ///
321 /// Its own struct as of 2026-08-08, having been the inline payload of
322 /// [`Node::Token`]. Extracted because a row can carry these now
323 /// ([`Row::tokens`], against `makeover-layout`'s `RowPart::Tokens`), and the
324 /// alternative was defining the same five fields twice and watching them drift.
325 ///
326 /// The tone rides on the tag rather than on whatever holds it, which is what
327 /// lets a strip of them say different things: a neutral type and an amber
328 /// status, side by side in one row.
329 ///
330 /// No `Hash`, because it can hold an [`Action`], which holds [`Params`], which
331 /// is a `Vec`. Same derive set as `Action` for that reason.
332 #[derive(Debug, Clone, PartialEq, Eq)]
333 pub struct Tag {
334 /// Whether it answers a click, and whether it can be removed.
335 pub kind: layout::Token,
336 /// What it says.
337 pub label: String,
338 /// What it is saying.
339 pub tone: layout::Tone,
340 /// Whether it is currently held down. Only meaningful for a chip.
341 pub latched: bool,
342 /// What clicking it calls, if it answers a click.
343 pub action: Option<Action>,
344 }
345
346 impl Tag {
347 /// A neutral badge: it says something and answers nothing.
348 pub fn badge(label: impl Into<String>) -> Self {
349 Self {
350 kind: layout::Token::Badge,
351 label: label.into(),
352 tone: layout::Tone::Neutral,
353 latched: false,
354 action: None,
355 }
356 }
357
358 /// A chip that calls a route when clicked.
359 ///
360 /// Not removable. A removable chip is a different control with a different
361 /// affordance, so it says so rather than being inferred from carrying an
362 /// action.
363 pub fn chip(label: impl Into<String>, action: Action) -> Self {
364 Self {
365 kind: layout::Token::Chip { removable: false },
366 label: label.into(),
367 tone: layout::Tone::Neutral,
368 latched: false,
369 action: Some(action),
370 }
371 }
372
373 /// Set what it is saying.
374 #[must_use]
375 pub const fn tone(mut self, tone: layout::Tone) -> Self {
376 self.tone = tone;
377 self
378 }
379
380 /// Hold it down. Only meaningful for a chip.
381 #[must_use]
382 pub const fn latched(mut self, latched: bool) -> Self {
383 self.latched = latched;
384 self
385 }
386 }
387
388 /// One option offered by a field, owned.
389 ///
390 /// The borrowed original is `makeover-layout`'s [`layout::Choice`]. Two strings
391 /// rather than one for the reason recorded there: the submitted value and the
392 /// read label are different facts, and every renderer that collapsed them has
393 /// had to un-collapse them later.
394 #[derive(Debug, Clone, PartialEq, Eq, Hash)]
395 pub struct Choice {
396 /// What is submitted.
397 pub value: String,
398 /// What is read.
399 pub label: String,
400 }
401
402 impl Choice {
403 /// An option whose submitted value is also its label.
404 pub fn plain(value: impl Into<String>) -> Self {
405 let value = value.into();
406 Self {
407 label: value.clone(),
408 value,
409 }
410 }
411
412 /// An option that reads differently from what it submits.
413 pub fn new(value: impl Into<String>, label: impl Into<String>) -> Self {
414 Self {
415 value: value.into(),
416 label: label.into(),
417 }
418 }
419
420 /// Borrow as the description layer's own type.
421 #[must_use]
422 pub fn as_layout(&self) -> layout::Choice<'_> {
423 layout::Choice {
424 value: &self.value,
425 label: &self.label,
426 }
427 }
428 }
429
430 /// One figure with a caption, owned.
431 ///
432 /// The borrowed original is [`layout::Figure`], and everything it says applies:
433 /// the value is text because only the app knows whether the number is a
434 /// percentage, a duration or a ratio, and the tone is carried because no
435 /// renderer can work out that a streak of zero is worth colouring.
436 ///
437 /// What it calls, if it calls anything, is not here. That is an address, which
438 /// `makeover-layout` never names, and it rides beside the figure in
439 /// [`Node::Stats`] the way [`Row::activate`] rides beside a row's parts.
440 #[derive(Debug, Clone, PartialEq, Eq, Hash)]
441 pub struct Figure {
442 /// The number, formatted the way the app means it to read.
443 pub value: String,
444 /// What it counts. The caption under the value.
445 pub caption: String,
446 /// How the value has moved, if the app is tracking that.
447 ///
448 /// Mirrors `layout::Figure::change`, added there at 0.13.0. Text for the
449 /// same reason [`value`](Self::value) is: only the app knows whether a move
450 /// reads as `+12.5%`, `+3` or `2x`.
451 ///
452 /// This is what [`tone`](Self::tone) was for. Counted before adding it: the
453 /// MNW server has four screens whose stat card is a label, a value and a
454 /// delta, and on all four the delta is the toned part while the number
455 /// itself is an ordinary fact. Without it the delta folds into the caption,
456 /// which loses the tone and turns a second smaller line into a longer first
457 /// one.
458 pub change: Option<String>,
459 /// What the figure means. [`layout::Tone::Neutral`] is an ordinary fact.
460 ///
461 /// Applies to [`change`](Self::change) where there is one, and to the value
462 /// where there is not. The renderer decides which element that lands on.
463 pub tone: layout::Tone,
464 }
465
466 impl Figure {
467 /// A figure that is an ordinary fact.
468 pub fn new(value: impl Into<String>, caption: impl Into<String>) -> Self {
469 Self {
470 value: value.into(),
471 caption: caption.into(),
472 change: None,
473 tone: layout::Tone::Neutral,
474 }
475 }
476
477 /// How the value has moved.
478 #[must_use]
479 pub fn change(mut self, change: impl Into<String>) -> Self {
480 self.change = Some(change.into());
481 self
482 }
483
484 /// What the figure means.
485 #[must_use]
486 pub const fn tone(mut self, tone: layout::Tone) -> Self {
487 self.tone = tone;
488 self
489 }
490
491 /// Borrow as the description layer's own type.
492 #[must_use]
493 pub fn as_layout(&self) -> layout::Figure<'_> {
494 layout::Figure {
495 value: &self.value,
496 caption: &self.caption,
497 change: self.change.as_deref(),
498 tone: self.tone,
499 }
500 }
501 }
502
503 /// What a list has that it is not showing.
504 ///
505 /// Deliberately not virtual scrolling, which is the neighbouring thing and is
506 /// not a description concern: goingson's `virtual-scroller.js` windows rows the
507 /// app already holds, which is a renderer performance technique. This is a fact
508 /// about the data — there are rows that were never fetched — and only the thing
509 /// that fetched them knows it.
510 #[derive(Debug, Clone, PartialEq, Eq)]
511 pub struct Rest {
512 /// How many more there are, when that is known.
513 ///
514 /// `None` is honest and common: a query that asked for 51 to find out
515 /// whether there were more than 50 knows that there are, and not how many.
516 /// A renderer with a count can say "50 of 400" and one without can still
517 /// offer the way forward.
518 pub remaining: Option<u32>,
519 /// What asking for more calls.
520 pub action: Action,
521 }
522
523 impl Rest {
524 /// There is more, reached this way, and the count is not known.
525 #[must_use]
526 pub const fn more(action: Action) -> Self {
527 Self {
528 remaining: None,
529 action,
530 }
531 }
532
533 /// How many more there are.
534 #[must_use]
535 pub const fn remaining(mut self, remaining: u32) -> Self {
536 self.remaining = Some(remaining);
537 self
538 }
539 }
540
541 /// Prose in a row part: what it says, and whether it is markdown.
542 ///
543 /// `secondary` has always been a `String`, and three call sites had markdown to
544 /// put in it: the goingson projects card's description, the mail list's body
545 /// preview, and a contact's note next. Each put the **source** in, so a row read
546 /// `**Ships Q3.** See [the brief](https://...)` where the screen it stands in
547 /// for reads the sentence. Flattening at the call site fixes what the user sees
548 /// and loses the fact on the way: a renderer receiving the row cannot tell text
549 /// an author typed from markdown somebody already flattened, so it cannot decide
550 /// for itself, and the flattening is copied per site.
551 ///
552 /// This is [`Meter`]'s answer, not [`Node`]'s. The row still holds no node --
553 /// the 2026-08-08 ruling, and the door through which a description becomes a
554 /// templating language -- it holds a two-case value saying which of two things
555 /// its string is. A webview renders the markdown inline, a terminal can emit
556 /// bold, and a renderer that wants neither flattens it, each from the same
557 /// description.
558 ///
559 /// [`Text`](Self::Text) is the default in every sense: `From<&str>` and
560 /// `From<String>` both produce it, so `.secondary("...")` means what it always
561 /// meant and no existing call site changes.
562 #[derive(Debug, Clone, PartialEq, Eq)]
563 pub enum Prose {
564 /// Text as written. A renderer escapes it and draws it, and nothing in it
565 /// is markup however it is punctuated.
566 Text(String),
567 /// Markdown source, carried as source for the reason [`Node::Rich`] does:
568 /// every renderer has an honest answer because each renders it its own way,
569 /// and nothing here is markup a renderer has to trust.
570 Rich(String),
571 }
572
573 impl Prose {
574 /// Markdown, to be rendered by whoever draws it.
575 pub fn rich(source: impl Into<String>) -> Self {
576 Self::Rich(source.into())
577 }
578
579 /// The string, whichever case this is.
580 ///
581 /// For a renderer that treats both the same, and for a test that does not
582 /// care. A renderer that draws this without looking at the case is drawing
583 /// markdown as text, which is the bug this type exists to make visible
584 /// rather than impossible.
585 #[must_use]
586 pub fn source(&self) -> &str {
587 match self {
588 Self::Text(text) | Self::Rich(text) => text,
589 }
590 }
591
592 /// Whether there is anything to draw.
593 #[must_use]
594 pub fn is_empty(&self) -> bool {
595 self.source().is_empty()
596 }
597 }
598
599 impl From<String> for Prose {
600 fn from(text: String) -> Self {
601 Self::Text(text)
602 }
603 }
604
605 impl From<&str> for Prose {
606 fn from(text: &str) -> Self {
607 Self::Text(text.to_owned())
608 }
609 }
610
611 impl From<&String> for Prose {
612 fn from(text: &String) -> Self {
613 Self::Text(text.clone())
614 }
615 }
616
617 /// How much of a set is done, owned.
618 ///
619 /// The borrowed original is [`layout::Meter`], which arrived at 0.10.0 for this.
620 /// Before it, a screen with a progress bar concatenated the two numbers into its
621 /// heading — "Subtasks 3/7" — which keeps both facts and loses the reading, the
622 /// same way a toned status badge read as prose before [`Row::tokens`].
623 ///
624 /// Its own struct as of 0.11.0, having been the inline payload of
625 /// [`Node::Meter`]. Extracted for the reason [`Tag`] was: a row can carry one
626 /// now ([`Row::meter`], against `makeover-layout`'s `RowPart::Proportion`), and
627 /// the alternative was defining the same four fields twice and watching them
628 /// drift.
629 ///
630 /// Carries the pair rather than a percentage for the reason [`layout::Meter`]
631 /// gives: a bar that is full because it landed exactly and one that is full
632 /// because it ran over are the same width and not the same fact.
633 ///
634 /// This is a proportion of a set and not the progress of an operation. A running
635 /// timer or a fetch is imperative and live, and a screen is described once per
636 /// answer; [`layout::Readiness::Pending`] and a [`layout::Notice::Toast`] are
637 /// what those get.
638 #[derive(Debug, Clone, PartialEq, Eq, Hash)]
639 pub struct Meter {
640 /// How much is done. May exceed [`total`](Self::total).
641 pub done: u32,
642 /// How much there is to do.
643 pub total: u32,
644 /// What the proportion means. No renderer can derive this.
645 pub tone: layout::Tone,
646 /// What is being counted: "subtasks", "tasks". The noun, not the ratio.
647 pub label: Option<String>,
648 }
649
650 impl Meter {
651 /// A proportion, untoned and unlabelled.
652 #[must_use]
653 pub const fn new(done: u32, total: u32) -> Self {
654 Self {
655 done,
656 total,
657 tone: layout::Tone::Neutral,
658 label: None,
659 }
660 }
661
662 /// What the proportion means.
663 #[must_use]
664 pub const fn tone(mut self, tone: layout::Tone) -> Self {
665 self.tone = tone;
666 self
667 }
668
669 /// What is being counted. The noun, not the ratio.
670 #[must_use]
671 pub fn label(mut self, label: impl Into<String>) -> Self {
672 self.label = Some(label.into());
673 self
674 }
675
676 /// Borrow as the description layer's own type.
677 #[must_use]
678 pub fn as_layout(&self) -> layout::Meter<'_> {
679 layout::Meter {
680 done: self.done,
681 total: self.total,
682 tone: self.tone,
683 label: self.label.as_deref(),
684 }
685 }
686 }
687
688 /// One field of a form, owned.
689 ///
690 /// The borrowed original is [`layout::Field`], and everything it says about
691 /// what a field carries applies unchanged, with one addition that does not
692 /// travel down to it: [`value`](Self::value).
693 ///
694 /// # Why the value lives here and not in `makeover-layout`
695 ///
696 /// `1c4a66a4`, decided 2026-08-09. [`layout::Field`] refuses to carry the
697 /// current value, and that refusal is right: an immediate-mode renderer writes
698 /// through a `&mut String` the app owns, and a terminal keeps an edit buffer,
699 /// so a description carrying a live value would need a way to write it back and
700 /// would then be a form model.
701 ///
702 /// What is carried here is not a live value. It is what to re-offer after a
703 /// submission was refused, and it has [`error`](Self::error)'s lifecycle rather
704 /// than a live value's: per-submission, one way, supplied by whoever validated,
705 /// gone on the next request. `error` already sits in this struct on exactly
706 /// those terms.
707 ///
708 /// The reason it is this crate's field and not the vocabulary's is that only a
709 /// stateless request and response destroys the value. In egui and in a terminal
710 /// the buffer never went anywhere, so nothing is lost and there is nothing to
711 /// re-offer. This is the layer where the loss happens, so this is the layer that
712 /// repairs it.
713 ///
714 /// # A field's described state is its value, and the caret is the renderer's
715 ///
716 /// `d52884b0`, decided 2026-08-12. Nothing here carries a caret position, and
717 /// nothing in [`layout::FieldKind`] does either. A description names the field
718 /// and, where it has one, its completion source. Where the caret sits is how a
719 /// renderer decides what to offer from that source.
720 ///
721 /// The question came from goingson's `search.js`, whose completion list depends
722 /// on which token the caret is inside rather than on the value: it reads
723 /// `selectionStart`, listens for caret moves that change nothing else, and
724 /// writes the caret back when a suggestion is applied. That is a real
725 /// dependency, and it still does not belong here. A caret is where the user is
726 /// pointing inside a control, the same class of fact as a scroll offset and a
727 /// focus position, and this stack already puts those in the renderer's view
728 /// rather than in the description (`quasi-tui`'s `View`).
729 ///
730 /// Growing this struct to (value, caret) was rejected: it is the most-consumed
731 /// member in the vocabulary, every renderer would owe it an answer, and a
732 /// terminal's answer would be a second cursor concept beside the one the runtime
733 /// already holds. The measured demand was one file.
734 ///
735 /// Reversible if a second consumer appears that needs the caret described rather
736 /// than held, such as a completion that has to survive a fragment swap. That is
737 /// a member here and a cascade, the same shape as every other addition.
738 ///
739 /// No `Hash`, for the reason [`Tag`] has none: it can hold an [`Action`], which
740 /// holds [`Params`], which is a `Vec`.
741 #[derive(Debug, Clone, PartialEq, Eq)]
742 pub struct Field {
743 /// What kind of value it takes.
744 pub kind: layout::FieldKind,
745 /// The name the value is submitted under, and the name the handler reads
746 /// back out of [`Params`].
747 pub name: String,
748 /// What the user is asked for.
749 pub label: String,
750 /// Standing help.
751 pub hint: Option<String>,
752 /// What is currently wrong with the value. Supplied by whoever validated;
753 /// nothing here decides that a value is wrong.
754 pub error: Option<String>,
755 /// Ghost text shown while the field is empty.
756 pub placeholder: Option<String>,
757 /// The options offered, in order. Empty for kinds that offer none.
758 pub options: Vec<Choice>,
759 /// Whether the form refuses to submit without it.
760 pub required: bool,
761 /// The longest the value may be, in characters.
762 ///
763 /// The borrowed original's [`layout::Field::max_length`], and everything it
764 /// says applies: the description carries the rule, the renderer emits its
765 /// host's idiom, and deciding a value is wrong stays with whoever validated.
766 pub max_length: Option<u32>,
767 /// The lowest value accepted, written the way the host writes one.
768 pub min: Option<String>,
769 /// The highest value accepted. See [`min`](Self::min).
770 pub max: Option<String>,
771 /// Whether the field lives behind a "more options" disclosure.
772 pub extended: bool,
773 /// What to put back in the box: what was submitted, when a submission was
774 /// refused and the form is being offered again.
775 ///
776 /// `None` on a first showing, which is every form that is not answering a
777 /// refusal. A checkbox is here by presence, the way HTML submits one: a
778 /// value means ticked and `None` means not.
779 ///
780 /// A [`layout::FieldKind::Secret`] never gets one. [`Field::value`] refuses
781 /// to set it and every renderer refuses to emit it, so the guarantee does
782 /// not rest on either alone.
783 pub value: Option<String>,
784 /// What changing this calls, for a control that writes on its own rather
785 /// than waiting for a submit.
786 ///
787 /// `14612ed8`. A field inside a [`Node::Form`] submits with the form and
788 /// needs nothing here. A settings toggle is the other kind: there is no
789 /// submit, and changing the control *is* the write. goingson had 13 of these
790 /// and reached them through `dispatch.js`, 109 lines of its own event
791 /// plumbing, because nothing in the description could say it. No version of
792 /// spinning up an app quickly has each app hand-rolling a dispatcher.
793 ///
794 /// The route receives the value under this field's [`name`](Self::name),
795 /// which is the same name a submit would have sent it under. Nothing else
796 /// changes about the field.
797 pub changes: Option<Action>,
798 }
799
800 impl Field {
801 /// A plain optional field of the given kind.
802 pub fn new(kind: layout::FieldKind, name: impl Into<String>, label: impl Into<String>) -> Self {
803 Self {
804 kind,
805 name: name.into(),
806 label: label.into(),
807 hint: None,
808 error: None,
809 placeholder: None,
810 options: Vec::new(),
811 required: false,
812 max_length: None,
813 min: None,
814 max: None,
815 extended: false,
816 value: None,
817 changes: None,
818 }
819 }
820
821 /// Changing this writes, without waiting for a submit.
822 #[must_use]
823 pub fn changes(mut self, action: Action) -> Self {
824 self.changes = Some(action);
825 self
826 }
827
828 /// A select offering the given options.
829 pub fn select(name: impl Into<String>, label: impl Into<String>, options: Vec<Choice>) -> Self {
830 Self {
831 options,
832 ..Self::new(layout::FieldKind::Select, name, label)
833 }
834 }
835
836 /// A radio group offering the given options.
837 pub fn radio(name: impl Into<String>, label: impl Into<String>, options: Vec<Choice>) -> Self {
838 Self {
839 options,
840 ..Self::new(layout::FieldKind::Radio, name, label)
841 }
842 }
843
844 /// The form refuses to submit without it.
845 #[must_use]
846 pub fn required(mut self) -> Self {
847 self.required = true;
848 self
849 }
850
851 /// Standing help, shown whether or not anything is wrong.
852 #[must_use]
853 pub fn hint(mut self, hint: impl Into<String>) -> Self {
854 self.hint = Some(hint.into());
855 self
856 }
857
858 /// What is wrong with the value now.
859 #[must_use]
860 pub fn error(mut self, error: impl Into<String>) -> Self {
861 self.error = Some(error.into());
862 self
863 }
864
865 /// Whether the field is currently reporting a problem.
866 #[must_use]
867 pub fn invalid(&self) -> bool {
868 self.error.is_some()
869 }
870
871 /// Put this back in the box when the form is offered again.
872 ///
873 /// A [`layout::FieldKind::Secret`] keeps `None` whatever it is handed. A
874 /// password that comes back down the wire is a password in a page, in a
875 /// proxy log and in a browser cache, and the field kind exists to say so.
876 /// Silently rather than by a `Result`, because there is no answer a caller
877 /// could give that would make echoing it right.
878 #[must_use]
879 pub fn value(mut self, value: impl Into<String>) -> Self {
880 if self.kind != layout::FieldKind::Secret {
881 self.value = Some(value.into());
882 }
883 self
884 }
885
886 /// Re-offer whatever was submitted under this field's name.
887 ///
888 /// What a refused write calls, with the [`Params`](crate::Params) it was
889 /// refusing. A name with nothing under it stays empty, which is what an
890 /// unticked checkbox and an untouched box both are.
891 #[must_use]
892 pub fn refilled(self, params: &crate::Params) -> Self {
893 match params.get(&self.name) {
894 Some(value) => {
895 let value = value.to_owned();
896 self.value(value)
897 }
898 None => self,
899 }
900 }
901
902 /// Read this field as the description layer's own type.
903 ///
904 /// A callback rather than a return, because [`layout::Field`] holds its
905 /// options as a slice and ours holds them as owned values, so the borrowed
906 /// slice has to live somewhere for the duration of the read. Building it
907 /// here means one allocation at the renderer's boundary instead of the
908 /// borrow leaking into every caller's signature.
909 pub fn with_layout<R>(&self, f: impl FnOnce(layout::Field<'_>) -> R) -> R {
910 let options: Vec<layout::Choice<'_>> = self.options.iter().map(Choice::as_layout).collect();
911 f(layout::Field {
912 kind: self.kind,
913 name: &self.name,
914 label: &self.label,
915 hint: self.hint.as_deref(),
916 error: self.error.as_deref(),
917 placeholder: self.placeholder.as_deref(),
918 options: &options,
919 required: self.required,
920 max_length: self.max_length,
921 min: self.min.as_deref(),
922 max: self.max.as_deref(),
923 extended: self.extended,
924 })
925 }
926 }
927
928 /// One column of a table, owned.
929 ///
930 /// The borrowed original is [`layout::Column`]. The `name` is both the heading
931 /// and the address a cell is found by, which is what replaces addressing
932 /// columns by position.
933 /// No `Hash`, for the reason [`Tag`] and [`Field`] have none: it can hold an
934 /// [`Action`], which holds [`Params`], which is a `Vec`.
935 #[derive(Debug, Clone, PartialEq, Eq)]
936 pub struct Column {
937 /// The heading, and the name the cell is addressed by.
938 pub name: String,
939 /// How much room it asks for.
940 pub width: layout::Width,
941 /// What it is worth when room runs out.
942 pub priority: layout::Priority,
943 /// Which way the table is ordered by this column, if it is.
944 pub sorted: Option<layout::Sort>,
945 /// What pressing this heading calls.
946 ///
947 /// `ce620871`. makeover-layout carries `Column::sortable`, a bare bool,
948 /// because it cannot name an address; here the address *is* the
949 /// sortability, so the two collapse into one field and cannot disagree.
950 /// [`as_layout`](Self::as_layout) sets the bool from whether this is here.
951 ///
952 /// Reordering a table is a control that writes with no surrounding submit,
953 /// which is `14612ed8`'s shape, and the renderer treats it the same way.
954 pub reorder: Option<Action>,
955 }
956
957 impl Column {
958 /// A column that absorbs slack and drops after the optional ones.
959 pub fn new(name: impl Into<String>) -> Self {
960 Self {
961 name: name.into(),
962 width: layout::Width::Fill,
963 priority: layout::Priority::Secondary,
964 sorted: None,
965 reorder: None,
966 }
967 }
968
969 /// Pressing this heading reorders the table.
970 #[must_use]
971 pub fn reorder(mut self, action: Action) -> Self {
972 self.reorder = Some(action);
973 self
974 }
975
976 /// The table is currently ordered by this column, this way.
977 #[must_use]
978 pub const fn sorted(mut self, sort: layout::Sort) -> Self {
979 self.sorted = Some(sort);
980 self
981 }
982
983 /// Set how much room it asks for.
984 #[must_use]
985 pub fn width(mut self, width: layout::Width) -> Self {
986 self.width = width;
987 self
988 }
989
990 /// Set what it is worth when room runs out.
991 #[must_use]
992 pub fn priority(mut self, priority: layout::Priority) -> Self {
993 self.priority = priority;
994 self
995 }
996
997 /// Borrow as the description layer's own type.
998 #[must_use]
999 pub fn as_layout(&self) -> layout::Column<'_> {
1000 layout::Column {
1001 name: &self.name,
1002 width: self.width,
1003 priority: self.priority,
1004 sortable: self.reorder.is_some(),
1005 sorted: self.sorted,
1006 }
1007 }
1008 }
1009
1010 /// Which region this is, owned.
1011 ///
1012 /// The borrowed original is [`layout::Region`], and only one member borrows:
1013 /// [`layout::Region::Bespoke`] carries a name the app owns and this crate never
1014 /// interprets.
1015 #[derive(Debug, Clone, PartialEq, Eq, Hash)]
1016 pub enum RegionKind {
1017 /// A full-width strip with a title slot and an actions cluster.
1018 Band,
1019 /// A persistent column beside the content, holding navigation.
1020 Sidebar,
1021 /// A region of content with its own scroll.
1022 Pane,
1023 /// Two panes side by side, the left choosing what the right shows.
1024 Split,
1025 /// A set of panes, one visible at a time, with tabs above.
1026 TabGroup,
1027 /// Content over a scrim, taking input until dismissed.
1028 ///
1029 /// A modal this screen *contains*, which is how a confirmation is drawn: it
1030 /// arrives with the screen and goes when the screen goes. The app-level one
1031 /// is [`Outcome::Over`](crate::Outcome::Over), which draws a whole screen
1032 /// over whatever is under it and is reachable from screens that know
1033 /// nothing about it.
1034 Modal,
1035 /// A place, and nothing else. The app fills it per host.
1036 ///
1037 /// Decision 4: the renderer hands the space over and the app puts a JS
1038 /// component, an egui closure or a TUI widget in it. The rejected
1039 /// alternative was giving the placeholder its own route and fetching a
1040 /// fragment for it, which is uniform on paper and wrong in currency: a byte
1041 /// payload is not what egui or a terminal wants.
1042 Bespoke {
1043 /// What the app calls it. Never interpreted here.
1044 name: String,
1045 },
1046 }
1047
1048 impl RegionKind {
1049 /// Borrow as the description layer's own type.
1050 #[must_use]
1051 pub fn as_layout(&self) -> layout::Region<'_> {
1052 match self {
1053 Self::Band => layout::Region::Band,
1054 Self::Sidebar => layout::Region::Sidebar,
1055 Self::Pane => layout::Region::Pane,
1056 Self::Split => layout::Region::Split,
1057 Self::TabGroup => layout::Region::TabGroup,
1058 Self::Modal => layout::Region::Modal,
1059 Self::Bespoke { name } => layout::Region::Bespoke { name },
1060 }
1061 }
1062
1063 /// Whether the description can say anything about the contents.
1064 #[must_use]
1065 pub fn described(&self) -> bool {
1066 self.as_layout().described()
1067 }
1068
1069 /// How the region sits on what is behind it.
1070 #[must_use]
1071 pub fn depth(&self) -> layout::Depth {
1072 self.as_layout().depth()
1073 }
1074 }
1075
1076 /// A named region, and the thing a fragment is aimed at.
1077 ///
1078 /// The name is what decision 7 needs and [`layout::Region`] deliberately does
1079 /// not have: two panes in a split are both `Pane`, so the kind cannot be an
1080 /// address. A webview maps the id onto `hx-target`; egui and the terminal
1081 /// ignore it and redraw, which costs them nothing because they were redrawing
1082 /// anyway.
1083 #[derive(Debug, Clone, PartialEq, Eq)]
1084 pub struct Slot {
1085 /// The address. Unique within a screen, and stable across responses, or a
1086 /// fragment lands nowhere.
1087 pub id: String,
1088 /// Which region it is.
1089 pub kind: RegionKind,
1090 /// Whether the region's own content is here or on its way.
1091 ///
1092 /// The loading axis, and only that. Emptiness is *not* said here, which
1093 /// looks like the obvious place for it and is not: a column with a heading
1094 /// and no rows is a region that has content — the heading — and a list that
1095 /// has none. Marking the region empty would hide the heading with it. See
1096 /// [`Node::StandIn`].
1097 pub readiness: layout::Readiness,
1098 /// What is in it.
1099 ///
1100 /// Blocks, regions included, which is the nesting that was always accepted:
1101 /// a region inside a region is a nested rect on every host. Leaves are
1102 /// admitted too, and deliberately -- a fact under a heading is a
1103 /// [`Node::Text`] straight in a pane, and it is the commonest thing in the
1104 /// tree.
1105 ///
1106 /// # Why there is no bound here
1107 ///
1108 /// [`Cell::part`] and [`Row::part`] assert that what they are handed is a
1109 /// leaf, and this does not, which looks like an oversight and is the model
1110 /// working. The ladder forbids reaching *up*: a run may not hold a block,
1111 /// because a run has to be drawable on one wrapped line. A block holding a
1112 /// leaf is going down, and going down is what containment is for. There is
1113 /// no upward violation for [`with`](Self::with) to catch, so an assertion
1114 /// here would be a runtime check that can never fire.
1115 ///
1116 /// A region whose whole content is one badge is the case that made this
1117 /// look like a question. It is describable, and it should be: a status pane
1118 /// is a real screen. Whether it is a *good* screen is a judgement about
1119 /// that screen rather than a property of the vocabulary, and the bound is
1120 /// not the place to hold opinions about taste.
1121 pub body: Vec<Node>,
1122 }
1123
1124 impl Slot {
1125 /// An empty region under this address.
1126 pub fn new(id: impl Into<String>, kind: RegionKind) -> Self {
1127 Self {
1128 id: id.into(),
1129 kind,
1130 readiness: layout::Readiness::Ready,
1131 body: Vec::new(),
1132 }
1133 }
1134
1135 /// A place the app fills itself.
1136 pub fn bespoke(id: impl Into<String>, name: impl Into<String>) -> Self {
1137 Self::new(id, RegionKind::Bespoke { name: name.into() })
1138 }
1139
1140 /// Add a node, chaining.
1141 #[must_use]
1142 pub fn with(mut self, node: Node) -> Self {
1143 self.body.push(node);
1144 self
1145 }
1146
1147 /// Add several nodes, chaining.
1148 #[must_use]
1149 pub fn extend(mut self, nodes: impl IntoIterator<Item = Node>) -> Self {
1150 self.body.extend(nodes);
1151 self
1152 }
1153
1154 /// The content is on its way rather than here.
1155 #[must_use]
1156 pub fn pending(mut self) -> Self {
1157 self.readiness = layout::Readiness::Pending;
1158 self
1159 }
1160
1161 /// This slot, or the first slot under this address anywhere inside it.
1162 #[must_use]
1163 pub fn find(&self, id: &str) -> Option<&Self> {
1164 if self.id == id {
1165 return Some(self);
1166 }
1167 self.body.iter().find_map(|node| match node {
1168 Node::Region(slot) => slot.find(id),
1169 _ => None,
1170 })
1171 }
1172
1173 /// The mutable half of [`find`](Self::find).
1174 ///
1175 /// Same walk, and it has to be a second function rather than the same one
1176 /// generic over mutability: a `&mut` borrow of `self` cannot be handed to
1177 /// the recursive call and kept, which is what `find_map` does on the shared
1178 /// side.
1179 fn find_mut(&mut self, id: &str) -> Option<&mut Self> {
1180 if self.id == id {
1181 return Some(self);
1182 }
1183 self.body.iter_mut().find_map(|node| match node {
1184 Node::Region(slot) => slot.find_mut(id),
1185 _ => None,
1186 })
1187 }
1188 }
1189
1190 /// A control that calls a route.
1191 ///
1192 /// A button, a link and a menu item are the same thing to a description: a
1193 /// label, an address, and how loudly it is saying it. Which of the three a
1194 /// renderer draws is a renderer decision.
1195 #[derive(Debug, Clone, PartialEq, Eq)]
1196 pub struct Act {
1197 /// What it is called.
1198 pub label: String,
1199 /// What it calls.
1200 pub action: Action,
1201 /// What it is saying. [`layout::Tone::Danger`] is what marks the button
1202 /// that destroys something.
1203 pub tone: layout::Tone,
1204 /// Focused, disabled, or neither.
1205 pub state: Option<layout::State>,
1206 /// What to ask before doing it, if it should be asked.
1207 ///
1208 /// `524a63fe`. Destructiveness is a property of the action, known where the
1209 /// action is described, and until this existed every app expressed it by
1210 /// calling a JS helper at the call site: goingson has 33 such calls across
1211 /// four helpers and Balanced Breakfast 5.
1212 ///
1213 /// The prompt only. The word on the agreeing button is
1214 /// [`label`](Self::label), because it already is — goingson's `confirmDelete`
1215 /// passes `confirmText: 'Delete'` for an act labelled "Delete" — and a
1216 /// second string would be the same word twice with a chance to disagree.
1217 /// [`tone`](Self::tone) already says whether the dialog is a dangerous one.
1218 ///
1219 /// `Region::Modal` names the box a confirmation appears in and does not name
1220 /// the pattern. This is the pattern: a webview raises a dialog, a touch host
1221 /// an action sheet, a terminal a y/n line, and none of them is a route to a
1222 /// modal screen and back, which is a different interaction.
1223 pub confirm: Option<String>,
1224 /// The key that reaches it, written the way a user would say it.
1225 ///
1226 /// `2daea915`. An `Act` had a label and a destination and nothing said which
1227 /// key gets there, so goingson's 279-line `keyboard.js` holds the table
1228 /// beside the description, and the help overlay that lists the shortcuts is
1229 /// a second hand-written copy that can drift from it.
1230 ///
1231 /// A terminal makes the case sharper than a webview does: there the key *is*
1232 /// the affordance, so a description that cannot name one cannot describe the
1233 /// screen's primary interaction at all.
1234 ///
1235 /// Text rather than a modelled chord — "n", "ctrl+k", "?" — because the
1236 /// vocabulary of keys is the host's and a description that modelled it would
1237 /// be naming one host's keyboard. A renderer that does not know a name
1238 /// ignores it, which is what a webview does with a key a terminal wants.
1239 ///
1240 /// Screen-scoped, because a screen is what this describes. An app-wide
1241 /// shortcut belongs to the app and is not a fact about any one screen:
1242 /// that is [`Chrome::bindings`](crate::Chrome::bindings), held beside the
1243 /// router rather than inside any answer. A renderer matches those first, so
1244 /// a screen cannot capture the key that opens the palette.
1245 pub key: Option<String>,
1246 /// The [`Screen::selection`] this acts on, if it acts on one.
1247 ///
1248 /// `5f2b8753`. This is what makes a commit control readable: "Archive" over
1249 /// a selection is a different sentence from "Archive" on a row, and until
1250 /// this existed the difference lived in whichever JS gathered the checked
1251 /// boxes.
1252 ///
1253 /// Every ticked [`Row::value`] is sent under [`Node::TICKED`], repeated
1254 /// once per member. Repeated rather than joined, because a name appearing
1255 /// many times is what [`Params::get_all`] is for and a delimiter would have
1256 /// to be one no value can contain.
1257 ///
1258 /// # The name does not select between sets yet, and cannot
1259 ///
1260 /// A screen holds one selection ([`Screen::selection`]), so being set at
1261 /// all is what makes a control a commit control, and the name is what makes
1262 /// it *readable* — "Archive" over `chosen` is a different sentence from
1263 /// "Archive" on a row.
1264 ///
1265 /// Matching it against the screen's name was the first shape and it does
1266 /// not work, because a renderer does not always have the screen: an
1267 /// [`Outcome::Fragment`] replaces a region and carries no screen at all, so
1268 /// a webview rendering one would have had to guess and a terminal, which
1269 /// keeps the screen beside it, would not. The two hosts would then disagree
1270 /// about a typo, which is exactly the drift this vocabulary exists to stop.
1271 /// So both read it the same way, and the name starts choosing between sets
1272 /// on the day [`Screen::selection`] becomes a map.
1273 ///
1274 /// [`Params::get_all`]: crate::Params::get_all
1275 /// [`Outcome::Fragment`]: crate::Outcome::Fragment
1276 pub over: Option<String>,
1277 }
1278
1279 impl Act {
1280 /// A neutral control calling this route.
1281 pub fn new(label: impl Into<String>, action: Action) -> Self {
1282 Self {
1283 label: label.into(),
1284 action,
1285 tone: layout::Tone::Neutral,
1286 state: None,
1287 confirm: None,
1288 key: None,
1289 over: None,
1290 }
1291 }
1292
1293 /// This acts on the screen's selection, by name.
1294 ///
1295 /// The commit half of a staged tick. See [`over`](Self::over) for what
1296 /// reaches the handler, and [`Screen::selection`] for why a tick stages
1297 /// rather than writes.
1298 #[must_use]
1299 pub fn over(mut self, selection: impl Into<String>) -> Self {
1300 self.over = Some(selection.into());
1301 self
1302 }
1303
1304 /// Ask this before doing it.
1305 #[must_use]
1306 pub fn confirm(mut self, prompt: impl Into<String>) -> Self {
1307 self.confirm = Some(prompt.into());
1308 self
1309 }
1310
1311 /// The key that reaches it.
1312 #[must_use]
1313 pub fn key(mut self, key: impl Into<String>) -> Self {
1314 self.key = Some(key.into());
1315 self
1316 }
1317
1318 /// Set what it is saying.
1319 #[must_use]
1320 pub fn tone(mut self, tone: layout::Tone) -> Self {
1321 self.tone = tone;
1322 self
1323 }
1324
1325 /// Present, visible, and not answering.
1326 #[must_use]
1327 pub fn disabled(mut self) -> Self {
1328 self.state = Some(layout::State::Disabled);
1329 self
1330 }
1331
1332 /// Whether the control currently answers input.
1333 #[must_use]
1334 pub fn interactive(&self) -> bool {
1335 !self
1336 .state
1337 .is_some_and(layout::State::suppresses_interaction)
1338 }
1339
1340 /// Borrow as the description layer's own type.
1341 ///
1342 /// [`action`](Self::action) and [`confirm`](Self::confirm) do not survive
1343 /// the crossing, and that is what the two layers disagree about rather than
1344 /// an oversight. An address is quasi's — every host follows one differently
1345 /// — and a confirmation is a question asked after the press, so it belongs
1346 /// to whoever is holding the interaction. What is left is what a renderer
1347 /// needs to *draw* the control, which is all `layout::Act` claims to be.
1348 #[must_use]
1349 pub fn as_layout(&self) -> layout::Act<'_> {
1350 layout::Act {
1351 label: &self.label,
1352 key: self.key.as_deref(),
1353 tone: self.tone,
1354 state: self.state,
1355 }
1356 }
1357 }
1358
1359 /// One part of a row's run, and the role it takes.
1360 ///
1361 /// A cell's run entries carry no role because their kind already says which
1362 /// part they are: text is the value, a [`Node::Link`] is the link, a
1363 /// [`Node::Token`] is a chip, a [`Node::Act`] is a control. A row's
1364 /// `primary`, `secondary` and `meta` are three *text* roles, and kind cannot
1365 /// tell those apart, so a row says which one it means.
1366 ///
1367 /// The role is a style role and nothing else. [`layout::RowPart`] is unchanged
1368 /// by the containment model: it says how a part is drawn, not what may sit in
1369 /// it, and that is the half of it worth keeping.
1370 #[derive(Debug, Clone, PartialEq, Eq)]
1371 pub struct Part {
1372 /// Which of the row's roles this part takes.
1373 pub role: layout::RowPart,
1374 /// What is in it. A leaf, since a row is an inline run.
1375 pub node: Node,
1376 }
1377
1378 /// One row of a list.
1379 ///
1380 /// # The run
1381 ///
1382 /// A row's content is an inline run of [`Part`]s, in the order the description
1383 /// says them, the same way a [`Cell`]'s is. It was six members before
1384 /// `1786cb94` -- `primary`, `secondary`, `meta`, `tokens`, `actions`, `meter`
1385 /// -- each of which arrived as a counted-sites argument, a member here, a
1386 /// [`layout::RowPart`] variant and a release: `RowPart::Tokens` at
1387 /// makeover-layout 0.9.0 for a badge in a row, `RowPart::Proportion` at 0.11.0
1388 /// for a bar in one. A link in a row was simply not sayable, and a figure in
1389 /// one was not either. Under the run both are already sayable and cost nothing.
1390 ///
1391 /// The bound is that every part is a leaf, so a row is drawable on one wrapped
1392 /// line without a renderer knowing what is in it. [`Row::part`] is where that
1393 /// bites at a call site.
1394 ///
1395 /// Order is the description's. The old members were drawn in a fixed sequence
1396 /// whatever order they were built in, so a row that wanted a tag between two
1397 /// facts got the tag hoisted to the end; now it draws where it was put.
1398 ///
1399 /// # What stayed a field
1400 ///
1401 /// [`activate`](Self::activate), [`current`](Self::current),
1402 /// [`selected`](Self::selected), [`menu`](Self::menu) and
1403 /// [`toggle`](Self::toggle) are facts *about* the row rather than content in
1404 /// it. A run of things on a line is not where "this row is the one the detail
1405 /// pane is showing" belongs.
1406 ///
1407 /// # The cost
1408 ///
1409 /// [`primary()`](Self::primary) is no longer guaranteed to be one string, which
1410 /// is what let a constrained renderer right-align a row cheaply. It answers the
1411 /// text of the primary parts joined, and a row built the ordinary way still has
1412 /// exactly one.
1413 #[derive(Debug, Clone, PartialEq, Eq, Default)]
1414 pub struct Row {
1415 /// What is in the row, in order.
1416 ///
1417 /// Built by the same constructors that named the old members:
1418 /// [`Row::new`], [`secondary`](Row::secondary), [`meta`](Row::meta),
1419 /// [`token`](Row::token), [`act`](Row::act) and [`meter`](Row::meter) all
1420 /// still mean what they meant, so no builder call site moved.
1421 pub parts: Vec<Part>,
1422 /// The route that selects this row, if selecting it does anything.
1423 pub activate: Option<Action>,
1424 /// Whether this is the row the detail side is currently showing.
1425 ///
1426 /// Named `selected` until 2026-08-08, which was one word doing two jobs.
1427 /// This one is the app's own pointer into a set: what a list-detail
1428 /// arrangement highlights because its pane is showing it, and what a
1429 /// webview says with `aria-current`. The user's tick is
1430 /// [`selected`](Self::selected), and conflating them meant a screen with
1431 /// bulk actions could not describe its checkboxes at all.
1432 pub current: bool,
1433 /// Whether the user has ticked this row, and whether they can.
1434 ///
1435 /// Three states in one field, which is why it is not a `bool`. `None` means
1436 /// the row is not selectable and no affordance should be drawn; `Some(false)`
1437 /// means it can be ticked and is not; `Some(true)` means it is. A plain bool
1438 /// cannot tell "not ticked" from "not tickable", so every renderer would
1439 /// have had to be told selectability some other way, and each would have
1440 /// picked a different way.
1441 ///
1442 /// This is the user's selection, as distinct from
1443 /// [`current`](Self::current). goingson's contacts and tasks screens both
1444 /// drive bulk actions from it.
1445 pub selected: Option<bool>,
1446 /// Everything else that can be done to this row.
1447 ///
1448 /// `5e02fbce`. The [`Actions`](layout::RowPart::Actions) parts of the run
1449 /// are what the row shows; this is
1450 /// what it *offers*, reached by right-click on a pointer host, long-press on
1451 /// a touch one, and a key in a terminal. That split is the whole reason it
1452 /// belongs in the description rather than in a renderer: one description has
1453 /// to become a context menu, an action sheet and a key-driven menu, and no
1454 /// single renderer can be the place where it is said.
1455 ///
1456 /// goingson opens one at 14 sites and Balanced Breakfast at 9, on top of
1457 /// 680 lines of generic menu machinery between `components.js` and
1458 /// `context-menus.js`.
1459 ///
1460 /// A field rather than a role in the run, because a menu is not on the
1461 /// line. The run is what the row draws; this is what it holds back until
1462 /// the host asks, and no renderer draws it in sequence with the primary.
1463 pub menu: Vec<Act>,
1464 /// What ticking this row calls, if ticking it is the write.
1465 ///
1466 /// `14612ed8`, part of it. [`selected`](Self::selected) says whether the row
1467 /// is ticked and whether it can be, and that was the whole story for a bulk
1468 /// checkbox, whose tick is client state feeding a later action. A checklist
1469 /// is the other case: the tick *is* the write, and it is the only affordance
1470 /// the screen offers for it. Described without this, the port drew the tick
1471 /// inert and put the toggle on a button beside it, which is a user clicking
1472 /// a button next to a checkbox that ignores clicks.
1473 ///
1474 /// Two fields rather than a `Selection` struct, matching how
1475 /// [`activate`](Self::activate) sits beside [`current`](Self::current):
1476 /// state and behaviour are separate facts about the row. They do have to
1477 /// agree — a `toggle` with no [`selected`](Self::selected) is a route on a
1478 /// control nothing draws — and [`Row::toggling`] is the constructor that
1479 /// makes them agree.
1480 pub toggle: Option<Action>,
1481 /// What this row's tick contributes to the screen's selection.
1482 ///
1483 /// `5f2b8753`. [`selected`](Self::selected) says the row can be ticked;
1484 /// this says what ticking it *means*, which is the half that was missing.
1485 /// A set of ticks with nothing in them is not a selection, so a renderer
1486 /// holding [`Screen::selection`] holds these.
1487 ///
1488 /// `value` rather than `id`, matching [`Choice::value`]: throughout this
1489 /// vocabulary it is the word for what a control contributes when it is
1490 /// chosen, and a row's tick is the same kind of fact.
1491 ///
1492 /// A selectable row without one is the dead affordance this member exists
1493 /// to end, and [`Row::ticking`] is the constructor that cannot produce it.
1494 /// It is not enforced here, for [`toggle`](Self::toggle)'s reason: a
1495 /// description layer that refused to hold a half-built row would refuse it
1496 /// at the moment the app is still building it.
1497 ///
1498 /// [`Choice::value`]: Choice::value
1499 /// [`Screen::selection`]: Screen::selection
1500 pub value: Option<String>,
1501 }
1502
1503 impl Row {
1504 /// A row with only its primary text.
1505 ///
1506 /// An empty string is an empty run rather than a run holding an empty
1507 /// string, so `Row::new("")` and [`Row::default`] are the same value. Same
1508 /// rule as [`Cell::new`], and for the same reason.
1509 pub fn new(primary: impl Into<String>) -> Self {
1510 let primary = primary.into();
1511 Self {
1512 parts: if primary.is_empty() {
1513 Vec::new()
1514 } else {
1515 vec![Part {
1516 role: layout::RowPart::Primary,
1517 node: Node::text(primary),
1518 }]
1519 },
1520 ..Self::default()
1521 }
1522 }
1523
1524 /// Something else that can be done to this row, not shown inline.
1525 #[must_use]
1526 pub fn offers(mut self, act: Act) -> Self {
1527 self.menu.push(act);
1528 self
1529 }
1530
1531 /// How much of this row's set is done.
1532 #[must_use]
1533 pub fn meter(mut self, meter: Meter) -> Self {
1534 self.set(layout::RowPart::Proportion, Node::Meter(meter));
1535 self
1536 }
1537
1538 /// A tick that is the write, in the state it is currently in.
1539 ///
1540 /// Sets [`selected`](Self::selected) and [`toggle`](Self::toggle) together,
1541 /// because a route on a tick nothing draws is the one way the two fields can
1542 /// disagree. A checklist item is what this is for; a bulk checkbox sets
1543 /// `selected` alone and keeps its meaning as client state.
1544 #[must_use]
1545 pub fn toggling(mut self, ticked: bool, action: Action) -> Self {
1546 self.selected = Some(ticked);
1547 self.toggle = Some(action);
1548 self
1549 }
1550
1551 /// Supporting text under the primary.
1552 #[must_use]
1553 pub fn secondary(mut self, text: impl Into<Prose>) -> Self {
1554 let node = match text.into() {
1555 Prose::Text(text) => Node::text(text),
1556 Prose::Rich(source) => Node::rich(source),
1557 };
1558 self.set(layout::RowPart::Secondary, node);
1559 self
1560 }
1561
1562 /// A short trailing fact.
1563 #[must_use]
1564 pub fn meta(mut self, text: impl Into<String>) -> Self {
1565 self.set(layout::RowPart::Meta, Node::text(text));
1566 self
1567 }
1568
1569 /// Add a token, chaining.
1570 #[must_use]
1571 pub fn token(mut self, tag: Tag) -> Self {
1572 self.parts.push(Part {
1573 role: layout::RowPart::Tokens,
1574 node: Node::Token(tag),
1575 });
1576 self
1577 }
1578
1579 /// Make the row tickable, and say whether it is ticked.
1580 ///
1581 /// A row is not selectable until something says so, which is what keeps a
1582 /// checkbox off every list in the app.
1583 ///
1584 /// Says nothing about what the tick contributes, so on a screen with a
1585 /// [`selection`](Screen::selection) it draws a box that joins no set. Reach
1586 /// for [`ticking`](Self::ticking) instead; this stays for the screens whose
1587 /// tick is the write, beside [`toggling`](Self::toggling).
1588 #[must_use]
1589 pub const fn selectable(mut self, ticked: bool) -> Self {
1590 self.selected = Some(ticked);
1591 self
1592 }
1593
1594 /// Make the row tickable under this value, and say whether it is ticked.
1595 ///
1596 /// Sets [`selected`](Self::selected) and [`value`](Self::value) together,
1597 /// which is the pair a screen's [`selection`](Screen::selection) needs.
1598 /// The two halves exist separately for [`toggling`](Self::toggling)'s
1599 /// reason — state and identity are different facts about the row — and
1600 /// this is the constructor that stops them being written apart.
1601 #[must_use]
1602 pub fn ticking(mut self, value: impl Into<String>, ticked: bool) -> Self {
1603 self.selected = Some(ticked);
1604 self.value = Some(value.into());
1605 self
1606 }
1607
1608 /// The route selecting this row.
1609 #[must_use]
1610 pub fn activate(mut self, action: Action) -> Self {
1611 self.activate = Some(action);
1612 self
1613 }
1614
1615 /// A control acting on this row.
1616 #[must_use]
1617 pub fn act(mut self, act: Act) -> Self {
1618 self.parts.push(Part {
1619 role: layout::RowPart::Actions,
1620 node: Node::Act(act),
1621 });
1622 self
1623 }
1624
1625 /// Anything in this row, under the role it takes.
1626 ///
1627 /// The general form the constructors above are shorthands for, and the
1628 /// point of the model: a link in a row and a figure in a row became
1629 /// sayable at once, where each was previously a
1630 /// [`layout::RowPart`] variant, a member here, a renderer arm and a
1631 /// release.
1632 ///
1633 /// Appends rather than replacing, so a row can hold two of a role. The
1634 /// named constructors keep the single-valued roles single-valued, which is
1635 /// what their call sites already meant.
1636 ///
1637 /// # Panics
1638 ///
1639 /// If the node is not a leaf. A row is an inline run, so what goes in it
1640 /// has to be drawable on one wrapped line without the renderer knowing what
1641 /// it is -- the constrained-consumer bound, biting at a call site rather
1642 /// than in a doc comment. Same assertion as [`Cell::part`].
1643 #[must_use]
1644 pub fn part(mut self, role: layout::RowPart, node: Node) -> Self {
1645 assert!(
1646 node.containment() == Containment::Text,
1647 "a row is an inline run and holds leaves; {node:?} holds {:?}",
1648 node.containment()
1649 );
1650 self.parts.push(Part { role, node });
1651 self
1652 }
1653
1654 /// Set the one part taking a role, replacing it if it is already there.
1655 ///
1656 /// For the roles that are single-valued at every call site that has ever
1657 /// existed: the primary, the supporting line, the trailing fact, the bar.
1658 /// Building a row that calls `.meta` twice meant the second one won when
1659 /// `meta` was an `Option`, and it still does.
1660 fn set(&mut self, role: layout::RowPart, node: Node) {
1661 match self.parts.iter_mut().find(|part| part.role == role) {
1662 Some(part) => part.node = node,
1663 None => self.parts.push(Part { role, node }),
1664 }
1665 }
1666
1667 /// The parts taking one role, in order.
1668 pub fn role(&self, role: layout::RowPart) -> impl Iterator<Item = &Node> {
1669 self.parts
1670 .iter()
1671 .filter(move |part| part.role == role)
1672 .map(|part| &part.node)
1673 }
1674
1675 /// The row's primary text.
1676 ///
1677 /// What every consumer of the old `primary` member wanted. A row built the
1678 /// ordinary way has one primary part and answers its string; one that was
1679 /// given two answers both, joined, in order.
1680 #[must_use]
1681 pub fn primary(&self) -> String {
1682 self.role(layout::RowPart::Primary)
1683 .filter_map(|node| match node {
1684 Node::Text { text, .. } | Node::Link { text, .. } => Some(text.as_str()),
1685 _ => None,
1686 })
1687 .collect::<Vec<_>>()
1688 .join(" ")
1689 }
1690
1691 /// The controls the row shows.
1692 ///
1693 /// The same service [`primary`](Self::primary) does, for the member the run
1694 /// replaced. `actions` was a `Vec<Act>` before the run, and every consumer
1695 /// that read it now writes the same three lines: filter the run by role,
1696 /// match the one node kind that can be there, and collect. goingson wrote
1697 /// them twice in one file the day the member went away.
1698 ///
1699 /// Not what the row *offers*: that is [`menu`](Self::menu), which is held
1700 /// back until the host asks for it and is not on the line.
1701 pub fn acts(&self) -> impl Iterator<Item = &Act> {
1702 self.role(layout::RowPart::Actions)
1703 .filter_map(|node| match node {
1704 Node::Act(act) => Some(act),
1705 _ => None,
1706 })
1707 }
1708
1709 /// The tags the row shows.
1710 ///
1711 /// [`acts`](Self::acts)' counterpart, for the same reason.
1712 pub fn tokens(&self) -> impl Iterator<Item = &Tag> {
1713 self.role(layout::RowPart::Tokens)
1714 .filter_map(|node| match node {
1715 Node::Token(tag) => Some(tag),
1716 _ => None,
1717 })
1718 }
1719 }
1720
1721 /// One cell of a table row.
1722 ///
1723 /// `022f0c59`, decided 2026-08-10. A cell was a `String` until then, so a table
1724 /// whose rows carry a control could not be described at all and had to become a
1725 /// [`Node::List`], losing its column headers — which is what the MNW server's
1726 /// SSH-keys tab did, and why it read worse than the Askama original it replaced.
1727 ///
1728 /// # Why the acts sit on the cell and not on the row
1729 ///
1730 /// Counted across MNW's templates, 30 table rows carry a control. 25 put it
1731 /// alone in the last cell, which a row-level `actions` list would have covered.
1732 /// The other five put it *beside a value*: `project_content`'s position cell is
1733 /// the number plus two reorder arrows, `project_synckit`'s slug cell is the slug
1734 /// plus "Set slug", `promo_codes_list`'s use count is a number that is itself
1735 /// the button opening the redemptions. A row-level list renders as an appended
1736 /// cell and cannot say any of those, and neither can an actions *column*, since
1737 /// a column is a column. The control belongs where it actually is.
1738 ///
1739 /// An empty [`value`](Self::value) with acts is the common case, and
1740 /// [`Cell::acts`] is the constructor for it. That is the trailing actions cell
1741 /// the markup already writes an empty `<th>` for.
1742 ///
1743 /// A `Vec<Act>` and not a node: the 2026-08-08 ruling that a row holds no nodes
1744 /// holds here for the same reason. Acts carry their own tone, state and
1745 /// confirmation, and that is the whole of what these cells hold.
1746 #[derive(Debug, Clone, PartialEq, Eq, Default)]
1747 pub struct Cell {
1748 /// What is in it, in order.
1749 ///
1750 /// An inline run: every part is a leaf, so the whole cell is drawable on
1751 /// one wrapped line without a renderer knowing what is in it. That is the
1752 /// bound, and [`Cell::part`] is where it is enforced.
1753 ///
1754 /// This was four members -- `value`, `tokens`, `actions`, `activate` --
1755 /// added one release at a time as each pairing was argued for on counted
1756 /// sites. `022f0c59` added two of them at once. That trajectory is what
1757 /// decided the containment model: a meter in a cell and a figure in a cell
1758 /// were simply not sayable, and each would have been a fifth and sixth
1759 /// member. Under the run they are already sayable and cost nothing.
1760 ///
1761 /// The constructors that named the old members are still here and still
1762 /// mean what they meant, so no call site moved: [`Cell::new`],
1763 /// [`tag`](Cell::tag), [`token`](Cell::token), [`acts`](Cell::acts),
1764 /// [`act`](Cell::act) and [`activate`](Cell::activate) build the run.
1765 pub parts: Vec<Node>,
1766 }
1767
1768 impl Cell {
1769 /// A cell holding text.
1770 ///
1771 /// An empty string is an empty run rather than a run holding an empty
1772 /// string, so an actions-only cell built through [`acts`](Self::acts) and
1773 /// one built as `Cell::new("").act(..)` are the same value.
1774 pub fn new(value: impl Into<String>) -> Self {
1775 let value = value.into();
1776 Self {
1777 parts: if value.is_empty() {
1778 Vec::new()
1779 } else {
1780 vec![Node::text(value)]
1781 },
1782 }
1783 }
1784
1785 /// A cell holding one tag and no text.
1786 ///
1787 /// What a status column is: the cell is the badge. `Cell::new("")` with a
1788 /// token would say the same thing and reads as an oversight.
1789 pub fn tag(tag: Tag) -> Self {
1790 Self {
1791 parts: vec![Node::Token(tag)],
1792 }
1793 }
1794
1795 /// A tag in this cell, chaining.
1796 #[must_use]
1797 pub fn token(mut self, tag: Tag) -> Self {
1798 self.parts.push(Node::Token(tag));
1799 self
1800 }
1801
1802 /// A cell holding controls and no text.
1803 pub fn acts(actions: impl IntoIterator<Item = Act>) -> Self {
1804 Self {
1805 parts: actions.into_iter().map(Node::Act).collect(),
1806 }
1807 }
1808
1809 /// A control in this cell, chaining.
1810 #[must_use]
1811 pub fn act(mut self, act: Act) -> Self {
1812 self.parts.push(Node::Act(act));
1813 self
1814 }
1815
1816 /// Where this cell's value goes.
1817 ///
1818 /// The value becomes the link. A cell with no value and an `activate` is a
1819 /// link with nothing to press, so give it text.
1820 ///
1821 /// Under the run this rewrites the leading text into a [`Node::Link`]
1822 /// rather than setting a member beside it, which is the same fact said once
1823 /// instead of as a pair of fields that could disagree. A cell with no text
1824 /// to link gains nothing, because a link with no label is a control nothing
1825 /// draws.
1826 #[must_use]
1827 pub fn activate(mut self, action: Action) -> Self {
1828 if let Some(first) = self
1829 .parts
1830 .iter_mut()
1831 .find(|part| matches!(part, Node::Text { .. }))
1832 && let Node::Text { text, .. } = first
1833 {
1834 *first = Node::Link {
1835 text: std::mem::take(text),
1836 action,
1837 };
1838 }
1839 self
1840 }
1841
1842 /// Anything in this cell, chaining.
1843 ///
1844 /// The general form the five constructors above are shorthands for, and the
1845 /// whole point of the model: a meter in a cell, a figure in a cell and a
1846 /// second linked value in a cell all became sayable at once, where each was
1847 /// previously a member, three renderer arms and a release.
1848 ///
1849 /// # Panics
1850 ///
1851 /// If the node is not a leaf. A cell is an inline run, so what goes in it
1852 /// has to be drawable on one wrapped line without the renderer knowing what
1853 /// it is -- that is the constrained-consumer bound, and this is where it
1854 /// bites at a call site rather than in a doc comment.
1855 #[must_use]
1856 pub fn part(mut self, node: Node) -> Self {
1857 assert!(
1858 node.containment() == Containment::Text,
1859 "a cell is an inline run and holds leaves; {node:?} holds \
1860 {:?}",
1861 node.containment()
1862 );
1863 self.parts.push(node);
1864 self
1865 }
1866
1867 /// The cell's text, with the parts that are not text left out.
1868 ///
1869 /// What every consumer of the old `value` member wanted. A cell that is one
1870 /// string answers that string; one that mixes answers the text between its
1871 /// tags and controls, in order.
1872 #[must_use]
1873 pub fn text(&self) -> String {
1874 self.parts
1875 .iter()
1876 .filter_map(|part| match part {
1877 Node::Text { text, .. } | Node::Link { text, .. } => Some(text.as_str()),
1878 _ => None,
1879 })
1880 .collect::<Vec<_>>()
1881 .join(" ")
1882 }
1883
1884 /// Whether anything in this cell answers a click.
1885 ///
1886 /// A badge is not one: it says something and answers nothing, which is why
1887 /// this asks the tag rather than counting tags.
1888 #[must_use]
1889 pub fn carries_control(&self) -> bool {
1890 self.parts.iter().any(|part| match part {
1891 Node::Act(_) | Node::Link { .. } => true,
1892 Node::Token(tag) => tag.kind.interactive() && tag.action.is_some(),
1893 _ => false,
1894 })
1895 }
1896 }
1897
1898 impl From<String> for Cell {
1899 fn from(value: String) -> Self {
1900 Self::new(value)
1901 }
1902 }
1903
1904 impl From<&str> for Cell {
1905 fn from(value: &str) -> Self {
1906 Self::new(value)
1907 }
1908 }
1909
1910 /// One row of a table.
1911 ///
1912 /// Cells are positional against the table's columns, and the table is the only
1913 /// place that pairing is made. A renderer narrowing the table drops columns by
1914 /// [`layout::Priority`] and drops the cells at the same indices, which is why
1915 /// the two live in one node rather than one per row.
1916 #[derive(Debug, Clone, PartialEq, Eq, Default)]
1917 pub struct Cells {
1918 /// One entry per column, in the table's column order.
1919 pub values: Vec<Cell>,
1920 /// The route that opens this row.
1921 pub activate: Option<Action>,
1922 /// Whether this is the row currently being shown elsewhere.
1923 ///
1924 /// The same fact [`Row::current`] carries, under the same name. It was
1925 /// `selected` until 2026-08-08, which is the word that decision retired:
1926 /// the app's pointer and the user's tick are two things, and one word for
1927 /// both is how every renderer ends up guessing which was meant. `Row` was
1928 /// renamed and this was missed, so it kept the ambiguous word while
1929 /// emitting `aria-current` from it.
1930 ///
1931 /// There is deliberately no tick here to go with it. `Row` grew one because
1932 /// goingson's contact cards have a bulk checkbox; no table asks for one, and
1933 /// a member added because its sibling has it is a member with no consumer to
1934 /// tell us what it should mean.
1935 pub current: bool,
1936 }
1937
1938 impl Cells {
1939 /// A row of cells in column order.
1940 ///
1941 /// Takes anything that becomes a [`Cell`], so a row of plain text is still
1942 /// `Cells::new(["kick.wav", "2.1 MB"])` and a row with a control mixes the
1943 /// two: `Cells::new([Cell::new(name), Cell::acts([remove])])`.
1944 pub fn new(values: impl IntoIterator<Item = impl Into<Cell>>) -> Self {
1945 Self {
1946 values: values.into_iter().map(Into::into).collect(),
1947 activate: None,
1948 current: false,
1949 }
1950 }
1951
1952 /// The route that opens this row.
1953 #[must_use]
1954 pub fn activate(mut self, action: Action) -> Self {
1955 self.activate = Some(action);
1956 self
1957 }
1958 }
1959
1960 /// A thing on a screen.
1961 ///
1962 /// Every member composes something `makeover-layout` already names, and that is
1963 /// the admission test for a new one. A node with no counterpart there means the
1964 /// vocabulary is missing a word, and the fix is to add the word rather than to
1965 /// add a widget here.
1966 #[derive(Debug, Clone, PartialEq, Eq)]
1967 pub enum Node {
1968 /// A title, at one of three depths in the heading tree.
1969 Heading {
1970 /// How far down the tree it sits.
1971 level: layout::Heading,
1972 /// The text.
1973 text: String,
1974 },
1975 /// Prose, with a tone.
1976 Text {
1977 /// The text.
1978 text: String,
1979 /// What it is saying. [`layout::Tone::Neutral`] is ordinary content.
1980 tone: layout::Tone,
1981 },
1982 /// Prose the author wrote in markdown.
1983 ///
1984 /// `25822137`, decided 2026-08-09. What is carried is the **source**, never
1985 /// markup, which is the property that lets this exist at all. Every renderer
1986 /// has an honest answer because each renders the source its own way: a
1987 /// webview through a markdown-to-HTML pass, a terminal through
1988 /// markdown-to-ANSI, egui through its own. A `Node::Html` would have handed
1989 /// every one of them a string it could not honour, and would have broken
1990 /// [`Node::Text`]'s escaping guarantee for every consumer rather than the
1991 /// one that asked. That refusal stands; this is not it.
1992 ///
1993 /// Sanitising is the renderer's, at the point markup is produced, for the
1994 /// reason escaping already is: this holds text a user typed, and a
1995 /// description that sanitised would be deciding what a host can draw.
1996 ///
1997 /// Not available inside a [`Row`]: a row part holds no node, by the
1998 /// 2026-08-08 ruling. What a row can hold is [`Prose`], which carries the
1999 /// same markdown source under the same reasoning without being a node, so
2000 /// the projects card no longer keeps its raw markdown in `secondary`.
2001 Rich {
2002 /// The markdown, as written.
2003 source: String,
2004 },
2005 /// A control that calls a route.
2006 Act(Act),
2007 /// Text that goes somewhere.
2008 ///
2009 /// The containment model predicted this before anything asked for it: the
2010 /// composability matrix had a `Link` row whose "as a `Node`" cell was a
2011 /// dash, and the only way to say it was [`Cell::activate`], a member on one
2012 /// container. Once a cell is a run of leaves, the run needs a leaf that
2013 /// means "this text is a link" or the thing stops being sayable at all.
2014 ///
2015 /// Distinct from [`Act`](Self::Act), and the difference is what the reader
2016 /// sees rather than what the route does. An act is a control drawn as one,
2017 /// which is right for `Edit` and wrong for a title: making every linked
2018 /// value a button would put a row of bevels down the first column of half a
2019 /// dashboard. Both call a route; only one of them looks like a button.
2020 Link {
2021 /// What it says.
2022 text: String,
2023 /// Where it goes.
2024 action: Action,
2025 },
2026 /// One figure, on a line.
2027 ///
2028 /// The second thing the containment model found, and it found it by
2029 /// refusing: a cell is a run of leaves, [`Stats`](Self::Stats) is a
2030 /// collection, so putting a figure in a cell failed the bound rather than
2031 /// quietly working. That is the check doing its job -- the matrix had
2032 /// "figure in a cell" as a dash nobody had attempted, and the dash turns
2033 /// out to have been hiding a missing member rather than a missing renderer
2034 /// arm.
2035 ///
2036 /// Not a duplicate of a one-element [`Stats`](Self::Stats), and the
2037 /// difference is the claim being made. A strip says "this is a row of
2038 /// tiles", which is why the set is the node there: a renderer handed one
2039 /// tile at a time cannot tell it is looking at a set. This says "this
2040 /// number sits on this line", where the run is already the grouping and
2041 /// there is nothing for a set to add. A dashboard strip of one is still a
2042 /// strip; a revenue column is not.
2043 Figure(Figure),
2044 /// A small labelled thing sitting inside something else.
2045 Token(Tag),
2046 /// Something the app is telling the user, unprompted.
2047 Notice {
2048 /// Transient and stacked, or persistent and in flow.
2049 kind: layout::Notice,
2050 /// What it is saying.
2051 tone: layout::Tone,
2052 /// The message.
2053 text: String,
2054 },
2055 /// What stands where content would be, when there is none.
2056 ///
2057 /// `703f4cd2`. goingson draws one at 27 sites across 12 files and Balanced
2058 /// Breakfast at 9, and the class families had already drifted into
2059 /// `empty-state--error` against `error-state` for the same fact. Every one
2060 /// of those sites substitutes markup where a list would go, which is what
2061 /// makes this a node.
2062 ///
2063 /// # Why not on the region
2064 ///
2065 /// It was on [`Slot`] first, and a real screen killed it: the project
2066 /// dashboard's columns are a heading and a list, and a column with no rows
2067 /// is a region that has content and a list that has none. Marking the
2068 /// region empty took the heading down with the rows. The emptiness belongs
2069 /// to the thing that is empty.
2070 ///
2071 /// [`Slot::readiness`] keeps the loading axis and only that, which is what
2072 /// `aria-busy` is about.
2073 ///
2074 /// # The state is the vocabulary's and the sentence is not
2075 ///
2076 /// `makeover-layout` names the four states because "nothing here yet" and
2077 /// "this broke" mean the same thing in every app that will have them. "No
2078 /// projects yet" is content, and so is the button under it, so both are
2079 /// here. A [`layout::Readiness::Ready`] renders nothing at all: the state
2080 /// that shows content has no stand-in to draw.
2081 StandIn {
2082 /// Which of the states this is standing in for.
2083 state: layout::Readiness,
2084 /// The sentence. "No projects yet", "Failed to load events".
2085 message: String,
2086 /// The way out, if there is one. "Add your first project", "Try again".
2087 ///
2088 /// 2 of goingson's 27 have one and 25 say a sentence and stop, which is
2089 /// why it is optional rather than a second required string.
2090 act: Option<Act>,
2091 },
2092 /// One control, standing on its own.
2093 ///
2094 /// `14612ed8`. A [`Form`](Self::Form) is a set of questions asked together
2095 /// and answered at once. A settings screen is not that: goingson's is
2096 /// sections with headings between them, each holding one control that writes
2097 /// as soon as it changes, and wrapping those in a form would describe markup
2098 /// that is not there and a submit that does not exist.
2099 ///
2100 /// Almost always carries a [`Field::changes`], because a control with no
2101 /// form around it and no route on it collects a value nothing reads.
2102 ///
2103 /// Boxed because it is the only member holding a whole struct by value, and
2104 /// [`Field`] is the largest one here — every other member holds a `Vec`, a
2105 /// `String` or a small enum. Unboxed it decides the size of every [`Node`]
2106 /// in every list, and of the [`Response`](crate::Response) that carries one.
2107 Field(Box<Field>),
2108 /// Fields, and the route that submits them.
2109 Form {
2110 /// Where the answers go. Almost always a [`Method::Post`].
2111 action: Action,
2112 /// What the submit control is called.
2113 submit: String,
2114 /// The questions, in order.
2115 fields: Vec<Field>,
2116 },
2117 /// Rows of the same kind of thing.
2118 List {
2119 /// The rows, in order.
2120 rows: Vec<Row>,
2121 /// What is not shown, if anything is.
2122 ///
2123 /// `346567f9`. A described list of the first 50 of 400 tasks was
2124 /// indistinguishable from a described list of 50 tasks, so each app
2125 /// grew its own answer: goingson a 159-line pagination manager with two
2126 /// consumers that had each written it separately first, Balanced
2127 /// Breakfast four `loadMore` sites. Two idioms for one fact, and the
2128 /// fact is what belongs here — how much more there is and how to ask
2129 /// for it. Whether that becomes numbered pages, a load-more button or
2130 /// an infinite scroll is the renderer's.
2131 more: Option<Rest>,
2132 },
2133 /// Rows with named columns.
2134 Table {
2135 /// The columns, in order. Cells are positional against these.
2136 columns: Vec<Column>,
2137 /// The rows, in order.
2138 rows: Vec<Cells>,
2139 },
2140 /// A control that picks between things.
2141 Select {
2142 /// Segmented, toggle, or tabs.
2143 kind: layout::Selector,
2144 /// What is on offer, and what each one calls if it calls something of
2145 /// its own.
2146 ///
2147 /// The tuple is [`Stats`](Self::Stats)' shape and it is here for the
2148 /// same reason, stated there: `makeover-layout` cannot name an action
2149 /// at all, so an address rides beside the described thing rather than
2150 /// inside it. [`Choice::as_layout`] hands back a value and a label and
2151 /// nothing else.
2152 ///
2153 /// It is what a tab strip needs. The MNW server's dashboard-user shell
2154 /// has fifteen tabs and fifteen routes; one strip-level action with the
2155 /// value substituted in cannot address them, and building the route by
2156 /// convention would put route construction in a renderer.
2157 ///
2158 /// An option carrying `None` falls back to
2159 /// [`action`](Self::Select::action) with its value under
2160 /// [`Self::SELECTED`], which is what every option did before the tuple,
2161 /// so a segmented control and a toggle are unchanged in meaning.
2162 options: Vec<(Choice, Option<Action>)>,
2163 /// Which option is currently picked, by its
2164 /// [`value`](Choice::value).
2165 chosen: Option<String>,
2166 /// What picking an option calls, for the options that name nothing
2167 /// themselves. The picked value is sent under [`Self::SELECTED`].
2168 action: Option<Action>,
2169 },
2170 /// How much of a set is done.
2171 Meter(Meter),
2172 /// A value with a caption, several of them as one strip.
2173 ///
2174 /// `93c6a174`. Against `makeover-layout`'s [`layout::Figure`], which arrived
2175 /// at 0.11.0 for this. The dashboard shape: a large value over a small
2176 /// caption, several in a row. goingson had five of them across five screens
2177 /// with five class vocabularies for the one shape, and the port had been
2178 /// making each out of a [`Row`] with the caption as `primary` and the figure
2179 /// as `meta`, which reads backwards — a row's primary slot means the thing
2180 /// itself, and here the thing is the number.
2181 ///
2182 /// # Why the set is the node and not each figure
2183 ///
2184 /// Four tiles in a strip and four tiles down a column are different things,
2185 /// and a renderer handed one at a time cannot tell it is looking at a set.
2186 /// The objection to that is real and is answered by what is already here: a
2187 /// node whose value is its grouping sounds like a layout instruction, and
2188 /// [`List`](Self::List) and [`Table`](Self::Table) have been exactly that
2189 /// since the beginning without anyone calling them one.
2190 ///
2191 /// # Why the action is here and not on the figure
2192 ///
2193 /// One of goingson's five is a control — sync's "Not Applied: 3" opens the
2194 /// list. `makeover-layout` cannot name an action at all, so the figure it
2195 /// describes carries none, and this pairs the description with the address
2196 /// the same way [`Row`] pairs its parts with [`Row::activate`].
2197 Stats {
2198 /// The figures, in order, and what each one calls if it calls anything.
2199 figures: Vec<(Figure, Option<Action>)>,
2200 },
2201 /// A region inside a region.
2202 Region(Slot),
2203 }
2204
2205 impl Node {
2206 /// The parameter name a [`Node::Select`] sends its picked value under.
2207 ///
2208 /// Named once here rather than agreed by convention between each renderer
2209 /// and each handler, which is how a value arrives under `tab` in one screen
2210 /// and `selected` in the next.
2211 pub const SELECTED: &'static str = "value";
2212
2213 /// The parameter name an [`Act::over`] sends each ticked value under.
2214 ///
2215 /// [`SELECTED`](Self::SELECTED)'s sibling, named here for the same reason:
2216 /// a convention agreed separately by each renderer and each handler is a
2217 /// convention that holds until one of them is written by someone else.
2218 ///
2219 /// Distinct from `SELECTED` rather than shared with it, because the two
2220 /// carry different counts. A [`Select`](Self::Select) sends one value and a
2221 /// handler reads it with [`Params::get`]; a selection sends however many
2222 /// are ticked, including none, and a handler reads it with
2223 /// [`Params::get_all`]. One name for both would make "the one thing picked"
2224 /// and "the first of the things ticked" the same read.
2225 ///
2226 /// [`Params::get`]: crate::Params::get
2227 /// [`Params::get_all`]: crate::Params::get_all
2228 pub const TICKED: &'static str = "ticked";
2229
2230 /// A page title.
2231 pub fn page(text: impl Into<String>) -> Self {
2232 Self::Heading {
2233 level: layout::Heading::Page,
2234 text: text.into(),
2235 }
2236 }
2237
2238 /// A section title.
2239 pub fn section(text: impl Into<String>) -> Self {
2240 Self::Heading {
2241 level: layout::Heading::Section,
2242 text: text.into(),
2243 }
2244 }
2245
2246 /// Ordinary prose.
2247 pub fn text(text: impl Into<String>) -> Self {
2248 Self::Text {
2249 text: text.into(),
2250 tone: layout::Tone::Neutral,
2251 }
2252 }
2253
2254 /// Prose written in markdown.
2255 pub fn rich(source: impl Into<String>) -> Self {
2256 Self::Rich {
2257 source: source.into(),
2258 }
2259 }
2260
2261 /// A control calling a route.
2262 pub fn act(label: impl Into<String>, action: Action) -> Self {
2263 Self::Act(Act::new(label, action))
2264 }
2265
2266 /// A persistent message, dismissed by fixing what caused it.
2267 pub fn banner(tone: layout::Tone, text: impl Into<String>) -> Self {
2268 Self::Notice {
2269 kind: layout::Notice::Banner,
2270 tone,
2271 text: text.into(),
2272 }
2273 }
2274
2275 /// A transient message that dismisses itself.
2276 pub fn toast(tone: layout::Tone, text: impl Into<String>) -> Self {
2277 Self::Notice {
2278 kind: layout::Notice::Toast,
2279 tone,
2280 text: text.into(),
2281 }
2282 }
2283
2284 /// A list of rows.
2285 pub fn list(rows: impl IntoIterator<Item = Row>) -> Self {
2286 Self::List {
2287 rows: rows.into_iter().collect(),
2288 more: None,
2289 }
2290 }
2291
2292 /// The same list, saying there is more of it.
2293 ///
2294 /// A no-op on anything that is not a [`Self::List`], which is the one place
2295 /// this file allows that: the alternative is a constructor taking rows and a
2296 /// `Rest` together, and every call site that has no more rows then passes a
2297 /// `None` to say so.
2298 #[must_use]
2299 pub fn and_more(mut self, rest: Rest) -> Self {
2300 if let Self::List { more, .. } = &mut self {
2301 *more = Some(rest);
2302 }
2303 self
2304 }
2305
2306 /// A proportion of a set, untoned and unlabelled.
2307 #[must_use]
2308 pub const fn meter(done: u32, total: u32) -> Self {
2309 Self::Meter(Meter::new(done, total))
2310 }
2311
2312 /// Nothing here yet.
2313 pub fn empty(message: impl Into<String>) -> Self {
2314 Self::StandIn {
2315 state: layout::Readiness::Empty,
2316 message: message.into(),
2317 act: None,
2318 }
2319 }
2320
2321 /// This did not load.
2322 pub fn failed(message: impl Into<String>) -> Self {
2323 Self::StandIn {
2324 state: layout::Readiness::Failed,
2325 message: message.into(),
2326 act: None,
2327 }
2328 }
2329
2330 /// The same stand-in, with a way out of it.
2331 ///
2332 /// A no-op on anything else, for the reason [`Self::and_more`] is one.
2333 #[must_use]
2334 pub fn offering(mut self, way_out: Act) -> Self {
2335 if let Self::StandIn { act, .. } = &mut self {
2336 *act = Some(way_out);
2337 }
2338 self
2339 }
2340
2341 /// One control on its own, outside any form.
2342 pub fn field(field: Field) -> Self {
2343 Self::Field(Box::new(field))
2344 }
2345
2346 /// A strip of figures, none of which answers a click.
2347 pub fn stats(figures: impl IntoIterator<Item = Figure>) -> Self {
2348 Self::Stats {
2349 figures: figures.into_iter().map(|figure| (figure, None)).collect(),
2350 }
2351 }
2352 }
2353
2354 /// A whole screen.
2355 ///
2356 /// [`Arrangement`](layout::Arrangement) is `makeover-layout`'s, and there are
2357 /// two of them because our apps have two: goingson is list-detail, Balanced
2358 /// Breakfast is sidebar plus content. Naming a third before an app has one is
2359 /// how a description becomes a framework.
2360 #[derive(Debug, Clone, PartialEq, Eq)]
2361 pub struct Screen {
2362 /// What the screen is called. A window title, a tab title, a page heading.
2363 pub title: String,
2364 /// How the regions are laid out.
2365 pub arrangement: layout::Arrangement,
2366 /// The regions, in order.
2367 pub slots: Vec<Slot>,
2368 /// Messages raised by whatever produced this screen.
2369 ///
2370 /// Separate from the slots because a notice belongs to the screen rather
2371 /// than to a place in it: which region a toast stacks in is the renderer's
2372 /// question, and a handler answering it would be describing a webview.
2373 pub notices: Vec<Node>,
2374 /// How this screen is found, shared and indexed.
2375 ///
2376 /// Not an `Option`. The default is meaningful — a screen nobody said
2377 /// anything about is an indexable website — and an `Option` would make
2378 /// "nobody said" and "indexable" two spellings of one thing.
2379 pub discovery: Discovery,
2380 /// The name of the set this screen's ticks go into, if it holds one.
2381 ///
2382 /// `5f2b8753`. [`Row::selected`] said a row could be ticked and nothing
2383 /// said what the tick was *for*, so the tick had nowhere to go: a webview
2384 /// hid the hole because the browser owns a checkbox's checked state, and
2385 /// every app then wrote its own JS to gather the boxes back up. A terminal
2386 /// could not hide it. It drew the `[ ]`, bound the key, and the key did
2387 /// nothing, which is worse than not drawing the box.
2388 ///
2389 /// So the screen names the set, each [`Row::value`] is what that row's tick
2390 /// contributes, and [`Act::over`] is how a control says it acts on the
2391 /// whole of it. The renderer holds the set the way `quasi-tui` already
2392 /// holds an edit buffer and a scroll offset, and the commit control reads
2393 /// it by name.
2394 ///
2395 /// # Ticking never writes
2396 ///
2397 /// Wiki `explicit-commit-affordance`, the general rule: a change that
2398 /// happens with no obvious indication is confusing, so a tick stages and
2399 /// the commit control is what locks it in. [`Row::toggle`] describes the
2400 /// other thing — screens where the tick *is* the write — and is left alone
2401 /// here rather than removed, because stopping those screens is work in the
2402 /// apps that have them.
2403 ///
2404 /// # One set per screen
2405 ///
2406 /// A screen with two independent sets has not been measured. Naming one is
2407 /// the smallest thing that closes the hole, and the field grows to a map
2408 /// when an app turns up wanting two, on the same rule every other member
2409 /// here arrived under.
2410 ///
2411 /// [`Row::selected`]: Row::selected
2412 /// [`Row::value`]: Row::value
2413 /// [`Act::over`]: Act::over
2414 pub selection: Option<String>,
2415 /// How wide this screen's content runs.
2416 ///
2417 /// `0eccff0d`. Measured in the MNW server, where 69 of 72 templates carry
2418 /// one of three mutually exclusive CSS classes for it and nothing described
2419 /// it, so the choice lived in the template rather than in the screen.
2420 ///
2421 /// Beside [`arrangement`](Self::arrangement) and answering the level above
2422 /// it: that one divides the screen's width between regions, this says how
2423 /// much of the window the screen takes in the first place. Both are the
2424 /// description's, which is what answering `e0fd485e` and `0eccff0d`
2425 /// together settled.
2426 ///
2427 /// Not an `Option`, for [`discovery`](Self::discovery)'s reason. The
2428 /// default is meaningful -- a screen nobody said anything about uses the
2429 /// window it was given -- and an `Option` would make "nobody said" and
2430 /// "the whole width" two spellings of one thing.
2431 pub measure: layout::Measure,
2432 }
2433
2434 /// How a screen is found, shared and indexed.
2435 ///
2436 /// Not presentation, which is why it is here and not in `makeover-layout`: a
2437 /// terminal ignores every field, the same way it ignores [`Slot::id`]. It is an
2438 /// address-and-identity fact, and that is the line that put [`Action`] in this
2439 /// crate rather than in the vocabulary.
2440 ///
2441 /// Measured before it was added. Every `og:*` value in the MNW server's 37
2442 /// templates is one of four things interpolated from the entity the screen is
2443 /// about: a title, a summary sentence, an image URL, or the screen's own
2444 /// address. None of them needed knowledge only a handler has, which is what
2445 /// made this the screen's to say rather than the host's.
2446 #[derive(Debug, Clone, PartialEq, Eq)]
2447 pub struct Discovery {
2448 /// Whether a crawler should index this screen.
2449 ///
2450 /// Defaults to indexable, because most screens are and a default that hides
2451 /// pages is a default that hides the bug. The six screens saying otherwise
2452 /// are purchased-content pages, and this field is why that is a fact the
2453 /// type carries rather than a line in a template that a conversion can drop
2454 /// in silence.
2455 pub indexable: bool,
2456 /// The sentence a link preview shows. [`Screen::title`] is the title.
2457 pub summary: Option<String>,
2458 /// The image a link preview shows, as an absolute URL.
2459 pub image: Option<String>,
2460 /// What kind of thing this screen is about.
2461 pub kind: SocialKind,
2462 /// The canonical address, when the screen answers at more than one.
2463 pub canonical: Option<String>,
2464 }
2465
2466 impl Default for Discovery {
2467 /// Indexable, and nothing else claimed.
2468 ///
2469 /// Written out rather than derived, and the reason is the one field that
2470 /// matters: `bool::default()` is `false`, so a derived impl would deindex
2471 /// every screen that never mentioned the subject, silently, and the failure
2472 /// would show up as traffic rather than as a test.
2473 fn default() -> Self {
2474 Self {
2475 indexable: true,
2476 summary: None,
2477 image: None,
2478 kind: SocialKind::Website,
2479 canonical: None,
2480 }
2481 }
2482 }
2483
2484 /// What kind of thing a screen is about.
2485 ///
2486 /// The six the server actually emits, and no more. Naming a seventh before a
2487 /// screen has one is how a description becomes a framework, which is the
2488 /// argument [`Arrangement`](layout::Arrangement) is held to two screens by.
2489 ///
2490 /// `#[non_exhaustive]`, because a seventh arriving should not be a lockstep
2491 /// event across every renderer that spells one. The match below stays
2492 /// exhaustive: within this crate the attribute does not apply, and a wildcard
2493 /// here would only hide a member added without a spelling.
2494 #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
2495 #[non_exhaustive]
2496 pub enum SocialKind {
2497 /// A page. The default, and four of the server's screens.
2498 #[default]
2499 Website,
2500 /// Something written, with an author and a date.
2501 Article,
2502 /// A person or an account.
2503 Profile,
2504 /// Something for sale.
2505 Product,
2506 /// A video.
2507 Video,
2508 /// A piece of music.
2509 Song,
2510 }
2511
2512 impl SocialKind {
2513 /// What this is spelled as in `og:type`.
2514 ///
2515 /// Named here rather than agreed between each renderer and each host, which
2516 /// is how one screen ends up `video.other` and the next `video`.
2517 #[must_use]
2518 pub const fn as_str(self) -> &'static str {
2519 match self {
2520 Self::Website => "website",
2521 Self::Article => "article",
2522 Self::Profile => "profile",
2523 Self::Product => "product",
2524 Self::Video => "video.other",
2525 Self::Song => "music.song",
2526 }
2527 }
2528 }
2529
2530 impl Screen {
2531 /// An empty screen with the given arrangement.
2532 pub fn new(title: impl Into<String>, arrangement: layout::Arrangement) -> Self {
2533 Self {
2534 title: title.into(),
2535 arrangement,
2536 slots: Vec::new(),
2537 notices: Vec::new(),
2538 discovery: Discovery::default(),
2539 selection: None,
2540 measure: layout::Measure::default(),
2541 }
2542 }
2543
2544 /// How wide this screen's content runs, chaining.
2545 ///
2546 /// See [`measure`](Self::measure). [`Measure::Wide`](layout::Measure::Wide)
2547 /// is the default and does not need saying.
2548 #[must_use]
2549 pub const fn measured(mut self, measure: layout::Measure) -> Self {
2550 self.measure = measure;
2551 self
2552 }
2553
2554 /// This screen holds a set of ticks under this name, chaining.
2555 ///
2556 /// The rows that join it say so with [`Row::ticking`], and the control that
2557 /// acts on it with [`Act::over`]. See [`selection`](Self::selection).
2558 #[must_use]
2559 pub fn selecting(mut self, name: impl Into<String>) -> Self {
2560 self.selection = Some(name.into());
2561 self
2562 }
2563
2564 /// Whether a crawler should index this screen, chaining.
2565 #[must_use]
2566 pub fn indexed(mut self, indexable: bool) -> Self {
2567 self.discovery.indexable = indexable;
2568 self
2569 }
2570
2571 /// The sentence a link preview shows, chaining.
2572 #[must_use]
2573 pub fn summarised(mut self, text: impl Into<String>) -> Self {
2574 self.discovery.summary = Some(text.into());
2575 self
2576 }
2577
2578 /// The image a link preview shows, chaining. An absolute URL.
2579 #[must_use]
2580 pub fn illustrated(mut self, url: impl Into<String>) -> Self {
2581 self.discovery.image = Some(url.into());
2582 self
2583 }
2584
2585 /// What kind of thing this screen is about, chaining.
2586 #[must_use]
2587 pub fn about(mut self, kind: SocialKind) -> Self {
2588 self.discovery.kind = kind;
2589 self
2590 }
2591
2592 /// The address this screen should be known by, chaining.
2593 #[must_use]
2594 pub fn canonical_at(mut self, url: impl Into<String>) -> Self {
2595 self.discovery.canonical = Some(url.into());
2596 self
2597 }
2598
2599 /// A list that chooses what the detail beside it shows.
2600 pub fn list_detail(title: impl Into<String>, tabbed: bool) -> Self {
2601 Self::new(title, layout::Arrangement::list_detail(tabbed))
2602 }
2603
2604 /// Navigation down the side, content filling the rest.
2605 pub fn sidebar_content(title: impl Into<String>) -> Self {
2606 Self::new(title, layout::Arrangement::sidebar_content())
2607 }
2608
2609 /// Add a region, chaining.
2610 #[must_use]
2611 pub fn with(mut self, slot: Slot) -> Self {
2612 self.slots.push(slot);
2613 self
2614 }
2615
2616 /// Raise a message on this screen, chaining.
2617 ///
2618 /// # Panics
2619 ///
2620 /// If the node is not a [`Node::Notice`]. The field is typed as a [`Node`]
2621 /// so a renderer walks one kind of thing, and this is the constructor that
2622 /// keeps that from meaning anything can go in it.
2623 #[must_use]
2624 pub fn saying(mut self, notice: Node) -> Self {
2625 assert!(
2626 matches!(notice, Node::Notice { .. }),
2627 "Screen::saying takes a Node::Notice"
2628 );
2629 self.notices.push(notice);
2630 self
2631 }
2632
2633 /// The slot under this address, at any depth.
2634 #[must_use]
2635 pub fn slot(&self, id: &str) -> Option<&Slot> {
2636 self.slots.iter().find_map(|slot| slot.find(id))
2637 }
2638
2639 /// Apply a fragment: put `node` in the region under `region`, replacing
2640 /// whatever was there. Returns whether the region was found.
2641 ///
2642 /// This is what a host holding a `Screen` does with
2643 /// [`Outcome::Fragment`](crate::Outcome::Fragment). A webview host needs
2644 /// none of it -- `quasi-http` turns the same outcome into an `hx-retarget`
2645 /// header and the browser performs the swap against a document it already
2646 /// has -- but a host that retains the description rather than the markup
2647 /// has nothing between the fragment and the tree.
2648 ///
2649 /// It lives here and not in a host because applying a fragment is surgery
2650 /// on this crate's own type. A host writing it means every retained-screen
2651 /// host writes it separately and each picks its own answer for the three
2652 /// decisions below, which is the thing this crate's no-host-imports rule
2653 /// exists to prevent.
2654 ///
2655 /// **A region that is not there answers `false`, not a panic.** The caller
2656 /// is the one that can act on it: a host can fall back to a redraw, and a
2657 /// test can assert it. What is worth avoiding is the silent no-op, because
2658 /// a miss means a route naming a slot that no longer exists, and that is a
2659 /// description bug rather than a rendering one.
2660 ///
2661 /// **It replaces rather than appends.** `Outcome::Fragment` is one region's
2662 /// new contents, which is the whole reason it can be smaller than a screen.
2663 ///
2664 /// **The region becomes [`Ready`](layout::Readiness::Ready).** A fragment
2665 /// arriving is the content arriving, so a slot marked
2666 /// [`Pending`](layout::Readiness::Pending) while it was in flight stops
2667 /// being pending here. Emptiness is a different axis and rides on the node:
2668 /// a [`Node::StandIn`] carries its own state, and replacing with one is a
2669 /// region that is ready and has nothing to show.
2670 pub fn replace(&mut self, region: &str, node: Node) -> bool {
2671 let Some(slot) = self.slots.iter_mut().find_map(|slot| slot.find_mut(region)) else {
2672 return false;
2673 };
2674 slot.body.clear();
2675 slot.body.push(node);
2676 slot.readiness = layout::Readiness::Ready;
2677 true
2678 }
2679 }
2680