Skip to main content

max / quasi

122.7 KB · 3122 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 /// A picture and where it is.
431 ///
432 /// Called `Picture` rather than `Image` because `layout::Image` is the
433 /// description half and the two are in scope together constantly. The same
434 /// dodge [`Tag`] makes for `layout::Token`.
435 ///
436 /// The split is `layout::Image`'s: [`src`](Self::src) is an address and lives
437 /// here, everything about what the picture *is* lives there.
438 #[derive(Debug, Clone, PartialEq, Eq, Hash)]
439 pub struct Picture {
440 /// Where the picture is. A URL, or whatever the host resolves.
441 ///
442 /// Never interpreted here. A renderer escapes it for wherever it is putting
443 /// it, the way it does every other app-supplied string.
444 pub src: String,
445 /// What the picture says, for anything not showing it.
446 ///
447 /// Empty means decorative. `layout::Image` carries the argument for why
448 /// this is a `String` and not an `Option<String>`.
449 pub alt: String,
450 /// A visible line under it, where the app wants one.
451 pub caption: Option<String>,
452 /// How it sits in the box it is given.
453 pub fit: layout::Fit,
454 /// The picture's own dimensions, where the app knows them.
455 ///
456 /// `layout::Image::intrinsic` carries the argument. The short form: without
457 /// it a renderer cannot hold the picture's place, so the picture takes no
458 /// room until it arrives and then shoves the page down.
459 pub intrinsic: Option<layout::Extent>,
460 /// Whether the picture is needed with the screen, or can arrive later.
461 pub loading: layout::Loading,
462 }
463
464 impl Picture {
465 /// A picture at a source, carrying its own proportions.
466 pub fn new(src: impl Into<String>, alt: impl Into<String>) -> Self {
467 Self {
468 src: src.into(),
469 alt: alt.into(),
470 caption: None,
471 fit: layout::Fit::Natural,
472 intrinsic: None,
473 loading: layout::Loading::Eager,
474 }
475 }
476
477 /// The picture's own dimensions, so a renderer can hold its place.
478 #[must_use]
479 pub const fn intrinsic(mut self, width: u32, height: u32) -> Self {
480 self.intrinsic = Some(layout::Extent::new(width, height));
481 self
482 }
483
484 /// This picture is not on screen yet; it can arrive when it is near.
485 #[must_use]
486 pub const fn lazy(mut self) -> Self {
487 self.loading = layout::Loading::Lazy;
488 self
489 }
490
491 /// A visible line under it.
492 #[must_use]
493 pub fn caption(mut self, caption: impl Into<String>) -> Self {
494 self.caption = Some(caption.into());
495 self
496 }
497
498 /// How it sits in its box.
499 #[must_use]
500 pub const fn fit(mut self, fit: layout::Fit) -> Self {
501 self.fit = fit;
502 self
503 }
504
505 /// Borrow as the description layer's own type.
506 #[must_use]
507 pub fn as_layout(&self) -> layout::Image<'_> {
508 layout::Image {
509 alt: &self.alt,
510 caption: self.caption.as_deref(),
511 fit: self.fit,
512 intrinsic: self.intrinsic,
513 loading: self.loading,
514 }
515 }
516 }
517
518 /// One figure with a caption, owned.
519 ///
520 /// The borrowed original is [`layout::Figure`], and everything it says applies:
521 /// the value is text because only the app knows whether the number is a
522 /// percentage, a duration or a ratio, and the tone is carried because no
523 /// renderer can work out that a streak of zero is worth colouring.
524 ///
525 /// What it calls, if it calls anything, is not here. That is an address, which
526 /// `makeover-layout` never names, and it rides beside the figure in
527 /// [`Node::Stats`] the way [`Row::activate`] rides beside a row's parts.
528 #[derive(Debug, Clone, PartialEq, Eq, Hash)]
529 pub struct Figure {
530 /// The number, formatted the way the app means it to read.
531 pub value: String,
532 /// What it counts. The caption under the value.
533 pub caption: String,
534 /// How the value has moved, if the app is tracking that.
535 ///
536 /// Mirrors `layout::Figure::change`, added there at 0.13.0. Text for the
537 /// same reason [`value`](Self::value) is: only the app knows whether a move
538 /// reads as `+12.5%`, `+3` or `2x`.
539 ///
540 /// This is what [`tone`](Self::tone) was for. Counted before adding it: the
541 /// MNW server has four screens whose stat card is a label, a value and a
542 /// delta, and on all four the delta is the toned part while the number
543 /// itself is an ordinary fact. Without it the delta folds into the caption,
544 /// which loses the tone and turns a second smaller line into a longer first
545 /// one.
546 pub change: Option<String>,
547 /// What the figure means. [`layout::Tone::Neutral`] is an ordinary fact.
548 ///
549 /// Applies to [`change`](Self::change) where there is one, and to the value
550 /// where there is not. The renderer decides which element that lands on.
551 pub tone: layout::Tone,
552 }
553
554 impl Figure {
555 /// A figure that is an ordinary fact.
556 pub fn new(value: impl Into<String>, caption: impl Into<String>) -> Self {
557 Self {
558 value: value.into(),
559 caption: caption.into(),
560 change: None,
561 tone: layout::Tone::Neutral,
562 }
563 }
564
565 /// How the value has moved.
566 #[must_use]
567 pub fn change(mut self, change: impl Into<String>) -> Self {
568 self.change = Some(change.into());
569 self
570 }
571
572 /// What the figure means.
573 #[must_use]
574 pub const fn tone(mut self, tone: layout::Tone) -> Self {
575 self.tone = tone;
576 self
577 }
578
579 /// Borrow as the description layer's own type.
580 #[must_use]
581 pub fn as_layout(&self) -> layout::Figure<'_> {
582 layout::Figure {
583 value: &self.value,
584 caption: &self.caption,
585 change: self.change.as_deref(),
586 tone: self.tone,
587 }
588 }
589 }
590
591 /// What a list has that it is not showing.
592 ///
593 /// Deliberately not virtual scrolling, which is the neighbouring thing and is
594 /// not a description concern: goingson's `virtual-scroller.js` windows rows the
595 /// app already holds, which is a renderer performance technique. This is a fact
596 /// about the data — there are rows that were never fetched — and only the thing
597 /// that fetched them knows it.
598 #[derive(Debug, Clone, PartialEq, Eq)]
599 pub struct Rest {
600 /// How many more there are, when that is known.
601 ///
602 /// `None` is honest and common: a query that asked for 51 to find out
603 /// whether there were more than 50 knows that there are, and not how many.
604 /// A renderer with a count can say "50 of 400" and one without can still
605 /// offer the way forward.
606 pub remaining: Option<u32>,
607 /// What asking for more calls.
608 pub action: Action,
609 }
610
611 impl Rest {
612 /// There is more, reached this way, and the count is not known.
613 #[must_use]
614 pub const fn more(action: Action) -> Self {
615 Self {
616 remaining: None,
617 action,
618 }
619 }
620
621 /// How many more there are.
622 #[must_use]
623 pub const fn remaining(mut self, remaining: u32) -> Self {
624 self.remaining = Some(remaining);
625 self
626 }
627 }
628
629 /// Prose in a row part: what it says, and whether it is markdown.
630 ///
631 /// `secondary` has always been a `String`, and three call sites had markdown to
632 /// put in it: the goingson projects card's description, the mail list's body
633 /// preview, and a contact's note next. Each put the **source** in, so a row read
634 /// `**Ships Q3.** See [the brief](https://...)` where the screen it stands in
635 /// for reads the sentence. Flattening at the call site fixes what the user sees
636 /// and loses the fact on the way: a renderer receiving the row cannot tell text
637 /// an author typed from markdown somebody already flattened, so it cannot decide
638 /// for itself, and the flattening is copied per site.
639 ///
640 /// This is [`Meter`]'s answer, not [`Node`]'s. The row still holds no node --
641 /// the 2026-08-08 ruling, and the door through which a description becomes a
642 /// templating language -- it holds a two-case value saying which of two things
643 /// its string is. A webview renders the markdown inline, a terminal can emit
644 /// bold, and a renderer that wants neither flattens it, each from the same
645 /// description.
646 ///
647 /// [`Text`](Self::Text) is the default in every sense: `From<&str>` and
648 /// `From<String>` both produce it, so `.secondary("...")` means what it always
649 /// meant and no existing call site changes.
650 #[derive(Debug, Clone, PartialEq, Eq)]
651 pub enum Prose {
652 /// Text as written. A renderer escapes it and draws it, and nothing in it
653 /// is markup however it is punctuated.
654 Text(String),
655 /// Markdown source, carried as source for the reason [`Node::Rich`] does:
656 /// every renderer has an honest answer because each renders it its own way,
657 /// and nothing here is markup a renderer has to trust.
658 Rich(String),
659 }
660
661 impl Prose {
662 /// Markdown, to be rendered by whoever draws it.
663 pub fn rich(source: impl Into<String>) -> Self {
664 Self::Rich(source.into())
665 }
666
667 /// The string, whichever case this is.
668 ///
669 /// For a renderer that treats both the same, and for a test that does not
670 /// care. A renderer that draws this without looking at the case is drawing
671 /// markdown as text, which is the bug this type exists to make visible
672 /// rather than impossible.
673 #[must_use]
674 pub fn source(&self) -> &str {
675 match self {
676 Self::Text(text) | Self::Rich(text) => text,
677 }
678 }
679
680 /// Whether there is anything to draw.
681 #[must_use]
682 pub fn is_empty(&self) -> bool {
683 self.source().is_empty()
684 }
685 }
686
687 impl From<String> for Prose {
688 fn from(text: String) -> Self {
689 Self::Text(text)
690 }
691 }
692
693 impl From<&str> for Prose {
694 fn from(text: &str) -> Self {
695 Self::Text(text.to_owned())
696 }
697 }
698
699 impl From<&String> for Prose {
700 fn from(text: &String) -> Self {
701 Self::Text(text.clone())
702 }
703 }
704
705 /// How much of a set is done, owned.
706 ///
707 /// The borrowed original is [`layout::Meter`], which arrived at 0.10.0 for this.
708 /// Before it, a screen with a progress bar concatenated the two numbers into its
709 /// heading — "Subtasks 3/7" — which keeps both facts and loses the reading, the
710 /// same way a toned status badge read as prose before [`Row::tokens`].
711 ///
712 /// Its own struct as of 0.11.0, having been the inline payload of
713 /// [`Node::Meter`]. Extracted for the reason [`Tag`] was: a row can carry one
714 /// now ([`Row::meter`], against `makeover-layout`'s `RowPart::Proportion`), and
715 /// the alternative was defining the same four fields twice and watching them
716 /// drift.
717 ///
718 /// Carries the pair rather than a percentage for the reason [`layout::Meter`]
719 /// gives: a bar that is full because it landed exactly and one that is full
720 /// because it ran over are the same width and not the same fact.
721 ///
722 /// This is a proportion of a set and not the progress of an operation. A running
723 /// timer or a fetch is imperative and live, and a screen is described once per
724 /// answer; [`layout::Readiness::Pending`] and a [`layout::Notice::Toast`] are
725 /// what those get.
726 #[derive(Debug, Clone, PartialEq, Eq, Hash)]
727 pub struct Meter {
728 /// How much is done. May exceed [`total`](Self::total).
729 pub done: u32,
730 /// How much there is to do.
731 pub total: u32,
732 /// What the proportion means. No renderer can derive this.
733 pub tone: layout::Tone,
734 /// What is being counted: "subtasks", "tasks". The noun, not the ratio.
735 pub label: Option<String>,
736 }
737
738 impl Meter {
739 /// A proportion, untoned and unlabelled.
740 #[must_use]
741 pub const fn new(done: u32, total: u32) -> Self {
742 Self {
743 done,
744 total,
745 tone: layout::Tone::Neutral,
746 label: None,
747 }
748 }
749
750 /// What the proportion means.
751 #[must_use]
752 pub const fn tone(mut self, tone: layout::Tone) -> Self {
753 self.tone = tone;
754 self
755 }
756
757 /// What is being counted. The noun, not the ratio.
758 #[must_use]
759 pub fn label(mut self, label: impl Into<String>) -> Self {
760 self.label = Some(label.into());
761 self
762 }
763
764 /// Borrow as the description layer's own type.
765 #[must_use]
766 pub fn as_layout(&self) -> layout::Meter<'_> {
767 layout::Meter {
768 done: self.done,
769 total: self.total,
770 tone: self.tone,
771 label: self.label.as_deref(),
772 }
773 }
774 }
775
776 /// One field of a form, owned.
777 ///
778 /// The borrowed original is [`layout::Field`], and everything it says about
779 /// what a field carries applies unchanged, with one addition that does not
780 /// travel down to it: [`value`](Self::value).
781 ///
782 /// # Why the value lives here and not in `makeover-layout`
783 ///
784 /// `1c4a66a4`, decided 2026-08-09. [`layout::Field`] refuses to carry the
785 /// current value, and that refusal is right: an immediate-mode renderer writes
786 /// through a `&mut String` the app owns, and a terminal keeps an edit buffer,
787 /// so a description carrying a live value would need a way to write it back and
788 /// would then be a form model.
789 ///
790 /// What is carried here is not a live value. It is what to re-offer after a
791 /// submission was refused, and it has [`error`](Self::error)'s lifecycle rather
792 /// than a live value's: per-submission, one way, supplied by whoever validated,
793 /// gone on the next request. `error` already sits in this struct on exactly
794 /// those terms.
795 ///
796 /// The reason it is this crate's field and not the vocabulary's is that only a
797 /// stateless request and response destroys the value. In egui and in a terminal
798 /// the buffer never went anywhere, so nothing is lost and there is nothing to
799 /// re-offer. This is the layer where the loss happens, so this is the layer that
800 /// repairs it.
801 ///
802 /// # A field's described state is its value, and the caret is the renderer's
803 ///
804 /// `d52884b0`, decided 2026-08-12. Nothing here carries a caret position, and
805 /// nothing in [`layout::FieldKind`] does either. A description names the field
806 /// and, where it has one, its completion source. Where the caret sits is how a
807 /// renderer decides what to offer from that source.
808 ///
809 /// The question came from goingson's `search.js`, whose completion list depends
810 /// on which token the caret is inside rather than on the value: it reads
811 /// `selectionStart`, listens for caret moves that change nothing else, and
812 /// writes the caret back when a suggestion is applied. That is a real
813 /// dependency, and it still does not belong here. A caret is where the user is
814 /// pointing inside a control, the same class of fact as a scroll offset and a
815 /// focus position, and this stack already puts those in the renderer's view
816 /// rather than in the description (`quasi-tui`'s `View`).
817 ///
818 /// Growing this struct to (value, caret) was rejected: it is the most-consumed
819 /// member in the vocabulary, every renderer would owe it an answer, and a
820 /// terminal's answer would be a second cursor concept beside the one the runtime
821 /// already holds. The measured demand was one file.
822 ///
823 /// Reversible if a second consumer appears that needs the caret described rather
824 /// than held, such as a completion that has to survive a fragment swap. That is
825 /// a member here and a cascade, the same shape as every other addition.
826 ///
827 /// No `Hash`, for the reason [`Tag`] has none: it can hold an [`Action`], which
828 /// holds [`Params`], which is a `Vec`.
829 #[derive(Debug, Clone, PartialEq, Eq)]
830 pub struct Field {
831 /// What kind of value it takes.
832 pub kind: layout::FieldKind,
833 /// The name the value is submitted under, and the name the handler reads
834 /// back out of [`Params`].
835 pub name: String,
836 /// What the user is asked for.
837 pub label: String,
838 /// Standing help.
839 pub hint: Option<String>,
840 /// What is currently wrong with the value. Supplied by whoever validated;
841 /// nothing here decides that a value is wrong.
842 pub error: Option<String>,
843 /// Ghost text shown while the field is empty.
844 pub placeholder: Option<String>,
845 /// The options offered, in order. Empty for kinds that offer none.
846 pub options: Vec<Choice>,
847 /// Whether the form refuses to submit without it.
848 pub required: bool,
849 /// The longest the value may be, in characters.
850 ///
851 /// The borrowed original's [`layout::Field::max_length`], and everything it
852 /// says applies: the description carries the rule, the renderer emits its
853 /// host's idiom, and deciding a value is wrong stays with whoever validated.
854 pub max_length: Option<u32>,
855 /// The lowest value accepted, written the way the host writes one.
856 pub min: Option<String>,
857 /// The highest value accepted. See [`min`](Self::min).
858 pub max: Option<String>,
859 /// Whether the field lives behind a "more options" disclosure.
860 pub extended: bool,
861 /// What to put back in the box: what was submitted, when a submission was
862 /// refused and the form is being offered again.
863 ///
864 /// `None` on a first showing, which is every form that is not answering a
865 /// refusal. A checkbox is here by presence, the way HTML submits one: a
866 /// value means ticked and `None` means not.
867 ///
868 /// A [`layout::FieldKind::Secret`] never gets one. [`Field::value`] refuses
869 /// to set it and every renderer refuses to emit it, so the guarantee does
870 /// not rest on either alone.
871 pub value: Option<String>,
872 /// What changing this calls, for a control that writes on its own rather
873 /// than waiting for a submit.
874 ///
875 /// `14612ed8`. A field inside a [`Node::Form`] submits with the form and
876 /// needs nothing here. A settings toggle is the other kind: there is no
877 /// submit, and changing the control *is* the write. goingson had 13 of these
878 /// and reached them through `dispatch.js`, 109 lines of its own event
879 /// plumbing, because nothing in the description could say it. No version of
880 /// spinning up an app quickly has each app hand-rolling a dispatcher.
881 ///
882 /// The route receives the value under this field's [`name`](Self::name),
883 /// which is the same name a submit would have sent it under. Nothing else
884 /// changes about the field.
885 pub changes: Option<Action>,
886 }
887
888 impl Field {
889 /// A plain optional field of the given kind.
890 pub fn new(kind: layout::FieldKind, name: impl Into<String>, label: impl Into<String>) -> Self {
891 Self {
892 kind,
893 name: name.into(),
894 label: label.into(),
895 hint: None,
896 error: None,
897 placeholder: None,
898 options: Vec::new(),
899 required: false,
900 max_length: None,
901 min: None,
902 max: None,
903 extended: false,
904 value: None,
905 changes: None,
906 }
907 }
908
909 /// Changing this writes, without waiting for a submit.
910 #[must_use]
911 pub fn changes(mut self, action: Action) -> Self {
912 self.changes = Some(action);
913 self
914 }
915
916 /// A select offering the given options.
917 pub fn select(name: impl Into<String>, label: impl Into<String>, options: Vec<Choice>) -> Self {
918 Self {
919 options,
920 ..Self::new(layout::FieldKind::Select, name, label)
921 }
922 }
923
924 /// A radio group offering the given options.
925 pub fn radio(name: impl Into<String>, label: impl Into<String>, options: Vec<Choice>) -> Self {
926 Self {
927 options,
928 ..Self::new(layout::FieldKind::Radio, name, label)
929 }
930 }
931
932 /// The form refuses to submit without it.
933 #[must_use]
934 pub fn required(mut self) -> Self {
935 self.required = true;
936 self
937 }
938
939 /// Standing help, shown whether or not anything is wrong.
940 #[must_use]
941 pub fn hint(mut self, hint: impl Into<String>) -> Self {
942 self.hint = Some(hint.into());
943 self
944 }
945
946 /// What is wrong with the value now.
947 #[must_use]
948 pub fn error(mut self, error: impl Into<String>) -> Self {
949 self.error = Some(error.into());
950 self
951 }
952
953 /// Whether the field is currently reporting a problem.
954 #[must_use]
955 pub fn invalid(&self) -> bool {
956 self.error.is_some()
957 }
958
959 /// Put this back in the box when the form is offered again.
960 ///
961 /// A [`layout::FieldKind::Secret`] keeps `None` whatever it is handed. A
962 /// password that comes back down the wire is a password in a page, in a
963 /// proxy log and in a browser cache, and the field kind exists to say so.
964 /// Silently rather than by a `Result`, because there is no answer a caller
965 /// could give that would make echoing it right.
966 #[must_use]
967 pub fn value(mut self, value: impl Into<String>) -> Self {
968 if self.kind != layout::FieldKind::Secret {
969 self.value = Some(value.into());
970 }
971 self
972 }
973
974 /// Re-offer whatever was submitted under this field's name.
975 ///
976 /// What a refused write calls, with the [`Params`](crate::Params) it was
977 /// refusing. A name with nothing under it stays empty, which is what an
978 /// unticked checkbox and an untouched box both are.
979 #[must_use]
980 pub fn refilled(self, params: &crate::Params) -> Self {
981 match params.get(&self.name) {
982 Some(value) => {
983 let value = value.to_owned();
984 self.value(value)
985 }
986 None => self,
987 }
988 }
989
990 /// Read this field as the description layer's own type.
991 ///
992 /// A callback rather than a return, because [`layout::Field`] holds its
993 /// options as a slice and ours holds them as owned values, so the borrowed
994 /// slice has to live somewhere for the duration of the read. Building it
995 /// here means one allocation at the renderer's boundary instead of the
996 /// borrow leaking into every caller's signature.
997 pub fn with_layout<R>(&self, f: impl FnOnce(layout::Field<'_>) -> R) -> R {
998 let options: Vec<layout::Choice<'_>> = self.options.iter().map(Choice::as_layout).collect();
999 f(layout::Field {
1000 kind: self.kind,
1001 name: &self.name,
1002 label: &self.label,
1003 hint: self.hint.as_deref(),
1004 error: self.error.as_deref(),
1005 placeholder: self.placeholder.as_deref(),
1006 options: &options,
1007 required: self.required,
1008 max_length: self.max_length,
1009 min: self.min.as_deref(),
1010 max: self.max.as_deref(),
1011 extended: self.extended,
1012 })
1013 }
1014 }
1015
1016 /// One column of a table, owned.
1017 ///
1018 /// The borrowed original is [`layout::Column`]. The `name` is both the heading
1019 /// and the address a cell is found by, which is what replaces addressing
1020 /// columns by position.
1021 /// No `Hash`, for the reason [`Tag`] and [`Field`] have none: it can hold an
1022 /// [`Action`], which holds [`Params`], which is a `Vec`.
1023 #[derive(Debug, Clone, PartialEq, Eq)]
1024 pub struct Column {
1025 /// The heading, and the name the cell is addressed by.
1026 pub name: String,
1027 /// How much room it asks for.
1028 pub width: layout::Width,
1029 /// What it is worth when room runs out.
1030 pub priority: layout::Priority,
1031 /// Which way the table is ordered by this column, if it is.
1032 pub sorted: Option<layout::Sort>,
1033 /// What pressing this heading calls.
1034 ///
1035 /// `ce620871`. makeover-layout carries `Column::sortable`, a bare bool,
1036 /// because it cannot name an address; here the address *is* the
1037 /// sortability, so the two collapse into one field and cannot disagree.
1038 /// [`as_layout`](Self::as_layout) sets the bool from whether this is here.
1039 ///
1040 /// Reordering a table is a control that writes with no surrounding submit,
1041 /// which is `14612ed8`'s shape, and the renderer treats it the same way.
1042 pub reorder: Option<Action>,
1043 }
1044
1045 impl Column {
1046 /// A column that absorbs slack and drops after the optional ones.
1047 pub fn new(name: impl Into<String>) -> Self {
1048 Self {
1049 name: name.into(),
1050 width: layout::Width::Fill,
1051 priority: layout::Priority::Secondary,
1052 sorted: None,
1053 reorder: None,
1054 }
1055 }
1056
1057 /// Pressing this heading reorders the table.
1058 #[must_use]
1059 pub fn reorder(mut self, action: Action) -> Self {
1060 self.reorder = Some(action);
1061 self
1062 }
1063
1064 /// The table is currently ordered by this column, this way.
1065 #[must_use]
1066 pub const fn sorted(mut self, sort: layout::Sort) -> Self {
1067 self.sorted = Some(sort);
1068 self
1069 }
1070
1071 /// Set how much room it asks for.
1072 #[must_use]
1073 pub fn width(mut self, width: layout::Width) -> Self {
1074 self.width = width;
1075 self
1076 }
1077
1078 /// Set what it is worth when room runs out.
1079 #[must_use]
1080 pub fn priority(mut self, priority: layout::Priority) -> Self {
1081 self.priority = priority;
1082 self
1083 }
1084
1085 /// Borrow as the description layer's own type.
1086 #[must_use]
1087 pub fn as_layout(&self) -> layout::Column<'_> {
1088 layout::Column {
1089 name: &self.name,
1090 width: self.width,
1091 priority: self.priority,
1092 sortable: self.reorder.is_some(),
1093 sorted: self.sorted,
1094 }
1095 }
1096 }
1097
1098 /// Which region this is, owned.
1099 ///
1100 /// The borrowed original is [`layout::Region`], and only one member borrows:
1101 /// [`layout::Region::Bespoke`] carries a name the app owns and this crate never
1102 /// interprets.
1103 #[derive(Debug, Clone, PartialEq, Eq, Hash)]
1104 pub enum RegionKind {
1105 /// A full-width strip with a title slot and an actions cluster.
1106 Band,
1107 /// A persistent column beside the content, holding navigation.
1108 Sidebar,
1109 /// A region of content with its own scroll.
1110 Pane,
1111 /// Two panes side by side, the left choosing what the right shows.
1112 Split,
1113 /// Peer regions across, all of them equals.
1114 ///
1115 /// A kanban board. Distinct from [`Split`](Self::Split), whose two panes
1116 /// stand in master-detail: these choose nothing about each other. Carries
1117 /// no count and no width -- the children say how many, and peers are equal
1118 /// by definition. See [`layout::Region::Columns`].
1119 ///
1120 /// The children are ordinary regions, reached as [`Node::Region`], so a
1121 /// renderer that lays nothing across still draws every column. A terminal
1122 /// stacking them vertically is honouring this.
1123 Columns,
1124 /// A set of panes, one visible at a time, with tabs above.
1125 TabGroup,
1126 /// Content over a scrim, taking input until dismissed.
1127 ///
1128 /// A modal this screen *contains*, which is how a confirmation is drawn: it
1129 /// arrives with the screen and goes when the screen goes. The app-level one
1130 /// is [`Outcome::Over`](crate::Outcome::Over), which draws a whole screen
1131 /// over whatever is under it and is reachable from screens that know
1132 /// nothing about it.
1133 Modal,
1134 /// A place, and nothing else. The app fills it per host.
1135 ///
1136 /// Decision 4: the renderer hands the space over and the app puts a JS
1137 /// component, an egui closure or a TUI widget in it. The rejected
1138 /// alternative was giving the placeholder its own route and fetching a
1139 /// fragment for it, which is uniform on paper and wrong in currency: a byte
1140 /// payload is not what egui or a terminal wants.
1141 Bespoke {
1142 /// What the app calls it. Never interpreted here.
1143 name: String,
1144 },
1145 /// A named assembly of things the description already says.
1146 ///
1147 /// The third tier, and the one member that is a name *and* contents. See
1148 /// [`layout::Region::Widget`] for what separates it from the two either
1149 /// side of it; the short form is that a primitive has to be drawable by
1150 /// every host from scratch and a bespoke carries nothing under it, and a
1151 /// carousel is neither.
1152 ///
1153 /// The body is the assembly and it is ordinary description: a renderer that
1154 /// does not recognise the name walks it and draws primitives, which is why
1155 /// naming one costs no renderer release. Contrast
1156 /// [`Bespoke`](Self::Bespoke), whose body a renderer can draw but whose
1157 /// *fill* only the host has.
1158 Widget {
1159 /// What the assembly is called. Never interpreted here, and a renderer
1160 /// is free not to know it.
1161 name: String,
1162 },
1163 }
1164
1165 impl RegionKind {
1166 /// Borrow as the description layer's own type.
1167 #[must_use]
1168 pub fn as_layout(&self) -> layout::Region<'_> {
1169 match self {
1170 Self::Band => layout::Region::Band,
1171 Self::Sidebar => layout::Region::Sidebar,
1172 Self::Pane => layout::Region::Pane,
1173 Self::Split => layout::Region::Split,
1174 Self::Columns => layout::Region::Columns,
1175 Self::TabGroup => layout::Region::TabGroup,
1176 Self::Modal => layout::Region::Modal,
1177 Self::Bespoke { name } => layout::Region::Bespoke { name },
1178 Self::Widget { name } => layout::Region::Widget { name },
1179 }
1180 }
1181
1182 /// Whether the description can say anything about the contents.
1183 #[must_use]
1184 pub fn described(&self) -> bool {
1185 self.as_layout().described()
1186 }
1187
1188 /// How the region sits on what is behind it.
1189 #[must_use]
1190 pub fn depth(&self) -> layout::Depth {
1191 self.as_layout().depth()
1192 }
1193 }
1194
1195 /// A named region, and the thing a fragment is aimed at.
1196 ///
1197 /// The name is what decision 7 needs and [`layout::Region`] deliberately does
1198 /// not have: two panes in a split are both `Pane`, so the kind cannot be an
1199 /// address. A webview maps the id onto `hx-target`; egui and the terminal
1200 /// ignore it and redraw, which costs them nothing because they were redrawing
1201 /// anyway.
1202 #[derive(Debug, Clone, PartialEq, Eq)]
1203 pub struct Slot {
1204 /// The address. Unique within a screen, and stable across responses, or a
1205 /// fragment lands nowhere.
1206 pub id: String,
1207 /// Which region it is.
1208 pub kind: RegionKind,
1209 /// Whether the region's own content is here or on its way.
1210 ///
1211 /// The loading axis, and only that. Emptiness is *not* said here, which
1212 /// looks like the obvious place for it and is not: a column with a heading
1213 /// and no rows is a region that has content — the heading — and a list that
1214 /// has none. Marking the region empty would hide the heading with it. See
1215 /// [`Node::StandIn`].
1216 pub readiness: layout::Readiness,
1217 /// What is in it.
1218 ///
1219 /// Blocks, regions included, which is the nesting that was always accepted:
1220 /// a region inside a region is a nested rect on every host. Leaves are
1221 /// admitted too, and deliberately -- a fact under a heading is a
1222 /// [`Node::Text`] straight in a pane, and it is the commonest thing in the
1223 /// tree.
1224 ///
1225 /// # Why there is no bound here
1226 ///
1227 /// [`Cell::part`] and [`Row::part`] assert that what they are handed is a
1228 /// leaf, and this does not, which looks like an oversight and is the model
1229 /// working. The ladder forbids reaching *up*: a run may not hold a block,
1230 /// because a run has to be drawable on one wrapped line. A block holding a
1231 /// leaf is going down, and going down is what containment is for. There is
1232 /// no upward violation for [`with`](Self::with) to catch, so an assertion
1233 /// here would be a runtime check that can never fire.
1234 ///
1235 /// A region whose whole content is one badge is the case that made this
1236 /// look like a question. It is describable, and it should be: a status pane
1237 /// is a real screen. Whether it is a *good* screen is a judgement about
1238 /// that screen rather than a property of the vocabulary, and the bound is
1239 /// not the place to hold opinions about taste.
1240 pub body: Vec<Node>,
1241 /// How many of [`body`](Self::body) are visible at once.
1242 ///
1243 /// `4dcd241b`. [`layout::Showing::All`] by default, which is what every
1244 /// region did before this field existed, so a description written against
1245 /// the previous version says the same thing.
1246 ///
1247 /// This is the kind. The two fields below are the current answer and the
1248 /// per-child name, and they are here rather than in `makeover-layout` for
1249 /// the reason [`Node::Select`]'s `chosen` is: a layer that defers every
1250 /// address does not hold what is picked either.
1251 pub showing: layout::Showing,
1252 /// Which child is up, when only one of them is.
1253 ///
1254 /// Meaningless under [`layout::Showing::All`] and ignored there. Read
1255 /// through [`current`](Self::current) rather than directly, which is where
1256 /// an index past the end of the body is dealt with.
1257 pub shown: Option<usize>,
1258 /// What this region is called, when something above it is showing one child
1259 /// at a time.
1260 ///
1261 /// The tab's name, and the whole of what separates a tab strip from a
1262 /// prev/next row: a renderer draws the strip when the children carry these
1263 /// and the row when they do not. A carousel's frames are [`Node::Image`] and
1264 /// have nowhere to put one, which is correct rather than a gap — a frame has
1265 /// a caption, not a tab name.
1266 pub label: Option<String>,
1267 }
1268
1269 impl Slot {
1270 /// An empty region under this address.
1271 pub fn new(id: impl Into<String>, kind: RegionKind) -> Self {
1272 Self {
1273 id: id.into(),
1274 kind,
1275 readiness: layout::Readiness::Ready,
1276 body: Vec::new(),
1277 showing: layout::Showing::All,
1278 shown: None,
1279 label: None,
1280 }
1281 }
1282
1283 /// A place the app fills itself.
1284 pub fn bespoke(id: impl Into<String>, name: impl Into<String>) -> Self {
1285 Self::new(id, RegionKind::Bespoke { name: name.into() })
1286 }
1287
1288 /// A named assembly, whose body says what it is made of.
1289 ///
1290 /// The body is not optional in spirit, though nothing here enforces it: a
1291 /// widget with an empty body is a [`bespoke`](Self::bespoke) that has
1292 /// mislaid its host fill, and a renderer that does not know the name will
1293 /// draw nothing at all. Assemble it out of members the description already
1294 /// has, the way [`layout::Region::Widget`] describes.
1295 pub fn widget(id: impl Into<String>, name: impl Into<String>) -> Self {
1296 Self::new(id, RegionKind::Widget { name: name.into() })
1297 }
1298
1299 /// Add a node, chaining.
1300 #[must_use]
1301 pub fn with(mut self, node: Node) -> Self {
1302 self.body.push(node);
1303 self
1304 }
1305
1306 /// Add several nodes, chaining.
1307 #[must_use]
1308 pub fn extend(mut self, nodes: impl IntoIterator<Item = Node>) -> Self {
1309 self.body.extend(nodes);
1310 self
1311 }
1312
1313 /// The content is on its way rather than here.
1314 #[must_use]
1315 pub fn pending(mut self) -> Self {
1316 self.readiness = layout::Readiness::Pending;
1317 self
1318 }
1319
1320 /// Show one child at a time, starting at this one.
1321 ///
1322 /// The carousel and the tab group, which are one thing said twice: whether
1323 /// a host draws a strip of names or a prev/next row falls out of whether
1324 /// the children carry a [`label`](Self::label), never out of the widget's
1325 /// name.
1326 #[must_use]
1327 pub fn showing_one(mut self, shown: usize) -> Self {
1328 self.showing = layout::Showing::One;
1329 self.shown = Some(shown);
1330 self
1331 }
1332
1333 /// Show one child or none, starting closed unless a child is named.
1334 ///
1335 /// Disclosure. `None` is the closed state and is a legal resting place,
1336 /// which is the whole of what separates this from
1337 /// [`showing_one`](Self::showing_one).
1338 #[must_use]
1339 pub fn showing_at_most_one(mut self, shown: Option<usize>) -> Self {
1340 self.showing = layout::Showing::AtMostOne;
1341 self.shown = shown;
1342 self
1343 }
1344
1345 /// Name this region, for when something above it shows one child at a time.
1346 #[must_use]
1347 pub fn label(mut self, label: impl Into<String>) -> Self {
1348 self.label = Some(label.into());
1349 self
1350 }
1351
1352 /// Which child to draw, once [`shown`](Self::shown) is read against the body.
1353 ///
1354 /// `None` means draw them all, which is both [`layout::Showing::All`] and a
1355 /// closed disclosure — the two cases differ in what chrome sits around them
1356 /// and not in what a renderer does with the body, so they answer the same
1357 /// here.
1358 ///
1359 /// An index past the end is clamped rather than refused. A description
1360 /// pointing at a frame that is not there is a bug in the app, and a renderer
1361 /// that answers it by drawing nothing reports it as a region that vanished,
1362 /// which is the hardest kind of bug to find from what is on the screen.
1363 /// [`layout::Share::percent`] clamps for the same reason.
1364 #[must_use]
1365 pub fn current(&self) -> Option<usize> {
1366 if self.body.is_empty() {
1367 return None;
1368 }
1369 let last = self.body.len() - 1;
1370 match self.showing {
1371 layout::Showing::One => Some(self.shown.unwrap_or(0).min(last)),
1372 layout::Showing::AtMostOne => self.shown.map(|shown| shown.min(last)),
1373 layout::Showing::All => None,
1374 // `Showing` is `#[non_exhaustive]`, so this arm is compulsory even
1375 // with every member above it named. Drawing the whole body is the
1376 // right default for a member this crate has not been taught yet:
1377 // more content rather than less, which is how every other unknown
1378 // in this vocabulary degrades.
1379 _ => None,
1380 }
1381 }
1382
1383 /// The children's names, when they have them.
1384 ///
1385 /// Empty unless *every* child is a named region, which is the test a
1386 /// renderer applies before drawing a strip: a strip with a hole in it is
1387 /// worse than the prev/next row it would otherwise have drawn, and a
1388 /// half-labelled body is an app bug rather than a third idiom.
1389 #[must_use]
1390 pub fn labels(&self) -> Vec<&str> {
1391 let named: Vec<&str> = self
1392 .body
1393 .iter()
1394 .filter_map(|node| match node {
1395 Node::Region(slot) => slot.label.as_deref(),
1396 _ => None,
1397 })
1398 .collect();
1399
1400 if named.len() == self.body.len() {
1401 named
1402 } else {
1403 Vec::new()
1404 }
1405 }
1406
1407 /// This slot, or the first slot under this address anywhere inside it.
1408 #[must_use]
1409 pub fn find(&self, id: &str) -> Option<&Self> {
1410 if self.id == id {
1411 return Some(self);
1412 }
1413 self.body.iter().find_map(|node| match node {
1414 Node::Region(slot) => slot.find(id),
1415 _ => None,
1416 })
1417 }
1418
1419 /// The mutable half of [`find`](Self::find).
1420 ///
1421 /// Same walk, and it has to be a second function rather than the same one
1422 /// generic over mutability: a `&mut` borrow of `self` cannot be handed to
1423 /// the recursive call and kept, which is what `find_map` does on the shared
1424 /// side.
1425 fn find_mut(&mut self, id: &str) -> Option<&mut Self> {
1426 if self.id == id {
1427 return Some(self);
1428 }
1429 self.body.iter_mut().find_map(|node| match node {
1430 Node::Region(slot) => slot.find_mut(id),
1431 _ => None,
1432 })
1433 }
1434 }
1435
1436 /// A control that calls a route.
1437 ///
1438 /// A button, a link and a menu item are the same thing to a description: a
1439 /// label, an address, and how loudly it is saying it. Which of the three a
1440 /// renderer draws is a renderer decision.
1441 #[derive(Debug, Clone, PartialEq, Eq)]
1442 pub struct Act {
1443 /// What it is called.
1444 pub label: String,
1445 /// What it calls.
1446 pub action: Action,
1447 /// What it is saying. [`layout::Tone::Danger`] is what marks the button
1448 /// that destroys something.
1449 pub tone: layout::Tone,
1450 /// Focused, disabled, or neither.
1451 pub state: Option<layout::State>,
1452 /// What to ask before doing it, if it should be asked.
1453 ///
1454 /// `524a63fe`. Destructiveness is a property of the action, known where the
1455 /// action is described, and until this existed every app expressed it by
1456 /// calling a JS helper at the call site: goingson has 33 such calls across
1457 /// four helpers and Balanced Breakfast 5.
1458 ///
1459 /// The prompt only. The word on the agreeing button is
1460 /// [`label`](Self::label), because it already is — goingson's `confirmDelete`
1461 /// passes `confirmText: 'Delete'` for an act labelled "Delete" — and a
1462 /// second string would be the same word twice with a chance to disagree.
1463 /// [`tone`](Self::tone) already says whether the dialog is a dangerous one.
1464 ///
1465 /// `Region::Modal` names the box a confirmation appears in and does not name
1466 /// the pattern. This is the pattern: a webview raises a dialog, a touch host
1467 /// an action sheet, a terminal a y/n line, and none of them is a route to a
1468 /// modal screen and back, which is a different interaction.
1469 pub confirm: Option<String>,
1470 /// The key that reaches it, written the way a user would say it.
1471 ///
1472 /// `2daea915`. An `Act` had a label and a destination and nothing said which
1473 /// key gets there, so goingson's 279-line `keyboard.js` holds the table
1474 /// beside the description, and the help overlay that lists the shortcuts is
1475 /// a second hand-written copy that can drift from it.
1476 ///
1477 /// A terminal makes the case sharper than a webview does: there the key *is*
1478 /// the affordance, so a description that cannot name one cannot describe the
1479 /// screen's primary interaction at all.
1480 ///
1481 /// Text rather than a modelled chord — "n", "ctrl+k", "?" — because the
1482 /// vocabulary of keys is the host's and a description that modelled it would
1483 /// be naming one host's keyboard. A renderer that does not know a name
1484 /// ignores it, which is what a webview does with a key a terminal wants.
1485 ///
1486 /// Screen-scoped, because a screen is what this describes. An app-wide
1487 /// shortcut belongs to the app and is not a fact about any one screen:
1488 /// that is [`Chrome::bindings`](crate::Chrome::bindings), held beside the
1489 /// router rather than inside any answer. A renderer matches those first, so
1490 /// a screen cannot capture the key that opens the palette.
1491 pub key: Option<String>,
1492 /// The [`Screen::selection`] this acts on, if it acts on one.
1493 ///
1494 /// `5f2b8753`. This is what makes a commit control readable: "Archive" over
1495 /// a selection is a different sentence from "Archive" on a row, and until
1496 /// this existed the difference lived in whichever JS gathered the checked
1497 /// boxes.
1498 ///
1499 /// Every ticked [`Row::value`] is sent under [`Node::TICKED`], repeated
1500 /// once per member. Repeated rather than joined, because a name appearing
1501 /// many times is what [`Params::get_all`] is for and a delimiter would have
1502 /// to be one no value can contain.
1503 ///
1504 /// # The name does not select between sets yet, and cannot
1505 ///
1506 /// A screen holds one selection ([`Screen::selection`]), so being set at
1507 /// all is what makes a control a commit control, and the name is what makes
1508 /// it *readable* — "Archive" over `chosen` is a different sentence from
1509 /// "Archive" on a row.
1510 ///
1511 /// Matching it against the screen's name was the first shape and it does
1512 /// not work, because a renderer does not always have the screen: an
1513 /// [`Outcome::Fragment`] replaces a region and carries no screen at all, so
1514 /// a webview rendering one would have had to guess and a terminal, which
1515 /// keeps the screen beside it, would not. The two hosts would then disagree
1516 /// about a typo, which is exactly the drift this vocabulary exists to stop.
1517 /// So both read it the same way, and the name starts choosing between sets
1518 /// on the day [`Screen::selection`] becomes a map.
1519 ///
1520 /// [`Params::get_all`]: crate::Params::get_all
1521 /// [`Outcome::Fragment`]: crate::Outcome::Fragment
1522 pub over: Option<String>,
1523 }
1524
1525 impl Act {
1526 /// A neutral control calling this route.
1527 pub fn new(label: impl Into<String>, action: Action) -> Self {
1528 Self {
1529 label: label.into(),
1530 action,
1531 tone: layout::Tone::Neutral,
1532 state: None,
1533 confirm: None,
1534 key: None,
1535 over: None,
1536 }
1537 }
1538
1539 /// This acts on the screen's selection, by name.
1540 ///
1541 /// The commit half of a staged tick. See [`over`](Self::over) for what
1542 /// reaches the handler, and [`Screen::selection`] for why a tick stages
1543 /// rather than writes.
1544 #[must_use]
1545 pub fn over(mut self, selection: impl Into<String>) -> Self {
1546 self.over = Some(selection.into());
1547 self
1548 }
1549
1550 /// Ask this before doing it.
1551 #[must_use]
1552 pub fn confirm(mut self, prompt: impl Into<String>) -> Self {
1553 self.confirm = Some(prompt.into());
1554 self
1555 }
1556
1557 /// The key that reaches it.
1558 #[must_use]
1559 pub fn key(mut self, key: impl Into<String>) -> Self {
1560 self.key = Some(key.into());
1561 self
1562 }
1563
1564 /// Set what it is saying.
1565 #[must_use]
1566 pub fn tone(mut self, tone: layout::Tone) -> Self {
1567 self.tone = tone;
1568 self
1569 }
1570
1571 /// Present, visible, and not answering.
1572 #[must_use]
1573 pub fn disabled(mut self) -> Self {
1574 self.state = Some(layout::State::Disabled);
1575 self
1576 }
1577
1578 /// Whether the control currently answers input.
1579 #[must_use]
1580 pub fn interactive(&self) -> bool {
1581 !self
1582 .state
1583 .is_some_and(layout::State::suppresses_interaction)
1584 }
1585
1586 /// Borrow as the description layer's own type.
1587 ///
1588 /// [`action`](Self::action) and [`confirm`](Self::confirm) do not survive
1589 /// the crossing, and that is what the two layers disagree about rather than
1590 /// an oversight. An address is quasi's — every host follows one differently
1591 /// — and a confirmation is a question asked after the press, so it belongs
1592 /// to whoever is holding the interaction. What is left is what a renderer
1593 /// needs to *draw* the control, which is all `layout::Act` claims to be.
1594 #[must_use]
1595 pub fn as_layout(&self) -> layout::Act<'_> {
1596 layout::Act {
1597 label: &self.label,
1598 key: self.key.as_deref(),
1599 tone: self.tone,
1600 state: self.state,
1601 }
1602 }
1603 }
1604
1605 /// One part of a row's run, and the role it takes.
1606 ///
1607 /// A cell's run entries carry no role because their kind already says which
1608 /// part they are: text is the value, a [`Node::Link`] is the link, a
1609 /// [`Node::Token`] is a chip, a [`Node::Act`] is a control. A row's
1610 /// `primary`, `secondary` and `meta` are three *text* roles, and kind cannot
1611 /// tell those apart, so a row says which one it means.
1612 ///
1613 /// The role is a style role and nothing else. [`layout::RowPart`] is unchanged
1614 /// by the containment model: it says how a part is drawn, not what may sit in
1615 /// it, and that is the half of it worth keeping.
1616 #[derive(Debug, Clone, PartialEq, Eq)]
1617 pub struct Part {
1618 /// Which of the row's roles this part takes.
1619 pub role: layout::RowPart,
1620 /// What is in it. A leaf, since a row is an inline run.
1621 pub node: Node,
1622 }
1623
1624 /// One row of a list.
1625 ///
1626 /// # The run
1627 ///
1628 /// A row's content is an inline run of [`Part`]s, in the order the description
1629 /// says them, the same way a [`Cell`]'s is. It was six members before
1630 /// `1786cb94` -- `primary`, `secondary`, `meta`, `tokens`, `actions`, `meter`
1631 /// -- each of which arrived as a counted-sites argument, a member here, a
1632 /// [`layout::RowPart`] variant and a release: `RowPart::Tokens` at
1633 /// makeover-layout 0.9.0 for a badge in a row, `RowPart::Proportion` at 0.11.0
1634 /// for a bar in one. A link in a row was simply not sayable, and a figure in
1635 /// one was not either. Under the run both are already sayable and cost nothing.
1636 ///
1637 /// The bound is that every part is a leaf, so a row is drawable on one wrapped
1638 /// line without a renderer knowing what is in it. [`Row::part`] is where that
1639 /// bites at a call site.
1640 ///
1641 /// Order is the description's. The old members were drawn in a fixed sequence
1642 /// whatever order they were built in, so a row that wanted a tag between two
1643 /// facts got the tag hoisted to the end; now it draws where it was put.
1644 ///
1645 /// # What stayed a field
1646 ///
1647 /// [`activate`](Self::activate), [`current`](Self::current),
1648 /// [`selected`](Self::selected), [`menu`](Self::menu) and
1649 /// [`toggle`](Self::toggle) are facts *about* the row rather than content in
1650 /// it. A run of things on a line is not where "this row is the one the detail
1651 /// pane is showing" belongs.
1652 ///
1653 /// # The cost
1654 ///
1655 /// [`primary()`](Self::primary) is no longer guaranteed to be one string, which
1656 /// is what let a constrained renderer right-align a row cheaply. It answers the
1657 /// text of the primary parts joined, and a row built the ordinary way still has
1658 /// exactly one.
1659 /// A row and when it happens.
1660 ///
1661 /// The pairing [`Node::Timeline`] is made of. Deliberately a pair rather than
1662 /// members on [`Row`]: a row does not become a different kind of thing by
1663 /// being placed, and every list, table and detail pane in the tree would
1664 /// otherwise carry two integers it has no use for.
1665 #[derive(Debug, Clone, PartialEq, Eq)]
1666 pub struct Placed {
1667 /// Where it sits on the axis, and for how long.
1668 pub placement: layout::Placement,
1669 /// The thing itself, said the ordinary way.
1670 pub row: Row,
1671 }
1672
1673 impl Placed {
1674 /// A row at a start and a duration, both in minutes.
1675 #[must_use]
1676 pub const fn new(at: u16, minutes: u16, row: Row) -> Self {
1677 Self {
1678 placement: layout::Placement::new(at, minutes),
1679 row,
1680 }
1681 }
1682
1683 /// Whether this and another cover any of the same time.
1684 ///
1685 /// Forwarded so a renderer laying out collisions does not reach through to
1686 /// the placement and, in doing so, decide for itself what overlapping
1687 /// means.
1688 #[must_use]
1689 pub const fn overlaps(&self, other: &Self) -> bool {
1690 self.placement.overlaps(other.placement)
1691 }
1692 }
1693
1694 #[derive(Debug, Clone, PartialEq, Eq, Default)]
1695 pub struct Row {
1696 /// What is in the row, in order.
1697 ///
1698 /// Built by the same constructors that named the old members:
1699 /// [`Row::new`], [`secondary`](Row::secondary), [`meta`](Row::meta),
1700 /// [`token`](Row::token), [`act`](Row::act) and [`meter`](Row::meter) all
1701 /// still mean what they meant, so no builder call site moved.
1702 pub parts: Vec<Part>,
1703 /// The route that selects this row, if selecting it does anything.
1704 pub activate: Option<Action>,
1705 /// Whether this is the row the detail side is currently showing.
1706 ///
1707 /// Named `selected` until 2026-08-08, which was one word doing two jobs.
1708 /// This one is the app's own pointer into a set: what a list-detail
1709 /// arrangement highlights because its pane is showing it, and what a
1710 /// webview says with `aria-current`. The user's tick is
1711 /// [`selected`](Self::selected), and conflating them meant a screen with
1712 /// bulk actions could not describe its checkboxes at all.
1713 pub current: bool,
1714 /// Whether the user has ticked this row, and whether they can.
1715 ///
1716 /// Three states in one field, which is why it is not a `bool`. `None` means
1717 /// the row is not selectable and no affordance should be drawn; `Some(false)`
1718 /// means it can be ticked and is not; `Some(true)` means it is. A plain bool
1719 /// cannot tell "not ticked" from "not tickable", so every renderer would
1720 /// have had to be told selectability some other way, and each would have
1721 /// picked a different way.
1722 ///
1723 /// This is the user's selection, as distinct from
1724 /// [`current`](Self::current). goingson's contacts and tasks screens both
1725 /// drive bulk actions from it.
1726 pub selected: Option<bool>,
1727 /// Everything else that can be done to this row.
1728 ///
1729 /// `5e02fbce`. The [`Actions`](layout::RowPart::Actions) parts of the run
1730 /// are what the row shows; this is
1731 /// what it *offers*, reached by right-click on a pointer host, long-press on
1732 /// a touch one, and a key in a terminal. That split is the whole reason it
1733 /// belongs in the description rather than in a renderer: one description has
1734 /// to become a context menu, an action sheet and a key-driven menu, and no
1735 /// single renderer can be the place where it is said.
1736 ///
1737 /// goingson opens one at 14 sites and Balanced Breakfast at 9, on top of
1738 /// 680 lines of generic menu machinery between `components.js` and
1739 /// `context-menus.js`.
1740 ///
1741 /// A field rather than a role in the run, because a menu is not on the
1742 /// line. The run is what the row draws; this is what it holds back until
1743 /// the host asks, and no renderer draws it in sequence with the primary.
1744 pub menu: Vec<Act>,
1745 /// What ticking this row calls, if ticking it is the write.
1746 ///
1747 /// `14612ed8`, part of it. [`selected`](Self::selected) says whether the row
1748 /// is ticked and whether it can be, and that was the whole story for a bulk
1749 /// checkbox, whose tick is client state feeding a later action. A checklist
1750 /// is the other case: the tick *is* the write, and it is the only affordance
1751 /// the screen offers for it. Described without this, the port drew the tick
1752 /// inert and put the toggle on a button beside it, which is a user clicking
1753 /// a button next to a checkbox that ignores clicks.
1754 ///
1755 /// Two fields rather than a `Selection` struct, matching how
1756 /// [`activate`](Self::activate) sits beside [`current`](Self::current):
1757 /// state and behaviour are separate facts about the row. They do have to
1758 /// agree — a `toggle` with no [`selected`](Self::selected) is a route on a
1759 /// control nothing draws — and [`Row::toggling`] is the constructor that
1760 /// makes them agree.
1761 pub toggle: Option<Action>,
1762 /// What this row's tick contributes to the screen's selection.
1763 ///
1764 /// `5f2b8753`. [`selected`](Self::selected) says the row can be ticked;
1765 /// this says what ticking it *means*, which is the half that was missing.
1766 /// A set of ticks with nothing in them is not a selection, so a renderer
1767 /// holding [`Screen::selection`] holds these.
1768 ///
1769 /// `value` rather than `id`, matching [`Choice::value`]: throughout this
1770 /// vocabulary it is the word for what a control contributes when it is
1771 /// chosen, and a row's tick is the same kind of fact.
1772 ///
1773 /// A selectable row without one is the dead affordance this member exists
1774 /// to end, and [`Row::ticking`] is the constructor that cannot produce it.
1775 /// It is not enforced here, for [`toggle`](Self::toggle)'s reason: a
1776 /// description layer that refused to hold a half-built row would refuse it
1777 /// at the moment the app is still building it.
1778 ///
1779 /// [`Choice::value`]: Choice::value
1780 /// [`Screen::selection`]: Screen::selection
1781 pub value: Option<String>,
1782 }
1783
1784 impl Row {
1785 /// A row with only its primary text.
1786 ///
1787 /// An empty string is an empty run rather than a run holding an empty
1788 /// string, so `Row::new("")` and [`Row::default`] are the same value. Same
1789 /// rule as [`Cell::new`], and for the same reason.
1790 pub fn new(primary: impl Into<String>) -> Self {
1791 let primary = primary.into();
1792 Self {
1793 parts: if primary.is_empty() {
1794 Vec::new()
1795 } else {
1796 vec![Part {
1797 role: layout::RowPart::Primary,
1798 node: Node::text(primary),
1799 }]
1800 },
1801 ..Self::default()
1802 }
1803 }
1804
1805 /// Something else that can be done to this row, not shown inline.
1806 #[must_use]
1807 pub fn offers(mut self, act: Act) -> Self {
1808 self.menu.push(act);
1809 self
1810 }
1811
1812 /// How much of this row's set is done.
1813 #[must_use]
1814 pub fn meter(mut self, meter: Meter) -> Self {
1815 self.set(layout::RowPart::Proportion, Node::Meter(meter));
1816 self
1817 }
1818
1819 /// A tick that is the write, in the state it is currently in.
1820 ///
1821 /// Sets [`selected`](Self::selected) and [`toggle`](Self::toggle) together,
1822 /// because a route on a tick nothing draws is the one way the two fields can
1823 /// disagree. A checklist item is what this is for; a bulk checkbox sets
1824 /// `selected` alone and keeps its meaning as client state.
1825 #[must_use]
1826 pub fn toggling(mut self, ticked: bool, action: Action) -> Self {
1827 self.selected = Some(ticked);
1828 self.toggle = Some(action);
1829 self
1830 }
1831
1832 /// Supporting text under the primary.
1833 #[must_use]
1834 pub fn secondary(mut self, text: impl Into<Prose>) -> Self {
1835 let node = match text.into() {
1836 Prose::Text(text) => Node::text(text),
1837 Prose::Rich(source) => Node::rich(source),
1838 };
1839 self.set(layout::RowPart::Secondary, node);
1840 self
1841 }
1842
1843 /// A short trailing fact.
1844 #[must_use]
1845 pub fn meta(mut self, text: impl Into<String>) -> Self {
1846 self.set(layout::RowPart::Meta, Node::text(text));
1847 self
1848 }
1849
1850 /// Add a token, chaining.
1851 #[must_use]
1852 pub fn token(mut self, tag: Tag) -> Self {
1853 self.parts.push(Part {
1854 role: layout::RowPart::Tokens,
1855 node: Node::Token(tag),
1856 });
1857 self
1858 }
1859
1860 /// Make the row tickable, and say whether it is ticked.
1861 ///
1862 /// A row is not selectable until something says so, which is what keeps a
1863 /// checkbox off every list in the app.
1864 ///
1865 /// Says nothing about what the tick contributes, so on a screen with a
1866 /// [`selection`](Screen::selection) it draws a box that joins no set. Reach
1867 /// for [`ticking`](Self::ticking) instead; this stays for the screens whose
1868 /// tick is the write, beside [`toggling`](Self::toggling).
1869 #[must_use]
1870 pub const fn selectable(mut self, ticked: bool) -> Self {
1871 self.selected = Some(ticked);
1872 self
1873 }
1874
1875 /// Make the row tickable under this value, and say whether it is ticked.
1876 ///
1877 /// Sets [`selected`](Self::selected) and [`value`](Self::value) together,
1878 /// which is the pair a screen's [`selection`](Screen::selection) needs.
1879 /// The two halves exist separately for [`toggling`](Self::toggling)'s
1880 /// reason — state and identity are different facts about the row — and
1881 /// this is the constructor that stops them being written apart.
1882 #[must_use]
1883 pub fn ticking(mut self, value: impl Into<String>, ticked: bool) -> Self {
1884 self.selected = Some(ticked);
1885 self.value = Some(value.into());
1886 self
1887 }
1888
1889 /// The route selecting this row.
1890 #[must_use]
1891 pub fn activate(mut self, action: Action) -> Self {
1892 self.activate = Some(action);
1893 self
1894 }
1895
1896 /// A control acting on this row.
1897 #[must_use]
1898 pub fn act(mut self, act: Act) -> Self {
1899 self.parts.push(Part {
1900 role: layout::RowPart::Actions,
1901 node: Node::Act(act),
1902 });
1903 self
1904 }
1905
1906 /// Anything in this row, under the role it takes.
1907 ///
1908 /// The general form the constructors above are shorthands for, and the
1909 /// point of the model: a link in a row and a figure in a row became
1910 /// sayable at once, where each was previously a
1911 /// [`layout::RowPart`] variant, a member here, a renderer arm and a
1912 /// release.
1913 ///
1914 /// Appends rather than replacing, so a row can hold two of a role. The
1915 /// named constructors keep the single-valued roles single-valued, which is
1916 /// what their call sites already meant.
1917 ///
1918 /// # Panics
1919 ///
1920 /// If the node is not a leaf. A row is an inline run, so what goes in it
1921 /// has to be drawable on one wrapped line without the renderer knowing what
1922 /// it is -- the constrained-consumer bound, biting at a call site rather
1923 /// than in a doc comment. Same assertion as [`Cell::part`].
1924 #[must_use]
1925 pub fn part(mut self, role: layout::RowPart, node: Node) -> Self {
1926 assert!(
1927 node.containment() == Containment::Text,
1928 "a row is an inline run and holds leaves; {node:?} holds {:?}",
1929 node.containment()
1930 );
1931 self.parts.push(Part { role, node });
1932 self
1933 }
1934
1935 /// Set the one part taking a role, replacing it if it is already there.
1936 ///
1937 /// For the roles that are single-valued at every call site that has ever
1938 /// existed: the primary, the supporting line, the trailing fact, the bar.
1939 /// Building a row that calls `.meta` twice meant the second one won when
1940 /// `meta` was an `Option`, and it still does.
1941 fn set(&mut self, role: layout::RowPart, node: Node) {
1942 match self.parts.iter_mut().find(|part| part.role == role) {
1943 Some(part) => part.node = node,
1944 None => self.parts.push(Part { role, node }),
1945 }
1946 }
1947
1948 /// The parts taking one role, in order.
1949 pub fn role(&self, role: layout::RowPart) -> impl Iterator<Item = &Node> {
1950 self.parts
1951 .iter()
1952 .filter(move |part| part.role == role)
1953 .map(|part| &part.node)
1954 }
1955
1956 /// The row's primary text.
1957 ///
1958 /// What every consumer of the old `primary` member wanted. A row built the
1959 /// ordinary way has one primary part and answers its string; one that was
1960 /// given two answers both, joined, in order.
1961 #[must_use]
1962 pub fn primary(&self) -> String {
1963 self.role(layout::RowPart::Primary)
1964 .filter_map(|node| match node {
1965 Node::Text { text, .. } | Node::Link { text, .. } => Some(text.as_str()),
1966 _ => None,
1967 })
1968 .collect::<Vec<_>>()
1969 .join(" ")
1970 }
1971
1972 /// The controls the row shows.
1973 ///
1974 /// The same service [`primary`](Self::primary) does, for the member the run
1975 /// replaced. `actions` was a `Vec<Act>` before the run, and every consumer
1976 /// that read it now writes the same three lines: filter the run by role,
1977 /// match the one node kind that can be there, and collect. goingson wrote
1978 /// them twice in one file the day the member went away.
1979 ///
1980 /// Not what the row *offers*: that is [`menu`](Self::menu), which is held
1981 /// back until the host asks for it and is not on the line.
1982 pub fn acts(&self) -> impl Iterator<Item = &Act> {
1983 self.role(layout::RowPart::Actions)
1984 .filter_map(|node| match node {
1985 Node::Act(act) => Some(act),
1986 _ => None,
1987 })
1988 }
1989
1990 /// The tags the row shows.
1991 ///
1992 /// [`acts`](Self::acts)' counterpart, for the same reason.
1993 pub fn tokens(&self) -> impl Iterator<Item = &Tag> {
1994 self.role(layout::RowPart::Tokens)
1995 .filter_map(|node| match node {
1996 Node::Token(tag) => Some(tag),
1997 _ => None,
1998 })
1999 }
2000 }
2001
2002 /// One cell of a table row.
2003 ///
2004 /// `022f0c59`, decided 2026-08-10. A cell was a `String` until then, so a table
2005 /// whose rows carry a control could not be described at all and had to become a
2006 /// [`Node::List`], losing its column headers — which is what the MNW server's
2007 /// SSH-keys tab did, and why it read worse than the Askama original it replaced.
2008 ///
2009 /// # Why the acts sit on the cell and not on the row
2010 ///
2011 /// Counted across MNW's templates, 30 table rows carry a control. 25 put it
2012 /// alone in the last cell, which a row-level `actions` list would have covered.
2013 /// The other five put it *beside a value*: `project_content`'s position cell is
2014 /// the number plus two reorder arrows, `project_synckit`'s slug cell is the slug
2015 /// plus "Set slug", `promo_codes_list`'s use count is a number that is itself
2016 /// the button opening the redemptions. A row-level list renders as an appended
2017 /// cell and cannot say any of those, and neither can an actions *column*, since
2018 /// a column is a column. The control belongs where it actually is.
2019 ///
2020 /// An empty [`value`](Self::value) with acts is the common case, and
2021 /// [`Cell::acts`] is the constructor for it. That is the trailing actions cell
2022 /// the markup already writes an empty `<th>` for.
2023 ///
2024 /// A `Vec<Act>` and not a node: the 2026-08-08 ruling that a row holds no nodes
2025 /// holds here for the same reason. Acts carry their own tone, state and
2026 /// confirmation, and that is the whole of what these cells hold.
2027 #[derive(Debug, Clone, PartialEq, Eq, Default)]
2028 pub struct Cell {
2029 /// What is in it, in order.
2030 ///
2031 /// An inline run: every part is a leaf, so the whole cell is drawable on
2032 /// one wrapped line without a renderer knowing what is in it. That is the
2033 /// bound, and [`Cell::part`] is where it is enforced.
2034 ///
2035 /// This was four members -- `value`, `tokens`, `actions`, `activate` --
2036 /// added one release at a time as each pairing was argued for on counted
2037 /// sites. `022f0c59` added two of them at once. That trajectory is what
2038 /// decided the containment model: a meter in a cell and a figure in a cell
2039 /// were simply not sayable, and each would have been a fifth and sixth
2040 /// member. Under the run they are already sayable and cost nothing.
2041 ///
2042 /// The constructors that named the old members are still here and still
2043 /// mean what they meant, so no call site moved: [`Cell::new`],
2044 /// [`tag`](Cell::tag), [`token`](Cell::token), [`acts`](Cell::acts),
2045 /// [`act`](Cell::act) and [`activate`](Cell::activate) build the run.
2046 pub parts: Vec<Node>,
2047 }
2048
2049 impl Cell {
2050 /// A cell holding text.
2051 ///
2052 /// An empty string is an empty run rather than a run holding an empty
2053 /// string, so an actions-only cell built through [`acts`](Self::acts) and
2054 /// one built as `Cell::new("").act(..)` are the same value.
2055 pub fn new(value: impl Into<String>) -> Self {
2056 let value = value.into();
2057 Self {
2058 parts: if value.is_empty() {
2059 Vec::new()
2060 } else {
2061 vec![Node::text(value)]
2062 },
2063 }
2064 }
2065
2066 /// A cell holding one tag and no text.
2067 ///
2068 /// What a status column is: the cell is the badge. `Cell::new("")` with a
2069 /// token would say the same thing and reads as an oversight.
2070 pub fn tag(tag: Tag) -> Self {
2071 Self {
2072 parts: vec![Node::Token(tag)],
2073 }
2074 }
2075
2076 /// A tag in this cell, chaining.
2077 #[must_use]
2078 pub fn token(mut self, tag: Tag) -> Self {
2079 self.parts.push(Node::Token(tag));
2080 self
2081 }
2082
2083 /// A cell holding controls and no text.
2084 pub fn acts(actions: impl IntoIterator<Item = Act>) -> Self {
2085 Self {
2086 parts: actions.into_iter().map(Node::Act).collect(),
2087 }
2088 }
2089
2090 /// A control in this cell, chaining.
2091 #[must_use]
2092 pub fn act(mut self, act: Act) -> Self {
2093 self.parts.push(Node::Act(act));
2094 self
2095 }
2096
2097 /// Where this cell's value goes.
2098 ///
2099 /// The value becomes the link. A cell with no value and an `activate` is a
2100 /// link with nothing to press, so give it text.
2101 ///
2102 /// Under the run this rewrites the leading text into a [`Node::Link`]
2103 /// rather than setting a member beside it, which is the same fact said once
2104 /// instead of as a pair of fields that could disagree. A cell with no text
2105 /// to link gains nothing, because a link with no label is a control nothing
2106 /// draws.
2107 #[must_use]
2108 pub fn activate(mut self, action: Action) -> Self {
2109 if let Some(first) = self
2110 .parts
2111 .iter_mut()
2112 .find(|part| matches!(part, Node::Text { .. }))
2113 && let Node::Text { text, .. } = first
2114 {
2115 *first = Node::Link {
2116 text: std::mem::take(text),
2117 action,
2118 };
2119 }
2120 self
2121 }
2122
2123 /// Anything in this cell, chaining.
2124 ///
2125 /// The general form the five constructors above are shorthands for, and the
2126 /// whole point of the model: a meter in a cell, a figure in a cell and a
2127 /// second linked value in a cell all became sayable at once, where each was
2128 /// previously a member, three renderer arms and a release.
2129 ///
2130 /// # Panics
2131 ///
2132 /// If the node is not a leaf. A cell is an inline run, so what goes in it
2133 /// has to be drawable on one wrapped line without the renderer knowing what
2134 /// it is -- that is the constrained-consumer bound, and this is where it
2135 /// bites at a call site rather than in a doc comment.
2136 #[must_use]
2137 pub fn part(mut self, node: Node) -> Self {
2138 assert!(
2139 node.containment() == Containment::Text,
2140 "a cell is an inline run and holds leaves; {node:?} holds \
2141 {:?}",
2142 node.containment()
2143 );
2144 self.parts.push(node);
2145 self
2146 }
2147
2148 /// The cell's text, with the parts that are not text left out.
2149 ///
2150 /// What every consumer of the old `value` member wanted. A cell that is one
2151 /// string answers that string; one that mixes answers the text between its
2152 /// tags and controls, in order.
2153 #[must_use]
2154 pub fn text(&self) -> String {
2155 self.parts
2156 .iter()
2157 .filter_map(|part| match part {
2158 Node::Text { text, .. } | Node::Link { text, .. } => Some(text.as_str()),
2159 _ => None,
2160 })
2161 .collect::<Vec<_>>()
2162 .join(" ")
2163 }
2164
2165 /// Whether anything in this cell answers a click.
2166 ///
2167 /// A badge is not one: it says something and answers nothing, which is why
2168 /// this asks the tag rather than counting tags.
2169 #[must_use]
2170 pub fn carries_control(&self) -> bool {
2171 self.parts.iter().any(|part| match part {
2172 Node::Act(_) | Node::Link { .. } => true,
2173 Node::Token(tag) => tag.kind.interactive() && tag.action.is_some(),
2174 _ => false,
2175 })
2176 }
2177 }
2178
2179 impl From<String> for Cell {
2180 fn from(value: String) -> Self {
2181 Self::new(value)
2182 }
2183 }
2184
2185 impl From<&str> for Cell {
2186 fn from(value: &str) -> Self {
2187 Self::new(value)
2188 }
2189 }
2190
2191 /// One row of a table.
2192 ///
2193 /// Cells are positional against the table's columns, and the table is the only
2194 /// place that pairing is made. A renderer narrowing the table drops columns by
2195 /// [`layout::Priority`] and drops the cells at the same indices, which is why
2196 /// the two live in one node rather than one per row.
2197 #[derive(Debug, Clone, PartialEq, Eq, Default)]
2198 pub struct Cells {
2199 /// One entry per column, in the table's column order.
2200 pub values: Vec<Cell>,
2201 /// The route that opens this row.
2202 pub activate: Option<Action>,
2203 /// Whether this is the row currently being shown elsewhere.
2204 ///
2205 /// The same fact [`Row::current`] carries, under the same name. It was
2206 /// `selected` until 2026-08-08, which is the word that decision retired:
2207 /// the app's pointer and the user's tick are two things, and one word for
2208 /// both is how every renderer ends up guessing which was meant. `Row` was
2209 /// renamed and this was missed, so it kept the ambiguous word while
2210 /// emitting `aria-current` from it.
2211 ///
2212 /// There is deliberately no tick here to go with it. `Row` grew one because
2213 /// goingson's contact cards have a bulk checkbox; no table asks for one, and
2214 /// a member added because its sibling has it is a member with no consumer to
2215 /// tell us what it should mean.
2216 pub current: bool,
2217 }
2218
2219 impl Cells {
2220 /// A row of cells in column order.
2221 ///
2222 /// Takes anything that becomes a [`Cell`], so a row of plain text is still
2223 /// `Cells::new(["kick.wav", "2.1 MB"])` and a row with a control mixes the
2224 /// two: `Cells::new([Cell::new(name), Cell::acts([remove])])`.
2225 pub fn new(values: impl IntoIterator<Item = impl Into<Cell>>) -> Self {
2226 Self {
2227 values: values.into_iter().map(Into::into).collect(),
2228 activate: None,
2229 current: false,
2230 }
2231 }
2232
2233 /// The route that opens this row.
2234 #[must_use]
2235 pub fn activate(mut self, action: Action) -> Self {
2236 self.activate = Some(action);
2237 self
2238 }
2239 }
2240
2241 /// A thing on a screen.
2242 ///
2243 /// Every member composes something `makeover-layout` already names, and that is
2244 /// the admission test for a new one. A node with no counterpart there means the
2245 /// vocabulary is missing a word, and the fix is to add the word rather than to
2246 /// add a widget here.
2247 #[derive(Debug, Clone, PartialEq, Eq)]
2248 pub enum Node {
2249 /// A title, at one of three depths in the heading tree.
2250 Heading {
2251 /// How far down the tree it sits.
2252 level: layout::Heading,
2253 /// The text.
2254 text: String,
2255 },
2256 /// Prose, with a tone.
2257 Text {
2258 /// The text.
2259 text: String,
2260 /// What it is saying. [`layout::Tone::Neutral`] is ordinary content.
2261 tone: layout::Tone,
2262 },
2263 /// Prose the author wrote in markdown.
2264 ///
2265 /// `25822137`, decided 2026-08-09. What is carried is the **source**, never
2266 /// markup, which is the property that lets this exist at all. Every renderer
2267 /// has an honest answer because each renders the source its own way: a
2268 /// webview through a markdown-to-HTML pass, a terminal through
2269 /// markdown-to-ANSI, egui through its own. A `Node::Html` would have handed
2270 /// every one of them a string it could not honour, and would have broken
2271 /// [`Node::Text`]'s escaping guarantee for every consumer rather than the
2272 /// one that asked. That refusal stands; this is not it.
2273 ///
2274 /// Sanitising is the renderer's, at the point markup is produced, for the
2275 /// reason escaping already is: this holds text a user typed, and a
2276 /// description that sanitised would be deciding what a host can draw.
2277 ///
2278 /// Not available inside a [`Row`]: a row part holds no node, by the
2279 /// 2026-08-08 ruling. What a row can hold is [`Prose`], which carries the
2280 /// same markdown source under the same reasoning without being a node, so
2281 /// the projects card no longer keeps its raw markdown in `secondary`.
2282 Rich {
2283 /// The markdown, as written.
2284 source: String,
2285 },
2286 /// A control that calls a route.
2287 Act(Act),
2288 /// Text that goes somewhere.
2289 ///
2290 /// The containment model predicted this before anything asked for it: the
2291 /// composability matrix had a `Link` row whose "as a `Node`" cell was a
2292 /// dash, and the only way to say it was [`Cell::activate`], a member on one
2293 /// container. Once a cell is a run of leaves, the run needs a leaf that
2294 /// means "this text is a link" or the thing stops being sayable at all.
2295 ///
2296 /// Distinct from [`Act`](Self::Act), and the difference is what the reader
2297 /// sees rather than what the route does. An act is a control drawn as one,
2298 /// which is right for `Edit` and wrong for a title: making every linked
2299 /// value a button would put a row of bevels down the first column of half a
2300 /// dashboard. Both call a route; only one of them looks like a button.
2301 Link {
2302 /// What it says.
2303 text: String,
2304 /// Where it goes.
2305 action: Action,
2306 },
2307 /// One figure, on a line.
2308 ///
2309 /// The second thing the containment model found, and it found it by
2310 /// refusing: a cell is a run of leaves, [`Stats`](Self::Stats) is a
2311 /// collection, so putting a figure in a cell failed the bound rather than
2312 /// quietly working. That is the check doing its job -- the matrix had
2313 /// "figure in a cell" as a dash nobody had attempted, and the dash turns
2314 /// out to have been hiding a missing member rather than a missing renderer
2315 /// arm.
2316 ///
2317 /// Not a duplicate of a one-element [`Stats`](Self::Stats), and the
2318 /// difference is the claim being made. A strip says "this is a row of
2319 /// tiles", which is why the set is the node there: a renderer handed one
2320 /// tile at a time cannot tell it is looking at a set. This says "this
2321 /// number sits on this line", where the run is already the grouping and
2322 /// there is nothing for a set to add. A dashboard strip of one is still a
2323 /// strip; a revenue column is not.
2324 Figure(Figure),
2325 /// A picture, at a source this crate holds and the description does not.
2326 ///
2327 /// The [`Act`](Self::Act) split, and `layout::Image`'s own docs carry the
2328 /// argument: an address is not the description's to hold, so the shape and
2329 /// the alt text live there and the URL lives here.
2330 ///
2331 /// A leaf, so it may sit in a run the way [`Link`](Self::Link) does. What
2332 /// it may *not* do is stand in for a region: a picture is one thing on the
2333 /// page, and a gallery of them is a widget assembled out of several.
2334 Image(Picture),
2335 /// A small labelled thing sitting inside something else.
2336 Token(Tag),
2337 /// Something the app is telling the user, unprompted.
2338 Notice {
2339 /// Transient and stacked, or persistent and in flow.
2340 kind: layout::Notice,
2341 /// What it is saying.
2342 tone: layout::Tone,
2343 /// The message.
2344 text: String,
2345 },
2346 /// What stands where content would be, when there is none.
2347 ///
2348 /// `703f4cd2`. goingson draws one at 27 sites across 12 files and Balanced
2349 /// Breakfast at 9, and the class families had already drifted into
2350 /// `empty-state--error` against `error-state` for the same fact. Every one
2351 /// of those sites substitutes markup where a list would go, which is what
2352 /// makes this a node.
2353 ///
2354 /// # Why not on the region
2355 ///
2356 /// It was on [`Slot`] first, and a real screen killed it: the project
2357 /// dashboard's columns are a heading and a list, and a column with no rows
2358 /// is a region that has content and a list that has none. Marking the
2359 /// region empty took the heading down with the rows. The emptiness belongs
2360 /// to the thing that is empty.
2361 ///
2362 /// [`Slot::readiness`] keeps the loading axis and only that, which is what
2363 /// `aria-busy` is about.
2364 ///
2365 /// # The state is the vocabulary's and the sentence is not
2366 ///
2367 /// `makeover-layout` names the four states because "nothing here yet" and
2368 /// "this broke" mean the same thing in every app that will have them. "No
2369 /// projects yet" is content, and so is the button under it, so both are
2370 /// here. A [`layout::Readiness::Ready`] renders nothing at all: the state
2371 /// that shows content has no stand-in to draw.
2372 StandIn {
2373 /// Which of the states this is standing in for.
2374 state: layout::Readiness,
2375 /// The sentence. "No projects yet", "Failed to load events".
2376 message: String,
2377 /// The way out, if there is one. "Add your first project", "Try again".
2378 ///
2379 /// 2 of goingson's 27 have one and 25 say a sentence and stop, which is
2380 /// why it is optional rather than a second required string.
2381 act: Option<Act>,
2382 },
2383 /// One control, standing on its own.
2384 ///
2385 /// `14612ed8`. A [`Form`](Self::Form) is a set of questions asked together
2386 /// and answered at once. A settings screen is not that: goingson's is
2387 /// sections with headings between them, each holding one control that writes
2388 /// as soon as it changes, and wrapping those in a form would describe markup
2389 /// that is not there and a submit that does not exist.
2390 ///
2391 /// Almost always carries a [`Field::changes`], because a control with no
2392 /// form around it and no route on it collects a value nothing reads.
2393 ///
2394 /// Boxed because it is the only member holding a whole struct by value, and
2395 /// [`Field`] is the largest one here — every other member holds a `Vec`, a
2396 /// `String` or a small enum. Unboxed it decides the size of every [`Node`]
2397 /// in every list, and of the [`Response`](crate::Response) that carries one.
2398 Field(Box<Field>),
2399 /// Fields, and the route that submits them.
2400 Form {
2401 /// Where the answers go. Almost always a [`Method::Post`].
2402 action: Action,
2403 /// What the submit control is called.
2404 submit: String,
2405 /// The questions, in order.
2406 fields: Vec<Field>,
2407 },
2408 /// Rows of the same kind of thing.
2409 List {
2410 /// The rows, in order.
2411 rows: Vec<Row>,
2412 /// What is not shown, if anything is.
2413 ///
2414 /// `346567f9`. A described list of the first 50 of 400 tasks was
2415 /// indistinguishable from a described list of 50 tasks, so each app
2416 /// grew its own answer: goingson a 159-line pagination manager with two
2417 /// consumers that had each written it separately first, Balanced
2418 /// Breakfast four `loadMore` sites. Two idioms for one fact, and the
2419 /// fact is what belongs here — how much more there is and how to ask
2420 /// for it. Whether that becomes numbered pages, a load-more button or
2421 /// an infinite scroll is the renderer's.
2422 more: Option<Rest>,
2423 },
2424 /// Rows with named columns.
2425 Table {
2426 /// The columns, in order. Cells are positional against these.
2427 columns: Vec<Column>,
2428 /// The rows, in order.
2429 rows: Vec<Cells>,
2430 },
2431 /// Rows placed by when they happen, rather than in order.
2432 ///
2433 /// The third of the three ways this vocabulary says "several of the same
2434 /// kind of thing", and the last one to arrive.
2435 /// [`List`](Self::List) puts them in order, [`Table`](Self::Table) lines
2436 /// their parts up in columns, and this one puts them on a clock.
2437 ///
2438 /// A row here is an ordinary [`Row`] and gets no new members: the item
2439 /// bodies on goingson's day view are a title, a time, a tag and a tone,
2440 /// which the vocabulary already said. What it could not say is *where the
2441 /// row sits*, and that is [`layout::Placement`] — a start and a duration,
2442 /// two integers, which is the whole of what the timeline refusal was
2443 /// pricing as a component library. See `makeover-layout` 0.24.0.
2444 ///
2445 /// # What a renderer owes it
2446 ///
2447 /// Draw the span, put each row at its placement, and lay overlapping rows
2448 /// so both can be read. That last part is presentation and deliberately
2449 /// unspecified: a webview puts them in columns, a terminal may stack them
2450 /// with a marker, and neither is wrong.
2451 /// [`layout::Placement::overlaps`] is how a renderer finds the pairs
2452 /// without the description declaring them.
2453 ///
2454 /// # What it is not
2455 ///
2456 /// Not a calendar and not a kanban board. Both were refused alongside the
2457 /// timeline and neither has been measured; whoever needs one counts the
2458 /// members it is missing rather than reaching for this.
2459 Timeline {
2460 /// The axis: its window, its granularity, how often it labels itself.
2461 track: layout::Track,
2462 /// What sits on it, each with where it sits.
2463 ///
2464 /// Not sorted here, and a renderer must not assume it is. Sorting by
2465 /// start is presentation for anything that draws top to bottom, and
2466 /// meaningless for anything that does not.
2467 entries: Vec<Placed>,
2468 /// A moment worth bringing into view, if any.
2469 ///
2470 /// "Show me 09:00" rather than a scroll offset in pixels. goingson's JS
2471 /// hardcodes `targetHour = 9` inside the renderer, which is the shape
2472 /// this replaces: the app knows the interesting hour, the renderer
2473 /// knows how to get there.
2474 ///
2475 /// `None` means the renderer chooses, which is usually the span's
2476 /// start.
2477 focus: Option<u16>,
2478 },
2479 /// A control that picks between things.
2480 Select {
2481 /// Segmented, toggle, or tabs.
2482 kind: layout::Selector,
2483 /// What is on offer, and what each one calls if it calls something of
2484 /// its own.
2485 ///
2486 /// The tuple is [`Stats`](Self::Stats)' shape and it is here for the
2487 /// same reason, stated there: `makeover-layout` cannot name an action
2488 /// at all, so an address rides beside the described thing rather than
2489 /// inside it. [`Choice::as_layout`] hands back a value and a label and
2490 /// nothing else.
2491 ///
2492 /// It is what a tab strip needs. The MNW server's dashboard-user shell
2493 /// has fifteen tabs and fifteen routes; one strip-level action with the
2494 /// value substituted in cannot address them, and building the route by
2495 /// convention would put route construction in a renderer.
2496 ///
2497 /// An option carrying `None` falls back to
2498 /// [`action`](Self::Select::action) with its value under
2499 /// [`Self::SELECTED`], which is what every option did before the tuple,
2500 /// so a segmented control and a toggle are unchanged in meaning.
2501 options: Vec<(Choice, Option<Action>)>,
2502 /// Which option is currently picked, by its
2503 /// [`value`](Choice::value).
2504 chosen: Option<String>,
2505 /// What picking an option calls, for the options that name nothing
2506 /// themselves. The picked value is sent under [`Self::SELECTED`].
2507 action: Option<Action>,
2508 },
2509 /// How much of a set is done.
2510 Meter(Meter),
2511 /// A value with a caption, several of them as one strip.
2512 ///
2513 /// `93c6a174`. Against `makeover-layout`'s [`layout::Figure`], which arrived
2514 /// at 0.11.0 for this. The dashboard shape: a large value over a small
2515 /// caption, several in a row. goingson had five of them across five screens
2516 /// with five class vocabularies for the one shape, and the port had been
2517 /// making each out of a [`Row`] with the caption as `primary` and the figure
2518 /// as `meta`, which reads backwards — a row's primary slot means the thing
2519 /// itself, and here the thing is the number.
2520 ///
2521 /// # Why the set is the node and not each figure
2522 ///
2523 /// Four tiles in a strip and four tiles down a column are different things,
2524 /// and a renderer handed one at a time cannot tell it is looking at a set.
2525 /// The objection to that is real and is answered by what is already here: a
2526 /// node whose value is its grouping sounds like a layout instruction, and
2527 /// [`List`](Self::List) and [`Table`](Self::Table) have been exactly that
2528 /// since the beginning without anyone calling them one.
2529 ///
2530 /// # Why the action is here and not on the figure
2531 ///
2532 /// One of goingson's five is a control — sync's "Not Applied: 3" opens the
2533 /// list. `makeover-layout` cannot name an action at all, so the figure it
2534 /// describes carries none, and this pairs the description with the address
2535 /// the same way [`Row`] pairs its parts with [`Row::activate`].
2536 Stats {
2537 /// The figures, in order, and what each one calls if it calls anything.
2538 figures: Vec<(Figure, Option<Action>)>,
2539 },
2540 /// A region inside a region.
2541 Region(Slot),
2542 }
2543
2544 impl Node {
2545 /// The parameter name a [`Node::Select`] sends its picked value under.
2546 ///
2547 /// Named once here rather than agreed by convention between each renderer
2548 /// and each handler, which is how a value arrives under `tab` in one screen
2549 /// and `selected` in the next.
2550 pub const SELECTED: &'static str = "value";
2551
2552 /// The parameter name an [`Act::over`] sends each ticked value under.
2553 ///
2554 /// [`SELECTED`](Self::SELECTED)'s sibling, named here for the same reason:
2555 /// a convention agreed separately by each renderer and each handler is a
2556 /// convention that holds until one of them is written by someone else.
2557 ///
2558 /// Distinct from `SELECTED` rather than shared with it, because the two
2559 /// carry different counts. A [`Select`](Self::Select) sends one value and a
2560 /// handler reads it with [`Params::get`]; a selection sends however many
2561 /// are ticked, including none, and a handler reads it with
2562 /// [`Params::get_all`]. One name for both would make "the one thing picked"
2563 /// and "the first of the things ticked" the same read.
2564 ///
2565 /// [`Params::get`]: crate::Params::get
2566 /// [`Params::get_all`]: crate::Params::get_all
2567 pub const TICKED: &'static str = "ticked";
2568
2569 /// A page title.
2570 pub fn page(text: impl Into<String>) -> Self {
2571 Self::Heading {
2572 level: layout::Heading::Page,
2573 text: text.into(),
2574 }
2575 }
2576
2577 /// A section title.
2578 pub fn section(text: impl Into<String>) -> Self {
2579 Self::Heading {
2580 level: layout::Heading::Section,
2581 text: text.into(),
2582 }
2583 }
2584
2585 /// Ordinary prose.
2586 pub fn text(text: impl Into<String>) -> Self {
2587 Self::Text {
2588 text: text.into(),
2589 tone: layout::Tone::Neutral,
2590 }
2591 }
2592
2593 /// Prose written in markdown.
2594 pub fn rich(source: impl Into<String>) -> Self {
2595 Self::Rich {
2596 source: source.into(),
2597 }
2598 }
2599
2600 /// A control calling a route.
2601 pub fn act(label: impl Into<String>, action: Action) -> Self {
2602 Self::Act(Act::new(label, action))
2603 }
2604
2605 /// A persistent message, dismissed by fixing what caused it.
2606 pub fn banner(tone: layout::Tone, text: impl Into<String>) -> Self {
2607 Self::Notice {
2608 kind: layout::Notice::Banner,
2609 tone,
2610 text: text.into(),
2611 }
2612 }
2613
2614 /// A transient message that dismisses itself.
2615 pub fn toast(tone: layout::Tone, text: impl Into<String>) -> Self {
2616 Self::Notice {
2617 kind: layout::Notice::Toast,
2618 tone,
2619 text: text.into(),
2620 }
2621 }
2622
2623 /// A list of rows.
2624 pub fn list(rows: impl IntoIterator<Item = Row>) -> Self {
2625 Self::List {
2626 rows: rows.into_iter().collect(),
2627 more: None,
2628 }
2629 }
2630
2631 /// The same list, saying there is more of it.
2632 ///
2633 /// A no-op on anything that is not a [`Self::List`], which is the one place
2634 /// this file allows that: the alternative is a constructor taking rows and a
2635 /// `Rest` together, and every call site that has no more rows then passes a
2636 /// `None` to say so.
2637 #[must_use]
2638 pub fn and_more(mut self, rest: Rest) -> Self {
2639 if let Self::List { more, .. } = &mut self {
2640 *more = Some(rest);
2641 }
2642 self
2643 }
2644
2645 /// A proportion of a set, untoned and unlabelled.
2646 #[must_use]
2647 pub const fn meter(done: u32, total: u32) -> Self {
2648 Self::Meter(Meter::new(done, total))
2649 }
2650
2651 /// Nothing here yet.
2652 pub fn empty(message: impl Into<String>) -> Self {
2653 Self::StandIn {
2654 state: layout::Readiness::Empty,
2655 message: message.into(),
2656 act: None,
2657 }
2658 }
2659
2660 /// This did not load.
2661 pub fn failed(message: impl Into<String>) -> Self {
2662 Self::StandIn {
2663 state: layout::Readiness::Failed,
2664 message: message.into(),
2665 act: None,
2666 }
2667 }
2668
2669 /// The same stand-in, with a way out of it.
2670 ///
2671 /// A no-op on anything else, for the reason [`Self::and_more`] is one.
2672 #[must_use]
2673 pub fn offering(mut self, way_out: Act) -> Self {
2674 if let Self::StandIn { act, .. } = &mut self {
2675 *act = Some(way_out);
2676 }
2677 self
2678 }
2679
2680 /// One control on its own, outside any form.
2681 pub fn field(field: Field) -> Self {
2682 Self::Field(Box::new(field))
2683 }
2684
2685 /// A strip of figures, none of which answers a click.
2686 pub fn stats(figures: impl IntoIterator<Item = Figure>) -> Self {
2687 Self::Stats {
2688 figures: figures.into_iter().map(|figure| (figure, None)).collect(),
2689 }
2690 }
2691 }
2692
2693 /// A whole screen.
2694 ///
2695 /// [`Arrangement`](layout::Arrangement) is `makeover-layout`'s, and there are
2696 /// two of them because our apps have two: goingson is list-detail, Balanced
2697 /// Breakfast is sidebar plus content. Naming a third before an app has one is
2698 /// how a description becomes a framework.
2699 #[derive(Debug, Clone, PartialEq, Eq)]
2700 pub struct Screen {
2701 /// What the screen is called. A window title, a tab title, a page heading.
2702 pub title: String,
2703 /// How the regions are laid out.
2704 pub arrangement: layout::Arrangement,
2705 /// The regions, in order.
2706 pub slots: Vec<Slot>,
2707 /// Messages raised by whatever produced this screen.
2708 ///
2709 /// Separate from the slots because a notice belongs to the screen rather
2710 /// than to a place in it: which region a toast stacks in is the renderer's
2711 /// question, and a handler answering it would be describing a webview.
2712 pub notices: Vec<Node>,
2713 /// How this screen is found, shared and indexed.
2714 ///
2715 /// Not an `Option`. The default is meaningful — a screen nobody said
2716 /// anything about is an indexable website — and an `Option` would make
2717 /// "nobody said" and "indexable" two spellings of one thing.
2718 pub discovery: Discovery,
2719 /// The name of the set this screen's ticks go into, if it holds one.
2720 ///
2721 /// `5f2b8753`. [`Row::selected`] said a row could be ticked and nothing
2722 /// said what the tick was *for*, so the tick had nowhere to go: a webview
2723 /// hid the hole because the browser owns a checkbox's checked state, and
2724 /// every app then wrote its own JS to gather the boxes back up. A terminal
2725 /// could not hide it. It drew the `[ ]`, bound the key, and the key did
2726 /// nothing, which is worse than not drawing the box.
2727 ///
2728 /// So the screen names the set, each [`Row::value`] is what that row's tick
2729 /// contributes, and [`Act::over`] is how a control says it acts on the
2730 /// whole of it. The renderer holds the set the way `quasi-tui` already
2731 /// holds an edit buffer and a scroll offset, and the commit control reads
2732 /// it by name.
2733 ///
2734 /// # Ticking never writes
2735 ///
2736 /// Wiki `explicit-commit-affordance`, the general rule: a change that
2737 /// happens with no obvious indication is confusing, so a tick stages and
2738 /// the commit control is what locks it in. [`Row::toggle`] describes the
2739 /// other thing — screens where the tick *is* the write — and is left alone
2740 /// here rather than removed, because stopping those screens is work in the
2741 /// apps that have them.
2742 ///
2743 /// # One set per screen
2744 ///
2745 /// A screen with two independent sets has not been measured. Naming one is
2746 /// the smallest thing that closes the hole, and the field grows to a map
2747 /// when an app turns up wanting two, on the same rule every other member
2748 /// here arrived under.
2749 ///
2750 /// [`Row::selected`]: Row::selected
2751 /// [`Row::value`]: Row::value
2752 /// [`Act::over`]: Act::over
2753 pub selection: Option<String>,
2754 /// How wide this screen's content runs.
2755 ///
2756 /// `0eccff0d`. Measured in the MNW server, where 69 of 72 templates carry
2757 /// one of three mutually exclusive CSS classes for it and nothing described
2758 /// it, so the choice lived in the template rather than in the screen.
2759 ///
2760 /// Beside [`arrangement`](Self::arrangement) and answering the level above
2761 /// it: that one divides the screen's width between regions, this says how
2762 /// much of the window the screen takes in the first place. Both are the
2763 /// description's, which is what answering `e0fd485e` and `0eccff0d`
2764 /// together settled.
2765 ///
2766 /// Not an `Option`, for [`discovery`](Self::discovery)'s reason. The
2767 /// default is meaningful -- a screen nobody said anything about uses the
2768 /// window it was given -- and an `Option` would make "nobody said" and
2769 /// "the whole width" two spellings of one thing.
2770 pub measure: layout::Measure,
2771 }
2772
2773 /// How a screen is found, shared and indexed.
2774 ///
2775 /// Not presentation, which is why it is here and not in `makeover-layout`: a
2776 /// terminal ignores every field, the same way it ignores [`Slot::id`]. It is an
2777 /// address-and-identity fact, and that is the line that put [`Action`] in this
2778 /// crate rather than in the vocabulary.
2779 ///
2780 /// Measured before it was added. Every `og:*` value in the MNW server's 37
2781 /// templates is one of four things interpolated from the entity the screen is
2782 /// about: a title, a summary sentence, an image URL, or the screen's own
2783 /// address. None of them needed knowledge only a handler has, which is what
2784 /// made this the screen's to say rather than the host's.
2785 #[derive(Debug, Clone, PartialEq, Eq)]
2786 pub struct Discovery {
2787 /// Whether a crawler should index this screen.
2788 ///
2789 /// Defaults to indexable, because most screens are and a default that hides
2790 /// pages is a default that hides the bug. The six screens saying otherwise
2791 /// are purchased-content pages, and this field is why that is a fact the
2792 /// type carries rather than a line in a template that a conversion can drop
2793 /// in silence.
2794 pub indexable: bool,
2795 /// The sentence a link preview shows. [`Screen::title`] is the title.
2796 pub summary: Option<String>,
2797 /// The image a link preview shows, as an absolute URL.
2798 pub image: Option<String>,
2799 /// What kind of thing this screen is about.
2800 pub kind: SocialKind,
2801 /// The canonical address, when the screen answers at more than one.
2802 pub canonical: Option<String>,
2803 }
2804
2805 impl Default for Discovery {
2806 /// Indexable, and nothing else claimed.
2807 ///
2808 /// Written out rather than derived, and the reason is the one field that
2809 /// matters: `bool::default()` is `false`, so a derived impl would deindex
2810 /// every screen that never mentioned the subject, silently, and the failure
2811 /// would show up as traffic rather than as a test.
2812 fn default() -> Self {
2813 Self {
2814 indexable: true,
2815 summary: None,
2816 image: None,
2817 kind: SocialKind::Website,
2818 canonical: None,
2819 }
2820 }
2821 }
2822
2823 /// What kind of thing a screen is about.
2824 ///
2825 /// The six the server actually emits, and no more. Naming a seventh before a
2826 /// screen has one is how a description becomes a framework, which is the
2827 /// argument [`Arrangement`](layout::Arrangement) is held to two screens by.
2828 ///
2829 /// `#[non_exhaustive]`, because a seventh arriving should not be a lockstep
2830 /// event across every renderer that spells one. The match below stays
2831 /// exhaustive: within this crate the attribute does not apply, and a wildcard
2832 /// here would only hide a member added without a spelling.
2833 #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
2834 #[non_exhaustive]
2835 pub enum SocialKind {
2836 /// A page. The default, and four of the server's screens.
2837 #[default]
2838 Website,
2839 /// Something written, with an author and a date.
2840 Article,
2841 /// A person or an account.
2842 Profile,
2843 /// Something for sale.
2844 Product,
2845 /// A video.
2846 Video,
2847 /// A piece of music.
2848 Song,
2849 }
2850
2851 impl SocialKind {
2852 /// What this is spelled as in `og:type`.
2853 ///
2854 /// Named here rather than agreed between each renderer and each host, which
2855 /// is how one screen ends up `video.other` and the next `video`.
2856 #[must_use]
2857 pub const fn as_str(self) -> &'static str {
2858 match self {
2859 Self::Website => "website",
2860 Self::Article => "article",
2861 Self::Profile => "profile",
2862 Self::Product => "product",
2863 Self::Video => "video.other",
2864 Self::Song => "music.song",
2865 }
2866 }
2867 }
2868
2869 impl Screen {
2870 /// An empty screen with the given arrangement.
2871 pub fn new(title: impl Into<String>, arrangement: layout::Arrangement) -> Self {
2872 Self {
2873 title: title.into(),
2874 arrangement,
2875 slots: Vec::new(),
2876 notices: Vec::new(),
2877 discovery: Discovery::default(),
2878 selection: None,
2879 measure: layout::Measure::default(),
2880 }
2881 }
2882
2883 /// How wide this screen's content runs, chaining.
2884 ///
2885 /// See [`measure`](Self::measure). [`Measure::Wide`](layout::Measure::Wide)
2886 /// is the default and does not need saying.
2887 #[must_use]
2888 pub const fn measured(mut self, measure: layout::Measure) -> Self {
2889 self.measure = measure;
2890 self
2891 }
2892
2893 /// This screen holds a set of ticks under this name, chaining.
2894 ///
2895 /// The rows that join it say so with [`Row::ticking`], and the control that
2896 /// acts on it with [`Act::over`]. See [`selection`](Self::selection).
2897 #[must_use]
2898 pub fn selecting(mut self, name: impl Into<String>) -> Self {
2899 self.selection = Some(name.into());
2900 self
2901 }
2902
2903 /// Whether a crawler should index this screen, chaining.
2904 #[must_use]
2905 pub fn indexed(mut self, indexable: bool) -> Self {
2906 self.discovery.indexable = indexable;
2907 self
2908 }
2909
2910 /// The sentence a link preview shows, chaining.
2911 #[must_use]
2912 pub fn summarised(mut self, text: impl Into<String>) -> Self {
2913 self.discovery.summary = Some(text.into());
2914 self
2915 }
2916
2917 /// The image a link preview shows, chaining. An absolute URL.
2918 #[must_use]
2919 pub fn illustrated(mut self, url: impl Into<String>) -> Self {
2920 self.discovery.image = Some(url.into());
2921 self
2922 }
2923
2924 /// What kind of thing this screen is about, chaining.
2925 #[must_use]
2926 pub fn about(mut self, kind: SocialKind) -> Self {
2927 self.discovery.kind = kind;
2928 self
2929 }
2930
2931 /// The address this screen should be known by, chaining.
2932 #[must_use]
2933 pub fn canonical_at(mut self, url: impl Into<String>) -> Self {
2934 self.discovery.canonical = Some(url.into());
2935 self
2936 }
2937
2938 /// A list that chooses what the detail beside it shows.
2939 pub fn list_detail(title: impl Into<String>, tabbed: bool) -> Self {
2940 Self::new(title, layout::Arrangement::list_detail(tabbed))
2941 }
2942
2943 /// Navigation down the side, content filling the rest.
2944 pub fn sidebar_content(title: impl Into<String>) -> Self {
2945 Self::new(title, layout::Arrangement::sidebar_content())
2946 }
2947
2948 /// Add a region, chaining.
2949 #[must_use]
2950 pub fn with(mut self, slot: Slot) -> Self {
2951 self.slots.push(slot);
2952 self
2953 }
2954
2955 /// Raise a message on this screen, chaining.
2956 ///
2957 /// # Panics
2958 ///
2959 /// If the node is not a [`Node::Notice`]. The field is typed as a [`Node`]
2960 /// so a renderer walks one kind of thing, and this is the constructor that
2961 /// keeps that from meaning anything can go in it.
2962 #[must_use]
2963 pub fn saying(mut self, notice: Node) -> Self {
2964 assert!(
2965 matches!(notice, Node::Notice { .. }),
2966 "Screen::saying takes a Node::Notice"
2967 );
2968 self.notices.push(notice);
2969 self
2970 }
2971
2972 /// The slot under this address, at any depth.
2973 #[must_use]
2974 pub fn slot(&self, id: &str) -> Option<&Slot> {
2975 self.slots.iter().find_map(|slot| slot.find(id))
2976 }
2977
2978 /// Apply a fragment: put `node` in the region under `region`, replacing
2979 /// whatever was there. Returns whether the region was found.
2980 ///
2981 /// This is what a host holding a `Screen` does with
2982 /// [`Outcome::Fragment`](crate::Outcome::Fragment). A webview host needs
2983 /// none of it -- `quasi-http` turns the same outcome into an `hx-retarget`
2984 /// header and the browser performs the swap against a document it already
2985 /// has -- but a host that retains the description rather than the markup
2986 /// has nothing between the fragment and the tree.
2987 ///
2988 /// It lives here and not in a host because applying a fragment is surgery
2989 /// on this crate's own type. A host writing it means every retained-screen
2990 /// host writes it separately and each picks its own answer for the three
2991 /// decisions below, which is the thing this crate's no-host-imports rule
2992 /// exists to prevent.
2993 ///
2994 /// **A region that is not there answers `false`, not a panic.** The caller
2995 /// is the one that can act on it: a host can fall back to a redraw, and a
2996 /// test can assert it. What is worth avoiding is the silent no-op, because
2997 /// a miss means a route naming a slot that no longer exists, and that is a
2998 /// description bug rather than a rendering one.
2999 ///
3000 /// **It replaces rather than appends.** `Outcome::Fragment` is one region's
3001 /// new contents, which is the whole reason it can be smaller than a screen.
3002 ///
3003 /// **The region becomes [`Ready`](layout::Readiness::Ready).** A fragment
3004 /// arriving is the content arriving, so a slot marked
3005 /// [`Pending`](layout::Readiness::Pending) while it was in flight stops
3006 /// being pending here. Emptiness is a different axis and rides on the node:
3007 /// a [`Node::StandIn`] carries its own state, and replacing with one is a
3008 /// region that is ready and has nothing to show.
3009 pub fn replace(&mut self, region: &str, node: Node) -> bool {
3010 let Some(slot) = self.slots.iter_mut().find_map(|slot| slot.find_mut(region)) else {
3011 return false;
3012 };
3013 slot.body.clear();
3014 slot.body.push(node);
3015 slot.readiness = layout::Readiness::Ready;
3016 true
3017 }
3018 }
3019
3020 #[cfg(test)]
3021 mod tests {
3022 use super::*;
3023
3024 fn frames(count: usize) -> Vec<Node> {
3025 (0..count)
3026 .map(|n| {
3027 Node::Image(Picture::new(
3028 format!("/frame-{n}.png"),
3029 format!("frame {n}"),
3030 ))
3031 })
3032 .collect()
3033 }
3034
3035 #[test]
3036 fn a_region_shows_everything_until_it_says_otherwise() {
3037 // The default has to be the old behaviour, or every description written
3038 // before this field existed changes meaning when it arrives.
3039 let pane = Slot::new("content", RegionKind::Pane).extend(frames(3));
3040
3041 assert_eq!(pane.showing, layout::Showing::All);
3042 assert_eq!(pane.current(), None);
3043 }
3044
3045 #[test]
3046 fn a_carousel_with_no_stated_frame_is_on_its_first() {
3047 // `Showing::One` says exactly one is up, so there is no honest reading
3048 // of a missing index other than the first. A renderer never has to
3049 // decide this for itself, which is the point of the method.
3050 let mut carousel = Slot::widget("shots", "carousel").extend(frames(3));
3051 carousel.showing = layout::Showing::One;
3052
3053 assert_eq!(carousel.current(), Some(0));
3054 }
3055
3056 #[test]
3057 fn a_frame_past_the_end_clamps_rather_than_vanishing() {
3058 // An out-of-range index is an app bug either way. Clamping reports it as
3059 // a carousel stuck on its last frame, which is findable; drawing nothing
3060 // reports it as a region that disappeared, which is not.
3061 let carousel = Slot::widget("shots", "carousel")
3062 .extend(frames(3))
3063 .showing_one(9);
3064
3065 assert_eq!(carousel.current(), Some(2));
3066
3067 // And an empty body has no frame to clamp to.
3068 assert_eq!(
3069 Slot::widget("shots", "carousel").showing_one(0).current(),
3070 None
3071 );
3072 }
3073
3074 #[test]
3075 fn a_closed_disclosure_is_the_one_selective_region_showing_nothing() {
3076 let closed = Slot::widget("details", "disclosure")
3077 .extend(frames(1))
3078 .showing_at_most_one(None);
3079 let open = Slot::widget("details", "disclosure")
3080 .extend(frames(1))
3081 .showing_at_most_one(Some(0));
3082
3083 assert_eq!(closed.current(), None);
3084 assert_eq!(open.current(), Some(0));
3085
3086 // Closed and `Showing::All` answer the same here on purpose: they differ
3087 // in the chrome around the body, not in what a renderer does with it.
3088 assert!(closed.showing.selective());
3089 }
3090
3091 #[test]
3092 fn labels_are_all_or_nothing() {
3093 // A strip with a hole in it is worse than the prev/next row it would
3094 // have replaced, so a half-labelled body gets the row.
3095 let tabs = Slot::new("detail", RegionKind::TabGroup)
3096 .with(Node::Region(
3097 Slot::new("overview", RegionKind::Pane).label("Overview"),
3098 ))
3099 .with(Node::Region(
3100 Slot::new("files", RegionKind::Pane).label("Files"),
3101 ));
3102 assert_eq!(tabs.labels(), ["Overview", "Files"]);
3103
3104 let half = tabs
3105 .clone()
3106 .with(Node::Region(Slot::new("history", RegionKind::Pane)));
3107 assert!(half.labels().is_empty());
3108 }
3109
3110 #[test]
3111 fn a_carousels_frames_carry_no_label_and_that_is_the_switch() {
3112 // Which idiom a renderer draws falls out of this rather than out of the
3113 // widget's name. A frame has a caption; only a region has a tab name.
3114 let carousel = Slot::widget("shots", "carousel")
3115 .extend(frames(3))
3116 .showing_one(1);
3117
3118 assert!(carousel.labels().is_empty());
3119 assert_eq!(carousel.current(), Some(1));
3120 }
3121 }
3122