Skip to main content

max / quasi

122.1 KB · 3110 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 /// A set of panes, one visible at a time, with tabs above.
1114 TabGroup,
1115 /// Content over a scrim, taking input until dismissed.
1116 ///
1117 /// A modal this screen *contains*, which is how a confirmation is drawn: it
1118 /// arrives with the screen and goes when the screen goes. The app-level one
1119 /// is [`Outcome::Over`](crate::Outcome::Over), which draws a whole screen
1120 /// over whatever is under it and is reachable from screens that know
1121 /// nothing about it.
1122 Modal,
1123 /// A place, and nothing else. The app fills it per host.
1124 ///
1125 /// Decision 4: the renderer hands the space over and the app puts a JS
1126 /// component, an egui closure or a TUI widget in it. The rejected
1127 /// alternative was giving the placeholder its own route and fetching a
1128 /// fragment for it, which is uniform on paper and wrong in currency: a byte
1129 /// payload is not what egui or a terminal wants.
1130 Bespoke {
1131 /// What the app calls it. Never interpreted here.
1132 name: String,
1133 },
1134 /// A named assembly of things the description already says.
1135 ///
1136 /// The third tier, and the one member that is a name *and* contents. See
1137 /// [`layout::Region::Widget`] for what separates it from the two either
1138 /// side of it; the short form is that a primitive has to be drawable by
1139 /// every host from scratch and a bespoke carries nothing under it, and a
1140 /// carousel is neither.
1141 ///
1142 /// The body is the assembly and it is ordinary description: a renderer that
1143 /// does not recognise the name walks it and draws primitives, which is why
1144 /// naming one costs no renderer release. Contrast
1145 /// [`Bespoke`](Self::Bespoke), whose body a renderer can draw but whose
1146 /// *fill* only the host has.
1147 Widget {
1148 /// What the assembly is called. Never interpreted here, and a renderer
1149 /// is free not to know it.
1150 name: String,
1151 },
1152 }
1153
1154 impl RegionKind {
1155 /// Borrow as the description layer's own type.
1156 #[must_use]
1157 pub fn as_layout(&self) -> layout::Region<'_> {
1158 match self {
1159 Self::Band => layout::Region::Band,
1160 Self::Sidebar => layout::Region::Sidebar,
1161 Self::Pane => layout::Region::Pane,
1162 Self::Split => layout::Region::Split,
1163 Self::TabGroup => layout::Region::TabGroup,
1164 Self::Modal => layout::Region::Modal,
1165 Self::Bespoke { name } => layout::Region::Bespoke { name },
1166 Self::Widget { name } => layout::Region::Widget { name },
1167 }
1168 }
1169
1170 /// Whether the description can say anything about the contents.
1171 #[must_use]
1172 pub fn described(&self) -> bool {
1173 self.as_layout().described()
1174 }
1175
1176 /// How the region sits on what is behind it.
1177 #[must_use]
1178 pub fn depth(&self) -> layout::Depth {
1179 self.as_layout().depth()
1180 }
1181 }
1182
1183 /// A named region, and the thing a fragment is aimed at.
1184 ///
1185 /// The name is what decision 7 needs and [`layout::Region`] deliberately does
1186 /// not have: two panes in a split are both `Pane`, so the kind cannot be an
1187 /// address. A webview maps the id onto `hx-target`; egui and the terminal
1188 /// ignore it and redraw, which costs them nothing because they were redrawing
1189 /// anyway.
1190 #[derive(Debug, Clone, PartialEq, Eq)]
1191 pub struct Slot {
1192 /// The address. Unique within a screen, and stable across responses, or a
1193 /// fragment lands nowhere.
1194 pub id: String,
1195 /// Which region it is.
1196 pub kind: RegionKind,
1197 /// Whether the region's own content is here or on its way.
1198 ///
1199 /// The loading axis, and only that. Emptiness is *not* said here, which
1200 /// looks like the obvious place for it and is not: a column with a heading
1201 /// and no rows is a region that has content — the heading — and a list that
1202 /// has none. Marking the region empty would hide the heading with it. See
1203 /// [`Node::StandIn`].
1204 pub readiness: layout::Readiness,
1205 /// What is in it.
1206 ///
1207 /// Blocks, regions included, which is the nesting that was always accepted:
1208 /// a region inside a region is a nested rect on every host. Leaves are
1209 /// admitted too, and deliberately -- a fact under a heading is a
1210 /// [`Node::Text`] straight in a pane, and it is the commonest thing in the
1211 /// tree.
1212 ///
1213 /// # Why there is no bound here
1214 ///
1215 /// [`Cell::part`] and [`Row::part`] assert that what they are handed is a
1216 /// leaf, and this does not, which looks like an oversight and is the model
1217 /// working. The ladder forbids reaching *up*: a run may not hold a block,
1218 /// because a run has to be drawable on one wrapped line. A block holding a
1219 /// leaf is going down, and going down is what containment is for. There is
1220 /// no upward violation for [`with`](Self::with) to catch, so an assertion
1221 /// here would be a runtime check that can never fire.
1222 ///
1223 /// A region whose whole content is one badge is the case that made this
1224 /// look like a question. It is describable, and it should be: a status pane
1225 /// is a real screen. Whether it is a *good* screen is a judgement about
1226 /// that screen rather than a property of the vocabulary, and the bound is
1227 /// not the place to hold opinions about taste.
1228 pub body: Vec<Node>,
1229 /// How many of [`body`](Self::body) are visible at once.
1230 ///
1231 /// `4dcd241b`. [`layout::Showing::All`] by default, which is what every
1232 /// region did before this field existed, so a description written against
1233 /// the previous version says the same thing.
1234 ///
1235 /// This is the kind. The two fields below are the current answer and the
1236 /// per-child name, and they are here rather than in `makeover-layout` for
1237 /// the reason [`Node::Select`]'s `chosen` is: a layer that defers every
1238 /// address does not hold what is picked either.
1239 pub showing: layout::Showing,
1240 /// Which child is up, when only one of them is.
1241 ///
1242 /// Meaningless under [`layout::Showing::All`] and ignored there. Read
1243 /// through [`current`](Self::current) rather than directly, which is where
1244 /// an index past the end of the body is dealt with.
1245 pub shown: Option<usize>,
1246 /// What this region is called, when something above it is showing one child
1247 /// at a time.
1248 ///
1249 /// The tab's name, and the whole of what separates a tab strip from a
1250 /// prev/next row: a renderer draws the strip when the children carry these
1251 /// and the row when they do not. A carousel's frames are [`Node::Image`] and
1252 /// have nowhere to put one, which is correct rather than a gap — a frame has
1253 /// a caption, not a tab name.
1254 pub label: Option<String>,
1255 }
1256
1257 impl Slot {
1258 /// An empty region under this address.
1259 pub fn new(id: impl Into<String>, kind: RegionKind) -> Self {
1260 Self {
1261 id: id.into(),
1262 kind,
1263 readiness: layout::Readiness::Ready,
1264 body: Vec::new(),
1265 showing: layout::Showing::All,
1266 shown: None,
1267 label: None,
1268 }
1269 }
1270
1271 /// A place the app fills itself.
1272 pub fn bespoke(id: impl Into<String>, name: impl Into<String>) -> Self {
1273 Self::new(id, RegionKind::Bespoke { name: name.into() })
1274 }
1275
1276 /// A named assembly, whose body says what it is made of.
1277 ///
1278 /// The body is not optional in spirit, though nothing here enforces it: a
1279 /// widget with an empty body is a [`bespoke`](Self::bespoke) that has
1280 /// mislaid its host fill, and a renderer that does not know the name will
1281 /// draw nothing at all. Assemble it out of members the description already
1282 /// has, the way [`layout::Region::Widget`] describes.
1283 pub fn widget(id: impl Into<String>, name: impl Into<String>) -> Self {
1284 Self::new(id, RegionKind::Widget { name: name.into() })
1285 }
1286
1287 /// Add a node, chaining.
1288 #[must_use]
1289 pub fn with(mut self, node: Node) -> Self {
1290 self.body.push(node);
1291 self
1292 }
1293
1294 /// Add several nodes, chaining.
1295 #[must_use]
1296 pub fn extend(mut self, nodes: impl IntoIterator<Item = Node>) -> Self {
1297 self.body.extend(nodes);
1298 self
1299 }
1300
1301 /// The content is on its way rather than here.
1302 #[must_use]
1303 pub fn pending(mut self) -> Self {
1304 self.readiness = layout::Readiness::Pending;
1305 self
1306 }
1307
1308 /// Show one child at a time, starting at this one.
1309 ///
1310 /// The carousel and the tab group, which are one thing said twice: whether
1311 /// a host draws a strip of names or a prev/next row falls out of whether
1312 /// the children carry a [`label`](Self::label), never out of the widget's
1313 /// name.
1314 #[must_use]
1315 pub fn showing_one(mut self, shown: usize) -> Self {
1316 self.showing = layout::Showing::One;
1317 self.shown = Some(shown);
1318 self
1319 }
1320
1321 /// Show one child or none, starting closed unless a child is named.
1322 ///
1323 /// Disclosure. `None` is the closed state and is a legal resting place,
1324 /// which is the whole of what separates this from
1325 /// [`showing_one`](Self::showing_one).
1326 #[must_use]
1327 pub fn showing_at_most_one(mut self, shown: Option<usize>) -> Self {
1328 self.showing = layout::Showing::AtMostOne;
1329 self.shown = shown;
1330 self
1331 }
1332
1333 /// Name this region, for when something above it shows one child at a time.
1334 #[must_use]
1335 pub fn label(mut self, label: impl Into<String>) -> Self {
1336 self.label = Some(label.into());
1337 self
1338 }
1339
1340 /// Which child to draw, once [`shown`](Self::shown) is read against the body.
1341 ///
1342 /// `None` means draw them all, which is both [`layout::Showing::All`] and a
1343 /// closed disclosure — the two cases differ in what chrome sits around them
1344 /// and not in what a renderer does with the body, so they answer the same
1345 /// here.
1346 ///
1347 /// An index past the end is clamped rather than refused. A description
1348 /// pointing at a frame that is not there is a bug in the app, and a renderer
1349 /// that answers it by drawing nothing reports it as a region that vanished,
1350 /// which is the hardest kind of bug to find from what is on the screen.
1351 /// [`layout::Share::percent`] clamps for the same reason.
1352 #[must_use]
1353 pub fn current(&self) -> Option<usize> {
1354 if self.body.is_empty() {
1355 return None;
1356 }
1357 let last = self.body.len() - 1;
1358 match self.showing {
1359 layout::Showing::One => Some(self.shown.unwrap_or(0).min(last)),
1360 layout::Showing::AtMostOne => self.shown.map(|shown| shown.min(last)),
1361 layout::Showing::All => None,
1362 // `Showing` is `#[non_exhaustive]`, so this arm is compulsory even
1363 // with every member above it named. Drawing the whole body is the
1364 // right default for a member this crate has not been taught yet:
1365 // more content rather than less, which is how every other unknown
1366 // in this vocabulary degrades.
1367 _ => None,
1368 }
1369 }
1370
1371 /// The children's names, when they have them.
1372 ///
1373 /// Empty unless *every* child is a named region, which is the test a
1374 /// renderer applies before drawing a strip: a strip with a hole in it is
1375 /// worse than the prev/next row it would otherwise have drawn, and a
1376 /// half-labelled body is an app bug rather than a third idiom.
1377 #[must_use]
1378 pub fn labels(&self) -> Vec<&str> {
1379 let named: Vec<&str> = self
1380 .body
1381 .iter()
1382 .filter_map(|node| match node {
1383 Node::Region(slot) => slot.label.as_deref(),
1384 _ => None,
1385 })
1386 .collect();
1387
1388 if named.len() == self.body.len() {
1389 named
1390 } else {
1391 Vec::new()
1392 }
1393 }
1394
1395 /// This slot, or the first slot under this address anywhere inside it.
1396 #[must_use]
1397 pub fn find(&self, id: &str) -> Option<&Self> {
1398 if self.id == id {
1399 return Some(self);
1400 }
1401 self.body.iter().find_map(|node| match node {
1402 Node::Region(slot) => slot.find(id),
1403 _ => None,
1404 })
1405 }
1406
1407 /// The mutable half of [`find`](Self::find).
1408 ///
1409 /// Same walk, and it has to be a second function rather than the same one
1410 /// generic over mutability: a `&mut` borrow of `self` cannot be handed to
1411 /// the recursive call and kept, which is what `find_map` does on the shared
1412 /// side.
1413 fn find_mut(&mut self, id: &str) -> Option<&mut Self> {
1414 if self.id == id {
1415 return Some(self);
1416 }
1417 self.body.iter_mut().find_map(|node| match node {
1418 Node::Region(slot) => slot.find_mut(id),
1419 _ => None,
1420 })
1421 }
1422 }
1423
1424 /// A control that calls a route.
1425 ///
1426 /// A button, a link and a menu item are the same thing to a description: a
1427 /// label, an address, and how loudly it is saying it. Which of the three a
1428 /// renderer draws is a renderer decision.
1429 #[derive(Debug, Clone, PartialEq, Eq)]
1430 pub struct Act {
1431 /// What it is called.
1432 pub label: String,
1433 /// What it calls.
1434 pub action: Action,
1435 /// What it is saying. [`layout::Tone::Danger`] is what marks the button
1436 /// that destroys something.
1437 pub tone: layout::Tone,
1438 /// Focused, disabled, or neither.
1439 pub state: Option<layout::State>,
1440 /// What to ask before doing it, if it should be asked.
1441 ///
1442 /// `524a63fe`. Destructiveness is a property of the action, known where the
1443 /// action is described, and until this existed every app expressed it by
1444 /// calling a JS helper at the call site: goingson has 33 such calls across
1445 /// four helpers and Balanced Breakfast 5.
1446 ///
1447 /// The prompt only. The word on the agreeing button is
1448 /// [`label`](Self::label), because it already is — goingson's `confirmDelete`
1449 /// passes `confirmText: 'Delete'` for an act labelled "Delete" — and a
1450 /// second string would be the same word twice with a chance to disagree.
1451 /// [`tone`](Self::tone) already says whether the dialog is a dangerous one.
1452 ///
1453 /// `Region::Modal` names the box a confirmation appears in and does not name
1454 /// the pattern. This is the pattern: a webview raises a dialog, a touch host
1455 /// an action sheet, a terminal a y/n line, and none of them is a route to a
1456 /// modal screen and back, which is a different interaction.
1457 pub confirm: Option<String>,
1458 /// The key that reaches it, written the way a user would say it.
1459 ///
1460 /// `2daea915`. An `Act` had a label and a destination and nothing said which
1461 /// key gets there, so goingson's 279-line `keyboard.js` holds the table
1462 /// beside the description, and the help overlay that lists the shortcuts is
1463 /// a second hand-written copy that can drift from it.
1464 ///
1465 /// A terminal makes the case sharper than a webview does: there the key *is*
1466 /// the affordance, so a description that cannot name one cannot describe the
1467 /// screen's primary interaction at all.
1468 ///
1469 /// Text rather than a modelled chord — "n", "ctrl+k", "?" — because the
1470 /// vocabulary of keys is the host's and a description that modelled it would
1471 /// be naming one host's keyboard. A renderer that does not know a name
1472 /// ignores it, which is what a webview does with a key a terminal wants.
1473 ///
1474 /// Screen-scoped, because a screen is what this describes. An app-wide
1475 /// shortcut belongs to the app and is not a fact about any one screen:
1476 /// that is [`Chrome::bindings`](crate::Chrome::bindings), held beside the
1477 /// router rather than inside any answer. A renderer matches those first, so
1478 /// a screen cannot capture the key that opens the palette.
1479 pub key: Option<String>,
1480 /// The [`Screen::selection`] this acts on, if it acts on one.
1481 ///
1482 /// `5f2b8753`. This is what makes a commit control readable: "Archive" over
1483 /// a selection is a different sentence from "Archive" on a row, and until
1484 /// this existed the difference lived in whichever JS gathered the checked
1485 /// boxes.
1486 ///
1487 /// Every ticked [`Row::value`] is sent under [`Node::TICKED`], repeated
1488 /// once per member. Repeated rather than joined, because a name appearing
1489 /// many times is what [`Params::get_all`] is for and a delimiter would have
1490 /// to be one no value can contain.
1491 ///
1492 /// # The name does not select between sets yet, and cannot
1493 ///
1494 /// A screen holds one selection ([`Screen::selection`]), so being set at
1495 /// all is what makes a control a commit control, and the name is what makes
1496 /// it *readable* — "Archive" over `chosen` is a different sentence from
1497 /// "Archive" on a row.
1498 ///
1499 /// Matching it against the screen's name was the first shape and it does
1500 /// not work, because a renderer does not always have the screen: an
1501 /// [`Outcome::Fragment`] replaces a region and carries no screen at all, so
1502 /// a webview rendering one would have had to guess and a terminal, which
1503 /// keeps the screen beside it, would not. The two hosts would then disagree
1504 /// about a typo, which is exactly the drift this vocabulary exists to stop.
1505 /// So both read it the same way, and the name starts choosing between sets
1506 /// on the day [`Screen::selection`] becomes a map.
1507 ///
1508 /// [`Params::get_all`]: crate::Params::get_all
1509 /// [`Outcome::Fragment`]: crate::Outcome::Fragment
1510 pub over: Option<String>,
1511 }
1512
1513 impl Act {
1514 /// A neutral control calling this route.
1515 pub fn new(label: impl Into<String>, action: Action) -> Self {
1516 Self {
1517 label: label.into(),
1518 action,
1519 tone: layout::Tone::Neutral,
1520 state: None,
1521 confirm: None,
1522 key: None,
1523 over: None,
1524 }
1525 }
1526
1527 /// This acts on the screen's selection, by name.
1528 ///
1529 /// The commit half of a staged tick. See [`over`](Self::over) for what
1530 /// reaches the handler, and [`Screen::selection`] for why a tick stages
1531 /// rather than writes.
1532 #[must_use]
1533 pub fn over(mut self, selection: impl Into<String>) -> Self {
1534 self.over = Some(selection.into());
1535 self
1536 }
1537
1538 /// Ask this before doing it.
1539 #[must_use]
1540 pub fn confirm(mut self, prompt: impl Into<String>) -> Self {
1541 self.confirm = Some(prompt.into());
1542 self
1543 }
1544
1545 /// The key that reaches it.
1546 #[must_use]
1547 pub fn key(mut self, key: impl Into<String>) -> Self {
1548 self.key = Some(key.into());
1549 self
1550 }
1551
1552 /// Set what it is saying.
1553 #[must_use]
1554 pub fn tone(mut self, tone: layout::Tone) -> Self {
1555 self.tone = tone;
1556 self
1557 }
1558
1559 /// Present, visible, and not answering.
1560 #[must_use]
1561 pub fn disabled(mut self) -> Self {
1562 self.state = Some(layout::State::Disabled);
1563 self
1564 }
1565
1566 /// Whether the control currently answers input.
1567 #[must_use]
1568 pub fn interactive(&self) -> bool {
1569 !self
1570 .state
1571 .is_some_and(layout::State::suppresses_interaction)
1572 }
1573
1574 /// Borrow as the description layer's own type.
1575 ///
1576 /// [`action`](Self::action) and [`confirm`](Self::confirm) do not survive
1577 /// the crossing, and that is what the two layers disagree about rather than
1578 /// an oversight. An address is quasi's — every host follows one differently
1579 /// — and a confirmation is a question asked after the press, so it belongs
1580 /// to whoever is holding the interaction. What is left is what a renderer
1581 /// needs to *draw* the control, which is all `layout::Act` claims to be.
1582 #[must_use]
1583 pub fn as_layout(&self) -> layout::Act<'_> {
1584 layout::Act {
1585 label: &self.label,
1586 key: self.key.as_deref(),
1587 tone: self.tone,
1588 state: self.state,
1589 }
1590 }
1591 }
1592
1593 /// One part of a row's run, and the role it takes.
1594 ///
1595 /// A cell's run entries carry no role because their kind already says which
1596 /// part they are: text is the value, a [`Node::Link`] is the link, a
1597 /// [`Node::Token`] is a chip, a [`Node::Act`] is a control. A row's
1598 /// `primary`, `secondary` and `meta` are three *text* roles, and kind cannot
1599 /// tell those apart, so a row says which one it means.
1600 ///
1601 /// The role is a style role and nothing else. [`layout::RowPart`] is unchanged
1602 /// by the containment model: it says how a part is drawn, not what may sit in
1603 /// it, and that is the half of it worth keeping.
1604 #[derive(Debug, Clone, PartialEq, Eq)]
1605 pub struct Part {
1606 /// Which of the row's roles this part takes.
1607 pub role: layout::RowPart,
1608 /// What is in it. A leaf, since a row is an inline run.
1609 pub node: Node,
1610 }
1611
1612 /// One row of a list.
1613 ///
1614 /// # The run
1615 ///
1616 /// A row's content is an inline run of [`Part`]s, in the order the description
1617 /// says them, the same way a [`Cell`]'s is. It was six members before
1618 /// `1786cb94` -- `primary`, `secondary`, `meta`, `tokens`, `actions`, `meter`
1619 /// -- each of which arrived as a counted-sites argument, a member here, a
1620 /// [`layout::RowPart`] variant and a release: `RowPart::Tokens` at
1621 /// makeover-layout 0.9.0 for a badge in a row, `RowPart::Proportion` at 0.11.0
1622 /// for a bar in one. A link in a row was simply not sayable, and a figure in
1623 /// one was not either. Under the run both are already sayable and cost nothing.
1624 ///
1625 /// The bound is that every part is a leaf, so a row is drawable on one wrapped
1626 /// line without a renderer knowing what is in it. [`Row::part`] is where that
1627 /// bites at a call site.
1628 ///
1629 /// Order is the description's. The old members were drawn in a fixed sequence
1630 /// whatever order they were built in, so a row that wanted a tag between two
1631 /// facts got the tag hoisted to the end; now it draws where it was put.
1632 ///
1633 /// # What stayed a field
1634 ///
1635 /// [`activate`](Self::activate), [`current`](Self::current),
1636 /// [`selected`](Self::selected), [`menu`](Self::menu) and
1637 /// [`toggle`](Self::toggle) are facts *about* the row rather than content in
1638 /// it. A run of things on a line is not where "this row is the one the detail
1639 /// pane is showing" belongs.
1640 ///
1641 /// # The cost
1642 ///
1643 /// [`primary()`](Self::primary) is no longer guaranteed to be one string, which
1644 /// is what let a constrained renderer right-align a row cheaply. It answers the
1645 /// text of the primary parts joined, and a row built the ordinary way still has
1646 /// exactly one.
1647 /// A row and when it happens.
1648 ///
1649 /// The pairing [`Node::Timeline`] is made of. Deliberately a pair rather than
1650 /// members on [`Row`]: a row does not become a different kind of thing by
1651 /// being placed, and every list, table and detail pane in the tree would
1652 /// otherwise carry two integers it has no use for.
1653 #[derive(Debug, Clone, PartialEq, Eq)]
1654 pub struct Placed {
1655 /// Where it sits on the axis, and for how long.
1656 pub placement: layout::Placement,
1657 /// The thing itself, said the ordinary way.
1658 pub row: Row,
1659 }
1660
1661 impl Placed {
1662 /// A row at a start and a duration, both in minutes.
1663 #[must_use]
1664 pub const fn new(at: u16, minutes: u16, row: Row) -> Self {
1665 Self {
1666 placement: layout::Placement::new(at, minutes),
1667 row,
1668 }
1669 }
1670
1671 /// Whether this and another cover any of the same time.
1672 ///
1673 /// Forwarded so a renderer laying out collisions does not reach through to
1674 /// the placement and, in doing so, decide for itself what overlapping
1675 /// means.
1676 #[must_use]
1677 pub const fn overlaps(&self, other: &Self) -> bool {
1678 self.placement.overlaps(other.placement)
1679 }
1680 }
1681
1682 #[derive(Debug, Clone, PartialEq, Eq, Default)]
1683 pub struct Row {
1684 /// What is in the row, in order.
1685 ///
1686 /// Built by the same constructors that named the old members:
1687 /// [`Row::new`], [`secondary`](Row::secondary), [`meta`](Row::meta),
1688 /// [`token`](Row::token), [`act`](Row::act) and [`meter`](Row::meter) all
1689 /// still mean what they meant, so no builder call site moved.
1690 pub parts: Vec<Part>,
1691 /// The route that selects this row, if selecting it does anything.
1692 pub activate: Option<Action>,
1693 /// Whether this is the row the detail side is currently showing.
1694 ///
1695 /// Named `selected` until 2026-08-08, which was one word doing two jobs.
1696 /// This one is the app's own pointer into a set: what a list-detail
1697 /// arrangement highlights because its pane is showing it, and what a
1698 /// webview says with `aria-current`. The user's tick is
1699 /// [`selected`](Self::selected), and conflating them meant a screen with
1700 /// bulk actions could not describe its checkboxes at all.
1701 pub current: bool,
1702 /// Whether the user has ticked this row, and whether they can.
1703 ///
1704 /// Three states in one field, which is why it is not a `bool`. `None` means
1705 /// the row is not selectable and no affordance should be drawn; `Some(false)`
1706 /// means it can be ticked and is not; `Some(true)` means it is. A plain bool
1707 /// cannot tell "not ticked" from "not tickable", so every renderer would
1708 /// have had to be told selectability some other way, and each would have
1709 /// picked a different way.
1710 ///
1711 /// This is the user's selection, as distinct from
1712 /// [`current`](Self::current). goingson's contacts and tasks screens both
1713 /// drive bulk actions from it.
1714 pub selected: Option<bool>,
1715 /// Everything else that can be done to this row.
1716 ///
1717 /// `5e02fbce`. The [`Actions`](layout::RowPart::Actions) parts of the run
1718 /// are what the row shows; this is
1719 /// what it *offers*, reached by right-click on a pointer host, long-press on
1720 /// a touch one, and a key in a terminal. That split is the whole reason it
1721 /// belongs in the description rather than in a renderer: one description has
1722 /// to become a context menu, an action sheet and a key-driven menu, and no
1723 /// single renderer can be the place where it is said.
1724 ///
1725 /// goingson opens one at 14 sites and Balanced Breakfast at 9, on top of
1726 /// 680 lines of generic menu machinery between `components.js` and
1727 /// `context-menus.js`.
1728 ///
1729 /// A field rather than a role in the run, because a menu is not on the
1730 /// line. The run is what the row draws; this is what it holds back until
1731 /// the host asks, and no renderer draws it in sequence with the primary.
1732 pub menu: Vec<Act>,
1733 /// What ticking this row calls, if ticking it is the write.
1734 ///
1735 /// `14612ed8`, part of it. [`selected`](Self::selected) says whether the row
1736 /// is ticked and whether it can be, and that was the whole story for a bulk
1737 /// checkbox, whose tick is client state feeding a later action. A checklist
1738 /// is the other case: the tick *is* the write, and it is the only affordance
1739 /// the screen offers for it. Described without this, the port drew the tick
1740 /// inert and put the toggle on a button beside it, which is a user clicking
1741 /// a button next to a checkbox that ignores clicks.
1742 ///
1743 /// Two fields rather than a `Selection` struct, matching how
1744 /// [`activate`](Self::activate) sits beside [`current`](Self::current):
1745 /// state and behaviour are separate facts about the row. They do have to
1746 /// agree — a `toggle` with no [`selected`](Self::selected) is a route on a
1747 /// control nothing draws — and [`Row::toggling`] is the constructor that
1748 /// makes them agree.
1749 pub toggle: Option<Action>,
1750 /// What this row's tick contributes to the screen's selection.
1751 ///
1752 /// `5f2b8753`. [`selected`](Self::selected) says the row can be ticked;
1753 /// this says what ticking it *means*, which is the half that was missing.
1754 /// A set of ticks with nothing in them is not a selection, so a renderer
1755 /// holding [`Screen::selection`] holds these.
1756 ///
1757 /// `value` rather than `id`, matching [`Choice::value`]: throughout this
1758 /// vocabulary it is the word for what a control contributes when it is
1759 /// chosen, and a row's tick is the same kind of fact.
1760 ///
1761 /// A selectable row without one is the dead affordance this member exists
1762 /// to end, and [`Row::ticking`] is the constructor that cannot produce it.
1763 /// It is not enforced here, for [`toggle`](Self::toggle)'s reason: a
1764 /// description layer that refused to hold a half-built row would refuse it
1765 /// at the moment the app is still building it.
1766 ///
1767 /// [`Choice::value`]: Choice::value
1768 /// [`Screen::selection`]: Screen::selection
1769 pub value: Option<String>,
1770 }
1771
1772 impl Row {
1773 /// A row with only its primary text.
1774 ///
1775 /// An empty string is an empty run rather than a run holding an empty
1776 /// string, so `Row::new("")` and [`Row::default`] are the same value. Same
1777 /// rule as [`Cell::new`], and for the same reason.
1778 pub fn new(primary: impl Into<String>) -> Self {
1779 let primary = primary.into();
1780 Self {
1781 parts: if primary.is_empty() {
1782 Vec::new()
1783 } else {
1784 vec![Part {
1785 role: layout::RowPart::Primary,
1786 node: Node::text(primary),
1787 }]
1788 },
1789 ..Self::default()
1790 }
1791 }
1792
1793 /// Something else that can be done to this row, not shown inline.
1794 #[must_use]
1795 pub fn offers(mut self, act: Act) -> Self {
1796 self.menu.push(act);
1797 self
1798 }
1799
1800 /// How much of this row's set is done.
1801 #[must_use]
1802 pub fn meter(mut self, meter: Meter) -> Self {
1803 self.set(layout::RowPart::Proportion, Node::Meter(meter));
1804 self
1805 }
1806
1807 /// A tick that is the write, in the state it is currently in.
1808 ///
1809 /// Sets [`selected`](Self::selected) and [`toggle`](Self::toggle) together,
1810 /// because a route on a tick nothing draws is the one way the two fields can
1811 /// disagree. A checklist item is what this is for; a bulk checkbox sets
1812 /// `selected` alone and keeps its meaning as client state.
1813 #[must_use]
1814 pub fn toggling(mut self, ticked: bool, action: Action) -> Self {
1815 self.selected = Some(ticked);
1816 self.toggle = Some(action);
1817 self
1818 }
1819
1820 /// Supporting text under the primary.
1821 #[must_use]
1822 pub fn secondary(mut self, text: impl Into<Prose>) -> Self {
1823 let node = match text.into() {
1824 Prose::Text(text) => Node::text(text),
1825 Prose::Rich(source) => Node::rich(source),
1826 };
1827 self.set(layout::RowPart::Secondary, node);
1828 self
1829 }
1830
1831 /// A short trailing fact.
1832 #[must_use]
1833 pub fn meta(mut self, text: impl Into<String>) -> Self {
1834 self.set(layout::RowPart::Meta, Node::text(text));
1835 self
1836 }
1837
1838 /// Add a token, chaining.
1839 #[must_use]
1840 pub fn token(mut self, tag: Tag) -> Self {
1841 self.parts.push(Part {
1842 role: layout::RowPart::Tokens,
1843 node: Node::Token(tag),
1844 });
1845 self
1846 }
1847
1848 /// Make the row tickable, and say whether it is ticked.
1849 ///
1850 /// A row is not selectable until something says so, which is what keeps a
1851 /// checkbox off every list in the app.
1852 ///
1853 /// Says nothing about what the tick contributes, so on a screen with a
1854 /// [`selection`](Screen::selection) it draws a box that joins no set. Reach
1855 /// for [`ticking`](Self::ticking) instead; this stays for the screens whose
1856 /// tick is the write, beside [`toggling`](Self::toggling).
1857 #[must_use]
1858 pub const fn selectable(mut self, ticked: bool) -> Self {
1859 self.selected = Some(ticked);
1860 self
1861 }
1862
1863 /// Make the row tickable under this value, and say whether it is ticked.
1864 ///
1865 /// Sets [`selected`](Self::selected) and [`value`](Self::value) together,
1866 /// which is the pair a screen's [`selection`](Screen::selection) needs.
1867 /// The two halves exist separately for [`toggling`](Self::toggling)'s
1868 /// reason — state and identity are different facts about the row — and
1869 /// this is the constructor that stops them being written apart.
1870 #[must_use]
1871 pub fn ticking(mut self, value: impl Into<String>, ticked: bool) -> Self {
1872 self.selected = Some(ticked);
1873 self.value = Some(value.into());
1874 self
1875 }
1876
1877 /// The route selecting this row.
1878 #[must_use]
1879 pub fn activate(mut self, action: Action) -> Self {
1880 self.activate = Some(action);
1881 self
1882 }
1883
1884 /// A control acting on this row.
1885 #[must_use]
1886 pub fn act(mut self, act: Act) -> Self {
1887 self.parts.push(Part {
1888 role: layout::RowPart::Actions,
1889 node: Node::Act(act),
1890 });
1891 self
1892 }
1893
1894 /// Anything in this row, under the role it takes.
1895 ///
1896 /// The general form the constructors above are shorthands for, and the
1897 /// point of the model: a link in a row and a figure in a row became
1898 /// sayable at once, where each was previously a
1899 /// [`layout::RowPart`] variant, a member here, a renderer arm and a
1900 /// release.
1901 ///
1902 /// Appends rather than replacing, so a row can hold two of a role. The
1903 /// named constructors keep the single-valued roles single-valued, which is
1904 /// what their call sites already meant.
1905 ///
1906 /// # Panics
1907 ///
1908 /// If the node is not a leaf. A row is an inline run, so what goes in it
1909 /// has to be drawable on one wrapped line without the renderer knowing what
1910 /// it is -- the constrained-consumer bound, biting at a call site rather
1911 /// than in a doc comment. Same assertion as [`Cell::part`].
1912 #[must_use]
1913 pub fn part(mut self, role: layout::RowPart, node: Node) -> Self {
1914 assert!(
1915 node.containment() == Containment::Text,
1916 "a row is an inline run and holds leaves; {node:?} holds {:?}",
1917 node.containment()
1918 );
1919 self.parts.push(Part { role, node });
1920 self
1921 }
1922
1923 /// Set the one part taking a role, replacing it if it is already there.
1924 ///
1925 /// For the roles that are single-valued at every call site that has ever
1926 /// existed: the primary, the supporting line, the trailing fact, the bar.
1927 /// Building a row that calls `.meta` twice meant the second one won when
1928 /// `meta` was an `Option`, and it still does.
1929 fn set(&mut self, role: layout::RowPart, node: Node) {
1930 match self.parts.iter_mut().find(|part| part.role == role) {
1931 Some(part) => part.node = node,
1932 None => self.parts.push(Part { role, node }),
1933 }
1934 }
1935
1936 /// The parts taking one role, in order.
1937 pub fn role(&self, role: layout::RowPart) -> impl Iterator<Item = &Node> {
1938 self.parts
1939 .iter()
1940 .filter(move |part| part.role == role)
1941 .map(|part| &part.node)
1942 }
1943
1944 /// The row's primary text.
1945 ///
1946 /// What every consumer of the old `primary` member wanted. A row built the
1947 /// ordinary way has one primary part and answers its string; one that was
1948 /// given two answers both, joined, in order.
1949 #[must_use]
1950 pub fn primary(&self) -> String {
1951 self.role(layout::RowPart::Primary)
1952 .filter_map(|node| match node {
1953 Node::Text { text, .. } | Node::Link { text, .. } => Some(text.as_str()),
1954 _ => None,
1955 })
1956 .collect::<Vec<_>>()
1957 .join(" ")
1958 }
1959
1960 /// The controls the row shows.
1961 ///
1962 /// The same service [`primary`](Self::primary) does, for the member the run
1963 /// replaced. `actions` was a `Vec<Act>` before the run, and every consumer
1964 /// that read it now writes the same three lines: filter the run by role,
1965 /// match the one node kind that can be there, and collect. goingson wrote
1966 /// them twice in one file the day the member went away.
1967 ///
1968 /// Not what the row *offers*: that is [`menu`](Self::menu), which is held
1969 /// back until the host asks for it and is not on the line.
1970 pub fn acts(&self) -> impl Iterator<Item = &Act> {
1971 self.role(layout::RowPart::Actions)
1972 .filter_map(|node| match node {
1973 Node::Act(act) => Some(act),
1974 _ => None,
1975 })
1976 }
1977
1978 /// The tags the row shows.
1979 ///
1980 /// [`acts`](Self::acts)' counterpart, for the same reason.
1981 pub fn tokens(&self) -> impl Iterator<Item = &Tag> {
1982 self.role(layout::RowPart::Tokens)
1983 .filter_map(|node| match node {
1984 Node::Token(tag) => Some(tag),
1985 _ => None,
1986 })
1987 }
1988 }
1989
1990 /// One cell of a table row.
1991 ///
1992 /// `022f0c59`, decided 2026-08-10. A cell was a `String` until then, so a table
1993 /// whose rows carry a control could not be described at all and had to become a
1994 /// [`Node::List`], losing its column headers — which is what the MNW server's
1995 /// SSH-keys tab did, and why it read worse than the Askama original it replaced.
1996 ///
1997 /// # Why the acts sit on the cell and not on the row
1998 ///
1999 /// Counted across MNW's templates, 30 table rows carry a control. 25 put it
2000 /// alone in the last cell, which a row-level `actions` list would have covered.
2001 /// The other five put it *beside a value*: `project_content`'s position cell is
2002 /// the number plus two reorder arrows, `project_synckit`'s slug cell is the slug
2003 /// plus "Set slug", `promo_codes_list`'s use count is a number that is itself
2004 /// the button opening the redemptions. A row-level list renders as an appended
2005 /// cell and cannot say any of those, and neither can an actions *column*, since
2006 /// a column is a column. The control belongs where it actually is.
2007 ///
2008 /// An empty [`value`](Self::value) with acts is the common case, and
2009 /// [`Cell::acts`] is the constructor for it. That is the trailing actions cell
2010 /// the markup already writes an empty `<th>` for.
2011 ///
2012 /// A `Vec<Act>` and not a node: the 2026-08-08 ruling that a row holds no nodes
2013 /// holds here for the same reason. Acts carry their own tone, state and
2014 /// confirmation, and that is the whole of what these cells hold.
2015 #[derive(Debug, Clone, PartialEq, Eq, Default)]
2016 pub struct Cell {
2017 /// What is in it, in order.
2018 ///
2019 /// An inline run: every part is a leaf, so the whole cell is drawable on
2020 /// one wrapped line without a renderer knowing what is in it. That is the
2021 /// bound, and [`Cell::part`] is where it is enforced.
2022 ///
2023 /// This was four members -- `value`, `tokens`, `actions`, `activate` --
2024 /// added one release at a time as each pairing was argued for on counted
2025 /// sites. `022f0c59` added two of them at once. That trajectory is what
2026 /// decided the containment model: a meter in a cell and a figure in a cell
2027 /// were simply not sayable, and each would have been a fifth and sixth
2028 /// member. Under the run they are already sayable and cost nothing.
2029 ///
2030 /// The constructors that named the old members are still here and still
2031 /// mean what they meant, so no call site moved: [`Cell::new`],
2032 /// [`tag`](Cell::tag), [`token`](Cell::token), [`acts`](Cell::acts),
2033 /// [`act`](Cell::act) and [`activate`](Cell::activate) build the run.
2034 pub parts: Vec<Node>,
2035 }
2036
2037 impl Cell {
2038 /// A cell holding text.
2039 ///
2040 /// An empty string is an empty run rather than a run holding an empty
2041 /// string, so an actions-only cell built through [`acts`](Self::acts) and
2042 /// one built as `Cell::new("").act(..)` are the same value.
2043 pub fn new(value: impl Into<String>) -> Self {
2044 let value = value.into();
2045 Self {
2046 parts: if value.is_empty() {
2047 Vec::new()
2048 } else {
2049 vec![Node::text(value)]
2050 },
2051 }
2052 }
2053
2054 /// A cell holding one tag and no text.
2055 ///
2056 /// What a status column is: the cell is the badge. `Cell::new("")` with a
2057 /// token would say the same thing and reads as an oversight.
2058 pub fn tag(tag: Tag) -> Self {
2059 Self {
2060 parts: vec![Node::Token(tag)],
2061 }
2062 }
2063
2064 /// A tag in this cell, chaining.
2065 #[must_use]
2066 pub fn token(mut self, tag: Tag) -> Self {
2067 self.parts.push(Node::Token(tag));
2068 self
2069 }
2070
2071 /// A cell holding controls and no text.
2072 pub fn acts(actions: impl IntoIterator<Item = Act>) -> Self {
2073 Self {
2074 parts: actions.into_iter().map(Node::Act).collect(),
2075 }
2076 }
2077
2078 /// A control in this cell, chaining.
2079 #[must_use]
2080 pub fn act(mut self, act: Act) -> Self {
2081 self.parts.push(Node::Act(act));
2082 self
2083 }
2084
2085 /// Where this cell's value goes.
2086 ///
2087 /// The value becomes the link. A cell with no value and an `activate` is a
2088 /// link with nothing to press, so give it text.
2089 ///
2090 /// Under the run this rewrites the leading text into a [`Node::Link`]
2091 /// rather than setting a member beside it, which is the same fact said once
2092 /// instead of as a pair of fields that could disagree. A cell with no text
2093 /// to link gains nothing, because a link with no label is a control nothing
2094 /// draws.
2095 #[must_use]
2096 pub fn activate(mut self, action: Action) -> Self {
2097 if let Some(first) = self
2098 .parts
2099 .iter_mut()
2100 .find(|part| matches!(part, Node::Text { .. }))
2101 && let Node::Text { text, .. } = first
2102 {
2103 *first = Node::Link {
2104 text: std::mem::take(text),
2105 action,
2106 };
2107 }
2108 self
2109 }
2110
2111 /// Anything in this cell, chaining.
2112 ///
2113 /// The general form the five constructors above are shorthands for, and the
2114 /// whole point of the model: a meter in a cell, a figure in a cell and a
2115 /// second linked value in a cell all became sayable at once, where each was
2116 /// previously a member, three renderer arms and a release.
2117 ///
2118 /// # Panics
2119 ///
2120 /// If the node is not a leaf. A cell is an inline run, so what goes in it
2121 /// has to be drawable on one wrapped line without the renderer knowing what
2122 /// it is -- that is the constrained-consumer bound, and this is where it
2123 /// bites at a call site rather than in a doc comment.
2124 #[must_use]
2125 pub fn part(mut self, node: Node) -> Self {
2126 assert!(
2127 node.containment() == Containment::Text,
2128 "a cell is an inline run and holds leaves; {node:?} holds \
2129 {:?}",
2130 node.containment()
2131 );
2132 self.parts.push(node);
2133 self
2134 }
2135
2136 /// The cell's text, with the parts that are not text left out.
2137 ///
2138 /// What every consumer of the old `value` member wanted. A cell that is one
2139 /// string answers that string; one that mixes answers the text between its
2140 /// tags and controls, in order.
2141 #[must_use]
2142 pub fn text(&self) -> String {
2143 self.parts
2144 .iter()
2145 .filter_map(|part| match part {
2146 Node::Text { text, .. } | Node::Link { text, .. } => Some(text.as_str()),
2147 _ => None,
2148 })
2149 .collect::<Vec<_>>()
2150 .join(" ")
2151 }
2152
2153 /// Whether anything in this cell answers a click.
2154 ///
2155 /// A badge is not one: it says something and answers nothing, which is why
2156 /// this asks the tag rather than counting tags.
2157 #[must_use]
2158 pub fn carries_control(&self) -> bool {
2159 self.parts.iter().any(|part| match part {
2160 Node::Act(_) | Node::Link { .. } => true,
2161 Node::Token(tag) => tag.kind.interactive() && tag.action.is_some(),
2162 _ => false,
2163 })
2164 }
2165 }
2166
2167 impl From<String> for Cell {
2168 fn from(value: String) -> Self {
2169 Self::new(value)
2170 }
2171 }
2172
2173 impl From<&str> for Cell {
2174 fn from(value: &str) -> Self {
2175 Self::new(value)
2176 }
2177 }
2178
2179 /// One row of a table.
2180 ///
2181 /// Cells are positional against the table's columns, and the table is the only
2182 /// place that pairing is made. A renderer narrowing the table drops columns by
2183 /// [`layout::Priority`] and drops the cells at the same indices, which is why
2184 /// the two live in one node rather than one per row.
2185 #[derive(Debug, Clone, PartialEq, Eq, Default)]
2186 pub struct Cells {
2187 /// One entry per column, in the table's column order.
2188 pub values: Vec<Cell>,
2189 /// The route that opens this row.
2190 pub activate: Option<Action>,
2191 /// Whether this is the row currently being shown elsewhere.
2192 ///
2193 /// The same fact [`Row::current`] carries, under the same name. It was
2194 /// `selected` until 2026-08-08, which is the word that decision retired:
2195 /// the app's pointer and the user's tick are two things, and one word for
2196 /// both is how every renderer ends up guessing which was meant. `Row` was
2197 /// renamed and this was missed, so it kept the ambiguous word while
2198 /// emitting `aria-current` from it.
2199 ///
2200 /// There is deliberately no tick here to go with it. `Row` grew one because
2201 /// goingson's contact cards have a bulk checkbox; no table asks for one, and
2202 /// a member added because its sibling has it is a member with no consumer to
2203 /// tell us what it should mean.
2204 pub current: bool,
2205 }
2206
2207 impl Cells {
2208 /// A row of cells in column order.
2209 ///
2210 /// Takes anything that becomes a [`Cell`], so a row of plain text is still
2211 /// `Cells::new(["kick.wav", "2.1 MB"])` and a row with a control mixes the
2212 /// two: `Cells::new([Cell::new(name), Cell::acts([remove])])`.
2213 pub fn new(values: impl IntoIterator<Item = impl Into<Cell>>) -> Self {
2214 Self {
2215 values: values.into_iter().map(Into::into).collect(),
2216 activate: None,
2217 current: false,
2218 }
2219 }
2220
2221 /// The route that opens this row.
2222 #[must_use]
2223 pub fn activate(mut self, action: Action) -> Self {
2224 self.activate = Some(action);
2225 self
2226 }
2227 }
2228
2229 /// A thing on a screen.
2230 ///
2231 /// Every member composes something `makeover-layout` already names, and that is
2232 /// the admission test for a new one. A node with no counterpart there means the
2233 /// vocabulary is missing a word, and the fix is to add the word rather than to
2234 /// add a widget here.
2235 #[derive(Debug, Clone, PartialEq, Eq)]
2236 pub enum Node {
2237 /// A title, at one of three depths in the heading tree.
2238 Heading {
2239 /// How far down the tree it sits.
2240 level: layout::Heading,
2241 /// The text.
2242 text: String,
2243 },
2244 /// Prose, with a tone.
2245 Text {
2246 /// The text.
2247 text: String,
2248 /// What it is saying. [`layout::Tone::Neutral`] is ordinary content.
2249 tone: layout::Tone,
2250 },
2251 /// Prose the author wrote in markdown.
2252 ///
2253 /// `25822137`, decided 2026-08-09. What is carried is the **source**, never
2254 /// markup, which is the property that lets this exist at all. Every renderer
2255 /// has an honest answer because each renders the source its own way: a
2256 /// webview through a markdown-to-HTML pass, a terminal through
2257 /// markdown-to-ANSI, egui through its own. A `Node::Html` would have handed
2258 /// every one of them a string it could not honour, and would have broken
2259 /// [`Node::Text`]'s escaping guarantee for every consumer rather than the
2260 /// one that asked. That refusal stands; this is not it.
2261 ///
2262 /// Sanitising is the renderer's, at the point markup is produced, for the
2263 /// reason escaping already is: this holds text a user typed, and a
2264 /// description that sanitised would be deciding what a host can draw.
2265 ///
2266 /// Not available inside a [`Row`]: a row part holds no node, by the
2267 /// 2026-08-08 ruling. What a row can hold is [`Prose`], which carries the
2268 /// same markdown source under the same reasoning without being a node, so
2269 /// the projects card no longer keeps its raw markdown in `secondary`.
2270 Rich {
2271 /// The markdown, as written.
2272 source: String,
2273 },
2274 /// A control that calls a route.
2275 Act(Act),
2276 /// Text that goes somewhere.
2277 ///
2278 /// The containment model predicted this before anything asked for it: the
2279 /// composability matrix had a `Link` row whose "as a `Node`" cell was a
2280 /// dash, and the only way to say it was [`Cell::activate`], a member on one
2281 /// container. Once a cell is a run of leaves, the run needs a leaf that
2282 /// means "this text is a link" or the thing stops being sayable at all.
2283 ///
2284 /// Distinct from [`Act`](Self::Act), and the difference is what the reader
2285 /// sees rather than what the route does. An act is a control drawn as one,
2286 /// which is right for `Edit` and wrong for a title: making every linked
2287 /// value a button would put a row of bevels down the first column of half a
2288 /// dashboard. Both call a route; only one of them looks like a button.
2289 Link {
2290 /// What it says.
2291 text: String,
2292 /// Where it goes.
2293 action: Action,
2294 },
2295 /// One figure, on a line.
2296 ///
2297 /// The second thing the containment model found, and it found it by
2298 /// refusing: a cell is a run of leaves, [`Stats`](Self::Stats) is a
2299 /// collection, so putting a figure in a cell failed the bound rather than
2300 /// quietly working. That is the check doing its job -- the matrix had
2301 /// "figure in a cell" as a dash nobody had attempted, and the dash turns
2302 /// out to have been hiding a missing member rather than a missing renderer
2303 /// arm.
2304 ///
2305 /// Not a duplicate of a one-element [`Stats`](Self::Stats), and the
2306 /// difference is the claim being made. A strip says "this is a row of
2307 /// tiles", which is why the set is the node there: a renderer handed one
2308 /// tile at a time cannot tell it is looking at a set. This says "this
2309 /// number sits on this line", where the run is already the grouping and
2310 /// there is nothing for a set to add. A dashboard strip of one is still a
2311 /// strip; a revenue column is not.
2312 Figure(Figure),
2313 /// A picture, at a source this crate holds and the description does not.
2314 ///
2315 /// The [`Act`](Self::Act) split, and `layout::Image`'s own docs carry the
2316 /// argument: an address is not the description's to hold, so the shape and
2317 /// the alt text live there and the URL lives here.
2318 ///
2319 /// A leaf, so it may sit in a run the way [`Link`](Self::Link) does. What
2320 /// it may *not* do is stand in for a region: a picture is one thing on the
2321 /// page, and a gallery of them is a widget assembled out of several.
2322 Image(Picture),
2323 /// A small labelled thing sitting inside something else.
2324 Token(Tag),
2325 /// Something the app is telling the user, unprompted.
2326 Notice {
2327 /// Transient and stacked, or persistent and in flow.
2328 kind: layout::Notice,
2329 /// What it is saying.
2330 tone: layout::Tone,
2331 /// The message.
2332 text: String,
2333 },
2334 /// What stands where content would be, when there is none.
2335 ///
2336 /// `703f4cd2`. goingson draws one at 27 sites across 12 files and Balanced
2337 /// Breakfast at 9, and the class families had already drifted into
2338 /// `empty-state--error` against `error-state` for the same fact. Every one
2339 /// of those sites substitutes markup where a list would go, which is what
2340 /// makes this a node.
2341 ///
2342 /// # Why not on the region
2343 ///
2344 /// It was on [`Slot`] first, and a real screen killed it: the project
2345 /// dashboard's columns are a heading and a list, and a column with no rows
2346 /// is a region that has content and a list that has none. Marking the
2347 /// region empty took the heading down with the rows. The emptiness belongs
2348 /// to the thing that is empty.
2349 ///
2350 /// [`Slot::readiness`] keeps the loading axis and only that, which is what
2351 /// `aria-busy` is about.
2352 ///
2353 /// # The state is the vocabulary's and the sentence is not
2354 ///
2355 /// `makeover-layout` names the four states because "nothing here yet" and
2356 /// "this broke" mean the same thing in every app that will have them. "No
2357 /// projects yet" is content, and so is the button under it, so both are
2358 /// here. A [`layout::Readiness::Ready`] renders nothing at all: the state
2359 /// that shows content has no stand-in to draw.
2360 StandIn {
2361 /// Which of the states this is standing in for.
2362 state: layout::Readiness,
2363 /// The sentence. "No projects yet", "Failed to load events".
2364 message: String,
2365 /// The way out, if there is one. "Add your first project", "Try again".
2366 ///
2367 /// 2 of goingson's 27 have one and 25 say a sentence and stop, which is
2368 /// why it is optional rather than a second required string.
2369 act: Option<Act>,
2370 },
2371 /// One control, standing on its own.
2372 ///
2373 /// `14612ed8`. A [`Form`](Self::Form) is a set of questions asked together
2374 /// and answered at once. A settings screen is not that: goingson's is
2375 /// sections with headings between them, each holding one control that writes
2376 /// as soon as it changes, and wrapping those in a form would describe markup
2377 /// that is not there and a submit that does not exist.
2378 ///
2379 /// Almost always carries a [`Field::changes`], because a control with no
2380 /// form around it and no route on it collects a value nothing reads.
2381 ///
2382 /// Boxed because it is the only member holding a whole struct by value, and
2383 /// [`Field`] is the largest one here — every other member holds a `Vec`, a
2384 /// `String` or a small enum. Unboxed it decides the size of every [`Node`]
2385 /// in every list, and of the [`Response`](crate::Response) that carries one.
2386 Field(Box<Field>),
2387 /// Fields, and the route that submits them.
2388 Form {
2389 /// Where the answers go. Almost always a [`Method::Post`].
2390 action: Action,
2391 /// What the submit control is called.
2392 submit: String,
2393 /// The questions, in order.
2394 fields: Vec<Field>,
2395 },
2396 /// Rows of the same kind of thing.
2397 List {
2398 /// The rows, in order.
2399 rows: Vec<Row>,
2400 /// What is not shown, if anything is.
2401 ///
2402 /// `346567f9`. A described list of the first 50 of 400 tasks was
2403 /// indistinguishable from a described list of 50 tasks, so each app
2404 /// grew its own answer: goingson a 159-line pagination manager with two
2405 /// consumers that had each written it separately first, Balanced
2406 /// Breakfast four `loadMore` sites. Two idioms for one fact, and the
2407 /// fact is what belongs here — how much more there is and how to ask
2408 /// for it. Whether that becomes numbered pages, a load-more button or
2409 /// an infinite scroll is the renderer's.
2410 more: Option<Rest>,
2411 },
2412 /// Rows with named columns.
2413 Table {
2414 /// The columns, in order. Cells are positional against these.
2415 columns: Vec<Column>,
2416 /// The rows, in order.
2417 rows: Vec<Cells>,
2418 },
2419 /// Rows placed by when they happen, rather than in order.
2420 ///
2421 /// The third of the three ways this vocabulary says "several of the same
2422 /// kind of thing", and the last one to arrive.
2423 /// [`List`](Self::List) puts them in order, [`Table`](Self::Table) lines
2424 /// their parts up in columns, and this one puts them on a clock.
2425 ///
2426 /// A row here is an ordinary [`Row`] and gets no new members: the item
2427 /// bodies on goingson's day view are a title, a time, a tag and a tone,
2428 /// which the vocabulary already said. What it could not say is *where the
2429 /// row sits*, and that is [`layout::Placement`] — a start and a duration,
2430 /// two integers, which is the whole of what the timeline refusal was
2431 /// pricing as a component library. See `makeover-layout` 0.24.0.
2432 ///
2433 /// # What a renderer owes it
2434 ///
2435 /// Draw the span, put each row at its placement, and lay overlapping rows
2436 /// so both can be read. That last part is presentation and deliberately
2437 /// unspecified: a webview puts them in columns, a terminal may stack them
2438 /// with a marker, and neither is wrong.
2439 /// [`layout::Placement::overlaps`] is how a renderer finds the pairs
2440 /// without the description declaring them.
2441 ///
2442 /// # What it is not
2443 ///
2444 /// Not a calendar and not a kanban board. Both were refused alongside the
2445 /// timeline and neither has been measured; whoever needs one counts the
2446 /// members it is missing rather than reaching for this.
2447 Timeline {
2448 /// The axis: its window, its granularity, how often it labels itself.
2449 track: layout::Track,
2450 /// What sits on it, each with where it sits.
2451 ///
2452 /// Not sorted here, and a renderer must not assume it is. Sorting by
2453 /// start is presentation for anything that draws top to bottom, and
2454 /// meaningless for anything that does not.
2455 entries: Vec<Placed>,
2456 /// A moment worth bringing into view, if any.
2457 ///
2458 /// "Show me 09:00" rather than a scroll offset in pixels. goingson's JS
2459 /// hardcodes `targetHour = 9` inside the renderer, which is the shape
2460 /// this replaces: the app knows the interesting hour, the renderer
2461 /// knows how to get there.
2462 ///
2463 /// `None` means the renderer chooses, which is usually the span's
2464 /// start.
2465 focus: Option<u16>,
2466 },
2467 /// A control that picks between things.
2468 Select {
2469 /// Segmented, toggle, or tabs.
2470 kind: layout::Selector,
2471 /// What is on offer, and what each one calls if it calls something of
2472 /// its own.
2473 ///
2474 /// The tuple is [`Stats`](Self::Stats)' shape and it is here for the
2475 /// same reason, stated there: `makeover-layout` cannot name an action
2476 /// at all, so an address rides beside the described thing rather than
2477 /// inside it. [`Choice::as_layout`] hands back a value and a label and
2478 /// nothing else.
2479 ///
2480 /// It is what a tab strip needs. The MNW server's dashboard-user shell
2481 /// has fifteen tabs and fifteen routes; one strip-level action with the
2482 /// value substituted in cannot address them, and building the route by
2483 /// convention would put route construction in a renderer.
2484 ///
2485 /// An option carrying `None` falls back to
2486 /// [`action`](Self::Select::action) with its value under
2487 /// [`Self::SELECTED`], which is what every option did before the tuple,
2488 /// so a segmented control and a toggle are unchanged in meaning.
2489 options: Vec<(Choice, Option<Action>)>,
2490 /// Which option is currently picked, by its
2491 /// [`value`](Choice::value).
2492 chosen: Option<String>,
2493 /// What picking an option calls, for the options that name nothing
2494 /// themselves. The picked value is sent under [`Self::SELECTED`].
2495 action: Option<Action>,
2496 },
2497 /// How much of a set is done.
2498 Meter(Meter),
2499 /// A value with a caption, several of them as one strip.
2500 ///
2501 /// `93c6a174`. Against `makeover-layout`'s [`layout::Figure`], which arrived
2502 /// at 0.11.0 for this. The dashboard shape: a large value over a small
2503 /// caption, several in a row. goingson had five of them across five screens
2504 /// with five class vocabularies for the one shape, and the port had been
2505 /// making each out of a [`Row`] with the caption as `primary` and the figure
2506 /// as `meta`, which reads backwards — a row's primary slot means the thing
2507 /// itself, and here the thing is the number.
2508 ///
2509 /// # Why the set is the node and not each figure
2510 ///
2511 /// Four tiles in a strip and four tiles down a column are different things,
2512 /// and a renderer handed one at a time cannot tell it is looking at a set.
2513 /// The objection to that is real and is answered by what is already here: a
2514 /// node whose value is its grouping sounds like a layout instruction, and
2515 /// [`List`](Self::List) and [`Table`](Self::Table) have been exactly that
2516 /// since the beginning without anyone calling them one.
2517 ///
2518 /// # Why the action is here and not on the figure
2519 ///
2520 /// One of goingson's five is a control — sync's "Not Applied: 3" opens the
2521 /// list. `makeover-layout` cannot name an action at all, so the figure it
2522 /// describes carries none, and this pairs the description with the address
2523 /// the same way [`Row`] pairs its parts with [`Row::activate`].
2524 Stats {
2525 /// The figures, in order, and what each one calls if it calls anything.
2526 figures: Vec<(Figure, Option<Action>)>,
2527 },
2528 /// A region inside a region.
2529 Region(Slot),
2530 }
2531
2532 impl Node {
2533 /// The parameter name a [`Node::Select`] sends its picked value under.
2534 ///
2535 /// Named once here rather than agreed by convention between each renderer
2536 /// and each handler, which is how a value arrives under `tab` in one screen
2537 /// and `selected` in the next.
2538 pub const SELECTED: &'static str = "value";
2539
2540 /// The parameter name an [`Act::over`] sends each ticked value under.
2541 ///
2542 /// [`SELECTED`](Self::SELECTED)'s sibling, named here for the same reason:
2543 /// a convention agreed separately by each renderer and each handler is a
2544 /// convention that holds until one of them is written by someone else.
2545 ///
2546 /// Distinct from `SELECTED` rather than shared with it, because the two
2547 /// carry different counts. A [`Select`](Self::Select) sends one value and a
2548 /// handler reads it with [`Params::get`]; a selection sends however many
2549 /// are ticked, including none, and a handler reads it with
2550 /// [`Params::get_all`]. One name for both would make "the one thing picked"
2551 /// and "the first of the things ticked" the same read.
2552 ///
2553 /// [`Params::get`]: crate::Params::get
2554 /// [`Params::get_all`]: crate::Params::get_all
2555 pub const TICKED: &'static str = "ticked";
2556
2557 /// A page title.
2558 pub fn page(text: impl Into<String>) -> Self {
2559 Self::Heading {
2560 level: layout::Heading::Page,
2561 text: text.into(),
2562 }
2563 }
2564
2565 /// A section title.
2566 pub fn section(text: impl Into<String>) -> Self {
2567 Self::Heading {
2568 level: layout::Heading::Section,
2569 text: text.into(),
2570 }
2571 }
2572
2573 /// Ordinary prose.
2574 pub fn text(text: impl Into<String>) -> Self {
2575 Self::Text {
2576 text: text.into(),
2577 tone: layout::Tone::Neutral,
2578 }
2579 }
2580
2581 /// Prose written in markdown.
2582 pub fn rich(source: impl Into<String>) -> Self {
2583 Self::Rich {
2584 source: source.into(),
2585 }
2586 }
2587
2588 /// A control calling a route.
2589 pub fn act(label: impl Into<String>, action: Action) -> Self {
2590 Self::Act(Act::new(label, action))
2591 }
2592
2593 /// A persistent message, dismissed by fixing what caused it.
2594 pub fn banner(tone: layout::Tone, text: impl Into<String>) -> Self {
2595 Self::Notice {
2596 kind: layout::Notice::Banner,
2597 tone,
2598 text: text.into(),
2599 }
2600 }
2601
2602 /// A transient message that dismisses itself.
2603 pub fn toast(tone: layout::Tone, text: impl Into<String>) -> Self {
2604 Self::Notice {
2605 kind: layout::Notice::Toast,
2606 tone,
2607 text: text.into(),
2608 }
2609 }
2610
2611 /// A list of rows.
2612 pub fn list(rows: impl IntoIterator<Item = Row>) -> Self {
2613 Self::List {
2614 rows: rows.into_iter().collect(),
2615 more: None,
2616 }
2617 }
2618
2619 /// The same list, saying there is more of it.
2620 ///
2621 /// A no-op on anything that is not a [`Self::List`], which is the one place
2622 /// this file allows that: the alternative is a constructor taking rows and a
2623 /// `Rest` together, and every call site that has no more rows then passes a
2624 /// `None` to say so.
2625 #[must_use]
2626 pub fn and_more(mut self, rest: Rest) -> Self {
2627 if let Self::List { more, .. } = &mut self {
2628 *more = Some(rest);
2629 }
2630 self
2631 }
2632
2633 /// A proportion of a set, untoned and unlabelled.
2634 #[must_use]
2635 pub const fn meter(done: u32, total: u32) -> Self {
2636 Self::Meter(Meter::new(done, total))
2637 }
2638
2639 /// Nothing here yet.
2640 pub fn empty(message: impl Into<String>) -> Self {
2641 Self::StandIn {
2642 state: layout::Readiness::Empty,
2643 message: message.into(),
2644 act: None,
2645 }
2646 }
2647
2648 /// This did not load.
2649 pub fn failed(message: impl Into<String>) -> Self {
2650 Self::StandIn {
2651 state: layout::Readiness::Failed,
2652 message: message.into(),
2653 act: None,
2654 }
2655 }
2656
2657 /// The same stand-in, with a way out of it.
2658 ///
2659 /// A no-op on anything else, for the reason [`Self::and_more`] is one.
2660 #[must_use]
2661 pub fn offering(mut self, way_out: Act) -> Self {
2662 if let Self::StandIn { act, .. } = &mut self {
2663 *act = Some(way_out);
2664 }
2665 self
2666 }
2667
2668 /// One control on its own, outside any form.
2669 pub fn field(field: Field) -> Self {
2670 Self::Field(Box::new(field))
2671 }
2672
2673 /// A strip of figures, none of which answers a click.
2674 pub fn stats(figures: impl IntoIterator<Item = Figure>) -> Self {
2675 Self::Stats {
2676 figures: figures.into_iter().map(|figure| (figure, None)).collect(),
2677 }
2678 }
2679 }
2680
2681 /// A whole screen.
2682 ///
2683 /// [`Arrangement`](layout::Arrangement) is `makeover-layout`'s, and there are
2684 /// two of them because our apps have two: goingson is list-detail, Balanced
2685 /// Breakfast is sidebar plus content. Naming a third before an app has one is
2686 /// how a description becomes a framework.
2687 #[derive(Debug, Clone, PartialEq, Eq)]
2688 pub struct Screen {
2689 /// What the screen is called. A window title, a tab title, a page heading.
2690 pub title: String,
2691 /// How the regions are laid out.
2692 pub arrangement: layout::Arrangement,
2693 /// The regions, in order.
2694 pub slots: Vec<Slot>,
2695 /// Messages raised by whatever produced this screen.
2696 ///
2697 /// Separate from the slots because a notice belongs to the screen rather
2698 /// than to a place in it: which region a toast stacks in is the renderer's
2699 /// question, and a handler answering it would be describing a webview.
2700 pub notices: Vec<Node>,
2701 /// How this screen is found, shared and indexed.
2702 ///
2703 /// Not an `Option`. The default is meaningful — a screen nobody said
2704 /// anything about is an indexable website — and an `Option` would make
2705 /// "nobody said" and "indexable" two spellings of one thing.
2706 pub discovery: Discovery,
2707 /// The name of the set this screen's ticks go into, if it holds one.
2708 ///
2709 /// `5f2b8753`. [`Row::selected`] said a row could be ticked and nothing
2710 /// said what the tick was *for*, so the tick had nowhere to go: a webview
2711 /// hid the hole because the browser owns a checkbox's checked state, and
2712 /// every app then wrote its own JS to gather the boxes back up. A terminal
2713 /// could not hide it. It drew the `[ ]`, bound the key, and the key did
2714 /// nothing, which is worse than not drawing the box.
2715 ///
2716 /// So the screen names the set, each [`Row::value`] is what that row's tick
2717 /// contributes, and [`Act::over`] is how a control says it acts on the
2718 /// whole of it. The renderer holds the set the way `quasi-tui` already
2719 /// holds an edit buffer and a scroll offset, and the commit control reads
2720 /// it by name.
2721 ///
2722 /// # Ticking never writes
2723 ///
2724 /// Wiki `explicit-commit-affordance`, the general rule: a change that
2725 /// happens with no obvious indication is confusing, so a tick stages and
2726 /// the commit control is what locks it in. [`Row::toggle`] describes the
2727 /// other thing — screens where the tick *is* the write — and is left alone
2728 /// here rather than removed, because stopping those screens is work in the
2729 /// apps that have them.
2730 ///
2731 /// # One set per screen
2732 ///
2733 /// A screen with two independent sets has not been measured. Naming one is
2734 /// the smallest thing that closes the hole, and the field grows to a map
2735 /// when an app turns up wanting two, on the same rule every other member
2736 /// here arrived under.
2737 ///
2738 /// [`Row::selected`]: Row::selected
2739 /// [`Row::value`]: Row::value
2740 /// [`Act::over`]: Act::over
2741 pub selection: Option<String>,
2742 /// How wide this screen's content runs.
2743 ///
2744 /// `0eccff0d`. Measured in the MNW server, where 69 of 72 templates carry
2745 /// one of three mutually exclusive CSS classes for it and nothing described
2746 /// it, so the choice lived in the template rather than in the screen.
2747 ///
2748 /// Beside [`arrangement`](Self::arrangement) and answering the level above
2749 /// it: that one divides the screen's width between regions, this says how
2750 /// much of the window the screen takes in the first place. Both are the
2751 /// description's, which is what answering `e0fd485e` and `0eccff0d`
2752 /// together settled.
2753 ///
2754 /// Not an `Option`, for [`discovery`](Self::discovery)'s reason. The
2755 /// default is meaningful -- a screen nobody said anything about uses the
2756 /// window it was given -- and an `Option` would make "nobody said" and
2757 /// "the whole width" two spellings of one thing.
2758 pub measure: layout::Measure,
2759 }
2760
2761 /// How a screen is found, shared and indexed.
2762 ///
2763 /// Not presentation, which is why it is here and not in `makeover-layout`: a
2764 /// terminal ignores every field, the same way it ignores [`Slot::id`]. It is an
2765 /// address-and-identity fact, and that is the line that put [`Action`] in this
2766 /// crate rather than in the vocabulary.
2767 ///
2768 /// Measured before it was added. Every `og:*` value in the MNW server's 37
2769 /// templates is one of four things interpolated from the entity the screen is
2770 /// about: a title, a summary sentence, an image URL, or the screen's own
2771 /// address. None of them needed knowledge only a handler has, which is what
2772 /// made this the screen's to say rather than the host's.
2773 #[derive(Debug, Clone, PartialEq, Eq)]
2774 pub struct Discovery {
2775 /// Whether a crawler should index this screen.
2776 ///
2777 /// Defaults to indexable, because most screens are and a default that hides
2778 /// pages is a default that hides the bug. The six screens saying otherwise
2779 /// are purchased-content pages, and this field is why that is a fact the
2780 /// type carries rather than a line in a template that a conversion can drop
2781 /// in silence.
2782 pub indexable: bool,
2783 /// The sentence a link preview shows. [`Screen::title`] is the title.
2784 pub summary: Option<String>,
2785 /// The image a link preview shows, as an absolute URL.
2786 pub image: Option<String>,
2787 /// What kind of thing this screen is about.
2788 pub kind: SocialKind,
2789 /// The canonical address, when the screen answers at more than one.
2790 pub canonical: Option<String>,
2791 }
2792
2793 impl Default for Discovery {
2794 /// Indexable, and nothing else claimed.
2795 ///
2796 /// Written out rather than derived, and the reason is the one field that
2797 /// matters: `bool::default()` is `false`, so a derived impl would deindex
2798 /// every screen that never mentioned the subject, silently, and the failure
2799 /// would show up as traffic rather than as a test.
2800 fn default() -> Self {
2801 Self {
2802 indexable: true,
2803 summary: None,
2804 image: None,
2805 kind: SocialKind::Website,
2806 canonical: None,
2807 }
2808 }
2809 }
2810
2811 /// What kind of thing a screen is about.
2812 ///
2813 /// The six the server actually emits, and no more. Naming a seventh before a
2814 /// screen has one is how a description becomes a framework, which is the
2815 /// argument [`Arrangement`](layout::Arrangement) is held to two screens by.
2816 ///
2817 /// `#[non_exhaustive]`, because a seventh arriving should not be a lockstep
2818 /// event across every renderer that spells one. The match below stays
2819 /// exhaustive: within this crate the attribute does not apply, and a wildcard
2820 /// here would only hide a member added without a spelling.
2821 #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
2822 #[non_exhaustive]
2823 pub enum SocialKind {
2824 /// A page. The default, and four of the server's screens.
2825 #[default]
2826 Website,
2827 /// Something written, with an author and a date.
2828 Article,
2829 /// A person or an account.
2830 Profile,
2831 /// Something for sale.
2832 Product,
2833 /// A video.
2834 Video,
2835 /// A piece of music.
2836 Song,
2837 }
2838
2839 impl SocialKind {
2840 /// What this is spelled as in `og:type`.
2841 ///
2842 /// Named here rather than agreed between each renderer and each host, which
2843 /// is how one screen ends up `video.other` and the next `video`.
2844 #[must_use]
2845 pub const fn as_str(self) -> &'static str {
2846 match self {
2847 Self::Website => "website",
2848 Self::Article => "article",
2849 Self::Profile => "profile",
2850 Self::Product => "product",
2851 Self::Video => "video.other",
2852 Self::Song => "music.song",
2853 }
2854 }
2855 }
2856
2857 impl Screen {
2858 /// An empty screen with the given arrangement.
2859 pub fn new(title: impl Into<String>, arrangement: layout::Arrangement) -> Self {
2860 Self {
2861 title: title.into(),
2862 arrangement,
2863 slots: Vec::new(),
2864 notices: Vec::new(),
2865 discovery: Discovery::default(),
2866 selection: None,
2867 measure: layout::Measure::default(),
2868 }
2869 }
2870
2871 /// How wide this screen's content runs, chaining.
2872 ///
2873 /// See [`measure`](Self::measure). [`Measure::Wide`](layout::Measure::Wide)
2874 /// is the default and does not need saying.
2875 #[must_use]
2876 pub const fn measured(mut self, measure: layout::Measure) -> Self {
2877 self.measure = measure;
2878 self
2879 }
2880
2881 /// This screen holds a set of ticks under this name, chaining.
2882 ///
2883 /// The rows that join it say so with [`Row::ticking`], and the control that
2884 /// acts on it with [`Act::over`]. See [`selection`](Self::selection).
2885 #[must_use]
2886 pub fn selecting(mut self, name: impl Into<String>) -> Self {
2887 self.selection = Some(name.into());
2888 self
2889 }
2890
2891 /// Whether a crawler should index this screen, chaining.
2892 #[must_use]
2893 pub fn indexed(mut self, indexable: bool) -> Self {
2894 self.discovery.indexable = indexable;
2895 self
2896 }
2897
2898 /// The sentence a link preview shows, chaining.
2899 #[must_use]
2900 pub fn summarised(mut self, text: impl Into<String>) -> Self {
2901 self.discovery.summary = Some(text.into());
2902 self
2903 }
2904
2905 /// The image a link preview shows, chaining. An absolute URL.
2906 #[must_use]
2907 pub fn illustrated(mut self, url: impl Into<String>) -> Self {
2908 self.discovery.image = Some(url.into());
2909 self
2910 }
2911
2912 /// What kind of thing this screen is about, chaining.
2913 #[must_use]
2914 pub fn about(mut self, kind: SocialKind) -> Self {
2915 self.discovery.kind = kind;
2916 self
2917 }
2918
2919 /// The address this screen should be known by, chaining.
2920 #[must_use]
2921 pub fn canonical_at(mut self, url: impl Into<String>) -> Self {
2922 self.discovery.canonical = Some(url.into());
2923 self
2924 }
2925
2926 /// A list that chooses what the detail beside it shows.
2927 pub fn list_detail(title: impl Into<String>, tabbed: bool) -> Self {
2928 Self::new(title, layout::Arrangement::list_detail(tabbed))
2929 }
2930
2931 /// Navigation down the side, content filling the rest.
2932 pub fn sidebar_content(title: impl Into<String>) -> Self {
2933 Self::new(title, layout::Arrangement::sidebar_content())
2934 }
2935
2936 /// Add a region, chaining.
2937 #[must_use]
2938 pub fn with(mut self, slot: Slot) -> Self {
2939 self.slots.push(slot);
2940 self
2941 }
2942
2943 /// Raise a message on this screen, chaining.
2944 ///
2945 /// # Panics
2946 ///
2947 /// If the node is not a [`Node::Notice`]. The field is typed as a [`Node`]
2948 /// so a renderer walks one kind of thing, and this is the constructor that
2949 /// keeps that from meaning anything can go in it.
2950 #[must_use]
2951 pub fn saying(mut self, notice: Node) -> Self {
2952 assert!(
2953 matches!(notice, Node::Notice { .. }),
2954 "Screen::saying takes a Node::Notice"
2955 );
2956 self.notices.push(notice);
2957 self
2958 }
2959
2960 /// The slot under this address, at any depth.
2961 #[must_use]
2962 pub fn slot(&self, id: &str) -> Option<&Slot> {
2963 self.slots.iter().find_map(|slot| slot.find(id))
2964 }
2965
2966 /// Apply a fragment: put `node` in the region under `region`, replacing
2967 /// whatever was there. Returns whether the region was found.
2968 ///
2969 /// This is what a host holding a `Screen` does with
2970 /// [`Outcome::Fragment`](crate::Outcome::Fragment). A webview host needs
2971 /// none of it -- `quasi-http` turns the same outcome into an `hx-retarget`
2972 /// header and the browser performs the swap against a document it already
2973 /// has -- but a host that retains the description rather than the markup
2974 /// has nothing between the fragment and the tree.
2975 ///
2976 /// It lives here and not in a host because applying a fragment is surgery
2977 /// on this crate's own type. A host writing it means every retained-screen
2978 /// host writes it separately and each picks its own answer for the three
2979 /// decisions below, which is the thing this crate's no-host-imports rule
2980 /// exists to prevent.
2981 ///
2982 /// **A region that is not there answers `false`, not a panic.** The caller
2983 /// is the one that can act on it: a host can fall back to a redraw, and a
2984 /// test can assert it. What is worth avoiding is the silent no-op, because
2985 /// a miss means a route naming a slot that no longer exists, and that is a
2986 /// description bug rather than a rendering one.
2987 ///
2988 /// **It replaces rather than appends.** `Outcome::Fragment` is one region's
2989 /// new contents, which is the whole reason it can be smaller than a screen.
2990 ///
2991 /// **The region becomes [`Ready`](layout::Readiness::Ready).** A fragment
2992 /// arriving is the content arriving, so a slot marked
2993 /// [`Pending`](layout::Readiness::Pending) while it was in flight stops
2994 /// being pending here. Emptiness is a different axis and rides on the node:
2995 /// a [`Node::StandIn`] carries its own state, and replacing with one is a
2996 /// region that is ready and has nothing to show.
2997 pub fn replace(&mut self, region: &str, node: Node) -> bool {
2998 let Some(slot) = self.slots.iter_mut().find_map(|slot| slot.find_mut(region)) else {
2999 return false;
3000 };
3001 slot.body.clear();
3002 slot.body.push(node);
3003 slot.readiness = layout::Readiness::Ready;
3004 true
3005 }
3006 }
3007
3008 #[cfg(test)]
3009 mod tests {
3010 use super::*;
3011
3012 fn frames(count: usize) -> Vec<Node> {
3013 (0..count)
3014 .map(|n| {
3015 Node::Image(Picture::new(
3016 format!("/frame-{n}.png"),
3017 format!("frame {n}"),
3018 ))
3019 })
3020 .collect()
3021 }
3022
3023 #[test]
3024 fn a_region_shows_everything_until_it_says_otherwise() {
3025 // The default has to be the old behaviour, or every description written
3026 // before this field existed changes meaning when it arrives.
3027 let pane = Slot::new("content", RegionKind::Pane).extend(frames(3));
3028
3029 assert_eq!(pane.showing, layout::Showing::All);
3030 assert_eq!(pane.current(), None);
3031 }
3032
3033 #[test]
3034 fn a_carousel_with_no_stated_frame_is_on_its_first() {
3035 // `Showing::One` says exactly one is up, so there is no honest reading
3036 // of a missing index other than the first. A renderer never has to
3037 // decide this for itself, which is the point of the method.
3038 let mut carousel = Slot::widget("shots", "carousel").extend(frames(3));
3039 carousel.showing = layout::Showing::One;
3040
3041 assert_eq!(carousel.current(), Some(0));
3042 }
3043
3044 #[test]
3045 fn a_frame_past_the_end_clamps_rather_than_vanishing() {
3046 // An out-of-range index is an app bug either way. Clamping reports it as
3047 // a carousel stuck on its last frame, which is findable; drawing nothing
3048 // reports it as a region that disappeared, which is not.
3049 let carousel = Slot::widget("shots", "carousel")
3050 .extend(frames(3))
3051 .showing_one(9);
3052
3053 assert_eq!(carousel.current(), Some(2));
3054
3055 // And an empty body has no frame to clamp to.
3056 assert_eq!(
3057 Slot::widget("shots", "carousel").showing_one(0).current(),
3058 None
3059 );
3060 }
3061
3062 #[test]
3063 fn a_closed_disclosure_is_the_one_selective_region_showing_nothing() {
3064 let closed = Slot::widget("details", "disclosure")
3065 .extend(frames(1))
3066 .showing_at_most_one(None);
3067 let open = Slot::widget("details", "disclosure")
3068 .extend(frames(1))
3069 .showing_at_most_one(Some(0));
3070
3071 assert_eq!(closed.current(), None);
3072 assert_eq!(open.current(), Some(0));
3073
3074 // Closed and `Showing::All` answer the same here on purpose: they differ
3075 // in the chrome around the body, not in what a renderer does with it.
3076 assert!(closed.showing.selective());
3077 }
3078
3079 #[test]
3080 fn labels_are_all_or_nothing() {
3081 // A strip with a hole in it is worse than the prev/next row it would
3082 // have replaced, so a half-labelled body gets the row.
3083 let tabs = Slot::new("detail", RegionKind::TabGroup)
3084 .with(Node::Region(
3085 Slot::new("overview", RegionKind::Pane).label("Overview"),
3086 ))
3087 .with(Node::Region(
3088 Slot::new("files", RegionKind::Pane).label("Files"),
3089 ));
3090 assert_eq!(tabs.labels(), ["Overview", "Files"]);
3091
3092 let half = tabs
3093 .clone()
3094 .with(Node::Region(Slot::new("history", RegionKind::Pane)));
3095 assert!(half.labels().is_empty());
3096 }
3097
3098 #[test]
3099 fn a_carousels_frames_carry_no_label_and_that_is_the_switch() {
3100 // Which idiom a renderer draws falls out of this rather than out of the
3101 // widget's name. A frame has a caption; only a region has a tab name.
3102 let carousel = Slot::widget("shots", "carousel")
3103 .extend(frames(3))
3104 .showing_one(1);
3105
3106 assert!(carousel.labels().is_empty());
3107 assert_eq!(carousel.current(), Some(1));
3108 }
3109 }
3110