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("&"),
5362 '<' => out.push_str("<"),
5363 '>' => out.push_str(">"),
5364 '"' => out.push_str("""),
5365 '\'' => out.push_str("'"),
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 /// Scroll a native navigation menu to its top or bottom without changing selection.
7085 /// The widget owns its content extent. Return false when the handle is unsupported.
7086 fn nav_scroll_to_edge(&mut self, _h: &Self::Handle, _bottom: bool) -> bool {
7087 false
7088 }
7089 fn scroll_offset(&mut self, _h: &Self::Handle) -> Point {
7090 Point::ZERO
7091 }
7092
7093 // events: one trampoline, node-id keyed; enqueue-only contract (§8.3).
7094 fn set_event_sink(&mut self, sink: EventSink);
7095
7096 // gestures (docs/shapes.md): attach a native recognizer for `kind` to `h`, emitting
7097 // `Event::Tap/LongPress/Drag` for `node` (enqueue-only). Default no gesture; a piece opts in
7098 // when it has a handler. Idempotent per (handle, kind).
7099 fn enable_gesture(&mut self, _h: &Self::Handle, _node: NodeId, _kind: GestureKind) {}
7100
7101 // focus (docs/focus.md): move native keyboard focus to (or away from) this control.
7102 // `focused = true` requests focus (on mobile this also raises the soft keyboard for text
7103 // inputs); `false` resigns it (dismissing the keyboard; platforms without a "focus nothing"
7104 // state resign to a focusable root). Backends report the resulting state, user- or
7105 // programmatic, with `Event::FocusChanged(bool)` through the sink; a request that cannot
7106 // be honored (unfocusable, unmounted) produces no event. The default no-op means a
7107 // backend without focus support neither moves nor reports focus.
7108 fn focus(&mut self, _h: &Self::Handle, _node: NodeId, _focused: bool) {}
7109
7110 // focusability (docs/focus.md): opt this container into the platform's focus system, the
7111 // canvas contract generalized (`Decorate::focusable`). A focusable container accepts focus,
7112 // takes it on a press (before the press reaches its gesture recognizers), reports both
7113 // directions with `Event::FocusChanged`, and delivers the non-text keys it hears while
7114 // focused as `Event::Key` for `node` (gated on `keys::handled`, like a canvas). The default
7115 // no-op means the piece renders normally but never joins the focus order: `.focused(…)`
7116 // bindings stay quiet and `.on_key(…)` never fires, the same graceful silence unfocusable
7117 // controls already have. Idempotent; only `true` is ever sent today (a piece opts in at
7118 // build, it does not opt back out).
7119 fn set_focusable(&mut self, _h: &Self::Handle, _node: NodeId, _focusable: bool) {}
7120
7121 // recycling list (docs/list.md, §10): day-core hands the `LIST` host its row-pull `source`
7122 // once, right after realize. A recycling backend stores it and calls it from its native
7123 // data-source; the default no-op means a backend without list support renders nothing.
7124 fn attach_list(&mut self, _host: &Self::Handle, _source: ListSource) {}
7125
7126 // hierarchical tree (docs/tree.md): day-core hands the `TREE` host its token-addressed
7127 // row-pull `source` once, right after realize. A backend with a native tree stores it and
7128 // answers its data-source from it; the default no-op means a backend without tree support
7129 // renders nothing (`Cap::Tree` answers `Unsupported`, and apps gate on that).
7130 fn attach_tree(&mut self, _host: &Self::Handle, _source: TreeSource) {}
7131
7132 // routes (docs/navigation.md): day-core reports the app's current route path here whenever
7133 // it changes ("" = everything at its root), so a backend with a native notion of location
7134 // can mirror it: web-dom writes the URL hash (`#controls`), and browser back/forward or a
7135 // hand-edited hash comes back as `Event::RouteRequested`. Default no-op: most toolkits
7136 // have nowhere to put a route.
7137 fn set_route(&mut self, _route: &str) {}
7138
7139 // undo bridge (docs/model.md): mirror the app's undo stack into the platform's own undo
7140 // objects, so the stock Edit menu retitles and enables itself and the platform's gestures
7141 // land: the native front of one Day-owned history. The state flows down whenever it
7142 // changes; the user's invocation comes back up as `Event::Undo`. Default no-op: a backend
7143 // without a native undo system answers `Cap::UndoBridge` `Unsupported`, and the app's own
7144 // affordances (menu items, buttons, accelerators) drive the stack instead.
7145 fn set_undo_state(&mut self, _state: &UndoState) {}
7146
7147 // edit bridge (docs/menus.md): mirror what the app's standard-edit handlers can do, so
7148 // the platform's own menu validation enables Cut/Copy/Paste exactly as it does for text
7149 // widgets; invocations come back up as `Event::Edit`. Default no-op: a backend without
7150 // a native edit-command route answers `Cap::EditBridge` accordingly.
7151 fn set_edit_state(&mut self, _state: &EditState) {}
7152
7153 // ambient modifiers (docs/menus.md): the keyboard modifiers held right now, for
7154 // interactions whose meaning they change (shift-click multi-select). Pull-based: the
7155 // platforms expose exactly this query (NSEvent.modifierFlags, the shim's tracked mask).
7156 // Touch-only backends keep the all-false default.
7157 fn modifiers(&mut self) -> Modifiers {
7158 Modifiers::default()
7159 }
7160
7161 // Dynamic context menu (docs/menus.md): the menu is built when it is summoned. The provider
7162 // is called synchronously from the platform's own menu callback (UI thread, outside any
7163 // day-core borrow) with the location in the node's local coordinates, and its result is
7164 // shown. The static `set_context_menu` below stays the simple path; this one exists for
7165 // surfaces whose menu depends on what is under the pointer (a canvas selection,
7166 // docs/tree.md). Default no-op: a backend without the affordance shows nothing.
7167 fn set_drag_source(&mut self, _h: &Self::Handle, _source: transfer::Source) {}
7168 fn set_drop_target(&mut self, _h: &Self::Handle, _target: transfer::Target) {}
7169
7170 fn set_context_menu_fn(&mut self, _h: &Self::Handle, _node: NodeId, _f: ContextMenuFn) {}
7171
7172 // menus (§ menus): render `items` with the backend's native menu affordance, firing
7173 // `Event::MenuAction(id)` (enqueue-only) for each id'd item; `role` items use the native standard
7174 // command. Default no-op: a toolkit without a menu bar / context menu shows nothing.
7175 /// The application menu (macOS/Windows/Linux menu bar; the app-bar overflow on Android; the
7176 /// UIMenuBuilder main menu on iPadOS/Catalyst). Replaces any previous app menu.
7177 fn set_app_menu(&mut self, _items: &[MenuItem]) {}
7178 /// A context menu for `h`, shown on secondary-click (desktop) or long-press (mobile). Passing an
7179 /// empty slice removes it.
7180 fn set_context_menu(&mut self, _h: &Self::Handle, _node: NodeId, _items: &[MenuItem]) {}
7181
7182 // toolbars (docs/toolbars.md): a window's native toolbar: NSToolbar, AdwHeaderBar, QToolBar,
7183 // CommandBar. `h` is the window root's handle (the same handle `open_window` returned, or the
7184 // primary root's); the backend walks from it to the window it belongs to. Default no-op: a
7185 // toolkit with no toolbar shows nothing rather than a drawn imitation, and reports
7186 // `Cap::Toolbar` as `Unsupported` so an app can put the command somewhere else.
7187 /// Edit the window's toolbar (docs/toolbars.md): the [`ToolbarOp`]s, removals first, then
7188 /// insertions in ascending position. The bar starts empty, and becomes empty again when every
7189 /// item has been removed, at which point the backend takes it off the window.
7190 ///
7191 /// An item no op names must keep its native widget untouched, which is what keeps a search
7192 /// field focused while the page around it changes. Day sends an edit only when the bar
7193 /// changed, at most once per turn, and an item keeps its [`ToolbarItem::action`] for as long
7194 /// as its window shows an item with that id, so a native item's handler never needs
7195 /// repointing. Keep a [`ToolbarMirror`] and place each new item after
7196 /// [`ToolbarMirror::prev_where`] its group.
7197 ///
7198 /// Returns whether the edit was applied. `false` (the window has no bar to edit yet) makes
7199 /// Day send the whole bar again at the next change rather than assume this one landed.
7200 fn edit_toolbar(&mut self, _h: &Self::Handle, _ops: &[ToolbarOp]) -> bool {
7201 true
7202 }
7203 /// Apply a targeted change to one live toolbar item, the path a bound signal writes through,
7204 /// so syncing a search field does not rebuild the bar. No-op if the item is not present.
7205 fn update_toolbar(&mut self, _h: &Self::Handle, _patch: &ToolbarPatch) {}
7206
7207 // lifecycle (docs/lifecycle.md): does this backend deliver `phase`? The default answers "yes" for
7208 // the universal phases (launch/activation/termination) and "no" for the mobile-only ones. Backends
7209 // that wire up more (the mobile ones) override this; it must agree with the crate's
7210 // `const fn lifecycle_supported`, which drives compile-time guards in `day::require_lifecycle!`.
7211 fn supports_lifecycle(&self, phase: Lifecycle) -> bool {
7212 phase.is_universal()
7213 }
7214
7215 // pillars
7216 /// Apply a node's accessibility annotations (docs/accessibility.md): the label, hint and
7217 /// value the screen reader speaks, an explicit role, whether the node is hidden from
7218 /// assistive technology, and the automation identifier. Called with the node's whole
7219 /// accumulated set each time any of it changes, so every member is written on every call.
7220 /// Only an explicit role is applied: a native control reports its own.
7221 fn set_a11y(&mut self, _h: &Self::Handle, _a11y: &A11yProps) {}
7222 /// Speak `text` through the screen reader without moving its focus (docs/accessibility.md;
7223 /// probe [`Cap::Announce`]). `urgent` interrupts what is being read; otherwise the sentence
7224 /// waits its turn. Nothing happens when no screen reader is running. The default is a
7225 /// backend that reaches no announcement API.
7226 fn announce(&mut self, _text: &str, _urgent: bool) {}
7227 /// Read a widget's native state back from the platform for `a11y_audit` and
7228 /// `assert_native` (§14.2, docs/testing.md) to diff against Day's expectation. Default:
7229 /// nothing read; the audit skips the node and `assert_native` records each field as unread.
7230 fn read_native(&self, _h: &Self::Handle) -> NativeSnapshot {
7231 NativeSnapshot::default()
7232 }
7233 fn replay(&mut self, _h: &Self::Handle, _ops: &[DrawOp], _size: Size) {}
7234
7235 /// Every family this toolkit can draw canvas text in, with the faces each ships
7236 /// (docs/fonts.md; probe [`Cap::FontList`]). Default: none. `day::font_families` calls
7237 /// this once per process and caches the answer: enumeration is slow on every platform
7238 /// (fontconfig, a DirectWrite collection, an XML parse on Android) and the set does not
7239 /// change under a running app.
7240 fn font_families(&mut self) -> Vec<FontFamilyInfo> {
7241 Vec::new()
7242 }
7243
7244 /// Measure one line of canvas text at `size` points in `font`, in the same engine
7245 /// [`Toolkit::replay`] draws [`DrawOp::Text`] with, so a `TextAnchor::LEADING` anchor plus
7246 /// the returned `ascent` lands on the drawn baseline. `None` = cannot measure; the facade
7247 /// then answers [`TextMetrics::approximate`].
7248 fn measure_text(&mut self, _text: &str, _size: f64, _font: &CanvasFont) -> Option<TextMetrics> {
7249 None
7250 }
7251 // --- raster images (docs/images.md) -------------------------------------------------
7252 //
7253 // Request-id shaped rather than blocking, because one backend can never answer
7254 // synchronously: a browser decodes through `createImageBitmap`, which is a promise. Every
7255 // other backend decodes inline and raises the completion before returning, so the shape
7256 // costs them nothing but a line, and the app-facing API can be one thing everywhere
7257 // instead of two (docs/async.md rule 3: a callback and a future, never a runtime).
7258
7259 /// Decode encoded image bytes (PNG/JPEG/…) into a native image, answering `req` with
7260 /// [`Event::ImageDecoded`].
7261 ///
7262 /// The id in that event is minted by the caller (day-core), not the backend: the backend
7263 /// keeps its own side table from that id to whatever it decoded.
7264 ///
7265 /// The default does nothing because a default body cannot reach the event sink
7266 /// (it is installed per backend by [`Toolkit::set_event_sink`], not reachable through this
7267 /// trait), so there is no way for it to answer. day-core asks [`Cap::ImageDecode`] first and
7268 /// resolves the request as [`ImageError::Unsupported`] itself, which is why a backend with
7269 /// no decoder needs no code here at all.
7270 fn decode_image(&mut self, _req: u64, _id: BitmapId, _bytes: &[u8]) {}
7271
7272 /// What a decoded image is: pixel size, scale, format, alpha ([`BitmapInfo`]).
7273 ///
7274 /// Synchronous: every backend that decoded the image already holds these facts. `None` means
7275 /// the id names nothing live.
7276 fn image_info(&mut self, _id: BitmapId) -> Option<BitmapInfo> {
7277 None
7278 }
7279
7280 /// The metadata beyond the pixels: EXIF orientation, DPI, capture time
7281 /// ([`ImageProperties`], probe [`Cap::ImageProperties`]). `None` where the backend has no
7282 /// metadata reader, which is most of them.
7283 fn image_properties(&mut self, _id: BitmapId) -> Option<ImageProperties> {
7284 None
7285 }
7286
7287 /// Encode a decoded image back to bytes per `spec`, answering `req` with
7288 /// [`Event::ImageEncoded`]. A format outside [`Self::encode_formats`] answers
7289 /// [`ImageError::Encode`] rather than silently substituting another one.
7290 ///
7291 /// Defaulted to nothing for the same reason as [`Self::decode_image`]: day-core probes
7292 /// [`Cap::ImageEncode`] and answers `Unsupported` without ever calling this.
7293 fn encode_image(&mut self, _req: u64, _id: BitmapId, _spec: &EncodeSpec) {}
7294
7295 /// Which formats [`Self::encode_image`] can write, the [`Cap::ImageEncode`]
7296 /// counterpart of [`Self::font_families`]/[`Cap::FontList`]. Empty by default.
7297 fn encode_formats(&mut self) -> Vec<ImageFormat> {
7298 Vec::new()
7299 }
7300
7301 /// Drop a decoded image and everything the backend cached for it. Called when the app's
7302 /// last handle to it goes away; an id the backend never saw is a no-op.
7303 fn release_image(&mut self, _id: BitmapId) {}
7304
7305 // --- canvas export (docs/canvas.md "Exporting") --------------------------------------
7306 //
7307 // Request-id shaped like the image duties above, for the same reason: the browser's
7308 // `createImageBitmap` is a promise. Every other backend renders inline and raises the
7309 // completion before returning. day-core validates sizes, enforces the pixel limits and
7310 // probes the caps first, so a backend sees only requests it can attempt.
7311
7312 /// Replay `ops` into a new offscreen bitmap of `size` points at `scale` pixels per point
7313 /// ([`export_pixels`] gives the pixel size), with a transparent background and the
7314 /// canvas's top-left origin. Store it under `id` in the same table [`Self::decode_image`]
7315 /// fills, so `image_info`, `encode_image`, `release_image`, the `image` piece and
7316 /// [`DrawOp::Image`] all take it, then answer `req` with [`Event::BitmapRendered`] (its
7317 /// [`BitmapInfo::scale`] is `scale`).
7318 ///
7319 /// The painter must be the one [`Self::replay`] uses, so text, gradients, clips and stamps
7320 /// match the screen. Defaulted to nothing: day-core probes [`Cap::CanvasBitmap`] and answers
7321 /// [`ExportError::Unsupported`] itself.
7322 fn render_bitmap(
7323 &mut self,
7324 _req: u64,
7325 _id: BitmapId,
7326 _ops: &[DrawOp],
7327 _size: Size,
7328 _scale: f64,
7329 ) {
7330 }
7331
7332 /// Replay each page into one PDF document and answer `req` with [`Event::PdfRendered`].
7333 /// Units are PDF points, top-left origin; shapes and text stay vector, and images embed at
7334 /// their pixel size. `pages` is never empty. Defaulted to nothing: day-core probes
7335 /// [`Cap::CanvasPdf`] first.
7336 fn render_pdf(&mut self, _req: u64, _pages: &[PdfPage], _info: &PdfInfo) {}
7337
7338 fn snapshot_window(&mut self) -> Result<Vec<u8>, String> {
7339 Err("snapshot unsupported".into())
7340 }
7341 /// Where `root`, the primary window's root container, sits in [`Self::snapshot_window`]'s
7342 /// image, in points from its top-left. A reader of the capture (dayscript `sample_pixel`)
7343 /// maps Day's frames into it with this; it matters where the capture spans more than Day's
7344 /// content on more than one side, as Android's edge-to-edge content does (status bar
7345 /// above, navigation bar below).
7346 ///
7347 /// Defaulted to `None`, which the reader takes as "any rows beyond Day's content are
7348 /// above it", true where the only extra area is a title bar.
7349 fn snapshot_origin(&mut self, _root: &Self::Handle) -> Option<Point> {
7350 None
7351 }
7352 /// Establish a fresh screenshot checkpoint after reactive work and native transitions.
7353 /// `revision` is stable across retries and unique to the request; `host` selects a
7354 /// secondary window, or None for the primary. Never block the UI thread waiting for
7355 /// a frame. Pending requests are retried within the dayscript deadline.
7356 ///
7357 /// Ready means the platform render checkpoint completed, not that a vsync callback
7358 /// proves pixels were displayed. OnCapture delegates freshness to snapshot_window.
7359 fn prepare_snapshot(
7360 &mut self,
7361 _host: Option<&Self::Handle>,
7362 _revision: u32,
7363 ) -> Result<capture::Readiness, String> {
7364 // Compatible with external toolkits predating this hook: their synchronous
7365 // snapshot owns freshness, and no external-capture acknowledgment is fabricated.
7366 Ok(capture::Readiness::OnCapture)
7367 }
7368
7369 /// The same capture with the window's own chrome: title bar, toolbar, whatever the platform
7370 /// draws around the content (docs/window-image.md).
7371 ///
7372 /// Defaulted to [`Self::snapshot_window`]: a backend that cannot separate the two
7373 /// (or has no chrome to speak of, a phone) answers with the content shot rather than an
7374 /// error, so an app asking for chrome degrades to "what there was" instead of failing. The
7375 /// dayscript `screenshot` step never calls this, which is what keeps every captured gallery
7376 /// baseline byte-identical to before it existed.
7377 fn snapshot_window_chrome(&mut self) -> Result<Vec<u8>, String> {
7378 self.snapshot_window()
7379 }
7380 /// Show/hide the sidebar pane of the navigation host `host`, which is what the sidebar
7381 /// affordance a `nav(Sidebar)` contributes for itself drives ([`SIDEBAR_TOGGLE_ID`]).
7382 /// `false` when `host` has no pane to toggle (a stack, a tab bar, no sidebar concept at all).
7383 ///
7384 /// Per host, not per process: a second window's button collapses that window's sidebar
7385 /// and not the first's. The item's own action makes the call, and dayscript's `toolbar:`
7386 /// step presses the item like any other, so a walkthrough drives exactly the path a click
7387 /// takes. Defaulted, so a backend with no sidebar needs no code. docs/toolbars.md,
7388 /// docs/navigation.md.
7389 fn toggle_sidebar(&mut self, _host: &Self::Handle) -> bool {
7390 false
7391 }
7392 /// Press the platform's own back affordance (the navigation bar's back button on iOS,
7393 /// the system back on Android) on the innermost navigation host that has something to
7394 /// pop. What `nav_back()` does not do: that is Day's rail, which pops the model and lets the
7395 /// backend follow, while this runs the native path first (the bar's `shouldPop`, the
7396 /// dispatcher's callbacks, the pop itself) and lets the backend report it to Day as a user
7397 /// back, guard and all. dayscript's `nav_back: { native: true }` drives it, so a walkthrough
7398 /// can cover the code that a real tap runs and Day's rail never reaches. `false` when this
7399 /// toolkit has no native back or nothing is popped by it. Defaulted, so a desktop needs no
7400 /// code. docs/navigation.md.
7401 fn native_back(&mut self) -> bool {
7402 false
7403 }
7404 /// Whether the UI has settled, with no native transition (modal present/dismiss, nav push)
7405 /// still animating. The dayscript `screenshot` step polls this before capturing so shots
7406 /// never catch a half-faded dialog or half-pushed page. Backends without async
7407 /// transitions (or without a way to know) report `true`.
7408 fn ui_idle(&mut self) -> bool {
7409 true
7410 }
7411
7412 // imperative presentation (docs/dialogs.md): show a native modal for request `req`;
7413 // the backend answers by enqueuing `Event::PresentResult { req, .. }`. `dismiss` is
7414 // used only when Day resolves programmatically (dayscript) while the modal is still up.
7415 fn present(&mut self, _req: u64, _spec: &present::PresentSpec) {}
7416 fn dismiss(&mut self, _req: u64) {}
7417
7418 /// Open `url` in the platform's default handler: the system browser for `http(s)`, the mail
7419 /// client for `mailto:`, etc. Backs the [`link`](/api/day_pieces/fn.link.html) piece. Fire and
7420 /// forget: there is no result, and an unopenable URL is ignored. The default no-ops so a
7421 /// backend that hasn't wired it up still compiles.
7422 fn open_url(&mut self, _url: &str) {}
7423
7424 /// Whether a native share chooser is available. Does not probe installed share targets.
7425 fn share_support(&self) -> Support {
7426 Support::Unsupported
7427 }
7428 /// Present the system share chooser. True means presentation started, not that sharing
7429 /// completed. Never sends data without the user's choice of a target.
7430 fn share_url(&mut self, _url: &str, _title: &str) -> bool {
7431 false
7432 }
7433
7434 /// Read current OS associations. Unsupported platforms must not fabricate a default.
7435 fn application_handlers(
7436 &mut self,
7437 _query: &applications::HandlerQuery,
7438 ) -> Result<applications::ApplicationHandlers, applications::ApplicationError> {
7439 Err(applications::ApplicationError::Unsupported)
7440 }
7441
7442 /// Open with an opaque application identifier returned by `application_handlers`.
7443 fn open_url_with_application(
7444 &mut self,
7445 _url: &str,
7446 _application: &str,
7447 completion: applications::OpenApplicationCompletion,
7448 ) {
7449 completion(Err(applications::ApplicationError::Unsupported));
7450 }
7451
7452 /// Ask the OS to require a second swipe for its edge gestures on `edges` (docs/cover.md):
7453 /// the union requested by every mounted `defers_system_gestures` modifier, re-sent whenever
7454 /// that set changes (`Edges::NONE` when the last one unmounts). iOS defers the home
7455 /// indicator / notification edges; Android enters swipe-to-reveal immersive mode while
7456 /// non-empty. The default no-ops (desktop has no system edge gestures).
7457 fn defer_system_gestures(&mut self, _edges: Edges) {}
7458
7459 /// Hide (`true`) or show the system status bar (docs/cover.md): whether any mounted
7460 /// `status_bar_hidden` modifier currently asks for it hidden, re-sent whenever that answer
7461 /// may have changed (`false` when the last request unmounts). iOS answers the root and
7462 /// cover view controllers' `prefersStatusBarHidden`; Android and HarmonyOS hide the status
7463 /// bar through their window inset controls. The default no-ops (desktop and web have no
7464 /// status bar); report `Cap::StatusBarHidden` alongside an implementation.
7465 fn set_status_bar_hidden(&mut self, _hidden: bool) {}
7466
7467 /// Whether the platform is rendering in dark appearance right now. Apps painting
7468 /// custom surfaces (opaque overlay panels, scrims) branch on this so their fills track
7469 /// the theme the default text colors already follow; hardcoding either theme's
7470 /// surface produces dark-on-dark or light-on-light text on the other. Queried at
7471 /// build time (a piece rebuilt after a theme change re-queries). The default honors a
7472 /// `DAY_THEME` launch override and otherwise answers light.
7473 fn dark_mode(&mut self) -> bool {
7474 std::env::var("DAY_THEME").ok().as_deref() == Some("dark")
7475 }
7476 /// Whether the user asked the system for less motion (docs/accessibility.md; probe
7477 /// [`Cap::ReduceMotion`]): macOS and iOS "Reduce Motion", Android "Remove animations" (an
7478 /// animator duration scale of zero), GTK's `gtk-enable-animations` off, Windows "Show
7479 /// animations" off, the web's `prefers-reduced-motion`. Read at launch; a backend that
7480 /// watches the setting calls `day_core::note_motion_changed` when it flips. Day then applies
7481 /// every finite animation at its destination and keeps only indeterminate motion. The default
7482 /// is a backend that reads no such setting.
7483 fn reduce_motion(&mut self) -> bool {
7484 false
7485 }
7486
7487 /// App-level appearance override: `Some(true)` forces dark, `Some(false)` forces light,
7488 /// `None` returns to following the system. Backends restyle their native widgets in
7489 /// place and answer the override from [`dark_mode`](Self::dark_mode); app-painted
7490 /// surfaces pick it up on their next rebuild. `Cap::Appearance` reports whether the
7491 /// backend honors this; the default ignores the call.
7492 fn set_appearance(&mut self, _dark: Option<bool>) {}
7493
7494 /// Put `badge` on the app's icon in the Dock / launcher / home screen (docs/badge.md).
7495 ///
7496 /// Fire-and-forget, like `set_appearance`: a payload this toolkit cannot render is ignored, and
7497 /// the app probes `Cap::AppBadge{Count,Text,Dot}` before choosing one. Never substitute: a
7498 /// backend that turned `Text("beta")` into `Count(1)` would put a wrong number on a user's
7499 /// icon, which is worse than showing nothing. The default ignores every call.
7500 fn set_app_badge(&mut self, _badge: &AppBadge) {}
7501
7502 // app lifecycle (mobile; desktop backends no-op)
7503 fn on_suspend(&mut self) {}
7504 fn on_resume(&mut self) {}
7505 fn on_memory_warning(&mut self) {}
7506
7507 // adoption of foreign native handles (polyglot pieces, §15.3)
7508 fn adopt(&mut self, _raw: RawHandle) -> Self::Handle {
7509 unimplemented!("this toolkit does not adopt foreign handles yet")
7510 }
7511
7512 // secondary windows (docs/windows.md)
7513
7514 /// Open a native OS window per `options` + `kind`: create and show the window, wire its
7515 /// events to `id` (`WindowResized` with the content size in points, `WindowClosed` after
7516 /// the native close, `WindowFocused` on key/active changes), and answer with its content
7517 /// container handle, the same contract as the `ready` root container. Backends whose
7518 /// window creation is asynchronous (a scene, an activity, an ability) answer
7519 /// [`WindowOpenReply::Pending`] and complete later through
7520 /// `day_core::finish_window_open(id, raw, size)`, which goes through [`Toolkit::adopt`].
7521 /// The default answers `Unsupported`; day-core then presents the content as a
7522 /// fullscreen cover in the primary window instead.
7523 fn open_window(
7524 &mut self,
7525 _id: NodeId,
7526 _options: &WindowOptions,
7527 _kind: WindowKind,
7528 ) -> WindowOpenReply<Self::Handle> {
7529 WindowOpenReply::Unsupported
7530 }
7531
7532 /// Close the native window whose content container is `host` (a handle `open_window`
7533 /// produced). Asynchronous: the platform confirms with `Event::WindowClosed` on the
7534 /// window's root node, and only then does day-core tear the subtree down. Idempotent; unknown
7535 /// hosts are ignored. Never called with the primary window's container.
7536 fn close_window(&mut self, _host: &Self::Handle) {}
7537
7538 /// End the application: the last window with [`WindowRole::Primary`] has closed
7539 /// (docs/windows.md). day-core decides when, since it owns the window registry and the role
7540 /// each window carries; the backend supplies only the platform's exit:
7541 /// `NSApplication.terminate`, `QCoreApplication::quit`, `GtkApplication.quit`,
7542 /// `PostQuitMessage`. Everything day-core wanted to happen first (secondary windows
7543 /// disposed, `WillTerminate` delivered) already has by the time this is called.
7544 ///
7545 /// The default does nothing, which is right for the backends whose process lifetime is
7546 /// not theirs to end: a browser tab, an Android activity, an iOS scene.
7547 fn quit_app(&mut self) {}
7548
7549 /// Bring the window whose content container is `host` to front and make it key/active:
7550 /// the focus half of open-or-focus singleton windows. Default no-op.
7551 fn focus_window(&mut self, _host: &Self::Handle) {}
7552
7553 /// Retitle the window whose content container is `host`. Default no-op.
7554 fn set_window_title(&mut self, _host: &Self::Handle, _title: &str) {}
7555
7556 /// Resize the window whose content container is `host` to fit `size` of content, and pin it
7557 /// there: what `WindowOptions::size_to_fit` asks for (docs/windows.md).
7558 ///
7559 /// `size` is the content size Day measured, not the window's outer frame: the backend adds
7560 /// its chrome. A settings panel is the case this exists for, and every desktop platform
7561 /// sizes one to its content rather than to a number the app guessed; a fixed height either
7562 /// clips the last row or leaves a band of empty panel under it, and which one you get depends
7563 /// on the user's text size.
7564 ///
7565 /// Default no-op, so a toolkit that cannot resize a live window keeps the size it was
7566 /// opened at, which is what every backend did before this existed.
7567 fn fit_window(&mut self, _host: &Self::Handle, _size: Size) {}
7568
7569 /// Snapshot the window whose content container is `host` (the dayscript `screenshot`
7570 /// step's `window:` target). The default answers the primary snapshot, so backends that
7571 /// never open windows stay correct without changes.
7572 fn snapshot_window_of(&mut self, _host: &Self::Handle) -> Result<Vec<u8>, String> {
7573 self.snapshot_window()
7574 }
7575
7576 /// Apply one property change to the window whose content container is `host`
7577 /// (docs/windows.md "Window properties"). The primary window arrives with its root container,
7578 /// like [`set_window_title`](Self::set_window_title).
7579 ///
7580 /// Fire-and-forget, like [`set_app_badge`](Self::set_app_badge): a change this toolkit cannot
7581 /// make is ignored, and the app probes the matching `Cap` (named on each [`WindowChange`]
7582 /// variant) before relying on it. A change the platform completes later (a fullscreen
7583 /// transition, a zoom animation) reports its outcome through
7584 /// [`Event::WindowStateChanged`], never by assuming the request took. The default ignores
7585 /// every change.
7586 fn apply_window(&mut self, _host: &Self::Handle, _change: &WindowChange) {}
7587
7588 /// True OS window groups, as distinct from native tab widgets.
7589 fn native_window_tabs(&self) -> bool {
7590 false
7591 }
7592 /// Native in-window document/section tab control; OS grouping is reported separately.
7593 fn native_document_tabs(&self) -> bool {
7594 false
7595 }
7596 /// Join/reorder these windows in an OS tab group. Content remains window-owned.
7597 fn group_windows(&mut self, _hosts: &[Self::Handle]) {}
7598 /// Current native order of the group containing this window, including detached groups.
7599 fn window_tab_order(&self, _host: &Self::Handle) -> Vec<NodeId> {
7600 Vec::new()
7601 }
7602
7603 /// Install the app's Dock menu (docs/menus.md "Dock menu"): the items macOS shows above
7604 /// its own when the user right-clicks the app's Dock icon, built from the app's
7605 /// `[[shortcuts]]` and its `dock_menu(..)` entries. Same item model and dispatch as
7606 /// [`set_app_menu`](Self::set_app_menu); an empty slice removes the menu. `Cap::DockMenu`
7607 /// reports support; the default ignores the call.
7608 fn set_dock_menu(&mut self, _items: &[MenuItem]) {}
7609
7610 /// Make the node at `h` move its window when the user drags it, as a title bar does
7611 /// (`.window_drag_region()`, docs/window-chrome.md): the region's own background starts a
7612 /// native window drag (with the platform's snapping and edge tiling), a double click does
7613 /// what the user's title-bar double-click setting says, and controls inside the region keep
7614 /// receiving their own clicks. `false` releases it. `Cap::DragRegion`; the default ignores
7615 /// the call, so on a phone the region is an ordinary view.
7616 fn set_drag_region(&mut self, _h: &Self::Handle, _drag: bool) {}
7617
7618 /// Show these status items, replacing the previous set (docs/status-item.md): add the
7619 /// ones whose `id` is new, update the ones that changed, remove the ones no longer listed.
7620 /// A click on an item runs its `activate` action or opens its menu; menu choices dispatch
7621 /// [`Event::MenuAction`] like the app menu's. `Cap::StatusItem`; the default ignores the
7622 /// call, which is what a phone and the web do.
7623 fn set_status_items(&mut self, _items: &[StatusItemSpec]) {}
7624
7625 /// Show `progress` on the app's Dock or taskbar icon (docs/status-item.md).
7626 /// `Cap::AppProgress`; the default ignores it.
7627 fn set_app_progress(&mut self, _progress: AppProgress) {}
7628
7629 /// Show or hide the app's Dock icon while it runs: a menu-bar app hides it and lives in its
7630 /// status item (macOS activation policy). `Cap::DockVisibility`; the default ignores it.
7631 fn set_dock_visible(&mut self, _visible: bool) {}
7632
7633 /// Keep the process running when its last window closes (docs/windows.md "Keeping the app
7634 /// running"). When `true`, the backend must not end the app on its own as windows close: it
7635 /// reports every close, the first window's included, as `Event::WindowClosed`, and day-core
7636 /// decides whether to call [`quit_app`](Self::quit_app). When `false`, the backend keeps its
7637 /// platform's own rule. A backend whose process lifetime is not its own (a phone, a browser
7638 /// tab) ignores it, which is the default.
7639 fn set_keep_running(&mut self, _keep: bool) {}
7640
7641 /// The displays attached now (docs/windows.md "Monitors"). `Cap::Monitors`; the default
7642 /// answers none, and day-core then describes the primary window's own size as one display.
7643 fn monitors(&self) -> Vec<Monitor> {
7644 Vec::new()
7645 }
7646
7647 /// The outer frame of the window whose content container is `host`, in points on the
7648 /// desktop's coordinate space (top-left origin), or `None` where the platform does not say
7649 /// (a phone, Wayland's positions). Read when the app asks for it and when day-core saves a
7650 /// remembered frame (`WindowOptions::remember_frame`).
7651 fn window_frame(&self, _host: &Self::Handle) -> Option<Rect> {
7652 None
7653 }
7654
7655 /// Replace the launcher shortcuts the app set while running (docs/deep-links.md "Launcher
7656 /// shortcuts"), shown beside the ones Day.toml declares: iOS Home Screen quick actions,
7657 /// Android dynamic shortcuts, the macOS Dock menu. Each opens its route through the deep-link
7658 /// intake. `Cap::DynamicShortcuts`; the default ignores the call.
7659 fn set_launcher_shortcuts(&mut self, _shortcuts: &[LauncherShortcut]) {}
7660}
7661
7662/// What a secondary window is, so backends can apply platform conventions (docs/windows.md).
7663#[derive(Clone, Copy, PartialEq, Eq, Debug)]
7664pub enum WindowKind {
7665 /// A regular document/tool window: resizable, miniaturizable, platform-default tabbing.
7666 Normal,
7667 /// A settings/preferences window: singleton by convention; macOS disallows window
7668 /// tabbing and drops the resize/minimize chrome, mobile presents it modally. Pairs with
7669 /// day-core's key-singleton reopen (`open_window` with a key).
7670 Preferences,
7671 /// The About window (docs/settings.md): the same fixed, centered, untabbed panel chrome
7672 /// as [`Self::Preferences`] (every desktop treats the two alike), presented modally on
7673 /// mobile. Singleton under day-core's `day.about` key.
7674 About,
7675}
7676
7677impl WindowKind {
7678 /// Whether this is a panel (settings or About) rather than a document window: the kinds
7679 /// every desktop draws fixed-size, centered and out of the Window menu, and mobile presents
7680 /// as a cover.
7681 pub fn is_panel(self) -> bool {
7682 !matches!(self, WindowKind::Normal)
7683 }
7684}
7685
7686/// Whether a window keeps the app alive (docs/windows.md close policy).
7687///
7688/// A separate axis from [`WindowKind`], which describes chrome. They correlate (a
7689/// preferences panel is the archetypal window that should not hold an app open), but they are
7690/// not the same question, and conflating them would leave no way to say "an ordinary-looking
7691/// tool window that shouldn't outlive the documents".
7692///
7693/// Note the word "secondary" is overloaded around windows: elsewhere in day it means "a window
7694/// other than the first one opened". Here it means "does not keep the app alive", which is
7695/// about lifetime, not about order of creation.
7696#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
7697pub enum WindowRole {
7698 /// Closing the last one of these ends the app.
7699 #[default]
7700 Primary,
7701 /// Never keeps the app alive: when the last `Primary` closes, the app exits and takes
7702 /// these with it, however many are open.
7703 Secondary,
7704}
7705
7706impl From<WindowKind> for WindowRole {
7707 /// The default pairing: an ordinary window holds the app open, a preferences panel does
7708 /// not. An app that needs a different answer sets the role itself.
7709 fn from(kind: WindowKind) -> WindowRole {
7710 match kind {
7711 WindowKind::Normal => WindowRole::Primary,
7712 WindowKind::Preferences | WindowKind::About => WindowRole::Secondary,
7713 }
7714 }
7715}
7716
7717/// A backend's answer to [`Toolkit::open_window`] (docs/windows.md).
7718pub enum WindowOpenReply<H> {
7719 /// The window exists now; here is its content container (desktop backends).
7720 Open(H),
7721 /// Native creation started but completes asynchronously (a scene, an activity, an
7722 /// ability); the backend will call `day_core::finish_window_open` when the content
7723 /// container exists. day-core parks the window record until then.
7724 Pending,
7725 /// This toolkit cannot open windows (`Cap::MultiWindow` = `Unsupported`); day-core
7726 /// falls back to presenting the content as a fullscreen cover in the primary window.
7727 Unsupported,
7728}
7729
7730/// How a window is shown (docs/windows.md "Window properties"): what the window's `state()`
7731/// signal holds and what [`WindowChange::State`] asks for.
7732#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
7733pub enum WindowState {
7734 /// On screen at its own size: neither minimized, maximized nor fullscreen.
7735 #[default]
7736 Normal,
7737 /// Minimized to the Dock, taskbar or shelf. `Cap::WindowStates`.
7738 Minimized,
7739 /// Zoomed to fill the screen's work area, keeping its chrome. `Cap::WindowStates`.
7740 Maximized,
7741 /// Covering the whole screen with no window chrome; on a phone, the system bars hidden.
7742 /// `Cap::WindowFullscreen`.
7743 Fullscreen,
7744}
7745
7746impl WindowState {
7747 /// The wire code of [`bridge::BridgeKind::WindowStateChanged`].
7748 pub const fn code(self) -> i32 {
7749 match self {
7750 WindowState::Normal => 0,
7751 WindowState::Minimized => 1,
7752 WindowState::Maximized => 2,
7753 WindowState::Fullscreen => 3,
7754 }
7755 }
7756
7757 /// The state a wire code names; an unknown code reads as `Normal`.
7758 pub const fn from_code(code: i32) -> WindowState {
7759 match code {
7760 1 => WindowState::Minimized,
7761 2 => WindowState::Maximized,
7762 3 => WindowState::Fullscreen,
7763 _ => WindowState::Normal,
7764 }
7765 }
7766}
7767
7768/// How a window stacks against other apps' windows ([`WindowChange::Level`], `Cap::WindowLevel`).
7769#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
7770pub enum WindowLevel {
7771 /// Ordinary stacking: the focused app's windows come forward.
7772 #[default]
7773 Normal,
7774 /// Stays above other apps' ordinary windows: a palette, a timer, a video peek.
7775 Floating,
7776}
7777
7778/// How strongly a window asks for the user's attention ([`WindowChange::RequestAttention`]).
7779#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
7780pub enum Attention {
7781 /// Once: one Dock bounce, a taskbar button that flashes a few times.
7782 #[default]
7783 Informational,
7784 /// Until the user switches to the app.
7785 Critical,
7786}
7787
7788/// One change to one window (docs/windows.md "Window properties"), carried by
7789/// [`Toolkit::apply_window`]. Each variant names the `Cap` that reports whether the running
7790/// toolkit makes it.
7791#[derive(Clone, Debug, PartialEq)]
7792pub enum WindowChange {
7793 /// Minimize, zoom, take fullscreen, or return to normal. `Cap::WindowStates` for the
7794 /// first two, `Cap::WindowFullscreen` for fullscreen. The outcome comes back as
7795 /// [`Event::WindowStateChanged`].
7796 State(WindowState),
7797 /// Keep the window's content out of screenshots, recordings and screen sharing.
7798 /// `Cap::ContentProtection`.
7799 ContentProtected(bool),
7800 /// Move and/or resize. `origin` is the outer frame's top-left corner in points on the
7801 /// desktop's coordinate space (top-left origin, `Cap::WindowPosition`); `size` is the size Day
7802 /// lays the content out at, in points, the same size `WindowResized` reports
7803 /// (`Cap::WindowGeometry`): the backend adds its own chrome around it.
7804 Frame {
7805 origin: Option<Point>,
7806 size: Option<Size>,
7807 },
7808 /// The content size the user can resize the window to. `None` lifts that bound.
7809 /// `Cap::WindowGeometry`.
7810 Limits {
7811 min: Option<Size>,
7812 max: Option<Size>,
7813 },
7814 /// Stacking against other apps' windows. `Cap::WindowLevel`.
7815 Level(WindowLevel),
7816 /// Show the window on every virtual desktop / Space. `Cap::WindowLevel`.
7817 OnAllWorkspaces(bool),
7818 /// Leave the window out of the taskbar and the window switcher. `Cap::WindowLevel`.
7819 SkipTaskbar(bool),
7820 /// Whether the user can resize the window. `Cap::WindowGeometry`.
7821 Resizable(bool),
7822 /// Whether the window offers a minimize control. `Cap::WindowStates`.
7823 Minimizable(bool),
7824 /// Whether the window offers a maximize / zoom control. `Cap::WindowStates`.
7825 Maximizable(bool),
7826 /// Whether the window offers a close control. `Cap::WindowStates`.
7827 Closable(bool),
7828 /// Hide the window without closing it, or show it again. `Cap::WindowStates`.
7829 Visible(bool),
7830 /// This window's light/dark override over the app-wide [`Toolkit::set_appearance`].
7831 /// `Cap::WindowAppearance`.
7832 Appearance(Option<bool>),
7833 /// Bounce the Dock icon, flash the taskbar button. `Cap::RequestAttention`.
7834 RequestAttention(Attention),
7835}
7836
7837/// The frame a window wears (docs/window-chrome.md), set once when it opens.
7838#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
7839pub enum WindowChrome {
7840 /// The platform's title bar and frame.
7841 #[default]
7842 Standard,
7843 /// The content runs to the window's top edge, under the title bar, and the platform's own
7844 /// window controls (close, minimize, zoom) stay where they are. For a custom title bar: the
7845 /// backend reports the bar's height as the window's safe-area top inset (`day::safe_area()`),
7846 /// so the app pads its content by it and paints its background under it. Mark the draggable
7847 /// part with `.window_drag_region()`. `Cap::OverlayTitleBar`; where unsupported the window
7848 /// keeps its standard bar.
7849 Overlay,
7850 /// No title bar and no frame: a splash screen, a heads-up panel, a borderless utility. The
7851 /// window still resizes from its edges when resizable; mark the part that moves it with
7852 /// `.window_drag_region()`. `Cap::FramelessWindow`; where unsupported the window keeps its
7853 /// standard frame.
7854 Frameless,
7855}
7856
7857/// What shows behind a window's content (docs/window-chrome.md).
7858#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
7859pub enum WindowBackground {
7860 /// The platform's window background.
7861 #[default]
7862 Opaque,
7863 /// Nothing: pixels the content leaves unpainted show the desktop through the window. For
7864 /// rounded or shaped splash screens and overlays. `Cap::TransparentWindow`.
7865 Transparent,
7866 /// A translucent system material that blurs what is behind the window.
7867 /// `Cap::WindowMaterial`; where unsupported the window stays opaque.
7868 Material(WindowMaterial),
7869}
7870
7871/// The system materials a window background can use ([`WindowBackground::Material`]).
7872#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
7873pub enum WindowMaterial {
7874 /// The main-window material: macOS's window background, Windows 11's Mica.
7875 Window,
7876 /// A sidebar or source list: macOS's sidebar material, Windows 11's Mica Alt (tabbed).
7877 Sidebar,
7878 /// A transient surface (a popover, a menu-like panel, a heads-up display): macOS's popover
7879 /// material, Windows 11's Acrylic.
7880 Transient,
7881}
7882
7883/// Where a window opens ([`WindowOptions::placement`]).
7884#[derive(Clone, Copy, Debug, Default, PartialEq)]
7885pub enum WindowPlacement {
7886 /// The platform's choice: macOS cascades, Windows and Linux let the window manager decide.
7887 #[default]
7888 Automatic,
7889 /// Centered on the screen with the active window.
7890 Centered,
7891 /// The outer frame's top-left corner at this point, in points on the desktop's coordinate
7892 /// space (top-left origin). Falls back to `Automatic` without `Cap::WindowPosition`.
7893 At(Point),
7894}
7895
7896/// A small runtime RGBA image for a status item: charts, meters, or a grid of results.
7897///
7898/// Rows run top to bottom; samples are tightly packed, non-premultiplied sRGB RGBA8.
7899/// `scale` is pixels per logical point (2 for Retina). This owns its bytes, independent of
7900/// any window or decoded-image registry. Currently rendered by AppKit; other backends use
7901/// [`StatusItemSpec::icon`] as a fallback. See docs/status-item.md.
7902#[derive(Clone, PartialEq)]
7903pub struct StatusImage {
7904 width: u32,
7905 height: u32,
7906 scale: f64,
7907 rgba: std::sync::Arc<[u8]>,
7908}
7909
7910impl StatusImage {
7911 /// Validate a runtime image. Rejects empty/mismatched pixels, invalid scales, and images
7912 /// exceeding 16 MiB or 4096 logical points on either axis. No platform allocation occurs.
7913 pub fn rgba(
7914 width: u32,
7915 height: u32,
7916 scale: f64,
7917 pixels: impl Into<std::sync::Arc<[u8]>>,
7918 ) -> Option<Self> {
7919 let rgba = pixels.into();
7920 let expected = (width as usize)
7921 .checked_mul(height as usize)?
7922 .checked_mul(4)?;
7923 if width == 0
7924 || height == 0
7925 || expected > 16 * 1024 * 1024
7926 || rgba.len() != expected
7927 || !scale.is_finite()
7928 || scale <= 0.0
7929 || f64::from(width) / scale > 4096.0
7930 || f64::from(height) / scale > 4096.0
7931 {
7932 return None;
7933 }
7934 Some(Self {
7935 width,
7936 height,
7937 scale,
7938 rgba,
7939 })
7940 }
7941
7942 pub fn width(&self) -> u32 {
7943 self.width
7944 }
7945 pub fn height(&self) -> u32 {
7946 self.height
7947 }
7948 pub fn scale(&self) -> f64 {
7949 self.scale
7950 }
7951 pub fn pixels(&self) -> &[u8] {
7952 &self.rgba
7953 }
7954 pub fn size(&self) -> Size {
7955 Size::new(
7956 f64::from(self.width) / self.scale,
7957 f64::from(self.height) / self.scale,
7958 )
7959 }
7960}
7961
7962impl std::fmt::Debug for StatusImage {
7963 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
7964 f.debug_struct("StatusImage")
7965 .field("width", &self.width)
7966 .field("height", &self.height)
7967 .field("scale", &self.scale)
7968 .field("bytes", &self.rgba.len())
7969 .finish()
7970 }
7971}
7972
7973#[cfg(test)]
7974mod status_image_tests {
7975 use super::*;
7976
7977 #[test]
7978 fn validates_pixel_layout_and_logical_size() {
7979 let image = StatusImage::rgba(120, 36, 2.0, vec![255; 120 * 36 * 4]).unwrap();
7980 assert_eq!(image.size(), Size::new(60.0, 18.0));
7981 assert_eq!(image.clone(), image);
7982 assert!(StatusImage::rgba(0, 1, 1.0, vec![]).is_none());
7983 assert!(StatusImage::rgba(1, 1, 1.0, vec![0; 3]).is_none());
7984 assert!(StatusImage::rgba(u32::MAX, u32::MAX, 1.0, vec![]).is_none());
7985 for scale in [0.0, -1.0, f64::NAN, f64::INFINITY, 0.00001] {
7986 assert!(StatusImage::rgba(1, 1, scale, vec![0; 4]).is_none());
7987 }
7988 }
7989}
7990
7991/// One status item: an icon in the menu bar (macOS), the notification area (Windows) or a
7992/// StatusNotifierItem host's tray (Linux), with a menu (docs/status-item.md).
7993#[derive(Clone, Debug, PartialEq)]
7994pub struct StatusItemSpec {
7995 /// The app's name for the item, stable across updates: what a backend diffs by.
7996 pub id: String,
7997 /// The glyph. `None` shows the title alone (macOS) or the app's icon (elsewhere).
7998 pub icon: Option<Icon>,
7999 /// A runtime image. AppKit prefers this over `icon`, preserving aspect ratio and point
8000 /// size up to 18pt high; other backends ignore it and show `icon`/`title` as before.
8001 pub raster: Option<StatusImage>,
8002 /// Draw the icon as a template: its shape only, in the menu bar's or panel's own color, so
8003 /// it reads in light and dark. Symbols are always templates.
8004 pub template: bool,
8005 /// Text beside the icon where the platform shows one (the macOS menu bar). Empty: icon only.
8006 pub title: String,
8007 /// The hover text.
8008 pub tooltip: String,
8009 /// The menu the item opens. Same model and dispatch as the app menu.
8010 pub menu: Vec<MenuItem>,
8011 /// A dispatch id (the [`Event::MenuAction`] registry) run when the user clicks the item
8012 /// itself, where the platform separates a click from opening the menu (a left click on macOS
8013 /// and Windows, the host's "activate" on Linux). 0: a click opens the menu.
8014 pub activate: u64,
8015}
8016
8017/// One display (docs/windows.md "Monitors"), in points on the desktop's coordinate space:
8018/// top-left origin at the primary display's top-left corner.
8019#[derive(Clone, Debug, PartialEq)]
8020pub struct Monitor {
8021 /// Stable for the display across launches where the platform offers it (a display UUID, a
8022 /// connector name); an index otherwise.
8023 pub id: String,
8024 /// The display's name, for a picker ("Built-in Retina Display", "DELL U2720Q").
8025 pub name: String,
8026 /// The display's whole area.
8027 pub frame: Rect,
8028 /// The part windows should use: the frame minus the menu bar, Dock, taskbar or panels.
8029 pub work_area: Rect,
8030 /// Device pixels per point.
8031 pub scale: f64,
8032 /// The display the desktop's menu bar or primary taskbar is on.
8033 pub primary: bool,
8034}
8035
8036/// A launcher shortcut the app sets while it runs (docs/deep-links.md "Launcher shortcuts"): a
8037/// route to open and the label the launcher shows, already localized.
8038#[derive(Clone, Debug, PartialEq, Eq)]
8039pub struct LauncherShortcut {
8040 /// The route the shortcut opens, through the deep-link intake.
8041 pub route: String,
8042 /// What the launcher shows.
8043 pub label: String,
8044}
8045
8046/// A progress indicator on the app's Dock or taskbar icon ([`Toolkit::set_app_progress`],
8047/// docs/status-item.md).
8048#[derive(Clone, Copy, Debug, Default, PartialEq)]
8049pub enum AppProgress {
8050 /// No indicator.
8051 #[default]
8052 None,
8053 /// Work of unknown length.
8054 Indeterminate,
8055 /// A fraction done, 0.0 to 1.0.
8056 Value(f64),
8057 /// A fraction done, paused. Platforms without a paused state show `Value`.
8058 Paused(f64),
8059 /// A fraction done when the work failed. Platforms without an error state show `Value`.
8060 Error(f64),
8061}
8062
8063/// Whether and how a window joins macOS window tabs ([`WindowOptions::tabbing`],
8064/// `Cap::WindowTabbing`). Other platforms have no system window tabs and ignore it.
8065#[derive(Clone, Debug, Default, PartialEq, Eq, Hash)]
8066pub enum WindowTabbing {
8067 /// The platform default for the window's kind: an ordinary window tabs with the app's other
8068 /// ordinary windows when the user's "Prefer tabs" setting says so; preferences never tab.
8069 #[default]
8070 Automatic,
8071 /// Tab only with windows of the same group, following the user's setting. The tab bar's
8072 /// "+" opens the builder registered for this group (`register_new_window_for`).
8073 Group(String),
8074 /// Always open as a tab of an existing window of the same group, whatever the setting.
8075 Preferred(String),
8076 /// Never become a tab.
8077 Disallowed,
8078}
8079
8080/// An app's generated locale catalog: `(DEFAULT, CATALOG)`, exactly what `day-build` emits as
8081/// `res::locales::{DEFAULT, CATALOG}` (§18.5). Carried in [`WindowOptions::locales`] so the
8082/// framework can install it at the one moment that is correct.
8083pub type AppLocales = (&'static str, &'static [(&'static str, &'static str)]);
8084
8085#[derive(Clone, Debug)]
8086pub struct WindowOptions {
8087 pub title: String,
8088 pub size: Size,
8089 pub min_size: Option<Size>,
8090 /// Create and mount the window without showing it or activating the app. Supported on
8091 /// AppKit; other backends currently ignore this option. Defaults to false.
8092 ///
8093 /// Menu-bar apps can keep their initial host hidden and open Preferences on demand.
8094 /// The window still exists and can be shown with `WindowHandle::set_visible(true)`;
8095 /// this does not change the app's keep-running policy.
8096 pub start_hidden: bool,
8097 /// Ask the backend to size this window to its content once it has been built and laid out,
8098 /// rather than keeping [`Self::size`] (docs/windows.md). `size` still decides the width and
8099 /// acts as the height ceiling, so a panel with more content than fits the screen scrolls
8100 /// instead of growing past it.
8101 ///
8102 /// What a preferences panel wants on every desktop. Ignored by backends that cannot resize a
8103 /// live window, and by the ones with no windows at all.
8104 pub size_to_fit: bool,
8105 /// The app's display name for the standard application menu / About (macOS). `None` falls back
8106 /// to `title`; set it when `title` carries extra decoration you don't want in `About <name>`
8107 /// (e.g. the showcase's window title is "Day Showcase (AppKit)" but its app name is "Showcase").
8108 pub app_name: Option<String>,
8109 /// The app's locale catalog, installed by `day::launch` (docs/localization.md).
8110 ///
8111 /// The order is why this is the framework's job: the OS's languages reach day-localization from the
8112 /// live backend, and the catalog has to be registered after that hint and before the first
8113 /// localized string is read. An app that installs it itself (before `launch`, where there
8114 /// is no backend yet) registers against an empty hint list and resolves to `DEFAULT`, so a
8115 /// French device opens an English window.
8116 pub locales: Option<AppLocales>,
8117 /// The title, computed once the catalog above is installed: how a window title comes from
8118 /// the catalog rather than a literal. Takes precedence over [`Self::title`], which stays the
8119 /// fallback for the moment before (and for apps with nothing to translate).
8120 ///
8121 /// A plain `fn` rather than a closure: this struct is `Clone`, and the callers are a
8122 /// non-capturing `|| res::str::app_title().format()`, which coerces.
8123 pub title_fn: Option<fn() -> String>,
8124 /// The app's version, for Day's exit line (`Clean exit for <app> v<version> …`,
8125 /// docs/lifecycle.md): `env!("CARGO_PKG_VERSION")` in the app crate, which the scaffold's
8126 /// `window()` sets. `None` falls back to the `DAY_APP_VERSION` the `day` CLI sets on a launch.
8127 pub version: Option<String>,
8128 /// macOS window tabbing for this window (`Cap::WindowTabbing`); ignored elsewhere.
8129 pub tabbing: WindowTabbing,
8130 /// The window's frame: standard, a title bar the content runs under, or none
8131 /// (docs/window-chrome.md). Desktop only; phones and the web ignore it.
8132 pub chrome: WindowChrome,
8133 /// What shows behind the content: the window background, nothing, or a system material.
8134 pub background: WindowBackground,
8135 /// Whether the window casts the platform's drop shadow. A transparent, shaped window often
8136 /// wants none.
8137 pub shadow: bool,
8138 /// Whether the user can resize the window by its edges.
8139 pub resizable: bool,
8140 /// Where the window opens.
8141 pub placement: WindowPlacement,
8142 /// The largest content size the user can resize the window to (`Cap::WindowGeometry`).
8143 pub max_size: Option<Size>,
8144 /// Remember this window's size, position and maximized state under this key, and reopen it
8145 /// that way next launch (docs/windows.md "Remembered frames"). A position that no attached
8146 /// display shows any more is dropped, so a window never reopens off-screen.
8147 pub remember_frame: Option<String>,
8148 /// The logical window this opens (docs/windows.md): `open_window` focuses an already-open
8149 /// window with the same key instead of opening a second (the preferences pattern). `None`
8150 /// always opens a new one.
8151 pub key: Option<String>,
8152 /// The kind of window: a regular one, a preferences panel, an About panel.
8153 pub kind: WindowKind,
8154}
8155
8156impl WindowOptions {
8157 /// The window title. Replaces a [`title_fn`](Self::title_fn) set earlier (the generated
8158 /// `res::window_options()` sets one), so the last title named wins.
8159 pub fn title(mut self, title: impl Into<String>) -> Self {
8160 self.title = title.into();
8161 self.title_fn = None;
8162 self
8163 }
8164 /// The content size the window opens at (desktop; phones fill the screen).
8165 pub fn size(mut self, width: f64, height: f64) -> Self {
8166 self.size = Size::new(width, height);
8167 self
8168 }
8169 /// The smallest content size the user can resize to.
8170 pub fn min_size(mut self, width: f64, height: f64) -> Self {
8171 self.min_size = Some(Size::new(width, height));
8172 self
8173 }
8174 /// The largest content size the user can resize to (`Cap::WindowGeometry`).
8175 pub fn max_size(mut self, width: f64, height: f64) -> Self {
8176 self.max_size = Some(Size::new(width, height));
8177 self
8178 }
8179 /// Create and mount the window without showing it; see [`Self::start_hidden`].
8180 pub fn start_hidden(mut self) -> Self {
8181 self.start_hidden = true;
8182 self
8183 }
8184 /// Size the window to its content once built; see [`Self::size_to_fit`].
8185 pub fn size_to_fit(mut self) -> Self {
8186 self.size_to_fit = true;
8187 self
8188 }
8189 /// The app's display name for the application menu and About; see [`Self::app_name`].
8190 pub fn app_name(mut self, name: impl Into<String>) -> Self {
8191 self.app_name = Some(name.into());
8192 self
8193 }
8194 /// The app's locale catalog, as `res::locales::{DEFAULT, CATALOG}`; see [`Self::locales`].
8195 pub fn locales(
8196 mut self,
8197 default: &'static str,
8198 catalog: &'static [(&'static str, &'static str)],
8199 ) -> Self {
8200 self.locales = Some((default, catalog));
8201 self
8202 }
8203 /// The title, computed once the catalog is installed; see [`Self::title_fn`].
8204 pub fn title_fn(mut self, f: fn() -> String) -> Self {
8205 self.title_fn = Some(f);
8206 self
8207 }
8208 /// The app's version, for Day's exit line; see [`Self::version`].
8209 pub fn version(mut self, version: impl Into<String>) -> Self {
8210 self.version = Some(version.into());
8211 self
8212 }
8213 /// macOS window tabbing for this window; see [`Self::tabbing`].
8214 pub fn tabbing(mut self, tabbing: WindowTabbing) -> Self {
8215 self.tabbing = tabbing;
8216 self
8217 }
8218 /// The window's frame; see [`Self::chrome`].
8219 pub fn chrome(mut self, chrome: WindowChrome) -> Self {
8220 self.chrome = chrome;
8221 self
8222 }
8223 /// What shows behind the content; see [`Self::background`].
8224 pub fn background(mut self, background: WindowBackground) -> Self {
8225 self.background = background;
8226 self
8227 }
8228 /// No drop shadow, for a transparent or shaped window; see [`Self::shadow`].
8229 pub fn no_shadow(mut self) -> Self {
8230 self.shadow = false;
8231 self
8232 }
8233 /// A window the user cannot resize; see [`Self::resizable`].
8234 pub fn fixed_size(mut self) -> Self {
8235 self.resizable = false;
8236 self
8237 }
8238 /// Where the window opens; see [`Self::placement`].
8239 pub fn placement(mut self, placement: WindowPlacement) -> Self {
8240 self.placement = placement;
8241 self
8242 }
8243 /// Remember and restore this window's frame under `key`; see [`Self::remember_frame`].
8244 pub fn remember_frame(mut self, key: impl Into<String>) -> Self {
8245 self.remember_frame = Some(key.into());
8246 self
8247 }
8248 /// The logical window this opens; see [`Self::key`].
8249 pub fn key(mut self, key: impl Into<String>) -> Self {
8250 self.key = Some(key.into());
8251 self
8252 }
8253 /// The kind of window; see [`Self::kind`].
8254 pub fn kind(mut self, kind: WindowKind) -> Self {
8255 self.kind = kind;
8256 self
8257 }
8258}
8259
8260impl Default for WindowOptions {
8261 fn default() -> Self {
8262 WindowOptions {
8263 title: "Day".into(),
8264 size: Size::new(480.0, 640.0),
8265 min_size: None,
8266 start_hidden: false,
8267 size_to_fit: false,
8268 app_name: None,
8269 locales: None,
8270 title_fn: None,
8271 version: None,
8272 tabbing: WindowTabbing::Automatic,
8273 chrome: WindowChrome::Standard,
8274 background: WindowBackground::Opaque,
8275 shadow: true,
8276 resizable: true,
8277 placement: WindowPlacement::Automatic,
8278 max_size: None,
8279 remember_frame: None,
8280 key: None,
8281 kind: WindowKind::Normal,
8282 }
8283 }
8284}
8285
8286/// A platform backend: owns the native main loop and exactly one window in v1 (§8.1).
8287///
8288/// `run` sets up the native app + window, installs the reactive scheduler + main poster,
8289/// then hands `(self, root_container, content_size)` to `ready` (which mounts the tree and
8290/// takes ownership of the backend), and finally runs the native main loop.
8291pub trait Platform: Toolkit {
8292 /// The process-constant target id, e.g. `"macos-appkit"`.
8293 const TARGET: &'static str;
8294 /// The toolkit half of the target, e.g. `"appkit"`.
8295 const TOOLKIT: &'static str;
8296 /// Whether [`Platform::run`] returning means the app is over. True for a backend that
8297 /// drives the native loop itself until the app quits (GTK, Qt, XAML; AppKit's never
8298 /// returns). False where the host owns the loop and `run` hands back at once, with the
8299 /// app still to come (ArkUI's ability, Android's activity, the web page, the mock's test):
8300 /// their `DidExit` is the host's last callback, not the return (docs/lifecycle.md).
8301 const RUN_ENDS_APP: bool = true;
8302
8303 fn run(self, options: WindowOptions, ready: Box<dyn FnOnce(Self, Self::Handle, Size)>);
8304
8305 /// Post a closure onto the native main loop. Callable from any thread; this is the
8306 /// single door the reactive scheduler and `Setter` deliveries ride (§3.3).
8307 fn post(f: Box<dyn FnOnce() + Send>);
8308
8309 /// Post `f` onto the native main loop after (at least) `ms` milliseconds: the timer
8310 /// door behind `day::sleep` (docs/async.md). Every shipped backend overrides it with the
8311 /// loop's own timer (`dispatch_after`, `Handler.postDelayed`, `g_timeout_add`,
8312 /// `QTimer::singleShot`, `SetTimer`, `setTimeout`), so a sleep costs no thread. The
8313 /// default is the fallback for a backend without one: a helper thread that sleeps the
8314 /// whole delay and rides [`Platform::post`] home. It is correct, but a thread per sleep
8315 /// adds up under a ticking loop, so the first use says so once in the log.
8316 fn post_delayed(ms: u32, f: Box<dyn FnOnce() + Send>) {
8317 static SAID: std::sync::Once = std::sync::Once::new();
8318 SAID.call_once(|| {
8319 log::warn!(
8320 "day: {} has no native timer door; `day::sleep` falls back to a helper thread \
8321 per delay (implement `Platform::post_delayed`)",
8322 Self::TARGET
8323 );
8324 });
8325 std::thread::spawn(move || {
8326 std::thread::sleep(std::time::Duration::from_millis(u64::from(ms)));
8327 Self::post(f);
8328 });
8329 }
8330
8331 /// Ordered OS locale preference list (BCP-47), for fluent-langneg (§12.2).
8332 fn locale_hints(&self) -> Vec<String> {
8333 Vec::new()
8334 }
8335}
8336
8337// ---------------------------------------------------------------------------
8338// Open renderer registry (§8.2)
8339// ---------------------------------------------------------------------------
8340
8341/// The scale a window snapshot is rendered at, when a scripted run states one
8342/// (`DAY_CAPTURE_SCALE=2`, set by `day launch --script` from Day.toml `[screenshots]`).
8343///
8344/// For the backends that render their own snapshot (GTK, Qt): the capture then measures
8345/// window points times this, whatever display the host has, which is what makes a CI capture
8346/// under a 1x xvfb the same 2560x1600 pixels a HiDPI laptop produces. A backend that reads the
8347/// compositor's pixels back (AppKit, XAML) captures at the display's own scale and ignores it.
8348/// `None` when unset or unparseable: capture at the backend's natural scale.
8349pub fn capture_scale() -> Option<f64> {
8350 std::env::var("DAY_CAPTURE_SCALE")
8351 .ok()
8352 .and_then(|v| v.trim().parse::<f64>().ok())
8353 .filter(|s| s.is_finite() && (0.5..=4.0).contains(s))
8354}
8355
8356/// Seconds on a monotonic clock since the first call: the frame timestamp for a backend that
8357/// does not expose a native timestamp (§8.4). The origin is arbitrary. Not for the web
8358/// build, where `Instant` is unavailable and `requestAnimationFrame` stamps the frame itself.
8359pub fn frame_timestamp() -> f64 {
8360 static ORIGIN: std::sync::OnceLock<std::time::Instant> = std::sync::OnceLock::new();
8361 ORIGIN
8362 .get_or_init(std::time::Instant::now)
8363 .elapsed()
8364 .as_secs_f64()
8365}
8366
8367/// The user's language preference from the POSIX environment, newest-first, as BCP-47 tags.
8368///
8369/// `LANGUAGE` is the GNU multi-language list (`fr:en`); `LC_ALL`, `LC_MESSAGES` and `LANG` each
8370/// carry one locale in POSIX form (`fr_FR.UTF-8`), which becomes `fr-FR`. Shared by the backends
8371/// whose platform has no richer API to ask (§12.2, docs/localization.md).
8372pub fn posix_locale_hints() -> Vec<String> {
8373 let mut out: Vec<String> = Vec::new();
8374 let mut push = |tag: &str| {
8375 // "C" and "POSIX" are the absence of a preference, not a language.
8376 let tag = tag.split('.').next().unwrap_or(tag).replace('_', "-");
8377 if tag.is_empty() || tag == "C" || tag == "POSIX" {
8378 return;
8379 }
8380 if !out.contains(&tag) {
8381 out.push(tag);
8382 }
8383 };
8384 if let Ok(list) = std::env::var("LANGUAGE") {
8385 for tag in list.split(':') {
8386 push(tag);
8387 }
8388 }
8389 for key in ["LC_ALL", "LC_MESSAGES", "LANG"] {
8390 if let Ok(v) = std::env::var(key) {
8391 push(&v);
8392 }
8393 }
8394 out
8395}
8396
8397/// Optional custom measure for a third-party piece (§8.2).
8398pub type MeasureFn<B> = fn(&mut B, &<B as Toolkit>::Handle, Proposal) -> Size;
8399
8400/// A third-party piece's per-toolkit implementation. `make` receives the concrete backend
8401/// (public helper surface) and returns a native handle the backend then owns like any built-in.
8402pub struct Renderer<B: Toolkit> {
8403 pub kind: PieceKind,
8404 pub make: fn(&mut B, &dyn Any, NodeId) -> B::Handle,
8405 pub update: fn(&mut B, &B::Handle, &dyn Any),
8406 pub measure: Option<MeasureFn<B>>,
8407 /// Teardown, run from the release queue just before [`Toolkit::release`] frees the handle.
8408 ///
8409 /// A piece that keeps per-view state (a retained delegate, a session map, a native
8410 /// observer) has no other place to drop it: `make`/`update` are the only other hooks, and a
8411 /// disposed node never calls them again. Without this a piece leaks one entry per realized
8412 /// view, and any map keyed by the handle's address can go on to answer for a later view that
8413 /// the allocator hands the same address.
8414 ///
8415 /// `None` for the pieces that own nothing (most of them).
8416 pub release: Option<fn(&mut B, &B::Handle)>,
8417}
8418
8419pub struct Registry<B: Toolkit> {
8420 map: HashMap<PieceKind, Renderer<B>>,
8421}
8422
8423impl<B: Toolkit> Default for Registry<B> {
8424 fn default() -> Self {
8425 Registry {
8426 map: HashMap::new(),
8427 }
8428 }
8429}
8430
8431impl<B: Toolkit> Registry<B> {
8432 pub fn register(&mut self, r: Renderer<B>) {
8433 let kind = r.kind;
8434 if self.map.insert(kind, r).is_some() {
8435 // Two pieces claiming one kind is last-linked-wins in link order, effectively
8436 // nondeterministic. Fail loudly in debug; in release, say so once at boot rather
8437 // than render the wrong widget silently.
8438 debug_assert!(
8439 false,
8440 "duplicate renderer registered for piece kind {kind:?}"
8441 );
8442 log::warn!("duplicate renderer for piece kind {kind:?} — later registration wins");
8443 }
8444 }
8445 pub fn get(&self, kind: PieceKind) -> Option<&Renderer<B>> {
8446 self.map.get(kind)
8447 }
8448 pub fn kinds(&self) -> impl Iterator<Item = PieceKind> + '_ {
8449 self.map.keys().copied()
8450 }
8451}
8452
8453/// The canvas wire op codes (§11, §15.3): slot 0 of every 9-number record in
8454/// [`encode_ops`]'s output. The numeric values are the frozen wire contract: the JNI, C++,
8455/// and JS decoders each hand-write these as constants, so a value here may never be reused
8456/// or renumbered; new ops append. `ALL` drives the density test that keeps this enum and
8457/// the encoder in step.
8458#[derive(Clone, Copy, Debug, PartialEq, Eq)]
8459#[repr(i32)]
8460pub enum OpCode {
8461 /// Fill a rect (a,b=origin, c,d=size).
8462 FillRect = 0,
8463 /// Stroke a rect (g=width).
8464 StrokeRect = 1,
8465 /// Fill a rounded rect (e=radius).
8466 FillRrect = 2,
8467 /// Fill an ellipse in a rect.
8468 FillEllipse = 3,
8469 /// Stroke an ellipse (g=width).
8470 StrokeEllipse = 4,
8471 /// Stroke an arc (e=start°, f=sweep°, g=width). `Fill(Shape::Arc)` also encodes as
8472 /// this with width 0, the known asymmetry; see [`encode_ops`].
8473 StrokeArc = 5,
8474 /// Line a,b → c,d (g=width).
8475 Line = 6,
8476 /// Text at a,b (e=size, f=anchor: 0 leading / 1 centered); the string rides the texts
8477 /// channel.
8478 Text = 7,
8479 /// Save the graphics state.
8480 Save = 8,
8481 /// Restore the graphics state.
8482 Restore = 9,
8483 /// Concat an affine transform (a..f).
8484 Concat = 10,
8485 /// Fill a polygon; points ride the texts channel as "x,y x,y …" (closed automatically).
8486 /// `a` = the [`geometry_key`] of those points, or 0 for none.
8487 FillPolygon = 11,
8488 /// Stroke a polygon (g=width); points and key as [`OpCode::FillPolygon`].
8489 StrokePolygon = 12,
8490 /// Stroke a rounded rect (e=radius, g=width).
8491 StrokeRrect = 13,
8492 /// Set a gradient for the next shape record (f=type: 0 linear with a,b=start / c,d=end
8493 /// unit points; 1 radial with a,b=center unit point, c=unit radius; e=stop count).
8494 /// Stops ride the texts channel as "offset,aarrggbb offset,aarrggbb …". Unit geometry
8495 /// resolves against the shape's bounding box.
8496 SetGradient = 14,
8497 /// Fill a path (f=fill rule: 0 non-zero / 1 even-odd); segments ride the texts channel
8498 /// (see [`encode_path`]). `a` = the [`geometry_key`] of those segments, or 0 for none.
8499 ///
8500 /// The key is a cache handle, not a payload: a decoder that recognizes it may keep the
8501 /// native geometry it built last time and skip re-parsing the segments, but it must still
8502 /// consume the texts entry either way, because that channel is positional and a skipped
8503 /// entry desynchronizes every record after it.
8504 FillPath = 15,
8505 /// Stroke a path (g=width); segments and key as [`OpCode::FillPath`].
8506 StrokePath = 16,
8507 /// Clip to a shape (f=shape kind: 0 rect / 1 rrect(e=radius) / 2 ellipse / 3 path
8508 /// (e=fill rule) / 4 polygon; a..d=geometry; path/polygon payloads ride the texts
8509 /// channel).
8510 ///
8511 /// For the two payload-bearing kinds (3 and 4) there is no geometry in the slots, so `a`
8512 /// carries the [`geometry_key`] instead and b..d stay 0. The other three kinds put real
8513 /// geometry in a..d and have nothing to cache.
8514 Clip = 17,
8515 /// Stroke style for the next stroke record (a=cap, b=join, c=miter limit, d=dash
8516 /// phase, e=dash count; the dash array rides the texts channel).
8517 StrokeStyle = 18,
8518 /// Font for the next text record (a=CSS weight 100 … 900, 0 = default; b=italic 0/1); the
8519 /// family name rides the texts channel, `""` = the platform's default face. Like
8520 /// [`OpCode::SetGradient`] and [`OpCode::StrokeStyle`] it is consumed by the record that
8521 /// follows and then cleared; a default [`CanvasFont`] emits no record at all, so a drawing
8522 /// without fonts encodes exactly as it did before this op existed.
8523 SetFont = 19,
8524 /// The next shape record is a template, drawn once per stamped position rather than once
8525 /// (docs/canvas.md "Stamping"). `a` = how many positions follow. Like [`OpCode::SetGradient`],
8526 /// [`OpCode::StrokeStyle`] and [`OpCode::SetFont`] it is a prefix, consumed by the record it
8527 /// applies to and then cleared.
8528 ///
8529 /// Between this and that template record come `ceil(a / 4)` [`OpCode::StampPoints`] records
8530 /// carrying the coordinates. A drawing with no stamps encodes exactly as it did before.
8531 Stamp = 20,
8532 /// Four stamped positions, filling the record edge to edge: `[op, x0, y0, x1, y1, x2, y2,
8533 /// x3, y3]`, the one record that uses its last slot for geometry instead of a color, because
8534 /// it carries no color of its own. The final record of a run is padded; the count in the
8535 /// [`OpCode::Stamp`] header says how many of the pairs are real.
8536 StampPoints = 21,
8537 /// Draw a decoded image: `a,b` = origin, `c,d` = size, `e` = the [`BitmapId`], `f` = opacity
8538 /// in `0.0..=1.0` (docs/images.md).
8539 ///
8540 /// The id rides the numeric channel because it is a number, and an `f64` carries a `u64` id
8541 /// exactly up to 2^53; ids are minted from 1 per process, so that ceiling is unreachable.
8542 /// Nothing rides the texts channel for this op.
8543 Image = 22,
8544}
8545
8546impl OpCode {
8547 /// Every code, in wire order. The density test iterates this so a new variant that
8548 /// forgets to join fails loudly.
8549 pub const ALL: [OpCode; 23] = [
8550 OpCode::FillRect,
8551 OpCode::StrokeRect,
8552 OpCode::FillRrect,
8553 OpCode::FillEllipse,
8554 OpCode::StrokeEllipse,
8555 OpCode::StrokeArc,
8556 OpCode::Line,
8557 OpCode::Text,
8558 OpCode::Save,
8559 OpCode::Restore,
8560 OpCode::Concat,
8561 OpCode::FillPolygon,
8562 OpCode::StrokePolygon,
8563 OpCode::StrokeRrect,
8564 OpCode::SetGradient,
8565 OpCode::FillPath,
8566 OpCode::StrokePath,
8567 OpCode::Clip,
8568 OpCode::StrokeStyle,
8569 OpCode::SetFont,
8570 OpCode::Stamp,
8571 OpCode::StampPoints,
8572 OpCode::Image,
8573 ];
8574
8575 /// The code back from a wire number (a decoder-side aid and the round-trip test's
8576 /// reference); `None` for anything outside the table.
8577 pub fn from_wire(n: f64) -> Option<OpCode> {
8578 OpCode::ALL.into_iter().find(|c| *c as i32 as f64 == n)
8579 }
8580}
8581
8582/// Flat numeric encoding of a display list for shim/JNI boundaries (§11, §15.3): per op
8583/// 9 numbers [kind, a, b, c, d, e, f, g, rgba-bits]; text payloads ride separately in order.
8584/// The kind slot carries an [`OpCode`]; its doc comments are the per-slot contract.
8585///
8586/// Transports join `texts` with the unit separator U+001F (one entry per text-carrying
8587/// record, in order), so text payloads must not contain U+001F. Known asymmetry:
8588/// `Fill(Shape::Arc)` encodes as [`OpCode::StrokeArc`] with width 0. Filled arcs render
8589/// only on the direct-replay backends (AppKit/UIKit); use a polygon fan if a filled arc
8590/// must be portable.
8591/// Serialize a path for the `texts` side-channel: one space-separated token per segment,
8592/// `M x y` / `L x y` / `Q cx cy x y` / `C c1x c1y c2x c2y x y` / `Z`.
8593///
8594/// Text rather than numbers because that is the channel the encoder already has for
8595/// variable-length payloads, and every decoder (JS, Java, C++) can already split a string. The
8596/// numeric channel is a flat `[f64; 9]`-per-record array with no length prefix, so a path could
8597/// not ride it without changing the record shape for every backend at once.
8598/// A content key for a shape's variable-length geometry: the handle a backend caches decoded
8599/// native geometry under (a `Path2D`, a `QPainterPath`, an `android.graphics.Path`) so an
8600/// unchanged path is rebuilt once instead of re-parsed out of the texts channel every frame.
8601///
8602/// It hashes the geometry, not the op's position or its identity, so that a draw closure that
8603/// rebuilds its `PathBuilder` chain from scratch on every frame (which is what canvas consumers
8604/// do) produces an equal path, hashes the same and hits the same entry. An identity would miss
8605/// every time.
8606///
8607/// Two different paths that collided would draw each other, so the width matters: FNV-1a over the
8608/// IEEE bits, masked to the 53 bits an `f64` wire slot carries exactly. `0.0` is reserved for "no
8609/// key": a decoder seeing it must decode the payload as it always did, which is also what every
8610/// decoder written before this key existed does with the slot.
8611pub fn geometry_key(shape: &Shape) -> f64 {
8612 fn mix(h: &mut u64, v: f64) {
8613 for byte in v.to_bits().to_le_bytes() {
8614 *h ^= u64::from(byte);
8615 *h = h.wrapping_mul(0x0000_0100_0000_01b3);
8616 }
8617 }
8618 fn mix_point(h: &mut u64, p: Point) {
8619 mix(h, p.x);
8620 mix(h, p.y);
8621 }
8622 let mut h: u64 = 0xcbf2_9ce4_8422_2325;
8623 match shape {
8624 Shape::Polygon(pts) => {
8625 // The discriminant joins the hash so a polygon and a path of the same points can
8626 // never share an entry: they decode to different geometry.
8627 mix(&mut h, 11.0);
8628 for p in pts {
8629 mix_point(&mut h, *p);
8630 }
8631 }
8632 Shape::Path(path) => {
8633 mix(&mut h, 15.0);
8634 mix(&mut h, rule_bits(path.rule));
8635 for seg in &path.segs {
8636 match seg {
8637 PathSeg::Move(a) => {
8638 mix(&mut h, 0.0);
8639 mix_point(&mut h, *a);
8640 }
8641 PathSeg::Line(a) => {
8642 mix(&mut h, 1.0);
8643 mix_point(&mut h, *a);
8644 }
8645 PathSeg::Quad(c, a) => {
8646 mix(&mut h, 2.0);
8647 mix_point(&mut h, *c);
8648 mix_point(&mut h, *a);
8649 }
8650 PathSeg::Cubic(c1, c2, a) => {
8651 mix(&mut h, 3.0);
8652 mix_point(&mut h, *c1);
8653 mix_point(&mut h, *c2);
8654 mix_point(&mut h, *a);
8655 }
8656 PathSeg::Close => mix(&mut h, 4.0),
8657 }
8658 }
8659 }
8660 // Everything else puts its whole geometry in the record's own slots, so there is nothing
8661 // to parse and nothing worth caching.
8662 _ => return 0.0,
8663 }
8664 let masked = h & ((1u64 << 53) - 1);
8665 // 0 means "no key", so the one hash that lands there borrows its neighbor.
8666 if masked == 0 { 1.0 } else { masked as f64 }
8667}
8668
8669pub fn encode_path(path: &Path) -> String {
8670 let mut out = String::with_capacity(path.segs.len() * 16);
8671 for seg in &path.segs {
8672 if !out.is_empty() {
8673 out.push(' ');
8674 }
8675 match seg {
8676 PathSeg::Move(p) => out.push_str(&format!("M {} {}", p.x, p.y)),
8677 PathSeg::Line(p) => out.push_str(&format!("L {} {}", p.x, p.y)),
8678 PathSeg::Quad(c, p) => out.push_str(&format!("Q {} {} {} {}", c.x, c.y, p.x, p.y)),
8679 PathSeg::Cubic(a, b, p) => out.push_str(&format!(
8680 "C {} {} {} {} {} {}",
8681 a.x, a.y, b.x, b.y, p.x, p.y
8682 )),
8683 PathSeg::Close => out.push('Z'),
8684 }
8685 }
8686 out
8687}
8688
8689/// `FillRule` as a wire number: 0 non-zero, 1 even-odd.
8690fn rule_bits(rule: FillRule) -> f64 {
8691 match rule {
8692 FillRule::NonZero => 0.0,
8693 FillRule::EvenOdd => 1.0,
8694 }
8695}
8696
8697pub fn encode_ops(ops: &[DrawOp]) -> (Vec<f64>, Vec<String>) {
8698 fn color_bits(c: Color) -> f64 {
8699 let r = (c.r.clamp(0.0, 1.0) * 255.0) as u32;
8700 let g = (c.g.clamp(0.0, 1.0) * 255.0) as u32;
8701 let b = (c.b.clamp(0.0, 1.0) * 255.0) as u32;
8702 let a = (c.a.clamp(0.0, 1.0) * 255.0) as u32;
8703 ((a << 24) | (r << 16) | (g << 8) | b) as f64
8704 }
8705 #[allow(clippy::too_many_arguments)]
8706 fn push(
8707 k: OpCode,
8708 a: f64,
8709 b: f64,
8710 c: f64,
8711 d: f64,
8712 e: f64,
8713 f: f64,
8714 g: f64,
8715 col: Color,
8716 nums: &mut Vec<f64>,
8717 ) {
8718 nums.extend_from_slice(&[k as i32 as f64, a, b, c, d, e, f, g, color_bits(col)]);
8719 }
8720 /// The kind-18 prefix a non-plain stroke style emits, applying to the next stroke record,
8721 /// the same "modifier record" rule the gradient and font records follow. A plain style emits
8722 /// nothing, so a drawing without dashes or caps encodes exactly as it always did.
8723 fn stroke_style_record(style: &StrokeStyle, nums: &mut Vec<f64>, texts: &mut Vec<String>) {
8724 if style.is_plain() {
8725 return;
8726 }
8727 push(
8728 OpCode::StrokeStyle,
8729 match style.cap {
8730 LineCap::Butt => 0.0,
8731 LineCap::Round => 1.0,
8732 LineCap::Square => 2.0,
8733 },
8734 match style.join {
8735 LineJoin::Miter => 0.0,
8736 LineJoin::Round => 1.0,
8737 LineJoin::Bevel => 2.0,
8738 },
8739 style.miter_limit,
8740 style.dash_phase,
8741 style.dash.len() as f64,
8742 0.0,
8743 0.0,
8744 Color::CLEAR,
8745 nums,
8746 );
8747 texts.push(
8748 style
8749 .dash
8750 .iter()
8751 .map(|d| d.to_string())
8752 .collect::<Vec<_>>()
8753 .join(" "),
8754 );
8755 }
8756
8757 /// One shape record (the fill/stroke kinds shared by both ops).
8758 fn shape_record(
8759 stroke: bool,
8760 shape: &Shape,
8761 w: f64,
8762 col: Color,
8763 nums: &mut Vec<f64>,
8764 texts: &mut Vec<String>,
8765 ) {
8766 match shape {
8767 Shape::Rect(r) => push(
8768 if stroke {
8769 OpCode::StrokeRect
8770 } else {
8771 OpCode::FillRect
8772 },
8773 r.origin.x,
8774 r.origin.y,
8775 r.size.width,
8776 r.size.height,
8777 0.0,
8778 0.0,
8779 w,
8780 col,
8781 nums,
8782 ),
8783 Shape::RoundedRect(r, rad) => push(
8784 if stroke {
8785 OpCode::StrokeRrect
8786 } else {
8787 OpCode::FillRrect
8788 },
8789 r.origin.x,
8790 r.origin.y,
8791 r.size.width,
8792 r.size.height,
8793 *rad,
8794 0.0,
8795 w,
8796 col,
8797 nums,
8798 ),
8799 Shape::Ellipse(r) => push(
8800 if stroke {
8801 OpCode::StrokeEllipse
8802 } else {
8803 OpCode::FillEllipse
8804 },
8805 r.origin.x,
8806 r.origin.y,
8807 r.size.width,
8808 r.size.height,
8809 0.0,
8810 0.0,
8811 w,
8812 col,
8813 nums,
8814 ),
8815 Shape::Arc {
8816 rect,
8817 start_deg,
8818 sweep_deg,
8819 } => push(
8820 OpCode::StrokeArc,
8821 rect.origin.x,
8822 rect.origin.y,
8823 rect.size.width,
8824 rect.size.height,
8825 *start_deg,
8826 *sweep_deg,
8827 w,
8828 col,
8829 nums,
8830 ),
8831 Shape::Line(p1, p2) => {
8832 push(OpCode::Line, p1.x, p1.y, p2.x, p2.y, 0.0, 0.0, w, col, nums)
8833 }
8834 Shape::Polygon(pts) => {
8835 // Variable-length points ride the texts side-channel ("x,y x,y …"),
8836 // consumed in record order exactly like text payloads.
8837 push(
8838 if stroke {
8839 OpCode::StrokePolygon
8840 } else {
8841 OpCode::FillPolygon
8842 },
8843 geometry_key(shape),
8844 0.0,
8845 0.0,
8846 0.0,
8847 0.0,
8848 0.0,
8849 w,
8850 col,
8851 nums,
8852 );
8853 texts.push(
8854 pts.iter()
8855 .map(|p| format!("{},{}", p.x, p.y))
8856 .collect::<Vec<_>>()
8857 .join(" "),
8858 );
8859 }
8860 Shape::Path(path) => {
8861 // Same side-channel as Polygon, one token per segment (see `encode_path`).
8862 // The fill rule rides slot f; stroking ignores it.
8863 push(
8864 if stroke {
8865 OpCode::StrokePath
8866 } else {
8867 OpCode::FillPath
8868 },
8869 geometry_key(shape),
8870 0.0,
8871 0.0,
8872 0.0,
8873 0.0,
8874 rule_bits(path.rule),
8875 w,
8876 col,
8877 nums,
8878 );
8879 texts.push(encode_path(path));
8880 }
8881 }
8882 }
8883 /// A gradient applies to the next shape record, fill or stroke. Geometry per type rides
8884 /// slots a..d and the type discriminant slot f: one record shape, so every decoder keeps a
8885 /// single gradient code path.
8886 fn gradient_record(
8887 geo: [f64; 4],
8888 kind: f64,
8889 stops: &[(f64, Color)],
8890 nums: &mut Vec<f64>,
8891 texts: &mut Vec<String>,
8892 ) {
8893 push(
8894 OpCode::SetGradient,
8895 geo[0],
8896 geo[1],
8897 geo[2],
8898 geo[3],
8899 stops.len() as f64,
8900 kind,
8901 0.0,
8902 Color::CLEAR,
8903 nums,
8904 );
8905 texts.push(
8906 stops
8907 .iter()
8908 .map(|(o, c)| format!("{o},{:08x}", color_bits(*c) as u32))
8909 .collect::<Vec<_>>()
8910 .join(" "),
8911 );
8912 }
8913 let mut nums = Vec::with_capacity(ops.len() * 9);
8914 let mut texts = Vec::new();
8915 for op in ops {
8916 match op {
8917 DrawOp::Fill(shape, paint) => {
8918 // A gradient emits one kind-14 set-gradient record before its shape record;
8919 // the stops ride the texts channel as "offset,aarrggbb offset,aarrggbb …".
8920 // Geometry per type rides slots a..d, the type discriminant slot f: one
8921 // record shape, so every decoder keeps a single gradient code path.
8922 let col = match paint {
8923 Paint::Solid(c) => *c,
8924 Paint::Linear(g) => {
8925 gradient_record(
8926 [g.start.x, g.start.y, g.end.x, g.end.y],
8927 0.0,
8928 &g.stops,
8929 &mut nums,
8930 &mut texts,
8931 );
8932 // The gradient replaces the shape record's color, but it must be
8933 // opaque, not clear: Skia-based decoders (Android Paint, OH_Drawing)
8934 // modulate a shader by the paint alpha, so a clear slot would render
8935 // the whole gradient invisible.
8936 Color::WHITE
8937 }
8938 Paint::Radial(g) => {
8939 gradient_record(
8940 [g.center.x, g.center.y, g.radius, 0.0],
8941 1.0,
8942 &g.stops,
8943 &mut nums,
8944 &mut texts,
8945 );
8946 Color::WHITE
8947 }
8948 };
8949 shape_record(false, shape, 0.0, col, &mut nums, &mut texts);
8950 }
8951 DrawOp::Stamp(st) => {
8952 // No copies means no drawing. Some native decoders use an empty point
8953 // buffer to mean an ordinary (unstamped) template, so omit the entire op.
8954 if st.at.is_empty() {
8955 continue;
8956 }
8957 // A prefix record, then the coordinates, then the template shape record: the
8958 // "applies to the next record" rule the gradient, stroke style and font records
8959 // already follow, so a decoder needs no new kind of state, only one more slot in
8960 // the state it keeps. The template goes last because a decoder has to hold the
8961 // points before it can draw with them.
8962 //
8963 // The points are numbers on the numeric channel, not the texts channel a
8964 // polygon's points ride: this op exists for tens of thousands of them, and
8965 // formatting that many floats into a string every frame would cost more than
8966 // the individual ops it replaces.
8967 let col = match &st.paint {
8968 Paint::Solid(c) => *c,
8969 Paint::Linear(g) => {
8970 gradient_record(
8971 [g.start.x, g.start.y, g.end.x, g.end.y],
8972 0.0,
8973 &g.stops,
8974 &mut nums,
8975 &mut texts,
8976 );
8977 Color::WHITE
8978 }
8979 Paint::Radial(g) => {
8980 gradient_record(
8981 [g.center.x, g.center.y, g.radius, 0.0],
8982 1.0,
8983 &g.stops,
8984 &mut nums,
8985 &mut texts,
8986 );
8987 Color::WHITE
8988 }
8989 };
8990 push(
8991 OpCode::Stamp,
8992 st.at.len() as f64,
8993 0.0,
8994 0.0,
8995 0.0,
8996 0.0,
8997 0.0,
8998 0.0,
8999 Color::CLEAR,
9000 &mut nums,
9001 );
9002 for chunk in st.at.chunks(4) {
9003 // Written directly rather than through `push`: this is the one record whose
9004 // last slot is a coordinate rather than a color, which is what lets four
9005 // points fit with nothing wasted.
9006 let mut rec = [
9007 OpCode::StampPoints as i32 as f64,
9008 0.0,
9009 0.0,
9010 0.0,
9011 0.0,
9012 0.0,
9013 0.0,
9014 0.0,
9015 0.0,
9016 ];
9017 for (i, p) in chunk.iter().enumerate() {
9018 rec[1 + i * 2] = p.x;
9019 rec[2 + i * 2] = p.y;
9020 }
9021 nums.extend_from_slice(&rec);
9022 }
9023 match &st.stroke {
9024 None => shape_record(false, &st.shape, 0.0, col, &mut nums, &mut texts),
9025 Some(style) => {
9026 stroke_style_record(style, &mut nums, &mut texts);
9027 shape_record(true, &st.shape, style.width, col, &mut nums, &mut texts);
9028 }
9029 }
9030 }
9031 DrawOp::Stroke(shape, paint, style) => {
9032 // A styled stroke emits one kind-18 record first, applying to the next stroke
9033 // only, the same "modifier record" shape the gradient uses, so decoders keep
9034 // one rule: consume, apply to the next shape record, reset.
9035 stroke_style_record(style, &mut nums, &mut texts);
9036 let col = match paint {
9037 Paint::Solid(c) => *c,
9038 Paint::Linear(g) => {
9039 gradient_record(
9040 [g.start.x, g.start.y, g.end.x, g.end.y],
9041 0.0,
9042 &g.stops,
9043 &mut nums,
9044 &mut texts,
9045 );
9046 Color::WHITE
9047 }
9048 Paint::Radial(g) => {
9049 gradient_record(
9050 [g.center.x, g.center.y, g.radius, 0.0],
9051 1.0,
9052 &g.stops,
9053 &mut nums,
9054 &mut texts,
9055 );
9056 Color::WHITE
9057 }
9058 };
9059 shape_record(true, shape, style.width, col, &mut nums, &mut texts);
9060 }
9061 DrawOp::Clip(shape) => {
9062 // One record for every clip shape, with the shape kind in slot f and the
9063 // geometry in a..e; a path or polygon puts its points on the texts channel.
9064 // Encoding clips as a variant of `shape_record` instead would need a third
9065 // kind code per shape, which is five more numbers every decoder must learn.
9066 let (kind, geo, extra, payload): (f64, [f64; 4], f64, Option<String>) = match shape
9067 {
9068 Shape::Rect(r) => (
9069 0.0,
9070 [r.origin.x, r.origin.y, r.size.width, r.size.height],
9071 0.0,
9072 None,
9073 ),
9074 Shape::RoundedRect(r, rad) => (
9075 1.0,
9076 [r.origin.x, r.origin.y, r.size.width, r.size.height],
9077 *rad,
9078 None,
9079 ),
9080 Shape::Ellipse(r) => (
9081 2.0,
9082 [r.origin.x, r.origin.y, r.size.width, r.size.height],
9083 0.0,
9084 None,
9085 ),
9086 // Slot a carries the geometry key for these two sub-kinds only. The other
9087 // three put real geometry there, which is why the key rides the same slot
9088 // the payload-bearing kinds leave empty.
9089 Shape::Path(p) => (
9090 3.0,
9091 [geometry_key(shape), 0.0, 0.0, 0.0],
9092 rule_bits(p.rule),
9093 Some(encode_path(p)),
9094 ),
9095 Shape::Polygon(pts) => (
9096 4.0,
9097 [geometry_key(shape), 0.0, 0.0, 0.0],
9098 0.0,
9099 Some(
9100 pts.iter()
9101 .map(|p| format!("{},{}", p.x, p.y))
9102 .collect::<Vec<_>>()
9103 .join(" "),
9104 ),
9105 ),
9106 // A clip to a line or an arc has no interior to clip to. Clipping to an
9107 // empty region would blank everything after it, so clip to the shape's
9108 // bounds instead: wrong in the same direction as no clip at all.
9109 Shape::Line(..) | Shape::Arc { .. } => {
9110 let r = shape.bounds();
9111 (
9112 0.0,
9113 [r.origin.x, r.origin.y, r.size.width, r.size.height],
9114 0.0,
9115 None,
9116 )
9117 }
9118 };
9119 push(
9120 OpCode::Clip,
9121 geo[0],
9122 geo[1],
9123 geo[2],
9124 geo[3],
9125 extra,
9126 kind,
9127 0.0,
9128 Color::CLEAR,
9129 &mut nums,
9130 );
9131 if let Some(s) = payload {
9132 texts.push(s);
9133 }
9134 }
9135 DrawOp::Text {
9136 text,
9137 at,
9138 size,
9139 color,
9140 anchor,
9141 font,
9142 } => {
9143 if *font != CanvasFont::default() {
9144 push(
9145 OpCode::SetFont,
9146 f64::from(font.css_weight()),
9147 f64::from(u8::from(font.italic)),
9148 0.0,
9149 0.0,
9150 0.0,
9151 0.0,
9152 0.0,
9153 Color::CLEAR,
9154 &mut nums,
9155 );
9156 texts.push(
9157 font.family_str()
9158 .replace([FONT_LIST_FIELD, FONT_LIST_FAMILY], " "),
9159 );
9160 }
9161 push(
9162 OpCode::Text,
9163 at.x,
9164 at.y,
9165 0.0,
9166 0.0,
9167 *size,
9168 anchor.pack(),
9169 0.0,
9170 *color,
9171 &mut nums,
9172 );
9173 texts.push(text.clone());
9174 }
9175 DrawOp::Image {
9176 image,
9177 rect,
9178 opacity,
9179 } => push(
9180 OpCode::Image,
9181 rect.origin.x,
9182 rect.origin.y,
9183 rect.size.width,
9184 rect.size.height,
9185 image.0 as f64,
9186 *opacity,
9187 0.0,
9188 Color::CLEAR,
9189 &mut nums,
9190 ),
9191 DrawOp::Save => push(
9192 OpCode::Save,
9193 0.0,
9194 0.0,
9195 0.0,
9196 0.0,
9197 0.0,
9198 0.0,
9199 0.0,
9200 Color::CLEAR,
9201 &mut nums,
9202 ),
9203 DrawOp::Restore => push(
9204 OpCode::Restore,
9205 0.0,
9206 0.0,
9207 0.0,
9208 0.0,
9209 0.0,
9210 0.0,
9211 0.0,
9212 Color::CLEAR,
9213 &mut nums,
9214 ),
9215 DrawOp::Concat(m) => push(
9216 OpCode::Concat,
9217 m.a,
9218 m.b,
9219 m.c,
9220 m.d,
9221 m.tx,
9222 m.ty,
9223 0.0,
9224 Color::CLEAR,
9225 &mut nums,
9226 ),
9227 }
9228 }
9229 (nums, texts)
9230}
9231
9232#[cfg(test)]
9233mod font_list_tests {
9234 use super::*;
9235
9236 #[test]
9237 fn css_weights_round_trip_and_snap_to_the_nearest_rung() {
9238 for w in FontWeight::ALL {
9239 assert_eq!(FontWeight::from_css(w.css()), w);
9240 }
9241 assert_eq!(FontWeight::from_css(0), FontWeight::UltraLight);
9242 assert_eq!(FontWeight::from_css(350), FontWeight::Regular); // ties go heavier
9243 assert_eq!(FontWeight::from_css(649), FontWeight::Semibold);
9244 assert_eq!(FontWeight::from_css(1200), FontWeight::Black);
9245 }
9246
9247 fn sans() -> FontFamilyInfo {
9248 FontFamilyInfo {
9249 family: "Day Sans".into(),
9250 faces: vec![
9251 FontFace {
9252 name: "Regular".into(),
9253 weight: FontWeight::Regular,
9254 italic: false,
9255 },
9256 FontFace {
9257 name: "Bold".into(),
9258 weight: FontWeight::Bold,
9259 italic: false,
9260 },
9261 FontFace {
9262 name: "Light Italic".into(),
9263 weight: FontWeight::Light,
9264 italic: true,
9265 },
9266 ],
9267 }
9268 }
9269
9270 #[test]
9271 fn face_for_prefers_the_slant_then_the_nearest_weight() {
9272 let f = sans();
9273 assert!(f.has_bold() && f.has_italic());
9274 assert_eq!(f.face_for(FontWeight::Bold, false).unwrap().name, "Bold");
9275 assert_eq!(
9276 f.face_for(FontWeight::Semibold, false).unwrap().name,
9277 "Bold"
9278 );
9279 assert_eq!(
9280 f.face_for(FontWeight::Medium, false).unwrap().name,
9281 "Regular"
9282 );
9283 let tie = FontFamilyInfo {
9284 family: "Tie".into(),
9285 faces: vec![
9286 FontFace {
9287 name: "Light".into(),
9288 weight: FontWeight::Light,
9289 italic: false,
9290 },
9291 FontFace {
9292 name: "Bold".into(),
9293 weight: FontWeight::Bold,
9294 italic: false,
9295 },
9296 ],
9297 };
9298 // Medium sits 200 from both; the heavier face wins the tie.
9299 assert_eq!(
9300 tie.face_for(FontWeight::Medium, false).unwrap().name,
9301 "Bold"
9302 );
9303 assert_eq!(
9304 f.face_for(FontWeight::Black, true).unwrap().name,
9305 "Light Italic"
9306 );
9307 let none = FontFamilyInfo {
9308 family: "Empty".into(),
9309 faces: vec![],
9310 };
9311 assert!(none.face_for(FontWeight::Regular, false).is_none());
9312 assert!(!none.has_bold());
9313 }
9314
9315 #[test]
9316 fn the_shim_list_format_decodes_tolerantly() {
9317 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\
9318 \u{1e}\u{1e}Day Mono\u{1f}Regular\u{1f}400\u{1f}0\u{1e}Faceless";
9319 let list = parse_font_list(s);
9320 assert_eq!(list.len(), 3);
9321 assert_eq!(list[0].family, "Day Sans");
9322 assert_eq!(list[0].faces.len(), 2, "the bad weight is skipped");
9323 assert_eq!(list[0].faces[1].name, "Bold Italic"); // synthesized from 700 + italic
9324 assert_eq!(list[1].family, "Day Mono");
9325 assert_eq!(list[2].family, "Faceless");
9326 assert!(list[2].faces.is_empty());
9327 }
9328
9329 #[test]
9330 fn approximate_metrics_scale_with_size_and_length() {
9331 let m = TextMetrics::approximate("abc", 10.0);
9332 assert_eq!((m.width, m.height, m.ascent), (18.0, 12.0, 9.0));
9333 assert_eq!(TextMetrics::approximate("", 10.0).width, 0.0);
9334 }
9335
9336 #[test]
9337 fn a_default_canvas_font_is_the_platform_face() {
9338 let f = CanvasFont::default();
9339 assert_eq!((f.family_str(), f.css_weight(), f.italic), ("", 0, false));
9340 }
9341}
9342
9343#[cfg(test)]
9344mod image_header_tests {
9345 use super::*;
9346
9347 /// A 4×2 PNG: signature, then an IHDR whose width and height are big-endian.
9348 fn png(w: u32, h: u32) -> Vec<u8> {
9349 let mut v = vec![0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a];
9350 v.extend(13u32.to_be_bytes());
9351 v.extend(b"IHDR");
9352 v.extend(w.to_be_bytes());
9353 v.extend(h.to_be_bytes());
9354 v
9355 }
9356
9357 /// A JPEG whose frame header sits behind one other segment, so the walk has to skip it.
9358 fn jpeg(w: u16, h: u16) -> Vec<u8> {
9359 let mut v = vec![0xFF, 0xD8]; // SOI
9360 v.extend([0xFF, 0xE0, 0x00, 0x04, 0x00, 0x00]); // APP0, length 4
9361 v.extend([0xFF, 0xC0, 0x00, 0x11, 0x08]); // SOF0, length 17, precision 8
9362 v.extend(h.to_be_bytes());
9363 v.extend(w.to_be_bytes());
9364 v
9365 }
9366
9367 #[test]
9368 fn a_header_states_the_size_before_anything_decodes() {
9369 assert_eq!(ImageFormat::dimensions(&png(512, 512)), Some((512, 512)));
9370 assert_eq!(ImageFormat::dimensions(&png(1, 65535)), Some((1, 65535)));
9371 // The JPEG walk must step over the APP0 segment to reach the frame header.
9372 assert_eq!(ImageFormat::dimensions(&jpeg(640, 480)), Some((640, 480)));
9373
9374 let mut gif = b"GIF89a".to_vec();
9375 gif.extend(320u16.to_le_bytes());
9376 gif.extend(240u16.to_le_bytes());
9377 assert_eq!(ImageFormat::dimensions(&gif), Some((320, 240)));
9378
9379 // A top-down BMP states its height as a negative number; the size is still positive.
9380 let mut bmp = b"BM".to_vec();
9381 bmp.resize(18, 0);
9382 bmp.extend(64i32.to_le_bytes());
9383 bmp.extend((-32i32).to_le_bytes());
9384 assert_eq!(ImageFormat::dimensions(&bmp), Some((64, 32)));
9385 }
9386
9387 #[test]
9388 fn a_truncated_or_unknown_header_answers_nothing_rather_than_a_guess() {
9389 assert_eq!(ImageFormat::dimensions(&[]), None);
9390 // The signature is there but the IHDR is not.
9391 assert_eq!(ImageFormat::dimensions(&png(8, 8)[..12]), None);
9392 // Not an image at all.
9393 assert_eq!(ImageFormat::dimensions(b"not an image"), None);
9394 // A JPEG that never reaches a frame header must not loop or invent a size.
9395 assert_eq!(
9396 ImageFormat::dimensions(&[0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x04, 0, 0]),
9397 None
9398 );
9399 }
9400}
9401
9402#[cfg(test)]
9403mod encode_ops_tests {
9404 use super::*;
9405
9406 /// The wire codes are a frozen dense table: any gap, duplicate, or variant missing from
9407 /// `ALL` breaks a decoder somewhere, so it must break here first.
9408 #[test]
9409 fn op_codes_are_dense_and_round_trip() {
9410 for (i, code) in OpCode::ALL.into_iter().enumerate() {
9411 assert_eq!(code as i32, i as i32, "{code:?} broke wire density");
9412 assert_eq!(OpCode::from_wire(i as f64), Some(code));
9413 }
9414 assert_eq!(OpCode::from_wire(OpCode::ALL.len() as f64), None);
9415 assert_eq!(OpCode::from_wire(-1.0), None);
9416 assert_eq!(OpCode::from_wire(0.5), None);
9417 }
9418
9419 /// Reference decode of a display list covering every op arm: the record stream must be
9420 /// whole 9-slot records of known codes, and the texts channel must carry exactly one
9421 /// entry per text-carrying record, in order: the contract every shim decoder assumes.
9422 #[test]
9423 fn encode_ops_emits_whole_records_and_matched_text_payloads() {
9424 let r = Rect::new(1.0, 2.0, 3.0, 4.0);
9425 let red = Color::rgb(1.0, 0.0, 0.0);
9426 let ops = vec![
9427 DrawOp::Fill(Shape::Rect(r), Paint::Solid(red)),
9428 DrawOp::Stroke(Shape::Rect(r), Paint::Solid(red), StrokeStyle::round(2.0)),
9429 DrawOp::Fill(Shape::RoundedRect(r, 5.0), Paint::Solid(red)),
9430 DrawOp::Fill(Shape::Ellipse(r), Paint::Solid(red)),
9431 // The documented asymmetry: a filled arc encodes as StrokeArc with width 0.
9432 DrawOp::Fill(
9433 Shape::Arc {
9434 rect: r,
9435 start_deg: 0.0,
9436 sweep_deg: 90.0,
9437 },
9438 Paint::Solid(red),
9439 ),
9440 DrawOp::Stroke(
9441 Shape::Line(Point::new(0.0, 0.0), Point::new(9.0, 9.0)),
9442 Paint::Solid(red),
9443 StrokeStyle::width(1.0),
9444 ),
9445 DrawOp::Fill(
9446 Shape::Polygon(vec![
9447 Point::new(0.0, 0.0),
9448 Point::new(4.0, 0.0),
9449 Point::new(2.0, 3.0),
9450 ]),
9451 Paint::Solid(red),
9452 ),
9453 DrawOp::Fill(
9454 Shape::Path(Path {
9455 segs: vec![
9456 PathSeg::Move(Point::new(0.0, 0.0)),
9457 PathSeg::Line(Point::new(5.0, 5.0)),
9458 PathSeg::Close,
9459 ],
9460 rule: FillRule::EvenOdd,
9461 }),
9462 Paint::Linear(LinearGradient::new(
9463 UnitPoint::new(0.0, 0.0),
9464 UnitPoint::new(1.0, 1.0),
9465 vec![(0.0, red), (1.0, Color::WHITE)],
9466 )),
9467 ),
9468 DrawOp::Clip(Shape::Rect(r)),
9469 DrawOp::Clip(Shape::Polygon(vec![
9470 Point::new(0.0, 0.0),
9471 Point::new(1.0, 0.0),
9472 Point::new(0.0, 1.0),
9473 ])),
9474 DrawOp::Text {
9475 text: "hi".into(),
9476 at: Point::new(7.0, 8.0),
9477 size: 12.0,
9478 color: red,
9479 anchor: TextAnchor::LEADING,
9480 font: CanvasFont::default(),
9481 },
9482 DrawOp::Text {
9483 text: "bold".into(),
9484 at: Point::new(7.0, 8.0),
9485 size: 12.0,
9486 color: red,
9487 anchor: TextAnchor::CENTERED,
9488 font: CanvasFont {
9489 family: Some("Day Sans".into()),
9490 weight: Some(FontWeight::Bold),
9491 italic: true,
9492 },
9493 },
9494 DrawOp::Save,
9495 DrawOp::Concat(Affine::IDENTITY),
9496 DrawOp::Restore,
9497 DrawOp::Image {
9498 image: BitmapId(7),
9499 rect: r,
9500 opacity: 0.5,
9501 },
9502 ];
9503 let (nums, texts) = encode_ops(&ops);
9504 assert_eq!(nums.len() % 9, 0, "partial record on the wire");
9505
9506 let mut expected_texts = 0usize;
9507 let mut decoded = Vec::new();
9508 for rec in nums.chunks(9) {
9509 let code = OpCode::from_wire(rec[0])
9510 .unwrap_or_else(|| panic!("unknown op code {} on the wire", rec[0]));
9511 decoded.push(code);
9512 expected_texts += match code {
9513 OpCode::Text
9514 | OpCode::FillPolygon
9515 | OpCode::StrokePolygon
9516 | OpCode::SetGradient
9517 | OpCode::FillPath
9518 | OpCode::StrokePath
9519 | OpCode::StrokeStyle
9520 | OpCode::SetFont => 1,
9521 // Clip carries a payload only for its path (3) / polygon (4) sub-kinds.
9522 OpCode::Clip => usize::from(rec[6] == 3.0 || rec[6] == 4.0),
9523 _ => 0,
9524 };
9525 }
9526 assert_eq!(
9527 texts.len(),
9528 expected_texts,
9529 "texts channel out of step with the records that consume it"
9530 );
9531 // No payload may carry the transport separator.
9532 assert!(texts.iter().all(|t| !t.contains('\u{1f}')));
9533
9534 let expected = {
9535 use OpCode::*;
9536 vec![
9537 FillRect,
9538 StrokeStyle, // ::round(2.0) is not plain, so the style modifier precedes
9539 StrokeRect,
9540 FillRrect,
9541 FillEllipse,
9542 StrokeArc, // the filled arc's stroke encoding…
9543 Line,
9544 FillPolygon,
9545 SetGradient, // gradient precedes its shape record
9546 FillPath,
9547 Clip,
9548 Clip,
9549 Text,
9550 SetFont, // a non-default font precedes its text record…
9551 Text,
9552 Save,
9553 Concat,
9554 Restore,
9555 Image,
9556 ]
9557 };
9558 assert_eq!(decoded, expected);
9559 // The bitmap id rides the numeric channel intact (an f64 holds a u64 id exactly well
9560 // past any id this process will mint), and nothing of an image reaches the texts channel.
9561 let img = nums
9562 .chunks(9)
9563 .find(|r| r[0] == OpCode::Image as i32 as f64)
9564 .expect("Image record");
9565 assert_eq!(&img[1..7], &[1.0, 2.0, 3.0, 4.0, 7.0, 0.5]);
9566 // …carrying the CSS weight and the slant, with the family on the texts channel.
9567 let font_rec = nums
9568 .chunks(9)
9569 .find(|r| r[0] == OpCode::SetFont as i32 as f64)
9570 .expect("SetFont record");
9571 assert_eq!(&font_rec[1..3], &[700.0, 1.0]);
9572 assert_eq!(texts.iter().filter(|t| *t == "Day Sans").count(), 1);
9573 // …with width 0, per the documented asymmetry.
9574 let arc = nums
9575 .chunks(9)
9576 .find(|r| r[0] == OpCode::StrokeArc as i32 as f64);
9577 assert_eq!(arc.map(|r| r[7]), Some(0.0));
9578 // A gradient-filled shape's color slot is opaque white, never clear (Skia decoders
9579 // modulate the shader by it).
9580 let path_rec = nums
9581 .chunks(9)
9582 .find(|r| r[0] == OpCode::FillPath as i32 as f64);
9583 assert_eq!(
9584 path_rec.map(|r| r[8] as u32),
9585 Some(0xFFFF_FFFF),
9586 "gradient shape record must carry opaque white"
9587 );
9588 }
9589
9590 #[test]
9591 fn variable_length_geometry_carries_a_content_key() {
9592 let path = |x: f64| {
9593 Shape::Path(Path {
9594 segs: vec![
9595 PathSeg::Move(Point::new(x, 0.0)),
9596 PathSeg::Cubic(
9597 Point::new(1.0, 1.0),
9598 Point::new(2.0, 2.0),
9599 Point::new(3.0, 3.0),
9600 ),
9601 PathSeg::Close,
9602 ],
9603 rule: FillRule::NonZero,
9604 })
9605 };
9606 let poly = Shape::Polygon(vec![
9607 Point::new(0.0, 0.0),
9608 Point::new(1.0, 0.0),
9609 Point::new(0.0, 1.0),
9610 ]);
9611
9612 // Equal geometry keys equal though the two were built separately. The key exists for
9613 // this: a draw closure rebuilds its paths from scratch on every frame.
9614 assert_eq!(geometry_key(&path(0.0)), geometry_key(&path(0.0)));
9615 assert_ne!(geometry_key(&path(0.0)), geometry_key(&path(1.0)));
9616 // The fill rule decides what the path draws, so it belongs to the key.
9617 let even_odd = Shape::Path(Path {
9618 segs: match path(0.0) {
9619 Shape::Path(p) => p.segs,
9620 _ => unreachable!(),
9621 },
9622 rule: FillRule::EvenOdd,
9623 });
9624 assert_ne!(geometry_key(&path(0.0)), geometry_key(&even_odd));
9625 // A polygon and a path decode to different geometry and never share an entry.
9626 assert_ne!(geometry_key(&poly), geometry_key(&path(0.0)));
9627 // Fixed-size shapes keep 0: their geometry rides the record's own slots.
9628 assert_eq!(
9629 geometry_key(&Shape::Rect(Rect::new(0.0, 0.0, 1.0, 1.0))),
9630 0.0
9631 );
9632 // And a key survives an f64 wire slot exactly, which is what the 53-bit mask buys.
9633 let k = geometry_key(&path(0.0));
9634 assert!(
9635 k > 0.0 && k < (1u64 << 53) as f64 && k.fract() == 0.0,
9636 "{k}"
9637 );
9638
9639 // It reaches the wire in slot a, for exactly the ops whose payload rides the texts
9640 // channel, and never displaces geometry that is already there.
9641 let (nums, _) = encode_ops(&[
9642 DrawOp::Fill(path(0.0), Paint::Solid(Color::WHITE)),
9643 DrawOp::Fill(poly.clone(), Paint::Solid(Color::WHITE)),
9644 DrawOp::Clip(path(0.0)),
9645 DrawOp::Clip(Shape::Rect(Rect::new(1.0, 2.0, 3.0, 4.0))),
9646 ]);
9647 let slot_a = |code: OpCode| {
9648 nums.chunks(9)
9649 .find(|r| r[0] == code as i32 as f64)
9650 .unwrap_or_else(|| panic!("no {code:?} record"))[1]
9651 };
9652 assert_eq!(slot_a(OpCode::FillPath), geometry_key(&path(0.0)));
9653 assert_eq!(slot_a(OpCode::FillPolygon), geometry_key(&poly));
9654 let clips: Vec<&[f64]> = nums
9655 .chunks(9)
9656 .filter(|r| r[0] == OpCode::Clip as i32 as f64)
9657 .collect();
9658 assert_eq!(clips[0][1], geometry_key(&path(0.0)), "path clip keys");
9659 assert_eq!(
9660 &clips[1][1..5],
9661 &[1.0, 2.0, 3.0, 4.0],
9662 "a rect clip still puts its geometry in a..d"
9663 );
9664 }
9665}
9666
9667#[cfg(test)]
9668mod markup_tests {
9669 use super::*;
9670
9671 fn bold(range: std::ops::Range<usize>) -> TextRun {
9672 TextRun::font(
9673 range,
9674 FontSpec {
9675 style: Font::Body,
9676 weight: Some(FontWeight::Bold),
9677 ..Default::default()
9678 },
9679 )
9680 }
9681
9682 #[test]
9683 fn plain_text_is_escaped_in_both_dialects() {
9684 // The case a translated string will find: markup metacharacters in the content.
9685 let text = "5 < 6 & \"quoted\" <b>not bold</b>";
9686 for d in [MarkupDialect::Pango, MarkupDialect::QtHtml] {
9687 let m = runs_to_markup(text, &[], d, 16.0);
9688 assert!(!m.contains("<b>"), "content tag survived into markup: {m}");
9689 assert!(m.contains("<b>"), "{m}");
9690 assert!(m.contains("&"), "{m}");
9691 assert!(m.contains("5 < 6"), "{m}");
9692 }
9693 }
9694
9695 #[test]
9696 fn a_styled_run_is_wrapped_and_its_text_escaped() {
9697 let text = "a <b> c";
9698 let m = runs_to_markup(text, &[bold(2..5)], MarkupDialect::Pango, 16.0);
9699 assert_eq!(m, "a <b><b></b> c");
9700 }
9701
9702 #[test]
9703 fn color_and_monospace_differ_per_dialect() {
9704 let text = "code";
9705 let run = TextRun {
9706 range: 0..4,
9707 font: FontSpec {
9708 style: Font::Body,
9709 monospace: true,
9710 ..Default::default()
9711 },
9712 color: Some(Color::rgb(1.0, 0.0, 0.0)),
9713 ..TextRun::default()
9714 };
9715 let pango = runs_to_markup(text, std::slice::from_ref(&run), MarkupDialect::Pango, 16.0);
9716 assert!(pango.contains("foreground=\"#ff0000\""), "{pango}");
9717 assert!(pango.contains("font_family=\"monospace\""), "{pango}");
9718 let qt = runs_to_markup(
9719 text,
9720 std::slice::from_ref(&run),
9721 MarkupDialect::QtHtml,
9722 16.0,
9723 );
9724 assert!(qt.contains("color:#ff0000"), "{qt}");
9725 // Qt takes its fixed face from <code>: a `font-family:monospace` style attribute
9726 // renders proportional (observed on Qt 6.11).
9727 assert!(qt.contains("<code>") && qt.contains("</code>"), "{qt}");
9728 }
9729
9730 #[test]
9731 fn the_new_attributes_reach_both_dialects() {
9732 let run = TextRun {
9733 range: 0..2,
9734 font: FontSpec::default().scaled(1.5),
9735 background: Some(Color::rgb(0.0, 1.0, 0.0)),
9736 underline: Underline::Single,
9737 ..TextRun::default()
9738 };
9739 let pango = runs_to_markup("hi", std::slice::from_ref(&run), MarkupDialect::Pango, 16.0);
9740 assert!(pango.contains("background=\"#00ff00\""), "{pango}");
9741 // Pango takes an absolute size in 1/1024 pt (16 pt base × 1.5 = 24 pt), because the
9742 // relative attributes are Pango 1.50's and an unknown attribute empties the whole label.
9743 assert!(pango.contains("size=\"24576\""), "{pango}");
9744 assert!(pango.contains("<u>hi</u>"), "{pango}");
9745 let qt = runs_to_markup(
9746 "hi",
9747 std::slice::from_ref(&run),
9748 MarkupDialect::QtHtml,
9749 16.0,
9750 );
9751 assert!(qt.contains("background-color:#00ff00"), "{qt}");
9752 // Qt takes points: 16 pt base × 1.5 (observed; its CSS subset ignores a percentage).
9753 assert!(qt.contains("font-size:24pt"), "{qt}");
9754 assert!(qt.contains("<u>hi</u>"), "{qt}");
9755 }
9756
9757 #[test]
9758 fn a_link_target_is_escaped_too() {
9759 // A URL carrying `&` between query parameters is ordinary, and unescaped it truncates
9760 // the attribute and swallows the rest of the label.
9761 let text = "docs";
9762 let run = TextRun {
9763 range: 0..4,
9764 link: Some("https://x.dev/?a=1&b=2".into()),
9765 ..TextRun::default()
9766 };
9767 let m = runs_to_markup(text, &[run], MarkupDialect::Pango, 16.0);
9768 assert!(m.contains("a=1&b=2"), "{m}");
9769 }
9770
9771 #[test]
9772 fn tags_nest_and_close_in_reverse() {
9773 let run = TextRun {
9774 range: 0..2,
9775 font: FontSpec {
9776 style: Font::Body,
9777 weight: Some(FontWeight::Bold),
9778 italic: true,
9779 ..Default::default()
9780 },
9781 strikethrough: true,
9782 link: Some("u".into()),
9783 ..TextRun::default()
9784 };
9785 let m = runs_to_markup("hi", &[run], MarkupDialect::Pango, 16.0);
9786 assert_eq!(m, "<a href=\"u\"><b><i><s>hi</s></i></b></a>");
9787 }
9788
9789 #[test]
9790 fn runs_out_of_bounds_are_skipped_not_panicked() {
9791 // `runs_are_valid` rejects these upstream; this is the belt-and-braces path.
9792 let m = runs_to_markup("hi", &[bold(0..99)], MarkupDialect::Pango, 16.0);
9793 assert_eq!(m, "hi");
9794 }
9795
9796 #[test]
9797 fn validation_catches_the_four_ways_runs_go_wrong() {
9798 let text = "héllo";
9799 assert!(runs_are_valid(text, &[bold(0..1)]).is_ok());
9800 // Overlapping.
9801 assert!(runs_are_valid(text, &[bold(0..3), bold(2..4)]).is_err());
9802 // Past the end.
9803 assert!(runs_are_valid(text, &[bold(0..99)]).is_err());
9804 // Inverted. Built from parts, since a literal `3..1` is a clippy error in its own right.
9805 let (hi, lo) = (3, 1);
9806 assert!(runs_are_valid(text, &[bold(hi..lo)]).is_err());
9807 // Splitting the 2-byte `é`.
9808 assert!(runs_are_valid(text, &[bold(0..2)]).is_err());
9809 }
9810}
9811
9812#[cfg(test)]
9813mod route_of_url_tests {
9814 use super::route_of_url;
9815
9816 #[test]
9817 fn strips_the_scheme_and_keeps_route_and_params() {
9818 assert_eq!(
9819 route_of_url("notes://mail/inbox?hint=x"),
9820 "mail/inbox?hint=x"
9821 );
9822 assert_eq!(route_of_url("notes://home"), "home");
9823 // No scheme: already a route, unchanged.
9824 assert_eq!(route_of_url("mail/inbox"), "mail/inbox");
9825 assert_eq!(route_of_url(""), "");
9826 // Percent-encoding passes through; the route parser decodes, not this mapping.
9827 assert_eq!(route_of_url("n://a/b%2Fc?q=1%202"), "a/b%2Fc?q=1%202");
9828 }
9829}
9830
9831#[cfg(test)]
9832mod builtin_kind_tests {
9833 use super::*;
9834
9835 /// Every built-in round-trips through its wire key, and the keys are unique. Guards the
9836 /// `builtin_kinds!` expansion: a copy-paste slip in the table would alias two kinds.
9837 #[test]
9838 fn keys_round_trip_and_are_unique() {
9839 let mut seen = std::collections::HashSet::new();
9840 for &b in Builtin::ALL {
9841 assert_eq!(Builtin::from_key(b.key()), Some(b), "{b:?} key round-trip");
9842 assert!(seen.insert(b.key()), "duplicate key {:?}", b.key());
9843 assert!(
9844 b.key().starts_with("day."),
9845 "built-in {b:?} must use the reserved `day.` prefix"
9846 );
9847 }
9848 }
9849
9850 /// The `kinds::*` constants are the enum's keys, so the two spellings cannot drift.
9851 #[test]
9852 fn kinds_constants_match_the_enum() {
9853 assert_eq!(kinds::LABEL, Builtin::Label.key());
9854 assert_eq!(kinds::LIST_CELL, Builtin::ListCell.key());
9855 assert_eq!(kinds::COVER, Builtin::Cover.key());
9856 assert_eq!(kinds::INSPECTOR, Builtin::Inspector.key());
9857 assert_eq!(kinds::TREE, Builtin::Tree.key());
9858 // 19 after `Tabs`/`TabsPage` retired (the tab bar is a NAV presentation, not a kind),
9859 // +2 for the inspector split and its panes (docs/inspector.md), +1 for the tree
9860 // (docs/tree.md).
9861 assert_eq!(Builtin::ALL.len(), 25);
9862 }
9863
9864 /// An extension piece's kind is not a built-in.
9865 #[test]
9866 fn extension_kinds_are_not_builtin() {
9867 assert_eq!(Builtin::from_key("acme.combobox"), None);
9868 assert_eq!(Builtin::from_key("day.not_a_real_kind"), None);
9869 }
9870}
9871
9872#[cfg(test)]
9873mod locale_tests {
9874 /// POSIX locale strings are not BCP-47 tags, and "C" is not a language; that difference is
9875 /// the job of `posix_locale_hints` (§12.2).
9876 #[test]
9877 fn posix_hints_parse_and_drop_the_c_locale() {
9878 // The parsing is exercised through the same normalization the function applies.
9879 let normalize = |v: &str| v.split('.').next().unwrap_or(v).replace('_', "-");
9880 assert_eq!(normalize("fr_FR.UTF-8"), "fr-FR");
9881 assert_eq!(normalize("en_US"), "en-US");
9882 assert_eq!(normalize("C"), "C");
9883 // A real read never panics whatever this host's environment holds.
9884 let hints = super::posix_locale_hints();
9885 assert!(
9886 hints
9887 .iter()
9888 .all(|h| !h.is_empty() && h != "C" && h != "POSIX")
9889 );
9890 }
9891}
9892
9893#[cfg(test)]
9894mod sidetable_tests {
9895 use super::sidetable::{SideTable, sweep};
9896 use std::cell::Cell;
9897 use std::rc::Rc;
9898
9899 /// The registry exists so that one `sweep(key)` clears every table without any
9900 /// table needing its own line in a backend's `release()`.
9901 #[test]
9902 fn sweep_clears_every_registered_table_and_runs_teardowns() {
9903 let torn = Rc::new(Cell::new(0u32));
9904 let t2 = torn.clone();
9905 let plain: SideTable<&'static str> = SideTable::new();
9906 let hooked: SideTable<u32> = SideTable::with_teardown(move |_| t2.set(t2.get() + 1));
9907
9908 plain.insert(7, "a");
9909 hooked.insert(7, 1);
9910 hooked.insert(8, 2);
9911 sweep(7);
9912 assert!(!plain.contains(7));
9913 assert!(!hooked.contains(7), "swept from every table");
9914 assert!(hooked.contains(8), "other keys untouched");
9915 assert_eq!(torn.get(), 1, "teardown ran for the swept value");
9916
9917 // remove() runs the teardown too; take() bypasses it for ownership transfer.
9918 hooked.remove(8);
9919 assert_eq!(torn.get(), 2);
9920 hooked.insert(9, 3);
9921 assert_eq!(hooked.take(9), Some(3));
9922 assert_eq!(torn.get(), 2, "take() skips the hook");
9923 }
9924
9925 #[test]
9926 fn contain_returns_default_on_panic() {
9927 let ok = super::ffi_guard::contain(0i32, || 5);
9928 assert_eq!(ok, 5);
9929 let prev = std::panic::take_hook();
9930 std::panic::set_hook(Box::new(|_| {}));
9931 let contained = super::ffi_guard::contain(-1i32, || panic!("boom"));
9932 std::panic::set_hook(prev);
9933 assert_eq!(contained, -1, "the panic must not escape the guard");
9934 }
9935}
9936
9937#[cfg(test)]
9938mod cursor_tests {
9939 use super::Cursor;
9940
9941 /// Every named cursor has a distinct CSS keyword, and `NAMED` lists each exactly once: the
9942 /// gallery and every backend's table key on the keyword.
9943 #[test]
9944 fn named_cursors_have_distinct_css_names() {
9945 let mut seen = std::collections::BTreeSet::new();
9946 for c in Cursor::NAMED.iter() {
9947 assert!(
9948 seen.insert(c.css_name().to_string()),
9949 "duplicate: {}",
9950 c.css_name()
9951 );
9952 assert!(!matches!(c, Cursor::Native(_)));
9953 }
9954 assert_eq!(seen.len(), Cursor::NAMED.len());
9955 assert_eq!(Cursor::native("dragCopy").css_name(), "dragCopy");
9956 assert_eq!(Cursor::default(), Cursor::Default);
9957 }
9958}
9959
9960#[cfg(test)]
9961mod text_anchor_tests {
9962 /// The anchor arithmetic every backend repeats. A drawing whose text lands in the wrong
9963 /// corner is a per-backend bug that only a screenshot catches, so the shared definition is
9964 /// pinned here and each shim is written to match it.
9965 #[test]
9966 fn text_anchor_offsets_place_the_line_box() {
9967 use crate::props::TextAlign;
9968 use crate::{TextAnchor, TextVAlign};
9969 // A 40-wide, 20-tall line box whose baseline sits 16 below its top.
9970 let (w, h, asc) = (40.0, 20.0, 16.0);
9971 let at = |ha, va| TextAnchor { h: ha, v: va }.offset(w, h, asc);
9972
9973 // The default is the drawing origin itself, every backend's native "draw here".
9974 assert_eq!(at(TextAlign::Leading, TextVAlign::Top), (0.0, 0.0));
9975 assert_eq!(TextAnchor::default(), TextAnchor::LEADING);
9976 // Horizontal: the box's leading edge, its middle, its trailing edge.
9977 assert_eq!(at(TextAlign::Center, TextVAlign::Top).0, -20.0);
9978 assert_eq!(at(TextAlign::Trailing, TextVAlign::Top).0, -40.0);
9979 // Vertical: top, middle, the baseline itself, bottom.
9980 assert_eq!(at(TextAlign::Leading, TextVAlign::Middle).1, -10.0);
9981 assert_eq!(at(TextAlign::Leading, TextVAlign::Baseline).1, -16.0);
9982 assert_eq!(at(TextAlign::Leading, TextVAlign::Bottom).1, -20.0);
9983 // The two named placements are the ones the two-variant enum used to have.
9984 assert_eq!(TextAnchor::CENTERED.offset(w, h, asc), (-20.0, -10.0));
9985 assert_eq!(TextAnchor::TRAILING.offset(w, h, asc), (-40.0, 0.0));
9986 }
9987
9988 /// The wire code the serializing backends (qt, xaml, arkui, android, web) decode by hand.
9989 #[test]
9990 fn text_anchor_packs_into_one_slot_and_round_trips() {
9991 use crate::props::TextAlign;
9992 use crate::{TextAnchor, TextVAlign};
9993 let mut seen = std::collections::BTreeSet::new();
9994 for h in [TextAlign::Leading, TextAlign::Center, TextAlign::Trailing] {
9995 for v in [
9996 TextVAlign::Top,
9997 TextVAlign::Middle,
9998 TextVAlign::Baseline,
9999 TextVAlign::Bottom,
10000 ] {
10001 let a = TextAnchor { h, v };
10002 let code = a.pack();
10003 assert!(
10004 (0.0..=11.0).contains(&code),
10005 "{code} outside the packed range"
10006 );
10007 assert!(seen.insert(code as u64), "two anchors share code {code}");
10008 assert_eq!(TextAnchor::unpack(code), a);
10009 }
10010 }
10011 assert_eq!(seen.len(), 12);
10012 // The shims decode as `h = code / 4`, `v = code % 4` in integer arithmetic.
10013 assert_eq!(TextAnchor::LEADING.pack(), 0.0);
10014 assert_eq!(TextAnchor::CENTERED.pack(), 5.0);
10015 // Junk answers the default rather than failing inside a native draw callback.
10016 assert_eq!(TextAnchor::unpack(-3.0), TextAnchor::LEADING);
10017 assert_eq!(TextAnchor::unpack(99.0), TextAnchor::unpack(11.0));
10018 }
10019
10020 /// The two shapes a backend's own metrics arrive in. Both land on the same contract: the line
10021 /// box's top-leading corner is the origin, and `ink` is relative to it.
10022 #[test]
10023 fn metrics_convert_from_a_backends_own_shape() {
10024 use crate::{Rect, TextMetrics};
10025 // Baseline-relative, y up-negative; Core Text, Skia and Qt all report like this.
10026 let m = TextMetrics::from_baseline(
10027 40.0,
10028 16.0,
10029 4.0,
10030 11.0,
10031 // Ink starting 1 right of the origin, from 11 above the baseline to 2 below it.
10032 Rect::new(1.0, -11.0, 38.0, 13.0),
10033 );
10034 assert_eq!(
10035 (m.width, m.height, m.ascent, m.cap_height),
10036 (40.0, 20.0, 16.0, 11.0)
10037 );
10038 // Shifted down by the ascent: the ink now starts 5 below the line box's top, which is
10039 // where a cap begins in a slot whose baseline is at 16.
10040 assert_eq!(m.ink, Rect::new(1.0, 5.0, 38.0, 13.0));
10041 assert_eq!(m.ascent - m.cap_height, m.ink.origin.y);
10042
10043 // The eight-slot wire form is already line-box-relative and passes straight through.
10044 let w = TextMetrics::from_slots(&[40.0, 20.0, 16.0, 11.0, 1.0, 5.0, 38.0, 13.0]);
10045 assert_eq!(w, m);
10046 }
10047
10048 /// The fallback must be usable, not merely present: a caller frames text with it on a toolkit
10049 /// that cannot measure, and every relation it relies on has to hold.
10050 #[test]
10051 fn the_approximation_is_internally_consistent() {
10052 use crate::TextMetrics;
10053 let m = TextMetrics::approximate("Agy", 20.0);
10054 assert!(m.ascent < m.height, "the baseline sits inside the line box");
10055 assert!(
10056 m.cap_height < m.ascent,
10057 "caps do not reach above the ascent"
10058 );
10059 // The ink box is the whole line box: a superset, so an overlap test using it can only be
10060 // too cautious. Never a claim to be tighter than it knows.
10061 assert_eq!(m.ink.origin.x, 0.0);
10062 assert_eq!(m.ink.origin.y, 0.0);
10063 assert_eq!(m.ink.size.width, m.width);
10064 assert_eq!(m.ink.size.height, m.height);
10065 // Empty text still answers a usable (zero-width, full-height) slot.
10066 let e = TextMetrics::approximate("", 20.0);
10067 assert_eq!(e.width, 0.0);
10068 assert!(e.height > 0.0);
10069 }
10070}
10071
10072#[cfg(test)]
10073mod cap_list_tests {
10074 use super::Cap;
10075
10076 #[test]
10077 fn every_capability_is_listed_once_in_its_place() {
10078 for (i, cap) in Cap::ALL.iter().enumerate() {
10079 assert_eq!(cap.ordinal(), i, "{cap:?} is out of place in Cap::ALL");
10080 }
10081 }
10082}