Skip to main content

day_spec/
lib.rs

1// Copyright © The Daybrite Project
2// SPDX-License-Identifier: MPL-2.0
3
4//! day-spec: the toolkit specification (DESIGN.md §8).
5//!
6//! Backends depend only on this crate (never on day-core). One backend is linked per binary;
7//! `day-core` is monomorphized over the concrete [`Toolkit`].
8
9use std::any::Any;
10use std::collections::HashMap;
11
12pub use day_geometry::*;
13
14pub mod controls;
15pub use controls::datetime::{DayDate, DayTime};
16
17pub mod applications;
18pub mod capture;
19/// Inline markdown → styled runs (docs/markdown.md). Lives here rather than in day-pieces
20/// because it produces `TextRun`s, and every format codec beside it reads the same model.
21pub mod markdown;
22/// Bundled-resource random-access API + the per-backend `ResourceOpener` hook (§18.3).
23pub mod resource;
24/// Styled text: the document model `label().runs(…)` renders and `day-piece-text-editor` edits,
25/// with the Markdown / HTML / RTF codecs over it (docs/texteditor.md).
26pub mod styled;
27/// Import and export for [`StyledText`]: Markdown, HTML and RTF (docs/texteditor.md).
28pub mod styled_codec;
29pub mod transfer;
30
31pub use resource::{
32    AssetDir, AssetName, FontFamily, ImageName, Resource, ResourceOpener, VectorName,
33    resolve_asset_dir, resolve_image_file, resource, set_resource_opener,
34};
35pub use styled::{
36    ListStyle, ParagraphAlign, ParagraphRun, ParagraphStyle, RunStyle, StyledText, Underline,
37    coalesce_runs, paragraph_bounds, paragraphs_are_valid,
38};
39pub use styled_codec::{
40    DocStyle, html_to_styled, markdown_to_styled, rtf_to_styled, styled_to_html,
41    styled_to_markdown, styled_to_rtf,
42};
43
44/// Bundled custom fonts: name-table parsing, runtime font directory, family → file resolution
45/// (§18.4). Shared by the CLI stagers and the backends' startup registration. Lives in the leaf
46/// `day-fonts` crate (pure std, no `day-geometry`), re-exported here so `day_spec::fonts::…` is
47/// unchanged for the backends while the CLI can depend on `day-fonts` alone.
48pub use day_fonts as fonts;
49
50/// The route inside a deep-link URL (docs/deep-links.md): everything after `scheme://`,
51/// query included (`notes://mail/inbox?hint=x` ⇒ `mail/inbox?hint=x`). A string with no
52/// scheme passes through unchanged (it is already a route). One definition, shared by every
53/// platform's intake and the dayscript `deep_link` step, so a URL maps to the same route
54/// bytes everywhere; percent-decoding stays with the route parser (docs/navigation.md).
55pub fn route_of_url(url: &str) -> String {
56    match url.split_once("://") {
57        Some((_, rest)) => rest.to_string(),
58        None => url.to_string(),
59    }
60}
61
62// ---------------------------------------------------------------------------
63// Identity
64// ---------------------------------------------------------------------------
65
66/// Interned piece-kind key, e.g. `"day.label"` or `"acme.combobox"`.
67pub type PieceKind = &'static str;
68
69/// Declare the built-in piece vocabulary once: the [`Builtin`] enum, its string keys, and the
70/// `kinds::*` constants are all generated from this list, so they can never drift apart.
71///
72/// Adding a variant here is a breaking change for backends: every `Toolkit` must decide how
73/// to realize the new kind, and the exhaustive `match Builtin` in each backend's `realize`
74/// turns that decision into a compile error rather than a runtime placeholder.
75macro_rules! builtin_kinds {
76    ($( $(#[$meta:meta])* $variant:ident = $konst:ident => $key:literal ),+ $(,)?) => {
77        /// Every piece kind Day itself defines (§5.3). Backends match on this exhaustively in
78        /// `realize`; anything outside it is an extension piece resolved through the
79        /// [`Registry`] by its string [`PieceKind`].
80        #[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
81        pub enum Builtin {
82            $( $(#[$meta])* $variant, )+
83        }
84
85        impl Builtin {
86            /// This kind's wire key: the same string as the matching `kinds::*` constant.
87            pub const fn key(self) -> PieceKind {
88                match self { $( Builtin::$variant => $key, )+ }
89            }
90
91            /// The built-in this key names, or `None` for an extension piece's kind.
92            pub fn from_key(kind: PieceKind) -> Option<Builtin> {
93                match kind {
94                    $( $key => Some(Builtin::$variant), )+
95                    _ => None,
96                }
97            }
98
99            /// Every built-in, in declaration order (conformance tests iterate it).
100            pub const ALL: &'static [Builtin] = &[ $( Builtin::$variant, )+ ];
101        }
102
103        /// The built-in piece keys as plain strings, for the registry and the `PieceKind` API.
104        pub mod kinds {
105            $( $(#[$meta])* pub const $konst: super::PieceKind = super::Builtin::$variant.key(); )+
106        }
107    };
108}
109
110builtin_kinds! {
111    /// A dumb native panel (the column/row/stack backing).
112    Container = CONTAINER => "day.container",
113    Label = LABEL => "day.label",
114    Button = BUTTON => "day.button",
115    Toggle = TOGGLE => "day.toggle",
116    Slider = SLIDER => "day.slider",
117    TextField = TEXT_FIELD => "day.text_field",
118    /// A native multi-line text editor (docs/textarea.md). Built-in since 2026-07 (previously
119    /// the satellite `day-piece-textarea`).
120    TextArea = TEXT_AREA => "day.text_area",
121    /// A native option picker with menu/segmented/inline stylings (docs/picker.md). Built-in
122    /// since 2026-07 (previously the satellite `day-piece-picker`).
123    Picker = PICKER => "day.picker",
124    Divider = DIVIDER => "day.divider",
125    Scroll = SCROLL => "day.scroll",
126    Image = IMAGE => "day.image",
127    /// Progress indicator: determinate bar (fraction) or indeterminate spinner.
128    Progress = PROGRESS => "day.progress",
129    Canvas = CANVAS => "day.canvas",
130    /// Navigation host (docs/navigation.md): stack on mobile, split panes on desktop.
131    Nav = NAV => "day.nav",
132    /// One destination's native container inside a NAV host.
133    NavPage = NAV_PAGE => "day.nav_page",
134    /// Native navigation item list (docs/navigation.md): NSOutlineView source list /
135    /// GtkListBox navigation-sidebar / QListWidget / UITableView rows with chevrons.
136    NavMenu = NAV_MENU => "day.nav_menu",
137    /// Native recycling list (docs/list.md): NSTableView / UITableView / RecyclerView /
138    /// GtkListView / QListView. Owns scrolling + cell reuse; Day binds row content on demand.
139    List = LIST => "day.list",
140    /// A recycled row's content anchor inside a `LIST`; Day adopts the native cell as its handle.
141    /// Adopted, never realized: `Toolkit::adopt` wraps the native cell, so backends' `realize`
142    /// never sees this kind.
143    ListCell = LIST_CELL => "day.list_cell",
144    /// Native hierarchical tree (docs/tree.md): NSOutlineView / sidebar collection list /
145    /// GtkListView+TreeListModel / WinUI TreeView / ArkTS TreeView. Rows nest, disclose, and
146    /// drag-to-reparent; Day binds row content on demand through `Toolkit::attach_tree`.
147    /// Row cells are adopted (the same `LIST_CELL` anchor path as `List`).
148    Tree = TREE => "day.tree",
149    /// A fullscreen cover (docs/cover.md): a modal surface presented over the whole window,
150    /// edge-to-edge, driven by `CoverPatch::{Present,Dismiss}`. The handle is the cover's
151    /// content container; its frame is native-owned while presented (the backend sizes it to
152    /// the safe area and reports it via `Event::FrameChanged`).
153    Cover = COVER => "day.cover",
154    /// Content beside a trailing inspector pane (docs/inspector.md): the native split whose
155    /// second pane is a show/hidable properties panel. Exactly two `INSPECTOR_PANE` children:
156    /// the content, then the panel (`InspectorPaneProps::panel`). Only realized where
157    /// `Cap::Inspector` answers `Native`; everywhere else the `inspector` piece composes the
158    /// pane from plain containers and this kind never reaches the backend.
159    Inspector = INSPECTOR => "day.inspector",
160    /// One pane's container inside an `INSPECTOR` host: the `NAV_PAGE` of the inspector split.
161    /// Its frame is native-owned (the splitter/dock sizes it), reported via
162    /// `Event::FrameChanged` on this node; Day lays the pane's content out inside it.
163    InspectorPane = INSPECTOR_PANE => "day.inspector_pane",
164    /// Two panes with a divider the user drags (docs/split.md): the toolkit's own splitter,
165    /// side by side or stacked (`SplitProps::axis`), the first pane's share bound to the app
166    /// (`SplitProps::fraction`, reported back as `Event::ValueChanged`). Exactly two
167    /// `SPLIT_PANE` children, first then second. Only realized where `Cap::Split` answers
168    /// `Native`; everywhere else the `split` piece composes the panes and draws the divider
169    /// itself, and this kind never reaches the backend.
170    Split = SPLIT => "day.split",
171    /// One pane's container inside a `SPLIT` host: native-owned frame (the splitter sizes it),
172    /// reported via `Event::FrameChanged` on this node, like an `INSPECTOR_PANE`.
173    SplitPane = SPLIT_PANE => "day.split_pane",
174    /// A list row's standard content (docs/list.md "List items"): a leading icon, a title, an
175    /// optional subtitle and trailing detail, and a check mark, drawn the way the platform
176    /// draws its own list rows: a `UIListContentView` with the cell's default content
177    /// configuration, a Material list item, an `NSTableCellView`'s image and text, a GTK row
178    /// box. Only realized where `Cap::ListItem` answers `Native`; everywhere else the
179    /// `list_item` piece composes the same content and this kind never reaches the backend.
180    ListItem = LIST_ITEM => "day.list_item",
181}
182
183/// Whether a kind is worth asking [`Toolkit::first_baseline`] about (docs/baseline.md).
184///
185/// A blocklist, not an allowlist: the structural and graphic kinds below have no text of their
186/// own, and everything else (including extension pieces like the date/time pickers, whose kinds
187/// day-spec has never heard of) is assumed to be worth asking. An extension piece that turns
188/// out to have no baseline answers `None`, which costs one query; an allowlist would instead
189/// silently leave every extension piece centered, which is the bug this shape avoids.
190pub fn kind_has_baseline(kind: PieceKind) -> bool {
191    !matches!(
192        Builtin::from_key(kind),
193        Some(
194            Builtin::Container
195                | Builtin::Scroll
196                | Builtin::Divider
197                | Builtin::Image
198                | Builtin::Canvas
199                | Builtin::Slider
200                | Builtin::Toggle
201                | Builtin::Progress
202                | Builtin::Nav
203                | Builtin::NavPage
204                | Builtin::NavMenu
205                | Builtin::Split
206                | Builtin::SplitPane
207                | Builtin::List
208                | Builtin::ListCell
209                | Builtin::Cover
210                | Builtin::Inspector
211                | Builtin::InspectorPane
212        )
213    )
214}
215
216/// Placeholder leaves: the one hole in Day's rendering that is invisible to a screenshot.
217///
218/// When a backend has no renderer for a kind it realizes a visible `⟨kind⟩` label rather than
219/// failing. That is the right runtime behavior, but it means a missing renderer looks like a
220/// rendered app: the walkthrough passes, and the gallery's validator (which counts distinct
221/// colors) sees nothing wrong. That is how a stale `skip_on:` survived in the showcase
222/// walkthrough after the renderer it skipped had landed.
223///
224/// So every backend reports here instead of keeping its own warn-once set. The table is process-
225/// wide and append-only, which gives dayscript's `assert_no_placeholders` something to assert on
226/// (`crates/day-script`): a placeholder becomes a test failure instead of a silent gap.
227pub mod placeholder {
228    use super::PieceKind;
229    use std::collections::BTreeSet;
230    use std::sync::{Mutex, OnceLock};
231
232    fn table() -> &'static Mutex<BTreeSet<PieceKind>> {
233        static TABLE: OnceLock<Mutex<BTreeSet<PieceKind>>> = OnceLock::new();
234        TABLE.get_or_init(|| Mutex::new(BTreeSet::new()))
235    }
236
237    /// Record that `toolkit` had no renderer for `kind` and warn, once per kind, however many
238    /// nodes of it are realized. Call from a backend's `realize` fallback arm.
239    pub fn report(kind: PieceKind, toolkit: &str) {
240        let Ok(mut seen) = table().lock() else { return };
241        if seen.insert(kind) {
242            log::warn!(
243                "no renderer for piece kind \"{kind}\" on {toolkit} — is the piece's \
244                 {toolkit} feature enabled? (rendering a placeholder)"
245            );
246        }
247    }
248
249    /// Every kind that has rendered a placeholder in this process, sorted. Empty is the goal.
250    pub fn seen() -> Vec<PieceKind> {
251        table()
252            .lock()
253            .map(|s| s.iter().copied().collect())
254            .unwrap_or_default()
255    }
256}
257
258/// Downcast a realize/update payload to the props/patch type a backend arm expects,
259/// warning (once per kind, via [`placeholder::report`]) instead of panicking on a
260/// mismatch. A mismatch means a piece registered under a builtin key or a day-core
261/// regression; degrade to the placeholder path, never abort: most backend arms run
262/// inside native up-calls, where a panic is a process kill.
263pub fn props_of<'a, T: 'static>(
264    kind: PieceKind,
265    toolkit: &str,
266    any: &'a dyn std::any::Any,
267) -> Option<&'a T> {
268    let p = any.downcast_ref::<T>();
269    if p.is_none() {
270        placeholder::report(kind, toolkit);
271    }
272    p
273}
274
275/// Pointer-keyed per-view side tables with an automatic release sweep.
276///
277/// Backends keep auxiliary per-view state in thread-local maps keyed by the native
278/// handle's address. Historically each map needed its own line in `release()`, a
279/// checklist that drifted repeatedly (missed entries have produced both leaks and
280/// stale-pointer CI segfaults, since a freed address gets recycled). A [`crate::sidetable::SideTable`]
281/// registers itself with a per-thread sweeper list at construction, so a backend's
282/// `release()` calls [`crate::sidetable::sweep`] once and every table, present and future, drops its
283/// entry for that handle, running its teardown hook if one was given.
284pub mod sidetable {
285    use std::cell::RefCell;
286    use std::collections::HashMap;
287    use std::rc::Rc;
288
289    /// One table's "remove this key" hook, registered at construction.
290    type Sweeper = Rc<dyn Fn(usize)>;
291
292    thread_local! {
293        static SWEEPERS: RefCell<Vec<Sweeper>> = const { RefCell::new(Vec::new()) };
294    }
295
296    /// A pointer-keyed map swept automatically by [`crate::sidetable::sweep`]. Construct inside a
297    /// `thread_local!` initializer; the optional teardown hook runs for each removed
298    /// value (release a retained native object, dispose an owned child, …), both on sweep
299    /// and on plain [`SideTable::remove`], so teardown cannot be bypassed by accident.
300    pub struct SideTable<V: 'static> {
301        map: Rc<RefCell<HashMap<usize, V>>>,
302        teardown: Option<Rc<dyn Fn(V)>>,
303    }
304
305    impl<V> Default for SideTable<V> {
306        fn default() -> Self {
307            Self::new()
308        }
309    }
310
311    impl<V> SideTable<V> {
312        pub fn new() -> Self {
313            Self::build(None)
314        }
315
316        /// A table whose values need active teardown when their view goes away.
317        pub fn with_teardown(teardown: impl Fn(V) + 'static) -> Self {
318            Self::build(Some(Rc::new(teardown)))
319        }
320
321        fn build(teardown: Option<Rc<dyn Fn(V)>>) -> Self {
322            let map: Rc<RefCell<HashMap<usize, V>>> = Rc::default();
323            let (m, t) = (map.clone(), teardown.clone());
324            SWEEPERS.with(|s| {
325                s.borrow_mut().push(Rc::new(move |key| {
326                    // Remove under the borrow, tear down outside it: hooks may re-enter
327                    // toolkit code that reads other tables.
328                    let v = m.borrow_mut().remove(&key);
329                    if let (Some(v), Some(t)) = (v, &t) {
330                        t(v);
331                    }
332                }));
333            });
334            SideTable { map, teardown }
335        }
336
337        pub fn insert(&self, key: usize, value: V) -> Option<V> {
338            self.map.borrow_mut().insert(key, value)
339        }
340
341        /// Remove and tear down the entry (returns `None` always when a teardown hook
342        /// exists, because the value is consumed by it; use [`SideTable::take`] to skip it).
343        pub fn remove(&self, key: usize) -> Option<V> {
344            let v = self.map.borrow_mut().remove(&key)?;
345            match &self.teardown {
346                Some(t) => {
347                    t(v);
348                    None
349                }
350                None => Some(v),
351            }
352        }
353
354        /// Remove without running the teardown hook (the caller takes ownership).
355        pub fn take(&self, key: usize) -> Option<V> {
356            self.map.borrow_mut().remove(&key)
357        }
358
359        pub fn contains(&self, key: usize) -> bool {
360            self.map.borrow().contains_key(&key)
361        }
362
363        pub fn get(&self, key: usize) -> Option<V>
364        where
365            V: Clone,
366        {
367            self.map.borrow().get(&key).cloned()
368        }
369
370        /// Run `f` on the value in place (returns `None` if absent).
371        pub fn with<R>(&self, key: usize, f: impl FnOnce(&mut V) -> R) -> Option<R> {
372            self.map.borrow_mut().get_mut(&key).map(f)
373        }
374
375        /// Run `f` over every `(key, value)` (immutable; do not re-enter this table).
376        pub fn for_each(&self, mut f: impl FnMut(usize, &V)) {
377            for (k, v) in self.map.borrow().iter() {
378                f(*k, v);
379            }
380        }
381    }
382
383    /// Remove `key` from every table registered on this thread, running teardowns.
384    /// Call once from a backend's `release()` (and once per auxiliary native object a
385    /// release frees, e.g. an owned content container).
386    pub fn sweep(key: usize) {
387        // Clone the sweeper list first: a teardown may construct a new SideTable
388        // (registering a sweeper) without deadlocking on the borrow.
389        let sweepers: Vec<Sweeper> = SWEEPERS.with(|s| s.borrow().clone());
390        for f in sweepers {
391            f(key);
392        }
393    }
394}
395
396/// Containment for FFI entry points: native callbacks, JNI up-calls, posted-closure
397/// trampolines. A Rust panic unwinding out of an `extern "C"` frame is undefined
398/// behavior (an abort), so every backend trampoline runs its body through
399/// [`ffi_guard::contain`]: a caught panic is reported, the installed recovery hook
400/// restores runtime coherence (day-core installs the reactive runtime's
401/// `recover_from_panic` at boot), and the given default is returned.
402pub mod ffi_guard {
403    use std::sync::OnceLock;
404
405    static RECOVERY: OnceLock<fn()> = OnceLock::new();
406
407    /// Install the post-panic recovery hook (idempotent; first install wins). day-core
408    /// registers `day_reactive::recover_from_panic` here so a contained panic cannot
409    /// leave the reactive observer stack stranded.
410    pub fn set_recovery(f: fn()) {
411        let _ = RECOVERY.set(f);
412    }
413
414    /// Run `f`, containing any panic; on panic, report, run the recovery hook, and
415    /// return `default`.
416    pub fn contain<R>(default: R, f: impl FnOnce() -> R) -> R {
417        match std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)) {
418            Ok(r) => r,
419            Err(payload) => {
420                let msg = payload
421                    .downcast_ref::<&str>()
422                    .map(|s| s.to_string())
423                    .or_else(|| payload.downcast_ref::<String>().cloned())
424                    .unwrap_or_else(|| "<non-string panic>".into());
425                log::error!("panic contained at an FFI boundary: {msg}");
426                if let Some(recover) = RECOVERY.get() {
427                    recover();
428                }
429                default
430            }
431        }
432    }
433}
434
435/// Realized-node identity as seen by backends (day-core's slotmap key, FFI-encoded).
436#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
437pub struct NodeId(pub u64);
438
439/// Default navigation sidebar width (split presentation) until the pane reports its size.
440/// Semantic container surfaces (see `ContainerProps::role`): each backend maps a role to its
441/// own theme-adaptive material so the fill tracks light/dark mode without app code.
442#[derive(Clone, Copy, Debug, PartialEq, Eq)]
443pub enum SurfaceRole {
444    /// A form `section` card, the platform's grouped-content background: AppKit quaternary
445    /// system fill, libadwaita `.card`, Qt `palette(alternate-base)`, UIKit tertiary system
446    /// fill, Material surface-container, XAML card background brush.
447    SectionCard,
448    /// The window's own background, the surface a page sits on: what an opaque cover paints
449    /// by default, and the card of a composed modal sheet (docs/cover.md "Modal sheet").
450    Window,
451    /// The layer between a composed modal sheet and the page under it, which says the page is
452    /// out of reach until the sheet goes. The container's `background` is the plain dimming a
453    /// backend draws when it has no material of its own; the web frosts the page instead.
454    Scrim,
455}
456
457pub const NAV_SIDEBAR_WIDTH: f64 = 240.0;
458
459/// Drag limits for a navigation host's sidebar pane, around [`NAV_SIDEBAR_WIDTH`]. Shared by the
460/// desktops whose divider the user can drag (docs/navigation.md): AppKit enforces both through
461/// its `NSSplitViewItem`; Qt honors the minimum through the pane's own minimum size and leaves
462/// the maximum to the user.
463pub const NAV_SIDEBAR_MIN_W: f64 = 160.0;
464pub const NAV_SIDEBAR_MAX_W: f64 = 400.0;
465
466/// Default preferred width of a navigation host's content-list pane
467/// (`props::NavProps::list_width`, docs/navigation.md), Mail's message-list proportion.
468pub const NAV_LIST_WIDTH: f64 = 300.0;
469
470/// Drag limits for the content-list pane, around the app's preferred width; the same split of
471/// duties as the sidebar's.
472pub const NAV_LIST_MIN_W: f64 = 220.0;
473pub const NAV_LIST_MAX_W: f64 = 560.0;
474
475/// Reserved id for window-level events (resize, lifecycle): day-core routes it to the root.
476pub const WINDOW_NODE: NodeId = NodeId(u64::MAX);
477
478/// Which nodes handle the non-text keys (docs/menus.md). Keys follow focus (the focused piece
479/// hears them and nobody else), so a backend whose focused view would have to claim a key from
480/// the platform's own dispatch (AppKit's responder chain, the DOM's default action) has to know
481/// whether the app wants it before swallowing it. An unclaimed arrow has to keep traveling, or
482/// a canvas inside a scroll view would silently eat the keys that scroll it.
483///
484/// It lives here, in day-spec, rather than in day-core: `day-toolkit-dom` depends on day-spec alone,
485/// and this is a fact about a node that both sides need.
486pub mod keys {
487    use super::NodeId;
488    use std::cell::RefCell;
489    use std::collections::HashSet;
490
491    thread_local! {
492        static HANDLED: RefCell<HashSet<NodeId>> = RefCell::new(HashSet::new());
493    }
494
495    /// Declare that `node` has a key handler (what `Decorate::on_key` records at build).
496    pub fn mark(node: NodeId) {
497        HANDLED.with(|h| h.borrow_mut().insert(node));
498    }
499
500    /// Whether `node` has one.
501    pub fn handled(node: NodeId) -> bool {
502        HANDLED.with(|h| h.borrow().contains(&node))
503    }
504}
505
506/// Raw foreign native handle for polyglot adoption (§15.3).
507pub type RawHandle = *mut std::ffi::c_void;
508
509// ---------------------------------------------------------------------------
510// Events (§8.3)
511// ---------------------------------------------------------------------------
512
513/// The wire table for backends whose native side reaches Rust through one numeric-kind
514/// trampoline (Android's JNI `nativeOnEvent`, ArkUI's `day_arkui_on_event`). This enum
515/// defines those kind numbers; the Java and C++ sides carry mirrored constants that parity
516/// tests check against these discriminants (so a collision or drift fails `cargo test` on the
517/// host instead of silently mis-decoding events on a device). AppKit/UIKit/GTK/Qt emit `Event`
518/// values directly and never use these numbers; XAML uses per-event callbacks with its own
519/// small local codes.
520///
521/// Payload conventions ride `(num: f64, text: String)` per kind, documented on each variant.
522pub mod bridge {
523    /// One numeric event kind. `as i32` is the wire value.
524    #[repr(i32)]
525    #[derive(Clone, Copy, Debug, PartialEq, Eq)]
526    pub enum BridgeKind {
527        /// Click/press. No payload.
528        Pressed = 0,
529        /// `text` = the field's full new text.
530        TextChanged = 1,
531        /// `num` != 0 ⇒ on.
532        ToggleChanged = 2,
533        /// `num` = the new value.
534        ValueChanged = 3,
535        /// `num` = the selected index.
536        SelectionChanged = 4,
537        /// `num` == 1 ⇒ the native side already popped (predictive back / up arrow).
538        NavBack = 5,
539        /// Nav/tab page size report; `text` = `"w,h"` in px (Rust divides by density).
540        FrameChanged = 6,
541        /// Warm deep link; `text` = the route.
542        Deeplink = 7,
543        /// Modal answered with a button; `num` = the button index.
544        PresentButton = 8,
545        /// Modal answered with text; `text` = the entry.
546        PresentText = 9,
547        /// Modal dismissed.
548        PresentDismissed = 10,
549        /// Gesture; `num` = phase (0 tap, 1 began, 2 changed, 3 ended), `text` = `"x,y,tx,ty"` px.
550        Gesture = 11,
551        /// Piece-defined open channel (`Event::Custom` with an empty tag): the piece reads the
552        /// raw `num`/`text` payload.
553        Custom = 12,
554        /// Menu selection; the event's node id is the chosen action's dispatch id.
555        MenuAction = 13,
556        /// App lifecycle; `num` = the phase code (`day_spec::Lifecycle` order).
557        Lifecycle = 14,
558        /// File-picker answer; `text` = chosen locators joined by the unit separator.
559        PresentFile = 15,
560        /// `num` != 0 ⇒ gained keyboard focus.
561        FocusChanged = 16,
562        /// IME action / Return.
563        Submitted = 17,
564        /// Root size change; `text` = `"w,h"` in px. Routed to `WINDOW_NODE` as a window
565        /// resize (the rail rotation, late inset passes, and the soft keyboard ride).
566        WindowResized = 18,
567        /// Safe-area report from an edge-to-edge backend; `text` =
568        /// `"top,bottom,leading,trailing"` in px (Rust divides by density and feeds
569        /// `day_core::set_safe_area`). Node id ignored; no `Event` is emitted.
570        SafeArea = 19,
571        /// A secondary window closed (docs/windows.md); the node id is the window's root.
572        WindowClosed = 20,
573        /// A secondary window's key/active state changed; `num` != 0 ⇒ focused.
574        WindowFocused = 21,
575        /// `num` = the settled value: the drag ended (see `Event::ValueCommitted`). A backend
576        /// that cannot tell a settled value from a moving one sends only `ValueChanged`.
577        ValueCommitted = 22,
578        /// Inline search on a `.searchable()` navigation surface (docs/search.md): the field's
579        /// new text, against the nav host's node. `num` = 1 ⇒ the text was submitted (Return or
580        /// the keyboard's Search key), decoding to [`crate::Event::SearchSubmitted`].
581        SearchChanged = 23,
582        /// The toolkit's own adaptive nav container changed presentation and is reporting it
583        /// (docs/size-classes.md). `num` = 1.0 for split, 0.0 for stacked.
584        NavPresentation = 24,
585        /// The system switched between light and dark appearance. No payload; node id ignored
586        /// and no `Event` is emitted; Rust calls `day_core::note_appearance_changed`, the same
587        /// thing AppKit's and GTK's own scheme observers do.
588        AppearanceChanged = 25,
589        /// A fullscreen cover's hide transition finished (docs/cover.md); no payload, and the
590        /// node id is the cover's. Decodes to [`crate::Event::CoverHidden`].
591        CoverHidden = 26,
592        /// A styled text run's link was tapped (docs/text-runs.md); `text` = the run's target.
593        /// Decodes to [`crate::Event::LinkActivated`].
594        LinkActivated = 27,
595        /// The platform's own undo affordance fired (⌘Z, a three-finger swipe, the Edit menu
596        /// through a native front); `num` != 0 ⇒ redo.
597        UndoInvoked = 28,
598        /// A toolbar item produced a value (docs/toolbars.md): the node id is the item's
599        /// dispatch action; `text` = `"on"` with `num` != 0 ⇒ a toggle turned on, `"on"` with
600        /// 0 ⇒ off, `"sel"` with `num` = the chosen segment, `"text:<query>"` ⇒ a search
601        /// item's new text, `"submit:<query>"` ⇒ that text submitted. Decodes to
602        /// [`crate::Event::ToolbarChanged`]. A plain toolbar button sends `MenuAction`.
603        ToolbarChanged = 30,
604        /// An activated list row; `num` is its current index.
605        ListActivated = 31,
606        /// External document copied into app storage; `text` = its local locator.
607        DocumentOpened = 32,
608        /// The user's reduce-motion setting flipped (docs/accessibility.md). No payload; node
609        /// id ignored and no `Event` is emitted; Rust calls `day_core::note_motion_changed`, as
610        /// the desktop backends' own observers do.
611        ReduceMotionChanged = 33,
612        /// A non-text key reached the focused node (docs/menus.md); `text` = the day key name
613        /// (`"ArrowLeft"`, `"5"`, …), `num` = the [`crate::KeyEvent`] modifier mask. Decodes to
614        /// [`crate::Event::Key`].
615        Key = 29,
616        /// A window's display state changed (docs/windows.md): `num` = the
617        /// [`crate::WindowState`] code (0 normal, 1 minimized, 2 maximized, 3 fullscreen), against
618        /// the window's root node, or [`crate::WINDOW_NODE`] for the primary. Decodes to
619        /// [`crate::Event::WindowStateChanged`] through [`crate::WindowState::from_code`].
620        WindowStateChanged = 34,
621        /// Native pull-to-refresh request; no payload.
622        RefreshRequested = 35,
623    }
624
625    impl BridgeKind {
626        /// Every variant, for uniqueness/parity tests and exhaustive dispatch.
627        pub const ALL: [BridgeKind; 36] = [
628            BridgeKind::Pressed,
629            BridgeKind::TextChanged,
630            BridgeKind::ToggleChanged,
631            BridgeKind::ValueChanged,
632            BridgeKind::SelectionChanged,
633            BridgeKind::NavBack,
634            BridgeKind::FrameChanged,
635            BridgeKind::Deeplink,
636            BridgeKind::PresentButton,
637            BridgeKind::PresentText,
638            BridgeKind::PresentDismissed,
639            BridgeKind::Gesture,
640            BridgeKind::Custom,
641            BridgeKind::MenuAction,
642            BridgeKind::Lifecycle,
643            BridgeKind::PresentFile,
644            BridgeKind::FocusChanged,
645            BridgeKind::Submitted,
646            BridgeKind::WindowResized,
647            BridgeKind::SafeArea,
648            BridgeKind::WindowClosed,
649            BridgeKind::WindowFocused,
650            BridgeKind::ValueCommitted,
651            BridgeKind::SearchChanged,
652            BridgeKind::NavPresentation,
653            BridgeKind::AppearanceChanged,
654            BridgeKind::CoverHidden,
655            BridgeKind::LinkActivated,
656            BridgeKind::UndoInvoked,
657            BridgeKind::Key,
658            BridgeKind::ToolbarChanged,
659            BridgeKind::ListActivated,
660            BridgeKind::DocumentOpened,
661            BridgeKind::ReduceMotionChanged,
662            BridgeKind::WindowStateChanged,
663            BridgeKind::RefreshRequested,
664        ];
665    }
666
667    #[cfg(test)]
668    mod tests {
669        use super::BridgeKind;
670
671        /// The kind-15 lesson: two meanings on one number decode silently as the first. Every
672        /// discriminant must be unique, and the table must stay dense enough to spot gaps.
673        #[test]
674        fn discriminants_are_unique() {
675            let mut seen = std::collections::BTreeSet::new();
676            for k in BridgeKind::ALL {
677                assert!(
678                    seen.insert(k as i32),
679                    "duplicate bridge kind number {} ({k:?})",
680                    k as i32
681                );
682            }
683            assert_eq!(seen.len(), BridgeKind::ALL.len());
684        }
685
686        /// `ALL` is what the uniqueness check above sees, so a variant left out of it is a variant
687        /// nothing checks, which is how `SearchChanged` and `NavPresentation` sat outside the
688        /// table. The wire numbers are assigned densely from 0, so demanding the set be exactly
689        /// `0..len` catches both a gap and an omission.
690        #[test]
691        fn table_covers_every_wire_number() {
692            let mut nums: Vec<i32> = BridgeKind::ALL.iter().map(|k| *k as i32).collect();
693            nums.sort_unstable();
694            let dense: Vec<i32> = (0..BridgeKind::ALL.len() as i32).collect();
695            assert_eq!(
696                nums, dense,
697                "bridge kinds must be dense from 0 — a missing entry means a variant no test sees"
698            );
699        }
700    }
701}
702
703#[derive(Clone, Debug, PartialEq)]
704pub enum Event {
705    Pressed,
706    TextChanged(String),
707    Submitted,
708    ToggleChanged(bool),
709    ValueChanged(f64),
710    /// A continuous control's value settled: the drag ended (mouse/touch up), or the value moved
711    /// by a discrete action (a keyboard arrow, a click on the track). Always preceded by the
712    /// `ValueChanged` carrying the same value.
713    ///
714    /// The pair exists because one gesture produces two different facts. `ValueChanged` is the
715    /// live one (bindings follow it so the UI tracks the thumb) and it fires continuously, which
716    /// is why nothing durable should key off it: a drag from 1 to 100 and back to 50 emits every
717    /// value in between. `ValueCommitted` fires once, with the value the user chose, and is what
718    /// a recording, a log, or a refetch should use.
719    ///
720    /// A backend that cannot tell the two apart emits only `ValueChanged`; its sliders then do not
721    /// record (docs/coverage-matrix.md).
722    ValueCommitted(f64),
723    SelectionChanged(i64),
724    /// A list row was activated: desktop double-click/Enter, or a touch tap.
725    /// Separate from selection so keyboard browsing need not open or play the row.
726    ListActivated(usize),
727    /// The platform's own undo affordance fired through a native front (⌘Z on the Edit menu,
728    /// a three-finger swipe, shake-to-undo); `redo` distinguishes the pair. Only backends
729    /// with a native undo system emit it; everywhere else the app's own controls call the
730    /// stack directly and this event never exists (docs/model.md).
731    Undo {
732        redo: bool,
733    },
734    /// The platform's own standard edit command fired through its native route (Edit ▸
735    /// Cut/Copy/Paste, ⌘X/⌘C/⌘V, the browser's `copy`/`paste` DOM events) with no text
736    /// widget claiming it first; the app's edit bridge answers (docs/menus.md). Transport
737    /// is the system clipboard (day-part-clipboard), not the event.
738    Edit(EditOp),
739    /// A multi-select list's selection changed: the full set of selected row indices,
740    /// ascending (empty = nothing selected). Emitted instead of `SelectionChanged` where
741    /// `ListProps::multi_select` is honored (docs/list.md).
742    SelectionSet(Vec<i64>),
743    FocusChanged(bool),
744    Tap(Point),
745    /// A link run inside a label was activated; the payload is [`TextRun::link`] verbatim.
746    LinkActivated(String),
747    LongPress(Point),
748    /// A context-menu summon reported by a toolkit that presents no native menu of its own
749    /// (web-dom): the pieces layer shows the composed menu (docs/menus.md). `local` is the
750    /// point in the node's own coordinates (what the app's provider receives); `window` the
751    /// same point in window coordinates (where the composed panel goes). Toolkits that serve
752    /// context menus natively never emit this.
753    ContextMenu {
754        local: Point,
755        window: Point,
756    },
757    /// A drag/pan gesture (docs/shapes.md). `location` is in the node's local coordinates;
758    /// `translation` is the cumulative movement since `Began`.
759    Drag {
760        phase: DragPhase,
761        location: Point,
762        translation: Point,
763    },
764    /// A pinch/magnify gesture over the node (docs/shapes.md): `scale` is cumulative since
765    /// `Began` (1.0 = unchanged), `location` the gesture's centroid in local coordinates,
766    /// the anchor a zoom keeps stationary. Only nodes that enabled
767    /// [`GestureKind::Pinch`] receive it; a backend with no recognizer emits nothing.
768    Pinch {
769        phase: DragPhase,
770        scale: f64,
771        location: Point,
772    },
773    /// A viewport pan over the node (docs/shapes.md): `delta` is the movement since the
774    /// previous event; incremental, not cumulative, because desktop wheels have no gesture
775    /// session to accumulate over (a discrete wheel tick arrives as a lone `Changed`).
776    /// Two-finger touch pans bracket theirs with `Began`/`Ended`. Distinct from
777    /// [`Event::Drag`], the primary pointer's press-drag.
778    Pan {
779        phase: DragPhase,
780        delta: Point,
781        location: Point,
782    },
783    /// The pointer moved over the node, or left it (docs/canvas.md "Interaction"). `location` is
784    /// in the node's local coordinates. Phases follow the drag vocabulary: `Began` on entry,
785    /// `Changed` for each move, `Ended` on exit, where `location` is the last point seen inside,
786    /// so a handler always knows where the pointer went out.
787    ///
788    /// Only nodes that enabled [`GestureKind::Hover`] receive it, and only on a device with a
789    /// pointer at all.
790    Hover {
791        phase: DragPhase,
792        location: Point,
793    },
794    ScrollChanged(Point),
795    /// A canvas node was re-framed by layout; re-record (§11). Nav pane/page containers
796    /// also report their allocated size with this (docs/navigation.md).
797    FrameChanged(Size),
798    /// Native back navigation (iOS back button/swipe, Android system back or toolbar up).
799    /// `already_popped` = the toolkit already performed the pop natively (iOS); the nav
800    /// host then syncs its stack without re-issuing `NavPatch::Popped`.
801    NavBack {
802        already_popped: bool,
803    },
804    /// A nav host's own adaptive container changed presentation, and Day is being told about it
805    /// after the fact (docs/size-classes.md).
806    ///
807    /// The counterpart to [`props::NavPatch::Presentation`], for the toolkits that answer
808    /// `Cap::NavRepresent = Emulated`: `UISplitViewController` collapsing as a Pro Max iPhone
809    /// rotates into portrait, `SlidingPaneLayout` deciding at measure time that two panes no
810    /// longer fit. Day did not ask for it and must not fight it; the pieces layer only
811    /// reconciles what follows, which is the selection rule (a split presentation cannot draw an
812    /// empty detail pane, so expanding with nothing selected picks the first item).
813    ///
814    /// Emitted on the NAV host node, and only when the presentation actually changed.
815    NavPresentationChanged(props::NavPresentation),
816    Key(KeyEvent),
817    Pointer(PointerEvent),
818    WindowResized(Size),
819    /// The platform asks the app to show a route (docs/navigation.md), a runtime deep link:
820    /// on web-dom, the URL hash changing under the app (hand-edited, or browser back/forward);
821    /// on mobile, a warm scheme link (iOS `application:openURL:`, Android `onNewIntent` via
822    /// `BridgeKind::Deeplink`). Emitted on [`WINDOW_NODE`] (day-core answers with `navigate()`
823    /// when the route differs from the current one) or on the active nav host's node (the nav
824    /// piece answers with `navigate()` itself, docs/deep-links.md). Launch-time deep links use
825    /// `DAY_DEEPLINK`/`set_launch_deeplink` instead; this variant is for changes while the
826    /// app runs.
827    RouteRequested(String),
828    /// A native modal answered request `req` (docs/dialogs.md).
829    PresentResult {
830        req: u64,
831        result: present::PresentResult,
832    },
833    /// A decode started by [`Toolkit::decode_image`] finished (docs/images.md).
834    ///
835    /// Keyed by request id like [`Event::PresentResult`], and for the same reason: the answer
836    /// belongs to a waiting caller, not to a tree node. It never crosses a native boundary
837    /// (every backend raises it from its own Rust arm once the platform decoder answers), so it
838    /// has no [`bridge::BridgeKind`], exactly like the completion events dialogs use in-process.
839    ///
840    /// No bitmap id rides here: day-core minted it and handed it to the backend with the bytes,
841    /// so it already knows which image a request answers for. A backend that stored the image
842    /// under that id has nothing more to say than the facts it read.
843    ImageDecoded {
844        req: u64,
845        result: Result<BitmapInfo, ImageError>,
846    },
847    /// An encode started by [`Toolkit::encode_image`] finished (docs/images.md): the encoded
848    /// bytes, or why they could not be produced.
849    ImageEncoded {
850        req: u64,
851        result: Result<Vec<u8>, ImageError>,
852    },
853    /// A canvas drawing started by [`Toolkit::render_bitmap`] finished rendering
854    /// (docs/canvas.md "Exporting"). Keyed by request id like [`Event::ImageDecoded`], and like
855    /// it carries no bitmap id: day-core minted the id and handed it over with the ops, so the
856    /// answer is only the facts of the image the backend stored under it.
857    BitmapRendered {
858        req: u64,
859        result: Result<BitmapInfo, ExportError>,
860    },
861    /// A PDF started by [`Toolkit::render_pdf`] finished (docs/canvas.md "Exporting"): the
862    /// document's bytes, or why they could not be produced.
863    PdfRendered {
864        req: u64,
865        result: Result<Vec<u8>, ExportError>,
866    },
867    /// An open, piece-defined event (§8.2). `tag` names the event for in-process emitters (a static
868    /// literal); it is empty for events that cross a native boundary (JNI/C-ABI), which carry only the
869    /// primitive `num`/`text` payload. A piece's `cx.on` reads whichever fields it needs. This is the
870    /// escape hatch for events the fixed variants above don't cover (a web view's URL, a picked date, …).
871    Custom {
872        tag: &'static str,
873        num: f64,
874        text: String,
875    },
876    /// A menu item (app menu or context menu) with this action id was activated (§ menus). day-core
877    /// routes it to the app closure registered for the id. Standard-role items don't carry an id
878    /// (`role` items are handled natively) so they never emit this.
879    MenuAction(u64),
880    /// A toolbar item produced a value: a search field's text, a toggle's new state
881    /// (docs/toolbars.md). `action` is the item's dispatch id, from the same registry
882    /// [`Event::MenuAction`] uses, and day-core routes it to the closure registered for it.
883    /// A plain toolbar button has no value and emits `MenuAction` instead.
884    ToolbarChanged {
885        action: u64,
886        value: ToolbarValue,
887    },
888    /// A searchable navigation surface's field changed (docs/search.md). Emitted against the nav
889    /// host's node, not through the toolbar's dispatch registry: search belongs to the surface,
890    /// so the field can move between the toolbar and the navigation list without the app
891    /// re-registering anything.
892    SearchChanged(String),
893    /// The user picked a different scope. Carries the index into `SearchProps::scopes`.
894    SearchScopeChanged(usize),
895    /// The user chose one of `SearchProps::suggestions`, by index. The toolkit has already put
896    /// that completion in the field and emitted [`Event::SearchChanged`] for it; this says which
897    /// one, for an app that wants to act on the choice itself.
898    SearchSuggestionChosen(usize),
899    /// The user submitted the inline field's text with its Return or Search key. Emitted
900    /// after the last [`Event::SearchChanged`], with the text as submitted, for a search that
901    /// runs on demand (a server query) rather than narrowing as the text changes.
902    SearchSubmitted(String),
903    /// The app moved through a lifecycle phase (docs/lifecycle.md). Backends emit this from the
904    /// native app/activity delegate; day-core routes it to the app's `on_lifecycle` handlers.
905    Lifecycle(Lifecycle),
906    /// A secondary native window closed (the title-bar close, a platform gesture such as an
907    /// app-switcher swipe or an activity finish, or `Toolkit::close_window`); emitted on the
908    /// window's root node after the platform committed the close. day-core disposes the
909    /// window's subtree on receipt, so native and programmatic closes share one teardown
910    /// path (docs/windows.md). Never emitted for the primary window (its close terminates
911    /// the app).
912    WindowClosed,
913    /// A secondary window's key/active state changed; emitted on the window's root node.
914    /// day-core tracks the focused window with it (dialog parenting, dayscript targeting).
915    WindowFocused(bool),
916    /// A window's display state changed (docs/windows.md): the user minimized, zoomed or took it
917    /// fullscreen, or a [`WindowChange::State`] request landed. Emitted on the window's root node
918    /// (the primary's may arrive on [`WINDOW_NODE`]); day-core feeds the window's `state()`
919    /// signal with it, so the app reads what the platform did rather than what it asked for.
920    WindowStateChanged(WindowState),
921    /// A native list committed a drag-reorder (docs/list.md): row `from` landed at row `to`.
922    /// Emitted on the `LIST` node by the list piece's commit hook: the reorder driver
923    /// rotates its snapshot synchronously inside the platform's drop callback, then reports
924    /// through the event queue so the app's `on_reorder` never runs inside a native animation
925    /// callstack.
926    ListReorder {
927        from: usize,
928        to: usize,
929    },
930    /// A regrouping sidebar committed a drop (`props::NavMenuProps::regroup`, docs/navigation.md
931    /// "Moving rows between sections"), in item indices into the rows as they were before the
932    /// move: row `from` now sits in the section whose first row is item `section` (item 0 for
933    /// rows above the first header), before item `before`, or at that section's end for
934    /// `None`. `before` is never `from`. Emitted on the `NAV_MENU` node by a toolkit whose
935    /// `Cap::NavReorder` is `Native`, deferred through the event queue exactly as
936    /// [`Event::ListReorder`] is; the nav validates it against the app's rules before reporting.
937    NavRegroup {
938        from: usize,
939        section: usize,
940        before: Option<usize>,
941    },
942    /// A native list committed a swipe-delete (docs/list.md): the row at this index is gone.
943    /// Deferred through the event queue exactly as [`Event::ListReorder`] is.
944    ListDelete(usize),
945    /// The native pull-to-refresh control requested an update. The app owns its busy state.
946    RefreshRequested,
947    /// A native list activated a swipe action (docs/list.md): the user pressed button `action`
948    /// (an index into the row's [`ListSwipe::actions_at`] offer for `edge`) on row `index`, or
949    /// swiped clear across for the edge's first action. Emitted on the `LIST` node by the list
950    /// piece's commit hook and deferred through the event queue exactly as
951    /// [`Event::ListReorder`] is, so the app's handler never runs inside a native animation
952    /// callstack.
953    ListSwipe {
954        index: usize,
955        edge: SwipeEdge,
956        action: usize,
957    },
958    /// A fullscreen cover's hide transition finished (docs/cover.md): the content can now be
959    /// disposed. Answers [`props::CoverPatch::Dismiss`]; delivery is a hard guarantee, and
960    /// backends over-report rather than under-report; consumers gate on their own closing
961    /// flag, so duplicates and belated reports are no-ops.
962    CoverHidden,
963    /// The user showed or hid an inspector pane through a native affordance (a dock widget's
964    /// close button, a divider dragged shut) rather than through Day (docs/inspector.md).
965    /// Emitted on the `kinds::INSPECTOR` node, and only when the visibility actually changed;
966    /// the piece writes it back to the bound signal. Applying
967    /// [`props::InspectorPatch::Visible`] must never re-emit it (the from-native echo rule).
968    InspectorChanged(bool),
969    /// The user disclosed or collapsed a tree row through the native affordance (docs/tree.md).
970    /// Emitted on the `kinds::TREE` node; the piece writes it back to the app's expansion
971    /// signal. Applying [`props::TreePatch::Expand`] must never re-emit it (the from-native
972    /// echo rule).
973    TreeExpanded {
974        token: u64,
975        expanded: bool,
976    },
977    /// A native tree committed a drag move (docs/tree.md): `token` now sits under `parent`
978    /// (`None` = the root) at `index` (`None` = dropped onto the parent, an append). Deferred
979    /// through the event queue exactly as [`Event::ListReorder`] is; the app's `on_move`
980    /// writes its own data, whose refresh reloads the tree.
981    TreeMove {
982        token: u64,
983        parent: Option<u64>,
984        index: Option<usize>,
985    },
986    /// A tree's selection changed: the full set of selected row tokens (empty = cleared).
987    /// Token-addressed, since a tree cannot speak row indices: expanding a row renumbers
988    /// everything below it (docs/tree.md).
989    TreeSelection(Vec<u64>),
990}
991
992impl Event {
993    /// Build a text-carrying [`Event::Custom`] (with `num` defaulted to 0), the common case for an
994    /// in-process piece reporting a value back: `emit(node, Event::custom("webview:url", url))`.
995    pub fn custom(tag: &'static str, text: impl Into<String>) -> Event {
996        Event::Custom {
997            tag,
998            num: 0.0,
999            text: text.into(),
1000        }
1001    }
1002}
1003
1004/// An app-lifecycle phase (docs/lifecycle.md). Each backend maps these onto its OS's native app /
1005/// activity delegate. Some phases only exist on some platforms (a mobile app enters the
1006/// background and can be low on memory, a desktop app cannot), so [`Lifecycle::is_universal`]
1007/// marks the ones every backend delivers, and [`Toolkit::supports_lifecycle`] reports per-backend
1008/// truth.
1009///
1010/// Rough native mapping:
1011///
1012/// | phase | AppKit | UIKit | GTK | Qt | Android | XAML |
1013/// |---|---|---|---|---|---|---|
1014/// | `WillLaunch` / `DidLaunch` | `applicationWill/DidFinishLaunching` | same | `startup`/mount | mount | `onCreate` | window create |
1015/// | `DidBecomeActive` | `didBecomeActive` | `didBecomeActive` | `notify::is-active` | `ApplicationActive` | `onResume` | `Activated` |
1016/// | `WillResignActive` | `willResignActive` | `willResignActive` | `notify::is-active` | `ApplicationInactive` | `onPause` | `Deactivated` |
1017/// | `WillEnterForeground` | — | `willEnterForeground` | — | — | `onStart` | — |
1018/// | `DidEnterBackground` | — | `didEnterBackground` | — | — | `onStop` | — |
1019/// | `DidReceiveMemoryWarning` | — | `didReceiveMemoryWarning` | — | — | `onTrimMemory` | — |
1020/// | `WillTerminate` | `willTerminate` | `willTerminate` | `shutdown` | `aboutToQuit` | `onDestroy` | window close |
1021/// | `DidExit` | after `willTerminate` | after `willTerminate` | `run` returned | `exec` returned | after `onDestroy` | loop returned |
1022#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
1023pub enum Lifecycle {
1024    /// Before the window and UI are built: the first thing to run. Set up global state here.
1025    WillLaunch,
1026    /// The UI is mounted and the app is about to start running. Kick off startup work here.
1027    DidLaunch,
1028    /// The app came to the foreground and is the active, focused app receiving input.
1029    DidBecomeActive,
1030    /// The app is about to stop being active (an interruption, app switch, or losing focus).
1031    WillResignActive,
1032    /// The app is about to return to the foreground (mobile). Refresh what background invalidated.
1033    WillEnterForeground,
1034    /// The app left the foreground and is no longer visible (mobile). Persist state, release UI work.
1035    DidEnterBackground,
1036    /// The system is low on memory (mobile). Drop caches and non-essential memory now.
1037    DidReceiveMemoryWarning,
1038    /// The app is about to terminate: the last chance to save. Triggered by the Quit command,
1039    /// the platform's quit shortcut, or the OS reclaiming the app.
1040    WillTerminate,
1041    /// The run loop is over and nothing of the app will run after this: the last phase, after
1042    /// every `WillTerminate` handler. Day logs its exit line ("Clean exit for …") once the
1043    /// handlers have run, so a handler here is the app's last word before it.
1044    DidExit,
1045}
1046
1047impl Lifecycle {
1048    /// Every phase, in delivery order (launch → run → quit). Handy for logging/registration sweeps.
1049    pub const ALL: [Lifecycle; 9] = [
1050        Lifecycle::WillLaunch,
1051        Lifecycle::DidLaunch,
1052        Lifecycle::DidBecomeActive,
1053        Lifecycle::WillResignActive,
1054        Lifecycle::WillEnterForeground,
1055        Lifecycle::DidEnterBackground,
1056        Lifecycle::DidReceiveMemoryWarning,
1057        Lifecycle::WillTerminate,
1058        Lifecycle::DidExit,
1059    ];
1060
1061    /// True for phases every backend delivers (launch, activation, termination). The remaining
1062    /// phases (`WillEnterForeground`, `DidEnterBackground`, `DidReceiveMemoryWarning`) are mobile
1063    /// concepts and are only delivered by the mobile backends. `const` so it composes into a
1064    /// backend's `const fn lifecycle_supported` and thus into compile-time guards.
1065    pub const fn is_universal(self) -> bool {
1066        matches!(
1067            self,
1068            Lifecycle::WillLaunch
1069                | Lifecycle::DidLaunch
1070                | Lifecycle::DidBecomeActive
1071                | Lifecycle::WillResignActive
1072                | Lifecycle::WillTerminate
1073                | Lifecycle::DidExit
1074        )
1075    }
1076
1077    /// A stable, human-readable name (for logs/warnings).
1078    pub const fn name(self) -> &'static str {
1079        match self {
1080            Lifecycle::WillLaunch => "WillLaunch",
1081            Lifecycle::DidLaunch => "DidLaunch",
1082            Lifecycle::DidBecomeActive => "DidBecomeActive",
1083            Lifecycle::WillResignActive => "WillResignActive",
1084            Lifecycle::WillEnterForeground => "WillEnterForeground",
1085            Lifecycle::DidEnterBackground => "DidEnterBackground",
1086            Lifecycle::DidReceiveMemoryWarning => "DidReceiveMemoryWarning",
1087            Lifecycle::WillTerminate => "WillTerminate",
1088            Lifecycle::DidExit => "DidExit",
1089        }
1090    }
1091}
1092
1093/// The phase of a drag gesture (docs/shapes.md).
1094#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1095pub enum DragPhase {
1096    Began,
1097    Changed,
1098    Ended,
1099}
1100
1101/// A gesture a node wants delivered. Backends attach the matching native recognizer when day-core
1102/// calls [`Toolkit::enable_gesture`]; the default is no gesture (recognizers cost, so opt-in).
1103#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
1104pub enum GestureKind {
1105    Tap,
1106    LongPress,
1107    Drag,
1108    /// The pointer moving over the node without pressing (docs/canvas.md "Interaction"), and
1109    /// leaving it. Delivered only where a pointer exists: every desktop, an iPad with a trackpad
1110    /// or pencil, an Android device with a mouse or stylus. A touch-only device reports none,
1111    /// which is the right answer, not a gap, so anything reachable by hover must also be
1112    /// reachable by a tap.
1113    Hover,
1114    /// Pinch/magnify (docs/shapes.md): trackpad magnification, two-finger touch pinch.
1115    Pinch,
1116    /// Viewport pan (docs/shapes.md): trackpad two-finger scroll, two-finger touch pan.
1117    /// Distinct from [`GestureKind::Drag`], the primary pointer's press-drag.
1118    Pan,
1119}
1120
1121// ---------------------------------------------------------------------------
1122// Menus (app menu bar + context menus). The model is a toolkit-neutral tree; each backend renders
1123// it with its native affordance (NSMenu / GMenu+GtkPopoverMenu / QMenu / UIMenu / Android
1124// PopupMenu / XAML MenuFlyout) and its own conventions, so day imposes no menu manager of its own.
1125// ---------------------------------------------------------------------------
1126
1127/// A keyboard shortcut for a menu item. `primary` is the platform's command modifier (⌘ on Apple,
1128/// Ctrl on GTK/Qt/XAML) so one declaration reads correctly everywhere. `key` is a single character
1129/// (`"s"`, `"."`) or a named key (`"Return"`, `"Delete"`, `"Left"`, `"F1"`).
1130#[derive(Clone, Debug, PartialEq, Eq, Default)]
1131pub struct Shortcut {
1132    pub key: String,
1133    /// ⌘ (Apple) / Ctrl (elsewhere). The conventional command modifier.
1134    pub primary: bool,
1135    pub shift: bool,
1136    /// ⌥ / Alt.
1137    pub alt: bool,
1138    /// Literal Control (⌃ on Apple). Rare; prefer `primary` for the command modifier.
1139    pub control: bool,
1140}
1141
1142impl Shortcut {
1143    /// `primary`+`key` (⌘S / Ctrl+S), the common case: `Shortcut::primary("s").shift()`.
1144    pub fn primary(key: impl Into<String>) -> Shortcut {
1145        Shortcut {
1146            key: key.into(),
1147            primary: true,
1148            ..Default::default()
1149        }
1150    }
1151    /// `key` with no modifiers (e.g. `F1`, plain `Delete`).
1152    pub fn plain(key: impl Into<String>) -> Shortcut {
1153        Shortcut {
1154            key: key.into(),
1155            ..Default::default()
1156        }
1157    }
1158    pub fn shift(mut self) -> Shortcut {
1159        self.shift = true;
1160        self
1161    }
1162    pub fn alt(mut self) -> Shortcut {
1163        self.alt = true;
1164        self
1165    }
1166    pub fn control(mut self) -> Shortcut {
1167        self.control = true;
1168        self
1169    }
1170}
1171
1172/// A standard/system command. The backend supplies the native item, a selector on AppKit/UIKit
1173/// (`cut:`/`copy:`/`paste:`…) or a stock action on GTK/Qt/XAML, so it targets the focused
1174/// control, gets the platform's default label + shortcut, and enables/disables itself
1175/// automatically. This is how default items (Edit ▸ Cut/Copy/Paste, the app's Quit/About) are
1176/// accommodated without the app re-implementing them.
1177#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1178pub enum MenuRole {
1179    Cut,
1180    Copy,
1181    Paste,
1182    SelectAll,
1183    Undo,
1184    Redo,
1185    Delete,
1186    /// The app's About command (docs/settings.md). With an About registered through
1187    /// `day::register_about` the item lowers to that dispatch action (a Day-rendered About
1188    /// window, or the toolkit's own About dialog where one exists); without one AppKit shows
1189    /// its stock Info.plist panel and the other desktops show the label alone.
1190    About,
1191    Quit,
1192    Preferences,
1193    Minimize,
1194    CloseWindow,
1195    Fullscreen,
1196    /// File ▸ New Window (docs/windows.md): opens another window through the builder the
1197    /// app registered with `day::register_new_window`. No platform has a native selector
1198    /// for it, so the item lowers to the registered dispatch action (disabled when none).
1199    NewWindow,
1200    /// Check for Updates… (docs/settings.md): placed directly under About in the macOS App
1201    /// menu (the Sparkle convention) and in Help elsewhere. Lowers to the action an updater
1202    /// registered with `day::register_check_for_updates`; absent from the bar when none did.
1203    CheckForUpdates,
1204}
1205
1206/// The keyboard modifiers held at an interaction, queryable ambiently
1207/// ([`Toolkit::modifiers`]): `primary` is the platform's command key (⌘ on Apple platforms,
1208/// Ctrl elsewhere, the same convention [`Shortcut::primary`] uses). Touch-only backends
1209/// answer all-false.
1210#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
1211pub struct Modifiers {
1212    pub shift: bool,
1213    pub primary: bool,
1214    pub alt: bool,
1215}
1216
1217/// A standard edit command a platform route delivered ([`Event::Edit`]).
1218#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1219pub enum EditOp {
1220    Cut,
1221    Copy,
1222    Paste,
1223    SelectAll,
1224}
1225
1226/// What the app's edit bridge can currently do, mirrored into the toolkit
1227/// ([`Toolkit::set_edit_state`]) so native menu validation enables the stock items.
1228/// `can_paste` is the app's half ("a paste handler is installed"); the toolkit combines it
1229/// with its own clipboard-has-text check where one exists.
1230#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
1231pub struct EditState {
1232    pub can_cut: bool,
1233    pub can_copy: bool,
1234    pub can_paste: bool,
1235    pub can_select_all: bool,
1236}
1237
1238/// The undo stack's face, as a native front mirrors it ([`Toolkit::set_undo_state`]): what is
1239/// possible and what the menu titles should say. Labels arrive already localized: a toolkit
1240/// cannot invent translated text, the same rule as every other label in this file.
1241#[derive(Clone, Debug, PartialEq, Default)]
1242pub struct UndoState {
1243    pub can_undo: bool,
1244    pub can_redo: bool,
1245    /// The next undo unit's display label ("Rename"), empty when nothing is undoable. Fronts
1246    /// interpolate it into the platform's own title form ("Undo Rename").
1247    pub undo_label: String,
1248    pub redo_label: String,
1249}
1250
1251/// One entry in a menu (recursive: a `Submenu` nests).
1252#[derive(Clone, Debug, PartialEq)]
1253pub enum MenuItem {
1254    /// A command. `action` (nonzero) dispatches [`Event::MenuAction`] to the app; a `role`-only item
1255    /// uses the native standard command instead (action 0). `label`/`shortcut` override the role's
1256    /// defaults. `icon` is the platform's own glyph beside the title where menus carry one (macOS,
1257    /// Windows, GNOME, KDE, Android); a backend whose menus are text-only ignores it.
1258    ///
1259    /// The `id`/`action` split is [`ToolbarItem`]'s: `id` names the item and `action` is what it
1260    /// runs. They are separate because a label cannot serve as a name (it is localized, and an
1261    /// item that shows its own state changes it), so anything addressing an item from outside
1262    /// (dayscript's `menu: { id: … }`) needs something that does not move.
1263    Action {
1264        /// The app's own name for this item, where it gave one. Stable across locales and across
1265        /// a change of `checked`, and unique within the menu it is installed in.
1266        id: Option<String>,
1267        /// The item's command, as a dispatch id from the same registry [`Event::MenuAction`] uses
1268        /// (0 = no command), so a toolbar button and its menu twin can share one closure.
1269        action: u64,
1270        label: String,
1271        shortcut: Option<Shortcut>,
1272        enabled: bool,
1273        /// On/off state where the item stands for a setting or one of several choices, drawn with
1274        /// the platform's own check mark. `None` is a plain command and reserves no mark; `Some`
1275        /// makes the item checkable and says which way it currently sits.
1276        ///
1277        /// The item still runs its `action` when chosen; a backend never flips this on its own.
1278        /// The app owns the state, and re-installing the menu (`app_menu`, which re-lowers on change) is what moves
1279        /// the mark, so what the menu shows can never disagree with what the app thinks.
1280        checked: Option<bool>,
1281        role: Option<MenuRole>,
1282        icon: Option<Icon>,
1283    },
1284    /// A nested submenu. At the top level of an app menu a `role` claims one of the platform's
1285    /// standard menu-bar slots: the backend then uses this menu instead of its stock one, and
1286    /// places it where the platform expects that menu to sit.
1287    Submenu {
1288        label: String,
1289        items: Vec<MenuItem>,
1290        role: Option<MenuBarRole>,
1291    },
1292    /// A visual separator.
1293    Separator,
1294}
1295
1296/// A standard menu-bar slot (macOS's File/Edit/View/Window/Help, and their counterparts
1297/// elsewhere). A backend that has a house style for the menu bar fills every slot an app did
1298/// not claim with its own stock menu, so an app never has to restate the platform's furniture,
1299/// and can still replace any of it by tagging its own submenu with the matching role.
1300#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1301#[non_exhaustive]
1302pub enum MenuBarRole {
1303    File,
1304    Edit,
1305    View,
1306    /// KDE/Plasma's Settings menu (`Configure <App>`, Configure Shortcuts). No counterpart on
1307    /// macOS, where preferences live in the app menu, or on GNOME/Windows.
1308    Settings,
1309    /// macOS's Window menu. Windows and Linux desktops do not have one.
1310    Window,
1311    Help,
1312}
1313
1314// ---------------------------------------------------------------------------
1315// Window toolbars (docs/toolbars.md)
1316// ---------------------------------------------------------------------------
1317
1318/// A standard icon, named by what it means rather than by how it looks, so each backend can
1319/// draw the platform's own glyph for it: an SF Symbol on macOS, a freedesktop icon name on
1320/// GTK and Qt, a Fluent glyph on Windows. This is the only way an icon looks native on every
1321/// desktop at once; a bundled PNG cannot, because it is one artist's take on all four.
1322///
1323/// The set is kept small: the commands that recur across toolbars. Anything
1324/// app-specific is an [`Icon::Image`].
1325#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
1326#[non_exhaustive]
1327pub enum Symbol {
1328    Add,
1329    Remove,
1330    Delete,
1331    Edit,
1332    New,
1333    Open,
1334    Save,
1335    Print,
1336    Refresh,
1337    Search,
1338    Share,
1339    Settings,
1340    Info,
1341    Star,
1342    Bookmark,
1343    Back,
1344    Forward,
1345    Up,
1346    Down,
1347    Home,
1348    /// Show/hide the sidebar, the leading item of most desktop toolbars.
1349    Sidebar,
1350    Filter,
1351    Sort,
1352    /// An overflow affordance (macOS `ellipsis`, GNOME's hamburger, Fluent `More`).
1353    More,
1354    Play,
1355    Pause,
1356    Stop,
1357    /// A photo capture: a screenshot command, a camera control.
1358    Camera,
1359    /// Source code.
1360    Code,
1361    /// Light appearance (a sun). The three below are the vocabulary of a theme chooser, which
1362    /// every platform draws with the same three ideas.
1363    Light,
1364    /// Dark appearance (a moon).
1365    Dark,
1366    /// Follow the system: the half-filled circle every platform uses for "automatic".
1367    Auto,
1368    ZoomIn,
1369    ZoomOut,
1370    /// Back to actual size: the 100% zoom command between [`Symbol::ZoomIn`] and
1371    /// [`Symbol::ZoomOut`].
1372    ZoomReset,
1373    Undo,
1374    Redo,
1375    Copy,
1376    Cut,
1377    Paste,
1378    Mail,
1379    Folder,
1380    Document,
1381    Check,
1382    Close,
1383    Warning,
1384    /// A rectangle: the shape vocabulary a drawing surface needs (docs/shapes.md), and the
1385    /// glyphs every drawing, diagram and annotation tool puts in the same place.
1386    Rectangle,
1387    /// An ellipse.
1388    Oval,
1389    /// An outlined circle: the read/unread vocabulary (a read row, its dot removed), and the
1390    /// generic ring glyph.
1391    Circle,
1392    /// A filled circle: the unread dot itself.
1393    CircleFilled,
1394    /// A straight line segment.
1395    Line,
1396    /// Combine the selection into a group: the drawing-tool command (two stacked squares).
1397    Group,
1398    /// Dissolve a group back into its members (two separated squares).
1399    Ungroup,
1400    /// Text: a line of type as a drawing element (the drawing-tool "T").
1401    Text,
1402}
1403
1404/// The SF Symbol each standard symbol draws as. The system's own glyphs match the user's
1405/// device: weight, optical size, accent color and all. Shared by both Apple backends
1406/// (toolbars on macOS, menus on both).
1407///
1408/// The match is exhaustive so that a new [`Symbol`] must name its glyph here rather than
1409/// silently drawing nothing on Apple. A name this OS release does not know still resolves to
1410/// no image, and the item falls back to its label.
1411pub fn sf_symbol_name(s: Symbol) -> &'static str {
1412    match s {
1413        Symbol::Add => "plus",
1414        Symbol::Remove => "minus",
1415        Symbol::Delete => "trash",
1416        Symbol::Edit => "pencil",
1417        Symbol::New => "square.and.pencil",
1418        Symbol::Open => "folder",
1419        Symbol::Save => "square.and.arrow.down",
1420        Symbol::Print => "printer",
1421        Symbol::Refresh => "arrow.clockwise",
1422        Symbol::Search => "magnifyingglass",
1423        Symbol::Share => "square.and.arrow.up",
1424        Symbol::Settings => "gearshape",
1425        Symbol::Info => "info.circle",
1426        Symbol::Star => "star",
1427        Symbol::Bookmark => "bookmark",
1428        Symbol::Back => "chevron.backward",
1429        Symbol::Forward => "chevron.forward",
1430        Symbol::Up => "chevron.up",
1431        Symbol::Down => "chevron.down",
1432        Symbol::Home => "house",
1433        Symbol::Sidebar => "sidebar.leading",
1434        Symbol::Filter => "line.3.horizontal.decrease",
1435        Symbol::Sort => "arrow.up.arrow.down",
1436        Symbol::More => "ellipsis",
1437        Symbol::Play => "play.fill",
1438        Symbol::Pause => "pause.fill",
1439        Symbol::Stop => "stop.fill",
1440        Symbol::Camera => "camera",
1441        Symbol::Code => "chevron.left.forwardslash.chevron.right",
1442        Symbol::Light => "sun.max",
1443        Symbol::Dark => "moon",
1444        Symbol::Auto => "circle.lefthalf.filled",
1445        Symbol::ZoomIn => "plus.magnifyingglass",
1446        Symbol::ZoomReset => "1.magnifyingglass",
1447        Symbol::ZoomOut => "minus.magnifyingglass",
1448        Symbol::Undo => "arrow.uturn.backward",
1449        Symbol::Redo => "arrow.uturn.forward",
1450        Symbol::Copy => "doc.on.doc",
1451        Symbol::Cut => "scissors",
1452        Symbol::Paste => "doc.on.clipboard",
1453        Symbol::Mail => "envelope",
1454        Symbol::Folder => "folder",
1455        Symbol::Document => "doc",
1456        Symbol::Check => "checkmark",
1457        Symbol::Close => "xmark",
1458        Symbol::Warning => "exclamationmark.triangle",
1459        Symbol::Rectangle => "rectangle",
1460        Symbol::Oval => "oval",
1461        Symbol::Circle => "circle",
1462        Symbol::CircleFilled => "circle.fill",
1463        Symbol::Line => "line.diagonal",
1464        Symbol::Group => "square.on.square",
1465        Symbol::Ungroup => "square.on.square.dashed",
1466        Symbol::Text => "textformat",
1467    }
1468}
1469
1470/// A button or toolbar item's picture: a standard [`Symbol`] (drawn with the platform's own icon set) or
1471/// a bundled image from `resource/images` for something only this app has.
1472#[derive(Clone, Debug, PartialEq)]
1473pub enum Icon {
1474    Symbol(Symbol),
1475    /// A bundled image name, resolved the same way [`ImageName`] is elsewhere.
1476    Image(String),
1477}
1478
1479/// What a toolbar item is. The variants are the vocabulary every desktop toolbar shares; each
1480/// backend realizes one with its native control, never with a drawn imitation.
1481#[derive(Clone, Debug, PartialEq)]
1482pub enum ToolbarItemKind {
1483    /// A push button running the item's `action`.
1484    Button,
1485    /// A two-state button. `on` seeds it; the user flipping it emits
1486    /// [`ToolbarValue::On`], and the app's own writes arrive as [`ToolbarPatch::On`].
1487    Toggle { on: bool },
1488    /// A button that drops a menu: the same [`MenuItem`] model the menu bar uses, so a
1489    /// toolbar menu and its menu-bar twin are one list of commands.
1490    Menu { items: Vec<MenuItem> },
1491    /// A search field. Edits emit [`ToolbarValue::Text`]; the app's own writes arrive as
1492    /// [`ToolbarPatch::Text`].
1493    ///
1494    /// Day creates this item itself, from a `.searchable()` surface whose placement resolved to
1495    /// the toolbar (docs/search.md); apps declare search on the surface, never here.
1496    Search {
1497        text: String,
1498        placeholder: String,
1499        /// Completions for the current text, drawn by the field's own completion affordance
1500        /// where it has one (`AutoSuggestBox`, `QCompleter`, `<datalist>`). Empty = none.
1501        suggestions: Vec<String>,
1502    },
1503    /// A row of mutually exclusive choices drawn as one control: the native segmented control
1504    /// (`NSSegmentedControl`, a linked GTK/Qt button box, a XAML toggle strip, `.day-segmented`
1505    /// on the web).
1506    ///
1507    /// This is what a set of related toolbar toggles should be when exactly one of them is on at
1508    /// a time: three separate toggles say "three independent switches" to the eye and to a screen
1509    /// reader, and leave the app to keep them exclusive. Choosing a segment emits
1510    /// [`ToolbarValue::Selected`]; the app's own writes arrive as [`ToolbarPatch::Selected`].
1511    Segmented {
1512        /// One entry per segment. A segment with an icon draws it in place of its title where
1513        /// the platform does that, and keeps the title as its accessible name.
1514        segments: Vec<ToolbarSegment>,
1515        selected: usize,
1516    },
1517    /// Static text, for a status or a caption.
1518    Label,
1519    /// A divider between neighbors in the same placement bucket, where the platform draws one
1520    /// (macOS toolbars have no separator, so AppKit renders it as a fixed space,
1521    /// docs/toolbars.md). Grouping is all it does: alignment is [`ToolbarPlacement`]'s job.
1522    Separator,
1523}
1524
1525/// One choice inside a [`ToolbarItemKind::Segmented`] control.
1526#[derive(Clone, Debug, PartialEq)]
1527pub struct ToolbarSegment {
1528    /// The segment's name: its label where the platform shows text, its accessible name always.
1529    pub title: String,
1530    pub icon: Option<Icon>,
1531}
1532
1533/// The reserved id of the sidebar affordance a `nav(Sidebar)` supplies for itself
1534/// (docs/toolbars.md). Backends with a system item for the job (AppKit's
1535/// `NSToolbarToggleSidebarItem`) recognize it by this id and draw theirs instead; dayscript
1536/// recognizes it to drive the toolkit's own duty rather than an app closure.
1537pub const SIDEBAR_TOGGLE_ID: &str = "day::sidebar_toggle";
1538
1539/// The reserved id of a `.searchable()` surface's field (docs/search.md): the `ToolbarItem` a
1540/// desktop bar draws for it, and the name dayscript's `toolbar:` step drives it by wherever the
1541/// platform put it — on the bar, or inline on the navigation surface where the bar holds no
1542/// field, in which case day-core resolves the id to that host instead.
1543pub const SEARCH_ITEM_ID: &str = "day::search";
1544
1545/// One item in a window's toolbar (docs/toolbars.md).
1546#[derive(Clone, Debug, PartialEq)]
1547pub struct ToolbarItem {
1548    /// Stable identity: the native item identifier, the dayscript target, and the key a
1549    /// [`ToolbarPatch`] addresses. Unique within a toolbar.
1550    pub id: String,
1551    pub kind: ToolbarItemKind,
1552    /// The item's name. Shown beside or below the icon where the platform does that, and used
1553    /// verbatim in the overflow and customization menus, so it is never optional, even on a
1554    /// toolbar that only shows icons. It is also the item's accessible name.
1555    pub label: String,
1556    /// Hover help. Defaults to `label` where the platform expects a tooltip and none is given.
1557    pub tooltip: Option<String>,
1558    pub icon: Option<Icon>,
1559    pub enabled: bool,
1560    /// The item's command, as a dispatch id from the same registry [`Event::MenuAction`] uses
1561    /// (0 = no command), so a toolbar button and its menu twin can share one closure.
1562    pub action: u64,
1563    /// This item's role on the chrome carrying it (docs/toolbars.md). It never names a surface:
1564    /// Which chrome an item rides is decided by where the app declared it, so this says only
1565    /// where on that chrome it belongs.
1566    pub placement: ToolbarPlacement,
1567    /// Whether the item draws its title, its icon, or both, where the platform can do more than
1568    /// one. An item folded into an overflow menu shows its title whatever this asks for; a menu
1569    /// row with no words is not a menu row.
1570    pub label_style: LabelStyle,
1571    /// Draw this item in the platform's emphasized style (a tinted/filled bar button, a
1572    /// prominent `NSToolbarItem`). For the one action a chrome is really offering.
1573    pub prominent: bool,
1574    /// Which column of a multi-pane window this item came from (docs/toolbars.md), set by Day
1575    /// from the piece that declared it, never by an app.
1576    ///
1577    /// A desktop toolbar spans every column at once, and a three-pane app expects each column's
1578    /// commands to sit over that column: the sidebar's against the first divider, the list's over
1579    /// the list, the rest over the detail. That is what Mail, Notes and Finder do, and it is only
1580    /// expressible because the declaration site already said which pane the command belongs to.
1581    /// Backends that draw one undivided bar ignore it.
1582    pub column: ToolbarColumn,
1583}
1584
1585/// Which pane of a window's navigation a toolbar item was declared in (docs/toolbars.md).
1586#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
1587pub enum ToolbarColumn {
1588    /// The window itself, over no particular column. Draws with the detail's items, which is
1589    /// where a command that acts on the whole window belongs when the panes are side by side.
1590    #[default]
1591    Window,
1592    /// The sidebar / master column.
1593    Sidebar,
1594    /// The content-list column between the sidebar and the detail.
1595    List,
1596    /// The detail column.
1597    Detail,
1598}
1599
1600/// Where an item sits on the chrome that carries it (docs/toolbars.md).
1601///
1602/// A placement is a role, never a surface. Which toolbar, navigation bar or column an item
1603/// rides follows from the piece that declared it, so an app never spells that out twice.
1604#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
1605pub enum ToolbarPlacement {
1606    /// The natural spot on this chrome: after the leading group on a desktop toolbar, the
1607    /// trailing item group on a navigation bar, an `IF_ROOM` menu action on Android.
1608    #[default]
1609    Automatic,
1610    /// The leading edge: a pane toggle, a control that reveals what is to its left.
1611    Navigation,
1612    /// Centered: a title accessory, a transport, a status readout.
1613    Principal,
1614    /// Trailing, and the last thing a crowded chrome gives up.
1615    Primary,
1616    /// Trailing, and the first thing a crowded chrome folds into its overflow.
1617    Secondary,
1618    /// A bottom bar where the platform has one (iOS `toolbarItems`, an Android bottom app bar);
1619    /// [`ToolbarPlacement::Secondary`] everywhere else.
1620    Bottom,
1621}
1622
1623/// Which halves of an item's label a platform draws, where it can draw more than one.
1624#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
1625pub enum LabelStyle {
1626    /// The platform's own convention for this chrome.
1627    #[default]
1628    Automatic,
1629    /// The icon alone (the title stays as the accessible name and the tooltip).
1630    IconOnly,
1631    /// The title alone, even where an icon was given.
1632    TitleOnly,
1633    /// Both, where the chrome has room for both.
1634    TitleAndIcon,
1635}
1636
1637/// A value a toolbar item produced.
1638#[derive(Clone, Debug, PartialEq)]
1639pub enum ToolbarValue {
1640    /// A search item's full new text.
1641    Text(String),
1642    /// A search item's text, submitted with the field's Return or Search key. Sent after the
1643    /// last [`ToolbarValue::Text`]; it changes no state of its own.
1644    Submit(String),
1645    /// A toggle item's new state.
1646    On(bool),
1647    /// A segmented item's newly chosen index.
1648    Selected(usize),
1649}
1650
1651/// A targeted update to one live toolbar item: the path that keeps a bound signal in sync
1652/// without rebuilding the bar (which would drop the search field's focus mid-keystroke).
1653#[derive(Clone, Debug, PartialEq)]
1654pub enum ToolbarPatch {
1655    /// Focus a search control and select its text without changing its value.
1656    Focus { item: String },
1657    /// Replace a search item's text.
1658    Text { item: String, text: String },
1659    /// Set a toggle item's state.
1660    On { item: String, on: bool },
1661    /// Select a segmented item's index.
1662    Selected { item: String, index: usize },
1663    /// Enable or disable any item.
1664    Enabled { item: String, on: bool },
1665    /// Replace a search item's completions (docs/search.md). Targeted rather than a rebuild,
1666    /// because these change on every keystroke and rebuilding the bar would take the field's
1667    /// focus with it.
1668    Suggestions { item: String, list: Vec<String> },
1669    /// Replace an item's words (a language change): its label, which an icon-only item still
1670    /// carries for the overflow menu and assistive technology, and its tooltip. The item keeps
1671    /// its native widget, its action and its place.
1672    Label {
1673        item: String,
1674        label: String,
1675        tooltip: Option<String>,
1676    },
1677    /// Replace a search item's empty-state prompt (docs/search.md), in the live field.
1678    Placeholder { item: String, text: String },
1679}
1680
1681impl ToolbarPatch {
1682    /// The id of the item this patch addresses.
1683    pub fn item(&self) -> &str {
1684        match self {
1685            ToolbarPatch::Focus { item }
1686            | ToolbarPatch::Text { item, .. }
1687            | ToolbarPatch::On { item, .. }
1688            | ToolbarPatch::Selected { item, .. }
1689            | ToolbarPatch::Enabled { item, .. }
1690            | ToolbarPatch::Suggestions { item, .. }
1691            | ToolbarPatch::Label { item, .. }
1692            | ToolbarPatch::Placeholder { item, .. } => item,
1693        }
1694    }
1695
1696    /// Write this patch's value into `items`, the model of a bar. `false` when no item there
1697    /// takes it (an unknown id, or a kind the value does not apply to).
1698    pub fn apply_to(&self, items: &mut [ToolbarItem]) -> bool {
1699        let Some(it) = items.iter_mut().find(|i| i.id == self.item()) else {
1700            return false;
1701        };
1702        match (self, &mut it.kind) {
1703            (ToolbarPatch::Focus { .. }, ToolbarItemKind::Search { .. }) => (),
1704            (ToolbarPatch::Text { text, .. }, ToolbarItemKind::Search { text: t, .. }) => {
1705                *t = text.clone()
1706            }
1707            (
1708                ToolbarPatch::Suggestions { list, .. },
1709                ToolbarItemKind::Search { suggestions, .. },
1710            ) => *suggestions = list.clone(),
1711            (ToolbarPatch::On { on, .. }, ToolbarItemKind::Toggle { on: o }) => *o = *on,
1712            (
1713                ToolbarPatch::Selected { index, .. },
1714                ToolbarItemKind::Segmented { segments, selected },
1715            ) if *index < segments.len() => *selected = *index,
1716            (ToolbarPatch::Enabled { on, .. }, _) => it.enabled = *on,
1717            (ToolbarPatch::Label { label, tooltip, .. }, _) => {
1718                it.label = label.clone();
1719                it.tooltip = tooltip.clone();
1720            }
1721            (
1722                ToolbarPatch::Placeholder { text, .. },
1723                ToolbarItemKind::Search { placeholder, .. },
1724            ) => *placeholder = text.clone(),
1725            _ => return false,
1726        }
1727        true
1728    }
1729}
1730
1731/// One step of an edit to a window's toolbar (docs/toolbars.md).
1732///
1733/// Day hands a backend its bar as edits, never as a whole new model to rebuild, for the same
1734/// reason the view tree arrives as `insert`/`remove` rather than as a fresh tree: an item the
1735/// edit does not name keeps its native widget, and with it the keyboard focus, the caret, a
1736/// menu that is open. A value (search text, a toggle) never travels as an edit; it is a
1737/// [`ToolbarPatch`] on the live item.
1738///
1739/// An edit lists every [`ToolbarOp::Remove`] first, then every [`ToolbarOp::Insert`] in
1740/// ascending `index`, so applying them in order to the bar's current items (a
1741/// [`ToolbarMirror`] does exactly that) produces the new bar. Items keep their relative order
1742/// across an edit: an item that moves arrives as a remove and an insert, like one whose
1743/// presentation changed.
1744#[derive(Clone, Debug, PartialEq)]
1745pub enum ToolbarOp {
1746    /// The item with this id leaves the bar.
1747    Remove { id: String },
1748    /// `item` joins the bar at `index`, its position once this edit is complete.
1749    Insert { index: usize, item: ToolbarItem },
1750}
1751
1752impl ToolbarOp {
1753    /// Apply this op to `items`, a bar's model in order.
1754    pub fn apply_to(&self, items: &mut Vec<ToolbarItem>) {
1755        match self {
1756            ToolbarOp::Remove { id } => items.retain(|i| i.id != *id),
1757            ToolbarOp::Insert { index, item } => {
1758                items.insert((*index).min(items.len()), item.clone())
1759            }
1760        }
1761    }
1762}
1763
1764/// A backend's copy of one window's toolbar, kept in step with the [`ToolbarOp`]s and
1765/// [`ToolbarPatch`]es that reach it, and the one place a backend works out where a new item
1766/// goes (docs/toolbars.md).
1767///
1768/// Every backend lays its bar out as a few groups (the leading and trailing ends of a header
1769/// bar, the columns of a split window, the primary and overflow commands of a command bar) and
1770/// keeps model order within each. So an inserted item always goes right after
1771/// [`ToolbarMirror::prev_where`] its own group, or first in the group when that is `None`, and
1772/// nothing already on the bar moves.
1773#[derive(Clone, Debug, Default)]
1774pub struct ToolbarMirror {
1775    items: Vec<ToolbarItem>,
1776}
1777
1778impl ToolbarMirror {
1779    /// The bar's items, in model order.
1780    pub fn items(&self) -> &[ToolbarItem] {
1781        &self.items
1782    }
1783
1784    pub fn is_empty(&self) -> bool {
1785        self.items.is_empty()
1786    }
1787
1788    /// The item with this id.
1789    pub fn get(&self, id: &str) -> Option<&ToolbarItem> {
1790        self.items.iter().find(|i| i.id == id)
1791    }
1792
1793    /// The item dispatching `action`: how a backend whose native bar reports only an action
1794    /// id (a menu tap) finds what was tapped.
1795    pub fn by_action(&self, action: u64) -> Option<&ToolbarItem> {
1796        self.items
1797            .iter()
1798            .find(|i| i.action == action && action != 0)
1799    }
1800
1801    /// Take the item with `id` out, returning it.
1802    pub fn remove(&mut self, id: &str) -> Option<ToolbarItem> {
1803        let at = self.items.iter().position(|i| i.id == id)?;
1804        Some(self.items.remove(at))
1805    }
1806
1807    /// Put `item` at `index` (clamped to the end).
1808    pub fn insert(&mut self, index: usize, item: ToolbarItem) {
1809        let at = index.min(self.items.len());
1810        self.items.insert(at, item);
1811    }
1812
1813    /// Apply one op to the model: what a backend that repaints its whole native bar from the
1814    /// model (a menu of actions) does with each.
1815    pub fn apply(&mut self, op: &ToolbarOp) {
1816        op.apply_to(&mut self.items);
1817    }
1818
1819    /// Write a value patch into the model. `false` when no item takes it.
1820    pub fn patch(&mut self, patch: &ToolbarPatch) -> bool {
1821        patch.apply_to(&mut self.items)
1822    }
1823
1824    /// The nearest item before `index` that `same_group` accepts: the native item a new one at
1825    /// `index` goes right after, in that group. `None` = it goes first in the group.
1826    pub fn prev_where(
1827        &self,
1828        index: usize,
1829        same_group: impl Fn(&ToolbarItem) -> bool,
1830    ) -> Option<&ToolbarItem> {
1831        self.items[..index.min(self.items.len())]
1832            .iter()
1833            .rev()
1834            .find(|i| same_group(i))
1835    }
1836}
1837
1838#[derive(Clone, Debug, PartialEq)]
1839pub struct KeyEvent {
1840    /// The key's name, in the web `KeyboardEvent.key` vocabulary every platform can map onto:
1841    /// the four arrows ("ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown") and the ten digits
1842    /// ("0" through "9", from the main row or the numeric keypad) everywhere, plus
1843    /// "Delete" and "Backspace" on backends with no menu bar ([`Cap::AppMenu`] unsupported),
1844    /// where no accelerator can own those keys. A focused piece claims every key the route
1845    /// carries, so offering them on a menu-bar platform would let a canvas swallow the Delete
1846    /// its own Edit menu was about to act on (docs/menus.md). The two delete keys keep the
1847    /// names the platform gives the physical keys (a Mac's ⌫ is "Backspace" and its ⌦ is
1848    /// "Delete"), so a handler that means "remove this" takes both. A digit arrives only while
1849    /// neither [`KeyEvent::PRIMARY`] nor [`KeyEvent::ALT`] (nor a Mac's Control) is held: ⌘1
1850    /// and Ctrl+1 are accelerators, and a canvas claiming them would starve the menu. Most
1851    /// backends name the digit the keypress types, so a layout whose number row needs Shift for
1852    /// digits reports "1" with [`KeyEvent::SHIFT`] set; XAML and ArkUI read the physical key.
1853    /// Backends emit `Event::Key` only while no text widget has focus; a field's own editing
1854    /// keys never surface here.
1855    pub key: String,
1856    /// A [`KeyEvent::SHIFT`]/[`KeyEvent::PRIMARY`]/[`KeyEvent::ALT`] mask.
1857    pub modifiers: u8,
1858}
1859
1860impl KeyEvent {
1861    pub const SHIFT: u8 = 1;
1862    /// The platform's command key: ⌘ on Apple platforms, Ctrl elsewhere (the
1863    /// [`Shortcut::primary`] convention).
1864    pub const PRIMARY: u8 = 2;
1865    pub const ALT: u8 = 4;
1866
1867    /// The ten digit key names, indexed by value.
1868    pub const DIGITS: [&'static str; 10] = ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"];
1869
1870    /// The key name for a typed character when it is one of the ten digits: what a backend
1871    /// reports for a keypress that types `0`–`9`.
1872    pub fn digit_name(c: char) -> Option<&'static str> {
1873        c.to_digit(10).map(|d| Self::DIGITS[d as usize])
1874    }
1875
1876    /// The value of a digit key, or `None` for every other key.
1877    pub fn digit(&self) -> Option<u8> {
1878        match self.key.as_bytes() {
1879            [d @ b'0'..=b'9'] => Some(d - b'0'),
1880            _ => None,
1881        }
1882    }
1883
1884    pub fn shift(&self) -> bool {
1885        self.modifiers & Self::SHIFT != 0
1886    }
1887    pub fn primary(&self) -> bool {
1888        self.modifiers & Self::PRIMARY != 0
1889    }
1890    pub fn alt(&self) -> bool {
1891        self.modifiers & Self::ALT != 0
1892    }
1893}
1894
1895#[cfg(test)]
1896mod key_event_tests {
1897    use super::KeyEvent;
1898
1899    #[test]
1900    fn digit_names_read_back_as_values() {
1901        for (value, c) in ('0'..='9').enumerate() {
1902            let name = KeyEvent::digit_name(c).expect("an ASCII digit names itself");
1903            let key = KeyEvent {
1904                key: name.to_string(),
1905                modifiers: 0,
1906            };
1907            assert_eq!(key.digit(), Some(value as u8));
1908        }
1909        // Only the ASCII digits: a letter, an Arabic-Indic three, and a named key are not.
1910        assert_eq!(KeyEvent::digit_name('a'), None);
1911        assert_eq!(KeyEvent::digit_name('٣'), None);
1912        let arrow = KeyEvent {
1913            key: "ArrowUp".to_string(),
1914            modifiers: 0,
1915        };
1916        assert_eq!(arrow.digit(), None);
1917    }
1918}
1919
1920#[derive(Clone, Copy, Debug, PartialEq)]
1921pub struct PointerEvent {
1922    pub position: Point,
1923    pub down: bool,
1924}
1925
1926/// The event sink: enqueue-only. It may be invoked re-entrantly from inside any Toolkit method;
1927/// day-core drains queued events at safe points, each as a fresh batch (§3.3).
1928pub type EventSink = Box<dyn Fn(NodeId, Event)>;
1929
1930/// The synchronous row-pull closures for recycling lists (docs/list.md, §10). day-core injects one
1931/// per `LIST` host via [`Toolkit::attach_list`]; a recycling backend stores it and calls it from
1932/// its native data-source (on the UI thread, outside any day-core borrow). Each closure re-enters
1933/// day-core, so, unlike [`EventSink`], these run to completion synchronously (`bind_row` even
1934/// flushes + lays out the row before returning, so the host can measure the cell immediately).
1935#[derive(Clone)]
1936pub struct ListSource {
1937    /// Report the first actually visible row (exclude overscan). Enqueue-only; optional,
1938    /// so hosts without a viewport binding incur no app event work.
1939    pub first_visible: Option<std::rc::Rc<dyn Fn(usize)>>,
1940    /// Current row count.
1941    pub len: std::rc::Rc<dyn Fn() -> usize>,
1942    /// Present on a sectioned list (`ListProps::sections`, docs/list.md "Sections"): whether
1943    /// row `index` is a section header. Every row index the host sees counts headers, and
1944    /// day-core translates to the app's items. A header row is not selectable, activatable or
1945    /// swipeable; the host draws it in its platform's group-header style, sized to its content
1946    /// even in a `Uniform` list, and floats it where the platform does (macOS group rows,
1947    /// UIKit plain-table section headers).
1948    pub is_header: Option<std::rc::Rc<dyn Fn(usize) -> bool>>,
1949    /// Stable identity token for the row at `index` (for native diffing / animation).
1950    pub token_at: std::rc::Rc<dyn Fn(usize) -> u64>,
1951    /// Build (first use of this cell) or rebind (recycled cell) row `index` into the native cell.
1952    pub bind_row: std::rc::Rc<dyn Fn(usize, RawHandle)>,
1953    /// A host that emulates `Cap::ListSections` binds the header it pins over the top of the
1954    /// list with this instead of `bind_row` (docs/list.md "Sections"): the same build or
1955    /// rebind, but the copy carries no element ids, so a dayscript or accessibility lookup of a
1956    /// header finds the header row in the list, never its pinned copy.
1957    pub bind_pinned: std::rc::Rc<dyn Fn(usize, RawHandle)>,
1958    /// The native cell left the viewport; Day may drop per-cell bookkeeping (optional).
1959    pub recycle: std::rc::Rc<dyn Fn(RawHandle)>,
1960    /// The native host is done with this cell for good (a `GtkListItemFactory::teardown`, a
1961    /// `UITableViewCell` deallocating): Day disposes the row it built there, its scope, nodes
1962    /// and element ids. Without this a host that discards cells, as every pool does on a far
1963    /// jump, leaves one dead row behind in day-core per discarded cell. Safe inside a day-core
1964    /// borrow (the work is held for the borrow's end) and for a cell Day never saw.
1965    pub release: std::rc::Rc<dyn Fn(RawHandle)>,
1966    /// Re-lay the row bound to this cell at `width`, called from the cell's own native layout
1967    /// pass, the hook [`TreeSource::layout_cell`] already gives trees. day-core lays a row at the
1968    /// `LIST`'s width first, which is right wherever the cell is the row itself; a backend
1969    /// whose native row wraps the cell in chrome (GtkListView's themed `row` node pads it)
1970    /// corrects it here, so the row fits the cell the platform gave it instead of holding the
1971    /// whole list wider than its frame. Skips quietly when called inside a day-core borrow (a
1972    /// snapshot pass).
1973    pub layout_cell: std::rc::Rc<dyn Fn(RawHandle, f64)>,
1974    /// The height day-core last laid the row bound to this cell at: the row's own height,
1975    /// padding included, as `RowHeight` resolves it. A backend whose cell measures itself
1976    /// (GtkListView's `DayCell`) reports it as the cell's height, so a padded row keeps its
1977    /// bottom padding rather than the height of its last child's frame. `0.0` before the row's
1978    /// first layout, or inside a day-core borrow.
1979    pub cell_height: std::rc::Rc<dyn Fn(RawHandle) -> f64>,
1980    /// Drag-to-reorder closures, present when `ListProps::reorderable` (docs/list.md). `None` on
1981    /// non-reorderable lists; backends must not enable their drag machinery without it.
1982    pub reorder: Option<ListReorder>,
1983    /// Swipe-to-delete closures, present when `ListProps::deletable` (docs/list.md). `None` on
1984    /// non-deletable lists; backends must not offer their delete affordance without it.
1985    pub delete: Option<ListDelete>,
1986    /// Swipe-actions closures, present when `ListProps::swipe_actions` (docs/list.md). `None` on
1987    /// lists that declared none; backends must not install their row-action machinery
1988    /// without it.
1989    pub swipe: Option<ListSwipe>,
1990}
1991
1992/// The synchronous drag-to-reorder half of [`ListSource`] (docs/list.md). Both closures follow
1993/// `bind_row`'s discipline: called on the UI thread from inside native drag callbacks, outside
1994/// any day-core borrow, and they run to completion synchronously.
1995#[derive(Clone)]
1996pub struct ListReorder {
1997    /// May row `from` drop at row `to`? Called from the native validate/hover hook so the
1998    /// affordance (gap, insertion mark, forbidden cursor) reflects the app's answer live.
1999    /// Returns the accepted target index: `to` itself to allow, another index to retarget the
2000    /// drop, or `-1` to deny.
2001    pub can_move: std::rc::Rc<dyn Fn(usize, usize) -> i64>,
2002    /// Commit: row `from` dropped at row `to` (an index `can_move` accepted). Rotates Day's row
2003    /// snapshot before returning (so `len`/`token_at`/`bind_row` reflect the new order while the
2004    /// native move animates) and defers the app's own callback to the next event drain.
2005    pub move_row: std::rc::Rc<dyn Fn(usize, usize)>,
2006}
2007
2008/// The synchronous swipe-to-delete half of [`ListSource`] (docs/list.md), shaped exactly like
2009/// [`ListReorder`]: both closures are called on the UI thread from inside native swipe callbacks,
2010/// outside any day-core borrow, and run to completion synchronously.
2011///
2012/// Each platform spells the gesture its own way (a trailing swipe action on iOS, an
2013/// `ItemTouchHelper` swipe on Android, a `ListItem` swipe action on ArkUI), but all three ask the
2014/// same two questions, so this struct is one pair of closures rather than three shapes.
2015#[derive(Clone)]
2016pub struct ListDelete {
2017    /// May row `index` be deleted? Called before the affordance is offered, so a row the app
2018    /// protects shows no delete action at all rather than one that fails on use.
2019    pub can_delete: std::rc::Rc<dyn Fn(usize) -> bool>,
2020    /// Commit: delete row `index`. Removes it from Day's row snapshot before returning (so
2021    /// `len`/`token_at`/`bind_row` already reflect the shorter list while the native row-removal
2022    /// animates) and defers the app's own callback to the next event drain.
2023    pub delete_row: std::rc::Rc<dyn Fn(usize)>,
2024}
2025
2026/// Which edge of a row a swipe action rides (docs/list.md). Semantic, not geometric:
2027/// `Leading` follows the reading direction (the left edge in LTR, the right in RTL), the
2028/// mapping every platform's swipe API already makes.
2029#[derive(Clone, Copy, Debug, PartialEq, Eq)]
2030pub enum SwipeEdge {
2031    Leading,
2032    Trailing,
2033}
2034
2035/// One offered swipe action (docs/list.md): pure data. The pieces layer keeps the handlers;
2036/// the backend answers a tap through [`ListSwipe::perform`] by index into the offer.
2037#[derive(Clone, Debug, PartialEq)]
2038pub struct ListSwipeAction {
2039    /// The word on the button, already localized (a toolkit cannot translate).
2040    pub label: String,
2041    /// Destructive styling: the platform's red.
2042    pub destructive: bool,
2043    /// Explicit button color; `None` = the platform's default for the style.
2044    pub tint: Option<Color>,
2045    /// A glyph for the button where the platform draws one: above the label on macOS, in
2046    /// place of it on iOS (docs/list.md).
2047    pub symbol: Option<Symbol>,
2048}
2049
2050/// The synchronous swipe-actions half of [`ListSource`] (docs/list.md), the generalized
2051/// sibling of [`ListDelete`]: reveal-as-you-swipe buttons on either row edge. The offer is
2052/// pulled per row at gesture time, so labels follow row state ("Mark Read" on an unread row,
2053/// "Mark Unread" on a read one); the commit defers to the event drain like every closure here.
2054#[derive(Clone)]
2055pub struct ListSwipe {
2056    /// The offer: the actions for row `index` on `edge`, called inside the platform's swipe
2057    /// callback, so keep it pure. Empty = no affordance for that row and edge. The first action
2058    /// is the full-swipe action where the platform activates one at the far edge.
2059    pub actions_at: std::rc::Rc<dyn Fn(usize, SwipeEdge) -> Vec<ListSwipeAction>>,
2060    /// The commit: the user activated action `action` (an index into the offer) on row
2061    /// `index`, `edge`. Unlike a delete this never reshapes the row snapshot; the app's
2062    /// handler is deferred to the next event drain, never run inside the native callback.
2063    pub perform: std::rc::Rc<dyn Fn(usize, SwipeEdge, usize)>,
2064}
2065
2066/// The synchronous row-pull closures for hierarchical trees (docs/tree.md): [`ListSource`]'s
2067/// shape, addressed by token instead of row index, because expanding a row renumbers every
2068/// index below it while tokens hold still. day-core injects one per `TREE` host via
2069/// [`Toolkit::attach_tree`]; the backend answers its native data-source from it. The same
2070/// synchronous discipline as [`ListSource`]: UI thread, outside any day-core borrow;
2071/// `bind_row` flushes and lays the row out before returning.
2072#[derive(Clone)]
2073pub struct TreeSource {
2074    /// How many children `parent` has (`None` = the root level).
2075    pub children_len: std::rc::Rc<dyn Fn(Option<u64>) -> usize>,
2076    /// The i-th child of `parent`: the stable token every backend keys its rows by.
2077    pub child_token: std::rc::Rc<dyn Fn(Option<u64>, usize) -> u64>,
2078    /// Whether this token can hold children at all: what draws (or omits) the disclosure.
2079    pub expandable: std::rc::Rc<dyn Fn(u64) -> bool>,
2080    /// Non-selectable section heading, separate from a navigation destination.
2081    pub section_header: std::rc::Rc<dyn Fn(u64) -> bool>,
2082    /// Current desired expansion, including restoration before the first native reload.
2083    pub expanded: std::rc::Rc<dyn Fn(u64) -> bool>,
2084    /// Build (first use of this cell) or rebind (recycled cell) the row for `token` into the
2085    /// native cell.
2086    pub bind_row: std::rc::Rc<dyn Fn(u64, RawHandle)>,
2087    /// The native cell left the viewport; Day may drop per-cell bookkeeping (optional).
2088    pub recycle: std::rc::Rc<dyn Fn(RawHandle)>,
2089    /// Re-lay the row bound to this cell at `width`, called from the cell's own native
2090    /// layout pass. Trees need this where lists don't: indentation makes every cell's
2091    /// content width per-row, so the host-width layout `bind_row` did is only a first
2092    /// approximation. Skips quietly when called inside a day-core borrow (a snapshot pass).
2093    pub layout_cell: std::rc::Rc<dyn Fn(RawHandle, f64)>,
2094    /// Native bounds for controls whose group headers have a different height from leaves.
2095    /// Overrides the initial row-height estimate and preserves the native height on rebind.
2096    pub layout_cell_size: std::rc::Rc<dyn Fn(RawHandle, Size)>,
2097    /// The height day-core last laid this cell's row at (see [`ListSource::cell_height`]).
2098    pub cell_height: std::rc::Rc<dyn Fn(RawHandle) -> f64>,
2099    /// The row's type-ahead string (docs/tree.md): what native type-select matches against.
2100    pub type_select_text: std::rc::Rc<dyn Fn(u64) -> String>,
2101    /// The row's context menu, built when the menu is summoned (docs/menus.md "Dynamic context
2102    /// menus"), called on the UI thread from the platform's own menu callback, outside any
2103    /// day-core borrow, like every closure here. `None` = rows carry no menu; an empty
2104    /// result suppresses the menu for that row.
2105    pub row_menu: Option<std::rc::Rc<dyn Fn(u64) -> Vec<MenuItem>>>,
2106    /// Drag-to-move closures, present when `TreeProps::movable` (docs/tree.md). `None` on
2107    /// immovable trees; backends must not enable their drag machinery without it.
2108    pub moves: Option<TreeMoves>,
2109}
2110
2111/// The synchronous drag half of [`TreeSource`] (docs/tree.md). Both closures follow
2112/// `bind_row`'s discipline: called on the UI thread from inside native drag callbacks.
2113#[derive(Clone)]
2114pub struct TreeMoves {
2115    /// The live verdict, consulted while the drag is over a target: may `token` land under
2116    /// `parent` at `index`? `index: None` means "dropped onto the parent" (append). Pure, since
2117    /// it runs inside the platform's drag-validate callback.
2118    #[allow(clippy::type_complexity)]
2119    pub can_move: std::rc::Rc<dyn Fn(u64, Option<u64>, Option<usize>) -> MoveVerdict>,
2120    /// Commit an accepted drop. Defers the app's `on_move` through the event queue
2121    /// ([`Event::TreeMove`]); the app's own data write drives the reload.
2122    #[allow(clippy::type_complexity)]
2123    pub move_node: std::rc::Rc<dyn Fn(u64, Option<u64>, Option<usize>)>,
2124}
2125
2126/// A summon-time context-menu provider (docs/menus.md): local-point in, menu out.
2127pub type ContextMenuFn = std::rc::Rc<dyn Fn(Point) -> Vec<MenuItem>>;
2128
2129/// A tree move guard's verdict (docs/tree.md). No `Retarget` yet: the drop vocabulary is
2130/// already positional, so a guard that wants a different target denies and the user drops
2131/// where it is allowed.
2132#[derive(Clone, Copy, Debug, PartialEq, Eq)]
2133pub enum MoveVerdict {
2134    Allow,
2135    Deny,
2136}
2137
2138/// One addressable widget within a composite native backing (docs/tree.md,
2139/// docs/tweaks.md), Qt's name for the concept (`QStyle::SubControl`). A `list`'s or
2140/// `tree`'s node handle is its scroller; `Subcontrol::Content` is the widget inside it.
2141/// Backends resolve an unknown subcontrol to `None`, never to the host.
2142#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
2143pub enum Subcontrol {
2144    /// The node's own handle: what every tweak accessor reached before subcontrols existed.
2145    Host,
2146    /// The content widget inside the scroller (`NSOutlineView`, `NSTableView`, …).
2147    Content,
2148    /// The header view, where the widget has one. Day's lists and trees ship headerless, so
2149    /// this answers `None` until a kind grows a header.
2150    Header,
2151}
2152
2153// ---------------------------------------------------------------------------
2154// Capabilities, animation, a11y
2155// ---------------------------------------------------------------------------
2156
2157/// What to show on the app's icon in the Dock, launcher, home screen, or taskbar (docs/badge.md).
2158///
2159/// Distinct from `NavItem::badge`, which annotates a sidebar row inside the window. This one
2160/// decorates the application itself and is drawn by the shell, not by Day.
2161///
2162/// What each payload needs is asked separately (`Cap::AppBadgeCount`, `AppBadgeText`,
2163/// `AppBadgeDot`) because the platforms differ in what they accept more than in whether they have
2164/// a badge at all: macOS takes arbitrary text, iOS and the web take a number, Android takes nothing.
2165#[derive(Clone, Debug, PartialEq, Eq, Default)]
2166pub enum AppBadge {
2167    /// Clear the badge.
2168    #[default]
2169    None,
2170    /// A count. Zero clears it, matching every platform's own convention.
2171    Count(u32),
2172    /// Short arbitrary text (`"99+"`, `"beta"`). Only macOS renders it; probe `Cap::AppBadgeText`.
2173    Text(String),
2174    /// An indicator with no value.
2175    Dot,
2176}
2177
2178/// How wide a window is, in coarse buckets (docs/size-classes.md).
2179///
2180/// The breakpoints are Android's window size classes, in density-independent points, and they are
2181/// used verbatim on every backend rather than per-platform. One table means one answer: a 700pt
2182/// window is [`WidthClass::Medium`] on a Mac, in a browser, and on a tablet, so an app that lays
2183/// out from the class gets the same layout at the same size everywhere. Apple publishes only two
2184/// buckets (compact/regular); those map onto this table rather than replacing it, with
2185/// [`WidthClass::Compact`] the compact one and everything above it regular.
2186#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug, Default)]
2187pub enum WidthClass {
2188    /// `< 600dp`: a phone in portrait. One pane at a time.
2189    #[default]
2190    Compact,
2191    /// `600–839dp`: a tablet in portrait, a narrow desktop window.
2192    Medium,
2193    /// `840–1199dp`: a tablet in landscape, a typical desktop window.
2194    Expanded,
2195    /// `1200–1599dp`: a large desktop window.
2196    Large,
2197    /// `≥ 1600dp`: a maximized window on a big display.
2198    ExtraLarge,
2199}
2200
2201/// How tall a window is (docs/size-classes.md). Consulted far less than [`WidthClass`]: it is
2202/// what tells a phone in landscape (wide enough for two panes, too short for a tall list) that
2203/// it should not grow vertical chrome.
2204#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug, Default)]
2205pub enum HeightClass {
2206    /// `< 480dp`: a phone in landscape.
2207    #[default]
2208    Compact,
2209    /// `480–899dp`: a phone in portrait, a tablet in landscape.
2210    Medium,
2211    /// `≥ 900dp`: a tablet in portrait, most desktop windows.
2212    Expanded,
2213}
2214
2215/// A window's size class (docs/size-classes.md), reported by the backend and read by apps
2216/// through `day::size_class()`.
2217///
2218/// Per-window, not per-app: two windows of one process can sit in different classes at the same
2219/// time (a narrow and a wide window side by side, iPadOS Stage Manager, Android split-screen), and
2220/// an app that keyed off a single global would lay the second window out for the first one's size.
2221#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
2222pub struct SizeClass {
2223    pub width: WidthClass,
2224    pub height: HeightClass,
2225}
2226
2227impl SizeClass {
2228    /// The `Compact`/`Medium` width boundary in dp: the narrowest window where a second
2229    /// pane starts to fit. Backends that hand an adaptive container its pane widths derive
2230    /// them from this (Android's list + minimum-detail widths sum to it), so the platform's
2231    /// measure-time decision and this table agree by construction (docs/size-classes.md).
2232    pub const SPLIT_MIN_WIDTH: f64 = 600.0;
2233
2234    /// Bucket a window's size in points. The one place the breakpoint numbers appear.
2235    pub fn from_size(width: f64, height: f64) -> Self {
2236        SizeClass {
2237            width: match width {
2238                w if w >= 1600.0 => WidthClass::ExtraLarge,
2239                w if w >= 1200.0 => WidthClass::Large,
2240                w if w >= 840.0 => WidthClass::Expanded,
2241                w if w >= Self::SPLIT_MIN_WIDTH => WidthClass::Medium,
2242                _ => WidthClass::Compact,
2243            },
2244            height: match height {
2245                h if h >= 900.0 => HeightClass::Expanded,
2246                h if h >= 480.0 => HeightClass::Medium,
2247                _ => HeightClass::Compact,
2248            },
2249        }
2250    }
2251
2252    /// Whether a two-pane presentation fits. The rule behind an automatic
2253    /// [`props::NavPresentation`]: anything wider than compact gets both panes.
2254    pub fn prefers_split(self) -> bool {
2255        self.width > WidthClass::Compact
2256    }
2257}
2258
2259#[derive(Clone, Copy, PartialEq, Eq, Debug)]
2260pub enum Cap {
2261    /// Standard searchfield control.
2262    SearchField,
2263    /// Standard combobox control.
2264    ComboBox,
2265    /// Standard stepper control.
2266    Stepper,
2267    /// Standard datetime control.
2268    DateTimePicker,
2269    /// Standard colorpicker control.
2270    ColorPicker,
2271    /// Standard pullrefresh control.
2272    PullRefresh,
2273    /// Standard pager control.
2274    Pager,
2275    /// User-driven pull recognition, separate from a refresh indicator.
2276    PullRefreshGesture,
2277
2278    /// Native data-transfer source and destination callbacks.
2279    DragDrop,
2280    /// Transfers between independent native applications (inbound).
2281    DragExternalImport,
2282    /// Transfers to independent native applications, including arbitrary binary MIME data.
2283    DragExternalExport,
2284    /// Lossless ordered items and alternatives between Day applications.
2285    DragMultipleItems,
2286    /// Existing native file references, distinct from promised/generated files.
2287    DragFileReferences,
2288    /// Native result can wait for asynchronous data receipt.
2289    DragDeferredReceipt,
2290    /// Generated files supplied to a destination after dropping.
2291    DragFilePromises,
2292    /// External move acknowledgment safe for source deletion.
2293    DragExternalMove,
2294    ListRecycling,
2295    /// The toolkit fronts the app's undo stack with the platform's own undo system
2296    /// ([`Toolkit::set_undo_state`] + `Event::Undo`): `Native` where one exists
2297    /// (`NSUndoManager` on macOS/iOS, `QUndoStack`), `Unsupported` everywhere else, where
2298    /// Day's menu items and accelerators drive the stack directly and nothing platform-owned
2299    /// needs mirroring (docs/model.md).
2300    UndoBridge,
2301    /// The toolkit routes the platform's standard Cut/Copy/Paste to the app's edit bridge
2302    /// ([`Toolkit::set_edit_state`] + `Event::Edit`): the same menu items, shortcuts, and
2303    /// responder precedence the platform's text editing uses; a focused text widget keeps
2304    /// its own clipboard behavior, everything else reaches the app (docs/menus.md).
2305    /// `Native` where a system route exists (the responder chain, the browser's clipboard
2306    /// events), `Emulated` where Day's own menu items dispatch it, `Unsupported` elsewhere.
2307    EditBridge,
2308    /// The toolkit realizes `ListProps::reorderable` as drag-to-reorder rows: `Native` when the
2309    /// platform's own mechanism drives it (NSTableView drag/drop, UITableView drag delegates,
2310    /// ItemTouchHelper, …), `Emulated` for a pointer-tracked fake (web-dom). `Unsupported` ⇒ the
2311    /// list renders normally but rows cannot be dragged (docs/list.md).
2312    ListReorder,
2313    /// Sidebar destinations can be dragged to reorder (`NavBuilder::reorder_items`) and between
2314    /// sections (`NavBuilder::regroup_items`). `Native` when the toolkit's own sidebar drags them
2315    /// (`NavMenuProps::movable`, and across headers under `NavMenuProps::regroup`); `Emulated`
2316    /// when Day stands a reorderable list in for the sidebar. Independent of `ListReorder`: UIKit
2317    /// supports draggable content lists but retains its native navigation collection for
2318    /// sidebars.
2319    NavReorder,
2320    /// The toolkit realizes `ListProps::deletable` as the platform's delete gesture:
2321    /// `Native` where the platform ships one (UIKit's trailing swipe actions, Android's
2322    /// `ItemTouchHelper`, ArkUI's `ListItem.swipeAction`), `Unsupported` on the desktop toolkits,
2323    /// whose lists have no swipe idiom and where deletion belongs to a menu or a button
2324    /// (docs/list.md).
2325    ListDelete,
2326    /// The toolkit realizes `ListProps::swipe_actions` as edge swipe actions: buttons
2327    /// that reveal as the row tracks the swipe, with the far edge activating the first one
2328    /// (docs/list.md). `Native` where the platform ships the affordance: macos-appkit
2329    /// (`NSTableView` row actions, Mail's own machinery) and ios-uikit
2330    /// (`UISwipeActionsConfiguration`). `Unsupported` everywhere else: the affordance is
2331    /// absent, and the app's command lives in its explicit controls.
2332    ListSwipeActions,
2333    /// The toolkit realizes `ListProps::editing` as the platform's list editing mode: rows
2334    /// show their delete controls and reorder grips while the list is editing (docs/list.md).
2335    /// `Native` on ios-uikit (`UITableView` editing). `Unsupported` everywhere else, where a
2336    /// list has no separate editing mode: its rows already drag, swipe and open menus as they
2337    /// are, and the list renders as if `editing` were off.
2338    ListEditing,
2339    /// The toolkit realizes `kinds::LIST_ITEM` as its own list row content (docs/list.md "List
2340    /// items"): `Native` where the platform has a standard row it draws itself (UIKit's
2341    /// `UIListContentConfiguration`, a Material list item, AppKit's image-and-text table cell,
2342    /// a GTK row box), so the row takes the platform's fonts, icon size, spacing, secondary
2343    /// colors and check mark. `Unsupported` ⇒ the `list_item` piece composes the same content
2344    /// from plain pieces. Apps have no reason to probe this; the piece does.
2345    ListItem,
2346    /// The toolkit realizes `kinds::TREE` as a hierarchical tree view (docs/tree.md):
2347    /// `Native` where a platform tree widget hosts Day-built rows (NSOutlineView, the UIKit
2348    /// sidebar list, GtkListView+TreeListModel, WinUI TreeView, ArkTS TreeView), `Emulated`
2349    /// where Day flattens the tree onto its list machinery, `Unsupported` where neither
2350    /// exists yet. An app gates its tree UI on this answer rather than showing a placeholder.
2351    Tree,
2352    /// The toolkit realizes `TreeProps::movable` as drag-to-reparent/restack rows
2353    /// (docs/tree.md). A strictly smaller set than [`Cap::Tree`]: a backend can render a
2354    /// tree it cannot yet drag within.
2355    TreeMove,
2356    /// Native two-level section headers; general trees may still be composed.
2357    TreeSections,
2358    /// The toolkit answers [`Toolkit::first_baseline`], so rows can align text on its baseline
2359    /// rather than on the middle of its box (docs/baseline.md). `Native` where the platform
2360    /// reports the baseline itself (`NSView.firstBaselineOffsetFromTop`, `View.getBaseline`,
2361    /// `gtk_widget_measure`), `Emulated` where Day derives it from the widget's font metrics,
2362    /// `Unsupported` ⇒ baseline-aligned rows fall back to centering and look exactly as they do
2363    /// today.
2364    BaselineAlignment,
2365    /// The toolkit draws a label's [`TextRun`]s: bold, italic, color or a monospace face within
2366    /// one wrapping paragraph (docs/text-runs.md). `Unsupported` ⇒ the label renders its text
2367    /// uniformly, which reads correctly and loses only the emphasis.
2368    TextRuns,
2369    /// The toolkit makes a run carrying [`TextRun::link`] activatable, emitting
2370    /// [`Event::LinkActivated`]. A strictly smaller set than [`Cap::TextRuns`]: several toolkits
2371    /// draw a link run in link colors but have no way to hit-test it, and one (Android) can do
2372    /// links or selection but not both (docs/text-runs.md).
2373    TextLinks,
2374    Lottie,
2375    NativeSymbols,
2376    /// The toolkit can rasterize its window and hand back the pixels
2377    /// (`Toolkit::snapshot_window`): what the dayscript `screenshot` step captures in-process
2378    /// and what `day::window_image()` offers the app (docs/window-image.md). `Unsupported` where
2379    /// a backend cannot draw itself into a bitmap: web-dom, which would need a rasterizer
2380    /// shipped with it. Probe it before offering a "save a screenshot" affordance, rather than
2381    /// offering one that fails when pressed.
2382    Snapshot,
2383    /// The toolkit can present `nav()` as sidebar+detail split panes, a statement about the
2384    /// toolkit, not about the window it is currently drawing. Whether a given host is split right
2385    /// now follows from its [`SizeClass`]: the pieces layer resolves an automatic
2386    /// [`props::NavPresentation`] against both, and re-resolves on every class change. A backend
2387    /// with no split container answers `Unsupported` and stays stacked at every size.
2388    NavSplit,
2389    /// The toolkit can draw a navigation host's rows as its own chrome: a tab bar
2390    /// ([`props::NavPresentation::Tabs`]) and, where it has one, a rail
2391    /// ([`props::NavPresentation::Rail`]). Like [`Self::NavSplit`] this is a statement about the
2392    /// toolkit, not about the window it is drawing right now.
2393    ///
2394    /// This is what `NavStyle::Automatic` resolves against (docs/navigation.md): a backend
2395    /// answering `Unsupported` gets the sidebar resolver instead (`Split` ↔ `Stack`), which is
2396    /// what every backend did before adaptive navigation existed. So an unimplemented backend
2397    /// degrades to its previous behavior rather than to a hole.
2398    ///
2399    /// `Emulated` means the rows are composed from other widgets rather than drawn by a native
2400    /// tab container (Qt, web-dom). `Native` means a real one (`UITabBarController`, a Material
2401    /// `NavigationBarView`, `NavigationView`).
2402    NavTabs,
2403    /// A narrow window should become a tab bar here, a separate question from [`Self::NavTabs`],
2404    /// which only says the toolkit *can* draw one.
2405    ///
2406    /// The two differ on every desktop. macOS, GNOME, Qt and Windows can all draw a tab bar, and
2407    /// must, because an app is free to pin `NavStyle::Tabs`; but none of them grows one when
2408    /// its window is dragged narrow. A narrow Mail.app hides its sidebar and pushes; it does not
2409    /// sprout a bottom tab bar, and an app that did would look like a port. The phones and the web
2410    /// are the opposite: those are the surfaces whose window size ranges from a phone to a
2411    /// desktop, and a tab bar is what their users expect at the narrow end.
2412    ///
2413    /// So this is a statement about the platform's idiom, not about its widget set:
2414    ///
2415    /// - `Native`/`Emulated`: `NavStyle::Automatic` may resolve to
2416    ///   [`props::NavPresentation::Tabs`] on a compact window (ios-uikit, android-material,
2417    ///   harmony-arkui, web-dom).
2418    /// - `Unsupported`: it may not; a compact window collapses to
2419    ///   [`props::NavPresentation::Stack`] instead, exactly as `NavStyle::Sidebar` has always
2420    ///   done (macos-appkit, linux-gtk, linux-qt, windows-winui).
2421    ///
2422    /// [`props::NavPresentation::Rail`] is not gated by this. A narrow sidebar is an ordinary
2423    /// desktop shape (on Windows it is what `NavigationView` does at that width on its own), so
2424    /// the rail rung stays available everywhere `Cap::NavSplit` is.
2425    NavTabsAdaptive,
2426    /// How a navigation host's presentation follows the window (docs/size-classes.md). All three
2427    /// answers mean something different here, and the difference is who decides:
2428    ///
2429    /// - `Native`: **Day tells the toolkit.** The pieces layer resolves the presentation from
2430    ///   the [`SizeClass`], re-resolves on every change, and sends
2431    ///   [`props::NavPatch::Presentation`]; the toolkit rebuilds its chrome and re-homes the
2432    ///   pages it already has. The desktops and web-dom.
2433    /// - `Emulated`: **the toolkit tells Day.** Its own adaptive container owns the decision
2434    ///   (`UISplitViewController` collapsing as a Pro Max iPhone rotates, `SlidingPaneLayout`
2435    ///   measuring whether both panes fit), so Day must not push a presentation into it: that
2436    ///   would be a second decision racing the platform's own animation. The toolkit
2437    ///   reports what it did with [`Event::NavPresentationChanged`] and Day reconciles.
2438    /// - `Unsupported`: nobody re-presents. The presentation is resolved once, from
2439    ///   [`Cap::NavSplit`] alone, and the window's size never enters into it. A toolkit that
2440    ///   cannot change presentation must not have it decided by something that can, or a window
2441    ///   launched narrow would be stuck stacked with no way back.
2442    NavRepresent,
2443    /// The toolkit places a navigation host's content-list page ([`props::Pane::List`]) in its
2444    /// own pane between the sidebar and the detail, the Mail shape: mailboxes, message list,
2445    /// message (docs/navigation.md). The three answers differ on what happens when the host
2446    /// leaves the split:
2447    ///
2448    /// - `Native`: the pane exists at every presentation. A narrow window collapses the
2449    ///   sidebar but keeps the list beside the detail, exactly as a narrow Mail.app does
2450    ///   (macos-appkit: a `contentList` `NSSplitViewItem`).
2451    /// - `Emulated`: the pane exists while split and merges into the navigation stack when
2452    ///   the host collapses (ios-uikit: `UISplitViewController` triple-column). The pieces
2453    ///   layer then interposes the list between the sidebar root and the detail
2454    ///   ([`props::NavPatch::ListInStack`]) and gates the detail push on the app's
2455    ///   `detail_visible` binding.
2456    /// - `Unsupported`: the backend never sees [`props::Pane::List`] or
2457    ///   [`props::NavProps::list_width`]; the nav piece composes the pane beside (split)
2458    ///   or in place of (stacked) the destination content itself.
2459    NavContentList,
2460    /// The toolkit shows the current destination's title in a native header/bar, so a page
2461    /// needn't repeat it in its own content. `Native` on XAML (the NavigationView header),
2462    /// UIKit (`UINavigationBar`), Android (`MaterialToolbar`), and ArkUI (`NavDestination`
2463    /// title bars). The desktop custom back-headers (AppKit/Qt/web) show a title only in
2464    /// stack mode, so those backends stay `Unsupported` (docs/navigation.md).
2465    NavHeader,
2466    /// The toolkit applies a runtime appearance override (`set_appearance`): native widgets
2467    /// restyle in place and `dark_mode` answers the override. Probe before showing a theme
2468    /// picker; on `Unsupported` backends the call is ignored.
2469    Appearance,
2470    /// The toolkit renders an application menu bar (docs/menus.md), so `app_menu` is visible to
2471    /// the user, and `register_preferences` gets its automatic Settings… item.
2472    ///
2473    /// The question an app asks before deciding where a command lives. Settings is the usual
2474    /// case: on a platform with a menu bar it belongs in the App menu and putting it in the
2475    /// navigation as well is clutter; on one without, the menu is a no-op and a Settings row is
2476    /// the only way in. Distinct from `Cap::Toolbar`: web-dom draws a window toolbar and has no
2477    /// menu bar at all, so keying off the toolbar strands Settings there.
2478    AppMenu,
2479    /// The toolkit can present native alert/confirm/sheet/prompt modals (docs/dialogs.md).
2480    Dialogs,
2481    /// The toolkit can present native open/save file pickers (docs/files.md).
2482    FileDialogs,
2483    /// The toolkit can present a native folder chooser (`PresentSpec::OpenFolder`,
2484    /// docs/files.md). `Native` on the four desktop toolkits (AppKit, GTK, Qt, WinUI); the
2485    /// phone and web backends answer `Unsupported` and resolve the request as `Dismissed`, so
2486    /// an app hides its "Add Folder…" command where this is not `Native`.
2487    FolderDialog,
2488    /// The toolkit runs backend-executed animation for `AnimSpec` intents on
2489    /// `update`/`set_frame`/`set_opacity`/`set_transform` (§8.4). `Unsupported` ⇒ animated calls
2490    /// apply instantly (still correct, just not animated).
2491    Animation,
2492    /// The toolkit presents a `kinds::COVER` node as a native fullscreen modal surface
2493    /// (docs/cover.md). `Unsupported` ⇒ the `cover` piece's content never shows.
2494    Cover,
2495    /// The toolkit presents a `.sheet()` cover as a native modal sheet (AppKit's window sheet,
2496    /// UIKit's page sheet) over the page it covers (docs/cover.md "Modal sheet"). `Unsupported`
2497    /// ⇒ the `cover` piece composes the sheet itself: on a window wider than compact, a card
2498    /// centered over the dimmed page; on a compact one, the full window.
2499    CoverSheet,
2500    /// The toolkit can hide the system status bar while a `status_bar_hidden` subtree is
2501    /// mounted (`Toolkit::set_status_bar_hidden`, docs/cover.md). `Native` on the phone and
2502    /// tablet backends; desktop and web have no status bar, so a setting that offers to hide
2503    /// it should be offered only where this is not `Unsupported`.
2504    StatusBarHidden,
2505    /// The toolkit's `text_area` can be made read-only (`TextAreaProps::editable = false`).
2506    TextEditable,
2507    /// The toolkit's `text_area` selectability can be toggled (`TextAreaProps::selectable`).
2508    /// `Unsupported` where selection is always on (GTK); `Emulated` where the backend
2509    /// collapses native selections itself (XAML, ArkUI).
2510    TextSelectable,
2511    /// The toolkit's `text_area` has built-in spell-check/autocorrect (`TextAreaProps::spellcheck`).
2512    /// `Unsupported` where the toolkit ships none (GTK, Qt, ArkUI).
2513    TextSpellCheck,
2514    /// The toolkit can open additional native OS windows (`Toolkit::open_window`,
2515    /// docs/windows.md). `Native` on the desktop backends and on mobile backends whose
2516    /// platform has a real secondary-window surface (iPad scenes, Android document
2517    /// activities, OHOS multiton abilities). On `Unsupported` backends
2518    /// `day_core::open_window` still works; the content presents as a fullscreen cover in
2519    /// the primary window instead. Probe it to adapt chrome: an `Unsupported` tier has no
2520    /// native title bar or close button, so window content should carry its own close
2521    /// affordance.
2522    MultiWindow,
2523    /// The toolkit can put a count on the app icon (`Toolkit::set_app_badge`, docs/badge.md).
2524    /// `Emulated` where the call is made but the shell may ignore it: desktop Linux, where the
2525    /// Unity launcher protocol is honored by Plasma and Dash-to-Dock but not by stock GNOME.
2526    AppBadgeCount,
2527    /// The toolkit renders `AppBadge::Text` as written. macOS only: `NSDockTile.badgeLabel` is an
2528    /// arbitrary string, while every other platform's badge is a number or nothing.
2529    AppBadgeText,
2530    /// The toolkit can show a valueless indicator (`AppBadge::Dot`).
2531    AppBadgeDot,
2532    /// The toolkit gives a window chrome that persists across pages
2533    /// (`Toolkit::edit_toolbar`, docs/toolbars.md): `Native` on the desktop backends and the
2534    /// phones, `Emulated` on web-dom, `Unsupported` where the only chrome belongs to a page.
2535    ///
2536    /// It no longer decides whether a command can be shown at all: a toolbar item declared on a
2537    /// piece rides that piece's own chrome everywhere. Probe it only for a layout decision that
2538    /// turns on a persistent bar existing.
2539    Toolbar,
2540    /// The window toolbar can hold a search field (docs/search.md): `Native` on the desktop
2541    /// backends, `Emulated` on web-dom. `Unsupported` on the phones, whose bar is a row of actions
2542    /// (an Android app-bar menu, iOS bar buttons, ArkUI title-bar menus): their field goes on the
2543    /// navigation surface instead, so `SearchPlacement::Toolbar` resolves to `Inline` there.
2544    ToolbarSearch,
2545    /// The toolkit realizes `kinds::INSPECTOR` as its own trailing-pane container
2546    /// (docs/inspector.md): an `NSSplitView` inspector pane, a `GtkPaned` with the panel as
2547    /// its end child, a `QDockWidget`, a XAML `SplitView` right pane. `Unsupported` ⇒ the
2548    /// `inspector` piece composes the pane from plain containers instead (same panel, same
2549    /// signal, no native divider), so an unimplemented backend degrades to a drawn pane, not
2550    /// to a hole. Apps normally have no reason to probe this; the piece does.
2551    Inspector,
2552    /// The toolkit realizes `kinds::SPLIT` as its own splitter (docs/split.md): an
2553    /// `NSSplitView`, a `GtkPaned`, a `QSplitter`, each side by side or stacked and with the
2554    /// divider the user drags. `Unsupported` ⇒ the `split` piece composes the two panes and
2555    /// draws and drags the divider itself (the web, the mobile toolkits, XAML), so the piece
2556    /// behaves the same everywhere and only the divider's look is the toolkit's. Apps have no
2557    /// reason to probe this; the piece does.
2558    Split,
2559    /// The toolkit shapes the pointer over a node from the `.cursor()` decorator
2560    /// ([`Toolkit::set_cursor`], docs/cursor.md). `Native` where the platform draws the requested
2561    /// shape from its own set (AppKit, GTK, the DOM, Android's `PointerIcon`), `Emulated` where
2562    /// some shapes map to a nearest neighbor (Qt, XAML) or the platform draws pointer effects
2563    /// rather than shapes (iPadOS), `Unsupported` where nothing changes: a touch-only toolkit,
2564    /// or one whose pointer API Day does not reach yet. Apps ask before offering an affordance
2565    /// that only a cursor reveals, such as a resize handle with no other visual.
2566    Cursor,
2567    /// The toolkit can enumerate the platform's font families with the faces each ships
2568    /// ([`Toolkit::font_families`], docs/fonts.md). `Native` where an OS font database answers
2569    /// (NSFontManager, UIFont, Pango, QFontDatabase, Android's `fonts.xml`,
2570    /// `OH_Drawing_FontMgr`, DirectWrite); `Emulated` where the list is composed rather than
2571    /// read: web-dom answers the CSS generic families plus the bundled `document.fonts`, since
2572    /// a browser exposes no local font list without a permission prompt; `Unsupported` means
2573    /// the list is empty and an app should offer only the default face. Measurement
2574    /// ([`Toolkit::measure_text`]) has no probe: a `None` answer degrades to
2575    /// [`TextMetrics::approximate`]. Probe this before offering a font menu, not before
2576    /// drawing; a [`CanvasFont`] draws everywhere.
2577    FontList,
2578    /// The toolkit reads the user's reduce-motion setting ([`Toolkit::reduce_motion`],
2579    /// docs/accessibility.md) and reports when it changes. `Native` where the platform has the
2580    /// switch and Day reaches it; `Unsupported` where Day reaches none (the app then animates as
2581    /// it would with the setting off, and `DAY_REDUCE_MOTION=1` still forces it on).
2582    ReduceMotion,
2583    /// The toolkit can speak a sentence through the screen reader without moving its focus
2584    /// ([`Toolkit::announce`], docs/accessibility.md): "Saved", "3 results", "Upload failed".
2585    /// `Native` where the platform has an announcement request (`NSAccessibility`'s
2586    /// announcement notification, `UIAccessibility`'s, Android's accessibility event, GTK
2587    /// 4.14's `gtk_accessible_announce`, a WinUI notification event); `Emulated` on the web,
2588    /// where an ARIA live region carries it; `Unsupported` where Day reaches no announcement
2589    /// API yet. An app with a visible status label needs no announcement: screen readers read
2590    /// what changes on screen only when told to, so this is for the change the user cannot
2591    /// otherwise learn about.
2592    Announce,
2593    /// The toolkit can turn encoded image bytes into a native image
2594    /// ([`Toolkit::decode_image`], docs/images.md): what `image(bytes)`, a pasted screenshot and
2595    /// a canvas image all rest on. `Native` wherever the platform decoder takes a buffer;
2596    /// `Unsupported` means only staged asset names draw, so an app should not offer "open an
2597    /// image" at all. Every backend that decodes also answers [`Toolkit::image_info`], so pixel
2598    /// size needs no probe of its own.
2599    ImageDecode,
2600    /// The toolkit can encode a decoded image back to bytes ([`Toolkit::encode_image`]).
2601    /// Which formats it encodes is [`Toolkit::encode_formats`], the same shape as
2602    /// [`Cap::FontList`]/[`Toolkit::font_families`], because "can encode" and "can encode WebP"
2603    /// are different questions and only the second one needs a list.
2604    ImageEncode,
2605    /// The toolkit can read an image's metadata beyond its pixels: EXIF orientation, DPI,
2606    /// capture time ([`Toolkit::image_properties`]). `Native` where a platform metadata reader
2607    /// is already linked (WinRT's `BitmapPropertiesView`, Android's `ExifInterface`);
2608    /// `Unsupported` where reading it would mean shipping a parser or a new framework, which is
2609    /// most places. An app that wants orientation must handle `None`.
2610    ImageProperties,
2611    /// The toolkit can replay a canvas drawing into an offscreen bitmap
2612    /// ([`Toolkit::render_bitmap`], docs/canvas.md "Exporting"): what `Drawing::to_bitmap` and
2613    /// `Drawing::to_png` rest on. `Native` where the toolkit's own canvas painter can target an
2614    /// image surface (a `CGBitmapContext`, a cairo image surface, a `QImage`, an android
2615    /// `Bitmap`, an `OH_Drawing_Bitmap`, a detached `<canvas>`); `Unsupported` where the
2616    /// on-screen canvas has no painter to retarget (WinUI builds XAML shapes instead).
2617    CanvasBitmap,
2618    /// The toolkit can replay canvas drawings into a PDF document ([`Toolkit::render_pdf`],
2619    /// docs/canvas.md "Exporting"), with vector shapes and the platform's own font embedding.
2620    /// `Native` only where the platform ships a PDF writer the canvas painter can draw into
2621    /// (Core Graphics on AppKit and UIKit, cairo on GTK, `QPdfWriter` on Qt, `PdfDocument` on
2622    /// Android); `Unsupported` on ArkUI, the web and WinUI, which have none.
2623    CanvasPdf,
2624    /// The toolkit minimizes, zooms, hides and restores a window on request
2625    /// ([`WindowChange::State`], [`WindowChange::Visible`], docs/windows.md) and reports what the
2626    /// user does through [`Event::WindowStateChanged`]. `Native` on the desktop backends;
2627    /// `Unsupported` on the phones and the web, whose windows the OS sizes.
2628    WindowStates,
2629    /// The toolkit takes a window fullscreen on request and reports when it enters and leaves
2630    /// ([`WindowState::Fullscreen`]). `Native` on the desktops, on Android (system bars hidden
2631    /// through `WindowInsetsController`), on HarmonyOS and on the web (the Fullscreen API, which
2632    /// browsers allow only during a user gesture); `Emulated` on iOS, which hides the status bar
2633    /// and lets the home indicator fade but always keeps the app full-screen anyway.
2634    WindowFullscreen,
2635    /// The toolkit keeps a window out of screenshots, recordings and screen sharing
2636    /// ([`WindowChange::ContentProtected`]). `Native` on macOS (`NSWindowSharingNone`), Windows
2637    /// (`WDA_EXCLUDEFROMCAPTURE`), Android (`FLAG_SECURE`) and HarmonyOS (privacy mode);
2638    /// `Emulated` on iOS, which has no switch for it: Day covers the content while the screen is
2639    /// being recorded or mirrored, but a screenshot still captures it. `Unsupported` on Linux
2640    /// and the web.
2641    ContentProtection,
2642    /// The toolkit honors [`WindowOptions::tabbing`]: macOS window tabs. `Unsupported` everywhere
2643    /// else, where no system window tabs exist.
2644    WindowTabbing,
2645    /// The toolkit shows a Dock menu ([`Toolkit::set_dock_menu`], docs/menus.md): the app's
2646    /// shortcuts and `dock_menu(..)` entries above the system's own items. macOS only.
2647    DockMenu,
2648    /// The toolkit resizes a window and bounds its size on request ([`WindowChange::Frame`]'s
2649    /// size, [`WindowChange::Limits`], [`WindowChange::Resizable`]).
2650    WindowGeometry,
2651    /// The toolkit moves a window to a requested position ([`WindowChange::Frame`]'s origin).
2652    /// `Unsupported` on GTK 4, which removed window positioning, and wherever the compositor owns
2653    /// placement (Wayland).
2654    WindowPosition,
2655    /// The toolkit changes how a window stacks ([`WindowChange::Level`],
2656    /// [`WindowChange::OnAllWorkspaces`], [`WindowChange::SkipTaskbar`]).
2657    WindowLevel,
2658    /// The toolkit gives one window its own light/dark appearance ([`WindowChange::Appearance`]).
2659    /// Where it cannot, the app-wide [`Toolkit::set_appearance`] still applies to every window.
2660    WindowAppearance,
2661    /// The toolkit asks for the user's attention on request ([`WindowChange::RequestAttention`]):
2662    /// a Dock bounce, a flashing taskbar button.
2663    RequestAttention,
2664    /// The toolkit opens a window with no title bar or frame ([`WindowChrome::Frameless`],
2665    /// docs/window-chrome.md). Desktop backends only.
2666    FramelessWindow,
2667    /// The toolkit runs a window's content under its title bar with the window controls kept
2668    /// ([`WindowChrome::Overlay`]) and reports the bar's height as the safe-area top inset.
2669    OverlayTitleBar,
2670    /// The toolkit opens a window whose unpainted pixels show the desktop
2671    /// ([`WindowBackground::Transparent`]).
2672    TransparentWindow,
2673    /// The toolkit puts a translucent system material behind a window
2674    /// ([`WindowBackground::Material`]): macOS's visual-effect materials, Windows 11's Mica and
2675    /// Acrylic. `Emulated` where an approximation stands in.
2676    WindowMaterial,
2677    /// The toolkit lets a piece move its window like a title bar does
2678    /// ([`Toolkit::set_drag_region`], `.window_drag_region()`). `Emulated` on the web, where
2679    /// only an installed app with a window-controls overlay honors it.
2680    DragRegion,
2681    /// The toolkit shows status items ([`Toolkit::set_status_items`], docs/status-item.md): the
2682    /// macOS menu bar, the Windows notification area, a Linux tray. On Linux the answer depends
2683    /// on the desktop: `Native` while a StatusNotifierItem host is running (KDE Plasma, most
2684    /// panels, GNOME with the AppIndicator extension), `Unsupported` without one (stock GNOME),
2685    /// so an app can keep a window instead of vanishing into a tray nobody shows.
2686    StatusItem,
2687    /// The toolkit shows progress on the app's Dock or taskbar icon
2688    /// ([`Toolkit::set_app_progress`]). `Emulated` on Linux, where the Unity launcher protocol is
2689    /// honored by KDE Plasma and Dash to Dock but not by every dock.
2690    AppProgress,
2691    /// The toolkit can hide the app's Dock icon while it runs ([`Toolkit::set_dock_visible`]).
2692    /// macOS only.
2693    DockVisibility,
2694    /// The toolkit lists the displays and their work areas ([`Toolkit::monitors`]). `Emulated`
2695    /// where the one screen the app runs on is reported (a phone, the web).
2696    Monitors,
2697    /// The toolkit shows launcher shortcuts the app sets while it runs
2698    /// ([`Toolkit::set_launcher_shortcuts`]).
2699    DynamicShortcuts,
2700    /// How the toolkit draws a sectioned list's headers (`ListProps::sections`, docs/list.md
2701    /// "Sections"). `Native` where the platform's list has group headers that pin to the top
2702    /// while their section scrolls under them: macos-appkit (`NSTableView` group rows with
2703    /// `floatsGroupRows`) and ios-uikit (plain-style `UITableView` section headers).
2704    /// `Emulated` where the host pins a copy of the current header over the top of the list
2705    /// itself. Headers are unselectable rows on every toolkit, so an app needs this only to
2706    /// describe the difference, never to decide whether to call `.sections`.
2707    ListSections,
2708}
2709
2710impl Cap {
2711    /// Every capability, in declaration order: what a conformance run asks the toolkit about,
2712    /// so the evidence can be held against the declared coverage matrix (docs/testing.md).
2713    pub const ALL: [Cap; 90] = [
2714        Cap::DragDrop,
2715        Cap::DragExternalImport,
2716        Cap::DragExternalExport,
2717        Cap::DragMultipleItems,
2718        Cap::DragFileReferences,
2719        Cap::DragDeferredReceipt,
2720        Cap::DragFilePromises,
2721        Cap::DragExternalMove,
2722        Cap::ListRecycling,
2723        Cap::UndoBridge,
2724        Cap::EditBridge,
2725        Cap::ListReorder,
2726        Cap::NavReorder,
2727        Cap::ListDelete,
2728        Cap::ListSwipeActions,
2729        Cap::Tree,
2730        Cap::TreeMove,
2731        Cap::BaselineAlignment,
2732        Cap::TextRuns,
2733        Cap::TextLinks,
2734        Cap::Lottie,
2735        Cap::NativeSymbols,
2736        Cap::Snapshot,
2737        Cap::NavSplit,
2738        Cap::NavTabs,
2739        Cap::NavTabsAdaptive,
2740        Cap::NavRepresent,
2741        Cap::NavContentList,
2742        Cap::NavHeader,
2743        Cap::Appearance,
2744        Cap::AppMenu,
2745        Cap::Dialogs,
2746        Cap::FileDialogs,
2747        Cap::Animation,
2748        Cap::Cover,
2749        Cap::StatusBarHidden,
2750        Cap::TextEditable,
2751        Cap::TextSelectable,
2752        Cap::TextSpellCheck,
2753        Cap::MultiWindow,
2754        Cap::AppBadgeCount,
2755        Cap::AppBadgeText,
2756        Cap::AppBadgeDot,
2757        Cap::Toolbar,
2758        Cap::ToolbarSearch,
2759        Cap::Inspector,
2760        Cap::Cursor,
2761        Cap::FontList,
2762        Cap::ReduceMotion,
2763        Cap::Announce,
2764        Cap::ImageDecode,
2765        Cap::ImageEncode,
2766        Cap::ImageProperties,
2767        Cap::WindowStates,
2768        Cap::WindowFullscreen,
2769        Cap::ContentProtection,
2770        Cap::WindowTabbing,
2771        Cap::DockMenu,
2772        Cap::WindowGeometry,
2773        Cap::WindowPosition,
2774        Cap::WindowLevel,
2775        Cap::WindowAppearance,
2776        Cap::RequestAttention,
2777        Cap::FramelessWindow,
2778        Cap::OverlayTitleBar,
2779        Cap::TransparentWindow,
2780        Cap::WindowMaterial,
2781        Cap::DragRegion,
2782        Cap::StatusItem,
2783        Cap::AppProgress,
2784        Cap::DockVisibility,
2785        Cap::Monitors,
2786        Cap::DynamicShortcuts,
2787        Cap::Split,
2788        Cap::TreeSections,
2789        Cap::CoverSheet,
2790        Cap::ListEditing,
2791        Cap::ListItem,
2792        Cap::CanvasBitmap,
2793        Cap::CanvasPdf,
2794        Cap::SearchField,
2795        Cap::ComboBox,
2796        Cap::Stepper,
2797        Cap::DateTimePicker,
2798        Cap::ColorPicker,
2799        Cap::PullRefresh,
2800        Cap::Pager,
2801        Cap::PullRefreshGesture,
2802        Cap::FolderDialog,
2803        Cap::ListSections,
2804    ];
2805
2806    /// The capability's place in [`Cap::ALL`]. Exhaustive, so a new variant fails to compile
2807    /// here until it is given one, and the unit test below then holds `ALL` to it.
2808    pub const fn ordinal(self) -> usize {
2809        match self {
2810            Cap::DragDrop => 0,
2811            Cap::DragExternalImport => 1,
2812            Cap::DragExternalExport => 2,
2813            Cap::DragMultipleItems => 3,
2814            Cap::DragFileReferences => 4,
2815            Cap::DragDeferredReceipt => 5,
2816            Cap::DragFilePromises => 6,
2817            Cap::DragExternalMove => 7,
2818            Cap::ListRecycling => 8,
2819            Cap::UndoBridge => 9,
2820            Cap::EditBridge => 10,
2821            Cap::ListReorder => 11,
2822            Cap::NavReorder => 12,
2823            Cap::ListDelete => 13,
2824            Cap::ListSwipeActions => 14,
2825            Cap::Tree => 15,
2826            Cap::TreeMove => 16,
2827            Cap::BaselineAlignment => 17,
2828            Cap::TextRuns => 18,
2829            Cap::TextLinks => 19,
2830            Cap::Lottie => 20,
2831            Cap::NativeSymbols => 21,
2832            Cap::Snapshot => 22,
2833            Cap::NavSplit => 23,
2834            Cap::NavTabs => 24,
2835            Cap::NavTabsAdaptive => 25,
2836            Cap::NavRepresent => 26,
2837            Cap::NavContentList => 27,
2838            Cap::NavHeader => 28,
2839            Cap::Appearance => 29,
2840            Cap::AppMenu => 30,
2841            Cap::Dialogs => 31,
2842            Cap::FileDialogs => 32,
2843            Cap::Animation => 33,
2844            Cap::Cover => 34,
2845            Cap::StatusBarHidden => 35,
2846            Cap::TextEditable => 36,
2847            Cap::TextSelectable => 37,
2848            Cap::TextSpellCheck => 38,
2849            Cap::MultiWindow => 39,
2850            Cap::AppBadgeCount => 40,
2851            Cap::AppBadgeText => 41,
2852            Cap::AppBadgeDot => 42,
2853            Cap::Toolbar => 43,
2854            Cap::ToolbarSearch => 44,
2855            Cap::Inspector => 45,
2856            Cap::Cursor => 46,
2857            Cap::FontList => 47,
2858            Cap::ReduceMotion => 48,
2859            Cap::Announce => 49,
2860            Cap::ImageDecode => 50,
2861            Cap::ImageEncode => 51,
2862            Cap::ImageProperties => 52,
2863            Cap::WindowStates => 53,
2864            Cap::WindowFullscreen => 54,
2865            Cap::ContentProtection => 55,
2866            Cap::WindowTabbing => 56,
2867            Cap::DockMenu => 57,
2868            Cap::WindowGeometry => 58,
2869            Cap::WindowPosition => 59,
2870            Cap::WindowLevel => 60,
2871            Cap::WindowAppearance => 61,
2872            Cap::RequestAttention => 62,
2873            Cap::FramelessWindow => 63,
2874            Cap::OverlayTitleBar => 64,
2875            Cap::TransparentWindow => 65,
2876            Cap::WindowMaterial => 66,
2877            Cap::DragRegion => 67,
2878            Cap::StatusItem => 68,
2879            Cap::AppProgress => 69,
2880            Cap::DockVisibility => 70,
2881            Cap::Monitors => 71,
2882            Cap::DynamicShortcuts => 72,
2883            Cap::Split => 73,
2884            Cap::TreeSections => 74,
2885            Cap::CoverSheet => 75,
2886            Cap::ListEditing => 76,
2887            Cap::ListItem => 77,
2888            Cap::CanvasBitmap => 78,
2889            Cap::CanvasPdf => 79,
2890            Cap::SearchField => 80,
2891            Cap::ComboBox => 81,
2892            Cap::Stepper => 82,
2893            Cap::DateTimePicker => 83,
2894            Cap::ColorPicker => 84,
2895            Cap::PullRefresh => 85,
2896            Cap::Pager => 86,
2897            Cap::PullRefreshGesture => 87,
2898            Cap::FolderDialog => 88,
2899            Cap::ListSections => 89,
2900        }
2901    }
2902}
2903
2904// ---------------------------------------------------------------------------
2905// Raster images (docs/images.md): the currency shared by the `image` piece, the canvas, and
2906// the decode/metadata/encode duties below.
2907// ---------------------------------------------------------------------------
2908
2909/// A decoded image the toolkit holds, addressed by an id day-core mints.
2910///
2911/// Opaque, because what sits behind it is an `NSImage`, a `UIImage`, a `GdkTexture`, a
2912/// `QPixmap`, a `BitmapImage`, an android `Bitmap` or an `ImageBitmap`, and none of those cross
2913/// the boundary. The id is released through [`Toolkit::release_image`] when the app's handle
2914/// drops, so a backend's side table never outlives the app's interest in it.
2915#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
2916pub struct BitmapId(pub u64);
2917
2918/// An encoded image format: what [`BitmapInfo::format`] reports and [`EncodeSpec::format`] asks
2919/// for. Not every backend encodes every one: ask [`Toolkit::encode_formats`] (the
2920/// [`Cap::ImageEncode`] pair of [`Toolkit::font_families`]/[`Cap::FontList`]) rather than
2921/// assuming.
2922#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)]
2923pub enum ImageFormat {
2924    #[default]
2925    Png,
2926    Jpeg,
2927    Webp,
2928    Gif,
2929    Bmp,
2930    Tiff,
2931    /// HEIF/HEIC, Apple's camera format; decodable on the Apple backends and Android, and
2932    /// encodable almost nowhere.
2933    Heif,
2934    /// Decoded from bytes whose magic numbers [`ImageFormat::sniff`] does not know. A backend may
2935    /// still decode it: the platform decoders read more formats than Day names.
2936    Unknown,
2937}
2938
2939impl ImageFormat {
2940    /// The IANA media type, which is also the string the ArkUI packer, GDI+ and the browser's
2941    /// `toBlob` take. `Unknown` answers `application/octet-stream`.
2942    pub fn mime(self) -> &'static str {
2943        match self {
2944            ImageFormat::Png => "image/png",
2945            ImageFormat::Jpeg => "image/jpeg",
2946            ImageFormat::Webp => "image/webp",
2947            ImageFormat::Gif => "image/gif",
2948            ImageFormat::Bmp => "image/bmp",
2949            ImageFormat::Tiff => "image/tiff",
2950            ImageFormat::Heif => "image/heif",
2951            ImageFormat::Unknown => "application/octet-stream",
2952        }
2953    }
2954
2955    /// The pixel size the encoded bytes declare, read from the container's own header
2956    /// (docs/images.md).
2957    ///
2958    /// Every backend that decodes natively reports its own dimensions; this exists for the one
2959    /// that cannot. The XAML backend's (`windows-winui`) `BitmapImage` learns its size
2960    /// asynchronously, only once the element has been shown, so a synchronous `image_info` there
2961    /// would have nothing to say.
2962    /// Reading the header instead gives the same answer the decoder would, before any decoding.
2963    ///
2964    /// `None` when the header is absent, truncated, or a container this does not parse.
2965    pub fn dimensions(bytes: &[u8]) -> Option<(u32, u32)> {
2966        let be16 = |i: usize| -> Option<u32> {
2967            Some(u32::from(u16::from_be_bytes([
2968                *bytes.get(i)?,
2969                *bytes.get(i + 1)?,
2970            ])))
2971        };
2972        let le16 = |i: usize| -> Option<u32> {
2973            Some(u32::from(u16::from_le_bytes([
2974                *bytes.get(i)?,
2975                *bytes.get(i + 1)?,
2976            ])))
2977        };
2978        let be32 = |i: usize| -> Option<u32> {
2979            Some(u32::from_be_bytes([
2980                *bytes.get(i)?,
2981                *bytes.get(i + 1)?,
2982                *bytes.get(i + 2)?,
2983                *bytes.get(i + 3)?,
2984            ]))
2985        };
2986        let le32 = |i: usize| -> Option<u32> {
2987            Some(u32::from_le_bytes([
2988                *bytes.get(i)?,
2989                *bytes.get(i + 1)?,
2990                *bytes.get(i + 2)?,
2991                *bytes.get(i + 3)?,
2992            ]))
2993        };
2994        match Self::sniff(bytes)? {
2995            // IHDR is always the first chunk: 8-byte signature, 4-byte length, 4-byte type.
2996            ImageFormat::Png => Some((be32(16)?, be32(20)?)),
2997            // The logical screen descriptor follows the 6-byte version stamp, little-endian.
2998            ImageFormat::Gif => Some((le16(6)?, le16(8)?)),
2999            // The DIB header's width/height are signed; a negative height is a top-down bitmap.
3000            ImageFormat::Bmp => Some((
3001                le32(18)?.cast_signed().unsigned_abs(),
3002                le32(22)?.cast_signed().unsigned_abs(),
3003            )),
3004            // Walk the segment chain to the frame header. Every SOF marker carries the size at
3005            // the same offsets; the arithmetic-coded and progressive ones are SOFs too, which is
3006            // why this matches the whole range rather than SOF0 alone. DNL/DHP (0xC4, 0xC8,
3007            // 0xCC) are not frame headers and are skipped like any other segment.
3008            ImageFormat::Jpeg => {
3009                let mut i = 2usize;
3010                // `<=`, not `<`: a frame header that ends the buffer is still a frame header, and
3011                // the size sits in its last four bytes.
3012                while i + 9 <= bytes.len() {
3013                    if bytes[i] != 0xFF {
3014                        i += 1;
3015                        continue;
3016                    }
3017                    let marker = bytes[i + 1];
3018                    // Padding fill bytes and the standalone markers carry no length.
3019                    if marker == 0xFF || (0xD0..=0xD9).contains(&marker) || marker == 0x01 {
3020                        i += 1;
3021                        continue;
3022                    }
3023                    let is_sof = (0xC0..=0xCF).contains(&marker)
3024                        && marker != 0xC4
3025                        && marker != 0xC8
3026                        && marker != 0xCC;
3027                    if is_sof {
3028                        // [marker][len:2][precision:1][height:2][width:2]
3029                        return Some((be16(i + 7)?, be16(i + 5)?));
3030                    }
3031                    i += 2 + be16(i + 2)? as usize;
3032                }
3033                None
3034            }
3035            // RIFF: a 12-byte header, then a chunk id. VP8X states the size minus one in 24-bit
3036            // little-endian; lossy VP8 hides it in the 14 low bits after the start code; lossless
3037            // VP8L packs width-1 and height-1 into 14 bits each across four bytes.
3038            ImageFormat::Webp => match bytes.get(12..16)? {
3039                b"VP8X" => {
3040                    let w = u32::from(*bytes.get(24)?)
3041                        | u32::from(*bytes.get(25)?) << 8
3042                        | u32::from(*bytes.get(26)?) << 16;
3043                    let h = u32::from(*bytes.get(27)?)
3044                        | u32::from(*bytes.get(28)?) << 8
3045                        | u32::from(*bytes.get(29)?) << 16;
3046                    Some((w + 1, h + 1))
3047                }
3048                b"VP8 " => Some((le16(26)? & 0x3FFF, le16(28)? & 0x3FFF)),
3049                b"VP8L" => {
3050                    let bits = le32(21)?;
3051                    Some(((bits & 0x3FFF) + 1, ((bits >> 14) & 0x3FFF) + 1))
3052                }
3053                _ => None,
3054            },
3055            // TIFF's IFD is a pointer chase and HEIF's is a box tree; neither is worth parsing
3056            // here, because every backend that reads them decodes natively and answers for itself.
3057            ImageFormat::Tiff | ImageFormat::Heif | ImageFormat::Unknown => None,
3058        }
3059    }
3060
3061    /// The format `bytes` begins with, by magic number: enough to fill [`BitmapInfo::format`]
3062    /// on every backend, including the ones whose decoder never says what it decoded.
3063    ///
3064    /// Hand-rolled rather than a crate: it is a dozen byte comparisons against fixed prefixes,
3065    /// and the alternative is an image-decoding dependency in the one crate that must stay
3066    /// dependency-free.
3067    pub fn sniff(bytes: &[u8]) -> Option<ImageFormat> {
3068        let starts = |sig: &[u8]| bytes.len() >= sig.len() && &bytes[..sig.len()] == sig;
3069        if starts(&[0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a]) {
3070            return Some(ImageFormat::Png);
3071        }
3072        if starts(&[0xff, 0xd8, 0xff]) {
3073            return Some(ImageFormat::Jpeg);
3074        }
3075        if starts(b"GIF87a") || starts(b"GIF89a") {
3076            return Some(ImageFormat::Gif);
3077        }
3078        if starts(b"BM") {
3079            return Some(ImageFormat::Bmp);
3080        }
3081        if starts(&[0x49, 0x49, 0x2a, 0x00]) || starts(&[0x4d, 0x4d, 0x00, 0x2a]) {
3082            return Some(ImageFormat::Tiff);
3083        }
3084        // RIFF....WEBP and ....ftypheic/heix/mif1 both carry their tag past a 4-byte prefix.
3085        if starts(b"RIFF") && bytes.len() >= 12 && &bytes[8..12] == b"WEBP" {
3086            return Some(ImageFormat::Webp);
3087        }
3088        if bytes.len() >= 12 && &bytes[4..8] == b"ftyp" {
3089            let brand = &bytes[8..12];
3090            if brand == b"heic" || brand == b"heix" || brand == b"mif1" || brand == b"msf1" {
3091                return Some(ImageFormat::Heif);
3092            }
3093        }
3094        None
3095    }
3096}
3097
3098/// What a decoded image is, in the terms every backend can answer
3099/// ([`Toolkit::image_info`], docs/images.md).
3100///
3101/// `pixels` is the image's own pixel size, not the points it draws at: an asset that stages at
3102/// 2× reports twice its point size and a `scale` of 2, so an app sizing a frame divides. Bytes
3103/// carry no density, so a decoded buffer always answers `scale: 1.0`.
3104#[derive(Clone, Copy, Debug, Default, PartialEq)]
3105pub struct BitmapInfo {
3106    pub pixels: Size,
3107    /// Pixels per point (1.0 for decoded bytes; 2 or 3 for a catalog/drawable asset that staged
3108    /// a density variant).
3109    pub scale: f64,
3110    /// The encoded format it came from, where the backend knows or [`ImageFormat::sniff`] said.
3111    pub format: Option<ImageFormat>,
3112    pub has_alpha: bool,
3113}
3114
3115/// What [`Toolkit::encode_image`] should produce (docs/images.md).
3116///
3117/// A struct rather than three positional arguments, per docs/api-style.md: the call site reads
3118/// `EncodeSpec { format: ImageFormat::Jpeg, quality: Some(0.8), ..Default::default() }`.
3119#[derive(Clone, Copy, Debug, PartialEq)]
3120pub struct EncodeSpec {
3121    pub format: ImageFormat,
3122    /// Lossy quality in `0.0..=1.0`. `None` takes the platform's default; a lossless format
3123    /// ignores it.
3124    pub quality: Option<f64>,
3125    /// Scale the longest side down to fit this box first (aspect preserved). `None` encodes at
3126    /// the image's own size.
3127    pub fit: Option<Size>,
3128}
3129
3130impl Default for EncodeSpec {
3131    fn default() -> Self {
3132        EncodeSpec {
3133            format: ImageFormat::Png,
3134            quality: None,
3135            fit: None,
3136        }
3137    }
3138}
3139
3140/// The metadata an image file carries beyond its pixels ([`Toolkit::image_properties`]).
3141///
3142/// Sparse by construction: every field is what the platform's own reader answered, and a
3143/// backend with no metadata reader at all answers `None` to the whole call rather than an empty
3144/// struct. Probe [`Cap::ImageProperties`] before offering an affordance that depends on it.
3145#[derive(Clone, Debug, Default, PartialEq)]
3146pub struct ImageProperties {
3147    /// The EXIF orientation tag (1–8), where one was recorded.
3148    pub orientation: Option<u16>,
3149    /// Pixels per inch, where the file records a resolution.
3150    pub dpi: Option<f64>,
3151    /// The capture timestamp as the file spells it (no parsing: the formats disagree).
3152    pub created: Option<String>,
3153    /// Everything else the platform reader named, in its own vocabulary. Keys are the
3154    /// platform's, so read them defensively.
3155    pub extra: Vec<(String, String)>,
3156}
3157
3158/// Why an image operation could not answer.
3159#[derive(Clone, Copy, Debug, PartialEq, Eq)]
3160pub enum ImageError {
3161    /// The bytes are not an image this platform decodes.
3162    Decode,
3163    /// The image decoded, but this backend cannot encode the format asked for. `encode_formats`
3164    /// says which it can.
3165    Encode,
3166    /// The backend has no path for this at all (probe [`Cap::ImageDecode`] /
3167    /// [`Cap::ImageEncode`]).
3168    Unsupported,
3169    /// The id named no live image: released already, or never decoded.
3170    Gone,
3171}
3172
3173impl std::fmt::Display for ImageError {
3174    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3175        match self {
3176            ImageError::Decode => write!(f, "the bytes are not a decodable image"),
3177            ImageError::Encode => write!(f, "this backend cannot encode that format"),
3178            ImageError::Unsupported => write!(f, "image support is unavailable on this backend"),
3179            ImageError::Gone => write!(f, "the image was released"),
3180        }
3181    }
3182}
3183
3184impl std::error::Error for ImageError {}
3185
3186// ---------------------------------------------------------------------------
3187// Canvas export (docs/canvas.md "Exporting"): a recorded drawing replayed into an offscreen
3188// bitmap or a PDF by the same painter the on-screen canvas uses.
3189// ---------------------------------------------------------------------------
3190
3191/// The largest side, in pixels, a rendered bitmap may have (docs/canvas.md "Exporting").
3192/// day-core answers [`ExportError::TooLarge`] above it before any toolkit allocates.
3193pub const EXPORT_MAX_SIDE: u32 = 16_384;
3194
3195/// The most pixels a rendered bitmap may hold in total: 64 megapixels, 256 MB at four bytes a
3196/// pixel, which is already more than the tightest per-app heap (Android's) comfortably holds.
3197pub const EXPORT_MAX_PIXELS: u64 = 64 * 1024 * 1024;
3198
3199/// The pixel size a drawing of `size` points renders to at `scale` pixels per point: each side
3200/// rounded up, so a drawing never loses its last partial pixel. Shared by day-core's limit
3201/// check and every backend, so the bitmap a backend allocates is the one day-core approved.
3202pub fn export_pixels(size: Size, scale: f64) -> (u32, u32) {
3203    let side = |v: f64| (v * scale).ceil().clamp(0.0, u32::MAX as f64) as u32;
3204    (side(size.width), side(size.height))
3205}
3206
3207/// One page of a PDF [`Toolkit::render_pdf`] writes: the page size in PDF points (1/72 inch,
3208/// the same unit as canvas points) and the ops to replay onto it, origin top-left.
3209#[derive(Clone, Debug, PartialEq)]
3210pub struct PdfPage {
3211    pub size: Size,
3212    pub ops: Vec<DrawOp>,
3213}
3214
3215/// The document information a PDF carries. A backend whose writer has no metadata API (Android's
3216/// `PdfDocument`) ignores it.
3217#[derive(Clone, Debug, Default, PartialEq)]
3218pub struct PdfInfo {
3219    pub title: Option<String>,
3220    pub author: Option<String>,
3221}
3222
3223/// Why a canvas export could not answer (docs/canvas.md "Exporting").
3224#[derive(Clone, Debug, PartialEq)]
3225pub enum ExportError {
3226    /// This toolkit has no path for the export (probe [`Cap::CanvasBitmap`] /
3227    /// [`Cap::CanvasPdf`]).
3228    Unsupported,
3229    /// The bitmap would exceed [`EXPORT_MAX_SIDE`] or [`EXPORT_MAX_PIXELS`]; checked in
3230    /// day-core before the toolkit runs. The size is the one that was refused, in pixels.
3231    TooLarge { width: u32, height: u32 },
3232    /// A zero, negative or non-finite size or scale, or a PDF with no pages.
3233    Invalid,
3234    /// The platform refused (out of memory, a writer error); the text is the platform's.
3235    Failed(String),
3236}
3237
3238impl std::fmt::Display for ExportError {
3239    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3240        match self {
3241            ExportError::Unsupported => write!(f, "this export is unavailable on this backend"),
3242            ExportError::TooLarge { width, height } => {
3243                write!(
3244                    f,
3245                    "a {width} × {height} pixel bitmap is too large to render"
3246                )
3247            }
3248            ExportError::Invalid => write!(f, "the drawing has no size or the PDF has no pages"),
3249            ExportError::Failed(why) => write!(f, "rendering failed: {why}"),
3250        }
3251    }
3252}
3253
3254impl std::error::Error for ExportError {}
3255
3256/// Where a raster image's pixels come from (docs/images.md).
3257///
3258/// One currency for both halves of the same question: `Named` is the staged asset every
3259/// `image(res::images::…)` has always drawn, `Bytes` is an encoded PNG/JPEG the app already
3260/// holds (a download, a file the user picked, a clipboard paste), and `Decoded` names an image
3261/// the toolkit decoded once so several nodes (and the canvas) share it.
3262#[derive(Clone)]
3263pub enum ImageSource {
3264    /// A staged asset name, resolved through the backend's own pipeline (§18.2).
3265    Named(String),
3266    /// Encoded bytes (PNG/JPEG/…) the app owns, decoded by the platform's own decoder. Shared
3267    /// rather than copied: handing over a new buffer is a refcount bump.
3268    Bytes(std::sync::Arc<Vec<u8>>),
3269    /// An image already decoded through [`Toolkit::decode_image`].
3270    Decoded(BitmapId),
3271}
3272
3273impl Default for ImageSource {
3274    fn default() -> Self {
3275        ImageSource::Named(String::new())
3276    }
3277}
3278
3279/// Bytes compare by identity, not content.
3280///
3281/// This is the equality a binding gates on (§4.2), so it runs on every evaluation, and
3282/// comparing two multi-megabyte buffers byte by byte would cost far more than the redundant
3283/// decode it saves. Handing over a distinct buffer holding the same pixels therefore re-decodes,
3284/// which is the same trade `Arc::ptr_eq` makes wherever else Day carries shared buffers.
3285impl PartialEq for ImageSource {
3286    fn eq(&self, other: &Self) -> bool {
3287        match (self, other) {
3288            (ImageSource::Named(a), ImageSource::Named(b)) => a == b,
3289            (ImageSource::Bytes(a), ImageSource::Bytes(b)) => std::sync::Arc::ptr_eq(a, b),
3290            (ImageSource::Decoded(a), ImageSource::Decoded(b)) => a == b,
3291            _ => false,
3292        }
3293    }
3294}
3295
3296/// Logs the buffer's length, never its contents: a `Debug` that dumped a megabyte of pixels
3297/// into a trace line would make the trace useless and slow.
3298impl std::fmt::Debug for ImageSource {
3299    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3300        match self {
3301            ImageSource::Named(n) => f.debug_tuple("Named").field(n).finish(),
3302            ImageSource::Bytes(b) => f.debug_tuple("Bytes").field(&b.len()).finish(),
3303            ImageSource::Decoded(id) => f.debug_tuple("Decoded").field(&id.0).finish(),
3304        }
3305    }
3306}
3307
3308impl From<BitmapId> for ImageSource {
3309    fn from(id: BitmapId) -> Self {
3310        ImageSource::Decoded(id)
3311    }
3312}
3313
3314/// A staged asset name is a source, so `image(res::images::cover)` keeps compiling unchanged.
3315///
3316/// There is no `From<&str>` here, for the same reason [`resource::ImageName`] has none:
3317/// `image("typo")` must stay a compile error rather than a blank view at runtime (resource.rs).
3318/// A path or URL string is not a source either: read the bytes and hand those over.
3319impl From<resource::ImageName> for ImageSource {
3320    fn from(name: resource::ImageName) -> Self {
3321        ImageSource::Named(name.as_str().to_owned())
3322    }
3323}
3324
3325impl From<resource::VectorName> for ImageSource {
3326    fn from(name: resource::VectorName) -> Self {
3327        ImageSource::Named(name.as_str().to_owned())
3328    }
3329}
3330
3331impl From<std::sync::Arc<Vec<u8>>> for ImageSource {
3332    fn from(bytes: std::sync::Arc<Vec<u8>>) -> Self {
3333        ImageSource::Bytes(bytes)
3334    }
3335}
3336
3337/// An owned string is a name, not bytes: the same conversion [`resource::ImageName`] offers for
3338/// a name computed at runtime, and the reason `image(some_string)` keeps compiling. A `&str`
3339/// still converts nowhere, so `image("typo")` stays a compile error (resource.rs).
3340impl From<String> for ImageSource {
3341    fn from(name: String) -> Self {
3342        ImageSource::Named(name)
3343    }
3344}
3345
3346impl From<Vec<u8>> for ImageSource {
3347    fn from(bytes: Vec<u8>) -> Self {
3348        ImageSource::Bytes(std::sync::Arc::new(bytes))
3349    }
3350}
3351
3352#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
3353pub enum Support {
3354    Native,
3355    Emulated,
3356    /// The default: what an unimplemented capability answers everywhere.
3357    #[default]
3358    Unsupported,
3359}
3360
3361/// What a text field collects (`text_field(..).input_purpose(_)`, docs/textfield.md).
3362///
3363/// One word for everything a platform keys off the kind of text: the on-screen keyboard's
3364/// layout, capitalization and correction, and the credential or contact the system offers to
3365/// fill in. A backend applies the parts its toolkit has: a desktop toolkit with no on-screen
3366/// keyboard still takes the autofill and correction half.
3367///
3368/// Purpose is independent of [`InputTraits::secure`]: a numeric PIN is `Number` and secure.
3369#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
3370pub enum InputPurpose {
3371    /// Free text: the default keyboard, with the platform's own capitalization and correction.
3372    #[default]
3373    Text,
3374    /// A person's name: words capitalized, never corrected.
3375    Name,
3376    /// An email address: the `@` keyboard, never capitalized or corrected.
3377    Email,
3378    /// A web address: the URL keyboard, never capitalized or corrected.
3379    Url,
3380    /// A telephone number: the phone pad.
3381    Phone,
3382    /// A whole number: the digit pad.
3383    Number,
3384    /// A number with a fractional part: the digit pad with a decimal separator.
3385    Decimal,
3386    /// An account name: never capitalized or corrected, and offered to the password manager.
3387    Username,
3388    /// An existing password, offered to the password manager for filling.
3389    Password,
3390    /// A password being chosen, so the password manager offers to make and save one.
3391    NewPassword,
3392    /// A code sent by message or mail, which the keyboard offers as it arrives. The keyboard
3393    /// stays the text one, since a code can carry letters.
3394    OneTimeCode,
3395}
3396
3397/// The on-screen keyboard's action key (`text_field(..).submit_label(_)`, docs/textfield.md).
3398///
3399/// The key always fires the field's `on_submit`; this names what it promises. A toolkit with
3400/// no on-screen keyboard ignores it.
3401#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
3402pub enum SubmitLabel {
3403    /// The platform's own key (Return).
3404    #[default]
3405    Return,
3406    Done,
3407    Go,
3408    Next,
3409    Search,
3410    Send,
3411}
3412
3413/// How a text field takes its text (docs/textfield.md): the whole set travels together through
3414/// [`Toolkit::set_input_traits`], so a backend that has to rebuild the widget for one member
3415/// has every other member in hand to dress the replacement.
3416#[derive(Clone, Debug, Default, PartialEq, Eq)]
3417pub struct InputTraits {
3418    /// Hide the characters (a password field).
3419    pub secure: bool,
3420    /// Show and select the text, take no edits.
3421    pub read_only: bool,
3422    pub purpose: InputPurpose,
3423    pub submit_label: SubmitLabel,
3424    /// The most characters (Unicode scalar values) the field takes; `None` is unbounded.
3425    /// day-pieces holds the bound on every backend. A toolkit applies its native limit as well
3426    /// only where that limit counts the same unit (GTK): one that counts UTF-16 units would
3427    /// refuse characters the bound allows.
3428    pub max_length: Option<u32>,
3429}
3430
3431/// The pointer's shape over a piece (the `.cursor()` decorator, docs/cursor.md).
3432///
3433/// The named variants are the CSS cursor vocabulary: the largest set any toolkit accepts as it
3434/// is (GTK and the DOM take the names verbatim), and every other toolkit's set is a subset of it
3435/// or maps onto it. A backend draws its own shape for each name, or the nearest one it has, and
3436/// answers [`Cap::Cursor`] with how faithful that is. [`Cursor::Native`] reaches a shape only one
3437/// toolkit names; the `day::cursor::<toolkit>` modules carry those as constants, gated on that
3438/// toolkit's feature, and every other backend ignores a name it does not know.
3439///
3440/// A cursor applies to the node and its descendants until a descendant sets its own.
3441/// [`Cursor::Default`] hands the shape back to the platform rather than pinning an arrow, so a
3442/// reactive cursor can release what it took.
3443#[derive(Clone, Debug, PartialEq, Eq, Hash, Default)]
3444pub enum Cursor {
3445    /// The platform default (an arrow), and the value that releases a node's cursor.
3446    #[default]
3447    Default,
3448    /// A pointing hand: links and other tap-to-activate content.
3449    Pointer,
3450    /// An I-beam over editable or selectable text.
3451    Text,
3452    /// An I-beam for vertical text.
3453    VerticalText,
3454    Crosshair,
3455    /// Four-way move.
3456    Move,
3457    /// An open hand: content that can be dragged.
3458    Grab,
3459    /// A closed hand: content being dragged.
3460    Grabbing,
3461    NotAllowed,
3462    /// The app is busy and cannot take input.
3463    Wait,
3464    /// The app is busy but still takes input.
3465    Progress,
3466    Help,
3467    ContextMenu,
3468    /// A drop will copy.
3469    Copy,
3470    /// A drop will link.
3471    Alias,
3472    /// A table cell.
3473    Cell,
3474    ZoomIn,
3475    ZoomOut,
3476    /// No pointer at all.
3477    None,
3478    /// Vertical (north–south) resize.
3479    NsResize,
3480    /// Horizontal (east–west) resize.
3481    EwResize,
3482    /// Diagonal resize, north-east to south-west.
3483    NeswResize,
3484    /// Diagonal resize, north-west to south-east.
3485    NwseResize,
3486    ColResize,
3487    RowResize,
3488    /// A toolkit's own named cursor, resolved by that backend's lookup and ignored by every
3489    /// other. Prefer the `day::cursor::<toolkit>` constants, which are gated on the toolkit.
3490    Native(std::borrow::Cow<'static, str>),
3491}
3492
3493impl Cursor {
3494    /// A toolkit-specific cursor by its native name (see [`Cursor::Native`]).
3495    pub const fn native(name: &'static str) -> Self {
3496        Cursor::Native(std::borrow::Cow::Borrowed(name))
3497    }
3498
3499    /// The CSS keyword for a named cursor, which GTK and the DOM take as it is. A native cursor
3500    /// answers its own name, so a backend keyed on CSS names may still recognize it.
3501    pub fn css_name(&self) -> &str {
3502        match self {
3503            Cursor::Default => "default",
3504            Cursor::Pointer => "pointer",
3505            Cursor::Text => "text",
3506            Cursor::VerticalText => "vertical-text",
3507            Cursor::Crosshair => "crosshair",
3508            Cursor::Move => "move",
3509            Cursor::Grab => "grab",
3510            Cursor::Grabbing => "grabbing",
3511            Cursor::NotAllowed => "not-allowed",
3512            Cursor::Wait => "wait",
3513            Cursor::Progress => "progress",
3514            Cursor::Help => "help",
3515            Cursor::ContextMenu => "context-menu",
3516            Cursor::Copy => "copy",
3517            Cursor::Alias => "alias",
3518            Cursor::Cell => "cell",
3519            Cursor::ZoomIn => "zoom-in",
3520            Cursor::ZoomOut => "zoom-out",
3521            Cursor::None => "none",
3522            Cursor::NsResize => "ns-resize",
3523            Cursor::EwResize => "ew-resize",
3524            Cursor::NeswResize => "nesw-resize",
3525            Cursor::NwseResize => "nwse-resize",
3526            Cursor::ColResize => "col-resize",
3527            Cursor::RowResize => "row-resize",
3528            Cursor::Native(name) => name,
3529        }
3530    }
3531
3532    /// Every named cursor, in declaration order, what a gallery or a test walks.
3533    pub const NAMED: [Cursor; 25] = [
3534        Cursor::Default,
3535        Cursor::Pointer,
3536        Cursor::Text,
3537        Cursor::VerticalText,
3538        Cursor::Crosshair,
3539        Cursor::Move,
3540        Cursor::Grab,
3541        Cursor::Grabbing,
3542        Cursor::NotAllowed,
3543        Cursor::Wait,
3544        Cursor::Progress,
3545        Cursor::Help,
3546        Cursor::ContextMenu,
3547        Cursor::Copy,
3548        Cursor::Alias,
3549        Cursor::Cell,
3550        Cursor::ZoomIn,
3551        Cursor::ZoomOut,
3552        Cursor::None,
3553        Cursor::NsResize,
3554        Cursor::EwResize,
3555        Cursor::NeswResize,
3556        Cursor::NwseResize,
3557        Cursor::ColResize,
3558        Cursor::RowResize,
3559    ];
3560}
3561
3562/// A set of screen edges, for the `defers_system_gestures` modifier (docs/cover.md). Mirrors
3563/// SwiftUI's `Edge.Set`: on iOS these map to `UIRectEdge` for
3564/// `preferredScreenEdgesDeferringSystemGestures`; on Android any non-empty set enters
3565/// swipe-to-reveal immersive mode (the closest platform analogue).
3566#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
3567pub struct Edges(pub u8);
3568
3569impl Edges {
3570    pub const NONE: Edges = Edges(0);
3571    pub const TOP: Edges = Edges(1);
3572    pub const BOTTOM: Edges = Edges(2);
3573    pub const LEADING: Edges = Edges(4);
3574    pub const TRAILING: Edges = Edges(8);
3575    pub const ALL: Edges = Edges(15);
3576
3577    pub fn contains(self, other: Edges) -> bool {
3578        self.0 & other.0 == other.0
3579    }
3580    pub fn union(self, other: Edges) -> Edges {
3581        Edges(self.0 | other.0)
3582    }
3583    pub fn is_empty(self) -> bool {
3584        self.0 == 0
3585    }
3586}
3587
3588impl std::ops::BitOr for Edges {
3589    type Output = Edges;
3590    fn bitor(self, rhs: Edges) -> Edges {
3591        self.union(rhs)
3592    }
3593}
3594
3595/// The timing curve of an animation (§8.4). Native backends map each variant onto their own
3596/// easing (`CAMediaTimingFunction`, `QEasingCurve`, ArkUI `ARKUI_CURVE_*`, spring animators); the
3597/// canvas/self-driven path samples it via [`Curve::fraction`]. `Spring` matches SwiftUI's
3598/// `.spring(response:dampingFraction:)`.
3599#[derive(Clone, Copy, Debug, PartialEq)]
3600pub enum Curve {
3601    Linear,
3602    EaseIn,
3603    EaseOut,
3604    EaseInOut,
3605    /// `response` = approximate settling period (seconds); `damping` = damping ratio (1.0 =
3606    /// critically damped, `<1` = bouncy overshoot).
3607    Spring {
3608        response: f64,
3609        damping: f64,
3610    },
3611}
3612
3613impl Curve {
3614    /// Fraction of the transition complete at `elapsed` seconds (0 at start, reaching 1, or
3615    /// overshooting it for an under-damped spring). Easing curves clamp to `duration`; springs
3616    /// evaluate their analytic unit-step response and use `duration` only as a settle cap. Drives
3617    /// the self-driven/canvas path; native backends interpolate on their own compositor instead.
3618    pub fn fraction(self, elapsed: f64, duration: f64) -> f64 {
3619        match self {
3620            Curve::Spring { response, damping } => spring_step(response, damping, elapsed),
3621            _ => {
3622                let t = if duration <= 0.0 {
3623                    1.0
3624                } else {
3625                    (elapsed / duration).clamp(0.0, 1.0)
3626                };
3627                self.ease(t)
3628            }
3629        }
3630    }
3631
3632    /// Eased progress for normalized `t` in `0.0..=1.0` (springs pass through; use [`Self::fraction`]).
3633    #[inline]
3634    pub fn ease(self, t: f64) -> f64 {
3635        match self {
3636            Curve::Linear | Curve::Spring { .. } => t,
3637            Curve::EaseIn => t * t,
3638            Curve::EaseOut => 1.0 - (1.0 - t) * (1.0 - t),
3639            Curve::EaseInOut => t * t * (3.0 - 2.0 * t), // smoothstep
3640        }
3641    }
3642
3643    /// Whether the transition has settled, so the canvas frame clock can stop ticking it.
3644    pub fn is_settled(self, elapsed: f64, duration: f64) -> bool {
3645        match self {
3646            Curve::Spring { response, damping } => {
3647                let cap = response.max(0.0) * 4.0 + 0.2;
3648                if elapsed >= cap {
3649                    return true;
3650                }
3651                (spring_step(response, damping, elapsed) - 1.0).abs() < 0.001
3652                    && elapsed > response * 0.5
3653            }
3654            _ => elapsed >= duration.max(0.0),
3655        }
3656    }
3657}
3658
3659/// Unit-step response of a second-order spring (`response` = period seconds, `damping` = ratio),
3660/// evaluated at `t` seconds. Under-damped rings and overshoots; critically/over-damped eases in.
3661fn spring_step(response: f64, damping: f64, t: f64) -> f64 {
3662    if response <= 0.0 || t <= 0.0 {
3663        return if t <= 0.0 { 0.0 } else { 1.0 };
3664    }
3665    let omega0 = std::f64::consts::TAU / response;
3666    let zeta = damping.max(0.0);
3667    if zeta < 1.0 {
3668        let omega_d = omega0 * (1.0 - zeta * zeta).sqrt();
3669        let e = (-zeta * omega0 * t).exp();
3670        1.0 - e * ((omega_d * t).cos() + (zeta * omega0 / omega_d) * (omega_d * t).sin())
3671    } else {
3672        let e = (-omega0 * t).exp();
3673        1.0 - e * (1.0 + omega0 * t)
3674    }
3675}
3676
3677/// Animation intent (§8.4). Native-widget backends map it onto their own animator (Core Animation,
3678/// `ViewPropertyAnimator`, XAML Composition, `OH_ArkUI_AnimateTo`, …); the canvas/self-driven path
3679/// samples `curve` via [`Curve::fraction`]. Threaded through `Toolkit::update`/`set_frame`/
3680/// `set_opacity`/`set_transform`.
3681#[derive(Clone, Copy, Debug, PartialEq)]
3682pub struct AnimSpec {
3683    pub duration_ms: u32,
3684    pub delay_ms: u32,
3685    pub curve: Curve,
3686    /// Repeat count beyond the first play (`0` = play once); `u32::MAX` = repeat forever.
3687    pub repeat: u32,
3688    pub autoreverse: bool,
3689}
3690
3691impl Default for AnimSpec {
3692    /// The default feel: a smooth spring (SwiftUI's modern default).
3693    fn default() -> Self {
3694        AnimSpec {
3695            duration_ms: 350,
3696            delay_ms: 0,
3697            curve: Curve::Spring {
3698                response: 0.4,
3699                damping: 0.8,
3700            },
3701            repeat: 0,
3702            autoreverse: false,
3703        }
3704    }
3705}
3706
3707impl AnimSpec {
3708    /// A smooth spring: `response` = settling period (s), `damping` = ratio (1.0 = no bounce, `<1`
3709    /// bouncy). `duration_ms` is a nominal cap for backends that need a duration; spring backends
3710    /// use `response`/`damping` directly.
3711    pub fn spring(response: f64, damping: f64) -> Self {
3712        AnimSpec {
3713            // `response` is the animation's duration: every backend maps a spring to a
3714            // fixed-duration overshoot curve over exactly `duration_ms`, so timing is identical
3715            // across toolkits (a physics spring's settle time would vary per platform).
3716            duration_ms: (response * 1000.0).max(50.0) as u32,
3717            delay_ms: 0,
3718            curve: Curve::Spring { response, damping },
3719            repeat: 0,
3720            autoreverse: false,
3721        }
3722    }
3723    pub fn linear(duration_ms: u32) -> Self {
3724        Self::timed(duration_ms, Curve::Linear)
3725    }
3726    pub fn ease_in(duration_ms: u32) -> Self {
3727        Self::timed(duration_ms, Curve::EaseIn)
3728    }
3729    pub fn ease_out(duration_ms: u32) -> Self {
3730        Self::timed(duration_ms, Curve::EaseOut)
3731    }
3732    pub fn ease_in_out(duration_ms: u32) -> Self {
3733        Self::timed(duration_ms, Curve::EaseInOut)
3734    }
3735    fn timed(duration_ms: u32, curve: Curve) -> Self {
3736        AnimSpec {
3737            duration_ms,
3738            delay_ms: 0,
3739            curve,
3740            repeat: 0,
3741            autoreverse: false,
3742        }
3743    }
3744    /// Delay before the animation starts (builder).
3745    pub fn delay(mut self, ms: u32) -> Self {
3746        self.delay_ms = ms;
3747        self
3748    }
3749    /// Repeat `count` extra times (builder); `autoreverse` ping-pongs each cycle.
3750    pub fn repeat(mut self, count: u32, autoreverse: bool) -> Self {
3751        self.repeat = count;
3752        self.autoreverse = autoreverse;
3753        self
3754    }
3755    /// Repeat forever (builder), e.g. a pulsing indicator.
3756    pub fn repeat_forever(mut self, autoreverse: bool) -> Self {
3757        self.repeat = u32::MAX;
3758        self.autoreverse = autoreverse;
3759        self
3760    }
3761    #[inline]
3762    pub fn duration_secs(&self) -> f64 {
3763        self.duration_ms as f64 / 1000.0
3764    }
3765    #[inline]
3766    pub fn delay_secs(&self) -> f64 {
3767        self.delay_ms as f64 / 1000.0
3768    }
3769}
3770
3771#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
3772pub enum Role {
3773    #[default]
3774    None,
3775    Button,
3776    Toggle,
3777    Slider,
3778    TextInput,
3779    Heading(u8),
3780    Image,
3781    Meter,
3782    Group,
3783    /// A hierarchical tree container (docs/tree.md): `role="tree"`, `AXOutline`.
3784    Tree,
3785    /// One row of a tree: `role="treeitem"`, with level/expanded state where the platform
3786    /// carries them (docs/tree.md).
3787    TreeItem,
3788    /// One tab in a strip of peers (`role="tab"`, `AXTabButton`): the composed document
3789    /// strip's title buttons. Pair it with [`A11yProps::selected`] for the current one.
3790    Tab,
3791    /// The strip holding tabs (`role="tablist"`, `AXTabGroup`).
3792    TabList,
3793}
3794
3795impl Role {
3796    /// The a11y role a built-in piece kind reports natively, the audit's *expectation* when the
3797    /// user hasn't set an explicit `.role()`. Native controls already expose these, so Day records
3798    /// them for `a11y_audit` (§14.2) rather than overriding the widget; only canvas/custom pieces
3799    /// need Day to apply a role. Returns `None` for kinds with no inherent control role.
3800    pub fn for_kind(kind: PieceKind) -> Role {
3801        match kind {
3802            kinds::BUTTON => Role::Button,
3803            kinds::TOGGLE => Role::Toggle,
3804            kinds::SLIDER => Role::Slider,
3805            kinds::TEXT_FIELD => Role::TextInput,
3806            kinds::IMAGE => Role::Image,
3807            kinds::PROGRESS => Role::Meter,
3808            kinds::TREE => Role::Tree,
3809            _ => Role::None,
3810        }
3811    }
3812}
3813
3814#[derive(Clone, Debug, Default, PartialEq)]
3815pub struct A11yProps {
3816    pub label: Option<String>,
3817    pub hint: Option<String>,
3818    pub value: Option<String>,
3819    pub role: Role,
3820    pub identifier: Option<String>,
3821    pub hidden: bool,
3822    pub decorative: bool,
3823    /// Whether the element is the selected one among its peers (a tab, a row): the
3824    /// platform's selected state, read aloud as such. `None` leaves the widget's own answer.
3825    pub selected: Option<bool>,
3826}
3827
3828impl A11yProps {
3829    /// Merge another set of annotations onto this one: any field `other` sets (a `Some`, a
3830    /// non-`None` role, or a `true` flag) overrides ours; unset fields are left intact. Lets a
3831    /// node accumulate its `.a11y()`, `.id()`, and piece defaults into one stored result, so
3832    /// each `set_a11y` re-applies the full picture and `a11y_audit` has the complete expectation.
3833    pub fn merge(&mut self, other: &A11yProps) {
3834        if other.label.is_some() {
3835            self.label = other.label.clone();
3836        }
3837        if other.hint.is_some() {
3838            self.hint = other.hint.clone();
3839        }
3840        if other.value.is_some() {
3841            self.value = other.value.clone();
3842        }
3843        if other.role != Role::None {
3844            self.role = other.role;
3845        }
3846        if other.identifier.is_some() {
3847            self.identifier = other.identifier.clone();
3848        }
3849        self.hidden |= other.hidden;
3850        self.decorative |= other.decorative;
3851        if other.selected.is_some() {
3852            self.selected = other.selected;
3853        }
3854    }
3855
3856    /// The role to *expect* for a node of `kind` carrying these annotations: an explicit
3857    /// `.role()` wins, otherwise the kind's native default (`Role::for_kind`).
3858    pub fn resolved_role(&self, kind: PieceKind) -> Role {
3859        if self.role != Role::None {
3860            self.role
3861        } else {
3862            Role::for_kind(kind)
3863        }
3864    }
3865}
3866
3867/// A widget's actual native state, read back from the platform by `Toolkit::read_native`
3868/// (docs/testing.md): what the widget itself shows and reports, independently of Day's tree,
3869/// so `a11y_audit` and `assert_native` (§14.2) can diff it against Day's expectation.
3870///
3871/// Two groups. The accessibility fields (`role`, `label`, `value`, `identifier`) are read
3872/// together and `found` says whether they were: `found = false` means the backend cannot read
3873/// its accessibility tree, and the audit skips the node. Every other field is read on its own,
3874/// and `None` means "not read": the backend cannot read it, or it does not apply to this kind.
3875/// A reader never reports a field it did not take from the widget, so a `None` is a recorded
3876/// gap rather than a pass.
3877#[derive(Clone, Debug, Default, PartialEq)]
3878pub struct NativeSnapshot {
3879    /// The accessibility fields below were read.
3880    pub found: bool,
3881    /// The native role mapped back to Day's `Role` (best-effort).
3882    pub role: Role,
3883    /// The accessibility label the platform reports.
3884    pub label: Option<String>,
3885    /// The accessibility value the platform reports.
3886    pub value: Option<String>,
3887    /// The automation identifier, which Day sets from `.id()`.
3888    pub identifier: Option<String>,
3889    /// The text the widget displays: a label's string, a button's title, a text field's or
3890    /// text area's contents, without markup or mnemonic markers. `None` for a secure field
3891    /// that masks it.
3892    pub text: Option<String>,
3893    /// A slider's or progress indicator's value, in the range Day gave it.
3894    pub number: Option<f64>,
3895    /// A toggle's state, or a button's selected state (`ButtonProps::selected`).
3896    pub checked: Option<bool>,
3897    /// Whether the widget takes input.
3898    pub enabled: Option<bool>,
3899    /// Whether the widget is shown: not hidden itself, nor by an ancestor.
3900    pub visible: Option<bool>,
3901    /// The widget's frame in points, relative to the window's content area, origin top left.
3902    pub frame: Option<Rect>,
3903}
3904
3905// ---------------------------------------------------------------------------
3906// Canvas display list (§11): full op set lands with M8a; the types are v1.
3907// ---------------------------------------------------------------------------
3908
3909#[derive(Clone, Debug, PartialEq)]
3910pub enum Shape {
3911    Rect(Rect),
3912    RoundedRect(Rect, f64),
3913    Ellipse(Rect),
3914    /// Arc within `rect`'s inscribed ellipse; angles in degrees, 0 = +x axis, clockwise.
3915    Arc {
3916        rect: Rect,
3917        start_deg: f64,
3918        sweep_deg: f64,
3919    },
3920    Line(Point, Point),
3921    Polygon(Vec<Point>),
3922    /// An arbitrary path: curves, several contours, and a fill rule (docs/canvas.md).
3923    ///
3924    /// [`Shape::Polygon`] is the straight-line, single-contour special case, kept because it is
3925    /// what most drawing code wants and it encodes far more compactly.
3926    Path(Path),
3927}
3928
3929impl Shape {
3930    /// A rectangle rounded corner by corner (docs/canvas.md "Rounded corners"), as the simplest
3931    /// shape that draws it: a plain [`Shape::Rect`] when every corner is square, a
3932    /// [`Shape::RoundedRect`] (each toolkit's own rounded rectangle) when the corners agree, and a
3933    /// path ([`rounded_rect_segs`]) otherwise. The radii are [fitted](CornerRadii::fitted) first.
3934    ///
3935    /// ```
3936    /// use day_spec::{CornerRadii, Rect, Shape};
3937    ///
3938    /// // A bar standing on its axis: rounded on top, square where it meets the baseline.
3939    /// let bar = Shape::rounded_rect(Rect::new(0.0, 0.0, 20.0, 80.0), CornerRadii::top(6.0));
3940    /// assert!(matches!(bar, Shape::Path(_)));
3941    /// ```
3942    pub fn rounded_rect(rect: Rect, radii: CornerRadii) -> Shape {
3943        let r = radii.fitted(rect.size);
3944        if r.is_zero() || rect.size.width <= 0.0 || rect.size.height <= 0.0 {
3945            Shape::Rect(rect)
3946        } else if r.is_uniform() {
3947            Shape::RoundedRect(rect, r.top_left)
3948        } else {
3949            Shape::Path(Path {
3950                segs: rounded_rect_segs(rect, r),
3951                rule: FillRule::NonZero,
3952            })
3953        }
3954    }
3955
3956    /// The same shape moved by `(dx, dy)`.
3957    ///
3958    /// What a [`Stamp`] means by "the template translated by one point", and the one definition of
3959    /// it: a backend builds each copy of a batch with this rather than reaching for its own
3960    /// platform transform, so every toolkit stamps to the same coordinates.
3961    pub fn translated(&self, dx: f64, dy: f64) -> Shape {
3962        let r = |r: &Rect| {
3963            Rect::new(
3964                r.origin.x + dx,
3965                r.origin.y + dy,
3966                r.size.width,
3967                r.size.height,
3968            )
3969        };
3970        let p = |p: &Point| Point::new(p.x + dx, p.y + dy);
3971        match self {
3972            Shape::Rect(rect) => Shape::Rect(r(rect)),
3973            Shape::RoundedRect(rect, rad) => Shape::RoundedRect(r(rect), *rad),
3974            Shape::Ellipse(rect) => Shape::Ellipse(r(rect)),
3975            Shape::Arc {
3976                rect,
3977                start_deg,
3978                sweep_deg,
3979            } => Shape::Arc {
3980                rect: r(rect),
3981                start_deg: *start_deg,
3982                sweep_deg: *sweep_deg,
3983            },
3984            Shape::Line(a, b) => Shape::Line(p(a), p(b)),
3985            Shape::Polygon(pts) => Shape::Polygon(pts.iter().map(p).collect()),
3986            Shape::Path(path) => Shape::Path(Path {
3987                segs: path
3988                    .segs
3989                    .iter()
3990                    .map(|seg| match seg {
3991                        PathSeg::Move(a) => PathSeg::Move(p(a)),
3992                        PathSeg::Line(a) => PathSeg::Line(p(a)),
3993                        PathSeg::Quad(c, a) => PathSeg::Quad(p(c), p(a)),
3994                        PathSeg::Cubic(c1, c2, a) => PathSeg::Cubic(p(c1), p(c2), p(a)),
3995                        PathSeg::Close => PathSeg::Close,
3996                    })
3997                    .collect(),
3998                rule: path.rule,
3999            }),
4000        }
4001    }
4002
4003    /// The shape's bounding rectangle: the box gradient [`UnitPoint`]s resolve against.
4004    pub fn bounds(&self) -> Rect {
4005        match self {
4006            Shape::Rect(r) | Shape::RoundedRect(r, _) | Shape::Ellipse(r) => *r,
4007            Shape::Arc { rect, .. } => *rect,
4008            Shape::Line(a, b) => points_bounds(&[*a, *b]),
4009            Shape::Polygon(pts) => points_bounds(pts),
4010            Shape::Path(p) => p.bounds(),
4011        }
4012    }
4013}
4014
4015/// One step of a [`Path`]. Points are absolute, in the canvas's coordinate space.
4016#[derive(Clone, Copy, Debug, PartialEq)]
4017pub enum PathSeg {
4018    /// Start a new contour at this point.
4019    Move(Point),
4020    Line(Point),
4021    /// Quadratic bezier: one control point, then the end point.
4022    Quad(Point, Point),
4023    /// Cubic bezier: two control points, then the end point.
4024    Cubic(Point, Point, Point),
4025    /// Close the current contour back to its `Move`.
4026    Close,
4027}
4028
4029/// Which points count as inside when a path's contours overlap (docs/canvas.md).
4030///
4031/// This matters the moment a shape has a hole: the counter of an "o", a washer, a ring chart. It
4032/// is a property of the fill, so stroking ignores it.
4033#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
4034pub enum FillRule {
4035    /// Direction-sensitive: a hole needs its contour wound the opposite way. Every 2-D API's
4036    /// default, and what a font's glyph outlines assume.
4037    #[default]
4038    NonZero,
4039    /// Parity: any contour inside another cuts a hole regardless of winding. What PDF's `f*`
4040    /// operator and SVG's `fill-rule: evenodd` mean.
4041    EvenOdd,
4042}
4043
4044/// An arbitrary 2-D path: any number of contours, straight or curved, with a fill rule.
4045#[derive(Clone, Debug, PartialEq, Default)]
4046pub struct Path {
4047    pub segs: Vec<PathSeg>,
4048    pub rule: FillRule,
4049}
4050
4051impl Path {
4052    /// A conservative bounding box: the hull of every point including control points, which
4053    /// contains the true curve bounds (a bezier stays inside its control hull). Used only to
4054    /// resolve gradient unit points, where being slightly generous costs nothing and computing
4055    /// exact curve extrema would not.
4056    pub fn bounds(&self) -> Rect {
4057        let mut pts: Vec<Point> = Vec::with_capacity(self.segs.len() * 3);
4058        for seg in &self.segs {
4059            match seg {
4060                PathSeg::Move(p) | PathSeg::Line(p) => pts.push(*p),
4061                PathSeg::Quad(c, p) => pts.extend_from_slice(&[*c, *p]),
4062                PathSeg::Cubic(c1, c2, p) => pts.extend_from_slice(&[*c1, *c2, *p]),
4063                PathSeg::Close => {}
4064            }
4065        }
4066        points_bounds(&pts)
4067    }
4068}
4069
4070/// A radius for each corner of a rectangle, clockwise from the top left (docs/canvas.md "Rounded
4071/// corners"). The shape a bar in a chart wants (rounded at its value end, square against its
4072/// axis), a tab (rounded on top), or a card joined to its neighbor.
4073///
4074/// Radii are in points and never negative. [`CornerRadii::fitted`] shrinks them the way CSS
4075/// does when two radii on one side would overlap, which is what a caller animating a radius or
4076/// drawing a short bar needs to not think about.
4077#[derive(Clone, Copy, Debug, PartialEq, Default)]
4078pub struct CornerRadii {
4079    pub top_left: f64,
4080    pub top_right: f64,
4081    pub bottom_right: f64,
4082    pub bottom_left: f64,
4083}
4084
4085impl CornerRadii {
4086    /// The same radius on every corner: what [`Shape::RoundedRect`] draws.
4087    pub fn uniform(r: f64) -> Self {
4088        CornerRadii {
4089            top_left: r,
4090            top_right: r,
4091            bottom_right: r,
4092            bottom_left: r,
4093        }
4094    }
4095    /// Rounded along the top edge, square along the bottom: a bar standing on its axis.
4096    pub fn top(r: f64) -> Self {
4097        CornerRadii {
4098            top_left: r,
4099            top_right: r,
4100            ..Default::default()
4101        }
4102    }
4103    /// Rounded along the bottom edge: a bar hanging below its axis.
4104    pub fn bottom(r: f64) -> Self {
4105        CornerRadii {
4106            bottom_right: r,
4107            bottom_left: r,
4108            ..Default::default()
4109        }
4110    }
4111    /// Rounded along the left edge: a bar growing leftward from its axis.
4112    pub fn left(r: f64) -> Self {
4113        CornerRadii {
4114            top_left: r,
4115            bottom_left: r,
4116            ..Default::default()
4117        }
4118    }
4119    /// Rounded along the right edge: a bar growing rightward from its axis.
4120    pub fn right(r: f64) -> Self {
4121        CornerRadii {
4122            top_right: r,
4123            bottom_right: r,
4124            ..Default::default()
4125        }
4126    }
4127
4128    /// The four radii, clockwise from the top left.
4129    pub fn to_array(self) -> [f64; 4] {
4130        [
4131            self.top_left,
4132            self.top_right,
4133            self.bottom_right,
4134            self.bottom_left,
4135        ]
4136    }
4137
4138    /// Whether every corner is square.
4139    pub fn is_zero(self) -> bool {
4140        self.to_array().iter().all(|r| *r <= 0.0)
4141    }
4142
4143    /// Whether every corner has the same radius, so the rectangle is a [`Shape::RoundedRect`].
4144    pub fn is_uniform(self) -> bool {
4145        let [a, b, c, d] = self.to_array();
4146        a == b && b == c && c == d
4147    }
4148
4149    /// These radii made to fit a `size` rectangle, the rule CSS uses for `border-radius`: a
4150    /// negative or non-finite radius is square, and when the two radii along any side add up to
4151    /// more than that side, every radius is scaled down by the same factor until none overlap.
4152    /// Scaling all four together keeps the corners in proportion, where clamping each one alone
4153    /// would turn a pill into a lopsided shape.
4154    pub fn fitted(self, size: Size) -> Self {
4155        let clean = |r: f64| if r.is_finite() && r > 0.0 { r } else { 0.0 };
4156        let [tl, tr, br, bl] = self.to_array().map(clean);
4157        let (w, h) = (size.width.max(0.0), size.height.max(0.0));
4158        let factor = [(w, tl + tr), (w, bl + br), (h, tl + bl), (h, tr + br)]
4159            .into_iter()
4160            .filter(|(_, sum)| *sum > 0.0)
4161            .map(|(side, sum)| side / sum)
4162            .fold(1.0f64, f64::min);
4163        CornerRadii {
4164            top_left: tl * factor,
4165            top_right: tr * factor,
4166            bottom_right: br * factor,
4167            bottom_left: bl * factor,
4168        }
4169    }
4170}
4171
4172/// The point `angle_deg` round a circle: degrees, `0` = the +x axis, positive clockwise in device
4173/// space (y down), the convention [`Shape::Arc`] and every arc helper here use.
4174pub fn arc_point(center: Point, radius: f64, angle_deg: f64) -> Point {
4175    let a = angle_deg.to_radians();
4176    Point::new(center.x + radius * a.cos(), center.y + radius * a.sin())
4177}
4178
4179/// A circular arc as cubic beziers: `sweep_deg` of a circle of `radius` about `center`, from
4180/// `start_deg` ([`arc_point`]'s convention). The segments start *from* the arc's first point, which
4181/// the caller reaches itself (a move or a line); nothing for a zero or non-finite radius or sweep.
4182///
4183/// The arc is split into quarter turns at most, where a cubic tracks a circle best: the radial
4184/// error peaks at 2.7 × 10⁻⁴ of the radius, a quarter of a pixel on a circle a thousand points
4185/// across (docs/canvas.md "Paths"). The same beziers on every backend mean the same pixels on
4186/// every backend, which an arc op interpreted by nine rasterizers would not.
4187pub fn arc_cubics(center: Point, radius: f64, start_deg: f64, sweep_deg: f64) -> Vec<PathSeg> {
4188    let (start, sweep) = (start_deg.to_radians(), sweep_deg.to_radians());
4189    if !radius.is_finite() || radius <= 0.0 || !sweep.is_finite() || sweep == 0.0 {
4190        return Vec::new();
4191    }
4192    let at = |a: f64| Point::new(center.x + radius * a.cos(), center.y + radius * a.sin());
4193    let steps = (sweep.abs() / std::f64::consts::FRAC_PI_2).ceil() as usize;
4194    let delta = sweep / steps as f64;
4195    // The exact control-handle length for a circular arc of this sweep. Negative for a negative
4196    // sweep, which is what turns the handles around for a counter-clockwise arc.
4197    let k = 4.0 / 3.0 * (delta / 4.0).tan() * radius;
4198    (0..steps)
4199        .map(|i| {
4200            let a = start + delta * i as f64;
4201            let b = a + delta;
4202            let (p0, p1) = (at(a), at(b));
4203            PathSeg::Cubic(
4204                Point::new(p0.x - k * a.sin(), p0.y + k * a.cos()),
4205                Point::new(p1.x + k * b.sin(), p1.y - k * b.cos()),
4206                p1,
4207            )
4208        })
4209        .collect()
4210}
4211
4212/// The closed contour of a rectangle rounded corner by corner, clockwise from the top left, with
4213/// the radii [fitted](CornerRadii::fitted) first. A square corner is a sharp point, not a
4214/// zero-length curve.
4215pub fn rounded_rect_segs(rect: Rect, radii: CornerRadii) -> Vec<PathSeg> {
4216    let r = radii.fitted(rect.size);
4217    let (x0, y0) = (rect.origin.x, rect.origin.y);
4218    let (x1, y1) = (x0 + rect.size.width, y0 + rect.size.height);
4219    // Each corner: the point where its curve starts, then the quarter turn round its center.
4220    let corners = [
4221        (
4222            Point::new(x0 + r.top_left, y0 + r.top_left),
4223            r.top_left,
4224            180.0,
4225        ),
4226        (
4227            Point::new(x1 - r.top_right, y0 + r.top_right),
4228            r.top_right,
4229            270.0,
4230        ),
4231        (
4232            Point::new(x1 - r.bottom_right, y1 - r.bottom_right),
4233            r.bottom_right,
4234            0.0,
4235        ),
4236        (
4237            Point::new(x0 + r.bottom_left, y1 - r.bottom_left),
4238            r.bottom_left,
4239            90.0,
4240        ),
4241    ];
4242    let mut segs = Vec::with_capacity(14);
4243    for (i, (center, radius, start)) in corners.into_iter().enumerate() {
4244        let first = arc_point(center, radius, start);
4245        segs.push(if i == 0 {
4246            PathSeg::Move(first)
4247        } else {
4248            PathSeg::Line(first)
4249        });
4250        segs.extend(arc_cubics(center, radius, start, 90.0));
4251    }
4252    segs.push(PathSeg::Close);
4253    segs
4254}
4255
4256/// How a stroked line ends (docs/canvas.md). Names match PDF's `J`, SVG's `stroke-linecap`, and
4257/// every native 2-D API.
4258#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
4259pub enum LineCap {
4260    #[default]
4261    Butt,
4262    Round,
4263    Square,
4264}
4265
4266/// How two stroked segments meet (docs/canvas.md). Matches PDF's `j` and SVG's
4267/// `stroke-linejoin`.
4268#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
4269pub enum LineJoin {
4270    #[default]
4271    Miter,
4272    Round,
4273    Bevel,
4274}
4275
4276/// Everything about a stroke except its paint.
4277///
4278/// [`StrokeStyle::width`] and `From<f64>` keep the common "just a width" case a single
4279/// argument, so plain `stroke(shape, color, 2.0)` call sites read the same as they always did.
4280#[derive(Clone, Debug, PartialEq)]
4281pub struct StrokeStyle {
4282    pub width: f64,
4283    pub cap: LineCap,
4284    pub join: LineJoin,
4285    /// Ignored unless `join` is [`LineJoin::Miter`]. The 2-D convention: past this ratio of miter
4286    /// length to line width the join falls back to a bevel.
4287    pub miter_limit: f64,
4288    /// On/off run lengths, repeating. Empty means solid.
4289    pub dash: Vec<f64>,
4290    /// How far into the dash pattern the line starts.
4291    pub dash_phase: f64,
4292}
4293
4294impl Default for StrokeStyle {
4295    fn default() -> Self {
4296        // 10.0 is the miter limit PDF, PostScript, SVG and Cairo all default to.
4297        StrokeStyle {
4298            width: 1.0,
4299            cap: LineCap::Butt,
4300            join: LineJoin::Miter,
4301            miter_limit: 10.0,
4302            dash: Vec::new(),
4303            dash_phase: 0.0,
4304        }
4305    }
4306}
4307
4308impl StrokeStyle {
4309    /// A solid stroke of `width`, everything else default.
4310    pub fn width(width: f64) -> Self {
4311        StrokeStyle {
4312            width,
4313            ..Default::default()
4314        }
4315    }
4316    /// A dashed stroke: `dash` is on/off run lengths, repeating.
4317    pub fn dashed(width: f64, dash: Vec<f64>) -> Self {
4318        StrokeStyle {
4319            width,
4320            dash,
4321            ..Default::default()
4322        }
4323    }
4324    /// Rounded ends and joins, the shape a data line usually wants.
4325    pub fn round(width: f64) -> Self {
4326        StrokeStyle {
4327            width,
4328            cap: LineCap::Round,
4329            join: LineJoin::Round,
4330            ..Default::default()
4331        }
4332    }
4333    /// Is this the plain width-only stroke every pre-existing call site asks for? Backends use
4334    /// it to skip setting dash/cap/join state they would only have to set back.
4335    pub fn is_plain(&self) -> bool {
4336        self.cap == LineCap::Butt
4337            && self.join == LineJoin::Miter
4338            && self.dash.is_empty()
4339            && self.miter_limit == 10.0
4340    }
4341}
4342
4343impl From<f64> for StrokeStyle {
4344    fn from(width: f64) -> Self {
4345        StrokeStyle::width(width)
4346    }
4347}
4348
4349fn points_bounds(pts: &[Point]) -> Rect {
4350    let (mut x0, mut y0, mut x1, mut y1) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN);
4351    for p in pts {
4352        x0 = x0.min(p.x);
4353        y0 = y0.min(p.y);
4354        x1 = x1.max(p.x);
4355        y1 = y1.max(p.y);
4356    }
4357    if pts.is_empty() {
4358        return Rect::ZERO;
4359    }
4360    Rect::new(x0, y0, x1 - x0, y1 - y0)
4361}
4362
4363/// A point in the unit space of a shape's bounding box: (0,0) = top-leading, (1,1) =
4364/// bottom-trailing. Gradient geometry is expressed in unit points so one paint value works for
4365/// any shape size (docs/shapes.md §3.2).
4366#[derive(Clone, Copy, Debug, PartialEq)]
4367pub struct UnitPoint {
4368    pub x: f64,
4369    pub y: f64,
4370}
4371
4372impl UnitPoint {
4373    pub const fn new(x: f64, y: f64) -> Self {
4374        UnitPoint { x, y }
4375    }
4376    pub const TOP: UnitPoint = UnitPoint::new(0.5, 0.0);
4377    pub const BOTTOM: UnitPoint = UnitPoint::new(0.5, 1.0);
4378    pub const LEADING: UnitPoint = UnitPoint::new(0.0, 0.5);
4379    pub const TRAILING: UnitPoint = UnitPoint::new(1.0, 0.5);
4380    pub const TOP_LEADING: UnitPoint = UnitPoint::new(0.0, 0.0);
4381    pub const TOP_TRAILING: UnitPoint = UnitPoint::new(1.0, 0.0);
4382    pub const BOTTOM_LEADING: UnitPoint = UnitPoint::new(0.0, 1.0);
4383    pub const BOTTOM_TRAILING: UnitPoint = UnitPoint::new(1.0, 1.0);
4384    pub const CENTER: UnitPoint = UnitPoint::new(0.5, 0.5);
4385
4386    /// Resolve to an absolute point within `rect`.
4387    pub fn resolve(&self, rect: Rect) -> Point {
4388        Point::new(
4389            rect.origin.x + self.x * rect.size.width,
4390            rect.origin.y + self.y * rect.size.height,
4391        )
4392    }
4393}
4394
4395/// A linear gradient (docs/shapes.md §3.2 / §7): color stops along the line from `start` to
4396/// `end`, both in the unit space of the filled shape's bounding box. Stops are
4397/// `(offset 0..=1, color)`, ascending.
4398#[derive(Clone, Debug, PartialEq)]
4399pub struct LinearGradient {
4400    pub start: UnitPoint,
4401    pub end: UnitPoint,
4402    pub stops: Vec<(f64, Color)>,
4403}
4404
4405impl LinearGradient {
4406    pub fn new(start: UnitPoint, end: UnitPoint, stops: Vec<(f64, Color)>) -> Self {
4407        LinearGradient { start, end, stops }
4408    }
4409    /// Top-to-bottom between two colors: the everyday sky/backdrop case.
4410    pub fn vertical(top: Color, bottom: Color) -> Self {
4411        LinearGradient::new(
4412            UnitPoint::TOP,
4413            UnitPoint::BOTTOM,
4414            vec![(0.0, top), (1.0, bottom)],
4415        )
4416    }
4417    /// Leading-to-trailing between two colors.
4418    pub fn horizontal(leading: Color, trailing: Color) -> Self {
4419        LinearGradient::new(
4420            UnitPoint::LEADING,
4421            UnitPoint::TRAILING,
4422            vec![(0.0, leading), (1.0, trailing)],
4423        )
4424    }
4425}
4426
4427/// A radial gradient (docs/shapes.md §3.2 / §7): color stops from `center` outward. Both the
4428/// center and the radius live in the unit space of the filled shape's bounding box, so the
4429/// gradient stretches into an ellipse when the bounds aren't square (the XAML relative-brush
4430/// behavior; the other backends reproduce it with a local matrix on a circular gradient). A
4431/// `radius` of `0.5` from the default center touches the edge midpoints of the bounds.
4432#[derive(Clone, Debug, PartialEq)]
4433pub struct RadialGradient {
4434    pub center: UnitPoint,
4435    pub radius: f64,
4436    pub stops: Vec<(f64, Color)>,
4437}
4438
4439impl RadialGradient {
4440    pub fn new(center: UnitPoint, radius: f64, stops: Vec<(f64, Color)>) -> Self {
4441        RadialGradient {
4442            center,
4443            radius,
4444            stops,
4445        }
4446    }
4447    /// Centered, edge-touching (radius 0.5) between two colors: the everyday glow case.
4448    pub fn centered(inner: Color, outer: Color) -> Self {
4449        RadialGradient::new(UnitPoint::CENTER, 0.5, vec![(0.0, inner), (1.0, outer)])
4450    }
4451}
4452
4453/// The payload of [`DrawOp::Stamp`]: one shape drawn at every position in `at`.
4454///
4455/// A scatter plot is the case this exists for. Drawn as individual ops, fifty thousand points are
4456/// fifty thousand `DrawOp`s, rebuilt, compared and cloned on every frame that re-records, before
4457/// a backend draws anything. As one `Stamp` they are one op and a flat array of coordinates, and
4458/// a backend can put every copy into one path and hand the rasterizer a single fill.
4459///
4460/// Every copy shares the shape, the size and the paint. Varying any of those means more than one
4461/// stamp: group by what varies, which for a chart is usually the series color.
4462#[derive(Clone, Debug, PartialEq)]
4463pub struct Stamp {
4464    /// The template, authored around the origin: each copy is this shape translated by one point.
4465    /// A 6-point dot is `Shape::Ellipse(Rect::new(-3.0, -3.0, 6.0, 6.0))`.
4466    pub shape: Shape,
4467    /// Where the copies go. Order is the drawing order, so later points paint over earlier ones.
4468    pub at: Vec<Point>,
4469    pub paint: Paint,
4470    /// `None` fills each copy; `Some` strokes it with this style.
4471    pub stroke: Option<StrokeStyle>,
4472}
4473
4474/// A fill source: a solid color, or a linear/radial gradient (docs/shapes.md §3.2; angular and
4475/// semantic tokens are later phases). `From<Color>` keeps every existing `fill(shape, color)`
4476/// call site compiling unchanged.
4477#[derive(Clone, Debug, PartialEq)]
4478pub enum Paint {
4479    Solid(Color),
4480    Linear(LinearGradient),
4481    Radial(RadialGradient),
4482}
4483
4484impl From<Color> for Paint {
4485    fn from(c: Color) -> Self {
4486        Paint::Solid(c)
4487    }
4488}
4489
4490impl From<LinearGradient> for Paint {
4491    fn from(g: LinearGradient) -> Self {
4492        Paint::Linear(g)
4493    }
4494}
4495
4496impl From<RadialGradient> for Paint {
4497    fn from(g: RadialGradient) -> Self {
4498        Paint::Radial(g)
4499    }
4500}
4501
4502/// Where `at` sits vertically. `Top`, `Middle` and `Bottom` are edges of the line box (ascent +
4503/// descent at the font's size, the box [`Toolkit::measure_text`] reports); `Baseline` is the
4504/// typographic baseline itself.
4505#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
4506pub enum TextVAlign {
4507    /// `at` is the top of the line box.
4508    #[default]
4509    Top,
4510    /// `at` is the vertical middle of the line box.
4511    Middle,
4512    /// `at` is the baseline itself: what every native 2-D text call takes, and what a run of labels
4513    /// sharing a line should align on rather than their individual boxes.
4514    Baseline,
4515    /// `at` is the bottom of the line box.
4516    Bottom,
4517}
4518
4519/// How canvas text hangs on its `at` point: one placement per axis (docs/canvas.md "Text").
4520///
4521/// Two axes rather than a list of named positions, because that is the shape of the problem and
4522/// of every native API underneath: `NSAttributedString` drawing, Pango extents,
4523/// `QFontMetricsF`, `Paint.Align`, Canvas2D's `textAlign`/`textBaseline`. The alignment is done
4524/// by the backend, which is both cheaper and more accurate than doing it in app code: it already
4525/// has the laid-out metrics, where an app has to ask for them through
4526/// [`Toolkit::measure_text`] first.
4527///
4528/// [`TextAnchor::LEADING`] and [`TextAnchor::CENTERED`] name the two placements that used to be
4529/// this type's only variants.
4530#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
4531pub struct TextAnchor {
4532    /// The horizontal placement: the same three-way leading/center/trailing a wrapped label's
4533    /// lines take ([`props::TextAlign`]), because it is the same question with the same RTL rule.
4534    /// `Trailing` is what an axis label right-aligned against its axis wants, and the reason the
4535    /// horizontal axis is settable at all: emulating it in app code costs a
4536    /// [`Toolkit::measure_text`] call per label, where the backend already holds the width it is
4537    /// about to draw with.
4538    pub h: props::TextAlign,
4539    pub v: TextVAlign,
4540}
4541
4542impl TextAnchor {
4543    /// The top-leading corner of the line box, the default.
4544    pub const LEADING: TextAnchor = TextAnchor {
4545        h: props::TextAlign::Leading,
4546        v: TextVAlign::Top,
4547    };
4548    /// The center of the line box, both ways.
4549    pub const CENTERED: TextAnchor = TextAnchor {
4550        h: props::TextAlign::Center,
4551        v: TextVAlign::Middle,
4552    };
4553    /// The top-trailing corner of the line box.
4554    pub const TRAILING: TextAnchor = TextAnchor {
4555        h: props::TextAlign::Trailing,
4556        v: TextVAlign::Top,
4557    };
4558
4559    /// Where the text's top-leading corner goes, as an offset from `at`, given the line box this
4560    /// text measures to. The single definition of what an anchor means: every backend that draws
4561    /// in Rust calls this, and the C++/Java/JS shims repeat exactly this arithmetic.
4562    ///
4563    /// `height` is the line box (ascent + descent) and `ascent` the baseline's offset from its
4564    /// top, the two numbers [`TextMetrics`] carries.
4565    pub fn offset(self, width: f64, height: f64, ascent: f64) -> (f64, f64) {
4566        let dx = match self.h {
4567            props::TextAlign::Leading => 0.0,
4568            props::TextAlign::Center => -width / 2.0,
4569            props::TextAlign::Trailing => -width,
4570        };
4571        let dy = match self.v {
4572            TextVAlign::Top => 0.0,
4573            TextVAlign::Middle => -height / 2.0,
4574            TextVAlign::Baseline => -ascent,
4575            TextVAlign::Bottom => -height,
4576        };
4577        (dx, dy)
4578    }
4579
4580    /// The anchor as one number, for the numeric op stream the serializing backends share:
4581    /// `h * 4 + v`, so it still occupies the single slot the two-variant enum did.
4582    pub fn pack(self) -> f64 {
4583        (self.h as u8 * 4 + self.v as u8) as f64
4584    }
4585
4586    /// Inverse of [`TextAnchor::pack`]. An unrecognized code answers the default rather than
4587    /// failing: a stream is only ever written by a matching encoder, and a drawing with text in
4588    /// the wrong corner beats one that panics inside a native draw callback.
4589    pub fn unpack(code: f64) -> TextAnchor {
4590        let c = code.round().clamp(0.0, 11.0) as u8;
4591        let h = match c / 4 {
4592            1 => props::TextAlign::Center,
4593            2 => props::TextAlign::Trailing,
4594            _ => props::TextAlign::Leading,
4595        };
4596        let v = match c % 4 {
4597            1 => TextVAlign::Middle,
4598            2 => TextVAlign::Baseline,
4599            3 => TextVAlign::Bottom,
4600            _ => TextVAlign::Top,
4601        };
4602        TextAnchor { h, v }
4603    }
4604}
4605
4606#[derive(Clone, Debug, PartialEq)]
4607pub enum DrawOp {
4608    Fill(Shape, Paint),
4609    /// Stroke `shape`. The paint may be a gradient, and [`StrokeStyle`] carries width, dash, cap
4610    /// and join; `Draw::stroke` builds the plain width-only case.
4611    Stroke(Shape, Paint, StrokeStyle),
4612    /// One line of text. `size` is absolute canvas points (no accessibility scale: canvas
4613    /// text is a label inside a drawing, docs/canvas.md); `font` picks the family, weight and
4614    /// slant, and its default is the platform's own UI face.
4615    Text {
4616        text: String,
4617        at: Point,
4618        size: f64,
4619        color: Color,
4620        anchor: TextAnchor,
4621        font: CanvasFont,
4622    },
4623    /// Draw one shape at many positions, the batched form of [`DrawOp::Fill`]/[`DrawOp::Stroke`]
4624    /// (docs/canvas.md "Stamping").
4625    ///
4626    /// Boxed because the payload is wider than the largest other op, and a bigger `DrawOp` would
4627    /// cost every op in every drawing, the exact thing this variant exists to stop paying.
4628    Stamp(Box<Stamp>),
4629    /// Draw an image into `rect` (docs/images.md).
4630    ///
4631    /// The source is a [`BitmapId`] and never bytes: a canvas re-records on every tracked read,
4632    /// so carrying a buffer here would hand the backend a megabyte to compare, and re-decode, on
4633    /// every frame. The app decodes once with `day::decode_image` and draws the handle.
4634    ///
4635    /// `opacity` multiplies the image's own alpha. A backend that cannot draw a bitmap at all
4636    /// draws nothing rather than a placeholder rectangle, the same way an unknown op is skipped.
4637    Image {
4638        image: BitmapId,
4639        rect: Rect,
4640        opacity: f64,
4641    },
4642    /// Intersect the clip with `shape`; everything drawn afterwards is confined to it.
4643    ///
4644    /// Scoped by [`DrawOp::Save`]/[`DrawOp::Restore`], which is the only way to widen a clip
4645    /// again; every native 2-D context works this way, so there is no "unclip".
4646    Clip(Shape),
4647    /// Push the current transform + clip (§11, shapes). Backends map to save/restore of the
4648    /// native 2-D context; `Concat` multiplies an affine onto the CTM (shape rotate/scale/offset).
4649    Save,
4650    Restore,
4651    Concat(day_geometry::Affine),
4652}
4653
4654// ---------------------------------------------------------------------------
4655// Built-in piece descriptors: full props (realize) + sparse patches (update).
4656// One binding = one attribute = one patch value: sparseness by construction (§8.1).
4657// ---------------------------------------------------------------------------
4658
4659/// A semantic (logical) text style. Each maps to the platform's native text style where the
4660/// toolkit has one: `UIFont`/`NSFont.preferredFont(forTextStyle:)` on Apple (Dynamic Type), the
4661/// `*TextBlockStyle` resources on XAML. A Day app thus matches the OS's own typography and
4662/// inherits its accessibility text scaling with no extra work. Backends without semantic styles
4663/// (GTK/Qt/Android) approximate with sizes that still track the platform's text-scale /
4664/// font-scale accessibility setting.
4665///
4666/// The set mirrors SwiftUI `Font.TextStyle` (largest → smallest).
4667#[derive(Clone, Copy, Debug, PartialEq, Default)]
4668pub enum Font {
4669    LargeTitle,
4670    Title,
4671    Title2,
4672    Title3,
4673    Headline,
4674    Subheadline,
4675    #[default]
4676    Body,
4677    Callout,
4678    Footnote,
4679    Caption,
4680    Caption2,
4681    /// A custom point size. Backends scale it by the platform's accessibility text-scale (iOS via
4682    /// `UIFontMetrics`, Android via `sp`, GTK via text-scaling-factor) so it stays legible.
4683    System(f64),
4684    /// A bundled custom font by **family name**, at a point size (`Font::Custom("Pacifico",
4685    /// 24.0)`). The family must ship in the project's `fonts/` directory; `day build` stages the
4686    /// file into each platform's native font store and the backend registers it at startup
4687    /// (§18.4). The name is the family name baked into the font file (what Font Book /
4688    /// fontconfig report), not the file name. The size scales with the platform accessibility
4689    /// text-scale exactly like [`Font::System`]; weight/italic apply only where the family ships
4690    /// (or the platform synthesizes) such a face. An unknown family falls back to the system font
4691    /// of the same size, with a warning in the log.
4692    Custom(&'static str, f64),
4693}
4694
4695impl Font {
4696    /// A bundled custom font by **typed family**, at a point size, the checked form of
4697    /// [`Font::Custom`]. Pass a generated `res::fonts::…` constant
4698    /// (`Font::custom(res::fonts::pacifico, 24.0)`), which exists only if the family ships in the
4699    /// project's `fonts/` directory, so the font is guaranteed bundled. For a family name known
4700    /// another way, the untyped [`Font::Custom`] variant is the escape hatch.
4701    pub const fn custom(family: FontFamily, size: f64) -> Font {
4702        Font::Custom(family.as_str(), size)
4703    }
4704}
4705
4706/// Font weight, matching `UIFont.Weight` / SwiftUI `Font.Weight` (lightest → heaviest).
4707/// Ordered by heaviness, so backends can e.g. map `>= Semibold` to a synthesized bold face.
4708#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
4709pub enum FontWeight {
4710    UltraLight,
4711    Thin,
4712    Light,
4713    Regular,
4714    Medium,
4715    Semibold,
4716    Bold,
4717    Heavy,
4718    Black,
4719}
4720
4721impl FontWeight {
4722    /// Every rung, lightest first.
4723    pub const ALL: [FontWeight; 9] = [
4724        FontWeight::UltraLight,
4725        FontWeight::Thin,
4726        FontWeight::Light,
4727        FontWeight::Regular,
4728        FontWeight::Medium,
4729        FontWeight::Semibold,
4730        FontWeight::Bold,
4731        FontWeight::Heavy,
4732        FontWeight::Black,
4733    ];
4734
4735    /// The CSS / OpenType numeric weight (100 … 900), the one number every platform font API
4736    /// also speaks (`Typeface.create(base, weight, italic)`, `QFont::Weight`,
4737    /// `DWRITE_FONT_WEIGHT`, `OH_Drawing_FontWeight`), and what the canvas wire carries.
4738    pub const fn css(self) -> u16 {
4739        match self {
4740            FontWeight::UltraLight => 100,
4741            FontWeight::Thin => 200,
4742            FontWeight::Light => 300,
4743            FontWeight::Regular => 400,
4744            FontWeight::Medium => 500,
4745            FontWeight::Semibold => 600,
4746            FontWeight::Bold => 700,
4747            FontWeight::Heavy => 800,
4748            FontWeight::Black => 900,
4749        }
4750    }
4751
4752    /// The nearest rung to a CSS weight (ties round heavier, like CSS font matching); anything
4753    /// outside 100 … 900 clamps.
4754    pub fn from_css(n: u16) -> FontWeight {
4755        let n = n.clamp(100, 900);
4756        let idx = (usize::from(n) + 50) / 100; // 100 → 1 … 900 → 9
4757        FontWeight::ALL[idx.clamp(1, 9) - 1]
4758    }
4759
4760    /// The English face name for a rung ("Semibold"), for platforms whose enumeration names
4761    /// no face (see [`FontFace::synthesized_name`]).
4762    pub const fn name(self) -> &'static str {
4763        match self {
4764            FontWeight::UltraLight => "Ultra Light",
4765            FontWeight::Thin => "Thin",
4766            FontWeight::Light => "Light",
4767            FontWeight::Regular => "Regular",
4768            FontWeight::Medium => "Medium",
4769            FontWeight::Semibold => "Semibold",
4770            FontWeight::Bold => "Bold",
4771            FontWeight::Heavy => "Heavy",
4772            FontWeight::Black => "Black",
4773        }
4774    }
4775}
4776
4777/// One face a font family ships (docs/fonts.md): its display name ("Bold Italic"), weight and
4778/// slant. What [`FontFamilyInfo::faces`] lists and what a font menu's style picker offers.
4779#[derive(Clone, Debug, PartialEq, Eq)]
4780pub struct FontFace {
4781    pub name: String,
4782    pub weight: FontWeight,
4783    pub italic: bool,
4784}
4785
4786impl FontFace {
4787    /// A display name from weight + slant ("Semibold Italic") for platforms whose enumeration
4788    /// API names no face (Android's `fonts.xml`, the CSS generic families).
4789    pub fn synthesized_name(weight: FontWeight, italic: bool) -> String {
4790        if italic {
4791            format!("{} Italic", weight.name())
4792        } else {
4793            weight.name().to_string()
4794        }
4795    }
4796}
4797
4798/// A platform (or bundled) font family and the faces it can draw canvas text in
4799/// ([`Toolkit::font_families`], docs/fonts.md).
4800#[derive(Clone, Debug, PartialEq, Eq)]
4801pub struct FontFamilyInfo {
4802    pub family: String,
4803    pub faces: Vec<FontFace>,
4804}
4805
4806impl FontFamilyInfo {
4807    /// Whether any face is [`FontWeight::Bold`] or heavier.
4808    pub fn has_bold(&self) -> bool {
4809        self.faces.iter().any(|f| f.weight >= FontWeight::Bold)
4810    }
4811    /// Whether any face is italic.
4812    pub fn has_italic(&self) -> bool {
4813        self.faces.iter().any(|f| f.italic)
4814    }
4815    /// The nearest face: an exact slant match preferred, then the smallest weight distance
4816    /// (ties go heavier, like CSS font matching). `None` only when the family lists no faces.
4817    pub fn face_for(&self, weight: FontWeight, italic: bool) -> Option<&FontFace> {
4818        let want = i32::from(weight.css());
4819        self.faces.iter().min_by_key(|f| {
4820            let slant = i32::from(f.italic != italic);
4821            let dist = (i32::from(f.weight.css()) - want).abs();
4822            // Heavier wins a tie: subtracting the bool sorts the heavier face first.
4823            (slant, dist, i32::from(i32::from(f.weight.css()) < want))
4824        })
4825    }
4826}
4827
4828/// Single-line text measurement in canvas points ([`Toolkit::measure_text`], docs/fonts.md).
4829///
4830/// Two boxes, and the difference between them matters. `width`/`height` is the **line box**: the
4831/// typographic slot the text occupies, the same for every string in a face at a size, and what
4832/// [`TextAnchor`] positions. [`ink`](TextMetrics::ink) is the **ink box**: where the glyphs
4833/// put marks. A line of digits leaves the descender space of its line box empty, so the
4834/// two disagree, and which one is wanted depends on the question: laying text out in a column
4835/// wants the line box, and centering it on a rule or keeping it clear of a mark wants the ink.
4836#[derive(Clone, Copy, Debug, PartialEq)]
4837pub struct TextMetrics {
4838    /// The advance width of the whole string.
4839    pub width: f64,
4840    /// The line box: ascent + descent at this size (plus the line gap where the engine reports
4841    /// one), which is the box [`TextAnchor`] positions.
4842    pub height: f64,
4843    /// The baseline's offset from the top of the line box.
4844    pub ascent: f64,
4845    /// The face's cap height at this size: baseline to the top of a capital.
4846    ///
4847    /// A property of the font, not of this string, reported here because it is what optical
4848    /// centering needs and asking for it separately would be a second measurement. Text made of
4849    /// capitals and digits looks centered on a rule when its cap box straddles it, not its line
4850    /// box: the line box reserves descender room that digits never use, so centering by it sits
4851    /// every label a little low. The cap middle is `ascent - cap_height / 2.0` below the line
4852    /// box's top.
4853    pub cap_height: f64,
4854    /// The ink box, the tight bounds of what is drawn, with its origin relative to the line
4855    /// box's top-leading corner, so `ink.origin` is where the marks start inside the slot.
4856    ///
4857    /// Empty (zero-size) for text that draws nothing, a space included. Where a toolkit cannot
4858    /// report ink it answers the whole line box, which is a superset: an overlap test against it
4859    /// can only be too cautious, never wrong.
4860    pub ink: Rect,
4861}
4862
4863impl TextMetrics {
4864    /// The portable guess `day::measure_text` falls back to when a toolkit cannot measure:
4865    /// 0.6 × size per character, 1.2 × size tall, the baseline at 0.9 × size, caps at 0.7 × size.
4866    /// Its ink is the whole line box, the safe direction to be wrong in (see
4867    /// [`ink`](TextMetrics::ink)).
4868    pub fn approximate(text: &str, size: f64) -> TextMetrics {
4869        let (width, height) = (0.6 * size * text.chars().count() as f64, 1.2 * size);
4870        TextMetrics {
4871            width,
4872            height,
4873            ascent: 0.9 * size,
4874            cap_height: 0.7 * size,
4875            ink: Rect::new(0.0, 0.0, width, height),
4876        }
4877    }
4878
4879    /// Read the eight-slot form the serializing backends (qt, xaml, arkui, web) fill across their
4880    /// C ABI: advance, line height, ascent, cap height, then the ink box relative to the line
4881    /// box's top-leading corner. One definition so four shims cannot drift from each other.
4882    pub fn from_slots(out: &[f64; 8]) -> TextMetrics {
4883        TextMetrics {
4884            width: out[0],
4885            height: out[1],
4886            ascent: out[2],
4887            cap_height: out[3],
4888            ink: Rect::new(out[4], out[5], out[6], out[7]),
4889        }
4890    }
4891
4892    /// The line box's top-leading corner, given a baseline-relative measurement, the shape every
4893    /// backend's metrics come in. `ink_*` are relative to the drawing origin on the baseline
4894    /// (`ink_top` negative above it), the convention Skia, Core Text and Qt all report in.
4895    pub fn from_baseline(
4896        width: f64,
4897        ascent: f64,
4898        descent: f64,
4899        cap_height: f64,
4900        ink: Rect,
4901    ) -> TextMetrics {
4902        TextMetrics {
4903            width,
4904            height: ascent + descent,
4905            ascent,
4906            cap_height,
4907            // Baseline-relative to line-box-relative is one shift by the ascent.
4908            ink: Rect::new(
4909                ink.origin.x,
4910                ink.origin.y + ascent,
4911                ink.size.width,
4912                ink.size.height,
4913            ),
4914        }
4915    }
4916}
4917
4918/// What canvas text is drawn with beyond its size (docs/canvas.md "Text"): a family, a weight and
4919/// a slant. The default is the platform's own UI face, regular, upright, which is what every
4920/// backend drew before the field existed, so a default font encodes to nothing on the wire.
4921#[derive(Clone, Debug, PartialEq, Eq, Default)]
4922pub struct CanvasFont {
4923    /// A platform or bundled family name (as [`Toolkit::font_families`] lists it); `None` =
4924    /// the platform's default UI face.
4925    pub family: Option<String>,
4926    /// `None` = Regular.
4927    pub weight: Option<FontWeight>,
4928    pub italic: bool,
4929}
4930
4931impl CanvasFont {
4932    /// The family name to send over the wire, empty for the default.
4933    pub fn family_str(&self) -> &str {
4934        self.family.as_deref().unwrap_or("")
4935    }
4936    /// The CSS weight to send over the wire, 0 for the default.
4937    pub fn css_weight(&self) -> u16 {
4938        self.weight.map(FontWeight::css).unwrap_or(0)
4939    }
4940}
4941
4942/// Field separator inside one family of the shim-side font list ([`parse_font_list`]).
4943pub const FONT_LIST_FIELD: char = '\u{1f}';
4944/// Family separator of the shim-side font list ([`parse_font_list`]).
4945pub const FONT_LIST_FAMILY: char = '\u{1e}';
4946
4947/// Decode the text a non-Rust font enumerator hands back (the Qt, XAML and ArkUI shims, the
4948/// Android bridge, the web shim all produce this one format): families separated by U+001E;
4949/// inside a family, U+001F-separated fields: the family name, then one (name, CSS weight,
4950/// italic `0`/`1`) triple per face. Tolerant: a malformed face or family is skipped, an empty
4951/// face name is synthesized from its weight and slant, and a family with no faces is kept
4952/// with none (the caller decides what a face-less family means).
4953pub fn parse_font_list(s: &str) -> Vec<FontFamilyInfo> {
4954    let mut out = Vec::new();
4955    for fam in s.split(FONT_LIST_FAMILY) {
4956        let mut fields = fam.split(FONT_LIST_FIELD);
4957        let Some(family) = fields.next().map(str::trim).filter(|f| !f.is_empty()) else {
4958            continue;
4959        };
4960        let rest: Vec<&str> = fields.collect();
4961        let mut faces = Vec::new();
4962        for face in rest.chunks(3) {
4963            let [name, weight, italic] = face else {
4964                continue;
4965            };
4966            let Ok(weight) = weight.trim().parse::<u16>() else {
4967                continue;
4968            };
4969            let weight = FontWeight::from_css(weight);
4970            let italic = matches!(italic.trim(), "1" | "true");
4971            let name = name.trim();
4972            let name = if name.is_empty() {
4973                FontFace::synthesized_name(weight, italic)
4974            } else {
4975                name.to_string()
4976            };
4977            if !faces
4978                .iter()
4979                .any(|f: &FontFace| f.weight == weight && f.italic == italic)
4980            {
4981                faces.push(FontFace {
4982                    name,
4983                    weight,
4984                    italic,
4985                });
4986            }
4987        }
4988        out.push(FontFamilyInfo {
4989            family: family.to_string(),
4990            faces,
4991        });
4992    }
4993    out
4994}
4995
4996/// The full font descriptor a label carries: a semantic (or custom) [`Font`] style plus an optional
4997/// weight override, an italic flag, and the tabular-figures request. Backends resolve this to one
4998/// native font.
4999#[derive(Clone, Copy, Debug, PartialEq)]
5000pub struct FontSpec {
5001    pub style: Font,
5002    pub weight: Option<FontWeight>,
5003    pub italic: bool,
5004    /// Ask for tabular (monospaced) figures: every digit takes the same advance, so a number that
5005    /// changes stops changing width.
5006    ///
5007    /// In a proportional system font `1` is narrower than `8`, so a readout beside a slider
5008    /// wobbles as the value changes even when the digit count is fixed. Every platform ships this
5009    /// as a font feature, so Day asks for the platform's own rather than substituting a monospaced
5010    /// family: the text keeps the system face, and only the digits change metrics.
5011    ///
5012    /// Reserving space (`Decorate::reserving`) and tabular figures solve the two halves of the
5013    /// same problem (reservation stops the box resizing, tabular stops the glyphs shifting inside
5014    /// it), and a numeric readout usually wants both.
5015    ///
5016    /// This rides the existing label realize/patch path rather than adding a Toolkit method, so it
5017    /// is not a backend duty and has no duty-matrix row. Every in-tree backend honors it: AppKit
5018    /// and UIKit re-pick the system face (`monospacedDigitSystemFont`), GTK and Qt and ArkUI set
5019    /// the `tnum` OpenType feature, Android `setFontFeatureSettings`, XAML the `Typography`
5020    /// NumeralAlignment property, and web-dom `font-variant-numeric`. A font with no `tnum` table,
5021    /// or an SDK predating the attribute, renders the stock proportional figures.
5022    pub tabular: bool,
5023    /// Ask for the platform's monospaced face at this style's size, what `` `code` `` in a
5024    /// markdown run needs (docs/text-runs.md).
5025    ///
5026    /// A flag rather than a `Font::Monospace(pt)` variant so the run keeps its semantic style:
5027    /// code inside a `Footnote` paragraph must stay footnote-sized and keep tracking the
5028    /// reader's text-size setting, which a fixed point size would throw away.
5029    ///
5030    /// Like [`Self::tabular`], this rides the label realize/patch path and is not a backend duty:
5031    /// every toolkit has a system monospace face (`monospacedSystemFont`, Pango's `monospace`
5032    /// family, `QFontDatabase::FixedFont`, `Typeface.MONOSPACE`, CSS `monospace`).
5033    pub monospace: bool,
5034    /// Multiply the resolved size. `1.0` is the style's own size, `1.6` a heading against a body
5035    /// paragraph, `0.8` a caption inside one.
5036    ///
5037    /// Relative rather than an absolute point size, so that an editor's font-size control can
5038    /// move this while text a person reads for an hour still tracks the platform's
5039    /// accessibility text-scale. `Font::System(pt)` remains the absolute escape hatch, and it
5040    /// is where an imported document's `\fs28` or `font-size: 14px` lands (docs/texteditor.md):
5041    /// faithful to the file, and no longer responsive to the reader, which is the trade that
5042    /// form makes.
5043    ///
5044    /// Three toolkits take it directly (`GtkTextTag::scale`, Android's `RelativeSizeSpan`, CSS
5045    /// `em`); the rest multiply it into the size they already compute.
5046    pub scale: f64,
5047}
5048
5049impl Default for FontSpec {
5050    fn default() -> Self {
5051        FontSpec {
5052            style: Font::Body,
5053            weight: None,
5054            italic: false,
5055            tabular: false,
5056            monospace: false,
5057            scale: 1.0,
5058        }
5059    }
5060}
5061
5062impl From<Font> for FontSpec {
5063    fn from(style: Font) -> Self {
5064        FontSpec {
5065            style,
5066            ..FontSpec::default()
5067        }
5068    }
5069}
5070
5071impl FontSpec {
5072    /// The plain descriptor for a style: no weight override, no italic, scale 1.0.
5073    pub fn new(style: Font) -> Self {
5074        FontSpec::from(style)
5075    }
5076    /// This descriptor at a different relative size (see [`FontSpec::scale`]).
5077    pub fn scaled(mut self, scale: f64) -> Self {
5078        self.scale = scale;
5079        self
5080    }
5081    /// The point size this descriptor resolves to, given the size the platform assigns its
5082    /// semantic style. The one place `scale` is applied, so every backend's font resolver
5083    /// multiplies the same way.
5084    pub fn resolved_points(&self, style_points: f64) -> f64 {
5085        let base = match self.style {
5086            Font::System(pt) | Font::Custom(_, pt) => pt,
5087            _ => style_points,
5088        };
5089        (base * self.scale).max(1.0)
5090    }
5091}
5092
5093/// One styled span of a label's text (docs/text-runs.md).
5094///
5095/// `range` is a byte range into the label's `text`. Bytes, not chars: every backend that takes a
5096/// structured attributed string wants UTF-16 or byte offsets, `str` slicing is byte-based, and a
5097/// char count would have to be converted at every boundary. Ranges must be non-overlapping and
5098/// ascending; a gap between them is unstyled text, drawn with the label's own font.
5099///
5100/// A run says only what differs. `font` is the whole descriptor rather than a delta because a
5101/// bold run inside a Body paragraph is `FontSpec { style: Body, weight: Some(Bold), .. }`. The
5102/// style has to travel with the weight or the run loses its semantic size.
5103#[derive(Clone, Debug, PartialEq)]
5104pub struct TextRun {
5105    pub range: std::ops::Range<usize>,
5106    pub font: FontSpec,
5107    pub color: Option<Color>,
5108    /// A highlight painted behind the glyphs. Every native text editor carries this attribute
5109    /// (docs/texteditor.md), and search hits and review comments are the two things an app is
5110    /// asked for the moment it has styled text at all.
5111    pub background: Option<Color>,
5112    /// A line under the text.
5113    pub underline: Underline,
5114    /// A line through the text. Separate from `FontSpec` because it is a decoration, not a face:
5115    /// no platform expresses it by picking a different font.
5116    pub strikethrough: bool,
5117    /// Makes this run a link to the given target. Rendering it is [`Cap::TextRuns`]; making it
5118    /// activatable is [`Cap::TextLinks`], which is a smaller set (see the docs).
5119    pub link: Option<String>,
5120}
5121
5122impl Default for TextRun {
5123    fn default() -> Self {
5124        TextRun {
5125            range: 0..0,
5126            font: FontSpec::default(),
5127            color: None,
5128            background: None,
5129            underline: Underline::None,
5130            strikethrough: false,
5131            link: None,
5132        }
5133    }
5134}
5135
5136impl TextRun {
5137    /// A run that only changes the font, the common case for emphasis.
5138    pub fn font(range: std::ops::Range<usize>, font: FontSpec) -> Self {
5139        TextRun {
5140            range,
5141            font,
5142            ..TextRun::default()
5143        }
5144    }
5145
5146    /// This run's attributes without its range: what a selection's style is compared against
5147    /// and what an editor's toolbar toggles (docs/texteditor.md).
5148    pub fn style(&self) -> RunStyle {
5149        RunStyle {
5150            font: self.font,
5151            color: self.color,
5152            background: self.background,
5153            underline: self.underline,
5154            strikethrough: self.strikethrough,
5155            link: self.link.clone(),
5156        }
5157    }
5158
5159    /// A run of `style` over `range`.
5160    pub fn styled(range: std::ops::Range<usize>, style: RunStyle) -> Self {
5161        TextRun {
5162            range,
5163            font: style.font,
5164            color: style.color,
5165            background: style.background,
5166            underline: style.underline,
5167            strikethrough: style.strikethrough,
5168            link: style.link,
5169        }
5170    }
5171}
5172
5173/// Serialize a label's text + runs as markup, for the two backends whose text widgets take a
5174/// string in their own dialect rather than a structured attributed string: GTK (Pango markup)
5175/// and Qt (its HTML subset). Shared so the escaping is written and tested once: a translated
5176/// string containing `&` or `<` corrupts the whole label otherwise, and it will be a translated
5177/// string that finds the bug.
5178///
5179/// `dialect` decides the spelling: Pango wants `<span foreground="#rrggbb"
5180/// font_family="monospace">`, Qt wants `<span style="color:#rrggbb">` with `<code>`. Both accept
5181/// `<b>`, `<i>`, `<u>`, `<s>` and `<a href>`, which is why one function can serve them.
5182///
5183/// `base_points` is the size the label itself resolved to. Only a relative-size run
5184/// ([`FontSpec::scale`]) needs it, and only in the Qt dialect: Pango markup takes a percentage
5185/// directly, while Qt's CSS subset understands `pt` and `px` and ignores `%`.
5186pub fn runs_to_markup(
5187    text: &str,
5188    runs: &[TextRun],
5189    dialect: MarkupDialect,
5190    base_points: f64,
5191) -> String {
5192    let mut out = String::with_capacity(text.len() + runs.len() * 24);
5193    let mut at = 0usize;
5194    for r in runs {
5195        // Skip a run whose range does not address this string, without advancing `at`: dropping
5196        // the run loses styling, but advancing past it would drop the rest of the sentence.
5197        // `runs_are_valid` rejects these upstream; this keeps the failure cheap if one slips by.
5198        let Some(styled) = text.get(r.range.clone()) else {
5199            continue;
5200        };
5201        if r.range.start > at
5202            && let Some(plain) = text.get(at..r.range.start)
5203        {
5204            escape_markup_in(plain, &mut out, dialect);
5205        }
5206        open_run(r, dialect, base_points, &mut out);
5207        escape_markup_in(styled, &mut out, dialect);
5208        close_run(r, dialect, &mut out);
5209        at = r.range.end;
5210    }
5211    if let Some(tail) = text.get(at..) {
5212        escape_markup_in(tail, &mut out, dialect);
5213    }
5214    out
5215}
5216
5217impl Symbol {
5218    /// A 24×24 outline path for this symbol, Day's own drawing of it.
5219    ///
5220    /// The platform's icon comes first on every backend that has one (SF Symbols, the freedesktop
5221    /// icon theme, Fluent's glyph font). This is the fallback for when that lookup finds nothing,
5222    /// which is not an edge case: `view-filter-symbolic` exists on a GNOME desktop and nowhere
5223    /// else, so a GTK or Qt app run off that desktop drew a toolbar item with no icon at all.
5224    /// One table here means the same fallback shape everywhere instead of a gap per platform.
5225    ///
5226    /// `None` for a variant with no drawing yet: `Symbol` is `#[non_exhaustive]`, so a new one
5227    /// degrades to a label rather than failing to compile.
5228    pub fn outline_path(self) -> Option<&'static str> {
5229        use Symbol as S;
5230        // The wildcard is unreachable today (every variant is drawn below) and it is here for
5231        // the next one, which should degrade to a label rather than fail to compile.
5232        #[allow(unreachable_patterns)]
5233        Some(match self {
5234            S::Add => "M11 5h2v6h6v2h-6v6h-2v-6H5v-2h6z",
5235            S::Remove => "M5 11h14v2H5z",
5236            S::Delete => "M9 3h6l1 2h4v2H4V5h4zM6 8h12l-1 13H7z",
5237            S::Edit => {
5238                "M3 17.3V21h3.7L17.8 9.9l-3.7-3.7zM20.7 7a1 1 0 0 0 0-1.4l-2.3-2.3a1 1 0 0 0-1.4 0l-1.8 1.8 3.7 3.7z"
5239            }
5240            S::New => "M13 2H6v20h12V7h-5zm2 .5L19.5 7H15z",
5241            S::Open => "M2 5h7l2 2h11v12H2z",
5242            S::Save => "M3 3h13l5 5v13H3zm5 0h7v6H8zm-1 11h10v7H7z",
5243            S::Print => "M7 3h10v4H7zM4 8h16v8h-3v6H7v-6H4z",
5244            S::Refresh => "M12 4V1L8 5l4 4V6a6 6 0 1 1-6 6H4a8 8 0 1 0 8-8z",
5245            S::Search => {
5246                "M10 4a6 6 0 1 0 3.5 10.9l4.8 4.8 1.4-1.4-4.8-4.8A6 6 0 0 0 10 4m0 2a4 4 0 1 1 0 8 4 4 0 0 1 0-8"
5247            }
5248            S::Share => "M12 3l5 5h-3v7h-4V8H7zM5 17h14v4H5z",
5249            // An eight-tooth gear, round about the center (radius 10 at the teeth, 7.6 at the
5250            // roots) with a 3.2 hole: square in its box, so it sits in a row's 24-point icon
5251            // slot at the same size as its neighbors.
5252            S::Settings => {
5253                "M9.9 4.7L10.2 2.2L13.8 2.2L14.1 4.7A7.6 7.6 0 0 1 15.7 5.4L17.7 3.8L20.2 6.3L18.6 8.3A7.6 7.6 0 0 1 19.3 9.9L21.8 10.2L21.8 13.8L19.3 14.1A7.6 7.6 0 0 1 18.6 15.7L20.2 17.7L17.7 20.2L15.7 18.6A7.6 7.6 0 0 1 14.1 19.3L13.8 21.8L10.2 21.8L9.9 19.3A7.6 7.6 0 0 1 8.3 18.6L6.3 20.2L3.8 17.7L5.4 15.7A7.6 7.6 0 0 1 4.7 14.1L2.2 13.8L2.2 10.2L4.7 9.9A7.6 7.6 0 0 1 5.4 8.3L3.8 6.3L6.3 3.8L8.3 5.4A7.6 7.6 0 0 1 9.9 4.7zM12 8.8a3.2 3.2 0 1 0 0 6.4a3.2 3.2 0 1 0 0-6.4z"
5254            }
5255            S::Info => "M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20m-1 5h2v2h-2zm0 4h2v6h-2z",
5256            S::Star => {
5257                "M12 2l2.9 6.3 6.9.8-5.1 4.7 1.4 6.8L12 17.3 5.9 20.6l1.4-6.8L2.2 9.1l6.9-.8z"
5258            }
5259            S::Bookmark => "M6 2h12v20l-6-4-6 4z",
5260            S::Back => "M15.4 5.4L14 4l-8 8 8 8 1.4-1.4L8.8 12z",
5261            S::Forward => "M8.6 5.4L10 4l8 8-8 8-1.4-1.4L15.2 12z",
5262            S::Up => "M5.4 15.4L4 14l8-8 8 8-1.4 1.4L12 8.8z",
5263            S::Down => "M5.4 8.6L4 10l8 8 8-8-1.4-1.4L12 15.2z",
5264            S::Home => "M12 3l9 8h-3v10h-5v-6h-2v6H6V11H3z",
5265            S::Sidebar => "M3 4h18v16H3zm2 2v12h4V6zm6 0v12h8V6z",
5266            S::Filter => "M3 5h18l-7 8v6l-4 2v-8z",
5267            S::Sort => "M8 4v12H4l4 4 4-4H10V4zM14 6h7v2h-7zm0 4h5v2h-5zm0 4h3v2h-3z",
5268            S::More => {
5269                "M6 10a2 2 0 1 0 0 4 2 2 0 0 0 0-4m6 0a2 2 0 1 0 0 4 2 2 0 0 0 0-4m6 0a2 2 0 1 0 0 4 2 2 0 0 0 0-4"
5270            }
5271            S::Play => "M8 5l11 7-11 7z",
5272            S::Pause => "M7 5h3v14H7zm7 0h3v14h-3z",
5273            S::Stop => "M6 6h12v12H6z",
5274            S::Camera => {
5275                "M9 3h6l1.5 2H20a2 2 0 0 1 2 2v12H2V7a2 2 0 0 1 2-2h3.5zm3 6a4.5 4.5 0 1 0 0 9 4.5 4.5 0 0 0 0-9m0 1.6a2.9 2.9 0 1 1 0 5.8 2.9 2.9 0 0 1 0-5.8"
5276            }
5277            S::Code => {
5278                "M9.4 16.6L4.8 12l4.6-4.6L8 6l-6 6 6 6zm5.2 0l4.6-4.6L14.6 7.4 16 6l6 6-6 6z"
5279            }
5280            S::Light => {
5281                "M12 7a5 5 0 1 0 0 10 5 5 0 0 0 0-10M11 1h2v3h-2zm0 19h2v3h-2zM1 11h3v2H1zm19 0h3v2h-3zM3.5 4.9l1.4-1.4 2.1 2.1-1.4 1.4zm13.5 13.5l1.4-1.4 2.1 2.1-1.4 1.4zM4.9 20.5l-1.4-1.4 2.1-2.1 1.4 1.4zm13.5-13.5l-1.4-1.4 2.1-2.1 1.4 1.4z"
5282            }
5283            S::Dark => "M12 3a9 9 0 1 0 9 9 7 7 0 0 1-9-9",
5284            // "Automatic": the half-filled circle every platform uses: the left half solid, the
5285            // right half an outline, which `evenodd` below turns into the ring it should be.
5286            S::Auto => "M12 2a10 10 0 0 0 0 20zM12 4a8 8 0 0 1 0 16v-1.6a6.4 6.4 0 0 0 0-12.8z",
5287            S::ZoomIn => {
5288                "M10 4a6 6 0 1 0 3.5 10.9l4.8 4.8 1.4-1.4-4.8-4.8A6 6 0 0 0 10 4M9 7h2v2h2v2h-2v2H9v-2H7V9h2z"
5289            }
5290            S::ZoomOut => {
5291                "M10 4a6 6 0 1 0 3.5 10.9l4.8 4.8 1.4-1.4-4.8-4.8A6 6 0 0 0 10 4M7 9h6v2H7z"
5292            }
5293            // The ZoomIn lens with a numeral 1 in it, "back to actual size".
5294            S::ZoomReset => {
5295                "M10 4a6 6 0 1 0 3.5 10.9l4.8 4.8 1.4-1.4-4.8-4.8A6 6 0 0 0 10 4M12 13h-1.7V9.3l-1.2.8-.9-1.3 2.4-1.8H12z"
5296            }
5297            S::Undo => "M12 5V2L7 7l5 5V9a5 5 0 1 1 0 10H8v2h4a7 7 0 0 0 0-14z",
5298            S::Redo => "M12 5V2l5 5-5 5V9a5 5 0 1 0 0 10h4v2h-4a7 7 0 0 1 0-14z",
5299            S::Copy => "M8 2h10v14H8zM4 6h2v14h12v2H4z",
5300            S::Cut => {
5301                "M9 4l5.5 9.5-1.2 2L7.8 6zM15 4L9.5 13.5l1.2 2L16.2 6zM6 16a3 3 0 1 0 0 6 3 3 0 0 0 0-6m12 0a3 3 0 1 0 0 6 3 3 0 0 0 0-6"
5302            }
5303            S::Paste => "M9 2h6v2h3v18H6V4h3zm0 2v2h6V4z",
5304            S::Mail => "M2 5h20v14H2zm2 3.2V17h16V8.2l-8 5z",
5305            S::Folder => "M2 5h7l2 2h11v12H2z",
5306            S::Document => "M14 2H6v20h12V6zm.5.8L17.2 6H14.5z",
5307            S::Check => "M9 16.2L4.8 12l-1.4 1.4L9 19 21 7l-1.4-1.4z",
5308            S::Close => {
5309                "M19 6.4L17.6 5 12 10.6 6.4 5 5 6.4 10.6 12 5 17.6 6.4 19 12 13.4 17.6 19 19 17.6 13.4 12z"
5310            }
5311            S::Warning => "M12 2l10 19H2zm-1 7h2v6h-2zm0 8h2v2h-2z",
5312            // Shape outlines, stroked as a 2-unit band (an even-odd ring), so they read as
5313            // outlines at any size rather than filled blocks.
5314            S::Rectangle => "M3 5h18v14H3zm2 2v10h14V7z",
5315            S::Oval => {
5316                "M12 5c5 0 9 3.1 9 7s-4 7-9 7-9-3.1-9-7 4-7 9-7zm0 2c-3.9 0-7 2.2-7 5s3.1 5 7 5 7-2.2 7-5-3.1-5-7-5z"
5317            }
5318            // A circular ring (even-odd: outer disc minus inner) and its filled twin.
5319            S::Circle => "M12 4a8 8 0 1 0 0 16 8 8 0 0 0 0-16zm0 2a6 6 0 1 1 0 12 6 6 0 0 1 0-12z",
5320            S::CircleFilled => "M12 4a8 8 0 1 0 0 16 8 8 0 0 0 0-16z",
5321            S::Line => "M4.7 17.9l13.2-13.2 1.4 1.4L6.1 19.3z",
5322            // Group: a solid square stacked on another (the visible L of the back one);
5323            // Ungroup: the same two squares pulled apart.
5324            S::Group => "M8 4h12v12h-4V8H8zM4 8h12v12H4z",
5325            S::Ungroup => "M3 3h8v8H3zM13 13h8v8h-8z",
5326            // `Symbol` is `#[non_exhaustive]`: a variant added upstream draws nothing here rather
5327            // than failing to compile, and its item keeps its label.
5328            _ => return None,
5329        })
5330    }
5331
5332    /// [`Self::outline_path`] wrapped as a standalone SVG document.
5333    pub fn outline_svg(self) -> Option<String> {
5334        self.outline_path().map(|d| {
5335            format!(
5336                "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\">\
5337                 <path fill-rule=\"evenodd\" d=\"{d}\"/></svg>"
5338            )
5339        })
5340    }
5341}
5342
5343/// Which markup dialect [`runs_to_markup`] should emit.
5344#[derive(Clone, Copy, Debug, PartialEq, Eq)]
5345pub enum MarkupDialect {
5346    /// Pango markup (GTK).
5347    Pango,
5348    /// Qt's rich-text HTML subset.
5349    QtHtml,
5350}
5351
5352/// XML-escape a run of plain text into `out`. `&` first, or the escapes escape each other.
5353///
5354/// Newlines are dialect-specific. Pango lays a literal `\n` out as a line break, but Qt's rich
5355/// text is HTML, where any run of whitespace collapses to one space, which silently ran a
5356/// two-paragraph body together into a single block. `<br>` is what says "break here" in HTML, so
5357/// the same string wraps the same way on both.
5358fn escape_markup_in(s: &str, out: &mut String, dialect: MarkupDialect) {
5359    for c in s.chars() {
5360        match c {
5361            '&' => out.push_str("&amp;"),
5362            '<' => out.push_str("&lt;"),
5363            '>' => out.push_str("&gt;"),
5364            '"' => out.push_str("&quot;"),
5365            '\'' => out.push_str("&#39;"),
5366            '\n' if dialect == MarkupDialect::QtHtml => out.push_str("<br>"),
5367            _ => out.push(c),
5368        }
5369    }
5370}
5371
5372/// [`escape_markup_in`] for the URL slot, where a newline cannot appear.
5373fn escape_markup(s: &str, out: &mut String) {
5374    escape_markup_in(s, out, MarkupDialect::Pango);
5375}
5376
5377fn hex(c: Color) -> String {
5378    format!(
5379        "#{:02x}{:02x}{:02x}",
5380        (c.r.clamp(0.0, 1.0) * 255.0) as u8,
5381        (c.g.clamp(0.0, 1.0) * 255.0) as u8,
5382        (c.b.clamp(0.0, 1.0) * 255.0) as u8
5383    )
5384}
5385
5386/// Does this run need a `<span>` in `dialect`, or do the plain tags cover it?
5387fn needs_span(r: &TextRun, dialect: MarkupDialect) -> bool {
5388    let extras = r.color.is_some() || r.background.is_some() || r.font.scale != 1.0;
5389    match dialect {
5390        // Pango expresses the monospace family as a span attribute; Qt cannot (see below).
5391        MarkupDialect::Pango => extras || r.font.monospace,
5392        MarkupDialect::QtHtml => extras,
5393    }
5394}
5395
5396fn open_run(r: &TextRun, dialect: MarkupDialect, base_points: f64, out: &mut String) {
5397    if let Some(url) = r.link.as_deref() {
5398        out.push_str("<a href=\"");
5399        escape_markup(url, out);
5400        out.push_str("\">");
5401    }
5402    // The span carries the attributes that have no tag: colors, relative size, and (on Pango)
5403    // the monospace family. Bold/italic/strike/underline are tags in both dialects, which keeps
5404    // the attribute string short and the escaping trivial.
5405    let color = r.color.map(hex);
5406    let background = r.background.map(hex);
5407    let mono = r.font.monospace;
5408    if needs_span(r, dialect) {
5409        match dialect {
5410            MarkupDialect::Pango => {
5411                out.push_str("<span");
5412                if let Some(c) = &color {
5413                    out.push_str(&format!(" foreground=\"{c}\""));
5414                }
5415                if let Some(c) = &background {
5416                    out.push_str(&format!(" background=\"{c}\""));
5417                }
5418                if mono {
5419                    out.push_str(" font_family=\"monospace\"");
5420                }
5421                if r.font.scale != 1.0 {
5422                    // Absolute, in Pango units (1/1024 pt), not the `font_scale`/`size="N%"`
5423                    // pair: those are Pango 1.50 attributes, and a Pango that does not know an
5424                    // attribute fails the whole markup parse, which renders the label empty
5425                    // rather than merely unscaled. A point size still tracks the desktop's text
5426                    // scaling, since Pango resolves points through the Xft DPI.
5427                    let pts = r.font.resolved_points(base_points);
5428                    out.push_str(&format!(" size=\"{}\"", (pts * 1024.0).round() as i64));
5429                }
5430                out.push('>');
5431            }
5432            MarkupDialect::QtHtml => {
5433                // Color only. Qt's rich text does not resolve the generic `monospace` family
5434                // from a style attribute (it rendered proportional), so the fixed face comes
5435                // from the `<code>` tag below, which Qt maps to its own fixed font.
5436                out.push_str("<span style=\"");
5437                if let Some(c) = &color {
5438                    out.push_str(&format!("color:{c};"));
5439                }
5440                if let Some(c) = &background {
5441                    out.push_str(&format!("background-color:{c};"));
5442                }
5443                if r.font.scale != 1.0 {
5444                    // Points, not a percentage: Qt's rich-text CSS subset understands `pt` and
5445                    // `px` and silently ignores `%`, which is why a scaled run rendered at the
5446                    // base size here while every other backend scaled it.
5447                    let pt = r.font.resolved_points(base_points);
5448                    out.push_str(&format!("font-size:{pt}pt;"));
5449                }
5450                out.push_str("\">");
5451            }
5452        }
5453    }
5454    if dialect == MarkupDialect::QtHtml && mono {
5455        out.push_str("<code>");
5456    }
5457    if r.font.weight.is_some_and(|w| w >= FontWeight::Semibold) {
5458        out.push_str("<b>");
5459    }
5460    if r.font.italic {
5461        out.push_str("<i>");
5462    }
5463    if r.underline.is_on() {
5464        out.push_str("<u>");
5465    }
5466    if r.strikethrough {
5467        out.push_str("<s>");
5468    }
5469}
5470
5471fn close_run(r: &TextRun, dialect: MarkupDialect, out: &mut String) {
5472    if r.strikethrough {
5473        out.push_str("</s>");
5474    }
5475    if r.underline.is_on() {
5476        out.push_str("</u>");
5477    }
5478    if r.font.italic {
5479        out.push_str("</i>");
5480    }
5481    if r.font.weight.is_some_and(|w| w >= FontWeight::Semibold) {
5482        out.push_str("</b>");
5483    }
5484    if dialect == MarkupDialect::QtHtml && r.font.monospace {
5485        out.push_str("</code>");
5486    }
5487    if needs_span(r, dialect) {
5488        out.push_str("</span>");
5489    }
5490    if r.link.is_some() {
5491        out.push_str("</a>");
5492    }
5493}
5494
5495/// Are these runs well formed for `text`: ascending, non-overlapping, inside the string, and on
5496/// character boundaries?
5497///
5498/// Checked once in the pieces layer rather than in eight backends. A backend handed overlapping
5499/// ranges would produce a different wrong answer per platform, and a range splitting a multi-byte
5500/// character would panic on `str` slicing in some and render mojibake in others.
5501pub fn runs_are_valid(text: &str, runs: &[TextRun]) -> Result<(), String> {
5502    let mut prev_end = 0usize;
5503    for (i, r) in runs.iter().enumerate() {
5504        if r.range.start < prev_end {
5505            return Err(format!(
5506                "run {i} starts at {} but the previous run ends at {prev_end} — runs must be \
5507                 ascending and non-overlapping",
5508                r.range.start
5509            ));
5510        }
5511        if r.range.end > text.len() {
5512            return Err(format!(
5513                "run {i} ends at {} but the text is {} bytes",
5514                r.range.end,
5515                text.len()
5516            ));
5517        }
5518        if r.range.start > r.range.end {
5519            return Err(format!("run {i} has an inverted range {:?}", r.range));
5520        }
5521        if !text.is_char_boundary(r.range.start) || !text.is_char_boundary(r.range.end) {
5522            return Err(format!(
5523                "run {i} range {:?} splits a multi-byte character",
5524                r.range
5525            ));
5526        }
5527        prev_end = r.range.end;
5528    }
5529    Ok(())
5530}
5531
5532pub mod props {
5533    use super::*;
5534
5535    #[derive(Clone, Debug, Default, PartialEq)]
5536    pub struct ContainerProps {
5537        pub background: Option<Color>,
5538        pub corner_radius: f64,
5539        pub clips: bool,
5540        /// Semantic, theme-adaptive surface, mapped by each backend to a native material that
5541        /// follows the platform's light/dark appearance automatically (unlike the fixed-RGBA
5542        /// `background`, which it overrides when set).
5543        pub role: Option<super::SurfaceRole>,
5544    }
5545    /// Reactive surface update for a `background(..)` decorator whose color is a signal/closure:
5546    /// the backend re-applies the fill on the container's native backing view. Corner radius and
5547    /// clipping are fixed at realize (the `corner_radius(r)` decorator takes a plain `f64`).
5548    #[derive(Clone, Debug, PartialEq)]
5549    pub enum ContainerPatch {
5550        Background(Option<Color>),
5551    }
5552
5553    /// Realize props for a `scroll` container: which axis it scrolls. Backends create the matching
5554    /// native scroll view (vertical `UIScrollView`/`ScrollView`, horizontal
5555    /// `HorizontalScrollView`, etc.).
5556    #[derive(Clone, Debug, Default, PartialEq)]
5557    pub struct ScrollProps {
5558        pub horizontal: bool,
5559    }
5560
5561    #[derive(Clone, Debug, Default, PartialEq)]
5562    pub struct LabelProps {
5563        pub text: String,
5564        pub font: FontSpec,
5565        pub color: Option<Color>,
5566        /// What the text means, for the color the platform gives it (docs/text.md).
5567        ///
5568        /// Semantic the way [`crate::Font`] is semantic: an app says "this is secondary text"
5569        /// and each platform answers with its own answer: `secondaryLabelColor` on Apple,
5570        /// `?android:textColorSecondary`, GTK's `dim-label`. That is the only way a de-emphasized
5571        /// label stays correct in both appearances, since a literal grey that reads well on white
5572        /// is wrong on black. An explicit [`Self::color`] still wins; a backend that has no such
5573        /// color renders primary, which is legible and correct, just not dimmed.
5574        pub role: TextRole,
5575        pub wraps: bool,
5576        /// Maximum rendered lines; zero is unlimited. Native Apple plain labels truncate
5577        /// the last line. Other backends may retain their ordinary wrapping behavior.
5578        pub max_lines: u32,
5579        /// How the label's lines sit within its own width. Only observable on a label that
5580        /// wraps or carries explicit newlines: a single line fills its box, so its alignment is
5581        /// the container's business, not the label's.
5582        ///
5583        /// `Leading` is the default and is what running text wants; `Center` is for the short,
5584        /// centered block a welcome screen or an empty state uses. A backend that cannot set
5585        /// it renders leading-aligned, which is legible and correct, just not centered.
5586        pub align: TextAlign,
5587        /// Styled spans within `text` (docs/text-runs.md). Empty is the overwhelmingly common
5588        /// case and means exactly what a label has always meant: one font, one color. A backend
5589        /// that cannot draw runs ignores this and renders `text` uniformly, which is legible and
5590        /// correct, just unstyled.
5591        pub runs: Vec<crate::TextRun>,
5592    }
5593    /// What a label's text means, for the color the platform gives it (`LabelProps::role`).
5594    #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
5595    pub enum TextRole {
5596        /// Ordinary text, in the platform's primary label color.
5597        #[default]
5598        Primary,
5599        /// De-emphasized text: a caption, a hint, the "nothing selected" line an empty detail
5600        /// pane shows. Rendered in the platform's secondary label color.
5601        Secondary,
5602    }
5603
5604    /// How a wrapped label's lines sit within its width (`LabelProps::align`).
5605    ///
5606    /// There is no `Justified` variant because no toolkit in Day's set agrees on how to justify
5607    /// the last line, and the ones that can do it at all need a paragraph style the others have
5608    /// no equivalent for.
5609    #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
5610    pub enum TextAlign {
5611        /// Left in a left-to-right locale, right in an RTL one (docs/localization.md).
5612        #[default]
5613        Leading,
5614        Center,
5615        Trailing,
5616    }
5617
5618    #[derive(Clone, Debug, PartialEq)]
5619    pub enum LabelPatch {
5620        Text(String),
5621        Color(Option<Color>),
5622        Font(FontSpec),
5623        /// New text and its runs together. One patch, not two: a run's byte range is only
5624        /// meaningful against a particular string, so applying them separately would leave the
5625        /// widget briefly holding ranges that point into the wrong text.
5626        Runs(String, Vec<crate::TextRun>),
5627    }
5628
5629    /// A button's native styling tier. `Automatic` is the toolkit's stock look; `Bordered`
5630    /// asks for a visually contained button where the stock look is borderless (iOS's plain
5631    /// system button reads as a link); `Prominent` asks for the platform's accent-filled /
5632    /// default-action affordance. Toolkits whose stock buttons are already contained treat
5633    /// `Bordered` as `Automatic`.
5634    // `Eq` is gone with the `Tinted` color: `Color` holds floats. `PartialEq` is what the
5635    // props diff uses, and that is unaffected.
5636    #[derive(Clone, Copy, Debug, Default, PartialEq)]
5637    pub enum ButtonStyleSpec {
5638        #[default]
5639        Automatic,
5640        Bordered,
5641        Prominent,
5642        /// A filled button in an app-chosen color, still drawn by the native control: the
5643        /// platform keeps its own pressed, hover, focus and disabled rendering, its button role,
5644        /// and its keyboard activation. The label color is not carried; each backend picks the
5645        /// readable one for the fill (see [`ButtonStyleSpec::on_tint`]), the same call the
5646        /// platform's own tinted buttons make.
5647        ///
5648        /// A backend with no way to recolor its button ignores this and draws its ordinary
5649        /// button. That is the trade: a plain button everywhere beats a colored
5650        /// rectangle that is no longer a button on the platforms that cannot.
5651        Tinted(Color),
5652        /// A button no wider than its title asks for (a one-glyph "−" or "+" in a stepper,
5653        /// a "×" on a chip) on toolkits whose stock button carries a minimum width and wide
5654        /// insets (Material's 88 dp button turns a two-button stepper into 240 dp). The native
5655        /// control stays: only its minimum width and horizontal padding are reduced. A toolkit
5656        /// whose buttons already hug their title (AppKit, UIKit's plain button, Qt) draws its
5657        /// ordinary button.
5658        Compact,
5659        /// Borderless native action button; compact horizontal insets on Material.
5660        Plain,
5661    }
5662
5663    impl ButtonStyleSpec {
5664        /// The label color to draw on a [`ButtonStyleSpec::Tinted`] fill: whichever of black or
5665        /// white contrasts better against it, by WCAG's contrast ratio.
5666        ///
5667        /// Comparing the two ratios rather than thresholding the luminance is the difference
5668        /// between right and nearly right. A mid amber (`#F0A64C`) has a relative luminance of
5669        /// 0.44; under a "brighter than half" test it reads as dark and takes white text, at a
5670        /// hopeless 2.2:1. Its contrast against black is 9.7:1. The two ratios cross at a
5671        /// luminance of 0.179, not 0.5, and everything between those two numbers is a button
5672        /// nobody can read.
5673        pub fn on_tint(fill: Color) -> Color {
5674            // WCAG relative luminance: sRGB channels linearized, then weighted by the eye's
5675            // sensitivity to each.
5676            let lin = |c: f64| {
5677                let c = c.clamp(0.0, 1.0);
5678                if c <= 0.04045 {
5679                    c / 12.92
5680                } else {
5681                    ((c + 0.055) / 1.055).powf(2.4)
5682                }
5683            };
5684            let l = 0.2126 * lin(fill.r) + 0.7152 * lin(fill.g) + 0.0722 * lin(fill.b);
5685            // Contrast ratio is (lighter + 0.05) / (darker + 0.05); white is 1.0, black is 0.0.
5686            let against_white = 1.05 / (l + 0.05);
5687            let against_black = (l + 0.05) / 0.05;
5688            if against_black >= against_white {
5689                Color::BLACK
5690            } else {
5691                Color::WHITE
5692            }
5693        }
5694    }
5695
5696    #[derive(Clone, Debug, Default, PartialEq)]
5697    /// The visible content and accessible title of a button with an icon.
5698    pub struct ButtonContent {
5699        /// Also serves as the accessible name when only the icon is visible.
5700        pub title: String,
5701        pub icon: Option<Icon>,
5702        pub icon_only: bool,
5703    }
5704    #[derive(Clone, Debug, Default, PartialEq)]
5705    pub struct ButtonProps {
5706        pub title: String,
5707        pub icon: Option<Icon>,
5708        pub icon_only: bool,
5709        pub enabled: bool,
5710        pub style: ButtonStyleSpec,
5711        /// The button stands for the current choice among its siblings (a document tab, a
5712        /// view switcher): the platform's own checked or selected rendering, which keeps the
5713        /// button enabled, focusable and activatable, and tells assistive tech it is selected.
5714        /// A click never toggles it natively; only the app's next patch changes it.
5715        pub selected: bool,
5716    }
5717    #[derive(Clone, Debug, PartialEq)]
5718    pub enum ButtonPatch {
5719        Title(String),
5720        /// Update an icon button's label and icon together. Plain buttons still use `Title`.
5721        Content(ButtonContent),
5722        Enabled(bool),
5723        /// The selected state (see [`ButtonProps::selected`]).
5724        Selected(bool),
5725        /// A live style change: what a reactive `.tint(…)` sends, so a button can recolor with
5726        /// app state without being torn down and realized again.
5727        Style(ButtonStyleSpec),
5728    }
5729
5730    #[derive(Clone, Debug, Default, PartialEq)]
5731    pub struct ToggleProps {
5732        pub on: bool,
5733        pub enabled: bool,
5734    }
5735    #[derive(Clone, Debug, PartialEq)]
5736    pub enum TogglePatch {
5737        On(bool),
5738        Enabled(bool),
5739    }
5740
5741    #[derive(Clone, Debug, PartialEq)]
5742    pub struct SliderProps {
5743        pub value: f64,
5744        pub min: f64,
5745        pub max: f64,
5746        pub step: Option<f64>,
5747        pub enabled: bool,
5748    }
5749    impl Default for SliderProps {
5750        fn default() -> Self {
5751            SliderProps {
5752                value: 0.0,
5753                min: 0.0,
5754                max: 1.0,
5755                step: None,
5756                enabled: true,
5757            }
5758        }
5759    }
5760    #[derive(Clone, Debug, PartialEq)]
5761    pub enum SliderPatch {
5762        Value(f64),
5763        Enabled(bool),
5764    }
5765
5766    /// SwiftUI's `pickerStyle` analogue (kinds::PICKER). Each maps to a distinct native
5767    /// control per toolkit (docs/picker.md).
5768    #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
5769    pub enum PickerStyle {
5770        /// A dropdown/pop-up menu (NSPopUpButton / GtkDropDown / QComboBox / UIButton+UIMenu / Spinner).
5771        #[default]
5772        Menu,
5773        /// A horizontal segmented control (NSSegmentedControl / UISegmentedControl / linked toggles / …).
5774        Segmented,
5775        /// A vertical radio-button group laid out inline (NSButton radios / GtkCheckButton group / …).
5776        Inline,
5777    }
5778
5779    /// Full picker props (realize). `style` is set once at build; `options`, `selected` and
5780    /// `enabled` patch (via [`PickerPatch`]).
5781    #[derive(Clone, Debug, PartialEq)]
5782    pub struct PickerProps {
5783        /// Decorative boundaries before option indexes (not selectable options). Native menu
5784        /// grouping on AppKit/UIKit; ignored by other backends and non-menu styles.
5785        pub separators_before: Vec<usize>,
5786        pub options: Vec<String>,
5787        pub selected: usize,
5788        pub style: PickerStyle,
5789        /// Whether the picker takes input. Default `true`.
5790        pub enabled: bool,
5791    }
5792    impl Default for PickerProps {
5793        fn default() -> Self {
5794            PickerProps {
5795                separators_before: Vec::new(),
5796                options: Vec::new(),
5797                selected: 0,
5798                style: PickerStyle::default(),
5799                enabled: true,
5800            }
5801        }
5802    }
5803
5804    #[derive(Clone, Debug, PartialEq)]
5805    pub enum PickerPatch {
5806        Selected(usize),
5807        /// New option labels, for a picker whose choices come from data (a document list, a
5808        /// live count). The backend rebuilds its items and keeps the selected index where it
5809        /// still exists, clamping to the last option otherwise; a fresh
5810        /// [`PickerPatch::Selected`] follows whenever the app's own binding disagrees.
5811        Options(Vec<String>),
5812        /// Enable or disable the whole control, every style alike.
5813        Enabled(bool),
5814    }
5815
5816    /// Full text-area props (realize, kinds::TEXT_AREA; docs/textarea.md). `text` seeds the
5817    /// editor; `min_lines`/`max_lines` bound the auto-growing height in text lines
5818    /// (`max_lines == 0` = unbounded). `editable`/`selectable`/`spellcheck` control the native
5819    /// editor attributes (all default `true`); a backend that can't honor one answers
5820    /// `Cap::Text{Editable,Selectable,SpellCheck}` = `Unsupported`. `text` and the three attributes
5821    /// change after build (via [`TextAreaPatch`]); the rest are build-only.
5822    #[derive(Clone, Debug, PartialEq)]
5823    pub struct TextAreaProps {
5824        pub text: String,
5825        pub placeholder: String,
5826        pub min_lines: u32,
5827        pub max_lines: u32,
5828        /// Whether the user can edit the text (`false` = read-only). Default `true`.
5829        pub editable: bool,
5830        /// Whether the text can be selected (and copied). Default `true`.
5831        pub selectable: bool,
5832        /// Whether spell-check / autocorrect highlighting is on. Default `true`.
5833        pub spellcheck: bool,
5834        /// Plain Enter emits `Event::Submitted` instead of inserting a newline (Shift+Enter
5835        /// still inserts one where the platform can distinguish). Chat composers. Default
5836        /// `false`.
5837        pub submit_on_enter: bool,
5838    }
5839
5840    impl Default for TextAreaProps {
5841        fn default() -> Self {
5842            TextAreaProps {
5843                text: String::new(),
5844                placeholder: String::new(),
5845                min_lines: 1,
5846                max_lines: 0,
5847                editable: true,
5848                selectable: true,
5849                spellcheck: true,
5850                submit_on_enter: false,
5851            }
5852        }
5853    }
5854
5855    /// Imperative text-area updates: replace the text (programmatic sync), or flip one of the
5856    /// live attributes.
5857    #[derive(Clone, Debug, PartialEq)]
5858    pub enum TextAreaPatch {
5859        SetText(String),
5860        SetEditable(bool),
5861        SetSelectable(bool),
5862        SetSpellCheck(bool),
5863    }
5864
5865    #[derive(Clone, Debug, Default, PartialEq)]
5866    pub struct TextFieldProps {
5867        pub text: String,
5868        pub placeholder: String,
5869        pub enabled: bool,
5870    }
5871    #[derive(Clone, Debug, PartialEq)]
5872    pub enum TextFieldPatch {
5873        /// Origin-tagged write (§4.4): `from_native` suppresses the echo back into the widget.
5874        Text {
5875            text: String,
5876            from_native: bool,
5877        },
5878        Placeholder(String),
5879        Enabled(bool),
5880    }
5881
5882    /// How an image is scaled to fill its frame (§18.3). Maps to each toolkit's native scaling.
5883    #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
5884    pub enum ContentMode {
5885        /// Scale to fit entirely inside the frame, preserving aspect ratio (letterboxed). The
5886        /// default: an image never stretches unless asked. SwiftUI's `.scaledToFit`.
5887        #[default]
5888        Fit,
5889        /// Scale to fill the frame, preserving aspect ratio and cropping the overflow. SwiftUI's
5890        /// `.scaledToFill`.
5891        Fill,
5892        /// Stretch to fill the frame exactly, ignoring aspect ratio.
5893        Stretch,
5894    }
5895
5896    #[derive(Clone, Debug, Default, PartialEq)]
5897    pub struct ImageProps {
5898        /// Which pixels to draw: a staged asset name, encoded bytes, or an already-decoded
5899        /// image ([`ImageSource`], docs/images.md). A name resolves through the backend's own
5900        /// pipeline (§18.2); bytes and decoded ids need [`Cap::ImageDecode`], and a backend
5901        /// without it draws nothing rather than guessing.
5902        pub source: ImageSource,
5903        pub decorative: bool,
5904        /// How the image scales within its frame (default [`ContentMode::Fit`], no stretching).
5905        pub content_mode: ContentMode,
5906        /// Optional width:height ratio the view is constrained to (e.g. `16.0/9.0`). `None` lets the
5907        /// image take its allocated frame.
5908        pub aspect_ratio: Option<f64>,
5909        /// Monochrome tint for a template/vector glyph (docs/vectors.md): backends that can,
5910        /// recolor natively (template rendering on Apple, drawable tint on Android, pixel
5911        /// recolor on GTK); backends that can't yet ignore it and draw the source colors.
5912        /// `None` (the default, and every raster `image(…)`) means "as authored".
5913        pub tint: Option<Color>,
5914        /// A monochrome template glyph (docs/vectors.md "Template"): with no `tint`, it draws
5915        /// in the foreground of the surface it sits on — the theme's label color on the
5916        /// desktops, the cell's tint on UIKit, `currentColor` on the web — and follows a
5917        /// theme switch. What a sidebar row's untinted icon is: a tinted glyph recolors the
5918        /// same way with or without this, and `false` (the default) draws an untinted glyph as
5919        /// authored. [`ImagePatch::Tint`]`(None)` on a template returns to that foreground,
5920        /// not to the authored colors.
5921        pub template: bool,
5922    }
5923
5924    /// A list row's standard content (`kinds::LIST_ITEM`, docs/list.md "List items"). Empty
5925    /// text means the part is absent.
5926    #[derive(Clone, Debug, Default, PartialEq)]
5927    pub struct ListItemProps {
5928        pub title: String,
5929        /// A second line under the title, in the platform's secondary style.
5930        pub subtitle: String,
5931        /// Trailing text (a count, a value), in the platform's secondary style.
5932        pub detail: String,
5933        /// The leading glyph: a symbol or a bundled image.
5934        pub icon: Option<crate::Icon>,
5935        /// The glyph's color; `None` takes the platform's own (template glyphs follow the
5936        /// foreground, the tint color on UIKit).
5937        pub icon_tint: Option<Color>,
5938        /// A trailing check mark: the platform's selected-option mark (UIKit's checkmark
5939        /// accessory, a Material check icon), announced as checked.
5940        pub checked: bool,
5941    }
5942
5943    /// A live change to a realized list item; every part changes in place.
5944    #[derive(Clone, Debug, PartialEq)]
5945    pub enum ListItemPatch {
5946        Title(String),
5947        Subtitle(String),
5948        Detail(String),
5949        Icon(Option<crate::Icon>),
5950        IconTint(Option<Color>),
5951        Checked(bool),
5952    }
5953
5954    /// A live change to a realized image or vector glyph.
5955    ///
5956    /// `content_mode` and the rest still describe which art this is and how it fills its frame,
5957    /// which a rebuild expresses better than a patch. Two things do want to follow a signal: the
5958    /// tint (a glyph that recolors with the selection or the theme should repaint, not be torn
5959    /// down and realized again, docs/vectors.md "Tint") and the source, now that it can be a
5960    /// buffer the app swaps (a download landing, a new photo picked, docs/images.md). Replacing
5961    /// the pixels of a view that is already on screen is what a patch is for; realizing a second
5962    /// view would flash.
5963    #[derive(Clone, Debug, PartialEq)]
5964    pub enum ImagePatch {
5965        /// Recolor the glyph, or `None` to draw the authored colors again.
5966        Tint(Option<Color>),
5967        /// Draw different pixels in the same view. A backend that cannot decode the new source
5968        /// (bytes where [`Cap::ImageDecode`] is `Unsupported`) leaves what it was drawing.
5969        Source(ImageSource),
5970    }
5971
5972    #[derive(Clone, Debug, Default, PartialEq)]
5973    pub struct CanvasProps {
5974        pub ops: Vec<DrawOp>,
5975    }
5976
5977    /// Progress indicator. `value` is the completed fraction in `0.0..=1.0`; `None` means
5978    /// indeterminate (an animated spinner / busy bar, no known extent). Backends map this to
5979    /// their native determinate/indeterminate widgets (docs/progress.md).
5980    #[derive(Clone, Debug, PartialEq)]
5981    pub struct ProgressProps {
5982        pub value: Option<f64>,
5983        /// Whether an indeterminate indicator animates; ignored for a determinate bar.
5984        pub animating: bool,
5985        /// The platform's large indicator size.
5986        pub large: bool,
5987    }
5988    impl Default for ProgressProps {
5989        fn default() -> Self {
5990            Self {
5991                value: None,
5992                animating: true,
5993                large: false,
5994            }
5995        }
5996    }
5997    #[derive(Clone, Debug, PartialEq)]
5998    pub enum ProgressPatch {
5999        Animating(bool),
6000        /// New completed fraction, or `None` to switch to indeterminate.
6001        Value(Option<f64>),
6002    }
6003
6004    /// A rule between neighbors (`divider()`): a hairline across the width it is offered, or,
6005    /// with `vertical`, down the height it is offered (`divider().vertical()`, between a
6006    /// row's panes). A toolkit measures both the same way, as a horizontal rule, because the
6007    /// pieces layer swaps the axes of a vertical one's proposal and answer; realization is
6008    /// where the orientation matters, for the few widgets that draw a horizontal line
6009    /// regardless of their frame.
6010    #[derive(Clone, Debug, Default, PartialEq)]
6011    pub struct DividerProps {
6012        pub vertical: bool,
6013    }
6014
6015    /// How a navigation host lays its panes out (docs/navigation.md).
6016    ///
6017    /// This is the resolved presentation, what the toolkit must draw right now. The app asks for
6018    /// one through `Nav::presentation`, where leaving it unset means "automatic": the pieces
6019    /// layer resolves it from the window's [`SizeClass`] and the toolkit's `Cap::NavSplit`, and
6020    /// re-resolves it whenever the class changes. There is no `Auto` variant here because a
6021    /// backend can only ever draw a concrete one, so the undecided state stays on the app's side
6022    /// of the boundary where it can still be decided.
6023    #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
6024    pub enum NavPresentation {
6025        /// Sidebar beside detail, both visible (`NSSplitViewController`, nested `GtkPaned`s,
6026        /// a `NavigationView`). The sidebar pane gets its own container.
6027        Split,
6028        /// One page at a time, back-navigable. The sidebar pane becomes the stack's root.
6029        #[default]
6030        Stack,
6031        /// The rows drawn as a tab bar, one page's content beside it, no back stack: what a
6032        /// phone-shaped window wants from a one-of-N surface (`UITabBarController`, a Material
6033        /// `NavigationBarView`, `NavigationView.PaneDisplayMode = Top`). The `Pane::Sidebar` page
6034        /// is not drawn at all: its rows are the chrome.
6035        Tabs,
6036        /// The rows drawn as a narrow icon strip beside the content, the `Medium`-width answer
6037        /// between [`Self::Tabs`] and [`Self::Split`] (a Material `NavigationRailView`,
6038        /// `PaneDisplayMode = LeftCompact`, an ArkUI vertical `Tabs`).
6039        ///
6040        /// A backend with no rail rounds it to a neighbor of its own choosing rather than
6041        /// failing: UIKit answers `Tabs` (which is what iPadOS does at that width), GTK the same.
6042        /// Rounding is expected and correct; this variant is a request, not a contract.
6043        Rail,
6044    }
6045
6046    impl NavPresentation {
6047        /// `true` for [`NavPresentation::Split`], the shape most backend code wants.
6048        pub fn is_split(self) -> bool {
6049            self == NavPresentation::Split
6050        }
6051
6052        /// `true` when the rows are the chrome rather than a drawn page: [`Self::Tabs`] and
6053        /// [`Self::Rail`]. Both hide the `Pane::Sidebar` page and render its rows themselves, so
6054        /// backend code that only cares about that distinction asks this instead of matching both.
6055        pub fn rows_are_chrome(self) -> bool {
6056            matches!(self, NavPresentation::Tabs | NavPresentation::Rail)
6057        }
6058    }
6059
6060    /// Navigation host (docs/navigation.md).
6061    #[derive(Clone, Debug, Default, PartialEq)]
6062    pub struct NavProps {
6063        /// Document-tab chrome, independent of navigation destinations.
6064        pub documents: Option<DocumentTabsConfig>,
6065        pub title: String,
6066        /// The presentation to draw now; re-presented in place by [`NavPatch::Presentation`].
6067        pub presentation: NavPresentation,
6068        /// The app left the presentation automatic (`NavStyle::Automatic`, or a `Sidebar`
6069        /// nav host with no `.presentation(…)` pin), so it is free to follow the window.
6070        ///
6071        /// Only a toolkit answering `Cap::NavRepresent = Emulated` needs this: its own adaptive
6072        /// container owns the morph, and it must know whether the app asked for one at all before
6073        /// building it. With two presentations that bit could be inferred from a lowered `Split`;
6074        /// with four it cannot, so it is carried explicitly rather than encoded.
6075        ///
6076        /// `Native` re-presenters ignore it (they are told each presentation as it is resolved),
6077        /// and so do toolkits that cannot re-present.
6078        pub adaptive: bool,
6079        /// Draw the platform's own show/hide-sidebar affordance on this host's chrome
6080        /// (docs/toolbars.md). `true` for every `nav(Sidebar)` unless the app opted out:
6081        /// the toolkit owns both the button and the behavior
6082        /// (`NSToolbarToggleSidebarItemIdentifier`, the split view's collapse on GTK,
6083        /// `NavigationView.IsPaneOpen` on XAML), so an app that once declared the item by hand
6084        /// now declares nothing. Hosts with no sidebar ignore it.
6085        pub sidebar_toggle: bool,
6086        /// Search over this navigation surface (`Nav::searchable`, docs/search.md).
6087        /// `None` = the surface is not searchable and no field is rendered anywhere.
6088        pub search: Option<SearchProps>,
6089        /// `Some(width)` = this host has a content-list pane ([`Pane::List`]) at this preferred
6090        /// width in points, between the sidebar and the detail (docs/navigation.md). Read at
6091        /// realize, because a backend that builds a different container for a three-pane host
6092        /// (UIKit's triple-column style is init-only) needs to know before it builds. `None` on
6093        /// every host without one, including all of them on backends answering
6094        /// [`crate::Cap::NavContentList`] `Unsupported`: the pieces layer composes there and
6095        /// the prop is never set.
6096        pub list_width: Option<f64>,
6097        /// Whether that pane is showing for the destination the host opens on
6098        /// (`Nav::content_list_for`). Read at realize, like `list_width`, and for a
6099        /// related reason: a backend whose pane is a real split item can only honor a
6100        /// collapsed state reliably by setting it before the item joins the split. Told
6101        /// afterwards (by `NavPatch::ListVisible`, on a window that has not been displayed
6102        /// yet), AppKit reports the item collapsed and then lays it back out from its holding
6103        /// priorities, and the app opens with a list beside a page that does not own one.
6104        /// `true` on every host whose first destination has a list, and on hosts with no pane
6105        /// at all, where it means nothing.
6106        pub list_visible: bool,
6107    }
6108
6109    /// Switch only tab chrome; resident contents must retain their native identity.
6110    #[derive(Clone, Copy, Debug, PartialEq)]
6111    pub struct DocumentTabsPatch {
6112        pub native: bool,
6113    }
6114
6115    /// Localized document-tab chrome. Native callbacks emit stable route keys.
6116    #[derive(Clone, Debug, Default, PartialEq)]
6117    pub struct DocumentTabsConfig {
6118        pub new_label: String,
6119        pub close_label: String,
6120        pub can_add: bool,
6121    }
6122
6123    /// Where a searchable surface's field should be drawn.
6124    ///
6125    /// A preference, not an instruction: a backend that cannot honor the request falls back to
6126    /// whatever its platform does, exactly as SwiftUI's `searchable(placement:)` does ("depending
6127    /// on the containing view hierarchy and platform, the requested placement may not be able to
6128    /// be fulfilled"). `Automatic` is the one to reach for: it is what lets the field live in the
6129    /// window toolbar on a wide window and move into the navigation list on a narrow one.
6130    #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
6131    pub enum SearchPlacement {
6132        /// The platform decides, from the window's size class and its own convention.
6133        #[default]
6134        Automatic,
6135        /// In the window's toolbar (`NSSearchToolbarItem`, an `AdwHeaderBar` entry, a
6136        /// `CommandBar` `AutoSuggestBox`). `Inline` where the toolbar cannot hold a search field
6137        /// (`Cap::ToolbarSearch`).
6138        Toolbar,
6139        /// Attached to the navigation surface itself: above the sidebar list, or in the
6140        /// navigation bar's search drawer on the phones.
6141        Inline,
6142    }
6143
6144    /// Search over a navigation surface (docs/search.md). The query itself stays an app-owned
6145    /// signal; this carries only what the toolkit must draw, so moving the field between
6146    /// placements never moves the state.
6147    #[derive(Clone, Debug, Default, PartialEq)]
6148    pub struct SearchProps {
6149        /// Current text. Two-way: the user typing emits [`Event::SearchChanged`], and the app's
6150        /// own writes arrive as [`SearchPatch::Text`].
6151        pub text: String,
6152        /// Empty-state prompt.
6153        pub prompt: String,
6154        pub placement: SearchPlacement,
6155        /// Opt into native scrolling search/title chrome on mobile. Default false keeps
6156        /// the field pinned. Desktop toolbar search is unchanged.
6157        pub hides_on_scroll: bool,
6158        /// Start the UIKit navigation search drawer collapsed, with a compact toolbar title.
6159        /// Implies scrolling search on UIKit; ignored by other backends. Default false.
6160        pub initially_hidden: bool,
6161        /// Scope titles, empty = no scope bar. One-of-N: a `UISearchBar` scope bar, a Material
6162        /// `ChipGroup` of single-selection filter chips, an ArkUI `SegmentButtonV2`, an
6163        /// `NSSegmentedControl` (docs/search.md has the per-backend table).
6164        pub scopes: Vec<String>,
6165        /// Index into `scopes`; meaningless when `scopes` is empty.
6166        pub scope: usize,
6167        /// Completions offered for the current text. On a navigation surface these complete the
6168        /// field rather than replacing the list: the list is already the result set, so an
6169        /// overlay of results would cover the thing it is filtering.
6170        pub suggestions: Vec<String>,
6171    }
6172
6173    /// A targeted update to a live search field, the path a bound signal writes through, so
6174    /// syncing text never rebuilds (and refocuses) the field mid-word.
6175    #[derive(Clone, Debug, PartialEq)]
6176    pub enum SearchPatch {
6177        Focus,
6178        Text(String),
6179        Scope(usize),
6180        Suggestions(Vec<String>),
6181    }
6182    /// Applied to the nav host after a page child is attached / before it is removed;
6183    /// the toolkit animates its native presentation accordingly.
6184    #[derive(Clone, Debug, PartialEq)]
6185    pub enum NavPatch {
6186        /// The just-attached last page child became the top of the stack. `immersive` marks a
6187        /// page that keeps the floating transparent chrome on backends with an immersive nav
6188        /// mode (day-toolkit-material edge-to-edge today; ignored elsewhere). Unmarked pages get the
6189        /// standard opaque bar.
6190        Pushed { title: String, immersive: bool },
6191        /// The top page is about to be removed; present its predecessor.
6192        Popped,
6193        /// Current top-of-stack title changed.
6194        Title(String),
6195        /// The top page has a back guard (`NavStack::on_back`, docs/navigation.md): while `true`,
6196        /// the toolkit must not auto-pop on a native back gesture/button but route it to
6197        /// Day as `Event::NavBack { already_popped: false }` so the app's guard decides. On
6198        /// backends whose back already routes through Day (AppKit/Qt/XAML/web custom headers)
6199        /// this is a no-op; iOS disables the swipe, Android holds its callback, GTK sets the
6200        /// page `can-pop=false`, ArkUI consumes `onBackPressed`.
6201        GuardTop(bool),
6202        /// Re-present the host: the window's size class crossed a breakpoint, or the app wrote a
6203        /// new `presentation`. The page children do not change: the toolkit rebuilds its
6204        /// chrome and re-homes the pages it already has, each one landing by its
6205        /// [`NavPageProps::pane`]. Rebuilding them instead would drop scroll offsets, field
6206        /// focus, and every native animation in flight, which is why this is a patch and not
6207        /// a rebuild.
6208        ///
6209        /// Backends with no split container ignore it and stay stacked.
6210        Presentation(NavPresentation),
6211        /// Show the resident [`Pane::Detail`] page at this index, counting the host's detail
6212        /// children in attach order. The counterpart to [`Self::Pushed`]/[`Self::Popped`] for the
6213        /// presentations where pages are resident rather than stacked
6214        /// ([`NavPresentation::rows_are_chrome`]): a tab bar switches between pages that all stay
6215        /// alive, so there is nothing to push and nothing to pop.
6216        ///
6217        /// Applied without re-emitting [`crate::Event::SelectionChanged`], per the from-native
6218        /// echo rule: this is the programmatic-sync direction.
6219        ///
6220        /// A backend drawing a stacked presentation never receives it; the pieces layer sends
6221        /// push/pop there instead.
6222        Select(usize),
6223        /// Reorder resident detail pages by their previous indices without rebuilding them.
6224        Reorder(Vec<usize>),
6225        /// Show or collapse the content-list pane ([`Pane::List`]), the per-destination
6226        /// visibility switch (`Nav::content_list_for`, docs/navigation.md): a nav host
6227        /// section that spans the whole detail area (a settings page) collapses the pane, and
6228        /// selecting a list-backed section brings it back. Only sent to hosts whose
6229        /// [`NavProps::list_width`] is set. Applied directly, not through an animator proxy:
6230        /// the screenshot step captures the instant the patch returns (the sidebar-toggle rule).
6231        ListVisible(bool),
6232        /// Stacked presentations on a merged-pane backend ([`crate::Cap::NavContentList`]
6233        /// `Emulated`) only: the resident [`Pane::List`] page joins (`true`) or leaves
6234        /// (`false`) the navigation stack directly above the sidebar root. The pieces layer
6235        /// sends it as the selection gains or loses a list-backed destination; the backend
6236        /// realizes the membership change through its platform's navigation APIs (UIKit:
6237        /// `showColumn(.supplementary)` / a pop to the root; never a manual splice, which
6238        /// destroys the split's collapse bookkeeping). Never sent to `Native` backends (their
6239        /// pane persists through every presentation) nor while split.
6240        ListInStack(bool),
6241    }
6242
6243    /// Which pane of a navigation host a page belongs to (docs/navigation.md).
6244    ///
6245    /// A page's pane is a fact about the model, not about how the host currently draws it: a
6246    /// nav host's list page is [`Pane::Sidebar`] whether the toolkit is showing a sidebar beside
6247    /// a detail or stacking the two. What the presentation decides is where each pane lands:
6248    /// its own splitter pane in [`NavPresentation::Split`], the root of the stack in
6249    /// [`NavPresentation::Stack`]. Keeping the two separate is what lets a host re-present on a
6250    /// size-class change (`NavPatch::Presentation`) by re-homing the pages it already has,
6251    /// instead of tearing the tree down and rebuilding it.
6252    #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
6253    pub enum Pane {
6254        /// The list / master pane of a nav host. At most one page per host.
6255        Sidebar,
6256        /// The content-list pane between the sidebar and the detail, Mail's message list
6257        /// (`Nav::content_list`, docs/navigation.md). At most one page per host, and only
6258        /// on hosts whose [`NavProps::list_width`] is set; a backend answering
6259        /// [`crate::Cap::NavContentList`] `Unsupported` never sees one.
6260        List,
6261        /// A destination page. Every page of a `nav_stack` presentation is one of these.
6262        #[default]
6263        Detail,
6264    }
6265
6266    /// One destination's native container.
6267    #[derive(Clone, Debug, Default, PartialEq)]
6268    pub struct NavPageProps {
6269        pub title: String,
6270        pub pane: Pane,
6271    }
6272
6273    /// A fullscreen cover's content container (docs/cover.md). Realized detached and hidden;
6274    /// `CoverPatch::Present` shows it over the whole window.
6275    #[derive(Clone, Debug, Default, PartialEq)]
6276    pub struct CoverProps {
6277        /// Prefer a native sheet; toolkits without sheet presentation keep their modal cover.
6278        pub sheet: bool,
6279        /// A half-height sheet rising from the bottom edge, the window content above it
6280        /// staying on show and, where the platform allows, interactive: an inspector over the
6281        /// canvas it describes. Implies `sheet`. The toolkit reports the sheet's own content
6282        /// size through `FrameChanged`, as for any cover (docs/cover.md "Half sheet").
6283        pub half: bool,
6284    }
6285
6286    /// Applied to a `kinds::COVER` node as its bound signal opens and closes it.
6287    #[derive(Clone, Debug, PartialEq)]
6288    pub enum CoverPatch {
6289        /// Present the cover fullscreen (slide up where the platform animates modals).
6290        /// `background` paints the whole surface edge-to-edge (under the status bar / home
6291        /// indicator); the content container is inset to the safe area within it.
6292        /// `dismiss_disabled` = the user must not be able to dismiss it interactively
6293        /// (system back / sheet gestures); programmatic dismissal still works.
6294        Present {
6295            background: Option<Color>,
6296            dismiss_disabled: bool,
6297        },
6298        /// The `interactive_dismiss_disabled` state changed while presented.
6299        DismissDisabled(bool),
6300        /// Dismiss the cover. When the hide transition finishes the backend emits
6301        /// [`crate::Event::CoverHidden`] on the node (trampoline backends send
6302        /// `BridgeKind::CoverHidden`), letting the piece dispose the content only after it
6303        /// left the screen.
6304        Dismiss,
6305    }
6306
6307    /// A `kinds::INSPECTOR` split (docs/inspector.md): content beside a trailing inspector
6308    /// pane. The pane's visibility is Day-owned state (the piece's bound signal), so the
6309    /// props carry the value to draw and [`InspectorPatch::Visible`] keeps it current;
6310    /// a native affordance hiding the pane (a dock close button, a dragged-shut divider)
6311    /// reports back with [`crate::Event::InspectorChanged`].
6312    #[derive(Clone, Debug, PartialEq)]
6313    pub struct InspectorProps {
6314        /// Whether the pane is showing now.
6315        pub visible: bool,
6316        /// The pane's preferred width in points. A backend with a user-draggable divider
6317        /// treats it as the initial width; one without draws it at exactly this.
6318        pub width: f64,
6319        /// Which side of the content the pane sits on. `Trailing` is the classic inspector;
6320        /// `Leading` is a utility pane like a layer panel (docs/tree.md).
6321        pub edge: PaneEdge,
6322    }
6323
6324    /// Which side an inspector pane occupies (docs/inspector.md).
6325    #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
6326    pub enum PaneEdge {
6327        #[default]
6328        Trailing,
6329        Leading,
6330    }
6331
6332    /// Applied to a `kinds::INSPECTOR` node as its bound signal changes.
6333    #[derive(Clone, Debug, PartialEq)]
6334    pub enum InspectorPatch {
6335        /// Show or hide the pane (animated where the platform animates panes). The
6336        /// programmatic-sync direction: applying it must not re-emit
6337        /// [`crate::Event::InspectorChanged`], per the from-native echo rule.
6338        Visible(bool),
6339    }
6340
6341    /// One pane of a `kinds::INSPECTOR` split (docs/inspector.md).
6342    #[derive(Clone, Debug, Default, PartialEq)]
6343    pub struct InspectorPaneProps {
6344        /// `true` for the inspector panel itself, `false` for the content pane.
6345        pub panel: bool,
6346    }
6347
6348    /// How a `kinds::SPLIT` lays its two panes (docs/split.md): beside each other, the divider
6349    /// standing, or stacked, the divider lying. Named by where the panes go, since that is
6350    /// what an app's "layout" toggle means; the divider runs the other way.
6351    #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
6352    pub enum SplitAxis {
6353        /// The panes side by side (first leading), the divider vertical.
6354        #[default]
6355        Horizontal,
6356        /// The panes stacked (first on top), the divider horizontal.
6357        Vertical,
6358    }
6359
6360    /// A `kinds::SPLIT` (docs/split.md): two panes with a divider the user drags. The first
6361    /// pane's share is Day-owned state (the piece's bound signal): the props carry the value
6362    /// to draw, [`SplitPatch::Fraction`] keeps it current, and a divider the user moved
6363    /// reports back as [`crate::Event::ValueChanged`] with the new share, which applying the
6364    /// patch must never re-emit (the from-native echo rule).
6365    #[derive(Clone, Debug, PartialEq)]
6366    pub struct SplitProps {
6367        pub axis: SplitAxis,
6368        /// The first pane's share of the split's length along `axis`, `0.0..=1.0`.
6369        pub fraction: f64,
6370        /// The least length either pane may be dragged to, in points.
6371        pub min_pane: f64,
6372    }
6373
6374    /// Applied to a `kinds::SPLIT` node as its bound signals change.
6375    #[derive(Clone, Debug, PartialEq)]
6376    pub enum SplitPatch {
6377        /// Turn the split the other way, keeping the share.
6378        Axis(SplitAxis),
6379        /// Move the divider so the first pane takes this share. The programmatic-sync
6380        /// direction: applying it must not re-emit [`crate::Event::ValueChanged`].
6381        Fraction(f64),
6382    }
6383
6384    /// One pane of a `kinds::SPLIT` (docs/split.md).
6385    #[derive(Clone, Debug, Default, PartialEq)]
6386    pub struct SplitPaneProps {
6387        /// `false` for the first pane, `true` for the second.
6388        pub second: bool,
6389    }
6390
6391    /// Native navigation item list. `items` are display titles in route order;
6392    /// `selected` highlights the active route (split presentation; None on mobile roots).
6393    /// `icons` (parallel to `items`, `None` = no icon) are bundled image names resolved by each
6394    /// backend via `resource::resolve_image_file`; a backend that can't decorate its rows just
6395    /// ignores them.
6396    #[derive(Clone, Debug, Default, PartialEq)]
6397    pub struct NavMenuProps {
6398        /// Stable document identities, parallel to items; empty for ordinary navigation.
6399        pub document_keys: Vec<String>,
6400        pub items: Vec<String>,
6401        pub icons: Vec<Option<String>>,
6402        /// A trailing accessory per row: an unread count, a status. Rendered right-aligned and
6403        /// de-emphasized, opposite the label. Parallel to `items`; `None` draws nothing.
6404        pub badges: Vec<Option<String>>,
6405        /// A trailing symbol accessory per row, in the same slot as [`Self::badges`] and drawn
6406        /// after it: a bundled image name resolved the way [`Self::icons`] is. This is what a
6407        /// row-level status glyph rides on (a starred page's star) where `badges` carries only
6408        /// text. Parallel to `items`; `None` draws nothing.
6409        pub badge_icons: Vec<Option<String>>,
6410        /// The tint for [`Self::badge_icons`], parallel to `items`. `None` leaves the glyph at
6411        /// the backend's neutral template tint; a status glyph that means something (a yellow
6412        /// star) names its own color here, exactly as [`Self::tints`] does for the leading icon.
6413        pub badge_tints: Vec<Option<Color>>,
6414        /// A per-row icon tint (docs/vectors.md): the row's glyph recolored to this instead of
6415        /// the backend's neutral template tint. Parallel to `items`; `None` keeps the default.
6416        pub tints: Vec<Option<Color>>,
6417        /// A section header introducing the row at the same index. `Some` opens a new group
6418        /// before that row; `None` continues the current one. Parallel to `items`, so adding a
6419        /// header never shifts the selection indices the rows are addressed by.
6420        pub sections: Vec<Option<String>>,
6421        /// A per-row context menu (docs/menus.md): shown on secondary-click / long-press on
6422        /// that row, the same [`crate::MenuItem`] model as everywhere else: items carry
6423        /// registered action ids, so a chosen entry dispatches [`crate::Event::MenuAction`]
6424        /// exactly like a piece context menu. Parallel to `items`; empty = no menu.
6425        pub menus: Vec<Vec<crate::MenuItem>>,
6426        /// Which rows drag to reorder (docs/navigation.md "Reordering sidebar destinations"),
6427        /// parallel to `items`; empty when the sidebar does not reorder. A row moves only
6428        /// within the run of movable rows around it, which a section header or a fixed row
6429        /// ends. A toolkit whose `Cap::NavReorder` is `Native` drags the rows in its own
6430        /// sidebar and reports a drop as [`crate::Event::ListReorder`] in item indices: `from`
6431        /// is the moved row, `to` the row whose position it takes, in the order before the move.
6432        pub movable: Vec<bool>,
6433        /// The sidebar regroups (docs/navigation.md "Moving rows between sections"): a movable
6434        /// row may also cross section headers, into any section whose rows are all movable. A
6435        /// toolkit whose `Cap::NavReorder` is `Native` offers those drops too and reports every
6436        /// drop as [`crate::Event::NavRegroup`] instead of [`crate::Event::ListReorder`]. `false`
6437        /// keeps [`Self::movable`]'s run rule.
6438        pub regroup: bool,
6439        /// Which movable rows keep the app's sorted order within their own section, parallel to
6440        /// `items`; empty when the sidebar does not regroup. Under [`Self::regroup`], a sorted
6441        /// row drops only into another section: no gap in its own section is offered.
6442        pub sorted: Vec<bool>,
6443        pub selected: Option<usize>,
6444    }
6445    #[derive(Clone, Debug, PartialEq)]
6446    // `Items` carries every row array; a patch travels boxed (`Box<dyn Any>`) and lives for one
6447    // update, so the variants' size difference costs nothing worth an indirection.
6448    #[allow(clippy::large_enum_variant)]
6449    pub enum NavMenuPatch {
6450        /// Active icon overlays by row index: None spins, Some(0..=1) fills a circle.
6451        /// Rows absent from this complete snapshot have no overlay. Does not relayout rows.
6452        /// Currently rendered by AppKit/UIKit; other backends may ignore this decoration.
6453        IconProgress(Vec<(usize, Option<f64>)>),
6454        /// Programmatic highlight sync: toolkits apply without re-emitting
6455        /// SelectionChanged (the TextField from_native echo rule).
6456        Selected(Option<usize>),
6457        /// The item set changed (data-driven `nav().items(signal, …)`): rebuild the rows
6458        /// from these labels/icons, then apply `selected` (docs/navigation.md). Applied without
6459        /// re-emitting SelectionChanged.
6460        ///
6461        /// `rows_changed` is `Some` when the rows themselves are the ones already shown (same
6462        /// keys, sections and row menus) and only these rows' title, icon, tint or badge moved:
6463        /// a count ticking, a language change. A backend updates those rows in place (a tab's
6464        /// badge, a menu item's title) rather than rebuilding, which on most platforms re-lays
6465        /// the bar and replays its selection animation; one that cannot rebuilds as for `None`.
6466        Items {
6467            rows_changed: Option<Box<[usize]>>,
6468            document_keys: Box<[String]>,
6469            items: Vec<String>,
6470            icons: Vec<Option<String>>,
6471            badges: Vec<Option<String>>,
6472            badge_icons: Vec<Option<String>>,
6473            badge_tints: Vec<Option<Color>>,
6474            sections: Vec<Option<String>>,
6475            tints: Vec<Option<Color>>,
6476            menus: Vec<Vec<crate::MenuItem>>,
6477            /// [`NavMenuProps::movable`] for the new rows.
6478            movable: Vec<bool>,
6479            /// [`NavMenuProps::sorted`] for the new rows ([`NavMenuProps::regroup`] holds for
6480            /// the sidebar's life).
6481            sorted: Vec<bool>,
6482            selected: Option<usize>,
6483        },
6484    }
6485
6486    /// How a recycling list sizes its rows (docs/list.md).
6487    #[derive(Clone, Copy, Debug, PartialEq, Default)]
6488    pub enum RowHeight {
6489        /// Every row is this tall, a true layout boundary; the fastest path.
6490        Uniform(f64),
6491        /// Rows self-size from their content (host re-measures on change; slower).
6492        #[default]
6493        Automatic,
6494    }
6495
6496    /// Configure a navigation sidebar's native pull-to-refresh control. Not a synthetic pan:
6497    /// supported toolkits own gesture arbitration and the progress indicator. A false state
6498    /// ends a pull, including a request the application declines. Never emits an event.
6499    #[derive(Clone, Copy, Debug, PartialEq, Eq)]
6500    pub struct RefreshPatch {
6501        pub refreshing: bool,
6502    }
6503
6504    /// Native recycling list (docs/list.md). The host owns scrolling + cell reuse; Day supplies
6505    /// row content on demand through the injected `ListSource` (see `Toolkit::attach_list`).
6506    #[derive(Clone, Debug, Default, PartialEq)]
6507    pub struct ListProps {
6508        /// Whether the app handles row activation. Do not claim activation keys when false.
6509        pub activatable: bool,
6510        pub row_height: RowHeight,
6511        /// Whether the native list reports row selection (`Event::SelectionChanged` with the row).
6512        pub selectable: bool,
6513        /// Whether the native list allows selecting several rows at once. Where honored
6514        /// (docs/list.md has the matrix), every selection change reports the full set via
6515        /// `Event::SelectionSet`; single-selection backends keep reporting
6516        /// `Event::SelectionChanged` and treat this as `selectable`.
6517        pub multi_select: bool,
6518        /// Whether rows can be drag-reordered. The toolkit enables its native drag machinery and
6519        /// drives `ListSource::reorder` (validate via `can_move`, commit via `move_row`);
6520        /// probe `Cap::ListReorder` for per-backend support (docs/list.md).
6521        pub reorderable: bool,
6522        /// Whether rows can be deleted by the platform's own delete gesture: a trailing swipe on
6523        /// iOS and ArkUI, an `ItemTouchHelper` swipe on Android. The toolkit drives
6524        /// `ListSource::delete` (offer via `can_delete`, commit via `delete_row`); probe
6525        /// `Cap::ListDelete` for per-backend support (docs/list.md). Desktop toolkits have no such
6526        /// gesture and answer `Unsupported`; an app that must delete everywhere pairs this with an
6527        /// explicit control (a menu item, a button) rather than relying on the swipe.
6528        pub deletable: bool,
6529        /// The label on that affordance, already localized by the app. A toolkit cannot invent
6530        /// translated text (it has no access to the app's catalog), so the string arrives with
6531        /// the props. Empty means "no text": each backend falls back to its platform's own
6532        /// wordless idiom (a trash glyph), which works in every language.
6533        pub delete_label: String,
6534        /// The rows offer edge swipe actions (docs/list.md) through the injected
6535        /// `ListSource::swipe` (offer via `actions_at`, commit via `perform`); probe
6536        /// [`crate::Cap::ListSwipeActions`] for per-backend support. A backend without the
6537        /// affordance never shows one; an app that must offer the command everywhere
6538        /// pairs the swipe with an explicit control, exactly as `deletable` documents.
6539        pub swipe_actions: bool,
6540        /// Row separators, drawn by the host at the row boundary (docs/list.md), so they
6541        /// align with the native selection and ride the platform's own row animations (a
6542        /// macOS/iOS swipe slides the row content past a stationary separator, exactly as
6543        /// Mail's does). `None` keeps each platform's own default (iOS draws them, the
6544        /// desktops don't); `Some(true)`/`Some(false)` forces. A backend without a separator
6545        /// mechanism ignores a force (the matrix in docs/list.md says which); rows there
6546        /// separate by their pitch alone.
6547        pub separators: Option<bool>,
6548        /// The list starts in the platform's editing mode (docs/list.md): delete controls on
6549        /// the rows `deletable` offers, reorder grips on the rows `reorderable` moves, and a
6550        /// tap that still activates. Later changes arrive as [`ListPatch::Editing`]. Probe
6551        /// [`crate::Cap::ListEditing`]; a backend without an editing mode ignores it.
6552        pub editing: bool,
6553        /// The rows fall into sections, each opened by a header row (docs/list.md
6554        /// "Sections"): `ListSource::is_header` says which rows. Probe
6555        /// [`crate::Cap::ListSections`] for whether headers float natively.
6556        pub sections: bool,
6557    }
6558
6559    /// One row-level result-set change. Indexes are sequential (each delta describes the
6560    /// set as the previous ones left it), so a host applies them one at a time (or falls back
6561    /// to a reload; the source's snapshot already holds the final state either way).
6562    #[derive(Clone, Copy, Debug, PartialEq, Eq)]
6563    pub enum RowDelta {
6564        Insert(usize),
6565        Remove(usize),
6566        Move(usize, usize),
6567    }
6568
6569    /// Immutable-at-realize props of a `kinds::TREE` host (docs/tree.md). Everything that
6570    /// changes after realize flows through [`TreePatch`] or the injected `TreeSource`.
6571    #[derive(Clone, Debug, PartialEq)]
6572    pub struct TreeProps {
6573        /// Two-level sectioned list: root branches are non-selectable headings.
6574        pub section_headers: bool,
6575        pub row_height: RowHeight,
6576        /// Rows highlight and report selection ([`crate::Event::TreeSelection`]).
6577        pub selectable: bool,
6578        /// Several rows may be selected at once; every change reports the full token set.
6579        pub multi_select: bool,
6580        /// Rows drag to a new parent/position through the platform's own mechanism, driving
6581        /// `TreeSource::moves`. Probe [`crate::Cap::TreeMove`] for support.
6582        pub movable: bool,
6583        /// Indentation per depth level, in points. `None` = the platform's default step.
6584        pub indent: Option<f64>,
6585    }
6586
6587    /// Post-realize changes to a `kinds::TREE` host (docs/tree.md). Token-addressed
6588    /// throughout; see [`crate::TreeSource`] for why indices cannot address tree rows.
6589    #[derive(Clone, Debug, PartialEq)]
6590    pub enum TreePatch {
6591        /// The node set changed (count/order/parentage/content): the host re-queries its
6592        /// `TreeSource`. Expansion and selection survive by token (docs/tree.md).
6593        Reload,
6594        /// Programmatically disclose (`true`) or collapse (`false`) one row. Applied without
6595        /// re-emitting [`crate::Event::TreeExpanded`] (the from-native echo rule).
6596        Expand(u64, bool),
6597        /// Programmatic selection sync (tokens; empty = clear), applied without re-emitting
6598        /// [`crate::Event::TreeSelection`].
6599        Selected(Vec<u64>),
6600        /// Scroll this row into view, realizing it if needed. The piece has already expanded
6601        /// the row's ancestors (through the app's expansion signal) before issuing this.
6602        Reveal(u64),
6603    }
6604
6605    #[derive(Clone, Debug, PartialEq)]
6606    pub enum ListPatch {
6607        /// The row set changed (count/order/content): the host re-queries its `ListSource`.
6608        Reload,
6609        /// The row set changed by exactly these deltas: a host that can, animates each row
6610        /// in, out, or across; one that cannot treats this as [`ListPatch::Reload`]. The
6611        /// data source answers with the final state throughout.
6612        Splice(Vec<RowDelta>),
6613        /// An `Automatic`-height row was re-laid at a new height after its first layout (its
6614        /// text changed, a wrapped line appeared, the backend corrected its width): the host
6615        /// gives the row bound to physical cell `cell` (the handle `ListSource::bind_row` got,
6616        /// as `usize`) the new `height`, padding included, the same value
6617        /// `ListSource::cell_height` now answers for it.
6618        ///
6619        /// Sent from inside a day-core layout pass, with the tree borrowed: a host applies it
6620        /// after the pass (posted to its main loop), never by asking the source anything
6621        /// synchronously, which would read a `0.0` height (docs/list.md "Row heights").
6622        RowSizeInvalidated { cell: usize, height: f64 },
6623        /// Imperatively scroll the native list so its last row is fully visible (a chat timeline
6624        /// sticking to the newest message). No-op when the list is empty (docs/list.md).
6625        ScrollToEnd,
6626        /// Imperatively scroll the native list so this row is visible, realizing it if needed
6627        /// (docs/list.md). Clamped to the row count; no-op when the list is empty.
6628        ScrollToRow(usize),
6629        /// Programmatic selection sync (row indices; empty = clear): toolkits apply without
6630        /// re-emitting a selection event, like every other programmatic sync.
6631        Selected(Vec<usize>),
6632        /// Enter (`true`) or leave (`false`) the platform's list editing mode, animated
6633        /// (docs/list.md, [`ListProps::editing`]). Ignored where `Cap::ListEditing` is
6634        /// `Unsupported`.
6635        Editing(bool),
6636    }
6637}
6638
6639// ---------------------------------------------------------------------------
6640// Imperative presentation (docs/dialogs.md)
6641// ---------------------------------------------------------------------------
6642
6643pub mod present {
6644    /// A dialog button's semantic role: styling + default/cancel placement on each toolkit.
6645    #[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
6646    pub enum ButtonRole {
6647        #[default]
6648        Default,
6649        Cancel,
6650        Destructive,
6651    }
6652
6653    #[derive(Clone, Debug, PartialEq)]
6654    pub struct PresentButton {
6655        pub label: String,
6656        pub role: ButtonRole,
6657    }
6658
6659    /// A named group of file extensions for a file dialog (e.g. "Text" → `["txt", "md"]`).
6660    /// An empty `extensions` list means "all files".
6661    #[derive(Clone, Debug, PartialEq, Default)]
6662    pub struct FileFilter {
6663        pub name: String,
6664        pub extensions: Vec<String>,
6665    }
6666
6667    /// What a backend should present for a `req`. Kept toolkit-agnostic; the pieces layer
6668    /// maps a chosen button index back to a typed payload.
6669    #[derive(Clone, Debug, PartialEq)]
6670    pub enum PresentSpec {
6671        /// Alert / confirmation / action sheet: title + optional message + ordered buttons.
6672        /// `sheet` = present from the bottom on mobile (desktop falls back to an alert).
6673        Dialog {
6674            title: String,
6675            message: Option<String>,
6676            buttons: Vec<PresentButton>,
6677            sheet: bool,
6678        },
6679        /// A dialog with a single text field.
6680        Prompt {
6681            title: String,
6682            message: Option<String>,
6683            placeholder: String,
6684            initial: String,
6685            ok: String,
6686            cancel: String,
6687        },
6688        /// Native "open file" picker (docs/files.md). The backend must answer with
6689        /// `PresentResult::Files` whose entries are **readable local paths**: desktop returns
6690        /// the chosen path directly; iOS/Android copy the selection into app storage first, so
6691        /// the pieces layer can read it with `std::fs` regardless of platform. `multiple` lets
6692        /// the user pick several files at once; the answer then carries every chosen path, in
6693        /// the picker's order. A backend that cannot select more than one file still answers
6694        /// with the single pick.
6695        OpenFile {
6696            title: String,
6697            filters: Vec<FileFilter>,
6698            multiple: bool,
6699        },
6700        /// Native "choose folder" picker (docs/files.md). The backend answers with
6701        /// `PresentResult::Files` holding the one chosen directory path, or `Dismissed`. Only
6702        /// backends answering [`Cap::FolderDialog`](crate::Cap::FolderDialog) as `Native` show a
6703        /// chooser; the others resolve the request as `Dismissed` at once, so the await never hangs.
6704        OpenFolder { title: String },
6705        /// Native "save file" picker (docs/files.md). `src_path` is a Day-written temp file
6706        /// holding the bytes to save; iOS/Android deliver it to the chosen destination natively,
6707        /// and the pieces layer best-effort copies it to a chosen local path otherwise.
6708        SaveFile {
6709            title: String,
6710            suggested_name: String,
6711            src_path: String,
6712            filters: Vec<FileFilter>,
6713        },
6714    }
6715
6716    /// The user's answer to a presentation.
6717    #[derive(Clone, Debug, PartialEq)]
6718    pub enum PresentResult {
6719        /// A dialog button at `index` (in spec order) was chosen.
6720        Button(i64),
6721        /// A prompt was confirmed with `text`.
6722        Text(String),
6723        /// One or more file locators chosen from an open/save picker (docs/files.md). Each is a
6724        /// local filesystem path or, on Android save, a `content://` URI.
6725        Files(Vec<String>),
6726        /// Dismissed without choosing (tap-outside / Esc / cancel gesture).
6727        Dismissed,
6728    }
6729
6730    /// The unit-separator that joins string lists across the C ABI (Qt shim / Android JNI), the
6731    /// same encoding the nav menu, combobox, and dialog-button shims use.
6732    pub const UNIT_SEP: char = '\u{1f}';
6733
6734    std::thread_local! {
6735        /// An app-writable scratch directory. Backends whose OS temp dir isn't app-writable
6736        /// (Android → `getCacheDir()`) set this at startup; elsewhere it stays `None` and callers
6737        /// fall back to `std::env::temp_dir()`.
6738        // False positive, and only when checking an android target: the initializer already is
6739        // the `const` block the lint asks for. Android's TLS model makes the macro expand to a
6740        // form clippy reads as lazy, so the suggestion is unfollowable as well as unneeded.
6741        #[allow(clippy::missing_const_for_thread_local)]
6742        static APP_TEMP_DIR: std::cell::RefCell<Option<std::path::PathBuf>> =
6743            const { std::cell::RefCell::new(None) };
6744    }
6745
6746    /// Record an app-writable scratch directory (see [`app_temp_dir`]). Called by a backend at
6747    /// startup when the OS temp dir isn't writable by the app (Android).
6748    pub fn set_app_temp_dir(dir: impl Into<std::path::PathBuf>) {
6749        APP_TEMP_DIR.with(|d| *d.borrow_mut() = Some(dir.into()));
6750    }
6751
6752    /// An app-writable scratch directory: the backend-supplied one, else `std::env::temp_dir()`.
6753    /// Used by the file-save flow (docs/files.md) to stage bytes before the native save picker.
6754    pub fn app_temp_dir() -> std::path::PathBuf {
6755        APP_TEMP_DIR.with(|d| {
6756            d.borrow().clone().unwrap_or_else(|| {
6757                #[cfg(target_arch = "wasm32")]
6758                {
6759                    // A browser has no filesystem and std's `temp_dir()` panics on wasm. A
6760                    // nominal path keeps join/display plumbing alive; actual reads and writes
6761                    // fail with ordinary io errors the file flows already surface.
6762                    std::path::PathBuf::from("/day-tmp")
6763                }
6764                #[cfg(not(target_arch = "wasm32"))]
6765                std::env::temp_dir()
6766            })
6767        })
6768    }
6769
6770    /// The web build's file bytes (wasm32-unknown-unknown only): a browser has no filesystem,
6771    /// so the open/save flows carry bytes through this per-page store instead of `std::fs`:
6772    /// day-toolkit-dom writes a picked file's bytes here and answers with its virtual path; the pieces
6773    /// layer stages save bytes here for the shim to download. Paths are plain strings under
6774    /// `/day-web/`; lifetimes are one picker round-trip (the flows remove what they stage).
6775    #[cfg(all(target_family = "wasm", target_os = "unknown"))]
6776    pub mod web_files {
6777        std::thread_local! {
6778            static FILES: std::cell::RefCell<std::collections::HashMap<String, Vec<u8>>> =
6779                std::cell::RefCell::new(std::collections::HashMap::new());
6780        }
6781
6782        /// Store `bytes` under `path` (replacing any prior content).
6783        pub fn write(path: &str, bytes: Vec<u8>) {
6784            FILES.with(|f| f.borrow_mut().insert(path.to_string(), bytes));
6785        }
6786
6787        /// The bytes at `path`, if present.
6788        pub fn read(path: &str) -> Option<Vec<u8>> {
6789            FILES.with(|f| f.borrow().get(path).cloned())
6790        }
6791
6792        /// Drop `path`'s bytes.
6793        pub fn remove(path: &str) {
6794            FILES.with(|f| {
6795                f.borrow_mut().remove(path);
6796            });
6797        }
6798    }
6799
6800    impl PresentResult {
6801        /// Flat wire tag for the C ABI (Qt shim / Android JNI): 0 dismissed, 1 button, 2 text,
6802        /// 3 files (`text` is the chosen locators joined by the unit separator).
6803        pub fn decode(tag: i32, index: i64, text: String) -> PresentResult {
6804            match tag {
6805                1 => PresentResult::Button(index),
6806                2 => PresentResult::Text(text),
6807                3 => PresentResult::Files(
6808                    text.split(UNIT_SEP)
6809                        .filter(|s| !s.is_empty())
6810                        .map(str::to_string)
6811                        .collect(),
6812                ),
6813                _ => PresentResult::Dismissed,
6814            }
6815        }
6816    }
6817
6818    impl PresentSpec {
6819        /// Backend-facing flattening for the C ABI: `(title, message, button labels, button
6820        /// roles as ints, sheet-or-prompt fields)`. Pure-Rust backends read the enum directly.
6821        pub fn title(&self) -> &str {
6822            match self {
6823                PresentSpec::Dialog { title, .. }
6824                | PresentSpec::Prompt { title, .. }
6825                | PresentSpec::OpenFile { title, .. }
6826                | PresentSpec::OpenFolder { title }
6827                | PresentSpec::SaveFile { title, .. } => title,
6828            }
6829        }
6830        pub fn message(&self) -> Option<&str> {
6831            match self {
6832                PresentSpec::Dialog { message, .. } | PresentSpec::Prompt { message, .. } => {
6833                    message.as_deref()
6834                }
6835                _ => None,
6836            }
6837        }
6838        /// Button labels joined with [`UNIT_SEP`], the encoding the nav menu and combobox
6839        /// shims already use for string lists.
6840        pub fn buttons_joined(&self) -> String {
6841            match self {
6842                PresentSpec::Dialog { buttons, .. } => buttons
6843                    .iter()
6844                    .map(|b| b.label.as_str())
6845                    .collect::<Vec<_>>()
6846                    .join(&UNIT_SEP.to_string()),
6847                PresentSpec::Prompt { ok, cancel, .. } => format!("{ok}{UNIT_SEP}{cancel}"),
6848                _ => String::new(),
6849            }
6850        }
6851        /// Button roles as ints (0 default, 1 cancel, 2 destructive), joined with commas.
6852        pub fn roles_joined(&self) -> String {
6853            let roles: Vec<i32> = match self {
6854                PresentSpec::Dialog { buttons, .. } => {
6855                    buttons.iter().map(|b| b.role as i32).collect()
6856                }
6857                PresentSpec::Prompt { .. } => {
6858                    vec![ButtonRole::Default as i32, ButtonRole::Cancel as i32]
6859                }
6860                _ => vec![],
6861            };
6862            roles
6863                .iter()
6864                .map(|r| r.to_string())
6865                .collect::<Vec<_>>()
6866                .join(",")
6867        }
6868
6869        // --- file-dialog accessors (docs/files.md) ---
6870
6871        /// The file filters, if this is a file dialog.
6872        pub fn filters(&self) -> &[FileFilter] {
6873            match self {
6874                PresentSpec::OpenFile { filters, .. } | PresentSpec::SaveFile { filters, .. } => {
6875                    filters
6876                }
6877                _ => &[],
6878            }
6879        }
6880        /// Whether an open-file dialog lets the user pick several files (`false` for every
6881        /// other spec).
6882        pub fn multiple(&self) -> bool {
6883            matches!(self, PresentSpec::OpenFile { multiple: true, .. })
6884        }
6885        /// The suggested file name for a save dialog (empty otherwise).
6886        pub fn suggested_name(&self) -> &str {
6887            match self {
6888                PresentSpec::SaveFile { suggested_name, .. } => suggested_name,
6889                _ => "",
6890            }
6891        }
6892        /// The Day-written temp source path for a save dialog (empty otherwise).
6893        pub fn src_path(&self) -> &str {
6894            match self {
6895                PresentSpec::SaveFile { src_path, .. } => src_path,
6896                _ => "",
6897            }
6898        }
6899        /// Filters flattened for the C ABI: each filter is `name|ext1,ext2`, joined by
6900        /// [`UNIT_SEP`]. A trailing `|` (no extensions) means "all files". Empty when unfiltered.
6901        pub fn filters_joined(&self) -> String {
6902            self.filters()
6903                .iter()
6904                .map(|f| format!("{}|{}", f.name, f.extensions.join(",")))
6905                .collect::<Vec<_>>()
6906                .join(&UNIT_SEP.to_string())
6907        }
6908    }
6909}
6910
6911// ---------------------------------------------------------------------------
6912// The Toolkit trait (§8.1)
6913// ---------------------------------------------------------------------------
6914
6915/// Timing supplied by a native display scheduler. Seconds on a monotonic clock; its epoch
6916/// is platform-specific. `target_timestamp` is absent when the platform cannot predict presentation.
6917#[derive(Clone, Copy, Debug, PartialEq)]
6918pub struct FrameStamp {
6919    pub timestamp: f64,
6920    pub target_timestamp: Option<f64>,
6921}
6922impl FrameStamp {
6923    pub fn new(timestamp: f64) -> Self {
6924        Self {
6925            timestamp,
6926            target_timestamp: None,
6927        }
6928    }
6929}
6930/// A one-shot native frame callback, always delivered asynchronously on the UI thread.
6931pub type FrameCallback = Box<dyn FnOnce(FrameStamp)>;
6932/// Cancels a pending callback and releases its native registration. UI-thread only, idempotent
6933/// in effect, and safe during/after delivery. Dropping without calling is allowed after delivery.
6934pub type CancelFrame = Box<dyn FnOnce()>;
6935
6936/// The margins and minimum height a native list gives each row ([`Toolkit::list_row_metrics`],
6937/// docs/list.md "Row metrics"): a `UITableView` cell's layout margins, a Material list item's
6938/// 16dp sides and 56dp height.
6939#[derive(Clone, Copy, Debug, Default, PartialEq)]
6940pub struct RowMetrics {
6941    /// Space between the row's edges and its content.
6942    pub insets: Insets,
6943    /// The least height a row takes, margins included; content centers vertically in it.
6944    pub min_height: f64,
6945}
6946
6947pub trait Toolkit: Sized + 'static {
6948    // `'static` so a handle clone can cross the object-safe TreeOps boundary boxed as `Any`
6949    // (`node_handle_any`, the tweaks door, docs/tweaks.md).
6950    type Handle: Clone + 'static;
6951
6952    /// Request one frame for the window containing `host`. Never call inline. Each native source
6953    /// must stop after delivery unless requested again; no polling timer. Hidden/unmapped windows
6954    /// may defer delivery until visible. Native callbacks must reach the UI thread before calling
6955    /// Rust. The returned closure cancels safely, including when the window has closed.
6956    /// The mock intentionally stays pending: tests inject a manual scheduler in day-core.
6957    fn request_frame(&mut self, _host: &Self::Handle, _cb: FrameCallback) -> CancelFrame {
6958        Box::new(|| {})
6959    }
6960
6961    fn capability(&self, _cap: Cap) -> Support {
6962        Support::Unsupported
6963    }
6964
6965    // node lifecycle
6966    fn realize(&mut self, kind: PieceKind, props: &dyn Any, id: NodeId) -> Self::Handle;
6967    fn update(
6968        &mut self,
6969        h: &Self::Handle,
6970        kind: PieceKind,
6971        patch: &dyn Any,
6972        anim: Option<&AnimSpec>,
6973    );
6974    /// Called from the turn-boundary release queue; backends may defer destruction further.
6975    fn release(&mut self, h: Self::Handle);
6976    /// Give a satellite piece a chance to drop its own per-view state, immediately before
6977    /// [`release`](Toolkit::release) frees `h` (§15.2).
6978    ///
6979    /// A backend that hosts third-party pieces implements this by looking `kind` up in its renderer
6980    /// registry and calling that piece's `release` hook. Defaulted to nothing: a backend with no
6981    /// registry has nothing to dispatch, and one that has not wired this yet leaves piece
6982    /// teardown unrun rather than failing to build.
6983    fn release_piece(&mut self, _kind: PieceKind, _h: &Self::Handle) {}
6984
6985    // tree
6986    fn insert(&mut self, parent: &Self::Handle, child: &Self::Handle, index: usize);
6987    fn remove(&mut self, parent: &Self::Handle, child: &Self::Handle);
6988    /// Put `child` at index `to` among `parent`'s native children: day's z-order resync, which
6989    /// walks the whole sibling row and asks for every position in turn. A child already at `to`
6990    /// must therefore cost nothing: a backend that re-parents unconditionally pays for the
6991    /// untouched siblings too, and where re-parenting means detach-then-add (no ViewGroup move
6992    /// primitive on Android) the detach resigns the focus of everything inside the subtree,
6993    /// which is how a text field being typed into loses the keyboard (docs/focus.md).
6994    fn move_child(&mut self, parent: &Self::Handle, child: &Self::Handle, to: usize);
6995
6996    // geometry (§7): frames are in the nearest realized native ancestor's space, in points.
6997    fn measure(&mut self, h: &Self::Handle, kind: PieceKind, p: Proposal) -> Size;
6998    /// Distance from the top of the widget's frame to its first text baseline, in points, when
6999    /// the widget is `size` (docs/baseline.md). `None` ⇒ it has no text baseline (an image, a
7000    /// slider, a bare container) and a baseline-aligned row falls back to box alignment for it.
7001    ///
7002    /// This is a measurement, not a layout mode: day places every frame itself (§7.1) and never
7003    /// hands a row to a native baseline-aligning container, so what it needs from the toolkit is
7004    /// where the text sits inside the box. Backends that cannot answer keep the default and rows
7005    /// stay centered, which is what every row did before this existed.
7006    fn first_baseline(&mut self, _h: &Self::Handle, _kind: PieceKind, _size: Size) -> Option<f64> {
7007        None
7008    }
7009    fn set_frame(&mut self, h: &Self::Handle, frame: Rect, anim: Option<&AnimSpec>);
7010
7011    // animatable visual channels (§8.4): cheap per-node opacity + transform that don't relayout.
7012    // Defaulted no-ops so backends adopt them incrementally; `anim = Some` ⇒ animate to the value
7013    // on the toolkit's own compositor, `None` ⇒ set instantly.
7014    fn set_opacity(&mut self, _h: &Self::Handle, _opacity: f64, _anim: Option<&AnimSpec>) {}
7015    fn set_transform(
7016        &mut self,
7017        _h: &Self::Handle,
7018        _t: Transform,
7019        _size: Size,
7020        _anim: Option<&AnimSpec>,
7021    ) {
7022    }
7023
7024    // text selection (docs/text.md): make this node's text user-selectable (copy/drag). Applied
7025    // once from the `.selectable()` modifier (day-pieces) to the widget it wraps (a `label`'s
7026    // native text view, most usefully). Unmanaged: Day sets it here and never patches it, so it
7027    // survives text updates. The default no-op means a backend without a selection affordance
7028    // silently leaves the text unselectable.
7029    //
7030    // Returns `Some(replacement)` when the toolkit had to rebuild the widget as a different
7031    // native class to gain a selection affordance (UIKit: `UILabel` has none, so the label
7032    // becomes a read-only `UITextView`); day-core re-points the node's handle at the
7033    // replacement, so later patches and layout reach the widget that is actually on screen.
7034    // Every property-flip backend returns `None`.
7035    fn set_selectable(&mut self, _h: &Self::Handle, _selectable: bool) -> Option<Self::Handle> {
7036        None
7037    }
7038
7039    // text entry (docs/textfield.md): how a `text_field` takes its text: hidden characters,
7040    // read-only, the keyboard and autofill purpose, the action key, the length bound. Called
7041    // once after `realize` when a field asks for anything but the defaults, and again with the
7042    // whole set whenever a reactive member changes, so the setter is idempotent.
7043    //
7044    // Returns `Some(replacement)` when hiding the characters takes a different native class
7045    // (AppKit: `NSSecureTextField`; WinUI: `PasswordBox`), the same contract as
7046    // `set_selectable`: the backend carries the text, the placeholder and its own event wiring
7047    // over, takes the old widget's place in the native tree, and day-core re-points the node's
7048    // handle. Required, with no default: a backend that left this out would show a password in
7049    // the clear. A member the toolkit has no notion of (an action key on a desktop) is ignored.
7050    fn set_input_traits(&mut self, h: &Self::Handle, traits: &InputTraits) -> Option<Self::Handle>;
7051
7052    // pointer shape (docs/cursor.md): the cursor to show while the pointer is over this node
7053    // and its descendants, from the `.cursor()` modifier. Called again whenever a reactive
7054    // cursor changes, so the setter is idempotent and cheap; `Cursor::Default` releases the
7055    // node. The default no-op is a backend with no pointer API to reach (`Cap::Cursor`
7056    // answers `Unsupported` there).
7057    fn set_cursor(&mut self, _h: &Self::Handle, _cursor: Cursor) {}
7058
7059    /// Show or hide a retained subtree, including its input and accessibility participation.
7060    /// The layout and reactive scope stay alive while hidden.
7061    fn set_presented(&mut self, _h: &Self::Handle, _visible: bool) {}
7062
7063    /// Attach overscroll delivery to a refresh host; the caller owns the mounted scope.
7064    fn attach_refresh(&mut self, _h: &Self::Handle, _host: NodeId) {}
7065
7066    // scroll (§7.6)
7067    fn set_scroll_content(&mut self, _h: &Self::Handle, _content: Size) {}
7068    // The room a classic (non-overlay) scroll bar takes out of a scroll view's content area
7069    // while the content overflows along `vertical`'s axis: its width for a vertical scroll, its
7070    // height for a horizontal one (docs/scroll.md). Day lays overflowing content out that much
7071    // narrower, so it fits beside the bar instead of reaching under it. 0 — the default — is
7072    // every backend whose bars overlay the content (AppKit, GTK, UIKit, Android, XAML, DOM).
7073    fn scroll_bar_inset(&mut self, _h: &Self::Handle, _vertical: bool) -> f64 {
7074        0.0
7075    }
7076    // The margins and minimum height the platform's own list gives a row (docs/list.md "Row
7077    // metrics"), asked of list `h` each time day-core lays a row. Day insets the row's content by
7078    // the margins and keeps it at least that tall, so a row reads at the platform's size without
7079    // the app padding it per platform. Zero — the default — leaves the row to its content.
7080    fn list_row_metrics(&mut self, _h: &Self::Handle) -> RowMetrics {
7081        RowMetrics::default()
7082    }
7083    fn scroll_to(&mut self, _h: &Self::Handle, _target: Rect, _animated: bool) {}
7084    fn scroll_offset(&mut self, _h: &Self::Handle) -> Point {
7085        Point::ZERO
7086    }
7087
7088    // events: one trampoline, node-id keyed; enqueue-only contract (§8.3).
7089    fn set_event_sink(&mut self, sink: EventSink);
7090
7091    // gestures (docs/shapes.md): attach a native recognizer for `kind` to `h`, emitting
7092    // `Event::Tap/LongPress/Drag` for `node` (enqueue-only). Default no gesture; a piece opts in
7093    // when it has a handler. Idempotent per (handle, kind).
7094    fn enable_gesture(&mut self, _h: &Self::Handle, _node: NodeId, _kind: GestureKind) {}
7095
7096    // focus (docs/focus.md): move native keyboard focus to (or away from) this control.
7097    // `focused = true` requests focus (on mobile this also raises the soft keyboard for text
7098    // inputs); `false` resigns it (dismissing the keyboard; platforms without a "focus nothing"
7099    // state resign to a focusable root). Backends report the resulting state, user- or
7100    // programmatic, with `Event::FocusChanged(bool)` through the sink; a request that cannot
7101    // be honored (unfocusable, unmounted) produces no event. The default no-op means a
7102    // backend without focus support neither moves nor reports focus.
7103    fn focus(&mut self, _h: &Self::Handle, _node: NodeId, _focused: bool) {}
7104
7105    // focusability (docs/focus.md): opt this container into the platform's focus system, the
7106    // canvas contract generalized (`Decorate::focusable`). A focusable container accepts focus,
7107    // takes it on a press (before the press reaches its gesture recognizers), reports both
7108    // directions with `Event::FocusChanged`, and delivers the non-text keys it hears while
7109    // focused as `Event::Key` for `node` (gated on `keys::handled`, like a canvas). The default
7110    // no-op means the piece renders normally but never joins the focus order: `.focused(…)`
7111    // bindings stay quiet and `.on_key(…)` never fires, the same graceful silence unfocusable
7112    // controls already have. Idempotent; only `true` is ever sent today (a piece opts in at
7113    // build, it does not opt back out).
7114    fn set_focusable(&mut self, _h: &Self::Handle, _node: NodeId, _focusable: bool) {}
7115
7116    // recycling list (docs/list.md, §10): day-core hands the `LIST` host its row-pull `source`
7117    // once, right after realize. A recycling backend stores it and calls it from its native
7118    // data-source; the default no-op means a backend without list support renders nothing.
7119    fn attach_list(&mut self, _host: &Self::Handle, _source: ListSource) {}
7120
7121    // hierarchical tree (docs/tree.md): day-core hands the `TREE` host its token-addressed
7122    // row-pull `source` once, right after realize. A backend with a native tree stores it and
7123    // answers its data-source from it; the default no-op means a backend without tree support
7124    // renders nothing (`Cap::Tree` answers `Unsupported`, and apps gate on that).
7125    fn attach_tree(&mut self, _host: &Self::Handle, _source: TreeSource) {}
7126
7127    // routes (docs/navigation.md): day-core reports the app's current route path here whenever
7128    // it changes ("" = everything at its root), so a backend with a native notion of location
7129    // can mirror it: web-dom writes the URL hash (`#controls`), and browser back/forward or a
7130    // hand-edited hash comes back as `Event::RouteRequested`. Default no-op: most toolkits
7131    // have nowhere to put a route.
7132    fn set_route(&mut self, _route: &str) {}
7133
7134    // undo bridge (docs/model.md): mirror the app's undo stack into the platform's own undo
7135    // objects, so the stock Edit menu retitles and enables itself and the platform's gestures
7136    // land: the native front of one Day-owned history. The state flows down whenever it
7137    // changes; the user's invocation comes back up as `Event::Undo`. Default no-op: a backend
7138    // without a native undo system answers `Cap::UndoBridge` `Unsupported`, and the app's own
7139    // affordances (menu items, buttons, accelerators) drive the stack instead.
7140    fn set_undo_state(&mut self, _state: &UndoState) {}
7141
7142    // edit bridge (docs/menus.md): mirror what the app's standard-edit handlers can do, so
7143    // the platform's own menu validation enables Cut/Copy/Paste exactly as it does for text
7144    // widgets; invocations come back up as `Event::Edit`. Default no-op: a backend without
7145    // a native edit-command route answers `Cap::EditBridge` accordingly.
7146    fn set_edit_state(&mut self, _state: &EditState) {}
7147
7148    // ambient modifiers (docs/menus.md): the keyboard modifiers held right now, for
7149    // interactions whose meaning they change (shift-click multi-select). Pull-based: the
7150    // platforms expose exactly this query (NSEvent.modifierFlags, the shim's tracked mask).
7151    // Touch-only backends keep the all-false default.
7152    fn modifiers(&mut self) -> Modifiers {
7153        Modifiers::default()
7154    }
7155
7156    // Dynamic context menu (docs/menus.md): the menu is built when it is summoned. The provider
7157    // is called synchronously from the platform's own menu callback (UI thread, outside any
7158    // day-core borrow) with the location in the node's local coordinates, and its result is
7159    // shown. The static `set_context_menu` below stays the simple path; this one exists for
7160    // surfaces whose menu depends on what is under the pointer (a canvas selection,
7161    // docs/tree.md). Default no-op: a backend without the affordance shows nothing.
7162    fn set_drag_source(&mut self, _h: &Self::Handle, _source: transfer::Source) {}
7163    fn set_drop_target(&mut self, _h: &Self::Handle, _target: transfer::Target) {}
7164
7165    fn set_context_menu_fn(&mut self, _h: &Self::Handle, _node: NodeId, _f: ContextMenuFn) {}
7166
7167    // menus (§ menus): render `items` with the backend's native menu affordance, firing
7168    // `Event::MenuAction(id)` (enqueue-only) for each id'd item; `role` items use the native standard
7169    // command. Default no-op: a toolkit without a menu bar / context menu shows nothing.
7170    /// The application menu (macOS/Windows/Linux menu bar; the app-bar overflow on Android; the
7171    /// UIMenuBuilder main menu on iPadOS/Catalyst). Replaces any previous app menu.
7172    fn set_app_menu(&mut self, _items: &[MenuItem]) {}
7173    /// A context menu for `h`, shown on secondary-click (desktop) or long-press (mobile). Passing an
7174    /// empty slice removes it.
7175    fn set_context_menu(&mut self, _h: &Self::Handle, _node: NodeId, _items: &[MenuItem]) {}
7176
7177    // toolbars (docs/toolbars.md): a window's native toolbar: NSToolbar, AdwHeaderBar, QToolBar,
7178    // CommandBar. `h` is the window root's handle (the same handle `open_window` returned, or the
7179    // primary root's); the backend walks from it to the window it belongs to. Default no-op: a
7180    // toolkit with no toolbar shows nothing rather than a drawn imitation, and reports
7181    // `Cap::Toolbar` as `Unsupported` so an app can put the command somewhere else.
7182    /// Edit the window's toolbar (docs/toolbars.md): the [`ToolbarOp`]s, removals first, then
7183    /// insertions in ascending position. The bar starts empty, and becomes empty again when every
7184    /// item has been removed, at which point the backend takes it off the window.
7185    ///
7186    /// An item no op names must keep its native widget untouched, which is what keeps a search
7187    /// field focused while the page around it changes. Day sends an edit only when the bar
7188    /// changed, at most once per turn, and an item keeps its [`ToolbarItem::action`] for as long
7189    /// as its window shows an item with that id, so a native item's handler never needs
7190    /// repointing. Keep a [`ToolbarMirror`] and place each new item after
7191    /// [`ToolbarMirror::prev_where`] its group.
7192    ///
7193    /// Returns whether the edit was applied. `false` (the window has no bar to edit yet) makes
7194    /// Day send the whole bar again at the next change rather than assume this one landed.
7195    fn edit_toolbar(&mut self, _h: &Self::Handle, _ops: &[ToolbarOp]) -> bool {
7196        true
7197    }
7198    /// Apply a targeted change to one live toolbar item, the path a bound signal writes through,
7199    /// so syncing a search field does not rebuild the bar. No-op if the item is not present.
7200    fn update_toolbar(&mut self, _h: &Self::Handle, _patch: &ToolbarPatch) {}
7201
7202    // lifecycle (docs/lifecycle.md): does this backend deliver `phase`? The default answers "yes" for
7203    // the universal phases (launch/activation/termination) and "no" for the mobile-only ones. Backends
7204    // that wire up more (the mobile ones) override this; it must agree with the crate's
7205    // `const fn lifecycle_supported`, which drives compile-time guards in `day::require_lifecycle!`.
7206    fn supports_lifecycle(&self, phase: Lifecycle) -> bool {
7207        phase.is_universal()
7208    }
7209
7210    // pillars
7211    /// Apply a node's accessibility annotations (docs/accessibility.md): the label, hint and
7212    /// value the screen reader speaks, an explicit role, whether the node is hidden from
7213    /// assistive technology, and the automation identifier. Called with the node's whole
7214    /// accumulated set each time any of it changes, so every member is written on every call.
7215    /// Only an explicit role is applied: a native control reports its own.
7216    fn set_a11y(&mut self, _h: &Self::Handle, _a11y: &A11yProps) {}
7217    /// Speak `text` through the screen reader without moving its focus (docs/accessibility.md;
7218    /// probe [`Cap::Announce`]). `urgent` interrupts what is being read; otherwise the sentence
7219    /// waits its turn. Nothing happens when no screen reader is running. The default is a
7220    /// backend that reaches no announcement API.
7221    fn announce(&mut self, _text: &str, _urgent: bool) {}
7222    /// Read a widget's native state back from the platform for `a11y_audit` and
7223    /// `assert_native` (§14.2, docs/testing.md) to diff against Day's expectation. Default:
7224    /// nothing read; the audit skips the node and `assert_native` records each field as unread.
7225    fn read_native(&self, _h: &Self::Handle) -> NativeSnapshot {
7226        NativeSnapshot::default()
7227    }
7228    fn replay(&mut self, _h: &Self::Handle, _ops: &[DrawOp], _size: Size) {}
7229
7230    /// Every family this toolkit can draw canvas text in, with the faces each ships
7231    /// (docs/fonts.md; probe [`Cap::FontList`]). Default: none. `day::font_families` calls
7232    /// this once per process and caches the answer: enumeration is slow on every platform
7233    /// (fontconfig, a DirectWrite collection, an XML parse on Android) and the set does not
7234    /// change under a running app.
7235    fn font_families(&mut self) -> Vec<FontFamilyInfo> {
7236        Vec::new()
7237    }
7238
7239    /// Measure one line of canvas text at `size` points in `font`, in the same engine
7240    /// [`Toolkit::replay`] draws [`DrawOp::Text`] with, so a `TextAnchor::LEADING` anchor plus
7241    /// the returned `ascent` lands on the drawn baseline. `None` = cannot measure; the facade
7242    /// then answers [`TextMetrics::approximate`].
7243    fn measure_text(&mut self, _text: &str, _size: f64, _font: &CanvasFont) -> Option<TextMetrics> {
7244        None
7245    }
7246    // --- raster images (docs/images.md) -------------------------------------------------
7247    //
7248    // Request-id shaped rather than blocking, because one backend can never answer
7249    // synchronously: a browser decodes through `createImageBitmap`, which is a promise. Every
7250    // other backend decodes inline and raises the completion before returning, so the shape
7251    // costs them nothing but a line, and the app-facing API can be one thing everywhere
7252    // instead of two (docs/async.md rule 3: a callback and a future, never a runtime).
7253
7254    /// Decode encoded image bytes (PNG/JPEG/…) into a native image, answering `req` with
7255    /// [`Event::ImageDecoded`].
7256    ///
7257    /// The id in that event is minted by the caller (day-core), not the backend: the backend
7258    /// keeps its own side table from that id to whatever it decoded.
7259    ///
7260    /// The default does nothing because a default body cannot reach the event sink
7261    /// (it is installed per backend by [`Toolkit::set_event_sink`], not reachable through this
7262    /// trait), so there is no way for it to answer. day-core asks [`Cap::ImageDecode`] first and
7263    /// resolves the request as [`ImageError::Unsupported`] itself, which is why a backend with
7264    /// no decoder needs no code here at all.
7265    fn decode_image(&mut self, _req: u64, _id: BitmapId, _bytes: &[u8]) {}
7266
7267    /// What a decoded image is: pixel size, scale, format, alpha ([`BitmapInfo`]).
7268    ///
7269    /// Synchronous: every backend that decoded the image already holds these facts. `None` means
7270    /// the id names nothing live.
7271    fn image_info(&mut self, _id: BitmapId) -> Option<BitmapInfo> {
7272        None
7273    }
7274
7275    /// The metadata beyond the pixels: EXIF orientation, DPI, capture time
7276    /// ([`ImageProperties`], probe [`Cap::ImageProperties`]). `None` where the backend has no
7277    /// metadata reader, which is most of them.
7278    fn image_properties(&mut self, _id: BitmapId) -> Option<ImageProperties> {
7279        None
7280    }
7281
7282    /// Encode a decoded image back to bytes per `spec`, answering `req` with
7283    /// [`Event::ImageEncoded`]. A format outside [`Self::encode_formats`] answers
7284    /// [`ImageError::Encode`] rather than silently substituting another one.
7285    ///
7286    /// Defaulted to nothing for the same reason as [`Self::decode_image`]: day-core probes
7287    /// [`Cap::ImageEncode`] and answers `Unsupported` without ever calling this.
7288    fn encode_image(&mut self, _req: u64, _id: BitmapId, _spec: &EncodeSpec) {}
7289
7290    /// Which formats [`Self::encode_image`] can write, the [`Cap::ImageEncode`]
7291    /// counterpart of [`Self::font_families`]/[`Cap::FontList`]. Empty by default.
7292    fn encode_formats(&mut self) -> Vec<ImageFormat> {
7293        Vec::new()
7294    }
7295
7296    /// Drop a decoded image and everything the backend cached for it. Called when the app's
7297    /// last handle to it goes away; an id the backend never saw is a no-op.
7298    fn release_image(&mut self, _id: BitmapId) {}
7299
7300    // --- canvas export (docs/canvas.md "Exporting") --------------------------------------
7301    //
7302    // Request-id shaped like the image duties above, for the same reason: the browser's
7303    // `createImageBitmap` is a promise. Every other backend renders inline and raises the
7304    // completion before returning. day-core validates sizes, enforces the pixel limits and
7305    // probes the caps first, so a backend sees only requests it can attempt.
7306
7307    /// Replay `ops` into a new offscreen bitmap of `size` points at `scale` pixels per point
7308    /// ([`export_pixels`] gives the pixel size), with a transparent background and the
7309    /// canvas's top-left origin. Store it under `id` in the same table [`Self::decode_image`]
7310    /// fills, so `image_info`, `encode_image`, `release_image`, the `image` piece and
7311    /// [`DrawOp::Image`] all take it, then answer `req` with [`Event::BitmapRendered`] (its
7312    /// [`BitmapInfo::scale`] is `scale`).
7313    ///
7314    /// The painter must be the one [`Self::replay`] uses, so text, gradients, clips and stamps
7315    /// match the screen. Defaulted to nothing: day-core probes [`Cap::CanvasBitmap`] and answers
7316    /// [`ExportError::Unsupported`] itself.
7317    fn render_bitmap(
7318        &mut self,
7319        _req: u64,
7320        _id: BitmapId,
7321        _ops: &[DrawOp],
7322        _size: Size,
7323        _scale: f64,
7324    ) {
7325    }
7326
7327    /// Replay each page into one PDF document and answer `req` with [`Event::PdfRendered`].
7328    /// Units are PDF points, top-left origin; shapes and text stay vector, and images embed at
7329    /// their pixel size. `pages` is never empty. Defaulted to nothing: day-core probes
7330    /// [`Cap::CanvasPdf`] first.
7331    fn render_pdf(&mut self, _req: u64, _pages: &[PdfPage], _info: &PdfInfo) {}
7332
7333    fn snapshot_window(&mut self) -> Result<Vec<u8>, String> {
7334        Err("snapshot unsupported".into())
7335    }
7336    /// Where `root`, the primary window's root container, sits in [`Self::snapshot_window`]'s
7337    /// image, in points from its top-left. A reader of the capture (dayscript `sample_pixel`)
7338    /// maps Day's frames into it with this; it matters where the capture spans more than Day's
7339    /// content on more than one side, as Android's edge-to-edge content does (status bar
7340    /// above, navigation bar below).
7341    ///
7342    /// Defaulted to `None`, which the reader takes as "any rows beyond Day's content are
7343    /// above it", true where the only extra area is a title bar.
7344    fn snapshot_origin(&mut self, _root: &Self::Handle) -> Option<Point> {
7345        None
7346    }
7347    /// Establish a fresh screenshot checkpoint after reactive work and native transitions.
7348    /// `revision` is stable across retries and unique to the request; `host` selects a
7349    /// secondary window, or None for the primary. Never block the UI thread waiting for
7350    /// a frame. Pending requests are retried within the dayscript deadline.
7351    ///
7352    /// Ready means the platform render checkpoint completed, not that a vsync callback
7353    /// proves pixels were displayed. OnCapture delegates freshness to snapshot_window.
7354    fn prepare_snapshot(
7355        &mut self,
7356        _host: Option<&Self::Handle>,
7357        _revision: u32,
7358    ) -> Result<capture::Readiness, String> {
7359        // Compatible with external toolkits predating this hook: their synchronous
7360        // snapshot owns freshness, and no external-capture acknowledgment is fabricated.
7361        Ok(capture::Readiness::OnCapture)
7362    }
7363
7364    /// The same capture with the window's own chrome: title bar, toolbar, whatever the platform
7365    /// draws around the content (docs/window-image.md).
7366    ///
7367    /// Defaulted to [`Self::snapshot_window`]: a backend that cannot separate the two
7368    /// (or has no chrome to speak of, a phone) answers with the content shot rather than an
7369    /// error, so an app asking for chrome degrades to "what there was" instead of failing. The
7370    /// dayscript `screenshot` step never calls this, which is what keeps every captured gallery
7371    /// baseline byte-identical to before it existed.
7372    fn snapshot_window_chrome(&mut self) -> Result<Vec<u8>, String> {
7373        self.snapshot_window()
7374    }
7375    /// Show/hide the sidebar pane of the navigation host `host`, which is what the sidebar
7376    /// affordance a `nav(Sidebar)` contributes for itself drives ([`SIDEBAR_TOGGLE_ID`]).
7377    /// `false` when `host` has no pane to toggle (a stack, a tab bar, no sidebar concept at all).
7378    ///
7379    /// Per host, not per process: a second window's button collapses that window's sidebar
7380    /// and not the first's. The item's own action makes the call, and dayscript's `toolbar:`
7381    /// step presses the item like any other, so a walkthrough drives exactly the path a click
7382    /// takes. Defaulted, so a backend with no sidebar needs no code. docs/toolbars.md,
7383    /// docs/navigation.md.
7384    fn toggle_sidebar(&mut self, _host: &Self::Handle) -> bool {
7385        false
7386    }
7387    /// Press the platform's own back affordance (the navigation bar's back button on iOS,
7388    /// the system back on Android) on the innermost navigation host that has something to
7389    /// pop. What `nav_back()` does not do: that is Day's rail, which pops the model and lets the
7390    /// backend follow, while this runs the native path first (the bar's `shouldPop`, the
7391    /// dispatcher's callbacks, the pop itself) and lets the backend report it to Day as a user
7392    /// back, guard and all. dayscript's `nav_back: { native: true }` drives it, so a walkthrough
7393    /// can cover the code that a real tap runs and Day's rail never reaches. `false` when this
7394    /// toolkit has no native back or nothing is popped by it. Defaulted, so a desktop needs no
7395    /// code. docs/navigation.md.
7396    fn native_back(&mut self) -> bool {
7397        false
7398    }
7399    /// Whether the UI has settled, with no native transition (modal present/dismiss, nav push)
7400    /// still animating. The dayscript `screenshot` step polls this before capturing so shots
7401    /// never catch a half-faded dialog or half-pushed page. Backends without async
7402    /// transitions (or without a way to know) report `true`.
7403    fn ui_idle(&mut self) -> bool {
7404        true
7405    }
7406
7407    // imperative presentation (docs/dialogs.md): show a native modal for request `req`;
7408    // the backend answers by enqueuing `Event::PresentResult { req, .. }`. `dismiss` is
7409    // used only when Day resolves programmatically (dayscript) while the modal is still up.
7410    fn present(&mut self, _req: u64, _spec: &present::PresentSpec) {}
7411    fn dismiss(&mut self, _req: u64) {}
7412
7413    /// Open `url` in the platform's default handler: the system browser for `http(s)`, the mail
7414    /// client for `mailto:`, etc. Backs the [`link`](/api/day_pieces/fn.link.html) piece. Fire and
7415    /// forget: there is no result, and an unopenable URL is ignored. The default no-ops so a
7416    /// backend that hasn't wired it up still compiles.
7417    fn open_url(&mut self, _url: &str) {}
7418
7419    /// Whether a native share chooser is available. Does not probe installed share targets.
7420    fn share_support(&self) -> Support {
7421        Support::Unsupported
7422    }
7423    /// Present the system share chooser. True means presentation started, not that sharing
7424    /// completed. Never sends data without the user's choice of a target.
7425    fn share_url(&mut self, _url: &str, _title: &str) -> bool {
7426        false
7427    }
7428
7429    /// Read current OS associations. Unsupported platforms must not fabricate a default.
7430    fn application_handlers(
7431        &mut self,
7432        _query: &applications::HandlerQuery,
7433    ) -> Result<applications::ApplicationHandlers, applications::ApplicationError> {
7434        Err(applications::ApplicationError::Unsupported)
7435    }
7436
7437    /// Open with an opaque application identifier returned by `application_handlers`.
7438    fn open_url_with_application(
7439        &mut self,
7440        _url: &str,
7441        _application: &str,
7442        completion: applications::OpenApplicationCompletion,
7443    ) {
7444        completion(Err(applications::ApplicationError::Unsupported));
7445    }
7446
7447    /// Ask the OS to require a second swipe for its edge gestures on `edges` (docs/cover.md):
7448    /// the union requested by every mounted `defers_system_gestures` modifier, re-sent whenever
7449    /// that set changes (`Edges::NONE` when the last one unmounts). iOS defers the home
7450    /// indicator / notification edges; Android enters swipe-to-reveal immersive mode while
7451    /// non-empty. The default no-ops (desktop has no system edge gestures).
7452    fn defer_system_gestures(&mut self, _edges: Edges) {}
7453
7454    /// Hide (`true`) or show the system status bar (docs/cover.md): whether any mounted
7455    /// `status_bar_hidden` modifier currently asks for it hidden, re-sent whenever that answer
7456    /// may have changed (`false` when the last request unmounts). iOS answers the root and
7457    /// cover view controllers' `prefersStatusBarHidden`; Android and HarmonyOS hide the status
7458    /// bar through their window inset controls. The default no-ops (desktop and web have no
7459    /// status bar); report `Cap::StatusBarHidden` alongside an implementation.
7460    fn set_status_bar_hidden(&mut self, _hidden: bool) {}
7461
7462    /// Whether the platform is rendering in dark appearance right now. Apps painting
7463    /// custom surfaces (opaque overlay panels, scrims) branch on this so their fills track
7464    /// the theme the default text colors already follow; hardcoding either theme's
7465    /// surface produces dark-on-dark or light-on-light text on the other. Queried at
7466    /// build time (a piece rebuilt after a theme change re-queries). The default honors a
7467    /// `DAY_THEME` launch override and otherwise answers light.
7468    fn dark_mode(&mut self) -> bool {
7469        std::env::var("DAY_THEME").ok().as_deref() == Some("dark")
7470    }
7471    /// Whether the user asked the system for less motion (docs/accessibility.md; probe
7472    /// [`Cap::ReduceMotion`]): macOS and iOS "Reduce Motion", Android "Remove animations" (an
7473    /// animator duration scale of zero), GTK's `gtk-enable-animations` off, Windows "Show
7474    /// animations" off, the web's `prefers-reduced-motion`. Read at launch; a backend that
7475    /// watches the setting calls `day_core::note_motion_changed` when it flips. Day then applies
7476    /// every finite animation at its destination and keeps only indeterminate motion. The default
7477    /// is a backend that reads no such setting.
7478    fn reduce_motion(&mut self) -> bool {
7479        false
7480    }
7481
7482    /// App-level appearance override: `Some(true)` forces dark, `Some(false)` forces light,
7483    /// `None` returns to following the system. Backends restyle their native widgets in
7484    /// place and answer the override from [`dark_mode`](Self::dark_mode); app-painted
7485    /// surfaces pick it up on their next rebuild. `Cap::Appearance` reports whether the
7486    /// backend honors this; the default ignores the call.
7487    fn set_appearance(&mut self, _dark: Option<bool>) {}
7488
7489    /// Put `badge` on the app's icon in the Dock / launcher / home screen (docs/badge.md).
7490    ///
7491    /// Fire-and-forget, like `set_appearance`: a payload this toolkit cannot render is ignored, and
7492    /// the app probes `Cap::AppBadge{Count,Text,Dot}` before choosing one. Never substitute: a
7493    /// backend that turned `Text("beta")` into `Count(1)` would put a wrong number on a user's
7494    /// icon, which is worse than showing nothing. The default ignores every call.
7495    fn set_app_badge(&mut self, _badge: &AppBadge) {}
7496
7497    // app lifecycle (mobile; desktop backends no-op)
7498    fn on_suspend(&mut self) {}
7499    fn on_resume(&mut self) {}
7500    fn on_memory_warning(&mut self) {}
7501
7502    // adoption of foreign native handles (polyglot pieces, §15.3)
7503    fn adopt(&mut self, _raw: RawHandle) -> Self::Handle {
7504        unimplemented!("this toolkit does not adopt foreign handles yet")
7505    }
7506
7507    // secondary windows (docs/windows.md)
7508
7509    /// Open a native OS window per `options` + `kind`: create and show the window, wire its
7510    /// events to `id` (`WindowResized` with the content size in points, `WindowClosed` after
7511    /// the native close, `WindowFocused` on key/active changes), and answer with its content
7512    /// container handle, the same contract as the `ready` root container. Backends whose
7513    /// window creation is asynchronous (a scene, an activity, an ability) answer
7514    /// [`WindowOpenReply::Pending`] and complete later through
7515    /// `day_core::finish_window_open(id, raw, size)`, which goes through [`Toolkit::adopt`].
7516    /// The default answers `Unsupported`; day-core then presents the content as a
7517    /// fullscreen cover in the primary window instead.
7518    fn open_window(
7519        &mut self,
7520        _id: NodeId,
7521        _options: &WindowOptions,
7522        _kind: WindowKind,
7523    ) -> WindowOpenReply<Self::Handle> {
7524        WindowOpenReply::Unsupported
7525    }
7526
7527    /// Close the native window whose content container is `host` (a handle `open_window`
7528    /// produced). Asynchronous: the platform confirms with `Event::WindowClosed` on the
7529    /// window's root node, and only then does day-core tear the subtree down. Idempotent; unknown
7530    /// hosts are ignored. Never called with the primary window's container.
7531    fn close_window(&mut self, _host: &Self::Handle) {}
7532
7533    /// End the application: the last window with [`WindowRole::Primary`] has closed
7534    /// (docs/windows.md). day-core decides when, since it owns the window registry and the role
7535    /// each window carries; the backend supplies only the platform's exit:
7536    /// `NSApplication.terminate`, `QCoreApplication::quit`, `GtkApplication.quit`,
7537    /// `PostQuitMessage`. Everything day-core wanted to happen first (secondary windows
7538    /// disposed, `WillTerminate` delivered) already has by the time this is called.
7539    ///
7540    /// The default does nothing, which is right for the backends whose process lifetime is
7541    /// not theirs to end: a browser tab, an Android activity, an iOS scene.
7542    fn quit_app(&mut self) {}
7543
7544    /// Bring the window whose content container is `host` to front and make it key/active:
7545    /// the focus half of open-or-focus singleton windows. Default no-op.
7546    fn focus_window(&mut self, _host: &Self::Handle) {}
7547
7548    /// Retitle the window whose content container is `host`. Default no-op.
7549    fn set_window_title(&mut self, _host: &Self::Handle, _title: &str) {}
7550
7551    /// Resize the window whose content container is `host` to fit `size` of content, and pin it
7552    /// there: what `WindowOptions::size_to_fit` asks for (docs/windows.md).
7553    ///
7554    /// `size` is the content size Day measured, not the window's outer frame: the backend adds
7555    /// its chrome. A settings panel is the case this exists for, and every desktop platform
7556    /// sizes one to its content rather than to a number the app guessed; a fixed height either
7557    /// clips the last row or leaves a band of empty panel under it, and which one you get depends
7558    /// on the user's text size.
7559    ///
7560    /// Default no-op, so a toolkit that cannot resize a live window keeps the size it was
7561    /// opened at, which is what every backend did before this existed.
7562    fn fit_window(&mut self, _host: &Self::Handle, _size: Size) {}
7563
7564    /// Snapshot the window whose content container is `host` (the dayscript `screenshot`
7565    /// step's `window:` target). The default answers the primary snapshot, so backends that
7566    /// never open windows stay correct without changes.
7567    fn snapshot_window_of(&mut self, _host: &Self::Handle) -> Result<Vec<u8>, String> {
7568        self.snapshot_window()
7569    }
7570
7571    /// Apply one property change to the window whose content container is `host`
7572    /// (docs/windows.md "Window properties"). The primary window arrives with its root container,
7573    /// like [`set_window_title`](Self::set_window_title).
7574    ///
7575    /// Fire-and-forget, like [`set_app_badge`](Self::set_app_badge): a change this toolkit cannot
7576    /// make is ignored, and the app probes the matching `Cap` (named on each [`WindowChange`]
7577    /// variant) before relying on it. A change the platform completes later (a fullscreen
7578    /// transition, a zoom animation) reports its outcome through
7579    /// [`Event::WindowStateChanged`], never by assuming the request took. The default ignores
7580    /// every change.
7581    fn apply_window(&mut self, _host: &Self::Handle, _change: &WindowChange) {}
7582
7583    /// True OS window groups, as distinct from native tab widgets.
7584    fn native_window_tabs(&self) -> bool {
7585        false
7586    }
7587    /// Native in-window document/section tab control; OS grouping is reported separately.
7588    fn native_document_tabs(&self) -> bool {
7589        false
7590    }
7591    /// Join/reorder these windows in an OS tab group. Content remains window-owned.
7592    fn group_windows(&mut self, _hosts: &[Self::Handle]) {}
7593    /// Current native order of the group containing this window, including detached groups.
7594    fn window_tab_order(&self, _host: &Self::Handle) -> Vec<NodeId> {
7595        Vec::new()
7596    }
7597
7598    /// Install the app's Dock menu (docs/menus.md "Dock menu"): the items macOS shows above
7599    /// its own when the user right-clicks the app's Dock icon, built from the app's
7600    /// `[[shortcuts]]` and its `dock_menu(..)` entries. Same item model and dispatch as
7601    /// [`set_app_menu`](Self::set_app_menu); an empty slice removes the menu. `Cap::DockMenu`
7602    /// reports support; the default ignores the call.
7603    fn set_dock_menu(&mut self, _items: &[MenuItem]) {}
7604
7605    /// Make the node at `h` move its window when the user drags it, as a title bar does
7606    /// (`.window_drag_region()`, docs/window-chrome.md): the region's own background starts a
7607    /// native window drag (with the platform's snapping and edge tiling), a double click does
7608    /// what the user's title-bar double-click setting says, and controls inside the region keep
7609    /// receiving their own clicks. `false` releases it. `Cap::DragRegion`; the default ignores
7610    /// the call, so on a phone the region is an ordinary view.
7611    fn set_drag_region(&mut self, _h: &Self::Handle, _drag: bool) {}
7612
7613    /// Show these status items, replacing the previous set (docs/status-item.md): add the
7614    /// ones whose `id` is new, update the ones that changed, remove the ones no longer listed.
7615    /// A click on an item runs its `activate` action or opens its menu; menu choices dispatch
7616    /// [`Event::MenuAction`] like the app menu's. `Cap::StatusItem`; the default ignores the
7617    /// call, which is what a phone and the web do.
7618    fn set_status_items(&mut self, _items: &[StatusItemSpec]) {}
7619
7620    /// Show `progress` on the app's Dock or taskbar icon (docs/status-item.md).
7621    /// `Cap::AppProgress`; the default ignores it.
7622    fn set_app_progress(&mut self, _progress: AppProgress) {}
7623
7624    /// Show or hide the app's Dock icon while it runs: a menu-bar app hides it and lives in its
7625    /// status item (macOS activation policy). `Cap::DockVisibility`; the default ignores it.
7626    fn set_dock_visible(&mut self, _visible: bool) {}
7627
7628    /// Keep the process running when its last window closes (docs/windows.md "Keeping the app
7629    /// running"). When `true`, the backend must not end the app on its own as windows close: it
7630    /// reports every close, the first window's included, as `Event::WindowClosed`, and day-core
7631    /// decides whether to call [`quit_app`](Self::quit_app). When `false`, the backend keeps its
7632    /// platform's own rule. A backend whose process lifetime is not its own (a phone, a browser
7633    /// tab) ignores it, which is the default.
7634    fn set_keep_running(&mut self, _keep: bool) {}
7635
7636    /// The displays attached now (docs/windows.md "Monitors"). `Cap::Monitors`; the default
7637    /// answers none, and day-core then describes the primary window's own size as one display.
7638    fn monitors(&self) -> Vec<Monitor> {
7639        Vec::new()
7640    }
7641
7642    /// The outer frame of the window whose content container is `host`, in points on the
7643    /// desktop's coordinate space (top-left origin), or `None` where the platform does not say
7644    /// (a phone, Wayland's positions). Read when the app asks for it and when day-core saves a
7645    /// remembered frame (`WindowOptions::remember_frame`).
7646    fn window_frame(&self, _host: &Self::Handle) -> Option<Rect> {
7647        None
7648    }
7649
7650    /// Replace the launcher shortcuts the app set while running (docs/deep-links.md "Launcher
7651    /// shortcuts"), shown beside the ones Day.toml declares: iOS Home Screen quick actions,
7652    /// Android dynamic shortcuts, the macOS Dock menu. Each opens its route through the deep-link
7653    /// intake. `Cap::DynamicShortcuts`; the default ignores the call.
7654    fn set_launcher_shortcuts(&mut self, _shortcuts: &[LauncherShortcut]) {}
7655}
7656
7657/// What a secondary window is, so backends can apply platform conventions (docs/windows.md).
7658#[derive(Clone, Copy, PartialEq, Eq, Debug)]
7659pub enum WindowKind {
7660    /// A regular document/tool window: resizable, miniaturizable, platform-default tabbing.
7661    Normal,
7662    /// A settings/preferences window: singleton by convention; macOS disallows window
7663    /// tabbing and drops the resize/minimize chrome, mobile presents it modally. Pairs with
7664    /// day-core's key-singleton reopen (`open_window` with a key).
7665    Preferences,
7666    /// The About window (docs/settings.md): the same fixed, centered, untabbed panel chrome
7667    /// as [`Self::Preferences`] (every desktop treats the two alike), presented modally on
7668    /// mobile. Singleton under day-core's `day.about` key.
7669    About,
7670}
7671
7672impl WindowKind {
7673    /// Whether this is a panel (settings or About) rather than a document window: the kinds
7674    /// every desktop draws fixed-size, centered and out of the Window menu, and mobile presents
7675    /// as a cover.
7676    pub fn is_panel(self) -> bool {
7677        !matches!(self, WindowKind::Normal)
7678    }
7679}
7680
7681/// Whether a window keeps the app alive (docs/windows.md close policy).
7682///
7683/// A separate axis from [`WindowKind`], which describes chrome. They correlate (a
7684/// preferences panel is the archetypal window that should not hold an app open), but they are
7685/// not the same question, and conflating them would leave no way to say "an ordinary-looking
7686/// tool window that shouldn't outlive the documents".
7687///
7688/// Note the word "secondary" is overloaded around windows: elsewhere in day it means "a window
7689/// other than the first one opened". Here it means "does not keep the app alive", which is
7690/// about lifetime, not about order of creation.
7691#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
7692pub enum WindowRole {
7693    /// Closing the last one of these ends the app.
7694    #[default]
7695    Primary,
7696    /// Never keeps the app alive: when the last `Primary` closes, the app exits and takes
7697    /// these with it, however many are open.
7698    Secondary,
7699}
7700
7701impl From<WindowKind> for WindowRole {
7702    /// The default pairing: an ordinary window holds the app open, a preferences panel does
7703    /// not. An app that needs a different answer sets the role itself.
7704    fn from(kind: WindowKind) -> WindowRole {
7705        match kind {
7706            WindowKind::Normal => WindowRole::Primary,
7707            WindowKind::Preferences | WindowKind::About => WindowRole::Secondary,
7708        }
7709    }
7710}
7711
7712/// A backend's answer to [`Toolkit::open_window`] (docs/windows.md).
7713pub enum WindowOpenReply<H> {
7714    /// The window exists now; here is its content container (desktop backends).
7715    Open(H),
7716    /// Native creation started but completes asynchronously (a scene, an activity, an
7717    /// ability); the backend will call `day_core::finish_window_open` when the content
7718    /// container exists. day-core parks the window record until then.
7719    Pending,
7720    /// This toolkit cannot open windows (`Cap::MultiWindow` = `Unsupported`); day-core
7721    /// falls back to presenting the content as a fullscreen cover in the primary window.
7722    Unsupported,
7723}
7724
7725/// How a window is shown (docs/windows.md "Window properties"): what the window's `state()`
7726/// signal holds and what [`WindowChange::State`] asks for.
7727#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
7728pub enum WindowState {
7729    /// On screen at its own size: neither minimized, maximized nor fullscreen.
7730    #[default]
7731    Normal,
7732    /// Minimized to the Dock, taskbar or shelf. `Cap::WindowStates`.
7733    Minimized,
7734    /// Zoomed to fill the screen's work area, keeping its chrome. `Cap::WindowStates`.
7735    Maximized,
7736    /// Covering the whole screen with no window chrome; on a phone, the system bars hidden.
7737    /// `Cap::WindowFullscreen`.
7738    Fullscreen,
7739}
7740
7741impl WindowState {
7742    /// The wire code of [`bridge::BridgeKind::WindowStateChanged`].
7743    pub const fn code(self) -> i32 {
7744        match self {
7745            WindowState::Normal => 0,
7746            WindowState::Minimized => 1,
7747            WindowState::Maximized => 2,
7748            WindowState::Fullscreen => 3,
7749        }
7750    }
7751
7752    /// The state a wire code names; an unknown code reads as `Normal`.
7753    pub const fn from_code(code: i32) -> WindowState {
7754        match code {
7755            1 => WindowState::Minimized,
7756            2 => WindowState::Maximized,
7757            3 => WindowState::Fullscreen,
7758            _ => WindowState::Normal,
7759        }
7760    }
7761}
7762
7763/// How a window stacks against other apps' windows ([`WindowChange::Level`], `Cap::WindowLevel`).
7764#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
7765pub enum WindowLevel {
7766    /// Ordinary stacking: the focused app's windows come forward.
7767    #[default]
7768    Normal,
7769    /// Stays above other apps' ordinary windows: a palette, a timer, a video peek.
7770    Floating,
7771}
7772
7773/// How strongly a window asks for the user's attention ([`WindowChange::RequestAttention`]).
7774#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
7775pub enum Attention {
7776    /// Once: one Dock bounce, a taskbar button that flashes a few times.
7777    #[default]
7778    Informational,
7779    /// Until the user switches to the app.
7780    Critical,
7781}
7782
7783/// One change to one window (docs/windows.md "Window properties"), carried by
7784/// [`Toolkit::apply_window`]. Each variant names the `Cap` that reports whether the running
7785/// toolkit makes it.
7786#[derive(Clone, Debug, PartialEq)]
7787pub enum WindowChange {
7788    /// Minimize, zoom, take fullscreen, or return to normal. `Cap::WindowStates` for the
7789    /// first two, `Cap::WindowFullscreen` for fullscreen. The outcome comes back as
7790    /// [`Event::WindowStateChanged`].
7791    State(WindowState),
7792    /// Keep the window's content out of screenshots, recordings and screen sharing.
7793    /// `Cap::ContentProtection`.
7794    ContentProtected(bool),
7795    /// Move and/or resize. `origin` is the outer frame's top-left corner in points on the
7796    /// desktop's coordinate space (top-left origin, `Cap::WindowPosition`); `size` is the size Day
7797    /// lays the content out at, in points, the same size `WindowResized` reports
7798    /// (`Cap::WindowGeometry`): the backend adds its own chrome around it.
7799    Frame {
7800        origin: Option<Point>,
7801        size: Option<Size>,
7802    },
7803    /// The content size the user can resize the window to. `None` lifts that bound.
7804    /// `Cap::WindowGeometry`.
7805    Limits {
7806        min: Option<Size>,
7807        max: Option<Size>,
7808    },
7809    /// Stacking against other apps' windows. `Cap::WindowLevel`.
7810    Level(WindowLevel),
7811    /// Show the window on every virtual desktop / Space. `Cap::WindowLevel`.
7812    OnAllWorkspaces(bool),
7813    /// Leave the window out of the taskbar and the window switcher. `Cap::WindowLevel`.
7814    SkipTaskbar(bool),
7815    /// Whether the user can resize the window. `Cap::WindowGeometry`.
7816    Resizable(bool),
7817    /// Whether the window offers a minimize control. `Cap::WindowStates`.
7818    Minimizable(bool),
7819    /// Whether the window offers a maximize / zoom control. `Cap::WindowStates`.
7820    Maximizable(bool),
7821    /// Whether the window offers a close control. `Cap::WindowStates`.
7822    Closable(bool),
7823    /// Hide the window without closing it, or show it again. `Cap::WindowStates`.
7824    Visible(bool),
7825    /// This window's light/dark override over the app-wide [`Toolkit::set_appearance`].
7826    /// `Cap::WindowAppearance`.
7827    Appearance(Option<bool>),
7828    /// Bounce the Dock icon, flash the taskbar button. `Cap::RequestAttention`.
7829    RequestAttention(Attention),
7830}
7831
7832/// The frame a window wears (docs/window-chrome.md), set once when it opens.
7833#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
7834pub enum WindowChrome {
7835    /// The platform's title bar and frame.
7836    #[default]
7837    Standard,
7838    /// The content runs to the window's top edge, under the title bar, and the platform's own
7839    /// window controls (close, minimize, zoom) stay where they are. For a custom title bar: the
7840    /// backend reports the bar's height as the window's safe-area top inset (`day::safe_area()`),
7841    /// so the app pads its content by it and paints its background under it. Mark the draggable
7842    /// part with `.window_drag_region()`. `Cap::OverlayTitleBar`; where unsupported the window
7843    /// keeps its standard bar.
7844    Overlay,
7845    /// No title bar and no frame: a splash screen, a heads-up panel, a borderless utility. The
7846    /// window still resizes from its edges when resizable; mark the part that moves it with
7847    /// `.window_drag_region()`. `Cap::FramelessWindow`; where unsupported the window keeps its
7848    /// standard frame.
7849    Frameless,
7850}
7851
7852/// What shows behind a window's content (docs/window-chrome.md).
7853#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
7854pub enum WindowBackground {
7855    /// The platform's window background.
7856    #[default]
7857    Opaque,
7858    /// Nothing: pixels the content leaves unpainted show the desktop through the window. For
7859    /// rounded or shaped splash screens and overlays. `Cap::TransparentWindow`.
7860    Transparent,
7861    /// A translucent system material that blurs what is behind the window.
7862    /// `Cap::WindowMaterial`; where unsupported the window stays opaque.
7863    Material(WindowMaterial),
7864}
7865
7866/// The system materials a window background can use ([`WindowBackground::Material`]).
7867#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
7868pub enum WindowMaterial {
7869    /// The main-window material: macOS's window background, Windows 11's Mica.
7870    Window,
7871    /// A sidebar or source list: macOS's sidebar material, Windows 11's Mica Alt (tabbed).
7872    Sidebar,
7873    /// A transient surface (a popover, a menu-like panel, a heads-up display): macOS's popover
7874    /// material, Windows 11's Acrylic.
7875    Transient,
7876}
7877
7878/// Where a window opens ([`WindowOptions::placement`]).
7879#[derive(Clone, Copy, Debug, Default, PartialEq)]
7880pub enum WindowPlacement {
7881    /// The platform's choice: macOS cascades, Windows and Linux let the window manager decide.
7882    #[default]
7883    Automatic,
7884    /// Centered on the screen with the active window.
7885    Centered,
7886    /// The outer frame's top-left corner at this point, in points on the desktop's coordinate
7887    /// space (top-left origin). Falls back to `Automatic` without `Cap::WindowPosition`.
7888    At(Point),
7889}
7890
7891/// A small runtime RGBA image for a status item: charts, meters, or a grid of results.
7892///
7893/// Rows run top to bottom; samples are tightly packed, non-premultiplied sRGB RGBA8.
7894/// `scale` is pixels per logical point (2 for Retina). This owns its bytes, independent of
7895/// any window or decoded-image registry. Currently rendered by AppKit; other backends use
7896/// [`StatusItemSpec::icon`] as a fallback. See docs/status-item.md.
7897#[derive(Clone, PartialEq)]
7898pub struct StatusImage {
7899    width: u32,
7900    height: u32,
7901    scale: f64,
7902    rgba: std::sync::Arc<[u8]>,
7903}
7904
7905impl StatusImage {
7906    /// Validate a runtime image. Rejects empty/mismatched pixels, invalid scales, and images
7907    /// exceeding 16 MiB or 4096 logical points on either axis. No platform allocation occurs.
7908    pub fn rgba(
7909        width: u32,
7910        height: u32,
7911        scale: f64,
7912        pixels: impl Into<std::sync::Arc<[u8]>>,
7913    ) -> Option<Self> {
7914        let rgba = pixels.into();
7915        let expected = (width as usize)
7916            .checked_mul(height as usize)?
7917            .checked_mul(4)?;
7918        if width == 0
7919            || height == 0
7920            || expected > 16 * 1024 * 1024
7921            || rgba.len() != expected
7922            || !scale.is_finite()
7923            || scale <= 0.0
7924            || f64::from(width) / scale > 4096.0
7925            || f64::from(height) / scale > 4096.0
7926        {
7927            return None;
7928        }
7929        Some(Self {
7930            width,
7931            height,
7932            scale,
7933            rgba,
7934        })
7935    }
7936
7937    pub fn width(&self) -> u32 {
7938        self.width
7939    }
7940    pub fn height(&self) -> u32 {
7941        self.height
7942    }
7943    pub fn scale(&self) -> f64 {
7944        self.scale
7945    }
7946    pub fn pixels(&self) -> &[u8] {
7947        &self.rgba
7948    }
7949    pub fn size(&self) -> Size {
7950        Size::new(
7951            f64::from(self.width) / self.scale,
7952            f64::from(self.height) / self.scale,
7953        )
7954    }
7955}
7956
7957impl std::fmt::Debug for StatusImage {
7958    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
7959        f.debug_struct("StatusImage")
7960            .field("width", &self.width)
7961            .field("height", &self.height)
7962            .field("scale", &self.scale)
7963            .field("bytes", &self.rgba.len())
7964            .finish()
7965    }
7966}
7967
7968#[cfg(test)]
7969mod status_image_tests {
7970    use super::*;
7971
7972    #[test]
7973    fn validates_pixel_layout_and_logical_size() {
7974        let image = StatusImage::rgba(120, 36, 2.0, vec![255; 120 * 36 * 4]).unwrap();
7975        assert_eq!(image.size(), Size::new(60.0, 18.0));
7976        assert_eq!(image.clone(), image);
7977        assert!(StatusImage::rgba(0, 1, 1.0, vec![]).is_none());
7978        assert!(StatusImage::rgba(1, 1, 1.0, vec![0; 3]).is_none());
7979        assert!(StatusImage::rgba(u32::MAX, u32::MAX, 1.0, vec![]).is_none());
7980        for scale in [0.0, -1.0, f64::NAN, f64::INFINITY, 0.00001] {
7981            assert!(StatusImage::rgba(1, 1, scale, vec![0; 4]).is_none());
7982        }
7983    }
7984}
7985
7986/// One status item: an icon in the menu bar (macOS), the notification area (Windows) or a
7987/// StatusNotifierItem host's tray (Linux), with a menu (docs/status-item.md).
7988#[derive(Clone, Debug, PartialEq)]
7989pub struct StatusItemSpec {
7990    /// The app's name for the item, stable across updates: what a backend diffs by.
7991    pub id: String,
7992    /// The glyph. `None` shows the title alone (macOS) or the app's icon (elsewhere).
7993    pub icon: Option<Icon>,
7994    /// A runtime image. AppKit prefers this over `icon`, preserving aspect ratio and point
7995    /// size up to 18pt high; other backends ignore it and show `icon`/`title` as before.
7996    pub raster: Option<StatusImage>,
7997    /// Draw the icon as a template: its shape only, in the menu bar's or panel's own color, so
7998    /// it reads in light and dark. Symbols are always templates.
7999    pub template: bool,
8000    /// Text beside the icon where the platform shows one (the macOS menu bar). Empty: icon only.
8001    pub title: String,
8002    /// The hover text.
8003    pub tooltip: String,
8004    /// The menu the item opens. Same model and dispatch as the app menu.
8005    pub menu: Vec<MenuItem>,
8006    /// A dispatch id (the [`Event::MenuAction`] registry) run when the user clicks the item
8007    /// itself, where the platform separates a click from opening the menu (a left click on macOS
8008    /// and Windows, the host's "activate" on Linux). 0: a click opens the menu.
8009    pub activate: u64,
8010}
8011
8012/// One display (docs/windows.md "Monitors"), in points on the desktop's coordinate space:
8013/// top-left origin at the primary display's top-left corner.
8014#[derive(Clone, Debug, PartialEq)]
8015pub struct Monitor {
8016    /// Stable for the display across launches where the platform offers it (a display UUID, a
8017    /// connector name); an index otherwise.
8018    pub id: String,
8019    /// The display's name, for a picker ("Built-in Retina Display", "DELL U2720Q").
8020    pub name: String,
8021    /// The display's whole area.
8022    pub frame: Rect,
8023    /// The part windows should use: the frame minus the menu bar, Dock, taskbar or panels.
8024    pub work_area: Rect,
8025    /// Device pixels per point.
8026    pub scale: f64,
8027    /// The display the desktop's menu bar or primary taskbar is on.
8028    pub primary: bool,
8029}
8030
8031/// A launcher shortcut the app sets while it runs (docs/deep-links.md "Launcher shortcuts"): a
8032/// route to open and the label the launcher shows, already localized.
8033#[derive(Clone, Debug, PartialEq, Eq)]
8034pub struct LauncherShortcut {
8035    /// The route the shortcut opens, through the deep-link intake.
8036    pub route: String,
8037    /// What the launcher shows.
8038    pub label: String,
8039}
8040
8041/// A progress indicator on the app's Dock or taskbar icon ([`Toolkit::set_app_progress`],
8042/// docs/status-item.md).
8043#[derive(Clone, Copy, Debug, Default, PartialEq)]
8044pub enum AppProgress {
8045    /// No indicator.
8046    #[default]
8047    None,
8048    /// Work of unknown length.
8049    Indeterminate,
8050    /// A fraction done, 0.0 to 1.0.
8051    Value(f64),
8052    /// A fraction done, paused. Platforms without a paused state show `Value`.
8053    Paused(f64),
8054    /// A fraction done when the work failed. Platforms without an error state show `Value`.
8055    Error(f64),
8056}
8057
8058/// Whether and how a window joins macOS window tabs ([`WindowOptions::tabbing`],
8059/// `Cap::WindowTabbing`). Other platforms have no system window tabs and ignore it.
8060#[derive(Clone, Debug, Default, PartialEq, Eq, Hash)]
8061pub enum WindowTabbing {
8062    /// The platform default for the window's kind: an ordinary window tabs with the app's other
8063    /// ordinary windows when the user's "Prefer tabs" setting says so; preferences never tab.
8064    #[default]
8065    Automatic,
8066    /// Tab only with windows of the same group, following the user's setting. The tab bar's
8067    /// "+" opens the builder registered for this group (`register_new_window_for`).
8068    Group(String),
8069    /// Always open as a tab of an existing window of the same group, whatever the setting.
8070    Preferred(String),
8071    /// Never become a tab.
8072    Disallowed,
8073}
8074
8075/// An app's generated locale catalog: `(DEFAULT, CATALOG)`, exactly what `day-build` emits as
8076/// `res::locales::{DEFAULT, CATALOG}` (§18.5). Carried in [`WindowOptions::locales`] so the
8077/// framework can install it at the one moment that is correct.
8078pub type AppLocales = (&'static str, &'static [(&'static str, &'static str)]);
8079
8080#[derive(Clone, Debug)]
8081pub struct WindowOptions {
8082    pub title: String,
8083    pub size: Size,
8084    pub min_size: Option<Size>,
8085    /// Create and mount the window without showing it or activating the app. Supported on
8086    /// AppKit; other backends currently ignore this option. Defaults to false.
8087    ///
8088    /// Menu-bar apps can keep their initial host hidden and open Preferences on demand.
8089    /// The window still exists and can be shown with `WindowHandle::set_visible(true)`;
8090    /// this does not change the app's keep-running policy.
8091    pub start_hidden: bool,
8092    /// Ask the backend to size this window to its content once it has been built and laid out,
8093    /// rather than keeping [`Self::size`] (docs/windows.md). `size` still decides the width and
8094    /// acts as the height ceiling, so a panel with more content than fits the screen scrolls
8095    /// instead of growing past it.
8096    ///
8097    /// What a preferences panel wants on every desktop. Ignored by backends that cannot resize a
8098    /// live window, and by the ones with no windows at all.
8099    pub size_to_fit: bool,
8100    /// The app's display name for the standard application menu / About (macOS). `None` falls back
8101    /// to `title`; set it when `title` carries extra decoration you don't want in `About <name>`
8102    /// (e.g. the showcase's window title is "Day Showcase (AppKit)" but its app name is "Showcase").
8103    pub app_name: Option<String>,
8104    /// The app's locale catalog, installed by `day::launch` (docs/localization.md).
8105    ///
8106    /// The order is why this is the framework's job: the OS's languages reach day-localization from the
8107    /// live backend, and the catalog has to be registered after that hint and before the first
8108    /// localized string is read. An app that installs it itself (before `launch`, where there
8109    /// is no backend yet) registers against an empty hint list and resolves to `DEFAULT`, so a
8110    /// French device opens an English window.
8111    pub locales: Option<AppLocales>,
8112    /// The title, computed once the catalog above is installed: how a window title comes from
8113    /// the catalog rather than a literal. Takes precedence over [`Self::title`], which stays the
8114    /// fallback for the moment before (and for apps with nothing to translate).
8115    ///
8116    /// A plain `fn` rather than a closure: this struct is `Clone`, and the callers are a
8117    /// non-capturing `|| res::str::app_title().format()`, which coerces.
8118    pub title_fn: Option<fn() -> String>,
8119    /// The app's version, for Day's exit line (`Clean exit for <app> v<version> …`,
8120    /// docs/lifecycle.md): `env!("CARGO_PKG_VERSION")` in the app crate, which the scaffold's
8121    /// `window()` sets. `None` falls back to the `DAY_APP_VERSION` the `day` CLI sets on a launch.
8122    pub version: Option<String>,
8123    /// macOS window tabbing for this window (`Cap::WindowTabbing`); ignored elsewhere.
8124    pub tabbing: WindowTabbing,
8125    /// The window's frame: standard, a title bar the content runs under, or none
8126    /// (docs/window-chrome.md). Desktop only; phones and the web ignore it.
8127    pub chrome: WindowChrome,
8128    /// What shows behind the content: the window background, nothing, or a system material.
8129    pub background: WindowBackground,
8130    /// Whether the window casts the platform's drop shadow. A transparent, shaped window often
8131    /// wants none.
8132    pub shadow: bool,
8133    /// Whether the user can resize the window by its edges.
8134    pub resizable: bool,
8135    /// Where the window opens.
8136    pub placement: WindowPlacement,
8137    /// The largest content size the user can resize the window to (`Cap::WindowGeometry`).
8138    pub max_size: Option<Size>,
8139    /// Remember this window's size, position and maximized state under this key, and reopen it
8140    /// that way next launch (docs/windows.md "Remembered frames"). A position that no attached
8141    /// display shows any more is dropped, so a window never reopens off-screen.
8142    pub remember_frame: Option<String>,
8143    /// The logical window this opens (docs/windows.md): `open_window` focuses an already-open
8144    /// window with the same key instead of opening a second (the preferences pattern). `None`
8145    /// always opens a new one.
8146    pub key: Option<String>,
8147    /// The kind of window: a regular one, a preferences panel, an About panel.
8148    pub kind: WindowKind,
8149}
8150
8151impl WindowOptions {
8152    /// The window title. Replaces a [`title_fn`](Self::title_fn) set earlier (the generated
8153    /// `res::window_options()` sets one), so the last title named wins.
8154    pub fn title(mut self, title: impl Into<String>) -> Self {
8155        self.title = title.into();
8156        self.title_fn = None;
8157        self
8158    }
8159    /// The content size the window opens at (desktop; phones fill the screen).
8160    pub fn size(mut self, width: f64, height: f64) -> Self {
8161        self.size = Size::new(width, height);
8162        self
8163    }
8164    /// The smallest content size the user can resize to.
8165    pub fn min_size(mut self, width: f64, height: f64) -> Self {
8166        self.min_size = Some(Size::new(width, height));
8167        self
8168    }
8169    /// The largest content size the user can resize to (`Cap::WindowGeometry`).
8170    pub fn max_size(mut self, width: f64, height: f64) -> Self {
8171        self.max_size = Some(Size::new(width, height));
8172        self
8173    }
8174    /// Create and mount the window without showing it; see [`Self::start_hidden`].
8175    pub fn start_hidden(mut self) -> Self {
8176        self.start_hidden = true;
8177        self
8178    }
8179    /// Size the window to its content once built; see [`Self::size_to_fit`].
8180    pub fn size_to_fit(mut self) -> Self {
8181        self.size_to_fit = true;
8182        self
8183    }
8184    /// The app's display name for the application menu and About; see [`Self::app_name`].
8185    pub fn app_name(mut self, name: impl Into<String>) -> Self {
8186        self.app_name = Some(name.into());
8187        self
8188    }
8189    /// The app's locale catalog, as `res::locales::{DEFAULT, CATALOG}`; see [`Self::locales`].
8190    pub fn locales(
8191        mut self,
8192        default: &'static str,
8193        catalog: &'static [(&'static str, &'static str)],
8194    ) -> Self {
8195        self.locales = Some((default, catalog));
8196        self
8197    }
8198    /// The title, computed once the catalog is installed; see [`Self::title_fn`].
8199    pub fn title_fn(mut self, f: fn() -> String) -> Self {
8200        self.title_fn = Some(f);
8201        self
8202    }
8203    /// The app's version, for Day's exit line; see [`Self::version`].
8204    pub fn version(mut self, version: impl Into<String>) -> Self {
8205        self.version = Some(version.into());
8206        self
8207    }
8208    /// macOS window tabbing for this window; see [`Self::tabbing`].
8209    pub fn tabbing(mut self, tabbing: WindowTabbing) -> Self {
8210        self.tabbing = tabbing;
8211        self
8212    }
8213    /// The window's frame; see [`Self::chrome`].
8214    pub fn chrome(mut self, chrome: WindowChrome) -> Self {
8215        self.chrome = chrome;
8216        self
8217    }
8218    /// What shows behind the content; see [`Self::background`].
8219    pub fn background(mut self, background: WindowBackground) -> Self {
8220        self.background = background;
8221        self
8222    }
8223    /// No drop shadow, for a transparent or shaped window; see [`Self::shadow`].
8224    pub fn no_shadow(mut self) -> Self {
8225        self.shadow = false;
8226        self
8227    }
8228    /// A window the user cannot resize; see [`Self::resizable`].
8229    pub fn fixed_size(mut self) -> Self {
8230        self.resizable = false;
8231        self
8232    }
8233    /// Where the window opens; see [`Self::placement`].
8234    pub fn placement(mut self, placement: WindowPlacement) -> Self {
8235        self.placement = placement;
8236        self
8237    }
8238    /// Remember and restore this window's frame under `key`; see [`Self::remember_frame`].
8239    pub fn remember_frame(mut self, key: impl Into<String>) -> Self {
8240        self.remember_frame = Some(key.into());
8241        self
8242    }
8243    /// The logical window this opens; see [`Self::key`].
8244    pub fn key(mut self, key: impl Into<String>) -> Self {
8245        self.key = Some(key.into());
8246        self
8247    }
8248    /// The kind of window; see [`Self::kind`].
8249    pub fn kind(mut self, kind: WindowKind) -> Self {
8250        self.kind = kind;
8251        self
8252    }
8253}
8254
8255impl Default for WindowOptions {
8256    fn default() -> Self {
8257        WindowOptions {
8258            title: "Day".into(),
8259            size: Size::new(480.0, 640.0),
8260            min_size: None,
8261            start_hidden: false,
8262            size_to_fit: false,
8263            app_name: None,
8264            locales: None,
8265            title_fn: None,
8266            version: None,
8267            tabbing: WindowTabbing::Automatic,
8268            chrome: WindowChrome::Standard,
8269            background: WindowBackground::Opaque,
8270            shadow: true,
8271            resizable: true,
8272            placement: WindowPlacement::Automatic,
8273            max_size: None,
8274            remember_frame: None,
8275            key: None,
8276            kind: WindowKind::Normal,
8277        }
8278    }
8279}
8280
8281/// A platform backend: owns the native main loop and exactly one window in v1 (§8.1).
8282///
8283/// `run` sets up the native app + window, installs the reactive scheduler + main poster,
8284/// then hands `(self, root_container, content_size)` to `ready` (which mounts the tree and
8285/// takes ownership of the backend), and finally runs the native main loop.
8286pub trait Platform: Toolkit {
8287    /// The process-constant target id, e.g. `"macos-appkit"`.
8288    const TARGET: &'static str;
8289    /// The toolkit half of the target, e.g. `"appkit"`.
8290    const TOOLKIT: &'static str;
8291    /// Whether [`Platform::run`] returning means the app is over. True for a backend that
8292    /// drives the native loop itself until the app quits (GTK, Qt, XAML; AppKit's never
8293    /// returns). False where the host owns the loop and `run` hands back at once, with the
8294    /// app still to come (ArkUI's ability, Android's activity, the web page, the mock's test):
8295    /// their `DidExit` is the host's last callback, not the return (docs/lifecycle.md).
8296    const RUN_ENDS_APP: bool = true;
8297
8298    fn run(self, options: WindowOptions, ready: Box<dyn FnOnce(Self, Self::Handle, Size)>);
8299
8300    /// Post a closure onto the native main loop. Callable from any thread; this is the
8301    /// single door the reactive scheduler and `Setter` deliveries ride (§3.3).
8302    fn post(f: Box<dyn FnOnce() + Send>);
8303
8304    /// Post `f` onto the native main loop after (at least) `ms` milliseconds: the timer
8305    /// door behind `day::sleep` (docs/async.md). Every shipped backend overrides it with the
8306    /// loop's own timer (`dispatch_after`, `Handler.postDelayed`, `g_timeout_add`,
8307    /// `QTimer::singleShot`, `SetTimer`, `setTimeout`), so a sleep costs no thread. The
8308    /// default is the fallback for a backend without one: a helper thread that sleeps the
8309    /// whole delay and rides [`Platform::post`] home. It is correct, but a thread per sleep
8310    /// adds up under a ticking loop, so the first use says so once in the log.
8311    fn post_delayed(ms: u32, f: Box<dyn FnOnce() + Send>) {
8312        static SAID: std::sync::Once = std::sync::Once::new();
8313        SAID.call_once(|| {
8314            log::warn!(
8315                "day: {} has no native timer door; `day::sleep` falls back to a helper thread \
8316                 per delay (implement `Platform::post_delayed`)",
8317                Self::TARGET
8318            );
8319        });
8320        std::thread::spawn(move || {
8321            std::thread::sleep(std::time::Duration::from_millis(u64::from(ms)));
8322            Self::post(f);
8323        });
8324    }
8325
8326    /// Ordered OS locale preference list (BCP-47), for fluent-langneg (§12.2).
8327    fn locale_hints(&self) -> Vec<String> {
8328        Vec::new()
8329    }
8330}
8331
8332// ---------------------------------------------------------------------------
8333// Open renderer registry (§8.2)
8334// ---------------------------------------------------------------------------
8335
8336/// The scale a window snapshot is rendered at, when a scripted run states one
8337/// (`DAY_CAPTURE_SCALE=2`, set by `day launch --script` from Day.toml `[screenshots]`).
8338///
8339/// For the backends that render their own snapshot (GTK, Qt): the capture then measures
8340/// window points times this, whatever display the host has, which is what makes a CI capture
8341/// under a 1x xvfb the same 2560x1600 pixels a HiDPI laptop produces. A backend that reads the
8342/// compositor's pixels back (AppKit, XAML) captures at the display's own scale and ignores it.
8343/// `None` when unset or unparseable: capture at the backend's natural scale.
8344pub fn capture_scale() -> Option<f64> {
8345    std::env::var("DAY_CAPTURE_SCALE")
8346        .ok()
8347        .and_then(|v| v.trim().parse::<f64>().ok())
8348        .filter(|s| s.is_finite() && (0.5..=4.0).contains(s))
8349}
8350
8351/// Seconds on a monotonic clock since the first call: the frame timestamp for a backend that
8352/// does not expose a native timestamp (§8.4). The origin is arbitrary. Not for the web
8353/// build, where `Instant` is unavailable and `requestAnimationFrame` stamps the frame itself.
8354pub fn frame_timestamp() -> f64 {
8355    static ORIGIN: std::sync::OnceLock<std::time::Instant> = std::sync::OnceLock::new();
8356    ORIGIN
8357        .get_or_init(std::time::Instant::now)
8358        .elapsed()
8359        .as_secs_f64()
8360}
8361
8362/// The user's language preference from the POSIX environment, newest-first, as BCP-47 tags.
8363///
8364/// `LANGUAGE` is the GNU multi-language list (`fr:en`); `LC_ALL`, `LC_MESSAGES` and `LANG` each
8365/// carry one locale in POSIX form (`fr_FR.UTF-8`), which becomes `fr-FR`. Shared by the backends
8366/// whose platform has no richer API to ask (§12.2, docs/localization.md).
8367pub fn posix_locale_hints() -> Vec<String> {
8368    let mut out: Vec<String> = Vec::new();
8369    let mut push = |tag: &str| {
8370        // "C" and "POSIX" are the absence of a preference, not a language.
8371        let tag = tag.split('.').next().unwrap_or(tag).replace('_', "-");
8372        if tag.is_empty() || tag == "C" || tag == "POSIX" {
8373            return;
8374        }
8375        if !out.contains(&tag) {
8376            out.push(tag);
8377        }
8378    };
8379    if let Ok(list) = std::env::var("LANGUAGE") {
8380        for tag in list.split(':') {
8381            push(tag);
8382        }
8383    }
8384    for key in ["LC_ALL", "LC_MESSAGES", "LANG"] {
8385        if let Ok(v) = std::env::var(key) {
8386            push(&v);
8387        }
8388    }
8389    out
8390}
8391
8392/// Optional custom measure for a third-party piece (§8.2).
8393pub type MeasureFn<B> = fn(&mut B, &<B as Toolkit>::Handle, Proposal) -> Size;
8394
8395/// A third-party piece's per-toolkit implementation. `make` receives the concrete backend
8396/// (public helper surface) and returns a native handle the backend then owns like any built-in.
8397pub struct Renderer<B: Toolkit> {
8398    pub kind: PieceKind,
8399    pub make: fn(&mut B, &dyn Any, NodeId) -> B::Handle,
8400    pub update: fn(&mut B, &B::Handle, &dyn Any),
8401    pub measure: Option<MeasureFn<B>>,
8402    /// Teardown, run from the release queue just before [`Toolkit::release`] frees the handle.
8403    ///
8404    /// A piece that keeps per-view state (a retained delegate, a session map, a native
8405    /// observer) has no other place to drop it: `make`/`update` are the only other hooks, and a
8406    /// disposed node never calls them again. Without this a piece leaks one entry per realized
8407    /// view, and any map keyed by the handle's address can go on to answer for a later view that
8408    /// the allocator hands the same address.
8409    ///
8410    /// `None` for the pieces that own nothing (most of them).
8411    pub release: Option<fn(&mut B, &B::Handle)>,
8412}
8413
8414pub struct Registry<B: Toolkit> {
8415    map: HashMap<PieceKind, Renderer<B>>,
8416}
8417
8418impl<B: Toolkit> Default for Registry<B> {
8419    fn default() -> Self {
8420        Registry {
8421            map: HashMap::new(),
8422        }
8423    }
8424}
8425
8426impl<B: Toolkit> Registry<B> {
8427    pub fn register(&mut self, r: Renderer<B>) {
8428        let kind = r.kind;
8429        if self.map.insert(kind, r).is_some() {
8430            // Two pieces claiming one kind is last-linked-wins in link order, effectively
8431            // nondeterministic. Fail loudly in debug; in release, say so once at boot rather
8432            // than render the wrong widget silently.
8433            debug_assert!(
8434                false,
8435                "duplicate renderer registered for piece kind {kind:?}"
8436            );
8437            log::warn!("duplicate renderer for piece kind {kind:?} — later registration wins");
8438        }
8439    }
8440    pub fn get(&self, kind: PieceKind) -> Option<&Renderer<B>> {
8441        self.map.get(kind)
8442    }
8443    pub fn kinds(&self) -> impl Iterator<Item = PieceKind> + '_ {
8444        self.map.keys().copied()
8445    }
8446}
8447
8448/// The canvas wire op codes (§11, §15.3): slot 0 of every 9-number record in
8449/// [`encode_ops`]'s output. The numeric values are the frozen wire contract: the JNI, C++,
8450/// and JS decoders each hand-write these as constants, so a value here may never be reused
8451/// or renumbered; new ops append. `ALL` drives the density test that keeps this enum and
8452/// the encoder in step.
8453#[derive(Clone, Copy, Debug, PartialEq, Eq)]
8454#[repr(i32)]
8455pub enum OpCode {
8456    /// Fill a rect (a,b=origin, c,d=size).
8457    FillRect = 0,
8458    /// Stroke a rect (g=width).
8459    StrokeRect = 1,
8460    /// Fill a rounded rect (e=radius).
8461    FillRrect = 2,
8462    /// Fill an ellipse in a rect.
8463    FillEllipse = 3,
8464    /// Stroke an ellipse (g=width).
8465    StrokeEllipse = 4,
8466    /// Stroke an arc (e=start°, f=sweep°, g=width). `Fill(Shape::Arc)` also encodes as
8467    /// this with width 0, the known asymmetry; see [`encode_ops`].
8468    StrokeArc = 5,
8469    /// Line a,b → c,d (g=width).
8470    Line = 6,
8471    /// Text at a,b (e=size, f=anchor: 0 leading / 1 centered); the string rides the texts
8472    /// channel.
8473    Text = 7,
8474    /// Save the graphics state.
8475    Save = 8,
8476    /// Restore the graphics state.
8477    Restore = 9,
8478    /// Concat an affine transform (a..f).
8479    Concat = 10,
8480    /// Fill a polygon; points ride the texts channel as "x,y x,y …" (closed automatically).
8481    /// `a` = the [`geometry_key`] of those points, or 0 for none.
8482    FillPolygon = 11,
8483    /// Stroke a polygon (g=width); points and key as [`OpCode::FillPolygon`].
8484    StrokePolygon = 12,
8485    /// Stroke a rounded rect (e=radius, g=width).
8486    StrokeRrect = 13,
8487    /// Set a gradient for the next shape record (f=type: 0 linear with a,b=start / c,d=end
8488    /// unit points; 1 radial with a,b=center unit point, c=unit radius; e=stop count).
8489    /// Stops ride the texts channel as "offset,aarrggbb offset,aarrggbb …". Unit geometry
8490    /// resolves against the shape's bounding box.
8491    SetGradient = 14,
8492    /// Fill a path (f=fill rule: 0 non-zero / 1 even-odd); segments ride the texts channel
8493    /// (see [`encode_path`]). `a` = the [`geometry_key`] of those segments, or 0 for none.
8494    ///
8495    /// The key is a cache handle, not a payload: a decoder that recognizes it may keep the
8496    /// native geometry it built last time and skip re-parsing the segments, but it must still
8497    /// consume the texts entry either way, because that channel is positional and a skipped
8498    /// entry desynchronizes every record after it.
8499    FillPath = 15,
8500    /// Stroke a path (g=width); segments and key as [`OpCode::FillPath`].
8501    StrokePath = 16,
8502    /// Clip to a shape (f=shape kind: 0 rect / 1 rrect(e=radius) / 2 ellipse / 3 path
8503    /// (e=fill rule) / 4 polygon; a..d=geometry; path/polygon payloads ride the texts
8504    /// channel).
8505    ///
8506    /// For the two payload-bearing kinds (3 and 4) there is no geometry in the slots, so `a`
8507    /// carries the [`geometry_key`] instead and b..d stay 0. The other three kinds put real
8508    /// geometry in a..d and have nothing to cache.
8509    Clip = 17,
8510    /// Stroke style for the next stroke record (a=cap, b=join, c=miter limit, d=dash
8511    /// phase, e=dash count; the dash array rides the texts channel).
8512    StrokeStyle = 18,
8513    /// Font for the next text record (a=CSS weight 100 … 900, 0 = default; b=italic 0/1); the
8514    /// family name rides the texts channel, `""` = the platform's default face. Like
8515    /// [`OpCode::SetGradient`] and [`OpCode::StrokeStyle`] it is consumed by the record that
8516    /// follows and then cleared; a default [`CanvasFont`] emits no record at all, so a drawing
8517    /// without fonts encodes exactly as it did before this op existed.
8518    SetFont = 19,
8519    /// The next shape record is a template, drawn once per stamped position rather than once
8520    /// (docs/canvas.md "Stamping"). `a` = how many positions follow. Like [`OpCode::SetGradient`],
8521    /// [`OpCode::StrokeStyle`] and [`OpCode::SetFont`] it is a prefix, consumed by the record it
8522    /// applies to and then cleared.
8523    ///
8524    /// Between this and that template record come `ceil(a / 4)` [`OpCode::StampPoints`] records
8525    /// carrying the coordinates. A drawing with no stamps encodes exactly as it did before.
8526    Stamp = 20,
8527    /// Four stamped positions, filling the record edge to edge: `[op, x0, y0, x1, y1, x2, y2,
8528    /// x3, y3]`, the one record that uses its last slot for geometry instead of a color, because
8529    /// it carries no color of its own. The final record of a run is padded; the count in the
8530    /// [`OpCode::Stamp`] header says how many of the pairs are real.
8531    StampPoints = 21,
8532    /// Draw a decoded image: `a,b` = origin, `c,d` = size, `e` = the [`BitmapId`], `f` = opacity
8533    /// in `0.0..=1.0` (docs/images.md).
8534    ///
8535    /// The id rides the numeric channel because it is a number, and an `f64` carries a `u64` id
8536    /// exactly up to 2^53; ids are minted from 1 per process, so that ceiling is unreachable.
8537    /// Nothing rides the texts channel for this op.
8538    Image = 22,
8539}
8540
8541impl OpCode {
8542    /// Every code, in wire order. The density test iterates this so a new variant that
8543    /// forgets to join fails loudly.
8544    pub const ALL: [OpCode; 23] = [
8545        OpCode::FillRect,
8546        OpCode::StrokeRect,
8547        OpCode::FillRrect,
8548        OpCode::FillEllipse,
8549        OpCode::StrokeEllipse,
8550        OpCode::StrokeArc,
8551        OpCode::Line,
8552        OpCode::Text,
8553        OpCode::Save,
8554        OpCode::Restore,
8555        OpCode::Concat,
8556        OpCode::FillPolygon,
8557        OpCode::StrokePolygon,
8558        OpCode::StrokeRrect,
8559        OpCode::SetGradient,
8560        OpCode::FillPath,
8561        OpCode::StrokePath,
8562        OpCode::Clip,
8563        OpCode::StrokeStyle,
8564        OpCode::SetFont,
8565        OpCode::Stamp,
8566        OpCode::StampPoints,
8567        OpCode::Image,
8568    ];
8569
8570    /// The code back from a wire number (a decoder-side aid and the round-trip test's
8571    /// reference); `None` for anything outside the table.
8572    pub fn from_wire(n: f64) -> Option<OpCode> {
8573        OpCode::ALL.into_iter().find(|c| *c as i32 as f64 == n)
8574    }
8575}
8576
8577/// Flat numeric encoding of a display list for shim/JNI boundaries (§11, §15.3): per op
8578/// 9 numbers [kind, a, b, c, d, e, f, g, rgba-bits]; text payloads ride separately in order.
8579/// The kind slot carries an [`OpCode`]; its doc comments are the per-slot contract.
8580///
8581/// Transports join `texts` with the unit separator U+001F (one entry per text-carrying
8582/// record, in order), so text payloads must not contain U+001F. Known asymmetry:
8583/// `Fill(Shape::Arc)` encodes as [`OpCode::StrokeArc`] with width 0. Filled arcs render
8584/// only on the direct-replay backends (AppKit/UIKit); use a polygon fan if a filled arc
8585/// must be portable.
8586/// Serialize a path for the `texts` side-channel: one space-separated token per segment,
8587/// `M x y` / `L x y` / `Q cx cy x y` / `C c1x c1y c2x c2y x y` / `Z`.
8588///
8589/// Text rather than numbers because that is the channel the encoder already has for
8590/// variable-length payloads, and every decoder (JS, Java, C++) can already split a string. The
8591/// numeric channel is a flat `[f64; 9]`-per-record array with no length prefix, so a path could
8592/// not ride it without changing the record shape for every backend at once.
8593/// A content key for a shape's variable-length geometry: the handle a backend caches decoded
8594/// native geometry under (a `Path2D`, a `QPainterPath`, an `android.graphics.Path`) so an
8595/// unchanged path is rebuilt once instead of re-parsed out of the texts channel every frame.
8596///
8597/// It hashes the geometry, not the op's position or its identity, so that a draw closure that
8598/// rebuilds its `PathBuilder` chain from scratch on every frame (which is what canvas consumers
8599/// do) produces an equal path, hashes the same and hits the same entry. An identity would miss
8600/// every time.
8601///
8602/// Two different paths that collided would draw each other, so the width matters: FNV-1a over the
8603/// IEEE bits, masked to the 53 bits an `f64` wire slot carries exactly. `0.0` is reserved for "no
8604/// key": a decoder seeing it must decode the payload as it always did, which is also what every
8605/// decoder written before this key existed does with the slot.
8606pub fn geometry_key(shape: &Shape) -> f64 {
8607    fn mix(h: &mut u64, v: f64) {
8608        for byte in v.to_bits().to_le_bytes() {
8609            *h ^= u64::from(byte);
8610            *h = h.wrapping_mul(0x0000_0100_0000_01b3);
8611        }
8612    }
8613    fn mix_point(h: &mut u64, p: Point) {
8614        mix(h, p.x);
8615        mix(h, p.y);
8616    }
8617    let mut h: u64 = 0xcbf2_9ce4_8422_2325;
8618    match shape {
8619        Shape::Polygon(pts) => {
8620            // The discriminant joins the hash so a polygon and a path of the same points can
8621            // never share an entry: they decode to different geometry.
8622            mix(&mut h, 11.0);
8623            for p in pts {
8624                mix_point(&mut h, *p);
8625            }
8626        }
8627        Shape::Path(path) => {
8628            mix(&mut h, 15.0);
8629            mix(&mut h, rule_bits(path.rule));
8630            for seg in &path.segs {
8631                match seg {
8632                    PathSeg::Move(a) => {
8633                        mix(&mut h, 0.0);
8634                        mix_point(&mut h, *a);
8635                    }
8636                    PathSeg::Line(a) => {
8637                        mix(&mut h, 1.0);
8638                        mix_point(&mut h, *a);
8639                    }
8640                    PathSeg::Quad(c, a) => {
8641                        mix(&mut h, 2.0);
8642                        mix_point(&mut h, *c);
8643                        mix_point(&mut h, *a);
8644                    }
8645                    PathSeg::Cubic(c1, c2, a) => {
8646                        mix(&mut h, 3.0);
8647                        mix_point(&mut h, *c1);
8648                        mix_point(&mut h, *c2);
8649                        mix_point(&mut h, *a);
8650                    }
8651                    PathSeg::Close => mix(&mut h, 4.0),
8652                }
8653            }
8654        }
8655        // Everything else puts its whole geometry in the record's own slots, so there is nothing
8656        // to parse and nothing worth caching.
8657        _ => return 0.0,
8658    }
8659    let masked = h & ((1u64 << 53) - 1);
8660    // 0 means "no key", so the one hash that lands there borrows its neighbor.
8661    if masked == 0 { 1.0 } else { masked as f64 }
8662}
8663
8664pub fn encode_path(path: &Path) -> String {
8665    let mut out = String::with_capacity(path.segs.len() * 16);
8666    for seg in &path.segs {
8667        if !out.is_empty() {
8668            out.push(' ');
8669        }
8670        match seg {
8671            PathSeg::Move(p) => out.push_str(&format!("M {} {}", p.x, p.y)),
8672            PathSeg::Line(p) => out.push_str(&format!("L {} {}", p.x, p.y)),
8673            PathSeg::Quad(c, p) => out.push_str(&format!("Q {} {} {} {}", c.x, c.y, p.x, p.y)),
8674            PathSeg::Cubic(a, b, p) => out.push_str(&format!(
8675                "C {} {} {} {} {} {}",
8676                a.x, a.y, b.x, b.y, p.x, p.y
8677            )),
8678            PathSeg::Close => out.push('Z'),
8679        }
8680    }
8681    out
8682}
8683
8684/// `FillRule` as a wire number: 0 non-zero, 1 even-odd.
8685fn rule_bits(rule: FillRule) -> f64 {
8686    match rule {
8687        FillRule::NonZero => 0.0,
8688        FillRule::EvenOdd => 1.0,
8689    }
8690}
8691
8692pub fn encode_ops(ops: &[DrawOp]) -> (Vec<f64>, Vec<String>) {
8693    fn color_bits(c: Color) -> f64 {
8694        let r = (c.r.clamp(0.0, 1.0) * 255.0) as u32;
8695        let g = (c.g.clamp(0.0, 1.0) * 255.0) as u32;
8696        let b = (c.b.clamp(0.0, 1.0) * 255.0) as u32;
8697        let a = (c.a.clamp(0.0, 1.0) * 255.0) as u32;
8698        ((a << 24) | (r << 16) | (g << 8) | b) as f64
8699    }
8700    #[allow(clippy::too_many_arguments)]
8701    fn push(
8702        k: OpCode,
8703        a: f64,
8704        b: f64,
8705        c: f64,
8706        d: f64,
8707        e: f64,
8708        f: f64,
8709        g: f64,
8710        col: Color,
8711        nums: &mut Vec<f64>,
8712    ) {
8713        nums.extend_from_slice(&[k as i32 as f64, a, b, c, d, e, f, g, color_bits(col)]);
8714    }
8715    /// The kind-18 prefix a non-plain stroke style emits, applying to the next stroke record,
8716    /// the same "modifier record" rule the gradient and font records follow. A plain style emits
8717    /// nothing, so a drawing without dashes or caps encodes exactly as it always did.
8718    fn stroke_style_record(style: &StrokeStyle, nums: &mut Vec<f64>, texts: &mut Vec<String>) {
8719        if style.is_plain() {
8720            return;
8721        }
8722        push(
8723            OpCode::StrokeStyle,
8724            match style.cap {
8725                LineCap::Butt => 0.0,
8726                LineCap::Round => 1.0,
8727                LineCap::Square => 2.0,
8728            },
8729            match style.join {
8730                LineJoin::Miter => 0.0,
8731                LineJoin::Round => 1.0,
8732                LineJoin::Bevel => 2.0,
8733            },
8734            style.miter_limit,
8735            style.dash_phase,
8736            style.dash.len() as f64,
8737            0.0,
8738            0.0,
8739            Color::CLEAR,
8740            nums,
8741        );
8742        texts.push(
8743            style
8744                .dash
8745                .iter()
8746                .map(|d| d.to_string())
8747                .collect::<Vec<_>>()
8748                .join(" "),
8749        );
8750    }
8751
8752    /// One shape record (the fill/stroke kinds shared by both ops).
8753    fn shape_record(
8754        stroke: bool,
8755        shape: &Shape,
8756        w: f64,
8757        col: Color,
8758        nums: &mut Vec<f64>,
8759        texts: &mut Vec<String>,
8760    ) {
8761        match shape {
8762            Shape::Rect(r) => push(
8763                if stroke {
8764                    OpCode::StrokeRect
8765                } else {
8766                    OpCode::FillRect
8767                },
8768                r.origin.x,
8769                r.origin.y,
8770                r.size.width,
8771                r.size.height,
8772                0.0,
8773                0.0,
8774                w,
8775                col,
8776                nums,
8777            ),
8778            Shape::RoundedRect(r, rad) => push(
8779                if stroke {
8780                    OpCode::StrokeRrect
8781                } else {
8782                    OpCode::FillRrect
8783                },
8784                r.origin.x,
8785                r.origin.y,
8786                r.size.width,
8787                r.size.height,
8788                *rad,
8789                0.0,
8790                w,
8791                col,
8792                nums,
8793            ),
8794            Shape::Ellipse(r) => push(
8795                if stroke {
8796                    OpCode::StrokeEllipse
8797                } else {
8798                    OpCode::FillEllipse
8799                },
8800                r.origin.x,
8801                r.origin.y,
8802                r.size.width,
8803                r.size.height,
8804                0.0,
8805                0.0,
8806                w,
8807                col,
8808                nums,
8809            ),
8810            Shape::Arc {
8811                rect,
8812                start_deg,
8813                sweep_deg,
8814            } => push(
8815                OpCode::StrokeArc,
8816                rect.origin.x,
8817                rect.origin.y,
8818                rect.size.width,
8819                rect.size.height,
8820                *start_deg,
8821                *sweep_deg,
8822                w,
8823                col,
8824                nums,
8825            ),
8826            Shape::Line(p1, p2) => {
8827                push(OpCode::Line, p1.x, p1.y, p2.x, p2.y, 0.0, 0.0, w, col, nums)
8828            }
8829            Shape::Polygon(pts) => {
8830                // Variable-length points ride the texts side-channel ("x,y x,y …"),
8831                // consumed in record order exactly like text payloads.
8832                push(
8833                    if stroke {
8834                        OpCode::StrokePolygon
8835                    } else {
8836                        OpCode::FillPolygon
8837                    },
8838                    geometry_key(shape),
8839                    0.0,
8840                    0.0,
8841                    0.0,
8842                    0.0,
8843                    0.0,
8844                    w,
8845                    col,
8846                    nums,
8847                );
8848                texts.push(
8849                    pts.iter()
8850                        .map(|p| format!("{},{}", p.x, p.y))
8851                        .collect::<Vec<_>>()
8852                        .join(" "),
8853                );
8854            }
8855            Shape::Path(path) => {
8856                // Same side-channel as Polygon, one token per segment (see `encode_path`).
8857                // The fill rule rides slot f; stroking ignores it.
8858                push(
8859                    if stroke {
8860                        OpCode::StrokePath
8861                    } else {
8862                        OpCode::FillPath
8863                    },
8864                    geometry_key(shape),
8865                    0.0,
8866                    0.0,
8867                    0.0,
8868                    0.0,
8869                    rule_bits(path.rule),
8870                    w,
8871                    col,
8872                    nums,
8873                );
8874                texts.push(encode_path(path));
8875            }
8876        }
8877    }
8878    /// A gradient applies to the next shape record, fill or stroke. Geometry per type rides
8879    /// slots a..d and the type discriminant slot f: one record shape, so every decoder keeps a
8880    /// single gradient code path.
8881    fn gradient_record(
8882        geo: [f64; 4],
8883        kind: f64,
8884        stops: &[(f64, Color)],
8885        nums: &mut Vec<f64>,
8886        texts: &mut Vec<String>,
8887    ) {
8888        push(
8889            OpCode::SetGradient,
8890            geo[0],
8891            geo[1],
8892            geo[2],
8893            geo[3],
8894            stops.len() as f64,
8895            kind,
8896            0.0,
8897            Color::CLEAR,
8898            nums,
8899        );
8900        texts.push(
8901            stops
8902                .iter()
8903                .map(|(o, c)| format!("{o},{:08x}", color_bits(*c) as u32))
8904                .collect::<Vec<_>>()
8905                .join(" "),
8906        );
8907    }
8908    let mut nums = Vec::with_capacity(ops.len() * 9);
8909    let mut texts = Vec::new();
8910    for op in ops {
8911        match op {
8912            DrawOp::Fill(shape, paint) => {
8913                // A gradient emits one kind-14 set-gradient record before its shape record;
8914                // the stops ride the texts channel as "offset,aarrggbb offset,aarrggbb …".
8915                // Geometry per type rides slots a..d, the type discriminant slot f: one
8916                // record shape, so every decoder keeps a single gradient code path.
8917                let col = match paint {
8918                    Paint::Solid(c) => *c,
8919                    Paint::Linear(g) => {
8920                        gradient_record(
8921                            [g.start.x, g.start.y, g.end.x, g.end.y],
8922                            0.0,
8923                            &g.stops,
8924                            &mut nums,
8925                            &mut texts,
8926                        );
8927                        // The gradient replaces the shape record's color, but it must be
8928                        // opaque, not clear: Skia-based decoders (Android Paint, OH_Drawing)
8929                        // modulate a shader by the paint alpha, so a clear slot would render
8930                        // the whole gradient invisible.
8931                        Color::WHITE
8932                    }
8933                    Paint::Radial(g) => {
8934                        gradient_record(
8935                            [g.center.x, g.center.y, g.radius, 0.0],
8936                            1.0,
8937                            &g.stops,
8938                            &mut nums,
8939                            &mut texts,
8940                        );
8941                        Color::WHITE
8942                    }
8943                };
8944                shape_record(false, shape, 0.0, col, &mut nums, &mut texts);
8945            }
8946            DrawOp::Stamp(st) => {
8947                // No copies means no drawing. Some native decoders use an empty point
8948                // buffer to mean an ordinary (unstamped) template, so omit the entire op.
8949                if st.at.is_empty() {
8950                    continue;
8951                }
8952                // A prefix record, then the coordinates, then the template shape record: the
8953                // "applies to the next record" rule the gradient, stroke style and font records
8954                // already follow, so a decoder needs no new kind of state, only one more slot in
8955                // the state it keeps. The template goes last because a decoder has to hold the
8956                // points before it can draw with them.
8957                //
8958                // The points are numbers on the numeric channel, not the texts channel a
8959                // polygon's points ride: this op exists for tens of thousands of them, and
8960                // formatting that many floats into a string every frame would cost more than
8961                // the individual ops it replaces.
8962                let col = match &st.paint {
8963                    Paint::Solid(c) => *c,
8964                    Paint::Linear(g) => {
8965                        gradient_record(
8966                            [g.start.x, g.start.y, g.end.x, g.end.y],
8967                            0.0,
8968                            &g.stops,
8969                            &mut nums,
8970                            &mut texts,
8971                        );
8972                        Color::WHITE
8973                    }
8974                    Paint::Radial(g) => {
8975                        gradient_record(
8976                            [g.center.x, g.center.y, g.radius, 0.0],
8977                            1.0,
8978                            &g.stops,
8979                            &mut nums,
8980                            &mut texts,
8981                        );
8982                        Color::WHITE
8983                    }
8984                };
8985                push(
8986                    OpCode::Stamp,
8987                    st.at.len() as f64,
8988                    0.0,
8989                    0.0,
8990                    0.0,
8991                    0.0,
8992                    0.0,
8993                    0.0,
8994                    Color::CLEAR,
8995                    &mut nums,
8996                );
8997                for chunk in st.at.chunks(4) {
8998                    // Written directly rather than through `push`: this is the one record whose
8999                    // last slot is a coordinate rather than a color, which is what lets four
9000                    // points fit with nothing wasted.
9001                    let mut rec = [
9002                        OpCode::StampPoints as i32 as f64,
9003                        0.0,
9004                        0.0,
9005                        0.0,
9006                        0.0,
9007                        0.0,
9008                        0.0,
9009                        0.0,
9010                        0.0,
9011                    ];
9012                    for (i, p) in chunk.iter().enumerate() {
9013                        rec[1 + i * 2] = p.x;
9014                        rec[2 + i * 2] = p.y;
9015                    }
9016                    nums.extend_from_slice(&rec);
9017                }
9018                match &st.stroke {
9019                    None => shape_record(false, &st.shape, 0.0, col, &mut nums, &mut texts),
9020                    Some(style) => {
9021                        stroke_style_record(style, &mut nums, &mut texts);
9022                        shape_record(true, &st.shape, style.width, col, &mut nums, &mut texts);
9023                    }
9024                }
9025            }
9026            DrawOp::Stroke(shape, paint, style) => {
9027                // A styled stroke emits one kind-18 record first, applying to the next stroke
9028                // only, the same "modifier record" shape the gradient uses, so decoders keep
9029                // one rule: consume, apply to the next shape record, reset.
9030                stroke_style_record(style, &mut nums, &mut texts);
9031                let col = match paint {
9032                    Paint::Solid(c) => *c,
9033                    Paint::Linear(g) => {
9034                        gradient_record(
9035                            [g.start.x, g.start.y, g.end.x, g.end.y],
9036                            0.0,
9037                            &g.stops,
9038                            &mut nums,
9039                            &mut texts,
9040                        );
9041                        Color::WHITE
9042                    }
9043                    Paint::Radial(g) => {
9044                        gradient_record(
9045                            [g.center.x, g.center.y, g.radius, 0.0],
9046                            1.0,
9047                            &g.stops,
9048                            &mut nums,
9049                            &mut texts,
9050                        );
9051                        Color::WHITE
9052                    }
9053                };
9054                shape_record(true, shape, style.width, col, &mut nums, &mut texts);
9055            }
9056            DrawOp::Clip(shape) => {
9057                // One record for every clip shape, with the shape kind in slot f and the
9058                // geometry in a..e; a path or polygon puts its points on the texts channel.
9059                // Encoding clips as a variant of `shape_record` instead would need a third
9060                // kind code per shape, which is five more numbers every decoder must learn.
9061                let (kind, geo, extra, payload): (f64, [f64; 4], f64, Option<String>) = match shape
9062                {
9063                    Shape::Rect(r) => (
9064                        0.0,
9065                        [r.origin.x, r.origin.y, r.size.width, r.size.height],
9066                        0.0,
9067                        None,
9068                    ),
9069                    Shape::RoundedRect(r, rad) => (
9070                        1.0,
9071                        [r.origin.x, r.origin.y, r.size.width, r.size.height],
9072                        *rad,
9073                        None,
9074                    ),
9075                    Shape::Ellipse(r) => (
9076                        2.0,
9077                        [r.origin.x, r.origin.y, r.size.width, r.size.height],
9078                        0.0,
9079                        None,
9080                    ),
9081                    // Slot a carries the geometry key for these two sub-kinds only. The other
9082                    // three put real geometry there, which is why the key rides the same slot
9083                    // the payload-bearing kinds leave empty.
9084                    Shape::Path(p) => (
9085                        3.0,
9086                        [geometry_key(shape), 0.0, 0.0, 0.0],
9087                        rule_bits(p.rule),
9088                        Some(encode_path(p)),
9089                    ),
9090                    Shape::Polygon(pts) => (
9091                        4.0,
9092                        [geometry_key(shape), 0.0, 0.0, 0.0],
9093                        0.0,
9094                        Some(
9095                            pts.iter()
9096                                .map(|p| format!("{},{}", p.x, p.y))
9097                                .collect::<Vec<_>>()
9098                                .join(" "),
9099                        ),
9100                    ),
9101                    // A clip to a line or an arc has no interior to clip to. Clipping to an
9102                    // empty region would blank everything after it, so clip to the shape's
9103                    // bounds instead: wrong in the same direction as no clip at all.
9104                    Shape::Line(..) | Shape::Arc { .. } => {
9105                        let r = shape.bounds();
9106                        (
9107                            0.0,
9108                            [r.origin.x, r.origin.y, r.size.width, r.size.height],
9109                            0.0,
9110                            None,
9111                        )
9112                    }
9113                };
9114                push(
9115                    OpCode::Clip,
9116                    geo[0],
9117                    geo[1],
9118                    geo[2],
9119                    geo[3],
9120                    extra,
9121                    kind,
9122                    0.0,
9123                    Color::CLEAR,
9124                    &mut nums,
9125                );
9126                if let Some(s) = payload {
9127                    texts.push(s);
9128                }
9129            }
9130            DrawOp::Text {
9131                text,
9132                at,
9133                size,
9134                color,
9135                anchor,
9136                font,
9137            } => {
9138                if *font != CanvasFont::default() {
9139                    push(
9140                        OpCode::SetFont,
9141                        f64::from(font.css_weight()),
9142                        f64::from(u8::from(font.italic)),
9143                        0.0,
9144                        0.0,
9145                        0.0,
9146                        0.0,
9147                        0.0,
9148                        Color::CLEAR,
9149                        &mut nums,
9150                    );
9151                    texts.push(
9152                        font.family_str()
9153                            .replace([FONT_LIST_FIELD, FONT_LIST_FAMILY], " "),
9154                    );
9155                }
9156                push(
9157                    OpCode::Text,
9158                    at.x,
9159                    at.y,
9160                    0.0,
9161                    0.0,
9162                    *size,
9163                    anchor.pack(),
9164                    0.0,
9165                    *color,
9166                    &mut nums,
9167                );
9168                texts.push(text.clone());
9169            }
9170            DrawOp::Image {
9171                image,
9172                rect,
9173                opacity,
9174            } => push(
9175                OpCode::Image,
9176                rect.origin.x,
9177                rect.origin.y,
9178                rect.size.width,
9179                rect.size.height,
9180                image.0 as f64,
9181                *opacity,
9182                0.0,
9183                Color::CLEAR,
9184                &mut nums,
9185            ),
9186            DrawOp::Save => push(
9187                OpCode::Save,
9188                0.0,
9189                0.0,
9190                0.0,
9191                0.0,
9192                0.0,
9193                0.0,
9194                0.0,
9195                Color::CLEAR,
9196                &mut nums,
9197            ),
9198            DrawOp::Restore => push(
9199                OpCode::Restore,
9200                0.0,
9201                0.0,
9202                0.0,
9203                0.0,
9204                0.0,
9205                0.0,
9206                0.0,
9207                Color::CLEAR,
9208                &mut nums,
9209            ),
9210            DrawOp::Concat(m) => push(
9211                OpCode::Concat,
9212                m.a,
9213                m.b,
9214                m.c,
9215                m.d,
9216                m.tx,
9217                m.ty,
9218                0.0,
9219                Color::CLEAR,
9220                &mut nums,
9221            ),
9222        }
9223    }
9224    (nums, texts)
9225}
9226
9227#[cfg(test)]
9228mod font_list_tests {
9229    use super::*;
9230
9231    #[test]
9232    fn css_weights_round_trip_and_snap_to_the_nearest_rung() {
9233        for w in FontWeight::ALL {
9234            assert_eq!(FontWeight::from_css(w.css()), w);
9235        }
9236        assert_eq!(FontWeight::from_css(0), FontWeight::UltraLight);
9237        assert_eq!(FontWeight::from_css(350), FontWeight::Regular); // ties go heavier
9238        assert_eq!(FontWeight::from_css(649), FontWeight::Semibold);
9239        assert_eq!(FontWeight::from_css(1200), FontWeight::Black);
9240    }
9241
9242    fn sans() -> FontFamilyInfo {
9243        FontFamilyInfo {
9244            family: "Day Sans".into(),
9245            faces: vec![
9246                FontFace {
9247                    name: "Regular".into(),
9248                    weight: FontWeight::Regular,
9249                    italic: false,
9250                },
9251                FontFace {
9252                    name: "Bold".into(),
9253                    weight: FontWeight::Bold,
9254                    italic: false,
9255                },
9256                FontFace {
9257                    name: "Light Italic".into(),
9258                    weight: FontWeight::Light,
9259                    italic: true,
9260                },
9261            ],
9262        }
9263    }
9264
9265    #[test]
9266    fn face_for_prefers_the_slant_then_the_nearest_weight() {
9267        let f = sans();
9268        assert!(f.has_bold() && f.has_italic());
9269        assert_eq!(f.face_for(FontWeight::Bold, false).unwrap().name, "Bold");
9270        assert_eq!(
9271            f.face_for(FontWeight::Semibold, false).unwrap().name,
9272            "Bold"
9273        );
9274        assert_eq!(
9275            f.face_for(FontWeight::Medium, false).unwrap().name,
9276            "Regular"
9277        );
9278        let tie = FontFamilyInfo {
9279            family: "Tie".into(),
9280            faces: vec![
9281                FontFace {
9282                    name: "Light".into(),
9283                    weight: FontWeight::Light,
9284                    italic: false,
9285                },
9286                FontFace {
9287                    name: "Bold".into(),
9288                    weight: FontWeight::Bold,
9289                    italic: false,
9290                },
9291            ],
9292        };
9293        // Medium sits 200 from both; the heavier face wins the tie.
9294        assert_eq!(
9295            tie.face_for(FontWeight::Medium, false).unwrap().name,
9296            "Bold"
9297        );
9298        assert_eq!(
9299            f.face_for(FontWeight::Black, true).unwrap().name,
9300            "Light Italic"
9301        );
9302        let none = FontFamilyInfo {
9303            family: "Empty".into(),
9304            faces: vec![],
9305        };
9306        assert!(none.face_for(FontWeight::Regular, false).is_none());
9307        assert!(!none.has_bold());
9308    }
9309
9310    #[test]
9311    fn the_shim_list_format_decodes_tolerantly() {
9312        let s = "Day Sans\u{1f}Regular\u{1f}400\u{1f}0\u{1f}\u{1f}700\u{1f}1\u{1f}Broken\u{1f}x\u{1f}0\
9313                 \u{1e}\u{1e}Day Mono\u{1f}Regular\u{1f}400\u{1f}0\u{1e}Faceless";
9314        let list = parse_font_list(s);
9315        assert_eq!(list.len(), 3);
9316        assert_eq!(list[0].family, "Day Sans");
9317        assert_eq!(list[0].faces.len(), 2, "the bad weight is skipped");
9318        assert_eq!(list[0].faces[1].name, "Bold Italic"); // synthesized from 700 + italic
9319        assert_eq!(list[1].family, "Day Mono");
9320        assert_eq!(list[2].family, "Faceless");
9321        assert!(list[2].faces.is_empty());
9322    }
9323
9324    #[test]
9325    fn approximate_metrics_scale_with_size_and_length() {
9326        let m = TextMetrics::approximate("abc", 10.0);
9327        assert_eq!((m.width, m.height, m.ascent), (18.0, 12.0, 9.0));
9328        assert_eq!(TextMetrics::approximate("", 10.0).width, 0.0);
9329    }
9330
9331    #[test]
9332    fn a_default_canvas_font_is_the_platform_face() {
9333        let f = CanvasFont::default();
9334        assert_eq!((f.family_str(), f.css_weight(), f.italic), ("", 0, false));
9335    }
9336}
9337
9338#[cfg(test)]
9339mod image_header_tests {
9340    use super::*;
9341
9342    /// A 4×2 PNG: signature, then an IHDR whose width and height are big-endian.
9343    fn png(w: u32, h: u32) -> Vec<u8> {
9344        let mut v = vec![0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a];
9345        v.extend(13u32.to_be_bytes());
9346        v.extend(b"IHDR");
9347        v.extend(w.to_be_bytes());
9348        v.extend(h.to_be_bytes());
9349        v
9350    }
9351
9352    /// A JPEG whose frame header sits behind one other segment, so the walk has to skip it.
9353    fn jpeg(w: u16, h: u16) -> Vec<u8> {
9354        let mut v = vec![0xFF, 0xD8]; // SOI
9355        v.extend([0xFF, 0xE0, 0x00, 0x04, 0x00, 0x00]); // APP0, length 4
9356        v.extend([0xFF, 0xC0, 0x00, 0x11, 0x08]); // SOF0, length 17, precision 8
9357        v.extend(h.to_be_bytes());
9358        v.extend(w.to_be_bytes());
9359        v
9360    }
9361
9362    #[test]
9363    fn a_header_states_the_size_before_anything_decodes() {
9364        assert_eq!(ImageFormat::dimensions(&png(512, 512)), Some((512, 512)));
9365        assert_eq!(ImageFormat::dimensions(&png(1, 65535)), Some((1, 65535)));
9366        // The JPEG walk must step over the APP0 segment to reach the frame header.
9367        assert_eq!(ImageFormat::dimensions(&jpeg(640, 480)), Some((640, 480)));
9368
9369        let mut gif = b"GIF89a".to_vec();
9370        gif.extend(320u16.to_le_bytes());
9371        gif.extend(240u16.to_le_bytes());
9372        assert_eq!(ImageFormat::dimensions(&gif), Some((320, 240)));
9373
9374        // A top-down BMP states its height as a negative number; the size is still positive.
9375        let mut bmp = b"BM".to_vec();
9376        bmp.resize(18, 0);
9377        bmp.extend(64i32.to_le_bytes());
9378        bmp.extend((-32i32).to_le_bytes());
9379        assert_eq!(ImageFormat::dimensions(&bmp), Some((64, 32)));
9380    }
9381
9382    #[test]
9383    fn a_truncated_or_unknown_header_answers_nothing_rather_than_a_guess() {
9384        assert_eq!(ImageFormat::dimensions(&[]), None);
9385        // The signature is there but the IHDR is not.
9386        assert_eq!(ImageFormat::dimensions(&png(8, 8)[..12]), None);
9387        // Not an image at all.
9388        assert_eq!(ImageFormat::dimensions(b"not an image"), None);
9389        // A JPEG that never reaches a frame header must not loop or invent a size.
9390        assert_eq!(
9391            ImageFormat::dimensions(&[0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x04, 0, 0]),
9392            None
9393        );
9394    }
9395}
9396
9397#[cfg(test)]
9398mod encode_ops_tests {
9399    use super::*;
9400
9401    /// The wire codes are a frozen dense table: any gap, duplicate, or variant missing from
9402    /// `ALL` breaks a decoder somewhere, so it must break here first.
9403    #[test]
9404    fn op_codes_are_dense_and_round_trip() {
9405        for (i, code) in OpCode::ALL.into_iter().enumerate() {
9406            assert_eq!(code as i32, i as i32, "{code:?} broke wire density");
9407            assert_eq!(OpCode::from_wire(i as f64), Some(code));
9408        }
9409        assert_eq!(OpCode::from_wire(OpCode::ALL.len() as f64), None);
9410        assert_eq!(OpCode::from_wire(-1.0), None);
9411        assert_eq!(OpCode::from_wire(0.5), None);
9412    }
9413
9414    /// Reference decode of a display list covering every op arm: the record stream must be
9415    /// whole 9-slot records of known codes, and the texts channel must carry exactly one
9416    /// entry per text-carrying record, in order: the contract every shim decoder assumes.
9417    #[test]
9418    fn encode_ops_emits_whole_records_and_matched_text_payloads() {
9419        let r = Rect::new(1.0, 2.0, 3.0, 4.0);
9420        let red = Color::rgb(1.0, 0.0, 0.0);
9421        let ops = vec![
9422            DrawOp::Fill(Shape::Rect(r), Paint::Solid(red)),
9423            DrawOp::Stroke(Shape::Rect(r), Paint::Solid(red), StrokeStyle::round(2.0)),
9424            DrawOp::Fill(Shape::RoundedRect(r, 5.0), Paint::Solid(red)),
9425            DrawOp::Fill(Shape::Ellipse(r), Paint::Solid(red)),
9426            // The documented asymmetry: a filled arc encodes as StrokeArc with width 0.
9427            DrawOp::Fill(
9428                Shape::Arc {
9429                    rect: r,
9430                    start_deg: 0.0,
9431                    sweep_deg: 90.0,
9432                },
9433                Paint::Solid(red),
9434            ),
9435            DrawOp::Stroke(
9436                Shape::Line(Point::new(0.0, 0.0), Point::new(9.0, 9.0)),
9437                Paint::Solid(red),
9438                StrokeStyle::width(1.0),
9439            ),
9440            DrawOp::Fill(
9441                Shape::Polygon(vec![
9442                    Point::new(0.0, 0.0),
9443                    Point::new(4.0, 0.0),
9444                    Point::new(2.0, 3.0),
9445                ]),
9446                Paint::Solid(red),
9447            ),
9448            DrawOp::Fill(
9449                Shape::Path(Path {
9450                    segs: vec![
9451                        PathSeg::Move(Point::new(0.0, 0.0)),
9452                        PathSeg::Line(Point::new(5.0, 5.0)),
9453                        PathSeg::Close,
9454                    ],
9455                    rule: FillRule::EvenOdd,
9456                }),
9457                Paint::Linear(LinearGradient::new(
9458                    UnitPoint::new(0.0, 0.0),
9459                    UnitPoint::new(1.0, 1.0),
9460                    vec![(0.0, red), (1.0, Color::WHITE)],
9461                )),
9462            ),
9463            DrawOp::Clip(Shape::Rect(r)),
9464            DrawOp::Clip(Shape::Polygon(vec![
9465                Point::new(0.0, 0.0),
9466                Point::new(1.0, 0.0),
9467                Point::new(0.0, 1.0),
9468            ])),
9469            DrawOp::Text {
9470                text: "hi".into(),
9471                at: Point::new(7.0, 8.0),
9472                size: 12.0,
9473                color: red,
9474                anchor: TextAnchor::LEADING,
9475                font: CanvasFont::default(),
9476            },
9477            DrawOp::Text {
9478                text: "bold".into(),
9479                at: Point::new(7.0, 8.0),
9480                size: 12.0,
9481                color: red,
9482                anchor: TextAnchor::CENTERED,
9483                font: CanvasFont {
9484                    family: Some("Day Sans".into()),
9485                    weight: Some(FontWeight::Bold),
9486                    italic: true,
9487                },
9488            },
9489            DrawOp::Save,
9490            DrawOp::Concat(Affine::IDENTITY),
9491            DrawOp::Restore,
9492            DrawOp::Image {
9493                image: BitmapId(7),
9494                rect: r,
9495                opacity: 0.5,
9496            },
9497        ];
9498        let (nums, texts) = encode_ops(&ops);
9499        assert_eq!(nums.len() % 9, 0, "partial record on the wire");
9500
9501        let mut expected_texts = 0usize;
9502        let mut decoded = Vec::new();
9503        for rec in nums.chunks(9) {
9504            let code = OpCode::from_wire(rec[0])
9505                .unwrap_or_else(|| panic!("unknown op code {} on the wire", rec[0]));
9506            decoded.push(code);
9507            expected_texts += match code {
9508                OpCode::Text
9509                | OpCode::FillPolygon
9510                | OpCode::StrokePolygon
9511                | OpCode::SetGradient
9512                | OpCode::FillPath
9513                | OpCode::StrokePath
9514                | OpCode::StrokeStyle
9515                | OpCode::SetFont => 1,
9516                // Clip carries a payload only for its path (3) / polygon (4) sub-kinds.
9517                OpCode::Clip => usize::from(rec[6] == 3.0 || rec[6] == 4.0),
9518                _ => 0,
9519            };
9520        }
9521        assert_eq!(
9522            texts.len(),
9523            expected_texts,
9524            "texts channel out of step with the records that consume it"
9525        );
9526        // No payload may carry the transport separator.
9527        assert!(texts.iter().all(|t| !t.contains('\u{1f}')));
9528
9529        let expected = {
9530            use OpCode::*;
9531            vec![
9532                FillRect,
9533                StrokeStyle, // ::round(2.0) is not plain, so the style modifier precedes
9534                StrokeRect,
9535                FillRrect,
9536                FillEllipse,
9537                StrokeArc, // the filled arc's stroke encoding…
9538                Line,
9539                FillPolygon,
9540                SetGradient, // gradient precedes its shape record
9541                FillPath,
9542                Clip,
9543                Clip,
9544                Text,
9545                SetFont, // a non-default font precedes its text record…
9546                Text,
9547                Save,
9548                Concat,
9549                Restore,
9550                Image,
9551            ]
9552        };
9553        assert_eq!(decoded, expected);
9554        // The bitmap id rides the numeric channel intact (an f64 holds a u64 id exactly well
9555        // past any id this process will mint), and nothing of an image reaches the texts channel.
9556        let img = nums
9557            .chunks(9)
9558            .find(|r| r[0] == OpCode::Image as i32 as f64)
9559            .expect("Image record");
9560        assert_eq!(&img[1..7], &[1.0, 2.0, 3.0, 4.0, 7.0, 0.5]);
9561        // …carrying the CSS weight and the slant, with the family on the texts channel.
9562        let font_rec = nums
9563            .chunks(9)
9564            .find(|r| r[0] == OpCode::SetFont as i32 as f64)
9565            .expect("SetFont record");
9566        assert_eq!(&font_rec[1..3], &[700.0, 1.0]);
9567        assert_eq!(texts.iter().filter(|t| *t == "Day Sans").count(), 1);
9568        // …with width 0, per the documented asymmetry.
9569        let arc = nums
9570            .chunks(9)
9571            .find(|r| r[0] == OpCode::StrokeArc as i32 as f64);
9572        assert_eq!(arc.map(|r| r[7]), Some(0.0));
9573        // A gradient-filled shape's color slot is opaque white, never clear (Skia decoders
9574        // modulate the shader by it).
9575        let path_rec = nums
9576            .chunks(9)
9577            .find(|r| r[0] == OpCode::FillPath as i32 as f64);
9578        assert_eq!(
9579            path_rec.map(|r| r[8] as u32),
9580            Some(0xFFFF_FFFF),
9581            "gradient shape record must carry opaque white"
9582        );
9583    }
9584
9585    #[test]
9586    fn variable_length_geometry_carries_a_content_key() {
9587        let path = |x: f64| {
9588            Shape::Path(Path {
9589                segs: vec![
9590                    PathSeg::Move(Point::new(x, 0.0)),
9591                    PathSeg::Cubic(
9592                        Point::new(1.0, 1.0),
9593                        Point::new(2.0, 2.0),
9594                        Point::new(3.0, 3.0),
9595                    ),
9596                    PathSeg::Close,
9597                ],
9598                rule: FillRule::NonZero,
9599            })
9600        };
9601        let poly = Shape::Polygon(vec![
9602            Point::new(0.0, 0.0),
9603            Point::new(1.0, 0.0),
9604            Point::new(0.0, 1.0),
9605        ]);
9606
9607        // Equal geometry keys equal though the two were built separately. The key exists for
9608        // this: a draw closure rebuilds its paths from scratch on every frame.
9609        assert_eq!(geometry_key(&path(0.0)), geometry_key(&path(0.0)));
9610        assert_ne!(geometry_key(&path(0.0)), geometry_key(&path(1.0)));
9611        // The fill rule decides what the path draws, so it belongs to the key.
9612        let even_odd = Shape::Path(Path {
9613            segs: match path(0.0) {
9614                Shape::Path(p) => p.segs,
9615                _ => unreachable!(),
9616            },
9617            rule: FillRule::EvenOdd,
9618        });
9619        assert_ne!(geometry_key(&path(0.0)), geometry_key(&even_odd));
9620        // A polygon and a path decode to different geometry and never share an entry.
9621        assert_ne!(geometry_key(&poly), geometry_key(&path(0.0)));
9622        // Fixed-size shapes keep 0: their geometry rides the record's own slots.
9623        assert_eq!(
9624            geometry_key(&Shape::Rect(Rect::new(0.0, 0.0, 1.0, 1.0))),
9625            0.0
9626        );
9627        // And a key survives an f64 wire slot exactly, which is what the 53-bit mask buys.
9628        let k = geometry_key(&path(0.0));
9629        assert!(
9630            k > 0.0 && k < (1u64 << 53) as f64 && k.fract() == 0.0,
9631            "{k}"
9632        );
9633
9634        // It reaches the wire in slot a, for exactly the ops whose payload rides the texts
9635        // channel, and never displaces geometry that is already there.
9636        let (nums, _) = encode_ops(&[
9637            DrawOp::Fill(path(0.0), Paint::Solid(Color::WHITE)),
9638            DrawOp::Fill(poly.clone(), Paint::Solid(Color::WHITE)),
9639            DrawOp::Clip(path(0.0)),
9640            DrawOp::Clip(Shape::Rect(Rect::new(1.0, 2.0, 3.0, 4.0))),
9641        ]);
9642        let slot_a = |code: OpCode| {
9643            nums.chunks(9)
9644                .find(|r| r[0] == code as i32 as f64)
9645                .unwrap_or_else(|| panic!("no {code:?} record"))[1]
9646        };
9647        assert_eq!(slot_a(OpCode::FillPath), geometry_key(&path(0.0)));
9648        assert_eq!(slot_a(OpCode::FillPolygon), geometry_key(&poly));
9649        let clips: Vec<&[f64]> = nums
9650            .chunks(9)
9651            .filter(|r| r[0] == OpCode::Clip as i32 as f64)
9652            .collect();
9653        assert_eq!(clips[0][1], geometry_key(&path(0.0)), "path clip keys");
9654        assert_eq!(
9655            &clips[1][1..5],
9656            &[1.0, 2.0, 3.0, 4.0],
9657            "a rect clip still puts its geometry in a..d"
9658        );
9659    }
9660}
9661
9662#[cfg(test)]
9663mod markup_tests {
9664    use super::*;
9665
9666    fn bold(range: std::ops::Range<usize>) -> TextRun {
9667        TextRun::font(
9668            range,
9669            FontSpec {
9670                style: Font::Body,
9671                weight: Some(FontWeight::Bold),
9672                ..Default::default()
9673            },
9674        )
9675    }
9676
9677    #[test]
9678    fn plain_text_is_escaped_in_both_dialects() {
9679        // The case a translated string will find: markup metacharacters in the content.
9680        let text = "5 < 6 & \"quoted\" <b>not bold</b>";
9681        for d in [MarkupDialect::Pango, MarkupDialect::QtHtml] {
9682            let m = runs_to_markup(text, &[], d, 16.0);
9683            assert!(!m.contains("<b>"), "content tag survived into markup: {m}");
9684            assert!(m.contains("&lt;b&gt;"), "{m}");
9685            assert!(m.contains("&amp;"), "{m}");
9686            assert!(m.contains("5 &lt; 6"), "{m}");
9687        }
9688    }
9689
9690    #[test]
9691    fn a_styled_run_is_wrapped_and_its_text_escaped() {
9692        let text = "a <b> c";
9693        let m = runs_to_markup(text, &[bold(2..5)], MarkupDialect::Pango, 16.0);
9694        assert_eq!(m, "a <b>&lt;b&gt;</b> c");
9695    }
9696
9697    #[test]
9698    fn color_and_monospace_differ_per_dialect() {
9699        let text = "code";
9700        let run = TextRun {
9701            range: 0..4,
9702            font: FontSpec {
9703                style: Font::Body,
9704                monospace: true,
9705                ..Default::default()
9706            },
9707            color: Some(Color::rgb(1.0, 0.0, 0.0)),
9708            ..TextRun::default()
9709        };
9710        let pango = runs_to_markup(text, std::slice::from_ref(&run), MarkupDialect::Pango, 16.0);
9711        assert!(pango.contains("foreground=\"#ff0000\""), "{pango}");
9712        assert!(pango.contains("font_family=\"monospace\""), "{pango}");
9713        let qt = runs_to_markup(
9714            text,
9715            std::slice::from_ref(&run),
9716            MarkupDialect::QtHtml,
9717            16.0,
9718        );
9719        assert!(qt.contains("color:#ff0000"), "{qt}");
9720        // Qt takes its fixed face from <code>: a `font-family:monospace` style attribute
9721        // renders proportional (observed on Qt 6.11).
9722        assert!(qt.contains("<code>") && qt.contains("</code>"), "{qt}");
9723    }
9724
9725    #[test]
9726    fn the_new_attributes_reach_both_dialects() {
9727        let run = TextRun {
9728            range: 0..2,
9729            font: FontSpec::default().scaled(1.5),
9730            background: Some(Color::rgb(0.0, 1.0, 0.0)),
9731            underline: Underline::Single,
9732            ..TextRun::default()
9733        };
9734        let pango = runs_to_markup("hi", std::slice::from_ref(&run), MarkupDialect::Pango, 16.0);
9735        assert!(pango.contains("background=\"#00ff00\""), "{pango}");
9736        // Pango takes an absolute size in 1/1024 pt (16 pt base × 1.5 = 24 pt), because the
9737        // relative attributes are Pango 1.50's and an unknown attribute empties the whole label.
9738        assert!(pango.contains("size=\"24576\""), "{pango}");
9739        assert!(pango.contains("<u>hi</u>"), "{pango}");
9740        let qt = runs_to_markup(
9741            "hi",
9742            std::slice::from_ref(&run),
9743            MarkupDialect::QtHtml,
9744            16.0,
9745        );
9746        assert!(qt.contains("background-color:#00ff00"), "{qt}");
9747        // Qt takes points: 16 pt base × 1.5 (observed; its CSS subset ignores a percentage).
9748        assert!(qt.contains("font-size:24pt"), "{qt}");
9749        assert!(qt.contains("<u>hi</u>"), "{qt}");
9750    }
9751
9752    #[test]
9753    fn a_link_target_is_escaped_too() {
9754        // A URL carrying `&` between query parameters is ordinary, and unescaped it truncates
9755        // the attribute and swallows the rest of the label.
9756        let text = "docs";
9757        let run = TextRun {
9758            range: 0..4,
9759            link: Some("https://x.dev/?a=1&b=2".into()),
9760            ..TextRun::default()
9761        };
9762        let m = runs_to_markup(text, &[run], MarkupDialect::Pango, 16.0);
9763        assert!(m.contains("a=1&amp;b=2"), "{m}");
9764    }
9765
9766    #[test]
9767    fn tags_nest_and_close_in_reverse() {
9768        let run = TextRun {
9769            range: 0..2,
9770            font: FontSpec {
9771                style: Font::Body,
9772                weight: Some(FontWeight::Bold),
9773                italic: true,
9774                ..Default::default()
9775            },
9776            strikethrough: true,
9777            link: Some("u".into()),
9778            ..TextRun::default()
9779        };
9780        let m = runs_to_markup("hi", &[run], MarkupDialect::Pango, 16.0);
9781        assert_eq!(m, "<a href=\"u\"><b><i><s>hi</s></i></b></a>");
9782    }
9783
9784    #[test]
9785    fn runs_out_of_bounds_are_skipped_not_panicked() {
9786        // `runs_are_valid` rejects these upstream; this is the belt-and-braces path.
9787        let m = runs_to_markup("hi", &[bold(0..99)], MarkupDialect::Pango, 16.0);
9788        assert_eq!(m, "hi");
9789    }
9790
9791    #[test]
9792    fn validation_catches_the_four_ways_runs_go_wrong() {
9793        let text = "héllo";
9794        assert!(runs_are_valid(text, &[bold(0..1)]).is_ok());
9795        // Overlapping.
9796        assert!(runs_are_valid(text, &[bold(0..3), bold(2..4)]).is_err());
9797        // Past the end.
9798        assert!(runs_are_valid(text, &[bold(0..99)]).is_err());
9799        // Inverted. Built from parts, since a literal `3..1` is a clippy error in its own right.
9800        let (hi, lo) = (3, 1);
9801        assert!(runs_are_valid(text, &[bold(hi..lo)]).is_err());
9802        // Splitting the 2-byte `é`.
9803        assert!(runs_are_valid(text, &[bold(0..2)]).is_err());
9804    }
9805}
9806
9807#[cfg(test)]
9808mod route_of_url_tests {
9809    use super::route_of_url;
9810
9811    #[test]
9812    fn strips_the_scheme_and_keeps_route_and_params() {
9813        assert_eq!(
9814            route_of_url("notes://mail/inbox?hint=x"),
9815            "mail/inbox?hint=x"
9816        );
9817        assert_eq!(route_of_url("notes://home"), "home");
9818        // No scheme: already a route, unchanged.
9819        assert_eq!(route_of_url("mail/inbox"), "mail/inbox");
9820        assert_eq!(route_of_url(""), "");
9821        // Percent-encoding passes through; the route parser decodes, not this mapping.
9822        assert_eq!(route_of_url("n://a/b%2Fc?q=1%202"), "a/b%2Fc?q=1%202");
9823    }
9824}
9825
9826#[cfg(test)]
9827mod builtin_kind_tests {
9828    use super::*;
9829
9830    /// Every built-in round-trips through its wire key, and the keys are unique. Guards the
9831    /// `builtin_kinds!` expansion: a copy-paste slip in the table would alias two kinds.
9832    #[test]
9833    fn keys_round_trip_and_are_unique() {
9834        let mut seen = std::collections::HashSet::new();
9835        for &b in Builtin::ALL {
9836            assert_eq!(Builtin::from_key(b.key()), Some(b), "{b:?} key round-trip");
9837            assert!(seen.insert(b.key()), "duplicate key {:?}", b.key());
9838            assert!(
9839                b.key().starts_with("day."),
9840                "built-in {b:?} must use the reserved `day.` prefix"
9841            );
9842        }
9843    }
9844
9845    /// The `kinds::*` constants are the enum's keys, so the two spellings cannot drift.
9846    #[test]
9847    fn kinds_constants_match_the_enum() {
9848        assert_eq!(kinds::LABEL, Builtin::Label.key());
9849        assert_eq!(kinds::LIST_CELL, Builtin::ListCell.key());
9850        assert_eq!(kinds::COVER, Builtin::Cover.key());
9851        assert_eq!(kinds::INSPECTOR, Builtin::Inspector.key());
9852        assert_eq!(kinds::TREE, Builtin::Tree.key());
9853        // 19 after `Tabs`/`TabsPage` retired (the tab bar is a NAV presentation, not a kind),
9854        // +2 for the inspector split and its panes (docs/inspector.md), +1 for the tree
9855        // (docs/tree.md).
9856        assert_eq!(Builtin::ALL.len(), 25);
9857    }
9858
9859    /// An extension piece's kind is not a built-in.
9860    #[test]
9861    fn extension_kinds_are_not_builtin() {
9862        assert_eq!(Builtin::from_key("acme.combobox"), None);
9863        assert_eq!(Builtin::from_key("day.not_a_real_kind"), None);
9864    }
9865}
9866
9867#[cfg(test)]
9868mod locale_tests {
9869    /// POSIX locale strings are not BCP-47 tags, and "C" is not a language; that difference is
9870    /// the job of `posix_locale_hints` (§12.2).
9871    #[test]
9872    fn posix_hints_parse_and_drop_the_c_locale() {
9873        // The parsing is exercised through the same normalization the function applies.
9874        let normalize = |v: &str| v.split('.').next().unwrap_or(v).replace('_', "-");
9875        assert_eq!(normalize("fr_FR.UTF-8"), "fr-FR");
9876        assert_eq!(normalize("en_US"), "en-US");
9877        assert_eq!(normalize("C"), "C");
9878        // A real read never panics whatever this host's environment holds.
9879        let hints = super::posix_locale_hints();
9880        assert!(
9881            hints
9882                .iter()
9883                .all(|h| !h.is_empty() && h != "C" && h != "POSIX")
9884        );
9885    }
9886}
9887
9888#[cfg(test)]
9889mod sidetable_tests {
9890    use super::sidetable::{SideTable, sweep};
9891    use std::cell::Cell;
9892    use std::rc::Rc;
9893
9894    /// The registry exists so that one `sweep(key)` clears every table without any
9895    /// table needing its own line in a backend's `release()`.
9896    #[test]
9897    fn sweep_clears_every_registered_table_and_runs_teardowns() {
9898        let torn = Rc::new(Cell::new(0u32));
9899        let t2 = torn.clone();
9900        let plain: SideTable<&'static str> = SideTable::new();
9901        let hooked: SideTable<u32> = SideTable::with_teardown(move |_| t2.set(t2.get() + 1));
9902
9903        plain.insert(7, "a");
9904        hooked.insert(7, 1);
9905        hooked.insert(8, 2);
9906        sweep(7);
9907        assert!(!plain.contains(7));
9908        assert!(!hooked.contains(7), "swept from every table");
9909        assert!(hooked.contains(8), "other keys untouched");
9910        assert_eq!(torn.get(), 1, "teardown ran for the swept value");
9911
9912        // remove() runs the teardown too; take() bypasses it for ownership transfer.
9913        hooked.remove(8);
9914        assert_eq!(torn.get(), 2);
9915        hooked.insert(9, 3);
9916        assert_eq!(hooked.take(9), Some(3));
9917        assert_eq!(torn.get(), 2, "take() skips the hook");
9918    }
9919
9920    #[test]
9921    fn contain_returns_default_on_panic() {
9922        let ok = super::ffi_guard::contain(0i32, || 5);
9923        assert_eq!(ok, 5);
9924        let prev = std::panic::take_hook();
9925        std::panic::set_hook(Box::new(|_| {}));
9926        let contained = super::ffi_guard::contain(-1i32, || panic!("boom"));
9927        std::panic::set_hook(prev);
9928        assert_eq!(contained, -1, "the panic must not escape the guard");
9929    }
9930}
9931
9932#[cfg(test)]
9933mod cursor_tests {
9934    use super::Cursor;
9935
9936    /// Every named cursor has a distinct CSS keyword, and `NAMED` lists each exactly once: the
9937    /// gallery and every backend's table key on the keyword.
9938    #[test]
9939    fn named_cursors_have_distinct_css_names() {
9940        let mut seen = std::collections::BTreeSet::new();
9941        for c in Cursor::NAMED.iter() {
9942            assert!(
9943                seen.insert(c.css_name().to_string()),
9944                "duplicate: {}",
9945                c.css_name()
9946            );
9947            assert!(!matches!(c, Cursor::Native(_)));
9948        }
9949        assert_eq!(seen.len(), Cursor::NAMED.len());
9950        assert_eq!(Cursor::native("dragCopy").css_name(), "dragCopy");
9951        assert_eq!(Cursor::default(), Cursor::Default);
9952    }
9953}
9954
9955#[cfg(test)]
9956mod text_anchor_tests {
9957    /// The anchor arithmetic every backend repeats. A drawing whose text lands in the wrong
9958    /// corner is a per-backend bug that only a screenshot catches, so the shared definition is
9959    /// pinned here and each shim is written to match it.
9960    #[test]
9961    fn text_anchor_offsets_place_the_line_box() {
9962        use crate::props::TextAlign;
9963        use crate::{TextAnchor, TextVAlign};
9964        // A 40-wide, 20-tall line box whose baseline sits 16 below its top.
9965        let (w, h, asc) = (40.0, 20.0, 16.0);
9966        let at = |ha, va| TextAnchor { h: ha, v: va }.offset(w, h, asc);
9967
9968        // The default is the drawing origin itself, every backend's native "draw here".
9969        assert_eq!(at(TextAlign::Leading, TextVAlign::Top), (0.0, 0.0));
9970        assert_eq!(TextAnchor::default(), TextAnchor::LEADING);
9971        // Horizontal: the box's leading edge, its middle, its trailing edge.
9972        assert_eq!(at(TextAlign::Center, TextVAlign::Top).0, -20.0);
9973        assert_eq!(at(TextAlign::Trailing, TextVAlign::Top).0, -40.0);
9974        // Vertical: top, middle, the baseline itself, bottom.
9975        assert_eq!(at(TextAlign::Leading, TextVAlign::Middle).1, -10.0);
9976        assert_eq!(at(TextAlign::Leading, TextVAlign::Baseline).1, -16.0);
9977        assert_eq!(at(TextAlign::Leading, TextVAlign::Bottom).1, -20.0);
9978        // The two named placements are the ones the two-variant enum used to have.
9979        assert_eq!(TextAnchor::CENTERED.offset(w, h, asc), (-20.0, -10.0));
9980        assert_eq!(TextAnchor::TRAILING.offset(w, h, asc), (-40.0, 0.0));
9981    }
9982
9983    /// The wire code the serializing backends (qt, xaml, arkui, android, web) decode by hand.
9984    #[test]
9985    fn text_anchor_packs_into_one_slot_and_round_trips() {
9986        use crate::props::TextAlign;
9987        use crate::{TextAnchor, TextVAlign};
9988        let mut seen = std::collections::BTreeSet::new();
9989        for h in [TextAlign::Leading, TextAlign::Center, TextAlign::Trailing] {
9990            for v in [
9991                TextVAlign::Top,
9992                TextVAlign::Middle,
9993                TextVAlign::Baseline,
9994                TextVAlign::Bottom,
9995            ] {
9996                let a = TextAnchor { h, v };
9997                let code = a.pack();
9998                assert!(
9999                    (0.0..=11.0).contains(&code),
10000                    "{code} outside the packed range"
10001                );
10002                assert!(seen.insert(code as u64), "two anchors share code {code}");
10003                assert_eq!(TextAnchor::unpack(code), a);
10004            }
10005        }
10006        assert_eq!(seen.len(), 12);
10007        // The shims decode as `h = code / 4`, `v = code % 4` in integer arithmetic.
10008        assert_eq!(TextAnchor::LEADING.pack(), 0.0);
10009        assert_eq!(TextAnchor::CENTERED.pack(), 5.0);
10010        // Junk answers the default rather than failing inside a native draw callback.
10011        assert_eq!(TextAnchor::unpack(-3.0), TextAnchor::LEADING);
10012        assert_eq!(TextAnchor::unpack(99.0), TextAnchor::unpack(11.0));
10013    }
10014
10015    /// The two shapes a backend's own metrics arrive in. Both land on the same contract: the line
10016    /// box's top-leading corner is the origin, and `ink` is relative to it.
10017    #[test]
10018    fn metrics_convert_from_a_backends_own_shape() {
10019        use crate::{Rect, TextMetrics};
10020        // Baseline-relative, y up-negative; Core Text, Skia and Qt all report like this.
10021        let m = TextMetrics::from_baseline(
10022            40.0,
10023            16.0,
10024            4.0,
10025            11.0,
10026            // Ink starting 1 right of the origin, from 11 above the baseline to 2 below it.
10027            Rect::new(1.0, -11.0, 38.0, 13.0),
10028        );
10029        assert_eq!(
10030            (m.width, m.height, m.ascent, m.cap_height),
10031            (40.0, 20.0, 16.0, 11.0)
10032        );
10033        // Shifted down by the ascent: the ink now starts 5 below the line box's top, which is
10034        // where a cap begins in a slot whose baseline is at 16.
10035        assert_eq!(m.ink, Rect::new(1.0, 5.0, 38.0, 13.0));
10036        assert_eq!(m.ascent - m.cap_height, m.ink.origin.y);
10037
10038        // The eight-slot wire form is already line-box-relative and passes straight through.
10039        let w = TextMetrics::from_slots(&[40.0, 20.0, 16.0, 11.0, 1.0, 5.0, 38.0, 13.0]);
10040        assert_eq!(w, m);
10041    }
10042
10043    /// The fallback must be usable, not merely present: a caller frames text with it on a toolkit
10044    /// that cannot measure, and every relation it relies on has to hold.
10045    #[test]
10046    fn the_approximation_is_internally_consistent() {
10047        use crate::TextMetrics;
10048        let m = TextMetrics::approximate("Agy", 20.0);
10049        assert!(m.ascent < m.height, "the baseline sits inside the line box");
10050        assert!(
10051            m.cap_height < m.ascent,
10052            "caps do not reach above the ascent"
10053        );
10054        // The ink box is the whole line box: a superset, so an overlap test using it can only be
10055        // too cautious. Never a claim to be tighter than it knows.
10056        assert_eq!(m.ink.origin.x, 0.0);
10057        assert_eq!(m.ink.origin.y, 0.0);
10058        assert_eq!(m.ink.size.width, m.width);
10059        assert_eq!(m.ink.size.height, m.height);
10060        // Empty text still answers a usable (zero-width, full-height) slot.
10061        let e = TextMetrics::approximate("", 20.0);
10062        assert_eq!(e.width, 0.0);
10063        assert!(e.height > 0.0);
10064    }
10065}
10066
10067#[cfg(test)]
10068mod cap_list_tests {
10069    use super::Cap;
10070
10071    #[test]
10072    fn every_capability_is_listed_once_in_its_place() {
10073        for (i, cap) in Cap::ALL.iter().enumerate() {
10074            assert_eq!(cap.ordinal(), i, "{cap:?} is out of place in Cap::ALL");
10075        }
10076    }
10077}