Skip to main content

max / makeover-layout

Split lib.rs into thirteen modules along the crate's own Scope groups lib.rs was 7187 lines holding 57 public items and 143 tests. Each Scope group becomes a private module; the 216-line header, Intent and the test module stay at the root, and every one of the 57 names is re-exported so no caller changes. The facade is proven three ways rather than asserted. The public name set extracted from HEAD and from the new tree is the same 57 both ways, with nothing stranded in a child. A new guard test binds all 57 through `use crate::{...}`, so a future omission is a compile error rather than a silent semver break. And `unreachable_pub` stays silent, which is an independent check that no child pub lost its route to the root. Line coverage is proven too: concatenating the new root, the thirteen children with their generated import headers stripped, and the test module reproduces HEAD's non-blank line set exactly. Cuts are taken at the top of each item's doc-comment and attribute block rather than at its pub line, so no doc comment is orphaned. Each child carries a doc-scope import for the names its prose links to, keeping the 422 intra-doc links resolving. Two broken links remain and were proven pre-existing at HEAD; neither names an item that exists, so repointing them would mean inventing intent. No public path moved, so no version bump.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session
https://claude.ai/code/session_01EEmeiSJnmyL98QzA5Dwsvz
Author: Max Johnson <me@maxj.phd> · 2026-09-04 15:45 UTC
Signed with PGP, not checked
Commit: 43756d0f23c87cace9e73784ab5026610b83ca46
Parent: 6092bfc
14 files changed, +4058 insertions, -497 deletions
M src/lib.rs -497
@@ -226,4932 +226,59 @@
226 226 fn token(self) -> &'static str;
227 227 }
228 228
229 - /// Which way the light falls across a two-tone edge.
230 - ///
231 - /// The whole content of a bevel, once colour and thickness are deferred. The
232 - /// light is always assumed to come from the top left: every consumer measured
233 - /// agreed on that and none of them ever varied it, so it is an invariant here
234 - /// rather than a parameter.
235 - ///
236 - /// # The two corners that belong to both edges
237 - ///
238 - /// Top-right and bottom-left are where the lit run meets the shaded one, and
239 - /// the description's claim is that they belong to *both*. How a renderer says
240 - /// that is its own business, because the answer is bounded by resolution and
241 - /// not by taste:
242 - ///
243 - /// - A terminal cell is roughly 8x17 device pixels, so giving the whole corner
244 - /// to one tone thickens that edge by a cell and reads as one run overrunning
245 - /// the other. A half-cell glyph divides the cell already, so `makeover-tui`
246 - /// splits it and recovers real information. Its box-drawing fallback cannot:
247 - /// a single stroke has no half to give, so there both corners go to dark.
248 - /// - A pixel bevel is a one-point stroke by default, which makes the corner a
249 - /// one-point square. There is nothing to divide — a diagonal seam across one
250 - /// point is sub-pixel, and antialiasing renders it as the blend a mitred join
251 - /// already produces. So `makeover-immediate` mitres and is *not* diverging;
252 - /// it is the same rule at a resolution where the split degenerates.
253 - ///
254 - /// Stated here so the difference reads as a decision rather than as drift. A
255 - /// renderer with room to divide the corner should; one without should mitre or
256 - /// pick the shaded tone, and neither is a bug.
257 - #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
258 - pub enum Bevel {
259 - /// Lit from the top left: light on top and left, dark on bottom and right.
260 - Raised,
261 - /// The same edge inverted, which is also the pressed state of anything
262 - /// that draws itself [`Bevel::Raised`].
263 - Inset,
264 - }
265 -
266 - impl Bevel {
267 - /// The edge intents, as `(top_left, bottom_right)`.
268 - ///
269 - /// Split out from any painting because the inversion *is* the idea, and
270 - /// it is the one part every renderer implements identically.
271 - #[must_use]
272 - pub const fn edges(self) -> (Edge, Edge) {
273 - match self {
274 - Self::Raised => (Edge::Light, Edge::Dark),
275 - Self::Inset => (Edge::Dark, Edge::Light),
276 - }
277 - }
278 -
279 - /// Pressing inverts. A raised control reads as inset while held.
280 - ///
281 - /// Stated here rather than left to each consumer because a cascade can
282 - /// carry a pressed state and an immediate-mode renderer cannot: audiofiles
283 - /// resolves this per call site, eighteen times.
284 - #[must_use]
285 - pub const fn pressed(self) -> Self {
286 - match self {
287 - Self::Raised => Self::Inset,
288 - Self::Inset => Self::Raised,
289 - }
290 - }
291 - }
292 -
293 - /// One side of a bevel, named by the intent it takes.
294 - #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
295 - pub enum Edge {
296 - /// The lit side.
297 - Light,
298 - /// The shadowed side.
299 - Dark,
300 - }
301 -
302 - impl Intent for Edge {
303 - fn token(self) -> &'static str {
304 - match self {
305 - Self::Light => "bevel-light",
306 - Self::Dark => "bevel-dark",
307 - }
308 - }
309 - }
310 -
311 - /// A surface intent a region is filled with.
312 - ///
313 - /// `#[non_exhaustive]`, so a renderer must carry a wildcard arm and a new
314 - /// member is additive rather than breaking. The vocabulary exists to grow and
315 - /// the renderers exist to disagree about how much of it they answer, so growth
316 - /// must not be a lockstep event. The renderer's wildcard is not a hole:
317 - /// [`Fill`] is resolved through a fallible lookup, and a missing intent is
318 - /// answered with structure rather than with a substituted colour.
319 - ///
320 - /// [`Sunken`]: Fill::Sunken
321 - #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
322 - #[non_exhaustive]
323 - pub enum Fill {
324 - /// The page behind everything.
325 - Page,
326 - /// A surface lifted off the page: cards, controls, menus, toasts.
327 - Raised,
328 - /// A surface floating above the page rather than resting on it.
329 - Overlay,
330 - /// The inside of a well.
331 - Well,
332 - /// A surface set back from the one it sits on, by colour and nothing else.
333 - ///
334 - /// Not a well. A well is a hole with an edge, and the two are authored in
335 - /// opposite directions: `makeover` derives `surface-well` by inverting
336 - /// against the theme's own content colour, while `surface-sunken` is
337 - /// authored and free to sit darker than raised (goingson's does). Naming
338 - /// only the well left the recessed-with-no-edge surface unsayable, which is
339 - /// what an unchosen tab is: it recedes so the chosen one can come forward,
340 - /// and it carries no bevel of its own.
341 - Sunken,
342 - }
343 -
344 - // No `fallback` here, deliberately. An earlier cut had `Fill::Well` fall back
345 - // to `Fill::Page` so a consumer on makeover 2.2.0, which has no `surface-well`,
346 - // had something to paint. makeover-tui found that wrong within a day: page is
347 - // the surface a well is usually cut into, so on a terminal that substitution
348 - // produces exactly the invisibility it was meant to prevent, and the right
349 - // answer there is a drawn edge rather than a different colour.
350 - //
351 - // Substituting one intent for another is renderer policy. The description says
352 - // what the region is and stops.
353 -
354 - impl Intent for Fill {
355 - fn token(self) -> &'static str {
356 - match self {
357 - Self::Page => "surface-page",
358 - Self::Raised => "surface-raised",
359 - Self::Overlay => "surface-overlay",
360 - Self::Well => "surface-well",
361 - Self::Sunken => "surface-sunken",
362 - }
363 - }
364 - }
365 -
366 - /// How a region sits relative to the surface behind it.
367 - ///
368 - /// Fill and bevel are named together because naming them apart is what let
369 - /// them disagree. Every consumer measured had at least one region carrying a
370 - /// raised bevel over a recessed fill: audiofiles fixed it in `raised_frame`
371 - /// and recorded the bug in its doc comment, and Balanced Breakfast still had
372 - /// twelve of them a year later. A single name for the pair makes that
373 - /// unrepresentable.
374 - /// `#[non_exhaustive]` for the same reason as [`Fill`], and in the same
375 - /// release: a depth this renderer has no drawing for should cost it a
376 - /// wildcard arm, not a compile error and a wait on someone else's publish.
377 - #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
378 - #[non_exhaustive]
379 - pub enum Depth {
380 - /// Level with its surroundings. No edge.
381 - Flat,
382 - /// A card laid on the panel it sits in.
383 - Raised,
384 - /// A hole in the panel, with content down inside it. For anything the
385 - /// user looks *into*: a table body, a tag tree, a text field.
386 - Well,
387 - /// Set back from what it sits on, by colour alone. No edge.
388 - ///
389 - /// The one member carrying a fill without a bevel, so a renderer cannot
390 - /// assume the two arrive together. That is deliberate and it is still the
391 - /// pairing rule: both halves come off the same `Depth`, so they cannot
392 - /// disagree, and here one half is legitimately absent.
393 - ///
394 - /// Distinct from [`Depth::Flat`], which has no fill either and inherits.
395 - /// Recessed and level-with are different claims, and only one of them
396 - /// needs a colour.
397 - Sunken,
398 - /// A surface sitting *over* the page rather than in it. A modal, a popover,
399 - /// a menu.
400 - ///
401 - /// Takes elevation and no bevel: a surface overlaying the page is lifted
402 - /// off it, and a surface in the page is cut into it. That is the same
403 - /// pairing rule the rest of the enum holds, applied to the one case where
404 - /// the separation is not an edge at all — the lift and the scrim behind it
405 - /// are already saying where the surface is.
406 - ///
407 - /// Every renderer already has the surface: `makeover-tui` carries
408 - /// `Palette::overlay`, `makeover-immediate` `Palette::elevation`, and
409 - /// `makeover-webview` emits `--elevation-overlay`. This variant is the
410 - /// route from a description to any of them, which is why it is one variant
411 - /// rather than a feature.
412 - Overlay,
413 - }
414 -
415 - impl Depth {
416 - /// The edge this depth is drawn with, if it has one.
417 - #[must_use]
418 - pub const fn bevel(self) -> Option<Bevel> {
419 - match self {
420 - // Sunken joins Flat here, for the opposite reason: Flat has no edge
421 - // because nothing separates it from its surroundings, and Sunken has
422 - // none because its colour is already doing the separating.
423 - Self::Flat | Self::Sunken => None,
424 - // A third reason to have no edge, which is why it gets its own arm
425 - // rather than joining the two above: an overlay is separated by the
426 - // lift and by the scrim behind it, so an edge would be a second
427 - // answer to a question already answered.
428 - Self::Overlay => None,
429 - Self::Raised => Some(Bevel::Raised),
430 - Self::Well => Some(Bevel::Inset),
431 - }
432 - }
433 -
434 - /// The surface this depth is filled with.
435 - ///
436 - /// [`Depth::Flat`] has no fill of its own: it inherits whatever it sits on,
437 - /// which is the difference between level-with and painted-the-same-colour.
438 - #[must_use]
439 - pub const fn fill(self) -> Option<Fill> {
440 - match self {
441 - Self::Flat => None,
442 - Self::Raised => Some(Fill::Raised),
443 - Self::Well => Some(Fill::Well),
444 - Self::Sunken => Some(Fill::Sunken),
445 - Self::Overlay => Some(Fill::Overlay),
446 - }
447 - }
448 -
449 - /// Pressing a raised region reads as a well, and nothing else moves.
450 - ///
451 - /// [`Depth::Overlay`] is untouched along with the rest: an overlay is a
452 - /// surface, not a control, so there is nothing there to press.
453 - #[must_use]
454 - pub const fn pressed(self) -> Self {
455 - match self {
456 - Self::Raised => Self::Well,
457 - other => other,
458 - }
459 - }
460 - }
461 -
462 - /// An interaction state a region can be in, beside whatever [`Depth`] it is.
463 - ///
464 - /// Orthogonal to depth on purpose. A disabled button is still [`Depth::Raised`]
465 - /// and a disabled field is still a [`Depth::Well`], so folding either member
466 - /// into `Depth` would make [`Depth::bevel`] and [`Depth::fill`] answer for
467 - /// something that is not a depth, and would leave disabled-button and
468 - /// disabled-field sharing one variant that cannot tell them apart.
469 - ///
470 - /// # Why hover and pressed are not members
471 - ///
472 - /// The line is whether every renderer has the state to express, not whether CSS
473 - /// does. Hover is renderer policy and `makeover-webview` says so in its own
474 - /// header: a terminal and an immediate-mode painter have no pointer hovering
475 - /// over anything, and pressed already arrives through [`Bevel::pressed`] and
476 - /// [`Depth::pressed`], where it belongs, because pressing is a depth inversion
477 - /// rather than a separate condition.
478 - ///
479 - /// Focus and disabled are different in kind. A TUI has a focused widget and a
480 - /// greyed-out one; so does egui. Both were unsayable here, so all three webview
481 - /// consumers supplied them from outside the primitive by out-specifying rules
482 - /// they did not own: goingson alone carries 19 of them, and the MNW server
483 - /// another 21. That is the divergence this crate exists to end, arriving one
484 - /// layer down.
485 - ///
486 - /// # The principle this encodes
487 - ///
488 - /// A primitive owns every state it implies. A renderer that emits a hover rule
489 - /// for a thing owes disabled and the capability answer for that same thing,
490 - /// because anything less exports the completion work to N consumers who will
491 - /// each do it differently.
492 - ///
493 - /// Focus is not on that list and is not on this axis. It is the renderer's,
494 - /// decided after the description; see the crate header, "Reach,
495 - /// focus and the focus ring", for the three terms and who owns each.
496 - ///
497 - /// `#[non_exhaustive]` for the reason [`Fill`] and [`Depth`] carry it: growth
498 - /// must not be a lockstep event across the three renderers.
499 - #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
500 - #[non_exhaustive]
501 - pub enum State {
502 - /// Present, visible, and not answering.
503 - ///
504 - /// Not the same as absent, and deliberately not a [`Fill`]: a disabled
505 - /// control keeps the surface it always had and stops responding, so what
506 - /// changes is its content and its interactivity rather than what it is.
507 - Disabled,
508 - }
509 -
510 - impl State {
511 - /// Whether a region in this state stops answering the pointer.
512 - ///
513 - /// Stated in the description rather than left to each renderer, on the same
514 - /// reasoning as [`Bevel::pressed`]: a cascade carries it for free and an
515 - /// immediate-mode renderer resolves it per call site, so leaving it unsaid
516 - /// means resolving it once per consumer and disagreeing.
517 - #[must_use]
518 - pub const fn suppresses_interaction(self) -> bool {
519 - // A match rather than a bare `true`, so a member added to this
520 - // `#[non_exhaustive]` axis has to answer the question rather than
521 - // inheriting an answer.
522 - match self {
523 - Self::Disabled => true,
524 - }
525 - }
526 - }
527 -
528 - impl Intent for State {
529 - fn token(self) -> &'static str {
530 - match self {
531 - // Reusing the muted content intent rather than minting a
532 - // `disabled` colour. Disabled is a reduction and not a status, and
533 - // `makeover-webview`'s progress rules already record the reading
534 - // that `content-muted` is what disabled looks like.
535 - Self::Disabled => "content-muted",
536 - }
537 - }
538 - }
539 -
540 - /// What a region is saying, when it is saying something.
541 - ///
542 - /// The one intent family shared by badges, notices and nothing else. Kept
543 - /// separate from [`Fill`] because a surface is where a thing sits and a tone is
544 - /// what it means, and the three apps agree on the four statuses:
545 - /// `info_banner` / `warning_banner` in audiofiles, `.toast-info` /
546 - /// `.toast-success` / `.toast-error` in goingson, `.toast.success` /
547 - /// `.toast.error` in Balanced Breakfast.
548 - ///
549 - /// The per-tag palette (`category-one` through `category-six`) is deliberately
550 - /// not here. Which colour a *particular* tag takes is app domain, and both
551 - /// webview apps already carry it as a `data-color` attribute.
552 - #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
553 - pub enum Tone {
554 - /// No status.
555 - ///
556 - /// Ordinary content, at full weight. It does not also mean muted: a
557 - /// badge reads quiet because [`Token::Badge`] answers no click, which is
558 - /// the renderer's knowledge and not this axis's. A renderer wanting a
559 - /// muted badge reaches for [`Token::interactive`] itself rather than
560 - /// expecting `Neutral` to have muted it.
561 - Neutral,
562 - /// Something worth knowing and nothing to do about it.
563 - Info,
564 - /// Something finished and it worked.
565 - Success,
566 - /// Something the user should look at before continuing.
567 - Warning,
568 - /// Something broken, or something about to be destroyed.
569 - Danger,
570 - }
571 -
572 - impl Intent for Tone {
573 - fn token(self) -> &'static str {
574 - match self {
575 - // Neutral has no status token of its own, so it takes the plain
576 - // content intent. It used to answer `content-muted`, which read
577 - // "no status" as "de-emphasised" and muted every figure value in
578 - // the webview. Muting is a renderer's call about a particular
579 - // token, not something the status axis knows.
580 - Self::Neutral => "content",
581 - Self::Info => "info",
582 - Self::Success => "success",
583 - Self::Warning => "warning",
584 - Self::Danger => "danger",
585 - }
586 - }
587 - }
588 -
589 - /// A small labelled thing that sits inside something else.
590 - ///
591 - /// Two members, because the three apps drew three taxonomies and only one line
592 - /// runs through all of them: does it answer a click. audiofiles has
593 - /// `classification_badge` (a label) against `tag_chip`, `tag_chip_removable`
594 - /// and `selectable_tag` (all of which do). Balanced Breakfast has `.tag` and
595 - /// `.badge` against `.tag-chip`. goingson is the one that has to move: its
596 - /// `.tag` and `.badge` are a single CSS rule, so every call site has to be read
597 - /// to decide which of the two it always was.
598 - ///
599 - /// The evidence that a chip is a real concept rather than a badge with a
600 - /// cursor: audiofiles inverts its bevel on press and Balanced Breakfast latches
601 - /// `.tag-chip.active` with the inset bevel. Two independent arrivals at "a chip
602 - /// holds itself down", which is exactly what [`Depth::pressed`] already says.
603 - #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
604 - pub enum Token {
605 - /// Non-interactive status or count. Answers no click.
606 - Badge,
607 - /// An interactive or removable token. Answers a click, and latches if it
608 - /// stands for a filter that is either on or off.
609 - Chip {
610 - /// Whether it carries its own remove affordance.
611 - removable: bool,
612 - },
613 - }
614 -
615 - impl Token {
616 - /// Whether this answers a click.
617 - ///
618 - /// The whole difference between the two members, and the reason a renderer
619 - /// with no hover (a touch surface, a terminal) can still tell them apart.
620 - #[must_use]
621 - pub const fn interactive(self) -> bool {
622 - matches!(self, Self::Chip { .. })
623 - }
624 -
625 - /// How it sits, given whether it is currently latched down.
626 - ///
627 - /// A badge is flat: it is a label, and giving it an edge would say it can
628 - /// be pressed. A chip is raised, and inset while latched.
629 - #[must_use]
630 - pub const fn depth(self, latched: bool) -> Depth {
631 - match self {
632 - Self::Badge => Depth::Flat,
633 - Self::Chip { .. } if latched => Depth::Well,
634 - Self::Chip { .. } => Depth::Raised,
635 - }
636 - }
637 - }
638 -
639 - /// Something the app is telling the user, unprompted.
640 - ///
641 - /// Two concepts, not one with a placement. They differ in more than where they
642 - /// sit: a toast is transient, stacked and self-dismissing, and a banner is
643 - /// persistent, in flow, one per region, and dismissed by fixing the condition
644 - /// it reports. Folding them into one member with a placement parameter would
645 - /// make lifetime, stacking and dismissal all placement-dependent, which is the
646 - /// description leaking renderer policy.
647 - ///
648 - /// All three apps have banners: `info_banner` and `warning_banner` in
649 - /// audiofiles, five of them in goingson (sync, sync-result, vacation-day,
650 - /// timer-active, past-review), `.update-banner` in Balanced Breakfast. The two
651 - /// webview apps also have toasts. So neither member is speculative, and no app
652 - /// gains a concept it lacks except audiofiles, whose renderer may legitimately
653 - /// decline to draw a toast at all.
654 - #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
655 - pub enum Notice {
656 - /// Transient, stacked, dismisses itself.
657 - Toast,
658 - /// Persistent, in flow, one per region, dismissed by fixing the cause.
659 - Banner,
660 - }
661 -
662 - impl Notice {
663 - /// Whether it goes away on its own.
664 - #[must_use]
665 - pub const fn transient(self) -> bool {
666 - matches!(self, Self::Toast)
667 - }
668 -
669 - /// How it sits.
670 - ///
671 - /// A toast floats above the page rather than resting on it, which is
672 - /// [`Fill::Overlay`]'s whole reason to exist. A banner is a card in the
673 - /// flow. Both are raised, and they are raised off different things.
674 - #[must_use]
675 - pub const fn fill(self) -> Fill {
676 - match self {
677 - Self::Toast => Fill::Overlay,
678 - Self::Banner => Fill::Raised,
679 - }
680 - }
681 - }
682 -
683 - /// The parts of a list row.
684 - ///
685 - /// `#[non_exhaustive]`: a renderer carries a wildcard arm, so a new part is not
686 - /// a lockstep event across three renderers.
687 - ///
688 - /// # Meta against Tokens
689 - ///
690 - /// The line is whether the thing has its own standing. `Meta` is one short
691 - /// trailing fact about the row, written as text: a count, a size, a date.
692 - /// `Tokens` is a set of small labelled things, each of which can be toned and
693 - /// can answer a click. "3 files" is meta. A status badge that is amber, and a
694 - /// tag you can click to filter by, are tokens.
695 - ///
696 - /// Keeping them apart is what a single widened slot would have foreclosed. A
697 - /// renderer can right-align one string and cannot usefully do the same to a
698 - /// strip of chips, and a fact that is not clickable should not be drawn as
699 - /// though it were.
700 - /// How much vertical room a part's text may take.
701 - ///
702 - /// A row is an inline run and every part in it is a leaf, so a part's text has
703 - /// always been drawn on one line and no description could say otherwise. Two
704 - /// apps say otherwise in their own stylesheets, both to the same number and
705 - /// both with a comment explaining it: Balanced Breakfast clamps a feed row's
706 - /// title to two lines (`.row--article .row-primary`, whose comment reads
707 - /// "overrides .row-primary's single flex line"), and goingson clamps a
708 - /// problem's body to two ("two lines is enough to recognize one, and the full
709 - /// text is in the task once promoted").
710 - ///
711 - /// Two named tiers rather than a line count, and the count is what the measured
712 - /// demand argues against. Both sites want exactly one tier past the default,
713 - /// and a number invites a row whose primary is a paragraph, which is a block
714 - /// and has no business in a run. A third tier is a decision, made here, rather
715 - /// than something a call site can reach for.
716 - ///
717 - /// What a renderer owes it: `Tight` is what a run already does and needs no
718 - /// answer. `Relaxed` is at most two lines and then truncation, however that
719 - /// renderer truncates -- a webview clamps, a terminal wraps into two rows of
720 - /// cells, an immediate-mode renderer caps the galley. A renderer that cannot
721 - /// give two lines may draw one; what it may not do is grow without bound,
722 - /// because the run is a line and the row's neighbours are relying on that.
723 - #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
724 - #[non_exhaustive]
725 - pub enum Flow {
Lines truncated
A src/act.rs +107
@@ -1,0 +1,107 @@
1 + use crate::{State, Tone};
2 +
3 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
4 + pub struct Act<'a> {
5 + /// What the control says.
6 + pub label: &'a str,
7 + /// The key that reaches it where a host has keys.
8 + ///
9 + /// The one member written for a terminal before there was one. A webview
10 + /// hangs it off `accesskey` or ignores it; a terminal has nothing else to
11 + /// offer, so this is the whole of how a control is reached there.
12 + pub key: Option<&'a str>,
13 + /// What pressing it means. [`Tone::Danger`] is the destructive one.
14 + pub tone: Tone,
15 + /// Disabled, or nothing said.
16 + ///
17 + /// [`State::Disabled`] is what changes what a renderer may do: see
18 + /// [`State::suppresses_interaction`], which is what says a disabled control
19 + /// is drawn and not reachable. A control's focus is not sayable here at
20 + /// all: see the crate header, "Reach, focus and the focus ring".
21 + pub state: Option<State>,
22 + /// A sentence that is always true of this control, shown rather than hunted
23 + /// for.
24 + ///
25 + /// Standing help, not a message and not a tooltip. Half the hosts that read
26 + /// this have no pointer: a hover is one spelling of it, and the shipped
27 + /// apps reached for that spelling only because egui and a browser both had
28 + /// one. What is being said is that the sentence is true, never that it is
29 + /// hidden until a pointer arrives.
30 + ///
31 + /// # Why it is here rather than a layer up
32 + ///
33 + /// A hint left to `quasi_router::Act` alone means each renderer draws it
34 + /// for itself: `makeover_tui` had no hint to read, so quasi-tui built
35 + /// the muted line, and quasi-immediate called `on_hover_text` outside
36 + /// [`crate::Act`] rather than inside it. `Field::hint` was here the whole
37 + /// time, so the same idea sat at two layers depending on which thing
38 + /// carried it, and a host that was not quasi could say it of a field and
39 + /// not of a control.
40 + ///
41 + /// What kept it out was price rather than doubt: this crate declares
42 + /// `links`, so a member here moves 25 manifests across 12 repos. That is a
43 + /// release's forward-fix pass, which is a cost and was being read as a
44 + /// barrier.
45 + ///
46 + /// # What a renderer owes it
47 + ///
48 + /// Somewhere to put it, or nothing. Dropping it is legitimate; drawing it
49 + /// *instead of* the label is not, and neither is drawing it in a way that
50 + /// takes it out of the accessible tree, which is the failure `title` alone
51 + /// has on a browser. Nothing may live only in a hint.
52 + ///
53 + /// `None` is a control whose label is the whole of it, which is nearly all
54 + /// of them.
55 + pub hint: Option<&'a str>,
56 + }
57 +
58 + impl<'a> Act<'a> {
59 + /// An ordinary control, reachable, with no key.
60 + #[must_use]
61 + pub const fn new(label: &'a str) -> Self {
62 + Self {
63 + label,
64 + key: None,
65 + tone: Tone::Neutral,
66 + state: None,
67 + hint: None,
68 + }
69 + }
70 +
71 + /// The sentence that is always true of it; see [`hint`](Self::hint).
72 + ///
73 + /// A renderer with nowhere to put it drops it, so this must never be the
74 + /// only place a fact appears.
75 + #[must_use]
76 + pub const fn hinted(mut self, hint: &'a str) -> Self {
77 + self.hint = Some(hint);
78 + self
79 + }
80 +
81 + /// The key that reaches it.
82 + #[must_use]
83 + pub const fn key(mut self, key: &'a str) -> Self {
84 + self.key = Some(key);
85 + self
86 + }
87 +
88 + /// What pressing it means.
89 + #[must_use]
90 + pub const fn tone(mut self, tone: Tone) -> Self {
91 + self.tone = tone;
92 + self
93 + }
94 +
95 + /// Focus, or disabled.
96 + #[must_use]
97 + pub const fn state(mut self, state: State) -> Self {
98 + self.state = Some(state);
99 + self
100 + }
101 +
102 + /// Whether the control is drawn and does not answer.
103 + #[must_use]
104 + pub fn disabled(&self) -> bool {
105 + self.state.is_some_and(State::suppresses_interaction)
106 + }
107 + }
A src/choice.rs +439
@@ -1,0 +1,439 @@
1 + // Names this module's prose links to, resolved for rustdoc.
2 + #[allow(unused_imports)]
3 + use crate::{Field, FieldKind, Unit};
4 +
5 + /// One option offered by a field [`FieldKind::offers_options`] accepts.
6 + ///
7 + /// Two strings, because the submitted value and the read label are different
8 + /// facts and every renderer that has tried to collapse them has had to
9 + /// un-collapse them later. `makeover-webview` invented this shape writing its
10 + /// form emitter and it is taken here unchanged; moving it down rather than
11 + /// re-deriving it is the point, since the second and third renderers were each
12 + /// going to arrive at a near-miss of it.
13 + /// `#[non_exhaustive]`, which every type here that a renderer matches or builds
14 + /// carries. Without it a new member is a breaking change at every literal site
15 + /// in the tree.
16 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
17 + #[non_exhaustive]
18 + pub struct Choice<'a> {
19 + /// What is submitted.
20 + pub value: &'a str,
21 + /// What is read.
22 + pub label: &'a str,
23 + /// Why it cannot be picked right now, when it cannot.
24 + ///
25 + /// One member rather than an `available: bool` beside a reason, and the
26 + /// conflation is the point: an option greyed out with no explanation is a
27 + /// dead end the user cannot act on, and it is exactly the state the app
28 + /// that found this gap had to patch by hand with a line of prose under the
29 + /// control. Making the reason mandatory means the description cannot say
30 + /// the useless half.
31 + ///
32 + /// The option stays in the list. Dropping it is what an app does today, and
33 + /// it costs the user the knowledge that the thing exists at all —
34 + /// audiofiles' multi-sample mode appears on its own once a second sample is
35 + /// dropped, so a user who never sees it never learns what to drop.
36 + ///
37 + /// **Not [`Field::error`], and not [`Field::hint`].** An error is about the
38 + /// answer and a hint is standing help for the whole question; this is about
39 + /// one option among several, which is the level neither of those reaches.
40 + ///
41 + /// **Not disabled-the-state.** `State::Disabled` is about a whole field
42 + /// refusing to answer. This says the field is live and one of its answers
43 + /// is not available yet, which is a different sentence and the reason the
44 + /// tone rule matters here: the *other* options are still usable.
45 + pub unavailable: Option<&'a str>,
46 + /// The line under the label that says what picking this means.
47 + ///
48 + /// A choice between three plans is a choice nobody can make from three
49 + /// names, and until this existed the description had nowhere to put the
50 + /// sentence that made it makeable. What the corpus did instead is the
51 + /// tell: four of the six measured sites fold it into the label —
52 + /// `<strong>Public</strong>: Anyone can see this repository` in MNW's git
53 + /// settings, the same shape in its project-basics AI tier and its cart's
54 + /// currency conversion, and `Mislabeled (wrong AI tier or category)` in
55 + /// its report modal. The described screens do it too, in miniature: `Every
56 + /// 15 minutes (recommended)`, `Reference samples in place (loose-files
57 + /// mode)`. One fact, six spellings, no member.
58 + ///
59 + /// # Where it goes is the host's, and the rule already exists
60 + ///
61 + /// This is [`unavailable`](Self::unavailable)'s question met a third time
62 + /// and it takes the same answer, which is the strongest evidence one member
63 + /// is right rather than two. A radio group has room and gives the line its
64 + /// own element beside the label. A `<select>`'s option takes no elements,
65 + /// no second line and no title a keyboard reaches, so the line runs into
66 + /// the option's own text — exactly as a precondition does, and as a theme's
67 + /// contrast badge does in brackets. A terminal has rows and puts it on one
68 + /// under the option.
69 + ///
70 + /// # Not a price, and that is a measurement rather than a preference
71 + ///
72 + /// The site that asked for this is MNW's fee calculator, whose tier cards
73 + /// carry a name, a price *and* a description, so a second member for the
74 + /// price was on the table. It loses on the count: the tree's other three
75 + /// priced tier lists — `project.html`, `project_paywall.html`,
76 + /// `index.html` — are not option lists at all. Each card carries its own
77 + /// submit, which makes it a region with a heading, a fact and an act, and
78 + /// it is sayable already. So a price member would have exactly one
79 + /// consumer, and it would mean this crate growing a money type it does not
80 + /// have: [`Unit`] is a time axis, and every amount in the described tree is
81 + /// text.
82 + ///
83 + /// The price therefore leads the line: `$24/mo. 2GB/file, 100GB total.
84 + /// Fits audio, plugins, binaries.` What would reopen it is a **second**
85 + /// priced option list, not a judgement about how that reads.
86 + ///
87 + /// # What it is not
88 + ///
89 + /// Not [`unavailable`](Self::unavailable), which says the option cannot be
90 + /// picked. This says what it means to pick it, and the two are drawn
91 + /// together on an option that carries both: the description that says a
92 + /// tier is out of stock *and* what the tier is has said two things.
93 + ///
94 + /// Not [`Field::hint`], which is standing help for the whole question, and
95 + /// not markup. One line of plain text, for [`Candidate::detail`]'s reason:
96 + /// an option list is a place a renderer lays out, and a description that
97 + /// put a block in one would be handing every host a layout problem for the
98 + /// benefit of one.
99 + pub detail: Option<&'a str>,
100 + }
101 +
102 + impl<'a> Choice<'a> {
103 + /// An option whose submitted value is also its label.
104 + #[must_use]
105 + pub const fn plain(value: &'a str) -> Self {
106 + Self::new(value, value)
107 + }
108 +
109 + /// An option that submits one string and reads as another.
110 + ///
111 + /// A constructor rather than a literal, which is what `#[non_exhaustive]`
112 + /// costs and buys: outside this crate the struct cannot be built by naming
113 + /// its members, so every call site goes through here and the next member
114 + /// added breaks none of them.
115 + #[must_use]
116 + pub const fn new(value: &'a str, label: &'a str) -> Self {
117 + Self {
118 + value,
119 + label,
120 + unavailable: None,
121 + detail: None,
122 + }
123 + }
124 +
125 + /// The same option, not pickable yet, and why.
126 + ///
127 + /// Builder-shaped because the reason is the rare case: 39 of the 40 option
128 + /// sites measured across the tree do not have one.
129 + #[must_use]
130 + pub const fn unless(mut self, reason: &'a str) -> Self {
131 + self.unavailable = Some(reason);
132 + self
133 + }
134 +
135 + /// The same option, with the line that says what picking it means.
136 + ///
137 + /// Builder-shaped for [`unless`](Self::unless)'s reason, and it is the
138 + /// commoner of the two: six measured sites want this and one wants a
139 + /// precondition. See [`detail`](Self::detail).
140 + #[must_use]
141 + pub const fn detailing(mut self, detail: &'a str) -> Self {
142 + self.detail = Some(detail);
143 + self
144 + }
145 +
146 + /// Whether the option can be picked right now.
147 + ///
148 + /// The predicate a renderer branches on, so that "unavailable" is read as
149 + /// one condition in one place rather than as `unavailable.is_some()` at
150 + /// three renderers, one of which will invert it.
151 + #[must_use]
152 + pub const fn available(&self) -> bool {
153 + self.unavailable.is_none()
154 + }
155 + }
156 +
157 + /// One entry in a field's suggestion list.
158 + ///
159 + /// A suggestion-only type rather than a fourth member on [`Choice`], ruled by
160 + /// Max. The two are near-identical and that is the accepted drift risk, so the
161 + /// mitigation is written here: **an
162 + /// option and a candidate are submitted the same way and read differently.**
163 + /// An option is a thing you pick from a known set, and the set is the whole of
164 + /// what there is. A candidate is a thing you are being *oriented* toward out of
165 + /// a set nobody can see, which is why it carries [`detail`](Self::detail) and
166 + /// an option does not.
167 + ///
168 + /// This reverses a position quasi-router stated in its own doc, that a
169 + /// candidate is [`Choice`] "because a candidate is submitted under one string
170 + /// and read under another, which is what an option is". True and not
171 + /// sufficient: how a thing is submitted was never the half that differed.
172 + ///
173 + /// # Why the second string is not folded into the label
174 + ///
175 + /// Because every renderer wants it separately, and the two measured sites both
176 + /// draw it by hand today. The MNW server's tag box computes its context as the
177 + /// parent path -- "the parent path orients an otherwise ambiguous leaf:
178 + /// 'Format' appears under audio, software, writing, and video" -- and a list of
179 + /// four identical rows reading "Format" is not a usable list. In a webview the
180 + /// second string is styled differently, in a terminal it wants the remaining
181 + /// columns rather than a dash, and in neither is it part of what the typed
182 + /// value matches against. `Choice::new(slug, format!("{label} - {context}"))`
183 + /// loses all three of those facts, which is the condition this type exists to
184 + /// end.
185 + ///
186 + /// # No `unavailable`
187 + ///
188 + /// [`Choice::unavailable`] has no counterpart here, and the omission is the
189 + /// implementer's call recorded rather than an oversight. A suggestion that
190 + /// cannot be picked is arguably not a suggestion: an option list is a fixed set
191 + /// a user is owed an explanation about, and a candidate list is whatever a
192 + /// route decided to offer, so a route with nothing to say simply does not offer
193 + /// the row. Add it if a measured site ever wants it.
194 + ///
195 + /// # What it does not carry, and where that lives
196 + ///
197 + /// What *happens* when a candidate is picked. Picking is local by default -- it
198 + /// writes [`value`](Self::value) into the field that owns the list -- and a
199 + /// candidate that does something else says so with an action. An action is not
200 + /// a word this crate has, exactly as [`Field`] here has no `suggests` member,
201 + /// so both live on the router's owned mirror of this type.
202 + ///
203 + /// `#[non_exhaustive]` from birth. Non-negotiable: adding it later means a
204 + /// breaking change at every literal site in the tree.
205 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
206 + #[non_exhaustive]
207 + pub struct Candidate<'a> {
208 + /// What is submitted, and what picking writes into the field.
209 + pub value: &'a str,
210 + /// What is read.
211 + pub label: &'a str,
212 + /// The second line: what orients this candidate among rows that read alike.
213 + ///
214 + /// Optional because a candidate list whose labels are already distinct
215 + /// wants nothing here, and a renderer given [`None`] draws one line rather
216 + /// than an empty second one.
217 + pub detail: Option<&'a str>,
218 + }
219 +
220 + impl<'a> Candidate<'a> {
221 + /// A candidate whose submitted value is also its label.
222 + #[must_use]
223 + pub const fn plain(value: &'a str) -> Self {
224 + Self::new(value, value)
225 + }
226 +
227 + /// A candidate that submits one string and reads as another.
228 + ///
229 + /// A constructor rather than a literal, which is what `#[non_exhaustive]`
230 + /// costs and buys: outside this crate the struct cannot be built by naming
231 + /// its members, so every call site goes through here and the next member
232 + /// added breaks none of them.
233 + #[must_use]
234 + pub const fn new(value: &'a str, label: &'a str) -> Self {
235 + Self {
236 + value,
237 + label,
238 + detail: None,
239 + }
240 + }
241 +
242 + /// The same candidate, with the line that tells it from its neighbours.
243 + #[must_use]
244 + pub const fn detailed(mut self, detail: &'a str) -> Self {
245 + self.detail = Some(detail);
246 + self
247 + }
248 + }
249 +
250 + /// One field of a form.
251 + ///
252 + /// Borrowed rather than owned: a description is built, read once by a renderer,
253 + /// and dropped. Nothing here outlives the screen it describes.
254 + ///
255 + /// # What it carries, and what it does not
256 + ///
257 + /// Stated here so the next renderer does not re-ask, which is what the first
258 + /// two both did. It carries everything a renderer needs to *draw* the field:
259 + /// its kind, what it is called, what it is asked for, its standing help, what
260 + /// is wrong with it now, whether it is compulsory, whether it hides behind a
261 + /// disclosure, its ghost text, and the options it offers.
262 + ///
263 + /// It does not carry the **current value**, and it is not going to. That is the
264 + /// one thing here that is genuinely renderer state: a webview reads it back out
265 + /// of the DOM, an immediate-mode renderer holds a `&mut` to the app's own field
266 + /// and writes through it, and a terminal keeps an edit buffer. A description
267 + /// that carried the value would have to carry a way to write it back, at which
268 + /// point it is a form model and no longer a description.
269 + ///
270 + /// **Constraints** are here and enforcement is not, which is one line rather
271 + /// than two. [`required`], [`max_length`], [`min`] and [`max`] are facts about
272 + /// the *question*, so a renderer can emit its host's idiom for each — an HTML
273 + /// attribute, a marked label, a clamped spinner — and the platform helps the
274 + /// user before anything is submitted. Deciding that a value is wrong stays with
275 + /// whoever validated, and [`error`] is that decision arriving back.
276 + ///
277 + /// The set stops before `pattern`, and stops there on both tests at once. A
278 + /// regex has an honest answer in a webview and none anywhere else: egui would
279 + /// have to run it per keystroke and decide what a half-typed value means,
280 + /// which is enforcement wearing description's clothes. And it is one site in
281 + /// goingson and none in Balanced Breakfast, against 8 and 1 for `maxlength`.
282 + ///
283 + /// [`error`]: Field::error
284 + /// [`required`]: Field::required
285 + /// [`max_length`]: Field::max_length
286 + /// [`min`]: Field::min
287 + /// [`max`]: Field::max
288 + /// How a slider's position becomes its value, and how finely it moves.
289 + ///
290 + /// **The data of a slider is a fraction and a function taking numbers to
291 + /// numbers.** Stated by Max, and it is what [`min`](Field::min) and
292 + /// [`max`](Field::max) are not: they were never the control's extent.
293 + /// A slider's extent is always 0 to 1 — a thumb at 40% of a track — and the
294 + /// bounds are `f(0)` and `f(1)`. Linear is the constant-slope case, which is
295 + /// exactly why nobody noticed the function was there: when `f` is
296 + /// `min + t * (max - min)` the extent and the bounds coincide numerically and
297 + /// the mapping is invisible.
298 + ///
299 + /// So this is not a scale flag bolted onto a range. Every range described
300 + /// before it had a mapping, and four renderers each hard-coded the same one.
301 + ///
302 + /// # Why a closed family and not a function
303 + ///
304 + /// `fn(f64) -> f64` is the literal reading and it does not survive the
305 + /// description boundary. A fn pointer cannot be emitted into a browser, and it
306 + /// cannot be compared or hashed in a way that means anything, which this struct
307 + /// needs. A named family is the same semantics with arbitrary closures given
308 + /// up, and nothing measured wants one: the tree has a single non-linear shape
309 + /// across five controls and no second shape at all.
310 + ///
311 + /// # Why the step is here
312 + ///
313 + /// Max, in the same breath: if the family is prescriptive anyway, the step
314 + /// spacing belongs in it. On a slider the granularity and the mapping are one
315 + /// decision — a curve chosen without saying how finely it moves is half an
316 + /// answer — and holding them apart is what let a 0-to-1 threshold ship as a
317 + /// two-position control, since the host default of 1 was applied to a mapping
318 + /// nobody had named. It also un-overloads [`Field::step`], which stays as it
319 + /// was for a *typed* value, where there is no mapping and the granularity is a
320 + /// plain fact about the number.
321 + ///
322 + /// A future curve carrying a fact of its own — an exponent, an inflection —
323 + /// puts it in its own variant rather than on the struct, which is the second
324 + /// reason this shape is right.
325 + ///
326 + /// **The step is in the value's own units under every curve.** What a curve
327 + /// changes is the mapping, not the units the granularity is measured in: a step
328 + /// of `0.001` on an envelope time is three decimals whether the track is
329 + /// logarithmic or not, and a renderer that reads the step for display precision
330 + /// keeps reading it the same way.
331 + #[non_exhaustive]
332 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
333 + pub enum Curve<'a> {
334 + /// Constant slope: `f(t) = min + t * (max - min)`.
335 + ///
336 + /// What every described range meant before this enum existed, and the
337 + /// default, so a site that says nothing is correct unchanged.
338 + Linear {
339 + /// The granularity, in the value's own units. `None` is the host's own.
340 + step: Option<&'a str>,
341 + },
342 + /// Constant ratio: `f(t) = min * (max / min).powf(t)`.
343 + ///
344 + /// The mapping for a question whose extent spans orders of magnitude and
345 + /// whose interesting half is the small end. audiofiles' envelope times run
346 + /// 0.001 to 5 seconds, where a 5 ms attack and a 50 ms attack are audibly
347 + /// different instruments and a linear track puts both inside its first one
348 + /// percent.
349 + ///
350 + /// # It needs positive bounds
351 + ///
352 + /// A constant ratio is undefined across zero, so this asks for `min > 0`.
353 + /// A range that does not have that is mapped [`Linear`](Self::Linear)ly
354 + /// instead — see [`value_at`](Self::value_at). Stated rather than enforced,
355 + /// the way every other constraint in this crate is, and it is not a
356 + /// hypothetical: an envelope's sustain is a 0-to-1 level and is linear for
357 + /// this reason rather than by oversight.
358 + Logarithmic {
359 + /// The granularity, in the value's own units. `None` is the host's own.
360 + step: Option<&'a str>,
361 + },
362 + }
363 +
364 + impl Default for Curve<'_> {
365 + fn default() -> Self {
366 + Self::Linear { step: None }
367 + }
368 + }
369 +
370 + impl<'a> Curve<'a> {
371 + /// The granularity this curve moves in, whichever curve it is.
372 + ///
373 + /// Every variant carries one, so reading it does not need a match at each
374 + /// of the four renderers.
375 + #[must_use]
376 + pub const fn step(self) -> Option<&'a str> {
377 + // No wildcard: `#[non_exhaustive]` binds downstream, not here, so a
378 + // curve added later has to answer this rather than fall through to a
379 + // granularity nobody chose.
380 + match self {
381 + Self::Linear { step } | Self::Logarithmic { step } => step,
382 + }
383 + }
384 +
385 + /// Whether this curve maps as a constant ratio *given these bounds*.
386 + ///
387 + /// The bounds are the argument because [`Logarithmic`](Self::Logarithmic)
388 + /// is a request rather than a guarantee: it needs `0 < min < max`, and a
389 + /// range that does not have that is drawn linearly. A renderer asks this
390 + /// instead of matching on the variant, so the fallback is decided in one
391 + /// place rather than four.
392 + #[must_use]
393 + pub fn is_ratio(self, min: f64, max: f64) -> bool {
394 + matches!(self, Self::Logarithmic { .. }) && min > 0.0 && max > min
395 + }
396 +
397 + /// The value at a position along the track, where `position` is 0 to 1.
398 + ///
399 + /// `f`. The whole point of the type, and it lives here rather than in each
400 + /// renderer so that a terminal's bar, an egui slider and a browser's input
401 + /// cannot disagree about where a value sits.
402 + ///
403 + /// A position outside 0 to 1 is clamped, and bounds that are equal or
404 + /// inverted give `min` back: a track with no extent has one value on it.
405 + #[must_use]
406 + pub fn value_at(self, position: f64, min: f64, max: f64) -> f64 {
407 + let position = position.clamp(0.0, 1.0);
408 + // NaN named rather than fallen through: `max <= min` is false for a NaN
409 + // bound, so without it a track with no numbers on it would be mapped as
410 + // if it had two.
411 + if max <= min || min.is_nan() || max.is_nan() {
412 + return min;
413 + }
414 + if self.is_ratio(min, max) {
415 + min * (max / min).powf(position)
416 + } else {
417 + position.mul_add(max - min, min)
418 + }
419 + }
420 +
421 + /// The position a value sits at, where the answer is 0 to 1.
422 + ///
423 + /// `f` inverted, which is what a renderer needs to *draw* a value it was
424 + /// handed. Same clamping and the same degenerate answer as
425 + /// [`value_at`](Self::value_at).
426 + #[must_use]
427 + pub fn position_of(self, value: f64, min: f64, max: f64) -> f64 {
428 + if max <= min || min.is_nan() || max.is_nan() {
429 + return 0.0;
430 + }
431 + let value = value.clamp(min, max);
432 + let position = if self.is_ratio(min, max) {
433 + (value / min).ln() / (max / min).ln()
434 + } else {
435 + (value - min) / (max - min)
436 + };
437 + position.clamp(0.0, 1.0)
438 + }
439 + }
A src/column.rs +294
@@ -1,0 +1,294 @@
1 + // Names this module's prose links to, resolved for rustdoc.
2 + #[allow(unused_imports)]
3 + use crate::{Field, FieldKind, Fill, Region, RowPart};
4 +
5 + /// How much room a placement asks for.
6 + ///
7 + /// A column says it, and so does a [`Field`]. An intent, so the actual floor
8 + /// stays with `makeover-geometry`. goingson's task table spells these as
9 + /// `minmax(200px, 1fr)`, `140px` and content-sized; only the first three words
10 + /// of that survive deferral.
11 + /// `#[non_exhaustive]`, for the reason [`Fill`] and [`FieldKind`] are: a
12 + /// renderer matches on this and a vocabulary that grows must not break every
13 + /// renderer when it does.
14 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
15 + #[non_exhaustive]
16 + pub enum Width {
17 + /// Takes what it needs and no more.
18 + Content,
19 + /// A fixed share, the same at every width.
20 + Fixed,
21 + /// Absorbs whatever is left over.
22 + ///
23 + /// **Several fills divide what is left equally.** Stated because it would
24 + /// otherwise be undefined and each renderer would invent something, and
25 + /// stated this way because equal division is the only sharing rule that
26 + /// answers to "Any width, one answer" without a tiebreak: allocating in
27 + /// declaration order makes the result depend on the order the description
28 + /// was written in, which is a fact about the source file and not about the
29 + /// screen. It documents what both renderers already do — CSS grid gives
30 + /// `1fr 1fr`, ratatui gives each a `Constraint::Fill(1)` — rather than
31 + /// changing anything.
32 + ///
33 + /// So a row of fills is a legal thing to describe, and there is no rule
34 + /// against it.
35 + Fill,
36 + }
37 +
38 + /// What a member is worth when there is not room for all of them.
39 + ///
40 + /// Written for table columns and no longer only theirs. Three shapes ask the
41 + /// same question and this answers all three: a table too narrow for its
42 + /// columns, a row too narrow for its parts (see [`RowPart::priority`]), and a
43 + /// group of regions sharing one run of room -- goingson's tab strip and the
44 + /// [`Region::Band`] beside it, which is the case wiki `layout-room-and-fallback`
45 + /// was ruled on. It is what any member of a group is worth, not a table
46 + /// concept, and [`Fallback::Shed`] is what reads it.
47 + ///
48 + /// The doc below is the column argument, which is where the type was measured;
49 + /// the sentence that gave it away is [`Priority::Essential`]'s, which was
50 + /// already written about a row.
51 + ///
52 + /// Ordered: [`Priority::Optional`] drops first, [`Priority::Essential`] never
53 + /// drops. This replaces addressing columns by position, which is what both
54 + /// webview apps do today and is a live bug rather than only verbosity. goingson
55 + /// hides mobile columns with `nth-child(n+5)` against a seven-column table, so
56 + /// inserting a column silently hides the wrong one.
57 + /// `#[non_exhaustive]`, same reasoning as [`Width`]. Note the ordering is the
58 + /// whole point of the type, so a new tier has to be declared in its place in
59 + /// the sequence rather than appended.
60 + #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
61 + #[non_exhaustive]
62 + pub enum Priority {
63 + /// Dropped first.
64 + Optional,
65 + /// Dropped once the optional members are gone.
66 + Secondary,
67 + /// Never dropped. Without it the group does not identify itself.
68 + Essential,
69 + }
70 +
71 + /// What a group does when it runs out of room.
72 + ///
73 + /// Authored, and required: the field carrying this has no `Default` and a group
74 + /// cannot be described without saying what it does when it runs out of room.
75 + /// Max ruled on that: more intentionality from layout designers is
76 + /// acceptable so long as the constraints are solvable, because the goal is
77 + /// enabling good layouts rather than rescuing bad ones. A default here would be
78 + /// the crate guessing, and the guess would be silently wrong on the screens
79 + /// that matter.
80 + ///
81 + /// Relief resolves inside-out. A group asks its children to fall back before
82 + /// falling back itself, or an outer group collapses while an inner one still
83 + /// had slack.
84 + ///
85 + /// # No `Swap`
86 + ///
87 + /// An authored alternate group for the tight case is deliberately out of the
88 + /// first cut. It doubles the description for that group and the two halves can
89 + /// drift, which is the failure this vocabulary exists to end. Add it when a
90 + /// site proves it needs one.
91 + ///
92 + /// `#[non_exhaustive]`, [`Width`]'s reasoning. Unlike [`Priority`] there is no
93 + /// order to preserve, so a member can be appended.
94 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
95 + #[non_exhaustive]
96 + pub enum Fallback {
97 + /// One row becomes two. Every member stays, in the order described.
98 + Wrap,
99 + /// A row becomes a column. Every member stays, full width.
100 + Stack,
101 + /// Members drop by [`Priority`], down to [`Priority::Essential`].
102 + ///
103 + /// What a narrow table already does with its columns, applied to a group.
104 + /// What drops is gone from the screen, so this is right when the dropped
105 + /// members are facts the reader can do without and wrong when they are the
106 + /// only way to act.
107 + Shed,
108 + /// The members [`Shed`](Self::Shed) would drop move into one overflow
109 + /// control instead.
110 + ///
111 + /// The answer when a group holds actions. A control is not a fact: dropping
112 + /// it does not cost the reader a detail, it costs them the only way to act,
113 + /// which is [`RowPart::priority`]'s argument one level up.
114 + Menu,
115 + }
116 +
117 + /// One column of a table.
118 + ///
119 + /// Described once. The grid track, the cell order and the drop behaviour are
120 + /// all derived from this, rather than being three hand-written encodings that
121 + /// must agree and are never checked against each other.
122 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
123 + pub struct Column<'a> {
124 + /// The heading, and the name the cell is addressed by.
125 + pub name: &'a str,
126 + /// How much room it asks for.
127 + pub width: Width,
128 + /// What it is worth when room runs out.
129 + pub priority: Priority,
130 + /// Whether the user can reorder the table by this column.
131 + ///
132 + /// What reordering *calls* is not here — that is an address, and this
133 + /// crate names none — so a host pairs this with the route the way it pairs
134 + /// a row's parts with the row's activation. This says the affordance
135 + /// exists, which is what a renderer needs to draw a header a user can
136 + /// press rather than a heading they cannot.
137 + pub sortable: bool,
138 + /// Which way the table is ordered by this column, if it is.
139 + ///
140 + /// `None` on every column but the one in force. A renderer draws the caret
141 + /// from this and a webview sets `aria-sort`, which is why it is per column
142 + /// rather than a single fact on the table: the host idiom is a property of
143 + /// the header cell.
144 + ///
145 + /// Independent of [`sortable`](Self::sortable) rather than implied by it,
146 + /// because both combinations mean something. A column sorted and not
147 + /// sortable is a list ordered by a key the user cannot change, which is a
148 + /// real thing to describe and a caret worth drawing.
149 + pub sorted: Option<Sort>,
150 + }
151 +
152 + /// Which way a column is ordered.
153 + ///
154 + /// Two, because there is no third. "Unsorted" is [`Column::sorted`] being
155 + /// `None`, and folding it in here would be the same absence said twice.
156 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
157 + pub enum Sort {
158 + /// Smallest, earliest or first alphabetically at the top.
159 + Ascending,
160 + /// The other way.
161 + Descending,
162 + }
163 +
164 + impl Sort {
165 + /// The other direction, for a header that flips when pressed.
166 + #[must_use]
167 + pub const fn reversed(self) -> Self {
168 + match self {
169 + Self::Ascending => Self::Descending,
170 + Self::Descending => Self::Ascending,
171 + }
172 + }
173 +
174 + /// What a webview writes into `aria-sort`.
175 + ///
176 + /// Named here rather than in the webview renderer because a terminal and an
177 + /// immediate-mode painter both want the same two words for a caret's label,
178 + /// and three renderers picking their own is the drift this crate ends.
179 + #[must_use]
180 + pub const fn as_str(self) -> &'static str {
181 + match self {
182 + Self::Ascending => "ascending",
183 + Self::Descending => "descending",
184 + }
185 + }
186 +
187 + /// The caret a renderer draws for this direction.
188 + ///
189 + /// Here for [`as_str`](Self::as_str)'s reason, said about a glyph rather
190 + /// than a word: three renderers picking their own is the drift this crate
191 + /// ends. They had picked their own — two on the solid triangles and
192 + /// `makeover-webview` on the arrows U+2191/U+2193 — and agreeing by
193 + /// coincidence in three files is not agreement.
194 + ///
195 + /// The reason generalizes past this pair and is the house rule now —
196 + /// prefer the bolder, simpler glyph over the thinner or more complicated
197 + /// one. A third spelling is not open for re-argument.
198 + ///
199 + /// **Bare, with no spacing.** Where the gap goes is each renderer's
200 + /// business: `makeover-tui` and `makeover-immediate` carry a leading space
201 + /// inside their `TableStyle` string and a webview emits its own in
202 + /// `content`, so folding a space in here would make one of the two wrong.
203 + ///
204 + /// Neither face the web apps self-host carries these — IBM Plex Mono has one
205 + /// glyph in the whole geometric-shapes block and Lato has none — so a
206 + /// browser falls back per glyph until the in-house face ships with them
207 + /// drawn in (wiki `typography-standard`). Cosmetic
208 + /// drift in one renderer, not a reason to spell it three ways.
209 + #[must_use]
210 + pub const fn glyph(self) -> &'static str {
211 + match self {
212 + Self::Ascending => "\u{25B2}",
213 + Self::Descending => "\u{25BC}",
214 + }
215 + }
216 + }
217 +
218 + impl<'a> Column<'a> {
219 + /// A column that absorbs slack and drops after the optional ones.
220 + #[must_use]
221 + pub const fn new(name: &'a str) -> Self {
222 + Self {
223 + name,
224 + width: Width::Fill,
225 + priority: Priority::Secondary,
226 + sortable: false,
227 + sorted: None,
228 + }
229 + }
230 +
231 + /// Whether this column survives at the given cutoff.
232 + ///
233 + /// A renderer narrows by raising the cutoff, and never by counting
234 + /// positions.
235 + #[must_use]
236 + pub const fn kept_at(&self, cutoff: Priority) -> bool {
237 + (self.priority as u8) >= (cutoff as u8)
238 + }
239 + }
240 +
241 + /// What a table cell holds.
242 + ///
243 + /// [`RowPart`] for tables, and it exists for the same reason: a part that
244 + /// carries a control is not text, and a renderer with one class for the whole
245 + /// cell paints it as though it were: a button in a cell inherits the cell's
246 + /// content colour, which is the drift [`RowPart::intent`] prevents for rows.
247 + ///
248 + /// Four members, and the count is what quasi's `Cell` was measured to carry: a
249 + /// value, tokens, actions and a link. Nothing was added past what something
250 + /// holds.
251 + ///
252 + /// `#[non_exhaustive]` for [`RowPart`]'s reason: growth here must not be a
253 + /// lockstep event across three renderers.
254 + ///
255 + /// # No hover-reveal
256 + ///
257 + /// This enum never gets one. A cell's actions are shown at rest in every
258 + /// consumer measured, and a member nothing uses is one three renderers owe an
259 + /// answer for.
260 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
261 + #[non_exhaustive]
262 + pub enum CellPart {
263 + /// The cell's own text.
264 + Value,
265 + /// Small labelled things in the cell: a status badge, a chip.
266 + Tokens,
267 + /// Controls that act on what the row is about.
268 + Actions,
269 + /// The cell's value, where the value is itself a link.
270 + Link,
271 + }
272 +
273 + impl CellPart {
274 + /// The content intent the part takes.
275 + ///
276 + /// One part is text and three are not, so three answer with the intent
277 + /// inheriting already gives. That is [`RowPart::intent`]'s shape with the
278 + /// text side narrower: a cell's secondary and muted readings are the
279 + /// column's business, not the cell's.
280 + #[must_use]
281 + pub const fn intent(self) -> &'static str {
282 + match self {
283 + Self::Value => "content",
284 + // A token carries its own tone, and a part-level intent underneath
285 + // it would fight the token sitting on it.
286 + Self::Tokens => "content",
287 + // Actions carry controls rather than text.
288 + Self::Actions => "content",
289 + // A link takes the action colour from the control it is, rather
290 + // than the cell's text colour from the cell it sits in.
291 + Self::Link => "content",
292 + }
293 + }
294 + }
@@ -1,0 +1,464 @@
1 + use crate::{Depth, Fill, Intent, Priority};
2 +
3 + // Names this module's prose links to, resolved for rustdoc.
4 + #[allow(unused_imports)]
5 + use crate::{Awaiting, Meter, Region};
6 +
7 + /// What a region is saying, when it is saying something.
8 + ///
9 + /// The one intent family shared by badges, notices and nothing else. Kept
10 + /// separate from [`Fill`] because a surface is where a thing sits and a tone is
11 + /// what it means, and the three apps agree on the four statuses:
12 + /// `info_banner` / `warning_banner` in audiofiles, `.toast-info` /
13 + /// `.toast-success` / `.toast-error` in goingson, `.toast.success` /
14 + /// `.toast.error` in Balanced Breakfast.
15 + ///
16 + /// The per-tag palette (`category-one` through `category-six`) is deliberately
17 + /// not here. Which colour a *particular* tag takes is app domain, and both
18 + /// webview apps already carry it as a `data-color` attribute.
19 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
20 + pub enum Tone {
21 + /// No status.
22 + ///
23 + /// Ordinary content, at full weight. It does not also mean muted: a
24 + /// badge reads quiet because [`Token::Badge`] answers no click, which is
25 + /// the renderer's knowledge and not this axis's. A renderer wanting a
26 + /// muted badge reaches for [`Token::interactive`] itself rather than
27 + /// expecting `Neutral` to have muted it.
28 + Neutral,
29 + /// Something worth knowing and nothing to do about it.
30 + Info,
31 + /// Something finished and it worked.
32 + Success,
33 + /// Something the user should look at before continuing.
34 + Warning,
35 + /// Something broken, or something about to be destroyed.
36 + Danger,
37 + }
38 +
39 + impl Intent for Tone {
40 + fn token(self) -> &'static str {
41 + match self {
42 + // Neutral has no status token of its own, so it takes the plain
43 + // content intent. It used to answer `content-muted`, which read
44 + // "no status" as "de-emphasised" and muted every figure value in
45 + // the webview. Muting is a renderer's call about a particular
46 + // token, not something the status axis knows.
47 + Self::Neutral => "content",
48 + Self::Info => "info",
49 + Self::Success => "success",
50 + Self::Warning => "warning",
51 + Self::Danger => "danger",
52 + }
53 + }
54 + }
55 +
56 + /// A small labelled thing that sits inside something else.
57 + ///
58 + /// Two members, because the three apps drew three taxonomies and only one line
59 + /// runs through all of them: does it answer a click. audiofiles has
60 + /// `classification_badge` (a label) against `tag_chip`, `tag_chip_removable`
61 + /// and `selectable_tag` (all of which do). Balanced Breakfast has `.tag` and
62 + /// `.badge` against `.tag-chip`. goingson is the one that has to move: its
63 + /// `.tag` and `.badge` are a single CSS rule, so every call site has to be read
64 + /// to decide which of the two it always was.
65 + ///
66 + /// The evidence that a chip is a real concept rather than a badge with a
67 + /// cursor: audiofiles inverts its bevel on press and Balanced Breakfast latches
68 + /// `.tag-chip.active` with the inset bevel. Two independent arrivals at "a chip
69 + /// holds itself down", which is exactly what [`Depth::pressed`] already says.
70 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
71 + pub enum Token {
72 + /// Non-interactive status or count. Answers no click.
73 + Badge,
74 + /// An interactive or removable token. Answers a click, and latches if it
75 + /// stands for a filter that is either on or off.
76 + Chip {
77 + /// Whether it carries its own remove affordance.
78 + removable: bool,
79 + },
80 + }
81 +
82 + impl Token {
83 + /// Whether this answers a click.
84 + ///
85 + /// The whole difference between the two members, and the reason a renderer
86 + /// with no hover (a touch surface, a terminal) can still tell them apart.
87 + #[must_use]
88 + pub const fn interactive(self) -> bool {
89 + matches!(self, Self::Chip { .. })
90 + }
91 +
92 + /// How it sits, given whether it is currently latched down.
93 + ///
94 + /// A badge is flat: it is a label, and giving it an edge would say it can
95 + /// be pressed. A chip is raised, and inset while latched.
96 + #[must_use]
97 + pub const fn depth(self, latched: bool) -> Depth {
98 + match self {
99 + Self::Badge => Depth::Flat,
100 + Self::Chip { .. } if latched => Depth::Well,
101 + Self::Chip { .. } => Depth::Raised,
102 + }
103 + }
104 + }
105 +
106 + /// Something the app is telling the user, unprompted.
107 + ///
108 + /// Two concepts, not one with a placement. They differ in more than where they
109 + /// sit: a toast is transient, stacked and self-dismissing, and a banner is
110 + /// persistent, in flow, one per region, and dismissed by fixing the condition
111 + /// it reports. Folding them into one member with a placement parameter would
112 + /// make lifetime, stacking and dismissal all placement-dependent, which is the
113 + /// description leaking renderer policy.
114 + ///
115 + /// All three apps have banners: `info_banner` and `warning_banner` in
116 + /// audiofiles, five of them in goingson (sync, sync-result, vacation-day,
117 + /// timer-active, past-review), `.update-banner` in Balanced Breakfast. The two
118 + /// webview apps also have toasts. So neither member is speculative, and no app
119 + /// gains a concept it lacks except audiofiles, whose renderer may legitimately
120 + /// decline to draw a toast at all.
121 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
122 + pub enum Notice {
123 + /// Transient, stacked, dismisses itself.
124 + Toast,
125 + /// Persistent, in flow, one per region, dismissed by fixing the cause.
126 + Banner,
127 + }
128 +
129 + impl Notice {
130 + /// Whether it goes away on its own.
131 + #[must_use]
132 + pub const fn transient(self) -> bool {
133 + matches!(self, Self::Toast)
134 + }
135 +
136 + /// How it sits.
137 + ///
138 + /// A toast floats above the page rather than resting on it, which is
139 + /// [`Fill::Overlay`]'s whole reason to exist. A banner is a card in the
140 + /// flow. Both are raised, and they are raised off different things.
141 + #[must_use]
142 + pub const fn fill(self) -> Fill {
143 + match self {
144 + Self::Toast => Fill::Overlay,
145 + Self::Banner => Fill::Raised,
146 + }
147 + }
148 + }
149 +
150 + /// The parts of a list row.
151 + ///
152 + /// `#[non_exhaustive]`: a renderer carries a wildcard arm, so a new part is not
153 + /// a lockstep event across three renderers.
154 + ///
155 + /// # Meta against Tokens
156 + ///
157 + /// The line is whether the thing has its own standing. `Meta` is one short
158 + /// trailing fact about the row, written as text: a count, a size, a date.
159 + /// `Tokens` is a set of small labelled things, each of which can be toned and
160 + /// can answer a click. "3 files" is meta. A status badge that is amber, and a
161 + /// tag you can click to filter by, are tokens.
162 + ///
163 + /// Keeping them apart is what a single widened slot would have foreclosed. A
164 + /// renderer can right-align one string and cannot usefully do the same to a
165 + /// strip of chips, and a fact that is not clickable should not be drawn as
166 + /// though it were.
167 + /// How much vertical room a part's text may take.
168 + ///
169 + /// A row is an inline run and every part in it is a leaf, so a part's text has
170 + /// always been drawn on one line and no description could say otherwise. Two
171 + /// apps say otherwise in their own stylesheets, both to the same number and
172 + /// both with a comment explaining it: Balanced Breakfast clamps a feed row's
173 + /// title to two lines (`.row--article .row-primary`, whose comment reads
174 + /// "overrides .row-primary's single flex line"), and goingson clamps a
175 + /// problem's body to two ("two lines is enough to recognize one, and the full
176 + /// text is in the task once promoted").
177 + ///
178 + /// Two named tiers rather than a line count, and the count is what the measured
179 + /// demand argues against. Both sites want exactly one tier past the default,
180 + /// and a number invites a row whose primary is a paragraph, which is a block
181 + /// and has no business in a run. A third tier is a decision, made here, rather
182 + /// than something a call site can reach for.
183 + ///
184 + /// What a renderer owes it: `Tight` is what a run already does and needs no
185 + /// answer. `Relaxed` is at most two lines and then truncation, however that
186 + /// renderer truncates -- a webview clamps, a terminal wraps into two rows of
187 + /// cells, an immediate-mode renderer caps the galley. A renderer that cannot
188 + /// give two lines may draw one; what it may not do is grow without bound,
189 + /// because the run is a line and the row's neighbours are relying on that.
190 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
191 + #[non_exhaustive]
192 + pub enum Flow {
193 + /// One line. What every part did before this type existed.
194 + #[default]
195 + Tight,
196 + /// Up to two lines, then truncated.
197 + Relaxed,
198 + }
199 +
200 + impl Flow {
201 + /// How many lines the part may take.
202 + ///
203 + /// A number here rather than in the enum, because a renderer needs one and
204 + /// a call site does not. That asymmetry is the whole argument for the
205 + /// tiers: the description says how much room the thing deserves and this
206 + /// says what that costs, so a third tier changes one line rather than every
207 + /// consumer's arithmetic.
208 + #[must_use]
209 + pub const fn lines(self) -> u8 {
210 + match self {
211 + Self::Relaxed => 2,
212 + // Including any tier added later: one line is the safe reading of
213 + // an unknown flow, since it is what the run guaranteed before flows
214 + // existed.
215 + _ => 1,
216 + }
217 + }
218 + }
219 +
220 + /// How deep a row sits inside a set: a tree, an outline, a threaded list.
221 + ///
222 + /// [`RowPart`] below already names what is *in* a row; nothing named where a
223 + /// row sits relative to its siblings, so every consumer that had a hierarchy
224 + /// carried a bare number and every renderer decided for itself what one was
225 + /// worth.
226 + ///
227 + /// # Not `Depth`, and the collision is the reason
228 + ///
229 + /// [`Depth`] is taken and means something else entirely: surface bevel --
230 + /// `Flat`, `Raised`, `Well`, `Sunken`, `Overlay` -- a fact about a surface
231 + /// rather than a position in a hierarchy. Two meanings under one word in one
232 + /// crate is the collision that costs a reader an hour, and the word this
233 + /// concept wants is the one that would cause it.
234 + ///
235 + /// # The magnitude is the renderer's, and that is the precedent
236 + ///
237 + /// [`Awaiting`] is the shape: this crate names the fact and declines to name
238 + /// what it is worth. makeover-webview writes the rule as a custom property with
239 + /// a fallback -- the way it writes `margin-inline-start: var(--awaiting-gap,
240 + /// 0.5ch)` -- so a level has one answer per renderer and an app can override
241 + /// it. A terminal spends columns, a browser spends inline space, and neither
242 + /// number belongs in a description.
243 + ///
244 + /// # Zero is a real answer
245 + ///
246 + /// [`top`](Self::top) is the default and is what a flat list says: every row is
247 + /// at the top level, which is true and is the reading a renderer needs. An
248 + /// `Option` here would make "not nested" and "nested at zero" two spellings of
249 + /// one thing, the same argument `Discovery`'s `indexable` makes about defaults
250 + /// that are meaningful.
251 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, PartialOrd, Ord)]
252 + #[non_exhaustive]
253 + pub struct Nesting {
254 + /// How many levels in, counting from zero.
255 + ///
256 + /// `u8` because a hierarchy a reader can follow is not 256 deep, and a
257 + /// renderer indenting by a level has to multiply it by something -- a wider
258 + /// integer here is a wider integer in every renderer's arithmetic for a
259 + /// range nothing will use.
260 + pub level: u8,
261 + }
262 +
263 + impl Nesting {
264 + /// The top level: not nested. The default, and what a flat list says.
265 + #[must_use]
266 + pub const fn top() -> Self {
267 + Self { level: 0 }
268 + }
269 +
270 + /// A row this many levels in.
271 + #[must_use]
272 + pub const fn at(level: u8) -> Self {
273 + Self { level }
274 + }
275 +
276 + /// Whether this row sits under another.
277 + ///
278 + /// The question every renderer asks before it spends anything on indenting,
279 + /// answered once here rather than by a `> 0` in each -- which is
280 + /// [`Awaiting::is_determinate`]'s reason too.
281 + #[must_use]
282 + pub const fn is_nested(self) -> bool {
283 + self.level > 0
284 + }
285 + }
286 +
287 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
288 + #[non_exhaustive]
289 + pub enum RowPart {
290 + /// The thing itself. What the row is called.
291 + Primary,
292 + /// Supporting text under the primary.
293 + Secondary,
294 + /// A short trailing fact: a count, a size, a date.
295 + Meta,
296 + /// Controls that act on this row.
297 + Actions,
298 + /// Small labelled things belonging to the row: badges, chips, tags.
299 + ///
300 + /// Each carries its own [`Token`] kind and [`Tone`], so a renderer with no
301 + /// colour still has the kind to work with, and one with no chips still has
302 + /// the label. That is the constrained-consumer test this vocabulary exists
303 + /// to pass, and it is why the tone lives on the token rather than on the
304 + /// part.
305 + Tokens,
306 + /// How much of a set the row's thing has done: a [`Meter`] in the row.
307 + ///
308 + /// A row holds no nodes, by the rule that a row part may not carry an
309 + /// arbitrary node, which is the door through which a description becomes a
310 + /// templating language. So the part carries the *description of a bar* rather than a node, exactly as
311 + /// `Tokens` carries tags rather than nodes.
312 + ///
313 + /// Without it a row flattens the proportion into [`Meta`](Self::Meta) as
314 + /// "3/7 subtasks", which keeps both numbers and loses the reading, the same
315 + /// way a toned status badge read as prose before `Tokens`.
316 + Proportion,
317 + }
318 +
319 + impl RowPart {
320 + /// What the part is worth when the run does not fit.
321 + ///
322 + /// The default only. A part may say otherwise, and a renderer reads the
323 + /// part rather than the role; this is what a description that has never
324 + /// heard of [`Priority`] means, which is every description written before
325 + /// the field existed.
326 + ///
327 + /// Deriving it from the role is the thing this vocabulary has otherwise
328 + /// been moving away from, and it is right here for one reason: the roles
329 + /// already encode this ranking and every consumer already assumes it.
330 + /// [`Primary`](Self::Primary) is what the row is called, and
331 + /// [`Priority::Essential`]'s own doc was written about exactly that --
332 + /// "without it the row does not identify itself".
333 + ///
334 + /// [`Actions`](Self::Actions) is `Essential` and it is the interesting one.
335 + /// A control is not a fact, so dropping it does not cost the reader a
336 + /// detail; it costs them the only way to act on the row, and in a terminal
337 + /// it silently removes something focus had already been claimed for. A
338 + /// renderer that needs room takes it from what the row *says*, never from
339 + /// what it *offers*.
340 + ///
341 + /// An unknown member reads as [`Priority::Secondary`]: droppable, but not
342 + /// first, since guessing `Optional` for something this crate has not been
343 + /// taught would make a new member the first thing to vanish.
344 + #[must_use]
345 + pub const fn priority(self) -> Priority {
346 + match self {
347 + Self::Primary | Self::Actions => Priority::Essential,
348 + Self::Meta | Self::Proportion => Priority::Optional,
349 + _ => Priority::Secondary,
350 + }
351 + }
352 +
353 + /// The content intent the part takes.
354 + #[must_use]
355 + pub const fn intent(self) -> &'static str {
356 + match self {
357 + Self::Primary => "content",
358 + Self::Secondary => "content-secondary",
359 + Self::Meta => "content-muted",
360 + // Actions carry controls rather than text, so they inherit.
361 + Self::Actions => "content",
362 + // So do tokens: each one carries its own tone, and a part-level
363 + // intent underneath it would fight the token that sits on it.
364 + Self::Tokens => "content",
365 + // And so does a proportion, for the same reason: the meter carries
366 + // the tone, and it is about the ratio rather than about the row.
367 + Self::Proportion => "content",
368 + }
369 + }
370 + }
371 +
372 + /// How far down the heading tree a title sits.
373 + ///
374 + /// Three, and only the three that are actually headings. The bands those used
375 + /// to be filed with (goingson's `.page-header`, Balanced Breakfast's `.header`
376 + /// and `.detail-header`) are arrangement, not type, and live at
377 + /// [`Region::Band`]. One of them contains no text at all.
378 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
379 + pub enum Heading {
380 + /// Names the whole screen. One per screen.
381 + Page,
382 + /// Names a block within the screen.
383 + Section,
384 + /// Names a sub-block inside an already-named section.
385 + Subsection,
386 + }
387 +
388 + impl Heading {
389 + /// Whether a rule follows the heading.
390 + ///
391 + /// audiofiles' `section_header` draws a separator and its
392 + /// `subsection_label` deliberately does not, which is the only thing
393 + /// distinguishing the two once weight and colour are deferred.
394 + #[must_use]
395 + pub const fn separated(self) -> bool {
396 + matches!(self, Self::Section)
397 + }
398 + }
399 +
400 + /// A control that picks between things.
401 + ///
402 + /// Three, because three distinct behaviours are in play and collapsing any two
403 + /// loses something. A segmented control picks a value; a tab picks a pane; a
404 + /// toggle picks nothing and simply holds itself on or off.
405 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
406 + pub enum Selector {
407 + /// Exactly one of N, and the options abut.
408 + Segmented,
409 + /// Independent on or off, on its own.
410 + Toggle,
411 + /// Navigation between panes. The folder semantic.
412 + Tabs,
413 + }
414 +
415 + impl Selector {
416 + /// How the chosen option sits.
417 + ///
418 + /// Held in for a segmented control and a toggle, which is the same shape
419 + /// pressing produces and the whole economy of the idiom: one appearance,
420 + /// two reasons to wear it. A tab is the exception, because the selected
421 + /// folder tab comes *forward* to join the pane it opens.
422 + #[must_use]
423 + pub const fn chosen(self) -> Depth {
424 + match self {
425 + Self::Segmented | Self::Toggle => Depth::Well,
426 + Self::Tabs => Depth::Raised,
427 + }
428 + }
429 +
430 + /// How the options that were *not* picked sit.
431 + ///
432 + /// Describing only [`Selector::chosen`] left the unchosen option falling
433 + /// through to [`Depth::Flat`], which says it is level with the strip it
434 + /// sits in, and no renderer emitted anything for it. That is wrong in both
435 + /// directions and goingson proved it: its unchosen tabs are recessed by
436 + /// hand, and being recessed is *why* the chosen one reads as coming
437 + /// forward. Against a flat strip, a raised chosen tab is a bevel drawn on
438 + /// the strip's own colour, which is a much weaker folder effect than the
439 + /// contrast the idiom is named after.
440 + ///
441 + /// Each member is the inverse of its chosen state, which is the whole
442 + /// content of "picked" once colour is deferred:
443 + ///
444 + /// - Tabs recede, so the chosen one comes forward.
445 + /// - A segment and a toggle stand up, so the chosen one is held in.
446 + #[must_use]
447 + pub const fn unchosen(self) -> Depth {
448 + match self {
449 + Self::Tabs => Depth::Sunken,
450 + Self::Segmented | Self::Toggle => Depth::Raised,
451 + }
452 + }
453 +
454 + /// Whether the options touch.
455 + ///
456 + /// The gap is the entire difference between a segmented control and a row
457 + /// of buttons that happen to sit near each other, which is what audiofiles'
458 + /// `segmented_control` says in its own comment and why it zeroes the
459 + /// spacing by hand.
460 + #[must_use]
461 + pub const fn abutting(self) -> bool {
462 + matches!(self, Self::Segmented | Self::Tabs)
463 + }
464 + }
A src/depth.rs +312
@@ -1,0 +1,312 @@
1 + use crate::Intent;
2 +
3 + /// Which way the light falls across a two-tone edge.
4 + ///
5 + /// The whole content of a bevel, once colour and thickness are deferred. The
6 + /// light is always assumed to come from the top left: every consumer measured
7 + /// agreed on that and none of them ever varied it, so it is an invariant here
8 + /// rather than a parameter.
9 + ///
10 + /// # The two corners that belong to both edges
11 + ///
12 + /// Top-right and bottom-left are where the lit run meets the shaded one, and
13 + /// the description's claim is that they belong to *both*. How a renderer says
14 + /// that is its own business, because the answer is bounded by resolution and
15 + /// not by taste:
16 + ///
17 + /// - A terminal cell is roughly 8x17 device pixels, so giving the whole corner
18 + /// to one tone thickens that edge by a cell and reads as one run overrunning
19 + /// the other. A half-cell glyph divides the cell already, so `makeover-tui`
20 + /// splits it and recovers real information. Its box-drawing fallback cannot:
21 + /// a single stroke has no half to give, so there both corners go to dark.
22 + /// - A pixel bevel is a one-point stroke by default, which makes the corner a
23 + /// one-point square. There is nothing to divide — a diagonal seam across one
24 + /// point is sub-pixel, and antialiasing renders it as the blend a mitred join
25 + /// already produces. So `makeover-immediate` mitres and is *not* diverging;
26 + /// it is the same rule at a resolution where the split degenerates.
27 + ///
28 + /// Stated here so the difference reads as a decision rather than as drift. A
29 + /// renderer with room to divide the corner should; one without should mitre or
30 + /// pick the shaded tone, and neither is a bug.
31 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
32 + pub enum Bevel {
33 + /// Lit from the top left: light on top and left, dark on bottom and right.
34 + Raised,
35 + /// The same edge inverted, which is also the pressed state of anything
36 + /// that draws itself [`Bevel::Raised`].
37 + Inset,
38 + }
39 +
40 + impl Bevel {
41 + /// The edge intents, as `(top_left, bottom_right)`.
42 + ///
43 + /// Split out from any painting because the inversion *is* the idea, and
44 + /// it is the one part every renderer implements identically.
45 + #[must_use]
46 + pub const fn edges(self) -> (Edge, Edge) {
47 + match self {
48 + Self::Raised => (Edge::Light, Edge::Dark),
49 + Self::Inset => (Edge::Dark, Edge::Light),
50 + }
51 + }
52 +
53 + /// Pressing inverts. A raised control reads as inset while held.
54 + ///
55 + /// Stated here rather than left to each consumer because a cascade can
56 + /// carry a pressed state and an immediate-mode renderer cannot: audiofiles
57 + /// resolves this per call site, eighteen times.
58 + #[must_use]
59 + pub const fn pressed(self) -> Self {
60 + match self {
61 + Self::Raised => Self::Inset,
62 + Self::Inset => Self::Raised,
63 + }
64 + }
65 + }
66 +
67 + /// One side of a bevel, named by the intent it takes.
68 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
69 + pub enum Edge {
70 + /// The lit side.
71 + Light,
72 + /// The shadowed side.
73 + Dark,
74 + }
75 +
76 + impl Intent for Edge {
77 + fn token(self) -> &'static str {
78 + match self {
79 + Self::Light => "bevel-light",
80 + Self::Dark => "bevel-dark",
81 + }
82 + }
83 + }
84 +
85 + /// A surface intent a region is filled with.
86 + ///
87 + /// `#[non_exhaustive]`, so a renderer must carry a wildcard arm and a new
88 + /// member is additive rather than breaking. The vocabulary exists to grow and
89 + /// the renderers exist to disagree about how much of it they answer, so growth
90 + /// must not be a lockstep event. The renderer's wildcard is not a hole:
91 + /// [`Fill`] is resolved through a fallible lookup, and a missing intent is
92 + /// answered with structure rather than with a substituted colour.
93 + ///
94 + /// [`Sunken`]: Fill::Sunken
95 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
96 + #[non_exhaustive]
97 + pub enum Fill {
98 + /// The page behind everything.
99 + Page,
100 + /// A surface lifted off the page: cards, controls, menus, toasts.
101 + Raised,
102 + /// A surface floating above the page rather than resting on it.
103 + Overlay,
104 + /// The inside of a well.
105 + Well,
106 + /// A surface set back from the one it sits on, by colour and nothing else.
107 + ///
108 + /// Not a well. A well is a hole with an edge, and the two are authored in
109 + /// opposite directions: `makeover` derives `surface-well` by inverting
110 + /// against the theme's own content colour, while `surface-sunken` is
111 + /// authored and free to sit darker than raised (goingson's does). Naming
112 + /// only the well left the recessed-with-no-edge surface unsayable, which is
113 + /// what an unchosen tab is: it recedes so the chosen one can come forward,
114 + /// and it carries no bevel of its own.
115 + Sunken,
116 + }
117 +
118 + // No `fallback` here, deliberately. An earlier cut had `Fill::Well` fall back
119 + // to `Fill::Page` so a consumer on makeover 2.2.0, which has no `surface-well`,
120 + // had something to paint. makeover-tui found that wrong within a day: page is
121 + // the surface a well is usually cut into, so on a terminal that substitution
122 + // produces exactly the invisibility it was meant to prevent, and the right
123 + // answer there is a drawn edge rather than a different colour.
124 + //
125 + // Substituting one intent for another is renderer policy. The description says
126 + // what the region is and stops.
127 +
128 + impl Intent for Fill {
129 + fn token(self) -> &'static str {
130 + match self {
131 + Self::Page => "surface-page",
132 + Self::Raised => "surface-raised",
133 + Self::Overlay => "surface-overlay",
134 + Self::Well => "surface-well",
135 + Self::Sunken => "surface-sunken",
136 + }
137 + }
138 + }
139 +
140 + /// How a region sits relative to the surface behind it.
141 + ///
142 + /// Fill and bevel are named together because naming them apart is what let
143 + /// them disagree. Every consumer measured had at least one region carrying a
144 + /// raised bevel over a recessed fill: audiofiles fixed it in `raised_frame`
145 + /// and recorded the bug in its doc comment, and Balanced Breakfast still had
146 + /// twelve of them a year later. A single name for the pair makes that
147 + /// unrepresentable.
148 + /// `#[non_exhaustive]` for the same reason as [`Fill`], and in the same
149 + /// release: a depth this renderer has no drawing for should cost it a
150 + /// wildcard arm, not a compile error and a wait on someone else's publish.
151 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
152 + #[non_exhaustive]
153 + pub enum Depth {
154 + /// Level with its surroundings. No edge.
155 + Flat,
156 + /// A card laid on the panel it sits in.
157 + Raised,
158 + /// A hole in the panel, with content down inside it. For anything the
159 + /// user looks *into*: a table body, a tag tree, a text field.
160 + Well,
161 + /// Set back from what it sits on, by colour alone. No edge.
162 + ///
163 + /// The one member carrying a fill without a bevel, so a renderer cannot
164 + /// assume the two arrive together. That is deliberate and it is still the
165 + /// pairing rule: both halves come off the same `Depth`, so they cannot
166 + /// disagree, and here one half is legitimately absent.
167 + ///
168 + /// Distinct from [`Depth::Flat`], which has no fill either and inherits.
169 + /// Recessed and level-with are different claims, and only one of them
170 + /// needs a colour.
171 + Sunken,
172 + /// A surface sitting *over* the page rather than in it. A modal, a popover,
173 + /// a menu.
174 + ///
175 + /// Takes elevation and no bevel: a surface overlaying the page is lifted
176 + /// off it, and a surface in the page is cut into it. That is the same
177 + /// pairing rule the rest of the enum holds, applied to the one case where
178 + /// the separation is not an edge at all — the lift and the scrim behind it
179 + /// are already saying where the surface is.
180 + ///
181 + /// Every renderer already has the surface: `makeover-tui` carries
182 + /// `Palette::overlay`, `makeover-immediate` `Palette::elevation`, and
183 + /// `makeover-webview` emits `--elevation-overlay`. This variant is the
184 + /// route from a description to any of them, which is why it is one variant
185 + /// rather than a feature.
186 + Overlay,
187 + }
188 +
189 + impl Depth {
190 + /// The edge this depth is drawn with, if it has one.
191 + #[must_use]
192 + pub const fn bevel(self) -> Option<Bevel> {
193 + match self {
194 + // Sunken joins Flat here, for the opposite reason: Flat has no edge
195 + // because nothing separates it from its surroundings, and Sunken has
196 + // none because its colour is already doing the separating.
197 + Self::Flat | Self::Sunken => None,
198 + // A third reason to have no edge, which is why it gets its own arm
199 + // rather than joining the two above: an overlay is separated by the
200 + // lift and by the scrim behind it, so an edge would be a second
201 + // answer to a question already answered.
202 + Self::Overlay => None,
203 + Self::Raised => Some(Bevel::Raised),
204 + Self::Well => Some(Bevel::Inset),
205 + }
206 + }
207 +
208 + /// The surface this depth is filled with.
209 + ///
210 + /// [`Depth::Flat`] has no fill of its own: it inherits whatever it sits on,
211 + /// which is the difference between level-with and painted-the-same-colour.
212 + #[must_use]
213 + pub const fn fill(self) -> Option<Fill> {
214 + match self {
215 + Self::Flat => None,
216 + Self::Raised => Some(Fill::Raised),
217 + Self::Well => Some(Fill::Well),
218 + Self::Sunken => Some(Fill::Sunken),
219 + Self::Overlay => Some(Fill::Overlay),
220 + }
221 + }
222 +
223 + /// Pressing a raised region reads as a well, and nothing else moves.
224 + ///
225 + /// [`Depth::Overlay`] is untouched along with the rest: an overlay is a
226 + /// surface, not a control, so there is nothing there to press.
227 + #[must_use]
228 + pub const fn pressed(self) -> Self {
229 + match self {
230 + Self::Raised => Self::Well,
231 + other => other,
232 + }
233 + }
234 + }
235 +
236 + /// An interaction state a region can be in, beside whatever [`Depth`] it is.
237 + ///
238 + /// Orthogonal to depth on purpose. A disabled button is still [`Depth::Raised`]
239 + /// and a disabled field is still a [`Depth::Well`], so folding either member
240 + /// into `Depth` would make [`Depth::bevel`] and [`Depth::fill`] answer for
241 + /// something that is not a depth, and would leave disabled-button and
242 + /// disabled-field sharing one variant that cannot tell them apart.
243 + ///
244 + /// # Why hover and pressed are not members
245 + ///
246 + /// The line is whether every renderer has the state to express, not whether CSS
247 + /// does. Hover is renderer policy and `makeover-webview` says so in its own
248 + /// header: a terminal and an immediate-mode painter have no pointer hovering
249 + /// over anything, and pressed already arrives through [`Bevel::pressed`] and
250 + /// [`Depth::pressed`], where it belongs, because pressing is a depth inversion
251 + /// rather than a separate condition.
252 + ///
253 + /// Focus and disabled are different in kind. A TUI has a focused widget and a
254 + /// greyed-out one; so does egui. Both were unsayable here, so all three webview
255 + /// consumers supplied them from outside the primitive by out-specifying rules
256 + /// they did not own: goingson alone carries 19 of them, and the MNW server
257 + /// another 21. That is the divergence this crate exists to end, arriving one
258 + /// layer down.
259 + ///
260 + /// # The principle this encodes
261 + ///
262 + /// A primitive owns every state it implies. A renderer that emits a hover rule
263 + /// for a thing owes disabled and the capability answer for that same thing,
264 + /// because anything less exports the completion work to N consumers who will
265 + /// each do it differently.
266 + ///
267 + /// Focus is not on that list and is not on this axis. It is the renderer's,
268 + /// decided after the description; see the crate header, "Reach,
269 + /// focus and the focus ring", for the three terms and who owns each.
270 + ///
271 + /// `#[non_exhaustive]` for the reason [`Fill`] and [`Depth`] carry it: growth
272 + /// must not be a lockstep event across the three renderers.
273 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
274 + #[non_exhaustive]
275 + pub enum State {
276 + /// Present, visible, and not answering.
277 + ///
278 + /// Not the same as absent, and deliberately not a [`Fill`]: a disabled
279 + /// control keeps the surface it always had and stops responding, so what
280 + /// changes is its content and its interactivity rather than what it is.
281 + Disabled,
282 + }
283 +
284 + impl State {
285 + /// Whether a region in this state stops answering the pointer.
286 + ///
287 + /// Stated in the description rather than left to each renderer, on the same
288 + /// reasoning as [`Bevel::pressed`]: a cascade carries it for free and an
289 + /// immediate-mode renderer resolves it per call site, so leaving it unsaid
290 + /// means resolving it once per consumer and disagreeing.
291 + #[must_use]
292 + pub const fn suppresses_interaction(self) -> bool {
293 + // A match rather than a bare `true`, so a member added to this
294 + // `#[non_exhaustive]` axis has to answer the question rather than
295 + // inheriting an answer.
296 + match self {
297 + Self::Disabled => true,
298 + }
299 + }
300 + }
301 +
302 + impl Intent for State {
303 + fn token(self) -> &'static str {
304 + match self {
305 + // Reusing the muted content intent rather than minting a
306 + // `disabled` colour. Disabled is a reduction and not a status, and
307 + // `makeover-webview`'s progress rules already record the reading
308 + // that `content-muted` is what disabled looks like.
309 + Self::Disabled => "content-muted",
310 + }
311 + }
312 + }
A src/facet.rs +315
@@ -1,0 +1,315 @@
1 + use crate::Nesting;
2 +
3 + // Names this module's prose links to, resolved for rustdoc.
4 + #[allow(unused_imports)]
5 + use crate::{Awaiting, Choice, Column, Field, FieldKind};
6 +
7 + /// A named dimension a set can be narrowed by.
8 + ///
9 + /// One word for six things that were six mechanisms. MNW's discover page filters
10 + /// by free text, a flat any-of over item types, a tree of tags, a numeric range
11 + /// over price, a nested one-of over AI tier, and a browse position in the tag
12 + /// tree held separately from the tag selection — and the last two being separate
13 + /// is the whole reason a filter row there needs a tick box *and* a chevron. The
14 + /// panel is a mixed bag of hand-written controls because nothing named the thing
15 + /// they all are.
16 + ///
17 + /// Deliberately wider than that one page. audiofiles' library browser and
18 + /// goingson's filters are the same shape, and a word that only fitted discover
19 + /// would be discover's markup with a neutral name on it.
20 + ///
21 + /// # What it does not say
22 + ///
23 + /// **What picking a value calls.** This crate names no address, so a facet is
24 + /// paired with routes the way a column's [`sortable`](Column::sortable) flag is
25 + /// paired with what reordering calls.
26 + ///
27 + /// **How a tree is drawn.** Indented rows, a column of panes, a breadcrumb and a
28 + /// list: all four are honest renderings of the same described facet, and a
29 + /// terminal will not pick the same one a browser does. [`FacetValue::depth`] is
30 + /// what a renderer needs to draw any of them; the choice is not described.
31 + ///
32 + /// **Which values to show.** A tag tree has thousands of nodes and a panel shows
33 + /// a handful. Deciding which handful is the app's — it is the same question as
34 + /// which rows go in a table, and no table member answers it either.
35 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
36 + #[non_exhaustive]
37 + pub struct Facet<'a> {
38 + /// What the dimension is called, as the user reads it.
39 + pub name: &'a str,
40 + /// How many of its values may be in force, and in what shape.
41 + pub mode: Selecting,
42 + /// The values on offer, in the order they are drawn.
43 + ///
44 + /// A [`Selecting::Text`] facet has none: the value is whatever was typed,
45 + /// and a description that listed the possible strings would be listing the
46 + /// corpus. A [`Selecting::Range`] facet has none either, for the reason
47 + /// [`FieldKind::Range`] takes bounds rather than options — the ends are the
48 + /// question and the values between them are not enumerable.
49 + pub values: &'a [FacetValue<'a>],
50 + }
51 +
52 + impl<'a> Facet<'a> {
53 + /// A dimension with values to pick from.
54 + #[must_use]
55 + pub const fn new(name: &'a str, mode: Selecting, values: &'a [FacetValue<'a>]) -> Self {
56 + Self { name, mode, values }
57 + }
58 +
59 + /// Whether the facet is narrowing the set right now.
60 + ///
61 + /// The question a renderer asks to decide whether to offer a way out of it,
62 + /// and the reason it is derived rather than carried: a facet with nothing
63 + /// standing is unengaged by construction, so a member saying so could
64 + /// disagree with the values beside it. [`Standing::Inherited`] does not
65 + /// count — something further up is what is doing the narrowing, and clearing
66 + /// a child that was never picked clears nothing.
67 + ///
68 + /// Always false for [`Selecting::Text`] and [`Selecting::Range`], which
69 + /// carry no values. A host that wants a clear affordance on those knows
70 + /// whether its own box is empty; the description does not hold the typed
71 + /// string.
72 + #[must_use]
73 + pub fn engaged(&self) -> bool {
74 + self.values.iter().any(|value| value.standing.is_picked())
75 + }
76 +
77 + /// The deepest value in the facet, or zero when it is flat.
78 + ///
79 + /// What an indenting renderer needs to reserve a gutter before it draws the
80 + /// first row, which is "First paint is final paint" applied to a tree: a
81 + /// gutter widened as deeper values arrive is the reflow that rule forbids.
82 + #[must_use]
83 + pub fn reach(&self) -> u8 {
84 + self.values
85 + .iter()
86 + .map(|value| value.depth.level)
87 + .max()
88 + .unwrap_or(0)
89 + }
90 + }
91 +
92 + /// How many of a [`Facet`]'s values may be in force, and in what shape.
93 + ///
94 + /// Five, and the fifth is what made this an enum rather than a bool. `one-of`,
95 + /// `any-of`, a range and free text are the four a form vocabulary already has in
96 + /// [`FieldKind`]; a tree's selection is none of them, and describing tags as
97 + /// any-of was what forced browsing to be a second mechanism beside filtering.
98 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
99 + #[non_exhaustive]
100 + pub enum Selecting {
101 + /// Exactly one value, and picking another replaces it.
102 + ///
103 + /// MNW's AI tier, whose three options are nested ranges rather than
104 + /// independent values, so two of them at once means nothing.
105 + OneOf,
106 + /// Any number of values, each independent of the others.
107 + AnyOf,
108 + /// A low end, a high end, or both.
109 + ///
110 + /// Carries no values for [`FieldKind::Range`]'s reason: the ends are the
111 + /// question.
112 + Range,
113 + /// Whatever the user types.
114 + Text,
115 + /// A position in a tree, edited by taking branches in and pruning branches
116 + /// out.
117 + ///
118 + /// The one mode that is not reducible to the others, and the one gesture
119 + /// that replaced two. Picking a value narrows the set to it *and* reveals
120 + /// its children, so browsing a tree and filtering by it stop being separate
121 + /// mechanisms with separate state. What a selection then is: a set of
122 + /// branches taken and a set pruned, resolved nearest-ancestor-first, so
123 + /// `music` in and `music/synths` out is sayable and no flat mode can say it.
124 + ///
125 + /// Resolution happens in the app, and what reaches a renderer is the
126 + /// [`Standing`] each drawn value ended up with. A renderer walking ancestors
127 + /// itself would be a renderer that can disagree with the results beside it.
128 + Subtree,
129 + }
130 +
131 + impl Selecting {
132 + /// Whether the mode picks from values the description lists.
133 + ///
134 + /// False for [`Text`](Self::Text) and [`Range`](Self::Range), which are the
135 + /// two whose answer is not one of a set. A renderer asks this before it
136 + /// looks at [`Facet::values`], the way it asks
137 + /// [`FieldKind::offers_options`] before it looks at [`Field::options`].
138 + #[must_use]
139 + pub const fn offers_values(self) -> bool {
140 + matches!(self, Self::OneOf | Self::AnyOf | Self::Subtree)
141 + }
142 +
143 + /// Whether a value can be pruned as well as picked.
144 + ///
145 + /// [`Subtree`](Self::Subtree) alone. Excluding a value from a flat facet is
146 + /// the same fact as not picking it, so an exclude affordance there would be
147 + /// a second control for a state the first one already holds.
148 + #[must_use]
149 + pub const fn prunes(self) -> bool {
150 + matches!(self, Self::Subtree)
151 + }
152 +
153 + /// Whether picking a second value keeps the first.
154 + #[must_use]
155 + pub const fn accumulates(self) -> bool {
156 + matches!(self, Self::AnyOf | Self::Subtree)
157 + }
158 + }
159 +
160 + /// One value a [`Facet`] offers, as it currently stands.
161 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
162 + #[non_exhaustive]
163 + pub struct FacetValue<'a> {
164 + /// What identifies it, and what a host keys its route on.
165 + ///
166 + /// [`Choice::value`]'s split, and a tree is why it is not optional: two
167 + /// leaves under different parents are legitimately both called "Ambient",
168 + /// and the path is the only thing telling them apart. It is also what
169 + /// nearest-ancestor-wins resolves over, so an app that carried only labels
170 + /// could not compute the [`standing`](Self::standing) it hands back here.
171 + pub value: &'a str,
172 + /// What it is called, as the user reads it.
173 + ///
174 + /// The leaf's own name rather than its path: a facet drawn as an indented
175 + /// tree repeats every ancestor on every line otherwise, and one drawn as a
176 + /// breadcrumb has the ancestors already.
177 + pub label: &'a str,
178 + /// How many members of the set carry it.
179 + ///
180 + /// Optional, and settled that way rather than made mandatory: a count is a
181 + /// measured fact the app may not have. Counting a tag subtree under an
182 + /// active text search is a second query, and an app that will not pay for it
183 + /// should be able to describe the facet anyway rather than write a zero that
184 + /// reads as "none of them". That is [`Awaiting::amount`]'s rule in a second
185 + /// place — state a number when it was measured, and nothing when it was not.
186 + pub count: Option<u64>,
187 + /// Whether it is narrowing the set, and how it came to be.
188 + pub standing: Standing,
189 + /// How far down the tree it sits, counting from zero at the root.
190 + ///
191 + /// Always zero for a flat facet, which is what makes an indenting renderer
192 + /// one code path rather than two. A renderer that draws no tree at all still
193 + /// reads this, since a value's depth is what distinguishes two same-named
194 + /// leaves under different parents.
195 + pub depth: Nesting,
196 + /// Whether taking it reveals values under it.
197 + ///
198 + /// Distinct from having a nonzero [`depth`](Self::depth): a leaf deep in the
199 + /// tree branches no further, and a root with children does. Both facts are
200 + /// needed and neither implies the other, which is why the pair is two
201 + /// members rather than one count.
202 + pub branching: bool,
203 + }
204 +
205 + impl<'a> FacetValue<'a> {
206 + /// An unpicked value at the root of the facet.
207 + #[must_use]
208 + pub const fn new(value: &'a str, label: &'a str) -> Self {
209 + Self {
210 + value,
211 + label,
212 + count: None,
213 + standing: Standing::Open,
214 + depth: Nesting::top(),
215 + branching: false,
216 + }
217 + }
218 +
219 + /// A value whose identifier is also what the user reads.
220 + ///
221 + /// [`Choice::of`]'s convenience, and it is the flat case: a type or a tier
222 + /// is its own name, and only a tree needs a path that is not one.
223 + #[must_use]
224 + pub const fn of(value: &'a str) -> Self {
225 + Self::new(value, value)
226 + }
227 +
228 + /// How many members carry it, when that was measured.
229 + #[must_use]
230 + pub const fn counted(mut self, count: u64) -> Self {
231 + self.count = Some(count);
232 + self
233 + }
234 +
235 + /// How it stands in the current selection.
236 + #[must_use]
237 + pub const fn standing(mut self, standing: Standing) -> Self {
238 + self.standing = standing;
239 + self
240 + }
241 +
242 + /// Where it sits in the tree, and whether anything hangs off it.
243 + #[must_use]
244 + pub const fn at(mut self, depth: Nesting, branching: bool) -> Self {
245 + self.depth = depth;
246 + self.branching = branching;
247 + self
248 + }
249 + }
250 +
251 + /// Whether a [`FacetValue`] is narrowing the set, and how it came to be.
252 + ///
253 + /// Four rather than a bool, and the two extra members are what a tree costs. A
254 + /// pruned branch and an untaken one are not the same state — one was decided
255 + /// against and the other was never reached — and a child under a taken parent is
256 + /// in force without anybody having picked it. A renderer given a bool either
257 + /// marks every descendant of a taken branch, which reads as forty deliberate
258 + /// choices, or marks none of them, which reads as unfiltered.
259 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
260 + #[non_exhaustive]
261 + pub enum Standing {
262 + /// Not picked, and nothing above it is either.
263 + #[default]
264 + Open,
265 + /// Picked here. The set is narrowed to it and whatever hangs off it.
266 + Taken,
267 + /// In force because something above it was taken.
268 + Inherited,
269 + /// Pruned out, though something above it was taken.
270 + ///
271 + /// The state that only [`Selecting::Subtree`] can reach, and the reason
272 + /// exclusion is drawn as a visible affordance beside each label rather than
273 + /// as a modifier on the ordinary one: a gesture a terminal cannot express is
274 + /// a gesture half the renderers would have to leave out, and an affordance
275 + /// nothing teaches is one users do not find.
276 + Pruned,
277 + }
278 +
279 + impl Standing {
280 + /// Whether the user decided this value, either way.
281 + ///
282 + /// True for [`Taken`](Self::Taken) and [`Pruned`](Self::Pruned) — both are
283 + /// choices, and both are things a "clear this" affordance has to clear.
284 + /// [`Inherited`](Self::Inherited) is not: clearing it clears nothing,
285 + /// because the decision is further up.
286 + #[must_use]
287 + pub const fn is_picked(self) -> bool {
288 + matches!(self, Self::Taken | Self::Pruned)
289 + }
290 +
291 + /// Whether the value narrows the set in.
292 + ///
293 + /// [`Taken`](Self::Taken) and [`Inherited`](Self::Inherited): one was picked
294 + /// and one came down from above, and to the set they mean the same thing.
295 + /// The pair is named here so a renderer colouring in-force values does not
296 + /// have to know which is which.
297 + #[must_use]
298 + pub const fn in_force(self) -> bool {
299 + matches!(self, Self::Taken | Self::Inherited)
300 + }
301 +
302 + /// The content intent the value takes.
303 + ///
304 + /// [`Pruned`](Self::Pruned) reads back a step, which is the three-tone rule
305 + /// above rather than a new decision: a pruned branch is still a live control
306 + /// — pressing it takes the prune off — so it may not wear `content-muted`,
307 + /// and it is not the thing itself either.
308 + #[must_use]
309 + pub const fn intent(self) -> &'static str {
310 + match self {
311 + Self::Taken | Self::Inherited | Self::Open => "content",
312 + Self::Pruned => "content-secondary",
313 + }
314 + }
315 + }
A src/field.rs +500
@@ -1,0 +1,1031 @@
1 + use crate::{Choice, Curve, ThemeChoice, Tone};
2 +
3 + // Names this module's prose links to, resolved for rustdoc.
4 + #[allow(unused_imports)]
5 + use crate::{Awaiting, Contrast, Fill, ThemeVariant};
6 +
7 + /// What kind of value a form field takes.
8 + ///
9 + /// The union of the two vocabularies that diverged, which is what triggered
10 + /// this crate. They have since converged on their own: both apps now have a
11 + /// `renderFormField` emitting the same anatomy, and what is left differing is
12 + /// the kind set, the error shape, and whether the return is a string or a node.
13 + ///
14 + /// Validation is deliberately absent. Neither app has a shared story (goingson
15 + /// validates after collecting the form data, with per-field transform hooks;
16 + /// Balanced Breakfast has `required` and nothing else), and a schema that
17 + /// describes fields but not constraints acquires a constraint layer per app,
18 + /// which is exactly how the current divergence started. Naming it absent is a
19 + /// decision; leaving it unmentioned would not be.
20 + /// `#[non_exhaustive]` for the reason [`Fill`] is: renderers match on this and
21 + /// the set keeps growing, so growth must not be a lockstep event.
22 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
23 + #[non_exhaustive]
24 + pub enum FieldKind {
25 + /// A single line of text.
26 + Text,
27 + /// A single line of text that must never be echoed, logged or round-tripped
28 + /// through anything that might persist it.
29 + Secret,
30 + /// A number.
31 + Number,
32 + /// A number inside bounds the user drags across, where the range being
33 + /// visible is the point.
34 + ///
35 + /// Not [`Number`](Self::Number) with [`min`](Field::min) and
36 + /// [`max`](Field::max), which is the reading to resist and is the same
37 + /// resistance [`Radio`](Self::Radio) needed against `Select`. A bounded
38 + /// number and a validated number are different *questions*. A validated
39 + /// number is typed and can be wrong: the bounds are a rule the answer is
40 + /// checked against, and being told "must be at least 1" afterwards is the
41 + /// normal course of it. A range cannot be out of range at all, because the
42 + /// bounds are the control's extent rather than a rule, and the two ends are
43 + /// what the question means — audiofiles asks for a classifier threshold
44 + /// between 0 and 1, where 0 is never and 1 is only-on-certainty, and a typed
45 + /// 0.72 says nothing without both ends on screen beside it.
46 + ///
47 + /// A renderer cannot infer which one is meant from `min`/`max` alone, which
48 + /// is why this is a kind and not an inference: goingson's `min="1"` duration
49 + /// is a validated number and would become a slider.
50 + ///
51 + /// The membership test passes without stretching: a webview emits
52 + /// `<input type="range">`, egui has `Slider`, a terminal draws a bar and
53 + /// takes arrow keys, a CLI takes a bounded argument.
54 + ///
55 + /// # It owes its bounds
56 + ///
57 + /// [`min`](Field::min) and [`max`](Field::max) are `Option` for every other
58 + /// kind and are **required** here, in the sense the description can require
59 + /// anything: [`Field::bounded`] is the check, and a range missing one has no
60 + /// extent for a renderer to draw. What a renderer does with an unbounded
61 + /// range is its own call and both answers are honest — fall back to a typed
62 + /// number, or pick a host default — so this is stated rather than enforced,
63 + /// the way every other constraint here is.
64 + ///
65 + /// [`Field::step`] is the third fact and is genuinely optional: absent, the
66 + /// host's own granularity stands.
67 + Range,
68 + /// One question with two ends: a lower value and an upper one, submitted
69 + /// under two names.
70 + ///
71 + /// "Show me samples between 90 and 130 BPM" has a single answer with two
72 + /// ends, and the ends constrain each other: a minimum above the maximum is
73 + /// not a wrong value, it is an empty result nobody asked for. Described as
74 + /// two [`Number`](Self::Number) fields that is unsayable — nothing says they
75 + /// are one question, so a renderer draws two controls with two labels and no
76 + /// relationship, and [`Field::error`] can only be attached to one side of a
77 + /// fault that belongs to both.
78 + ///
79 + /// Not [`Range`](Self::Range), which was the reading to resist and the
80 + /// resistance is the same one `Range` itself needed against `Number`. A
81 + /// range describes *one* value inside an extent; this describes two, and the
82 + /// extent is a bound on each rather than the question's meaning. The two
83 + /// come apart in the answer: a range has a value, an interval has a pair,
84 + /// and either end may be absent while the other stands.
85 + ///
86 + /// # It states both names
87 + ///
88 + /// [`Field::name`] is the lower end and [`Field::upper_name`] is the upper
89 + /// one, stated rather than derived. One member instead of a naming
90 + /// convention this crate would then own forever.
91 + ///
92 + /// Direction is carried by which member the name sits in, so nothing
93 + /// separate says which end is which.
94 + ///
95 + /// # What it does not enforce
96 + ///
97 + /// The crossing rule. A lower end above the upper one is describable here
98 + /// and always was, exactly as an out-of-[`min`](Field::min) number is: this
99 + /// crate carries constraints and never checks them, and deciding a value is
100 + /// wrong stays with whoever validated. What the description buys is that the
101 + /// fault now has one place to be reported rather than two.
102 + ///
103 + /// # Both ends take the same facts
104 + ///
105 + /// [`min`](Field::min), [`max`](Field::max), [`step`](Field::step) and
106 + /// [`unit`](Field::unit) describe the axis rather than one end of it, so
107 + /// they are read once and applied to both. Six of audiofiles' filter axes
108 + /// are exactly this: one extent, one unit, one granularity, two ends.
109 + ///
110 + /// The bounds are optional here, unlike `Range`. They are a rule the answer
111 + /// is checked against rather than the control's extent, which is
112 + /// [`Number`](Self::Number)'s arrangement and not a slider's.
113 + Interval,
114 + /// An email address.
115 + ///
116 + /// Distinct from [`Text`](Self::Text) because the distinction is not
117 + /// decoration: a webview renderer emits `type="email"`, which on a touch
118 + /// device changes the keyboard that appears and turns on the platform's own
119 + /// validation. goingson ships to iOS, so collapsing this into text costs a
120 + /// keyboard with no `@` on it.
121 + Email,
122 + /// A URL. Same reasoning as [`Email`](Self::Email).
123 + Url,
124 + /// A telephone number. Same reasoning as [`Email`](Self::Email), and the
125 + /// clearest case of it: the keyboard is a numeric pad rather than letters.
126 + Tel,
127 + /// A calendar day, with no time of day in it.
128 + ///
129 + /// [`Email`](Self::Email)'s argument, and it carries further: a webview
130 + /// emits `type="date"`, which is a native picker, the platform's own
131 + /// validation, and on a touch device the date keyboard. Described as
132 + /// [`Text`](Self::Text) with a hint reading "YYYY-MM-DD", all three are
133 + /// lost and the hint is doing the platform's job in prose.
134 + ///
135 + /// The membership test passes on every host without stretching: a webview
136 + /// and a Tauri app emit the input, egui has a date picker, a terminal
137 + /// prompts for a day and can validate it, a CLI takes an argument.
138 + ///
139 + /// # The value is ISO 8601, `YYYY-MM-DD`
140 + ///
141 + /// Named here rather than left to each host, because a host that picks
142 + /// differently sends a server something it parses differently, and the
143 + /// failure is silent and per-host. It is `<input type="date">`'s own wire
144 + /// format, so the webview renderer owes nothing to honour it and the other
145 + /// hosts have one spelling to meet. [`DATE_FORMAT`] is the constant, and a
146 + /// test asserts this doc and that constant agree.
147 + Date,
148 + /// A calendar day and a time of day together.
149 + ///
150 + /// Apart from [`Date`](Self::Date) because the question is different rather
151 + /// than more precise: "which day does this expire" and "at what moment does
152 + /// this publish" are asked by different screens and answered by different
153 + /// controls. A webview emits `type="datetime-local"` for one and
154 + /// `type="date"` for the other, and a host that collapsed them would ask
155 + /// half the tree for a precision it does not want.
156 + ///
157 + /// Both arrived together on measurement rather than on symmetry: 13 sites
158 + /// of each across the MNW server and goingson, and **zero** of `time`,
159 + /// `month` or `week`, which is why those are not here. A member added for a
160 + /// case nobody has is a member designed against nothing, which is
161 + /// [`File`](Self::File)'s reasoning about `accept` applied to a whole
162 + /// member.
163 + ///
164 + /// # The value is `YYYY-MM-DDTHH:MM`, local, with no zone
165 + ///
166 + /// `<input type="datetime-local">`'s own format, and the "local" is the
167 + /// load-bearing half: the value carries no offset and no `Z`, so the moment
168 + /// it names is only fixed once something supplies a zone. That is the app's
169 + /// business and not the description's. Seconds are absent, which is the
170 + /// browser's own default and is left as the rule rather than restated as a
171 + /// constraint. [`DATETIME_FORMAT`] is the constant.
172 + ///
173 + /// [`Field::min`] and [`Field::max`] already take "the host's own spelling
174 + /// of a bound", so a floor of *not in the past* needs nothing new here: it
175 + /// is a string in this same format.
176 + DateTime,
177 + /// Several lines of text.
178 + Textarea,
179 + /// Several lines of text the user writes markdown in.
180 + ///
181 + /// The editing counterpart of prose a description carries as markdown
182 + /// source, and the reason it can exist at all is the same one that lets the
183 + /// source be carried: editing markdown is editing text, so a terminal, an
184 + /// immediate-mode host and a webview all have an honest answer, and none of
185 + /// them has to refuse. A kind that meant "rich text" in the WYSIWYG sense
186 + /// would have been a document model, and two of the three hosts would have
187 + /// had to draw something they cannot.
188 + ///
189 + /// What the mark buys over [`Textarea`](Self::Textarea) is that a renderer
190 + /// may offer the affordances markdown has and plain text does not — a
191 + /// preview, a syntax pass, a monospaced face for the source — and that a
192 + /// host reading the value back knows what it is holding. A renderer with
193 + /// none of that draws a textarea, which is why this is additive rather than
194 + /// a second control.
195 + ///
196 + /// It says nothing about **when** the value is saved. Autosave is a clock,
197 + /// clocks are not described here, and the four MNW editors this was measured
198 + /// against each keep their own.
199 + ///
200 + /// Sanitising stays where it already is for markdown that is only displayed:
201 + /// with the renderer, at the point markup is produced. Being described is
202 + /// not a safety property, and a host with its own sanitiser and its own
203 + /// content-security posture still owns both.
204 + Rich,
205 + /// One of a fixed set, offered behind a control that shows one at a time.
206 + Select,
207 + /// One of a fixed set, with every option on screen at once.
208 + ///
209 + /// Not a presentation of [`Select`](Self::Select), which is the reading to
210 + /// resist: what differs is a property of the *question*. A choice that is
211 + /// consequential or irreversible has to be readable without opening
212 + /// anything, because a closed control shows one option and hides the rest,
213 + /// and the one it shows is whichever was current before the user had read
214 + /// the alternatives. audiofiles asks whether a library copies samples into
215 + /// its store or references them where they lie — which cannot be changed
216 + /// afterwards — and had already promoted that out of a checkbox by hand,
217 + /// with a comment giving this reason, before the description could say it.
218 + ///
219 + /// Everything here is an `<input type=...>`, a `<select>` or a
220 + /// `<textarea>`, and the way this enum grows is by a site being measured
221 + /// rather than by a list being completed. No member is ever "the last one".
222 + Radio,
223 + /// On or off.
224 + Checkbox,
225 + /// A file the user picks from wherever the host keeps files.
226 + ///
227 + /// It was filed as a router finding — a control whose destination is a
228 + /// host capability rather than an address — and splitting it is what made
229 + /// it two answers instead of one member satisfying neither. *Opening* a
230 + /// file is a one-way handoff and needs no new API. *Picking* one returns a
231 + /// value into a write, which is a form concern, which is this.
232 + ///
233 + /// The membership test passes on every host and not by a stretch: a Tauri
234 + /// app opens a native picker, a server renders `<input type="file">`, a
235 + /// terminal prompts for a path, a CLI takes an argument. That is closer to
236 + /// [`Email`](Self::Email), which exists because it changes the keyboard,
237 + /// than to anything bespoke.
238 + ///
239 + /// # The four things an upload says, and where each of them lives
240 + ///
241 + /// | axis | where |
242 + /// |---|---|
243 + /// | what it accepts | [`Field::accept`] |
244 + /// | one file or several | [`Field::multiple`] |
245 + /// | where the bytes go | the router's action, not here |
246 + /// | how far along it is | [`Awaiting`] on that action |
247 + ///
248 + /// Only the first two are this crate's, and that split is the answer to
249 + /// "describe an upload in full" rather than a gap in it. A destination is an
250 + /// address and this crate holds no addresses; progress is a live number and
251 + /// a description is built once, so the number is the renderer's to observe
252 + /// against the size [`Awaiting::amount`] carried before the transfer began.
253 + ///
254 + /// # How the file is handed over is the host's
255 + ///
256 + /// A drop area, a button opening a native picker, a path typed at a prompt:
257 + /// all three are the same field, and every measured site has the first. It
258 + /// is not described for the reason no gesture is — this crate owns no
259 + /// coordinates and no pointer, and a terminal that cannot be dropped on
260 + /// would be refusing a description it can otherwise honour completely.
261 + ///
262 + /// [`Field::accept`] and [`Field::multiple`] are measured rather than
263 + /// deferred. A member designed against nothing is the rule to keep: count
264 + /// the sites before adding one.
265 + File,
266 + /// Which theme the app wears.
267 + ///
268 + /// The one member here that names a *subject* rather than a shape of
269 + /// answer, and it is worth saying why that is not the door it looks like.
270 + /// Every other kind is a question a screen might ask about anything; this
271 + /// one is a specific question every app in the family asks, once, on its
272 + /// settings screen, and three of them wrote the same control by hand.
273 + ///
274 + /// # It is furniture, and the measurement is what says so
275 + ///
276 + /// The reading to resist is that this is [`Select`](Self::Select) with a
277 + /// grouped option list. Max rejected that: `optgroup` appears at one live
278 + /// site in the tree and the non-theme grouping count is zero, so the thing
279 + /// that recurs is this picker rather than option lists that group.
280 + ///
281 + /// # What it carries that a select cannot
282 + ///
283 + /// [`Field::themes`] rather than [`Field::options`], because a theme is
284 + /// four facts and an option is two. The two extra facts are the ones no
285 + /// app can supply without redoing work the theme layer has already done:
286 + /// which [`ThemeVariant`] group a theme is in, and how legible its muted
287 + /// text measured. `Choice::new(id, format!("{name} ({variant})"))` is what
288 + /// the three apps had, and it flattens the group into prose and loses the
289 + /// tier entirely.
290 + ///
291 + /// [`Field::follows`] carries the entry that is not a theme.
292 + ///
293 + /// # The cost, stated rather than discovered later
294 + ///
295 + /// This puts one screen's shape into a vocabulary that otherwise holds
296 + /// none, which was the objection raised against it and accepted going in.
297 + /// The mitigation is narrowness: this describes a theme picker, not a
298 + /// general "list the host resolved" mechanism. A second host-resolved list
299 + /// is when that generalisation gets measured, and not before.
300 + ///
301 + /// A renderer that has not heard of it draws a select over
302 + /// [`Field::themes`]' names and loses the grouping, which is the state
303 + /// every app was in before this member. Degrading to the status quo ante
304 + /// is the floor the member is designed against.
305 + Theme,
306 + /// Carried through the form and never shown.
307 + Hidden,
308 + }
309 +
310 + /// The wire format a [`FieldKind::Date`] value takes: ISO 8601, `YYYY-MM-DD`.
311 + ///
312 + /// A constant rather than a sentence in a doc comment, because the reason to
313 + /// name the format at all is that a host picking its own would fail silently
314 + /// against a server parsing another. A host that cannot emit the native control
315 + /// still has one spelling to meet, and can say which one it meant.
316 + pub const DATE_FORMAT: &str = "%Y-%m-%d";
317 +
318 + /// The wire format a [`FieldKind::DateTime`] value takes: `YYYY-MM-DDTHH:MM`,
319 + /// local, carrying no zone and no seconds.
320 + ///
321 + /// [`DATE_FORMAT`]'s sibling and there for its reason. The absent zone is a
322 + /// property of the value rather than an omission: the moment is not fixed until
323 + /// something outside the description supplies one.
324 + pub const DATETIME_FORMAT: &str = "%Y-%m-%dT%H:%M";
325 +
326 + impl FieldKind {
327 + /// Whether the value the kind takes is a moment rather than a string.
328 + ///
329 + /// Named once here for the reason [`offers_options`](Self::offers_options)
330 + /// is: two kinds answer yes, and a host that has to parse or format a value
331 + /// needs to ask without spelling the pair out at each renderer. A third
332 + /// temporal kind should land here and nowhere else.
333 + ///
334 + /// The format each one takes is [`DATE_FORMAT`] and [`DATETIME_FORMAT`].
335 + #[must_use]
336 + pub const fn temporal(self) -> bool {
337 + matches!(self, Self::Date | Self::DateTime)
338 + }
339 +
340 + /// Whether the field is drawn at all.
341 + #[must_use]
342 + pub const fn visible(self) -> bool {
343 + !matches!(self, Self::Hidden)
344 + }
345 +
346 + /// Whether the value must be kept out of logs and diagnostics.
347 + #[must_use]
348 + pub const fn confidential(self) -> bool {
349 + matches!(self, Self::Secret)
350 + }
351 +
352 + /// Where the field's own label sits.
353 + ///
354 + /// A checkbox labels itself on the right of the box; everything else takes
355 + /// a label above. Both webview apps already do this and both special-case
356 + /// it inline, which is the tell that it belongs in the description.
357 + ///
358 + /// A [`Radio`](Self::Radio) is not one of them, and the near-miss is worth
359 + /// naming: its *options* each label themselves, but the field still asks a
360 + /// question above them, so the group takes a label like everything else.
361 + #[must_use]
362 + pub const fn labels_itself(self) -> bool {
363 + matches!(self, Self::Checkbox)
364 + }
365 +
366 + /// Whether the kind reads [`Field::options`].
367 + ///
368 + /// Two kinds do, so the pair is named once here rather than spelled out at
369 + /// each renderer and again in [`Field::options`]' own doc, where "every
370 + /// kind but `Select`" was true for exactly one release. A third
371 + /// option-taking kind should land here and nowhere else.
372 + #[must_use]
373 + pub const fn offers_options(self) -> bool {
374 + matches!(self, Self::Select | Self::Radio)
375 + }
376 +
377 + /// Whether the kind reads [`Field::themes`] and [`Field::follows`].
378 + ///
379 + /// One member answers yes, and it gets a name for
380 + /// [`takes_files`](Self::takes_files)'s reason rather than in spite of
381 + /// being alone: four renderers ask it before they read either member, and
382 + /// a `matches!` per renderer is where the next one goes missing.
383 + ///
384 + /// Deliberately not folded into
385 + /// [`offers_options`](Self::offers_options). A theme picker offers no
386 + /// [`Choice`]es at all, so a renderer walking `options` for it walks an
387 + /// empty slice and draws an empty control.
388 + #[must_use]
389 + pub const fn offers_themes(self) -> bool {
390 + matches!(self, Self::Theme)
391 + }
392 +
393 + /// Whether the value runs to more than one line.
394 + ///
395 + /// Named once here for [`temporal`](Self::temporal)'s reason: two kinds
396 + /// answer yes, every renderer has to ask it before it can size anything,
397 + /// and a `matches!` per renderer is the pair drifting apart one member at a
398 + /// time. What a host does with the markdown, if anything, it reads from the
399 + /// kind itself; this is only whether one line is enough.
400 + #[must_use]
401 + pub const fn multiline(self) -> bool {
402 + matches!(self, Self::Textarea | Self::Rich)
403 + }
404 +
405 + /// Whether the value is a file the host picks rather than a string typed
406 + /// into a box.
407 + ///
408 + /// One member answers yes, which is [`visible`](Self::visible)'s and
409 + /// [`confidential`](Self::confidential)'s footing rather than a departure
410 + /// from it: the question gets a name because three renderers ask it before
411 + /// they can read [`Field::accept`] or [`Field::multiple`], and a `matches!`
412 + /// per renderer is where a second file-taking kind would go missing.
413 + #[must_use]
414 + pub const fn takes_files(self) -> bool {
415 + matches!(self, Self::File)
416 + }
417 +
418 + /// Whether the value is a quantity, so [`Field::unit`] means something.
419 + ///
420 + /// The numeric kinds and nothing else. A date is a quantity in the sense
421 + /// that it is ordered, and it is not one in the sense that matters here:
422 + /// its unit is fixed by the kind, so `Date` carrying `days` would be the
423 + /// description restating what [`kind`](Field::kind) already said.
424 + ///
425 + /// [`takes_files`](Self::takes_files)'s footing, and for its reason: the
426 + /// renderers ask this before they decide where a unit goes, and a
427 + /// `matches!` per renderer is where the next measurable kind goes missing.
428 + ///
429 + /// [`Interval`](Self::Interval) is measurable too: an axis is measured in
430 + /// something and both its ends are in it.
431 + #[must_use]
432 + pub const fn measurable(self) -> bool {
433 + matches!(self, Self::Number | Self::Range | Self::Interval)
434 + }
435 + }
436 +
437 + /// A family of media a file can belong to.
438 + ///
439 + /// Three members, because three is what a media type's own first segment offers
440 + /// that a renderer can do anything with. `text` and `application` are families
441 + /// too and neither buys a disclosure — there is no preview of an
442 + /// `application/octet-stream` — so naming them would be a member added for a
443 + /// case nobody has.
444 + ///
445 + /// It is the answer to "which disclosure", not a validation rule.
446 + /// [`Field::accept`] is what a host filters on.
447 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
448 + #[non_exhaustive]
449 + pub enum Family {
450 + /// A still picture.
451 + Image,
452 + /// Sound.
453 + Audio,
454 + /// Moving pictures, with or without sound.
455 + Video,
456 + }
457 +
458 + impl Family {
459 + /// The wildcard media type that means the whole family.
460 + ///
461 + /// `image/*` and its two siblings, which is what the measured sites write
462 + /// and what a webview puts in an `accept` attribute. Named here so the three
463 + /// renderers do not each spell the star.
464 + #[must_use]
465 + pub const fn wildcard(self) -> &'static str {
466 + match self {
467 + Self::Image => "image/*",
468 + Self::Audio => "audio/*",
469 + Self::Video => "video/*",
470 + }
471 + }
472 +
473 + /// The family a media type's first segment names, if it is one of these.
474 + ///
475 + /// Case-insensitive on the segment, because a media type is
476 + /// case-insensitive and half the tree writes them lowercase by habit rather
477 + /// than by rule.
478 + #[must_use]
479 + pub fn of_type(media_type: &str) -> Option<Self> {
480 + let (top, _) = media_type.split_once('/')?;
481 + if top.eq_ignore_ascii_case("image") {
482 + Some(Self::Image)
483 + } else if top.eq_ignore_ascii_case("audio") {
484 + Some(Self::Audio)
485 + } else if top.eq_ignore_ascii_case("video") {
486 + Some(Self::Video)
487 + } else {
488 + None
489 + }
490 + }
491 + }
492 +
493 + /// One entry in a file field's accept list.
494 + ///
495 + /// Three shapes rather than a string, and all three are in the measured sites:
496 + /// the MNW server writes `image/*`, `image/jpeg,image/png,image/webp`,
497 + /// `.zip,.dmg,.exe,.appimage,.deb,.tar.gz,.clap,.vst3` and, in one place,
498 + /// `.csv,text/csv`. A single string would carry all of them and answer nothing
499 + /// about any of them.
500 + ///
Lines truncated
A src/figure.rs +301
@@ -1,0 +1,301 @@
1 + use crate::Tone;
2 +
3 + // Names this module's prose links to, resolved for rustdoc.
4 + #[allow(unused_imports)]
5 + use crate::{Choice, Notice, Readiness};
6 +
7 + /// How much of a set is done.
8 + ///
9 + /// Nine sites across the two webview apps drew a bar and nothing here named
10 + /// one, so every described screen concatenated the two numbers into its
11 + /// heading text instead: "Subtasks 3/7", "Time Tracking 45m tracked / 30m est,
12 + /// over". Every fact survives that and the reading does not, which is the same
13 + /// loss `RowPart::Tokens` closed when a toned status badge became prose.
14 + ///
15 + /// # Why a pair and not a percentage
16 + ///
17 + /// Both numbers, not the percentage the apps compute from them. The percentage
18 + /// was the obvious shape and it had already been tried: goingson's
19 + /// `Task::time_progress` divides, rounds, and then clamps to 100, which throws
20 + /// away the one case the bar exists to show — 45 minutes tracked against a
21 + /// 30-minute estimate. It carries a separate `is_over_estimate` boolean beside
22 + /// it to recover the fact the clamp dropped. A pair keeps the over-run without a
23 + /// companion flag, and [`percent`](Meter::percent) is still one call away for a
24 + /// renderer that wants it.
25 + ///
26 + /// The pair is also what the apps already have at every site. All seven
27 + /// determinate bars write the ratio into the accessible layer and never the
28 + /// percentage: `title="3/7 subtasks"`, `aria-label="3 of 7 subtasks completed"`,
29 + /// a milestone's own `3/7` span. Given 43 nothing can recover "3 of 7", so a
30 + /// percentage member would have made [`label`](Meter::label) mandatory at every
31 + /// call site, which is the concatenated text this member removes, moved one
32 + /// layer down.
33 + ///
34 + /// # What this is not
35 + ///
36 + /// The progress of an *operation*. Two of the nine sites are that — goingson's
37 + /// focus timer, Balanced Breakfast's feed fetch — and they get nothing here, on
38 + /// purpose. Both are imperative controllers over a live handle, driven by a tick
39 + /// or an event stream, and a description is built once and dropped. Holding one
40 + /// would mean growing a way to update a description between renders, which is a
41 + /// different feature. [`Readiness::Pending`] and a [`Notice::Toast`] carry the
42 + /// honest part.
43 + ///
44 + /// The two cases are distinguishable in the markup rather than by taste: every
45 + /// determinate bar in both apps carries a tone, and neither operation bar
46 + /// carries one. Two codebases drew that line the same way without coordinating.
47 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
48 + pub struct Meter<'a> {
49 + /// How much is done. May exceed [`total`](Self::total), and that is the
50 + /// case worth drawing.
51 + pub done: u32,
52 + /// How much there is to do. Zero means there is no set, not that the set is
53 + /// complete.
54 + pub total: u32,
55 + /// What the proportion means right now.
56 + ///
57 + /// Carried rather than derived, because no renderer can work it out. The
58 + /// same 90% is [`Tone::Success`] on a subtask rollup and [`Tone::Danger`] on
59 + /// a time estimate, and goingson picks between them from `is_over_estimate`,
60 + /// a fact about the data and not about the number.
61 + pub tone: Tone,
62 + /// What is being counted, if the bar says so: "subtasks", "tasks".
63 + ///
64 + /// The noun, not the ratio. A renderer builds "3 of 7 subtasks" from this
65 + /// and the two numbers; handing it the assembled string would put the
66 + /// sentence order in the description, where a terminal at one line and a
67 + /// tooltip want different ones.
68 + pub label: Option<&'a str>,
69 + }
70 +
71 + impl<'a> Meter<'a> {
72 + /// A proportion with no tone and no label.
73 + #[must_use]
74 + pub const fn new(done: u32, total: u32) -> Self {
75 + Self {
76 + done,
77 + total,
78 + tone: Tone::Neutral,
79 + label: None,
80 + }
81 + }
82 +
83 + /// What the proportion means.
84 + #[must_use]
85 + pub const fn tone(mut self, tone: Tone) -> Self {
86 + self.tone = tone;
87 + self
88 + }
89 +
90 + /// What is being counted.
91 + #[must_use]
92 + pub const fn label(mut self, label: &'a str) -> Self {
93 + self.label = Some(label);
94 + self
95 + }
96 +
97 + /// How full the bar is, 0 to 100, clamped.
98 + ///
99 + /// For drawing, which is the only thing a clamped number is good for. Ask
100 + /// [`overflowing`](Self::overflowing) before reporting it as a fact, or this
101 + /// is `time_progress`'s bug again with the clamp moved.
102 + ///
103 + /// An empty set reads as 0. Nothing is done, because there is nothing to do
104 + /// and no bar to fill; the apps guard on the count before drawing at all.
105 + #[must_use]
106 + pub const fn percent(&self) -> u8 {
107 + if self.total == 0 {
108 + return 0;
109 + }
110 + let scaled = (self.done as u64 * 100) / self.total as u64;
111 + if scaled > 100 { 100 } else { scaled as u8 }
112 + }
113 +
114 + /// Whether more is done than there was to do.
115 + ///
116 + /// The fact [`percent`](Self::percent) destroys, kept reachable so a
117 + /// renderer can mark the over-run rather than drawing a full bar and
118 + /// implying it landed exactly.
119 + #[must_use]
120 + pub const fn overflowing(&self) -> bool {
121 + self.done > self.total
122 + }
123 +
124 + /// Whether there is a set at all.
125 + ///
126 + /// A meter over nothing is sayable on purpose, for the same reason a field
127 + /// with no options is: it is what an app with an unloaded count actually
128 + /// has, and a renderer that shows an empty bar says so on screen rather than
129 + /// dividing by zero.
130 + #[must_use]
131 + pub const fn is_empty(&self) -> bool {
132 + self.total == 0
133 + }
134 + }
135 +
136 + /// One figure with a caption: a number and what it counts.
137 + ///
138 + /// The dashboard shape. A large value over a small caption, several of them in
139 + /// a strip: a current streak, a completion rate, a total. Four put the value
140 + /// above the caption and one inverts it, which is drift inside the shape
141 + /// rather than a second shape.
142 + ///
143 + /// # Why the value is text
144 + ///
145 + /// "17", "84%", "12/30", "3d". A figure is whatever the app computed, already
146 + /// formatted, and the formatting is the app's because only it knows whether the
147 + /// number is a percentage, a duration or a ratio. This carries none of the
148 + /// arithmetic [`Meter`] carries, and that is the difference between them: a
149 + /// meter is a proportion a renderer draws, and a figure is a fact a renderer
150 + /// sets in type.
151 + ///
152 + /// # Tone is carried, for [`Meter`]'s reason
153 + ///
154 + /// Three of the five sites tone the figure by their own means — `red`/`blue` on
155 + /// the weekly review, a `${type}` class on the monthly one, `sync-stat-warn` on
156 + /// sync. So tone is carried at every site that needs it and derived at none, and
157 + /// no renderer can work out that a streak of zero is worth colouring.
158 + ///
159 + /// # What is not here
160 + ///
161 + /// Whether the figure answers a click. One of the five is a control — sync's
162 + /// "Not Applied: 3" opens the list — and an action is not something this crate
163 + /// can name: nothing here knows what a route is. That belongs beside the figure
164 + /// in whatever layer holds the actions, the same way a row's activation sits
165 + /// beside its parts rather than inside them.
166 + ///
167 + /// The arrangement is not here either. Several figures in a strip is a set, and
168 + /// a renderer given them one at a time cannot tell it is looking at one; the
169 + /// layer that holds the tree is where the set gets said.
170 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
171 + pub struct Figure<'a> {
172 + /// The number, formatted the way the app means it to read.
173 + pub value: &'a str,
174 + /// What it counts. The caption under the value.
175 + pub caption: &'a str,
176 + /// How the value has moved, if the app is tracking that.
177 + ///
178 + /// Text, for [`value`](Self::value)'s reason: only the app knows whether a
179 + /// move reads as `+12.5%`, `+3` or `2x`, and a renderer handed a number
180 + /// would have to guess.
181 + ///
182 + /// This is what [`tone`](Self::tone) was for and had no consumer of. The MNW
183 + /// server has four screens whose stat card is a label, a value and a delta,
184 + /// and the delta is the toned part: the figure itself is an ordinary fact
185 + /// and it is the movement that reads as good or bad. Without this the delta
186 + /// has to be folded into the caption, which loses the tone and reads as a
187 + /// longer caption rather than as a second, smaller line.
188 + pub change: Option<&'a str>,
189 + /// What the figure means right now. [`Tone::Neutral`] is an ordinary fact.
190 + ///
191 + /// Applies to [`change`](Self::change) where there is one, since that is the
192 + /// part that carries the judgement, and to the value where there is not.
193 + pub tone: Tone,
194 + }
195 +
196 + impl<'a> Figure<'a> {
197 + /// A figure that is an ordinary fact.
198 + #[must_use]
199 + pub const fn new(value: &'a str, caption: &'a str) -> Self {
200 + Self {
201 + value,
202 + caption,
203 + change: None,
204 + tone: Tone::Neutral,
205 + }
206 + }
207 +
208 + /// How the value has moved.
209 + #[must_use]
210 + pub const fn change(mut self, change: &'a str) -> Self {
211 + self.change = Some(change);
212 + self
213 + }
214 +
215 + /// What the figure means.
216 + #[must_use]
217 + pub const fn tone(mut self, tone: Tone) -> Self {
218 + self.tone = tone;
219 + self
220 + }
221 + }
222 +
223 + /// Something the user can do, and what it costs to say so.
224 + ///
225 + /// Beside [`Meter`] and [`Figure`] for the reason those are here: a renderer
226 + /// that is handed the parts has to decide how to say them, and a renderer that
227 + /// is handed a finished string has already had the decision made for it.
228 + ///
229 + /// No address. Where a control goes is the app's business and every host
230 + /// follows it differently — an `hx-get`, a protocol URL, a function call — so
231 + /// the description says what the control *is* and the caller keeps what it
232 + /// does. That is the same split [`Choice`] makes.
233 + ///
234 + /// No confirmation flag either, and that one is a finding rather than an
235 + /// omission: a question asked *after* a control is pressed belongs to whatever
236 + /// is holding the interaction, and a renderer that drew it would be asking
237 + /// before there was anything to answer.
238 + /// How a picture sits in the box it is given.
239 + ///
240 + /// An intent rather than a value, so a renderer picks the expression it has:
241 + /// `object-fit` in a webview, a texture's UV rect in egui, and in a terminal a
242 + /// choice about how many cells the blit gets. Named because MNW already makes
243 + /// the distinction deliberately at 17 sites and makes it three different ways,
244 + /// which is a policy the app decided rather than one a shared crate would be
245 + /// picking by accident.
246 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
247 + #[non_exhaustive]
248 + pub enum Fit {
249 + /// The picture's own proportions, and the box takes the height they imply.
250 + ///
251 + /// The default because it is the only one that shows the whole picture at
252 + /// its own shape, so a renderer that ignores this enum entirely is still
253 + /// right about the common case. A screenshot wants this; the shipped MNW
254 + /// carousel sets no `object-fit` at all, which is this.
255 + #[default]
256 + Natural,
257 + /// Fill the box and crop whatever does not fit.
258 + ///
259 + /// For a picture in a slot whose shape the layout fixed: a thumbnail, an
260 + /// avatar, cover art. 15 of MNW's 17 sites.
261 + Cover,
262 + /// Fit inside the box whole, leaving space on two sides.
263 + ///
264 + /// The letterbox. For when the whole picture matters more than filling the
265 + /// space, and the space is not the picture's shape.
266 + Contain,
267 + }
268 +
269 + /// A picture's own pixel dimensions.
270 + ///
271 + /// Deliberately not [`makeover_geometry`]'s business. Geometry answers *how
272 + /// much space a thing should get*, which is a scale question with the same
273 + /// answer on every screen. This is the intrinsic size of one asset, which is a
274 + /// fact about that asset and varies per picture.
275 + ///
276 + /// [`makeover_geometry`]: https://docs.rs/makeover-geometry
277 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
278 + pub struct Extent {
279 + /// Width in the picture's own pixels.
280 + pub width: u32,
281 + /// Height in the picture's own pixels.
282 + pub height: u32,
283 + }
284 +
285 + impl Extent {
286 + /// A picture's dimensions.
287 + #[must_use]
288 + pub const fn new(width: u32, height: u32) -> Self {
289 + Self { width, height }
290 + }
291 +
292 + /// Width over height, or `None` if either side is zero.
293 + ///
294 + /// The form a renderer actually reserves space with: a box that knows its
295 + /// proportion holds the right height at any width, which is what a
296 + /// responsive picture needs and what a fixed pixel height cannot give.
297 + #[must_use]
298 + pub fn ratio(self) -> Option<f32> {
299 + (self.width > 0 && self.height > 0).then(|| self.width as f32 / self.height as f32)
300 + }
301 + }
@@ -1,0 +1,224 @@
1 + use crate::Tone;
2 +
3 + // Names this module's prose links to, resolved for rustdoc.
4 + #[allow(unused_imports)]
5 + use crate::{Figure, Meter};
6 +
7 + /// What is in a region right now.
8 + ///
9 + /// The state, not the shimmer. Whether pending paints a skeleton, a spinner or
10 + /// nothing at all is renderer policy, the same class of decision that got
11 + /// `Fill::fallback` deleted from this crate. goingson and Balanced Breakfast
12 + /// each grew a skeleton with differently-named parts; both keep them, as the
13 + /// webview renderer's expression of [`Readiness::Pending`]. audiofiles has none
14 + /// and needs none, because an immediate-mode renderer simply repaints.
15 + ///
16 + /// # Four states and not two
17 + ///
18 + /// Naming only `Ready` and `Pending` leaves a screen whose list came back empty
19 + /// with nothing to say about it, so it renders an empty region or invents its
20 + /// own placeholder text and neither says what it is. Left to the apps, the
21 + /// class family drifts: `empty-state`, `empty-state--error`, `error-state` and
22 + /// six more.
23 + ///
24 + /// The four are one axis because they are mutually exclusive: a region shows its
25 + /// content, or a sign that it is coming, or a sign that there is none, or a sign
26 + /// that it broke. Never two. That is the test for one enum against several
27 + /// fields, and it is why this grew rather than a new member arriving beside it.
28 + ///
29 + /// # What is not here
30 + ///
31 + /// **The message.** "No projects yet" is content, and this names a state. It
32 + /// lives with whatever holds the region — in quasi's case a `Slot` — alongside
33 + /// the action that leads out of the emptiness, since an address is the one thing
34 + /// this crate never names.
35 + ///
36 + /// **How much room it gets.** goingson's `--compact`, `--dashboard` and
37 + /// `--padded` are the same state at three sizes, and a size is
38 + /// `makeover-geometry`'s question. Naming them here would be this crate stating
39 + /// values again.
40 + ///
41 + /// **The icon.** Presentation, and each host has its own answer or none.
42 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
43 + #[non_exhaustive]
44 + pub enum Readiness {
45 + /// The content is here.
46 + Ready,
47 + /// The content is on its way.
48 + ///
49 + /// For a region that changes *after* the first paint, and never for the
50 + /// first paint itself: see "First paint is final paint" in the crate header.
51 + /// A host that renders once, with its data already in hand, has nothing to
52 + /// say this about, and a screen arriving in this state is describing a
53 + /// moment its host should not have been in.
54 + ///
55 + /// What stands in occupies the geometry the content will occupy. A stand-in
56 + /// sized to itself rather than to what replaces it is the reflow the rule
57 + /// forbids, arriving one repaint later.
58 + Pending,
59 + /// The content arrived and there is none of it.
60 + ///
61 + /// Not a failure. An empty list is the normal state of a new install, and a
62 + /// renderer that drew it in a danger tone would be reporting a fault where
63 + /// there is none.
64 + Empty,
65 + /// The content did not arrive.
66 + Failed,
67 + }
68 +
69 + impl Readiness {
70 + /// Whether the region draws its own content, or something standing in for
71 + /// it.
72 + ///
73 + /// The question every renderer asks first, so it is answered once here
74 + /// rather than by a `matches!` in each. A state added later is a stand-in
75 + /// until proven otherwise: falling back to drawing content that may not be
76 + /// there is the worse of the two mistakes.
77 + #[must_use]
78 + pub const fn shows_content(self) -> bool {
79 + matches!(self, Self::Ready)
80 + }
81 +
82 + /// What the state means, for a renderer choosing a colour.
83 + ///
84 + /// Derived rather than carried, which is the opposite of [`Meter`] and
85 + /// [`Figure`], and the difference is worth stating: a proportion's meaning
86 + /// depends on what is being counted and only the app knows it, while
87 + /// "nothing here yet" and "this broke" mean the same thing in every app that
88 + /// will ever have them.
89 + #[must_use]
90 + pub const fn tone(self) -> Tone {
91 + match self {
92 + Self::Failed => Tone::Danger,
93 + _ => Tone::Neutral,
94 + }
95 + }
96 + }
97 +
98 + /// An action is waiting on something that resolves once, in expected finite
99 + /// time.
100 + ///
101 + /// The control-side sibling of [`Readiness`]. That enum names four states for a
102 + /// region and named nothing at all for the button that is currently doing what
103 + /// it was clicked for, so the in-flight treatment is hand-written wherever it
104 + /// exists: the MNW server carries 57 in-flight indicators against 2 guards
105 + /// against a second press, which is the spinner mostly present and the guard
106 + /// mostly absent, on a codebase whose money path is a purchase button.
107 + ///
108 + /// # What is described here, and what is not
109 + ///
110 + /// The fact is that there is an outstanding thing which will complete. Not that
111 + /// the address is remote: a heavy local query waits too, and a server calling a
112 + /// payment provider is not the browser leaving the app. Not that the call is
113 + /// slow either, which is a judgement about a call rather than a property of one.
114 + ///
115 + /// Resolving **once** is the boundary, and it is what separates this from a
116 + /// screen that keeps changing. A live screen never resolves and has no name in
117 + /// this crate yet.
118 + ///
119 + /// # One mark, two renderings
120 + ///
121 + /// | what reads it | what it does |
122 + /// |---|---|
123 + /// | a control that was pressed | goes busy and refuses a second press until it resolves |
124 + /// | a region fed by it | stands in as [`Readiness::Pending`], then fills |
125 + ///
126 + /// The two were on the table separately and both were taken. Controls alone
127 + /// leaves a slow region hand-split into its own route, which is what MNW's user
128 + /// dashboard does with its payout summary; regions alone leaves the purchase
129 + /// button unguarded.
130 + ///
131 + /// # A quantity when it is measured, never a duration
132 + ///
133 + /// [`amount`](Self::amount) is stated only when it is a measured fact about the
134 + /// payload. An upload's file length, yes; a round trip to a payment provider,
135 + /// [`None`]. A duration is described nowhere, and a renderer may not manufacture
136 + /// one from the amount either: a determinate bar shows what is done over what
137 + /// there is, plus the time it has taken so far, and never a remaining time, an
138 + /// arrival time or a rate extrapolated forwards. A prediction is wrong the
139 + /// moment the transfer stalls, and being confidently wrong is worse than being
140 + /// honestly indeterminate.
141 + ///
142 + /// This is why the crate refuses to say how long an undo stays offered and
143 + /// accepts a byte count here. The refusal is about naming a decision that
144 + /// belongs to the renderer; a file's length is not a decision, nobody chose it.
145 + ///
146 + /// # Not [`Meter`]
147 + ///
148 + /// [`Meter`] is how much of a set is done, and its own docs refuse the progress
149 + /// of an operation on the grounds that a description is built once and dropped
150 + /// while an operation runs between renders. That refusal stands. This names the
151 + /// operation and its size, which is all that is known before it starts; how much
152 + /// of it has gone through is the renderer's to observe live, and nothing round
153 + /// trips through a description to say so.
154 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
155 + #[non_exhaustive]
156 + pub struct Awaiting {
157 + /// Total work to get through, when it is a measured fact about the payload.
158 + ///
159 + /// `None` when the wait has no countable size, which is the common case and
160 + /// the default.
161 + ///
162 + /// Unit-agnostic on purpose. Bytes for an upload, rows for an import; what
163 + /// is being counted is the app's business and a renderer draws a proportion
164 + /// either way.
165 + pub amount: Option<u64>,
166 + }
167 +
168 + impl Awaiting {
169 + /// A wait with no countable size.
170 + #[must_use]
171 + pub const fn unmeasured() -> Self {
172 + Self { amount: None }
173 + }
174 +
175 + /// A wait whose size is known.
176 + ///
177 + /// Reach for it only with a measured figure. An estimate written in here is
178 + /// a prediction wearing a fact's clothes, and the renderer has no way to
179 + /// tell the two apart.
180 + #[must_use]
181 + pub const fn of(amount: u64) -> Self {
182 + Self {
183 + amount: Some(amount),
184 + }
185 + }
186 +
187 + /// Whether there is a proportion to draw.
188 + ///
189 + /// The question every renderer asks first, answered once here rather than by
190 + /// a `matches!` in each. False means indeterminate, which is the honest
191 + /// drawing when nothing countable was measured.
192 + #[must_use]
193 + pub const fn is_determinate(self) -> bool {
194 + self.amount.is_some()
195 + }
196 + }
197 +
198 + /// When a picture is needed.
199 + ///
200 + /// A claim about *importance and position* rather than a fetch mechanism, which
201 + /// is why it is the description's to make: only the app knows whether a picture
202 + /// is the first thing on the screen or the fortieth thing down a list.
203 + ///
204 + /// # Eager is the default, and that is a correctness choice
205 + ///
206 + /// Emitting the webview's `loading="lazy"` for every picture reads one
207 + /// consumer's habit as a rule. Deferring a picture that is on screen at first paint does not
208 + /// save anything -- it is needed immediately either way -- and it delays the
209 + /// arrival, so the space it eventually takes is claimed later and the shift is
210 + /// more visible, not less.
211 + ///
212 + /// So the safe answer is the default and the optimisation is opted into. A
213 + /// carousel is the case that proves the two cannot be one setting for the
214 + /// renderer to choose: its first frame is on screen and its other frames are
215 + /// not, in the same widget, at the same moment.
216 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
217 + #[non_exhaustive]
218 + pub enum Loading {
219 + /// Needed with the screen. Fetch it now.
220 + #[default]
221 + Eager,
222 + /// Not on screen yet. It can wait until it is near.
223 + Lazy,
224 + }
A src/region.rs +500
@@ -1,0 +1,878 @@
1 + use crate::Depth;
2 +
3 + // Names this module's prose links to, resolved for rustdoc.
4 + #[allow(unused_imports)]
5 + use crate::{Fill, Heading, Readiness, RowPart, Selector, Sort, Track};
6 +
7 + /// A named part of a screen.
8 + ///
9 + /// The thing `makeover-geometry` deliberately does not name: it names the space
10 + /// *between* things by relationship, and nothing named the things. Six named
11 + /// members, taken from what the two webview apps actually use, plus
12 + /// [`Region::Handover`] and [`Region::Ceded`] for the parts no description
13 + /// should reach. Both apps'
14 + /// `layout.css` currently names exactly two things, `.raised` and `.well`, so
15 + /// this layer is absent rather than divergent, which makes it the cheapest of
16 + /// the schemas to add and the easiest to over-build.
17 + ///
18 + /// `#[non_exhaustive]`, for [`RowPart`]'s and [`Readiness`]' reason: the member
19 + /// after this one should not be a lockstep event across three renderers.
20 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
21 + #[non_exhaustive]
22 + pub enum Region<'a> {
23 + /// A full-width strip with a title slot and an actions cluster, either of
24 + /// which may be empty. goingson's `.page-header`, Balanced Breakfast's
25 + /// `.header` and `.detail-header` are all this, differing only in which
26 + /// slots they fill.
27 + Band,
28 + /// A persistent column beside the content, holding navigation.
29 + Sidebar,
30 + /// A region of content with its own scroll.
31 + Pane,
32 + /// Things that belong together, and nothing else.
33 + ///
34 + /// The block [`Heading::Section`] names, which the vocabulary otherwise
35 + /// cannot contain. A section heading is a leaf sitting
36 + /// *beside* the things it names, so nothing said where a section started or
37 + /// ended and a renderer learned one had ended only because the next heading
38 + /// arrived.
39 + ///
40 + /// # The measurement
41 + ///
42 + /// 41 [`Heading::Section`] sites across the ten screens described through
43 + /// the router, not one of them contained. audiofiles' settings screen is the
44 + /// clearest: one pane holding a heading, a field, a heading, two toggles, a
45 + /// heading, a toggle and a heading, which is four sections and no
46 + /// containers. Under the hand-written CSS the ports are replacing the same
47 + /// block is spelled `.settings-section` in goingson, `.form-section` and
48 + /// `.content-section` in the MNW server, `.help-section` in Balanced
49 + /// Breakfast: three apps, four names, one shape.
50 + ///
51 + /// # Why the existing members were the wrong answer
52 + ///
53 + /// [`Pane`](Self::Pane) is what apps reached for, and it is 28 of the 45
54 + /// regions in the described screens. It claims a scroll of its own and
55 + /// [`Depth::Well`], so four settings groups inside a pane are four wells
56 + /// inside a well and four scroll contexts. Neither claim is true of a group.
57 + ///
58 + /// [`Widget`](Self::Widget) is wrong from the other side. Its own docs say a
59 + /// widget is never how a primitive gets added by the back door, and a run of
60 + /// related controls under a heading is furniture any app would have, which
61 + /// is the generic-against-bespoke bar a primitive has to clear.
62 + ///
63 + /// # What it does not carry
64 + ///
65 + /// **A heading.** A group usually has one and it is an ordinary node in the
66 + /// body, the way it already was. A group of related toggles with no heading
67 + /// is a real thing and a mandatory slot would forbid it.
68 + ///
69 + /// **A depth.** [`Depth::Flat`], on [`Handover`](Self::Handover)'s reasoning:
70 + /// it inherits, and an app that wants its group in a well puts it in a
71 + /// [`Pane`](Self::Pane), which composes rather than adding a knob here.
72 + ///
73 + /// **A colour.** Distinguishing sibling groups by colour is the thing this
74 + /// member was asked for and it is deliberately not stated here. The
75 + /// description says these things belong together; which of the theme's
76 + /// categorical colours a renderer reaches for, and whether it reaches for
77 + /// one at all, is derived from sibling order at the renderer. A terminal
78 + /// that tints nothing and separates with a rule is honouring this.
79 + Group,
80 + /// Two panes side by side, where the left chooses what the right shows.
81 + Split,
82 + /// Peer regions across, all of them equals.
83 + ///
84 + /// A kanban board's columns, and the shape [`Split`](Self::Split) is not:
85 + /// a split's two panes stand in a master-detail relationship, where the
86 + /// left chooses what the right shows. These choose nothing about each
87 + /// other. Each is a whole region and the set is the arrangement.
88 + ///
89 + /// # What it does not carry
90 + ///
91 + /// **How many.** The children say, and a count here would be a second
92 + /// source for something the description already states by containing them.
93 + ///
94 + /// **How wide.** Peers are equal by definition, so there is no [`Share`] to
95 + /// state. A board whose columns wanted different widths would be a
96 + /// different member, and no app has one.
97 + ///
98 + /// **What happens when there is no room.** Scroll across, wrap, or collapse
99 + /// to one column at a time: all three are right on some host, none is
100 + /// derivable from the description, and every one of them is presentation.
101 + /// A terminal that stacks them vertically is honouring this, not degrading
102 + /// it.
103 + ///
104 + /// # Why it is not an `Arrangement`
105 + ///
106 + /// [`Arrangement`] is the page's shape, and a board is usually a region
107 + /// *inside* a page that also has a band over it. Naming it here composes;
108 + /// naming it there would make a screen either a board or a list-detail and
109 + /// never a band above a board. It also keeps [`Arrangement::share`]
110 + /// meaningful, which a peer arrangement has no answer for.
111 + Columns,
112 + /// A set of panes, one visible at a time, and a [`Selector::Tabs`] that
113 + /// chooses between them.
114 + ///
115 + /// Says nothing about where the strip sits. A row over the panes, a column
116 + /// beside them, a wrapped run of links under them: all three are the same
117 + /// member drawn by a renderer that knows its host, the way the strip's
118 + /// overflow is.
119 + TabGroup,
120 + /// Content over a scrim, taking input until dismissed.
121 + Modal,
122 + /// A region this crate names the *place* of, whose contents the app still
123 + /// owes every host.
124 + ///
125 + /// The escape hatch, and the thing that keeps the description honest about
126 + /// its own limits. A day-plan timeline, a kanban board, a calendar and the
127 + /// paint interaction over the timeline are not describable here and are not
128 + /// going to become describable: a description expressive enough to produce
129 + /// a timeline is a widget library wearing a description's name.
130 + ///
131 + /// But a screen containing one still has to be a screen. Without this
132 + /// member the description covers only the boring screens, and the four that
133 + /// make goingson worth using would need a second, undescribed path beside
134 + /// the router. Two paths is how the vocabulary starts drifting from the app
135 + /// again, which is the exact failure this crate exists to end.
136 + ///
137 + /// So the description says "a thing called `day-plan` goes here" and stops.
138 + /// The name is opaque: this crate never interprets it, and no renderer is
139 + /// expected to know what it means beyond handing the space over.
140 + ///
141 + /// # What separates it from [`Ceded`](Self::Ceded)
142 + ///
143 + /// **A fill is owed here in every host's currency.** A renderer handed one
144 + /// of these and given nothing to put in it is looking at a hole the app
145 + /// meant to fill, and saying so is the honest drawing. [`owed`](Self::owed)
146 + /// is how it asks.
147 + ///
148 + /// That is the whole of the split. Before it there was one opaque member,
149 + /// so a region the description had given up on and a region nobody had
150 + /// converted yet were the same value, and both drew as a silently empty
151 + /// box on the two renderers that answer no fill.
152 + Handover {
153 + /// What the app calls it. Never interpreted here.
154 + name: &'a str,
155 + },
156 + /// A region this crate names the place of, whose contents no host is owed.
157 + ///
158 + /// The other half of the old single opaque member. The app has decided this
159 + /// space is not the description's to fill and is not going to become so:
160 + /// a chart, a waveform, a rendered picture of domain data with marks
161 + /// painted over it at positions no description knows.
162 + ///
163 + /// **Silence is the correct drawing.** A renderer with no fill for this
164 + /// draws nothing and is right to; unlike [`Handover`](Self::Handover) there
165 + /// is nothing missing. That is what makes the pair worth two members rather
166 + /// than a flag: the two want opposite behaviour from a renderer that cannot
167 + /// fill them, and one name cannot carry both.
168 + ///
169 + /// The measured sites are MNW's analytics charts, which already carry the
170 + /// ruling that a bar chart is not describable and should not be, and
171 + /// audiofiles' waveform, whose exclusion had no vocabulary to live in and
172 + /// was recorded in a doc comment instead.
173 + Ceded {
174 + /// What the app calls it. Never interpreted here.
175 + name: &'a str,
176 + },
177 + /// A named assembly of things the vocabulary already says.
178 + ///
179 + /// The third tier, between a primitive and the two opaque members.
180 + ///
181 + /// # What separates it from the two members either side
182 + ///
183 + /// A primitive is a thing every renderer draws from scratch, and the test
184 + /// it has to pass is that every host has an honest answer. A carousel fails
185 + /// that test — a terminal has no carousel — which is the same refusal
186 + /// `Node::Html` got and is why the carousel sat unsayable for months.
187 + ///
188 + /// [`Handover`](Self::Handover) fails it from the other side. It is for
189 + /// what one app owns and nobody will build twice, and it carries *no*
190 + /// contents: the description names the place and stops. A carousel is
191 + /// furniture any app would have, and every part of it — an ordered set of
192 + /// frames, a position, prev and next, a strip of position indicators — is
193 + /// already sayable. Only the assembly had no name.
194 + ///
195 + /// So this member is the pair the other two are not: a name **and**
196 + /// contents. The contents are the assembly, in the region's own body, said
197 + /// in members that already exist.
198 + ///
199 + /// # Why the name does not have to be understood
200 + ///
201 + /// A renderer that recognises the name draws it the way its host does it: a
202 + /// carousel in a webview, a pager with a count in a terminal, a selector in
203 + /// egui. A renderer that does not recognise it walks the body, which is
204 + /// primitives all the way down and which it can already draw.
205 + ///
206 + /// That is what lets the widget set be **open** without every renderer
207 + /// knowing every widget. An unrecognised widget degrades to its assembly
208 + /// instead of failing, so a second or third party can name one without
209 + /// three renderers releasing in lockstep to accept it. Contrast
210 + /// [`Handover`](Self::Handover), which no renderer can degrade: there is
211 + /// nothing under it to fall back to.
212 + ///
213 + /// # What it does not do
214 + ///
215 + /// A widget is an assembly of things the vocabulary *already* says, so it
216 + /// buys no expressive power. Anything needing a member the vocabulary does
217 + /// not have is a finding about the vocabulary, and the answer to a finding
218 + /// is to add the member. A widget is never the way a primitive gets added
219 + /// by the back door. A timeline is describable because [`Track`] was added
220 + /// to say it, not because a screen was dressed up as an assembly.
221 + Widget {
222 + /// What the assembly is called. This crate never interprets it, and a
223 + /// renderer is free not to know it.
224 + name: &'a str,
225 + },
226 + }
227 +
228 + impl<'a> Region<'a> {
229 + /// How the region sits on what is behind it.
230 + #[must_use]
231 + pub const fn depth(self) -> Depth {
232 + match self {
233 + Self::Band | Self::Sidebar | Self::Split | Self::TabGroup => Depth::Flat,
234 + // Flat, and it inherits. A group says its contents belong together
235 + // and says nothing about the surface they sit on, so a group in a
236 + // pane is in a well and a group on the page is on the page. An app
237 + // wanting one lifted puts it in a `Pane`.
238 + Self::Group => Depth::Flat,
239 + // Flat, and it is the container rather than the columns. Each
240 + // column is its own region and brings its own depth; a well here
241 + // would put a second edge around a row of wells.
242 + Self::Columns => Depth::Flat,
243 + // A pane is looked into, the same as a table body or a tag tree.
244 + Self::Pane => Depth::Well,
245 + Self::Modal => Depth::Raised,
246 + // Flat because it inherits: a bespoke region takes the depth of
247 + // whatever frames it. An app that wants its timeline in a well puts
248 + // it in a `Pane`, which composes rather than adding a knob here.
249 + //
250 + // A widget inherits for the same reason and it matters more here,
251 + // because a widget is drawn by whichever renderer recognises it. A
252 + // depth set here would be this crate deciding that a carousel is
253 + // raised on every host, which is the kind of value the deferral
254 + // rule exists to refuse.
255 + Self::Handover { .. } | Self::Ceded { .. } | Self::Widget { .. } => Depth::Flat,
256 + }
257 + }
258 +
259 + /// Whether this crate can say anything about the region's contents.
260 + ///
261 + /// A renderer walks the description and hands every region it understands
262 + /// to the right drawing code. This is how it tells the two apart, and the
263 + /// reason it is a method rather than a `matches!` at each renderer: there
264 + /// is exactly one opaque member and there should stay exactly one.
265 + ///
266 + /// [`Widget`](Self::Widget) is described, and that is the whole of what
267 + /// separates it from the two opaque members here. All three carry a name
268 + /// this crate never interprets; only the widget carries contents under it.
269 + /// A renderer that does not recognise a widget's name still walks its body,
270 + /// so there is nothing for it to hand over and nothing it cannot draw.
271 + #[must_use]
272 + pub const fn described(self) -> bool {
273 + !matches!(self, Self::Handover { .. } | Self::Ceded { .. })
274 + }
275 +
276 + /// Whether a fill is owed here, for a renderer that has none.
277 + ///
278 + /// The question the single opaque member could not answer. True for
279 + /// [`Handover`](Self::Handover): the app meant to fill this and a renderer
280 + /// with nothing to put in it should say so. False for everything else,
281 + /// [`Ceded`](Self::Ceded) included, where silence is the correct drawing
282 + /// because nothing is missing.
283 + ///
284 + /// A method rather than a `matches!` at each renderer, for
285 + /// [`described`](Self::described)'s reason: three renderers writing the
286 + /// same match is three chances to disagree about what an empty region
287 + /// means.
288 + #[must_use]
289 + pub const fn owed(self) -> bool {
290 + matches!(self, Self::Handover { .. })
291 + }
292 +
293 + /// The name an app gave this region, if it gave one.
294 + ///
295 + /// [`Handover`](Self::Handover), [`Ceded`](Self::Ceded) and
296 + /// [`Widget`](Self::Widget) are the members that carry a name, for two
297 + /// different purposes: the first two say what the app puts in the space,
298 + /// the third says what the assembly under it is called. A renderer dispatching on either wants the string without
299 + /// caring which member it came from, and writing that `matches!` at each
300 + /// renderer is how the two drift apart.
301 + #[must_use]
302 + pub const fn name(self) -> Option<&'a str> {
303 + match self {
304 + Self::Handover { name } | Self::Ceded { name } | Self::Widget { name } => Some(name),
305 + // Spelled out rather than a wildcard, so a member added later has
306 + // to answer whether it carries a name instead of inheriting `None`
307 + // by sitting under a `_`.
308 + Self::Band
309 + | Self::Sidebar
310 + | Self::Pane
311 + | Self::Group
312 + | Self::Split
313 + | Self::Columns
314 + | Self::TabGroup
315 + | Self::Modal => None,
316 + }
317 + }
318 + }
319 +
320 + /// How many of a region's children are visible at once.
321 + ///
322 + /// One sentence covering three shapes: *this region holds several children and
323 + /// shows some of them, and the reader can change which.* A tab group, a
324 + /// carousel and a disclosure all need it, and without it a renderer has two
325 + /// moves: hardcode a widget name, or draw every child. That is what puts
326 + /// per-widget code in renderers.
327 + ///
328 + /// # What is here and what is not
329 + ///
330 + /// The *kind*, and only the kind. Which child is currently up is the current
331 + /// answer, and a layer that defers every address does not hold the current
332 + /// answer either — the split [`Selector`] already makes, where this crate says
333 + /// what kind of chooser a thing is and the router says which option is picked.
334 + /// So a holder of regions carries the index and the per-child label beside this.
335 + ///
336 + /// # What a renderer does with it
337 + ///
338 + /// Derives its chrome, once, for every widget rather than per name:
339 + ///
340 + /// - Children carrying labels get a strip of the labels, the current one marked.
341 + /// - Children carrying none get previous, position, next.
342 + /// - [`AtMostOne`](Self::AtMostOne) over one child gets a summary line that
343 + /// opens.
344 + ///
345 + /// The name on [`Region::Widget`] survives as app vocabulary, for a renderer
346 + /// that wants to do something *special* with one, which is what it should have
347 + /// been from the start.
348 + ///
349 + /// Degradation runs the way it already did: a renderer ignoring this draws every
350 + /// child, which is more content rather than less.
351 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
352 + #[non_exhaustive]
353 + pub enum Showing {
354 + /// Every child, in order. What every region did before this existed.
355 + #[default]
356 + All,
357 + /// Exactly one. A carousel, a tab group.
358 + One,
359 + /// One, or none. A disclosure, which is closed until it is opened.
360 + AtMostOne,
361 + }
362 +
363 + impl Showing {
364 + /// Whether the reader can change which child is up.
365 + ///
366 + /// The question every renderer's region arm asks before deriving any
367 + /// chrome, and a method rather than a `matches!` at each renderer for
368 + /// [`Region::name`]'s reason: three renderers writing the same comparison is
369 + /// how they come to disagree about a member added later.
370 + #[must_use]
371 + pub const fn selective(self) -> bool {
372 + !matches!(self, Self::All)
373 + }
374 +
375 + /// Whether showing nothing is a legal state.
376 + ///
377 + /// True only for [`AtMostOne`](Self::AtMostOne). A renderer needs this to
378 + /// know whether its control closes as well as moves: a carousel's row moves
379 + /// between frames and never reaches empty, and a disclosure's summary line
380 + /// is the same control wearing its closed state.
381 + #[must_use]
382 + pub const fn dismissible(self) -> bool {
383 + matches!(self, Self::AtMostOne)
384 + }
385 + }
386 +
387 + /// A window onto a sequence: where it starts, how much it covers, and how long
388 + /// the sequence is when that is known.
389 + ///
390 + /// The mechanism under two things the vocabulary deliberately keeps apart. A
391 + /// carousel is a window of one frame over children that are all present; a
392 + /// paged list is a window of a page over rows most of which were never fetched.
393 + /// Those are different facts and they stay different types — [`Showing`] says
394 + /// which child is up, [`Paging`] says where a reader is in a query — but the
395 + /// arithmetic underneath is one piece of code, so a terminal and a browser
396 + /// cannot come to disagree about which frame is last.
397 + ///
398 + /// # Why `of` is optional and `count` is not
399 + ///
400 + /// `count` is what is on screen and is therefore always known. `of` is the
401 + /// length of the thing being windowed, and a host that cannot count says so by
402 + /// leaving it empty **for the life of the screen**. It is never "not counted
403 + /// yet": see "First paint is final paint" in the crate header. A total that
404 + /// turns up on a later pass widens the text that prints it.
405 + ///
406 + /// # Clamping
407 + ///
408 + /// Every derivation clamps rather than refusing, and a zero `count` answers
409 + /// `None` rather than dividing. A window past the end is a bug in the host, and
410 + /// a renderer that answered it by drawing nothing would report a region that
411 + /// vanished, which is the hardest kind of bug to find from what is on screen.
412 + /// [`Share::percent`] clamps for the same reason.
413 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
414 + pub struct Window {
415 + /// The index into the sequence where the window starts.
416 + pub from: usize,
417 + /// How many the window covers. One, for a carousel.
418 + pub count: usize,
419 + /// How long the sequence is, when the host can say.
420 + pub of: Option<usize>,
421 + }
422 +
423 + impl Window {
424 + /// A window of `count`, starting at `from`, over a sequence of unknown
425 + /// length.
426 + #[must_use]
427 + pub const fn new(from: usize, count: usize) -> Self {
428 + Self {
429 + from,
430 + count,
431 + of: None,
432 + }
433 + }
434 +
435 + /// How long the sequence is.
436 + #[must_use]
437 + pub const fn of(mut self, of: usize) -> Self {
438 + self.of = Some(of);
439 + self
440 + }
441 +
442 + /// One item of a sequence whose length is known. A carousel frame.
443 + #[must_use]
444 + pub const fn frame(at: usize, of: usize) -> Self {
445 + Self {
446 + from: at,
447 + count: 1,
448 + of: Some(of),
449 + }
450 + }
451 +
452 + /// Which window this is, counting from zero.
453 + ///
454 + /// `None` when `count` is zero, which is the only input with no answer
455 + /// rather than a clamped one.
456 + #[must_use]
457 + pub const fn index(self) -> Option<usize> {
458 + if self.count == 0 {
459 + return None;
460 + }
461 + Some(self.from / self.count)
462 + }
463 +
464 + /// How many windows the sequence holds.
465 + ///
466 + /// `None` unless both the length and a non-zero `count` are known. A
467 + /// partial answer here would be a renderer drawing "of 0".
468 + #[must_use]
469 + pub const fn windows(self) -> Option<usize> {
470 + match self.of {
471 + Some(of) if self.count > 0 => Some(of.div_ceil(self.count)),
472 + _ => None,
473 + }
474 + }
475 +
476 + /// Whether anything sits before this window.
477 + #[must_use]
478 + pub const fn has_before(self) -> bool {
479 + self.from > 0
480 + }
481 +
482 + /// How many sit after this window, when the length is known.
483 + ///
484 + /// Here rather than in each renderer for [`Showing::selective`]'s reason:
485 + /// three of them writing the same subtraction is how they come to disagree,
486 + /// and this one has an underflow in it for whoever writes it fourth.
487 + #[must_use]
488 + pub const fn after(self) -> Option<usize> {
489 + match self.of {
490 + Some(of) => Some(of.saturating_sub(self.from.saturating_add(self.count))),
491 + None => None,
492 + }
493 + }
494 +
495 + /// Whether anything sits after it.
496 + ///
497 + /// `true` when the length is unknown: a host that cannot count cannot rule
498 + /// out more, and offering a way forward that turns out to be empty is the
499 + /// cheaper of the two mistakes.
500 + #[must_use]
Lines truncated
A src/text.rs +147
@@ -1,0 +1,147 @@
1 + use crate::Intent;
2 +
3 + /// What a run of source code is, once something has classified it.
4 + ///
5 + /// The description carries the classification and never the source, which is
6 + /// the whole of decision `19d7602d` (2026-09-02, option d). An app that browses
7 + /// source already has a lexer; a renderer does not and should not grow one, and
8 + /// three renderers each growing their own would disagree about the same file.
9 + ///
10 + /// # Why the app classifies and the renderer colours
11 + ///
12 + /// Measured on MNW's source browser, which is the only consumer in the tree.
13 + /// It highlights server-side with syntect and had already reduced syntect's
14 + /// scope space to seven colours held fixed across all 31 themes, because a
15 + /// reader recognises a highlighting palette and re-tinting it per theme costs
16 + /// that recognition to gain nothing. So the classification existed on the app
17 + /// side already: the only question was whether to throw it away at the seam and
18 + /// have each renderer redo it. This is the answer.
19 + ///
20 + /// The precedent is `docengine`'s `Emphasis`, which crosses the same seam the
21 + /// same way: `quasi-tui` maps its four flags onto terminal modifiers, a webview
22 + /// maps them onto elements, and neither parses markdown to do it.
23 + ///
24 + /// # The eight, and why these eight
25 + ///
26 + /// The seven MNW's palette fixes, plus [`Plain`](Self::Plain) for a run nothing
27 + /// claimed. `Plain` is not an absence: a lexer that ran and found ordinary code
28 + /// is saying something a renderer wants, and an `Option<Syntax>` would have made
29 + /// "unclassified" and "not classified yet" one value.
30 + ///
31 + /// `#[non_exhaustive]` from the first commit, deliberately. A ninth class is the
32 + /// obvious next request and it must not be a breaking release across three
33 + /// renderers and five apps.
34 + ///
35 + /// # What it is not
36 + ///
37 + /// A token type in a grammar. These are display classes, coarse on purpose:
38 + /// the distinctions a reader uses at a glance, not the ones a parser makes.
39 + /// A renderer wanting more has `language` on the node beside this and may do
40 + /// whatever it likes with it.
41 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
42 + #[non_exhaustive]
43 + pub enum Syntax {
44 + /// Ordinary code nothing else claimed.
45 + ///
46 + /// The default, and a real answer rather than a missing one. See the type's
47 + /// docs for why this is not an `Option`.
48 + #[default]
49 + Plain,
50 + /// A language keyword, and the storage and modifier words with it.
51 + Keyword,
52 + /// A string or character literal.
53 + String,
54 + /// A comment.
55 + Comment,
56 + /// A literal that is not a string: a number, a boolean, a constant name.
57 + Constant,
58 + /// A name being defined: a function, a type, a module.
59 + Entity,
60 + /// A name being used: a variable, a parameter, a field.
61 + Variable,
62 + /// Something the language or its library provides rather than this file.
63 + Support,
64 + }
65 +
66 + impl Syntax {
67 + /// The stable lowercase name, for a renderer keying its own palette off it.
68 + ///
69 + /// Named here rather than agreed between each renderer and each host, for
70 + /// the reason every other spelling in this crate is: that is how one
71 + /// renderer ends up calling it `str` and the next `string`, and a theme
72 + /// written against one stops working under the other.
73 + ///
74 + /// Not an [`Intent`] token, and the difference is worth stating. An intent
75 + /// resolves against a `makeover` theme token, and there are none for syntax
76 + /// colours: a highlighting palette is deliberately outside the theme, held
77 + /// fixed while everything around it changes. So this is a name a renderer
78 + /// maps however it can, and a renderer with no colours to spend maps every
79 + /// one of them onto the same face and is not wrong.
80 + #[must_use]
81 + pub const fn name(self) -> &'static str {
82 + match self {
83 + Self::Plain => "plain",
84 + Self::Keyword => "keyword",
85 + Self::String => "string",
86 + Self::Comment => "comment",
87 + Self::Constant => "constant",
88 + Self::Entity => "entity",
89 + Self::Variable => "variable",
90 + Self::Support => "support",
91 + // Exhaustive rather than wildcarded, which is `State`'s rule one
92 + // type up: within this crate `#[non_exhaustive]` does not apply, so
93 + // a class added here has to be given a spelling rather than
94 + // inheriting "plain" in silence. Consumers outside the crate take
95 + // the wildcard, and for them "plain" is the right degradation.
96 + }
97 + }
98 + }
99 +
100 + /// What one line of a diff is: added, removed, or neither.
101 + ///
102 + /// The other half of decision `19d7602d`. A diff is a table of lines and the
103 + /// only thing the vocabulary was missing was a way for a line to say which side
104 + /// of the change it is on, so this rides on [`Cells`] rather than arriving as a
105 + /// `Node::Diff` carrying git's data model.
106 + ///
107 + /// # Why three and not two
108 + ///
109 + /// [`Context`](Self::Context) is a line that did not change, and it is most of
110 + /// a diff. Said as `Option<Change>` with `None` for context, a renderer could
111 + /// not tell an unchanged line from a line nobody marked, which is the same
112 + /// argument [`Syntax::Plain`] makes one type up.
113 + ///
114 + /// `#[non_exhaustive]` for [`Syntax`]'s reason. A fourth kind -- a moved line, a
115 + /// conflict side -- is a plausible request and must not be a breaking release.
116 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
117 + #[non_exhaustive]
118 + pub enum Change {
119 + /// The line is in both sides and did not change.
120 + #[default]
121 + Context,
122 + /// The line is only in the new side.
123 + Added,
124 + /// The line is only in the old side.
125 + Removed,
126 + }
127 +
128 + impl Intent for Change {
129 + fn token(self) -> &'static str {
130 + match self {
131 + // The status axis, reused rather than given a palette of its own.
132 + // A diff's green and red mean exactly what success and danger mean
133 + // everywhere else in the system, and an app that wants them held
134 + // fixed across themes says so in its own sheet, which is what MNW's
135 + // `--diff-add` / `--diff-del` pair already does.
136 + Self::Added => "success",
137 + Self::Removed => "danger",
138 + // Context is the bulk of a diff and is ordinary content. Muting it
139 + // would be the axis deciding that unchanged lines are less worth
140 + // reading, which is a renderer's call about a particular view.
141 + //
142 + // Exhaustive for `Syntax::name`'s reason: a fourth kind must answer
143 + // this rather than inherit an answer.
144 + Self::Context => "content",
145 + }
146 + }
147 + }
@@ -1,0 +1,174 @@
1 + // Names this module's prose links to, resolved for rustdoc.
2 + #[allow(unused_imports)]
3 + use crate::{Choice, Field};
4 +
5 + /// Which ambient mode a theme is written for.
6 + ///
7 + /// The vocabulary's own spelling of what `makeover` calls a theme's variant,
8 + /// and the duplication is deliberate rather than an oversight. This crate has
9 + /// no dependencies by charter — it emits nothing, reads nothing and resolves
10 + /// nothing — so it cannot take the crate that owns the file format, and a
11 + /// renderer that must group a picker needs the three groups as values.
12 + ///
13 + /// The two are kept in step by the app that converts between them, which is a
14 + /// three-arm `match` at each adopter and the price of the layering. If a fourth
15 + /// mode is ever authored, this enum and `makeover::Variant` move together.
16 + ///
17 + /// Three, not two: one shipped theme is high contrast, and an app matching on
18 + /// light-or-dark alone files it under the wrong one.
19 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
20 + #[non_exhaustive]
21 + pub enum ThemeVariant {
22 + /// Written for a light ambient mode.
23 + Light,
24 + /// Written for a dark ambient mode.
25 + Dark,
26 + /// Written to be legible before it is pretty.
27 + HighContrast,
28 + }
29 +
30 + impl ThemeVariant {
31 + /// The machine spelling, matching the theme file's own `meta.variant`.
32 + ///
33 + /// A data attribute, a stored value, a test assertion. Not a heading: what
34 + /// a group is *called* on screen is [`heading`](Self::heading).
35 + #[must_use]
36 + pub const fn as_str(self) -> &'static str {
37 + match self {
38 + ThemeVariant::Light => "light",
39 + ThemeVariant::Dark => "dark",
40 + ThemeVariant::HighContrast => "high-contrast",
41 + }
42 + }
43 +
44 + /// What the group of themes in this variant is called on screen.
45 + ///
46 + /// Here rather than at each renderer, which is the whole argument for the
47 + /// member existing: three renderers picking their own headings is one
48 + /// picker reading three ways, and the spellings below are the ones
49 + /// goingson's shipped picker used before it was described.
50 + #[must_use]
51 + pub const fn heading(self) -> &'static str {
52 + match self {
53 + ThemeVariant::Light => "Light",
54 + ThemeVariant::Dark => "Dark",
55 + ThemeVariant::HighContrast => "High Contrast",
56 + }
57 + }
58 + }
59 +
60 + impl std::fmt::Display for ThemeVariant {
61 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
62 + f.write_str(self.as_str())
63 + }
64 + }
65 +
66 + /// How legible a theme measured, as a picker reports it.
67 + ///
68 + /// A measurement carried into the description, which is unusual here and is the
69 + /// one case that earns it: the number comes off the theme's resolved colours,
70 + /// so the layer that loaded the theme is the only party that has it, and an app
71 + /// re-deriving it would be parsing every theme file a second time to learn what
72 + /// was already known. What a renderer does with it is a badge beside the name.
73 + ///
74 + /// Ordered worst-first, matching `makeover::ContrastTier`, so the two sort the
75 + /// same way and an adopter's `match` cannot invert an ordering by accident.
76 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
77 + #[non_exhaustive]
78 + pub enum Contrast {
79 + /// Muted text below the 3:1 floor for large text and UI parts.
80 + Low,
81 + /// Muted text clears 3:1 but not the 4.5:1 bar for normal text.
82 + Standard,
83 + /// Muted text meets WCAG AA on every panel ground.
84 + High,
85 + }
86 +
87 + impl Contrast {
88 + /// The machine spelling, for a data attribute or a test.
89 + #[must_use]
90 + pub const fn as_str(self) -> &'static str {
91 + match self {
92 + Contrast::Low => "low",
93 + Contrast::Standard => "standard",
94 + Contrast::High => "high",
95 + }
96 + }
97 +
98 + /// The short mark shown beside a theme's name.
99 + ///
100 + /// One spelling for the tree, for [`ThemeVariant::heading`]'s reason. These
101 + /// are the marks audiofiles shipped before its picker was described, which
102 + /// is the only implementation that ever drew them.
103 + ///
104 + /// [`Standard`](Self::Standard) is not the absence of a mark: a reader
105 + /// scanning a column of badges learns more from three marks than from two
106 + /// and a gap, and "OK" is the honest reading of a theme that clears the UI
107 + /// floor and misses the text one.
108 + #[must_use]
109 + pub const fn badge(self) -> &'static str {
110 + match self {
111 + Contrast::Low => "low",
112 + Contrast::Standard => "OK",
113 + Contrast::High => "AA",
114 + }
115 + }
116 + }
117 +
118 + impl std::fmt::Display for Contrast {
119 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
120 + f.write_str(self.as_str())
121 + }
122 + }
123 +
124 + /// One theme, as a picker offers it.
125 + ///
126 + /// Four facts where a [`Choice`] has two, and the two extra ones are why this
127 + /// is its own type rather than options with the variant folded into the label.
128 + /// Both are facts the theme layer resolved and neither survives being written
129 + /// into a string: a group is structure and a badge is a second column.
130 + ///
131 + /// # No `unavailable`
132 + ///
133 + /// [`Choice::unavailable`]'s counterpart is absent for its own sibling's
134 + /// reason. A theme that is installed can be picked, and a theme that is not
135 + /// installed is not in the list. There is no third state for a reason to
136 + /// explain.
137 + ///
138 + /// `#[non_exhaustive]` from birth, so a new member costs no call site.
139 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
140 + #[non_exhaustive]
141 + pub struct ThemeChoice<'a> {
142 + /// What is submitted, and what the app stores.
143 + pub id: &'a str,
144 + /// What is read.
145 + pub name: &'a str,
146 + /// Which group it belongs to.
147 + pub variant: ThemeVariant,
148 + /// How legible its muted text measured.
149 + pub contrast: Contrast,
150 + }
151 +
152 + impl<'a> ThemeChoice<'a> {
153 + /// A theme, with everything a picker needs to place and mark it.
154 + ///
155 + /// Every fact is an argument and none is a builder, which is the opposite
156 + /// of [`Choice`]'s arrangement and is deliberate: a theme missing its
157 + /// variant has no group to sit in and a theme missing its tier has no badge
158 + /// to draw, so both are the control rather than embellishments on it. The
159 + /// same reasoning [`Field::range`] applies to its bounds.
160 + #[must_use]
161 + pub const fn new(
162 + id: &'a str,
163 + name: &'a str,
164 + variant: ThemeVariant,
165 + contrast: Contrast,
166 + ) -> Self {
167 + Self {
168 + id,
169 + name,
170 + variant,
171 + contrast,
172 + }
173 + }
174 + }
A src/track.rs +281
@@ -1,0 +1,281 @@
1 + // Names this module's prose links to, resolved for rustdoc.
2 + #[allow(unused_imports)]
3 + use crate::{Region, Share, Tone};
4 +
5 + /// What a [`Track`]'s integers count.
6 + ///
7 + /// `Track::fraction` never needed this -- the arithmetic is the same whatever
8 + /// the numbers mean -- which is exactly how the ruler came to assume minutes
9 + /// and print `00:00` over a month. A renderer drawing an axis has to write a
10 + /// label, and it cannot derive the unit from the numbers.
11 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
12 + #[non_exhaustive]
13 + pub enum Unit {
14 + /// Minutes from the start of a day. A day view.
15 + #[default]
16 + Minutes,
17 + /// Whole days. A month strip, a sprint, a stretch of leave.
18 + ///
19 + /// A day-granularity axis is a *strip*, not a calendar: one line with
20 + /// spans laid along it. What it deliberately does not do is wrap into
21 + /// weeks, which is the shape that makes weekday periodicity visible and
22 + /// the one job of a month grid that a strip cannot take over. See the
23 + /// crate header.
24 + Days,
25 + }
26 +
27 + /// A window on an axis, in whatever [`Unit`] its [`Track`] counts.
28 + ///
29 + /// The axis a [`Track`] draws. Offsets rather than instants, because a
30 + /// description carrying a `DateTime` would carry a timezone with it and the
31 + /// vocabulary has no business holding one. The app knows which day or month
32 + /// this is; the description says how far along it a thing sits.
33 + ///
34 + /// `to` is exclusive and may exceed the natural period, which is how a span
35 + /// running past the end is said without a second date: under
36 + /// [`Unit::Minutes`], `Span::new(1320, 1560)` is 22:00 to 02:00.
37 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
38 + pub struct Span {
39 + from: u16,
40 + to: u16,
41 + }
42 +
43 + impl Span {
44 + /// Midnight to midnight, the ordinary day.
45 + pub const DAY: Self = Self { from: 0, to: 1440 };
46 +
47 + /// A span, clamped to a sane one.
48 + ///
49 + /// An empty or backwards span is a caller bug that should not cost a
50 + /// renderer a division by zero, so `to` is forced at least one minute past
51 + /// `from` rather than returning an error nobody can act on. Same reasoning
52 + /// as [`Share::percent`], which clamps rather than refuses.
53 + #[must_use]
54 + pub const fn new(from: u16, to: u16) -> Self {
55 + Self {
56 + from,
57 + to: if to > from { to } else { from + 1 },
58 + }
59 + }
60 +
61 + /// The first minute on the axis.
62 + #[must_use]
63 + pub const fn from(self) -> u16 {
64 + self.from
65 + }
66 +
67 + /// One past the last minute on the axis.
68 + #[must_use]
69 + pub const fn to(self) -> u16 {
70 + self.to
71 + }
72 +
73 + /// How much the axis covers, in its track's unit. Never zero.
74 + #[must_use]
75 + pub const fn length(self) -> u16 {
76 + self.to - self.from
77 + }
78 +
79 + /// Whether an offset falls on this axis.
80 + #[must_use]
81 + pub const fn holds(self, minute: u16) -> bool {
82 + minute >= self.from && minute < self.to
83 + }
84 + }
85 +
86 + impl Default for Span {
87 + fn default() -> Self {
88 + Self::DAY
89 + }
90 + }
91 +
92 + /// Where a thing sits on a [`Track`], and for how long.
93 + ///
94 + /// The one fact a list cannot carry and the whole reason this primitive exists.
95 + /// A list says what order things come in; a track says a thing starts 135
96 + /// minutes along and lasts 45, which is a different claim and not derivable
97 + /// from the first.
98 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
99 + pub struct Placement {
100 + at: u16,
101 + length: u16,
102 + }
103 +
104 + impl Placement {
105 + /// A placement, clamped to a drawable one.
106 + ///
107 + /// Zero length becomes one for the same reason [`Span::new`] clamps: a
108 + /// zero-height thing is invisible rather than expressive, and every
109 + /// renderer would need its own guard.
110 + #[must_use]
111 + pub const fn new(at: u16, length: u16) -> Self {
112 + Self {
113 + at,
114 + length: if length == 0 { 1 } else { length },
115 + }
116 + }
117 +
118 + /// Offset from the axis origin, matching [`Span`]'s.
119 + #[must_use]
120 + pub const fn at(self) -> u16 {
121 + self.at
122 + }
123 +
124 + /// How long it lasts, in its track's unit. Never zero.
125 + #[must_use]
126 + pub const fn length(self) -> u16 {
127 + self.length
128 + }
129 +
130 + /// One past its last minute.
131 + #[must_use]
132 + pub const fn end(self) -> u16 {
133 + self.at + self.length
134 + }
135 +
136 + /// Whether two placements cover any of the same time.
137 + ///
138 + /// Geometry, and deliberately not a described field. Whether an overlap is
139 + /// a *conflict* is the app's judgment -- a meeting inside a block of free
140 + /// time overlaps and is fine -- and that judgment travels the way every
141 + /// other judgment does, as a [`Tone`] on the thing itself. What a renderer
142 + /// needs in order to lay two things side by side instead of on top of each
143 + /// other is this, and it can compute it.
144 + ///
145 + /// The alternative was a `conflicts: bool` on each entry, which is state
146 + /// that can disagree with the times beside it. Two sources for one fact is
147 + /// how a screen starts rendering a conflict badge on a thing that no longer
148 + /// conflicts.
149 + #[must_use]
150 + pub const fn overlaps(self, other: Self) -> bool {
151 + self.at < other.end() && other.at < self.end()
152 + }
153 + }
154 +
155 + /// A time axis: things placed by when they happen, rather than flowed.
156 + ///
157 + /// # Why this is a primitive
158 + ///
159 + /// The argument against naming a timeline is that a description expressive
160 + /// enough to draw one is a component library wearing a description's name. It
161 + /// does not hold here, and being precise about why matters, because the
162 + /// reasoning applies to real cases.
163 + ///
164 + /// What a timeline needs that a [`List`](Region::Pane) does not is **one**
165 + /// thing: placement. Where a thing sits is a fact about the thing, the way a
166 + /// row's primary text is, and it is not derivable from order. Everything else a
167 + /// day view draws -- the labels, the gridlines, the item bodies, the tones --
168 + /// is furniture this vocabulary already names. Measured against goingson's
169 + /// `day-planning-render.js`, the only members it needed and could not get were
170 + /// `at` and `minutes`.
171 + ///
172 + /// So the timeline was never a component library's worth of vocabulary. It was
173 + /// two integers, and the refusal was priced as though it were the whole widget.
174 + /// The test that matters is not "does this shape look complicated" but "how
175 + /// many members does it actually add, and are they facts or presentation".
176 + /// Slot heights, gridline colour, how overlaps stack and which hour scrolls
177 + /// into view on open are all presentation and all stay the renderer's, which is
178 + /// why they are absent here.
179 + ///
180 + /// # What it does not carry
181 + ///
182 + /// No pixel measure, no scroll offset, no drag affordance. A renderer draws the
183 + /// span at whatever density its host uses; `makeover-geometry` owns that the
184 + /// way it owns everything else measured in pixels.
185 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
186 + pub struct Track {
187 + /// The window the axis covers.
188 + pub span: Span,
189 + /// The granularity a thing can be placed on, in minutes.
190 + ///
191 + /// goingson's day view is 15, giving 96 slots across a day. A renderer uses
192 + /// it to decide where gridlines fall and what a drop lands on; it does not
193 + /// constrain [`Placement`], because data arriving from a calendar does not
194 + /// respect anyone's grid.
195 + pub slot: u16,
196 + /// How often the axis labels itself, in its own unit.
197 + ///
198 + /// 60 gives an hourly ruler over a 15-minute grid, which is the common
199 + /// shape and the reason this is separate from `slot`. Zero means an
200 + /// unlabelled axis.
201 + pub tick: u16,
202 + /// What `span`, `slot`, `tick` and every [`Placement`] on it count.
203 + ///
204 + /// The one field here a renderer cannot derive, and the reason it exists:
205 + /// [`fraction`](Self::fraction) is unit-agnostic, so a day-granularity
206 + /// track produced correct geometry under an hours-and-minutes ruler until
207 + /// this was added. Geometry never needed it; a label always did.
208 + pub unit: Unit,
209 + }
210 +
211 + impl Track {
212 + /// An ordinary day: midnight to midnight, quarter-hour slots, hourly ticks.
213 + pub const DAY: Self = Self {
214 + span: Span::DAY,
215 + slot: 15,
216 + tick: 60,
217 + unit: Unit::Minutes,
218 + };
219 +
220 + /// A track over `span`, with the day's usual granularity.
221 + #[must_use]
222 + pub const fn over(span: Span) -> Self {
223 + Self {
224 + span,
225 + slot: 15,
226 + tick: 60,
227 + unit: Unit::Minutes,
228 + }
229 + }
230 +
231 + /// A strip of whole days: one slot a day, a label a week.
232 + ///
233 + /// The shape a stretch of leave or a sprint is drawn on. Not a calendar --
234 + /// it does not wrap into weeks, and the crate header says why that
235 + /// distinction is the whole of what a month grid still has over this.
236 + #[must_use]
237 + pub const fn days(span: Span) -> Self {
238 + Self {
239 + span,
240 + slot: 1,
241 + tick: 7,
242 + unit: Unit::Days,
243 + }
244 + }
245 +
246 + /// How many slots the axis holds.
247 + ///
248 + /// Rounded up, so a span that does not divide evenly by `slot` still has a
249 + /// slot covering its tail rather than dropping it. Never zero: `slot` of 0
250 + /// reads as one slot spanning the whole axis rather than a division by
251 + /// zero, since a renderer asking this question has already committed to
252 + /// drawing something.
253 + #[must_use]
254 + pub const fn slots(self) -> u16 {
255 + if self.slot == 0 {
256 + 1
257 + } else {
258 + self.span.length().div_ceil(self.slot)
259 + }
260 + }
261 +
262 + /// Where a placement sits on the axis, as a fraction from 0.0 to 1.0.
263 + ///
264 + /// The one calculation every renderer would otherwise write itself, and the
265 + /// place the three would drift apart. Clamped, so a placement outside the
266 + /// span draws at the edge rather than off it -- an event running past
267 + /// midnight is a real thing and truncating it is better than either
268 + /// panicking or drawing it somewhere impossible.
269 + #[must_use]
270 + pub fn fraction(self, minute: u16) -> f32 {
271 + let span = f32::from(self.span.length());
272 + let offset = f32::from(minute.saturating_sub(self.span.from()));
273 + (offset / span).clamp(0.0, 1.0)
274 + }
275 + }
276 +
277 + impl Default for Track {
278 + fn default() -> Self {
279 + Self::DAY
280 + }
281 + }