Skip to main content

aetna_core/
event.rs

1//! Event types and the [`App`] trait.
2//!
3//! State-driven rebuilds, routed events, keyboard input, and automatic
4//! hover/press/focus visuals. See `docs/LIBRARY_VISION.md` for the application
5//! model this fits into.
6//!
7//! This module owns the *types* — what the host's `App::on_event` sees
8//! and what gets registered as hotkeys. The state machine that produces
9//! these events lives in [`crate::state::UiState`]; the routing helpers
10//! live in [`mod@crate::hit_test`] and [`mod@crate::focus`].
11//!
12//! # The model
13//!
14//! ```ignore
15//! use aetna_core::prelude::*;
16//!
17//! struct Counter { value: i32 }
18//!
19//! impl App for Counter {
20//!     fn build(&self, _cx: &BuildCx) -> El {
21//!         column([
22//!             h1(format!("{}", self.value)),
23//!             row([
24//!                 button("-").key("dec"),
25//!                 button("+").key("inc"),
26//!             ]),
27//!         ])
28//!     }
29//!     fn on_event(&mut self, e: UiEvent) {
30//!         if e.is_click_or_activate("inc") {
31//!             self.value += 1;
32//!         } else if e.is_click_or_activate("dec") {
33//!             self.value -= 1;
34//!         }
35//!     }
36//! }
37//! ```
38//!
39//! - **Identity** is `El::key`. Tag a node with `.key("...")` and it's
40//!   hit-testable (and gets automatic hover/press visuals).
41//! - **The build closure is pure.** It reads `&self`, returns a fresh
42//!   tree. The library tracks pointer state, hovered key, pressed key
43//!   internally and applies visual deltas after build but before layout
44//!   completes.
45//! - **Events flow back via `on_event`.** The library hit-tests pointer
46//!   events against the most-recently-laid-out tree and emits
47//!   [`UiEvent`]s when something is clicked. The host's `App::on_event`
48//!   updates state; the renderer reports whether animation state needs
49//!   another redraw.
50
51use crate::tree::{El, Rect};
52
53/// Hit-test target metadata. `key` is the author-facing route, while
54/// `node_id` is the stable laid-out tree path used by artifacts.
55///
56/// `tooltip` snapshots the node's tooltip text at the moment the
57/// target was constructed, so the tooltip pass doesn't have to walk
58/// the live tree to resolve it. This is what makes tooltips work on
59/// virtual-list rows: hit-testing reads `last_tree` (where the row
60/// has been realized), and the cached text survives into the next
61/// frame's `synthesize_tooltip` even though that frame's tree hasn't
62/// rebuilt its virtual-list children yet.
63#[derive(Clone, Debug, PartialEq)]
64#[non_exhaustive]
65pub struct UiTarget {
66    pub key: String,
67    pub node_id: String,
68    pub rect: Rect,
69    pub tooltip: Option<String>,
70    /// Scroll offset of the deepest scroll subtree inside this hit
71    /// target, in logical pixels. `0.0` for widgets that don't
72    /// contain a scroll. Used by widgets like
73    /// [`crate::widgets::text_area`] to convert a pointer in viewport
74    /// space (what the user clicks) into content space (what
75    /// cosmic-text's `hit_byte` and `caret_xy` work in) — without
76    /// this, clicks after scrolling land on the wrong line because
77    /// the content has been shifted up by `scroll_offset_y` while
78    /// the outer's `rect` hasn't moved.
79    pub scroll_offset_y: f32,
80}
81
82/// Which mouse button (or pointer button) generated a pointer event.
83/// The host backend translates its native button id to one of these
84/// before calling `pointer_down` / `pointer_up`.
85#[derive(Clone, Copy, Debug, PartialEq, Eq)]
86pub enum PointerButton {
87    /// Left mouse, primary touch, or pen tip. Drives `Click`.
88    Primary,
89    /// Right mouse or two-finger touch. Drives `SecondaryClick` —
90    /// typically opens a context menu.
91    Secondary,
92    /// Middle mouse / scroll-wheel click. No library default; surfaced
93    /// as `MiddleClick` for apps that want it (autoscroll, paste-on-X).
94    Middle,
95}
96
97/// Physical kind of pointer that produced an event. Mirrors the DOM
98/// `PointerEvent.pointerType`. Backends without a real signal pass
99/// [`PointerKind::Mouse`].
100///
101/// The runtime uses this to specialize behavior that does not transfer
102/// across modalities — for example, `Touch` has no resting hover state
103/// and gates `PointerEnter`/`PointerLeave` accordingly.
104#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
105pub enum PointerKind {
106    /// Mouse, trackpad, or any device that reports continuous hover.
107    #[default]
108    Mouse,
109    /// Touchscreen. No hover state; contact starts with `pointer_down`.
110    Touch,
111    /// Pen / stylus. Behaves like `Mouse` for hover, but backends may
112    /// surface pressure in [`Pointer::pressure`].
113    Pen,
114}
115
116/// Stable per-pointer identifier within a frame. Mirrors the DOM
117/// `PointerEvent.pointerId`. Backends with only one pointer pass
118/// [`PointerId::PRIMARY`]; multi-touch backends keep IDs stable for the
119/// lifetime of a single contact.
120///
121/// The runtime currently routes only the primary contact; secondary IDs
122/// are reserved for future multi-touch / gesture work.
123#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)]
124pub struct PointerId(pub u32);
125
126impl PointerId {
127    /// The conventional ID for backends that have only one pointer
128    /// (mouse-only hosts, synthetic test events, the first touch
129    /// contact when multi-touch IDs are not tracked).
130    pub const PRIMARY: PointerId = PointerId(0);
131}
132
133/// One pointer sample, in logical pixels. The argument shape for
134/// [`crate::runtime::RunnerCore::pointer_moved`],
135/// [`crate::runtime::RunnerCore::pointer_down`], and
136/// [`crate::runtime::RunnerCore::pointer_up`].
137///
138/// Modeled on the DOM `PointerEvent` interface so backends that
139/// already speak browser pointer events can map fields directly.
140/// `button` is meaningful on `pointer_down` / `pointer_up` and is
141/// ignored on `pointer_moved`; constructors default it to
142/// [`PointerButton::Primary`] for that case.
143#[derive(Clone, Copy, Debug, PartialEq)]
144pub struct Pointer {
145    /// X coordinate in logical pixels relative to the window origin.
146    pub x: f32,
147    /// Y coordinate in logical pixels relative to the window origin.
148    pub y: f32,
149    /// Which button this event refers to. Ignored by `pointer_moved`.
150    pub button: PointerButton,
151    /// Physical kind of pointer (mouse / touch / pen).
152    pub kind: PointerKind,
153    /// Stable per-pointer ID. Use [`PointerId::PRIMARY`] for
154    /// single-pointer backends.
155    pub id: PointerId,
156    /// Normalized pressure in `0.0..=1.0` when the device reports it
157    /// (pen, force-touch). `None` when unavailable; mouse backends
158    /// always pass `None`.
159    pub pressure: Option<f32>,
160}
161
162impl Pointer {
163    /// A mouse-driven pointer at `(x, y)` for the given button. Use
164    /// from mouse-only hosts and synthetic tests.
165    pub fn mouse(x: f32, y: f32, button: PointerButton) -> Self {
166        Self {
167            x,
168            y,
169            button,
170            kind: PointerKind::Mouse,
171            id: PointerId::PRIMARY,
172            pressure: None,
173        }
174    }
175
176    /// A mouse pointer for `pointer_moved`, where `button` is
177    /// irrelevant. Equivalent to
178    /// [`Pointer::mouse(x, y, PointerButton::Primary)`][Self::mouse].
179    pub fn moving(x: f32, y: f32) -> Self {
180        Self::mouse(x, y, PointerButton::Primary)
181    }
182
183    /// A touch contact at `(x, y)` carrying the given pointer ID.
184    /// Backends translating browser `PointerEvent` should pass the
185    /// browser's `pointerId` directly.
186    pub fn touch(x: f32, y: f32, button: PointerButton, id: PointerId) -> Self {
187        Self {
188            x,
189            y,
190            button,
191            kind: PointerKind::Touch,
192            id,
193            pressure: None,
194        }
195    }
196}
197
198/// Keyboard key values normalized by the core library. This keeps the
199/// core independent from host/windowing crates while covering the
200/// navigation and activation keys the library owns.
201#[derive(Clone, Debug, PartialEq, Eq)]
202pub enum UiKey {
203    Enter,
204    Escape,
205    Tab,
206    Space,
207    ArrowUp,
208    ArrowDown,
209    ArrowLeft,
210    ArrowRight,
211    /// Backspace — deletes the grapheme before the caret.
212    Backspace,
213    /// Forward delete — deletes the grapheme after the caret.
214    Delete,
215    /// Home — caret to start of line.
216    Home,
217    /// End — caret to end of line.
218    End,
219    /// PageUp — coarse-step navigation (sliders adjust by a larger
220    /// amount; lists scroll a viewport).
221    PageUp,
222    /// PageDown — coarse-step navigation (sliders adjust by a larger
223    /// amount; lists scroll a viewport).
224    PageDown,
225    Character(String),
226    Other(String),
227}
228
229/// OS modifier-key mask. The four fields mirror the platform-standard
230/// modifier set; this struct is intentionally **not** `#[non_exhaustive]`
231/// so callers can use struct-literal syntax with `..Default::default()`
232/// to spell precise modifier combinations.
233#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
234pub struct KeyModifiers {
235    pub shift: bool,
236    pub ctrl: bool,
237    pub alt: bool,
238    pub logo: bool,
239}
240
241#[derive(Clone, Debug, PartialEq, Eq)]
242#[non_exhaustive]
243pub struct KeyPress {
244    pub key: UiKey,
245    pub modifiers: KeyModifiers,
246    pub repeat: bool,
247}
248
249/// A keyboard chord for app-level hotkey registration. Match a key with
250/// an exact modifier mask: `KeyChord::ctrl('f')` does not also match
251/// `Ctrl+Shift+F`, and `KeyChord::vim('j')` does not match if any
252/// modifier is held.
253///
254/// Register chords from [`App::hotkeys`]; the library matches them
255/// against incoming key presses ahead of focus activation routing and
256/// emits a [`UiEvent`] with `kind = UiEventKind::Hotkey` and `key`
257/// equal to the registered name.
258#[derive(Clone, Debug, PartialEq, Eq)]
259#[non_exhaustive]
260pub struct KeyChord {
261    pub key: UiKey,
262    pub modifiers: KeyModifiers,
263}
264
265impl KeyChord {
266    /// A bare key with no modifiers (vim-style). `KeyChord::vim('j')`
267    /// matches the `j` key with no Ctrl/Shift/Alt/Logo held.
268    pub fn vim(c: char) -> Self {
269        Self {
270            key: UiKey::Character(c.to_string()),
271            modifiers: KeyModifiers::default(),
272        }
273    }
274
275    /// `Ctrl+<char>`.
276    pub fn ctrl(c: char) -> Self {
277        Self {
278            key: UiKey::Character(c.to_string()),
279            modifiers: KeyModifiers {
280                ctrl: true,
281                ..Default::default()
282            },
283        }
284    }
285
286    /// `Ctrl+Shift+<char>`.
287    pub fn ctrl_shift(c: char) -> Self {
288        Self {
289            key: UiKey::Character(c.to_string()),
290            modifiers: KeyModifiers {
291                ctrl: true,
292                shift: true,
293                ..Default::default()
294            },
295        }
296    }
297
298    /// A named key with no modifiers (e.g. `KeyChord::named(UiKey::Escape)`).
299    pub fn named(key: UiKey) -> Self {
300        Self {
301            key,
302            modifiers: KeyModifiers::default(),
303        }
304    }
305
306    pub fn with_modifiers(mut self, modifiers: KeyModifiers) -> Self {
307        self.modifiers = modifiers;
308        self
309    }
310
311    /// Strict match: keys equal AND modifier mask is identical. Holding
312    /// extra modifiers does not match a chord that didn't request them.
313    pub fn matches(&self, key: &UiKey, modifiers: KeyModifiers) -> bool {
314        key_eq(&self.key, key) && self.modifiers == modifiers
315    }
316}
317
318fn key_eq(a: &UiKey, b: &UiKey) -> bool {
319    match (a, b) {
320        (UiKey::Character(x), UiKey::Character(y)) => x.eq_ignore_ascii_case(y),
321        _ => a == b,
322    }
323}
324
325/// User-facing event. The host's [`App::on_event`] receives one of these
326/// per discrete user action.
327///
328/// Most apps should not destructure every field. Prefer the convenience
329/// methods on this type for common routes:
330///
331/// ```
332/// # use aetna_core::prelude::*;
333/// # struct Counter { value: i32 }
334/// # impl App for Counter {
335/// # fn build(&self, _cx: &BuildCx) -> El { button("+").key("inc") }
336/// fn on_event(&mut self, event: UiEvent) {
337///     if event.is_click_or_activate("inc") {
338///         self.value += 1;
339///     }
340/// }
341/// # }
342/// ```
343#[derive(Clone, Debug)]
344#[non_exhaustive]
345pub struct UiEvent {
346    /// Route string for this event.
347    ///
348    /// For pointer and focus events, this is the [`El::key`][crate::El::key]
349    /// of the target node. For [`UiEventKind::Hotkey`], this is the
350    /// action name returned from [`App::hotkeys`]. For window-level
351    /// keyboard events such as Escape with no focused target, this is
352    /// `None`.
353    ///
354    /// Prefer [`Self::route`] or [`Self::is_click_or_activate`] in app
355    /// code. The field remains public for direct pattern matching.
356    pub key: Option<String>,
357    /// Full hit-test target for events routed to a concrete element.
358    pub target: Option<UiTarget>,
359    /// Pointer position in logical pixels when the event was emitted.
360    pub pointer: Option<(f32, f32)>,
361    /// Keyboard payload for key events.
362    pub key_press: Option<KeyPress>,
363    /// Composed text payload for [`UiEventKind::TextInput`] events.
364    pub text: Option<String>,
365    /// Library-emitted selection state for
366    /// [`UiEventKind::SelectionChanged`] events. Carries the new
367    /// [`crate::selection::Selection`] after the runtime resolved a
368    /// pointer interaction. The app folds this into its
369    /// `Selection` field the same way it folds `apply_event` results
370    /// into a [`crate::widgets::text_input::TextSelection`].
371    pub selection: Option<crate::selection::Selection>,
372    /// Modifier mask captured at the moment this event was emitted. For
373    /// keyboard events this duplicates `key_press.modifiers`; for
374    /// pointer events it's the host-tracked modifier state at the time
375    /// of the click / drag (used by widgets like text_input that need
376    /// to detect Shift+click for "extend selection").
377    pub modifiers: KeyModifiers,
378    /// Click number within a multi-click sequence. Set to 1 for single
379    /// click, 2 for double-click, 3 for triple-click, etc. The runtime
380    /// increments this when consecutive `PointerDown`s land on the same
381    /// target within ~500ms and ~4px of the previous click. `Drag`
382    /// events emitted while the final click is held keep the active
383    /// sequence count so text widgets can preserve word / line
384    /// granularity. `0` means "not applicable" — set on events outside
385    /// pointer click / drag routing.
386    ///
387    /// `text_input` / `text_area` and the static-text selection
388    /// manager read this to map double-click → select word, triple-
389    /// click → select line.
390    pub click_count: u8,
391    /// File system path for [`UiEventKind::FileHovered`] /
392    /// [`UiEventKind::FileDropped`] events. Multi-file drag-drops fire
393    /// one event per file (matching the underlying winit semantics);
394    /// each event carries one path. `PathBuf` rather than `String`
395    /// because Windows wide-char paths and unusual Unix paths aren't
396    /// guaranteed to be UTF-8.
397    pub path: Option<std::path::PathBuf>,
398    /// Modality of the pointer that produced this event. `None` for
399    /// non-pointer events (hotkeys, keyboard activation, file drops
400    /// without a tracked pointer). Apps that need to specialize for
401    /// touch (accessibility, analytics, alternate affordances) read
402    /// this; most app code can ignore it.
403    pub pointer_kind: Option<PointerKind>,
404    pub kind: UiEventKind,
405}
406
407impl UiEvent {
408    /// Synthesize a click event for the given route key.
409    ///
410    /// Intended for tests, headless automation, and snapshot
411    /// fixtures that drive UI logic without a real pointer history.
412    /// All optional fields default to `None`; modifiers are empty.
413    pub fn synthetic_click(key: impl Into<String>) -> Self {
414        Self {
415            kind: UiEventKind::Click,
416            key: Some(key.into()),
417            target: None,
418            pointer: None,
419            key_press: None,
420            text: None,
421            selection: None,
422            modifiers: KeyModifiers::default(),
423            click_count: 1,
424            path: None,
425            pointer_kind: None,
426        }
427    }
428
429    /// Route string for this event, if any.
430    ///
431    /// For pointer/focus events this is the target element key. For
432    /// hotkeys this is the registered action name.
433    pub fn route(&self) -> Option<&str> {
434        self.key.as_deref()
435    }
436
437    /// Target element key, if this event was routed to an element.
438    ///
439    /// Unlike [`Self::route`], this returns `None` for app-level
440    /// hotkey actions because those do not have a concrete element
441    /// target.
442    pub fn target_key(&self) -> Option<&str> {
443        self.target.as_ref().map(|t| t.key.as_str())
444    }
445
446    /// True when this event's route equals `key`.
447    pub fn is_route(&self, key: &str) -> bool {
448        self.route() == Some(key)
449    }
450
451    /// True for a primary click or keyboard activation on `key`.
452    ///
453    /// This is the most common button/menu route in app code.
454    pub fn is_click_or_activate(&self, key: &str) -> bool {
455        matches!(self.kind, UiEventKind::Click | UiEventKind::Activate) && self.is_route(key)
456    }
457
458    /// True for a registered hotkey action name.
459    pub fn is_hotkey(&self, action: &str) -> bool {
460        self.kind == UiEventKind::Hotkey && self.is_route(action)
461    }
462
463    /// Pointer position in logical pixels, if this event carries one.
464    pub fn pointer_pos(&self) -> Option<(f32, f32)> {
465        self.pointer
466    }
467
468    /// Pointer x coordinate in logical pixels, if this event carries one.
469    pub fn pointer_x(&self) -> Option<f32> {
470        self.pointer.map(|(x, _)| x)
471    }
472
473    /// Pointer y coordinate in logical pixels, if this event carries one.
474    pub fn pointer_y(&self) -> Option<f32> {
475        self.pointer.map(|(_, y)| y)
476    }
477
478    /// Rectangle of the routed target from the last layout pass.
479    /// This is the target's transformed visual rect, not any
480    /// `hit_overflow` band that may also route pointer events to it.
481    pub fn target_rect(&self) -> Option<Rect> {
482        self.target.as_ref().map(|t| t.rect)
483    }
484
485    /// OS-composed text payload for [`UiEventKind::TextInput`].
486    pub fn text(&self) -> Option<&str> {
487        self.text.as_deref()
488    }
489}
490
491/// What kind of event happened.
492///
493/// This enum is non-exhaustive so Aetna can add new input events
494/// without breaking downstream apps. Match the variants you handle and
495/// include a wildcard arm for everything else.
496#[derive(Clone, Copy, Debug, PartialEq, Eq)]
497#[non_exhaustive]
498pub enum UiEventKind {
499    /// Primary-button pointer down + up landed on the same node.
500    Click,
501    /// Primary-button click landed on a text run carrying a
502    /// [`crate::tree::El::text_link`] URL. The URL is in [`UiEvent::key`].
503    /// Apps decide whether to honor it (filtering, confirmation,
504    /// platform-appropriate open via [`App::drain_link_opens`] +
505    /// host-side opener). Aetna doesn't open URLs itself — it surfaces
506    /// the click and lets the app route it.
507    LinkActivated,
508    /// Secondary-button (right-click) pointer down + up landed on the
509    /// same node. Used for context menus.
510    SecondaryClick,
511    /// Middle-button pointer down + up landed on the same node.
512    MiddleClick,
513    /// Focused element was activated by keyboard (Enter/Space).
514    Activate,
515    /// Escape was pressed. Routed to the focused element when present,
516    /// otherwise emitted as a window-level event.
517    Escape,
518    /// A registered hotkey chord matched. `event.key` is the registered
519    /// name (the second element of the `(KeyChord, String)` pair).
520    Hotkey,
521    /// Other keyboard input.
522    KeyDown,
523    /// Composed text input — printable characters from the OS, after
524    /// dead-key composition / IME / shift mapping. Routed to the
525    /// focused element. Distinct from `KeyDown(Character(_))`: the
526    /// latter is the raw key event used for shortcuts and navigation;
527    /// `TextInput` is the grapheme stream a text field should consume.
528    TextInput,
529    /// Pointer moved while the primary button was held down. Routed
530    /// to the originally pressed target so a widget can extend a
531    /// selection / scrub a slider / move a draggable. `event.pointer`
532    /// carries the current logical-pixel position; `event.target` is
533    /// the node where the drag began.
534    Drag,
535    /// Primary pointer button released. Fires regardless of whether
536    /// the up landed on the same node as the down — paired with
537    /// `Click` (which only fires on a same-node match), this lets
538    /// drag-aware widgets always observe drag-end.
539    /// `event.target` is the originally pressed node;
540    /// `event.pointer` is the up position.
541    PointerUp,
542    /// Primary pointer button pressed on a hit-test target. Routed
543    /// before the eventual `Click` (which fires on up-on-same-target).
544    /// Used by widgets like text_input that need to react at
545    /// down-time — e.g., to set the selection anchor before any drag
546    /// extends it. `event.target` is the down-target,
547    /// `event.pointer` is the down position, and `event.modifiers`
548    /// carries the modifier mask (Shift+click for extend-selection).
549    PointerDown,
550    /// The library's selection manager resolved a pointer interaction
551    /// on selectable text and wants the app to update its
552    /// [`crate::selection::Selection`] state. `event.selection`
553    /// carries the new value (an empty `Selection` clears).
554    /// Emitted by `pointer_down`, `pointer_moved` (during a drag),
555    /// and the runtime's escape / dismiss paths.
556    SelectionChanged,
557    /// Pointer crossed onto a keyed hit-test target. Routed to the
558    /// newly hovered leaf — `event.target` is the new hover target,
559    /// `event.pointer` is the current pointer position. Fires
560    /// once per identity change, including the initial hover when the
561    /// pointer first enters a keyed region from nothing.
562    ///
563    /// Use for transition-driven side effects (sound on hover-enter,
564    /// analytics, hover-intent prefetch) — read state via
565    /// [`crate::BuildCx::hovered_key`] /
566    /// [`crate::BuildCx::is_hovering_within`] when you just need to
567    /// branch the build output. Both surfaces stay coherent because
568    /// the runtime debounces redraws and events to the same
569    /// hover-identity transitions.
570    ///
571    /// Always paired with a preceding `PointerLeave` for the previous
572    /// target (when there was one). Apps that want subtree-aware
573    /// behavior (parent stays "hot" while a child is hovered) should
574    /// query `is_hovering_within` rather than tracking enter/leave on
575    /// every keyed descendant.
576    PointerEnter,
577    /// Pointer crossed off a keyed hit-test target — either onto a
578    /// different keyed target (paired with a following `PointerEnter`)
579    /// or off any keyed surface entirely. Routed to the leaf that
580    /// just lost hover — `event.target` is the previous hover target,
581    /// `event.pointer` is the current pointer position (or the last
582    /// known position when the pointer left the window).
583    PointerLeave,
584    /// The runner is abandoning a press because the gesture became
585    /// something else — currently only fired when a touch contact's
586    /// movement crosses the touch-scroll threshold and the press
587    /// target did not opt in via `consumes_touch_drag`. The contact
588    /// has *not* lifted; the user is still touching the screen, but
589    /// from the widget's perspective the press is gone (no
590    /// subsequent `Drag`, no `Click`, no `PointerUp`). Routed to the
591    /// originally pressed target — apps that handle `PointerDown`
592    /// for in-flight visual / state setup should also handle
593    /// `PointerCancel` to roll it back.
594    ///
595    /// Browser-initiated pointer cancels (OS gesture takeover, etc.)
596    /// currently come through as `PointerUp` rather than this event;
597    /// that may change.
598    PointerCancel,
599    /// A touch contact has been held in place past
600    /// [`crate::state::LONG_PRESS_DELAY`] without lifting or moving
601    /// past the gesture threshold. Fired exactly once per qualifying
602    /// press, immediately after a `PointerCancel` is dispatched to
603    /// the originally pressed target — the underlying primary press
604    /// is consumed by the long-press, so no subsequent `Drag`,
605    /// `Click`, or `PointerUp` follows. The eventual finger lift is
606    /// silently swallowed.
607    ///
608    /// `event.target` is the keyed leaf at the press point (same
609    /// node that received the cancelled `PointerDown`), `event.pointer`
610    /// is the original press coords (not the current finger position
611    /// — the contact may have drifted within the gesture-threshold
612    /// radius before firing), and `event.pointer_kind` is always
613    /// `PointerKind::Touch`.
614    ///
615    /// Mouse and pen pointers never produce this event — right-click
616    /// goes through `PointerDown` with [`PointerButton::Secondary`]
617    /// instead, which is the desktop-shape signal for the same
618    /// "open a context menu here" intent. Apps that want both paths
619    /// to drive the same menu match on either kind.
620    LongPress,
621    /// A file is being dragged over the window (the user hasn't
622    /// released yet). `event.path` carries the file's path; multi-file
623    /// drags fire one event per file, matching the underlying winit
624    /// semantics. `event.target` is the keyed leaf at the current
625    /// pointer position when one was hit, otherwise `None`
626    /// (drop-zone overlays that span the window can match on
627    /// `event.target.is_none()` or filter by their own key).
628    ///
629    /// Apps use this to highlight a drop zone before the drop lands.
630    /// Always paired with either a later `FileHoverCancelled` (the
631    /// user moved off without releasing) or `FileDropped` (the user
632    /// released).
633    FileHovered,
634    /// The user moved a hovered file off the window without releasing,
635    /// or pressed Escape. Window-level event (`event.target` is
636    /// `None`) — apps clear any drop-zone affordance state regardless
637    /// of which keyed leaf was previously highlighted.
638    FileHoverCancelled,
639    /// A file was dropped on the window. `event.path` carries the
640    /// path; multi-file drops fire one event per file. `event.target`
641    /// is the keyed leaf at the drop position, or `None` if the drop
642    /// landed outside any keyed surface — apps that want a global drop
643    /// target match on `target.is_none()` or treat unrouted events as
644    /// hits to a single window-level upload sink.
645    FileDropped,
646}
647
648/// Per-frame, read-only context for [`App::build`].
649///
650/// The runner snapshots the app's [`crate::Theme`] before calling
651/// `build` and exposes it through `cx.theme()` / `cx.palette()` so app
652/// code can branch on the active palette (a custom widget that picks
653/// between two non-token colors based on dark vs. light, for instance).
654/// `BuildCx` is the explicit handle for this — token references inside
655/// widgets resolve through the palette automatically and don't need it.
656///
657/// Future fields like viewport metrics or frame phase will live here so
658/// the API stays additive: adding a new accessor on `BuildCx` doesn't
659/// break apps that ignore the context.
660#[derive(Copy, Clone, Debug)]
661pub struct BuildCx<'a> {
662    theme: &'a crate::Theme,
663    ui_state: Option<&'a crate::state::UiState>,
664    diagnostics: Option<&'a HostDiagnostics>,
665    /// Logical-pixel viewport this frame is being built for, when the
666    /// host attached one. Apps query this via [`Self::viewport`] /
667    /// [`Self::viewport_below`] to branch layout on phone-vs-desktop
668    /// without threading the surface size through their own state.
669    viewport: Option<(f32, f32)>,
670    /// Logical-pixel insets the host wants the app to inset its
671    /// layout by — content underneath these bands is obscured by
672    /// platform chrome and shouldn't host interactive widgets.
673    /// Today only the bottom inset is populated, by the web host's
674    /// VisualViewport listener when the on-screen keyboard appears;
675    /// the same field will carry status-bar / notch / home-indicator
676    /// insets when native mobile hosts land.
677    safe_area: Option<crate::tree::Sides>,
678}
679
680/// Why the current frame is being built. Hosts set this before each
681/// `request_redraw` so apps that surface a diagnostic overlay can show
682/// what kind of input is driving the redraw cadence.
683///
684/// `Other` is the conservative default: it covers redraws the host
685/// can't attribute (idle redraws driven by external `request_redraw`
686/// callers, the initial paint, etc.). Specific variants narrow the
687/// reason when the host can.
688#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
689pub enum FrameTrigger {
690    /// Host can't attribute the redraw to a specific cause.
691    #[default]
692    Other,
693    /// Initial paint after surface configuration.
694    Initial,
695    /// Surface resize / DPI change.
696    Resize,
697    /// Pointer move, button, or wheel.
698    Pointer,
699    /// Keyboard / IME input.
700    Keyboard,
701    /// Inside-out animation deadline elapsed (one of the visible
702    /// widgets asked for a future frame via `redraw_within`, or a
703    /// visual animation is still settling). Drives the layout-path
704    /// (full rebuild + prepare).
705    Animation,
706    /// Time-driven shader deadline elapsed (e.g. stock spinner /
707    /// skeleton / progress-indeterminate, or a custom shader
708    /// registered with `samples_time=true`). Drives the paint-only
709    /// path: `frame.time` advances but layout state is unchanged.
710    ShaderPaint,
711    /// Periodic host-config cadence (`HostConfig::redraw_interval`).
712    Periodic,
713}
714
715impl FrameTrigger {
716    /// Short, fixed-width tag for diagnostic overlays.
717    pub fn label(self) -> &'static str {
718        match self {
719            FrameTrigger::Other => "other",
720            FrameTrigger::Initial => "initial",
721            FrameTrigger::Resize => "resize",
722            FrameTrigger::Pointer => "pointer",
723            FrameTrigger::Keyboard => "keyboard",
724            FrameTrigger::Animation => "animation",
725            FrameTrigger::ShaderPaint => "shader-paint",
726            FrameTrigger::Periodic => "periodic",
727        }
728    }
729}
730
731/// Per-frame diagnostic snapshot the host hands the app via
732/// [`BuildCx::diagnostics`]. Apps that surface a debug overlay (e.g.
733/// the showcase status block) read this each build to display the
734/// active backend, frame cadence, and what triggered the redraw.
735/// Timing fields describe the last completed rendered frame, not the
736/// frame currently being built; the host cannot know current layout /
737/// paint timings until after `App::build` returns.
738///
739/// Hosts populate every field they can; `backend` is a static string
740/// (`"WebGPU"`, `"Vulkan"`, `"Metal"`, `"DX12"`, `"GL"`) so the app
741/// doesn't need to depend on `wgpu` to read it. Time fields use
742/// `std::time::Duration`, which works on both native and wasm32 — only
743/// `Instant::now()` is the wasm-incompatible piece, and that stays on
744/// the host side.
745#[derive(Clone, Debug)]
746pub struct HostDiagnostics {
747    /// Render backend in human-readable form.
748    pub backend: &'static str,
749    /// Current surface size in physical pixels.
750    pub surface_size: (u32, u32),
751    /// Display scale factor (`physical / logical`).
752    pub scale_factor: f32,
753    /// Active MSAA sample count (1 = MSAA off).
754    pub msaa_samples: u32,
755    /// Frame counter; increments every redraw the host actually
756    /// renders. Useful for verifying that an animated source is
757    /// progressing.
758    pub frame_index: u64,
759    /// Wall-clock time between this redraw and the previous one.
760    /// `Duration::ZERO` for the first frame (no prior frame).
761    pub last_frame_dt: std::time::Duration,
762    /// Time spent in the app's `build` method for the last completed
763    /// frame. `Duration::ZERO` before the first full frame and on
764    /// paint-only frames that skipped build.
765    pub last_build: std::time::Duration,
766    /// Total time spent in the backend `prepare` call for the last
767    /// completed frame.
768    pub last_prepare: std::time::Duration,
769    /// Sub-stage inside `prepare`: layout pass, focus/selection sync,
770    /// state application, and animation tick.
771    pub last_layout: std::time::Duration,
772    /// Intrinsic-measurement cache hits during the last layout pass.
773    pub last_layout_intrinsic_cache_hits: u64,
774    /// Intrinsic-measurement cache misses during the last layout pass.
775    pub last_layout_intrinsic_cache_misses: u64,
776    /// Direct scroll children whose descendants were skipped during
777    /// layout because the child was outside the scroll viewport.
778    pub last_layout_pruned_subtrees: u64,
779    /// Descendant nodes assigned zero rects as part of scroll layout
780    /// pruning during the last layout pass.
781    pub last_layout_pruned_nodes: u64,
782    /// Sub-stage inside `prepare`: laid-out tree to backend-neutral
783    /// `DrawOp` list.
784    pub last_draw_ops: std::time::Duration,
785    /// Text draw ops skipped during draw-op generation because their
786    /// glyph rect did not intersect the inherited clip.
787    pub last_draw_ops_culled_text_ops: u64,
788    /// Sub-stage inside `prepare`: paint-stream packing and text
789    /// shaping/rasterization recording.
790    pub last_paint: std::time::Duration,
791    /// Paint ops skipped because their painted rect did not intersect
792    /// the effective clip/viewport in the last completed frame.
793    pub last_paint_culled_ops: u64,
794    /// Sub-stage inside `prepare`: backend-side buffer writes, glyph
795    /// atlas uploads, and frame uniforms.
796    pub last_gpu_upload: std::time::Duration,
797    /// Sub-stage inside `prepare`: clone the laid-out tree for
798    /// next-frame hit-testing.
799    pub last_snapshot: std::time::Duration,
800    /// Time spent encoding/submitting/presenting the last completed
801    /// frame after `prepare`.
802    pub last_submit: std::time::Duration,
803    /// Layout-side text-cache hits during the last completed full
804    /// prepare.
805    pub last_text_layout_cache_hits: u64,
806    /// Layout-side text-cache misses during the last completed full
807    /// prepare.
808    pub last_text_layout_cache_misses: u64,
809    /// Estimated layout-side text-cache evictions during the last
810    /// completed full prepare.
811    pub last_text_layout_cache_evictions: u64,
812    /// Total UTF-8 bytes shaped on layout-cache misses during the last
813    /// completed full prepare.
814    pub last_text_layout_shaped_bytes: u64,
815    /// Why the host triggered this frame.
816    pub trigger: FrameTrigger,
817}
818
819impl Default for HostDiagnostics {
820    fn default() -> Self {
821        Self {
822            backend: "?",
823            surface_size: (0, 0),
824            scale_factor: 1.0,
825            msaa_samples: 1,
826            frame_index: 0,
827            last_frame_dt: std::time::Duration::ZERO,
828            last_build: std::time::Duration::ZERO,
829            last_prepare: std::time::Duration::ZERO,
830            last_layout: std::time::Duration::ZERO,
831            last_layout_intrinsic_cache_hits: 0,
832            last_layout_intrinsic_cache_misses: 0,
833            last_layout_pruned_subtrees: 0,
834            last_layout_pruned_nodes: 0,
835            last_draw_ops: std::time::Duration::ZERO,
836            last_draw_ops_culled_text_ops: 0,
837            last_paint: std::time::Duration::ZERO,
838            last_paint_culled_ops: 0,
839            last_gpu_upload: std::time::Duration::ZERO,
840            last_snapshot: std::time::Duration::ZERO,
841            last_submit: std::time::Duration::ZERO,
842            last_text_layout_cache_hits: 0,
843            last_text_layout_cache_misses: 0,
844            last_text_layout_cache_evictions: 0,
845            last_text_layout_shaped_bytes: 0,
846            trigger: FrameTrigger::default(),
847        }
848    }
849}
850
851impl<'a> BuildCx<'a> {
852    /// Construct a [`BuildCx`] borrowing the supplied theme. Hosts call
853    /// this once per frame after [`App::theme`] and before [`App::build`].
854    /// Hosts that own a [`crate::state::UiState`] should chain
855    /// [`Self::with_ui_state`] so the app can read interaction state
856    /// (hover) during build via [`Self::hovered_key`] /
857    /// [`Self::is_hovering_within`].
858    pub fn new(theme: &'a crate::Theme) -> Self {
859        Self {
860            theme,
861            ui_state: None,
862            diagnostics: None,
863            viewport: None,
864            safe_area: None,
865        }
866    }
867
868    /// Attach the runtime's [`crate::state::UiState`] so build-time
869    /// accessors (`hovered_key`, `is_hovering_within`) can answer.
870    /// When omitted, those accessors return `None` / `false` — useful
871    /// for headless rendering paths that don't track interaction
872    /// state.
873    pub fn with_ui_state(mut self, ui_state: &'a crate::state::UiState) -> Self {
874        self.ui_state = Some(ui_state);
875        self
876    }
877
878    /// Attach a [`HostDiagnostics`] snapshot for this frame. Hosts call
879    /// this when they want apps to surface debug overlays (e.g. the
880    /// showcase status block); apps that don't read `diagnostics()`
881    /// pay nothing for it. Headless render paths leave it `None`.
882    pub fn with_diagnostics(mut self, diagnostics: &'a HostDiagnostics) -> Self {
883        self.diagnostics = Some(diagnostics);
884        self
885    }
886
887    /// Attach the logical-pixel viewport size for this frame. Hosts
888    /// chain this so apps can branch on viewport metrics during build
889    /// (responsive layout, phone-vs-desktop splits) without threading
890    /// surface size through their own state. Headless render paths
891    /// without a meaningful viewport leave it unset.
892    pub fn with_viewport(mut self, width: f32, height: f32) -> Self {
893        self.viewport = Some((width, height));
894        self
895    }
896
897    /// Attach the host's reported safe-area insets in logical pixels.
898    /// Hosts chain this when platform chrome (on-screen keyboard,
899    /// notch, status bar, home indicator) is obscuring some band of
900    /// the viewport. Apps read it via [`Self::safe_area`] /
901    /// [`Self::safe_area_bottom`] and inset their interactive content
902    /// accordingly. Hosts that don't report safe-area metrics omit
903    /// this; apps see `Sides::zero()` from the read accessors.
904    pub fn with_safe_area(mut self, sides: crate::tree::Sides) -> Self {
905        self.safe_area = Some(sides);
906        self
907    }
908
909    /// Per-frame diagnostic snapshot from the host (backend, frame
910    /// cadence, trigger reason, etc.), or `None` when the host did
911    /// not attach one. Apps display this in optional debug overlays.
912    pub fn diagnostics(&self) -> Option<&HostDiagnostics> {
913        self.diagnostics
914    }
915
916    /// The active runtime theme for this frame.
917    pub fn theme(&self) -> &crate::Theme {
918        self.theme
919    }
920
921    /// Shorthand for `self.theme().palette()`.
922    pub fn palette(&self) -> &crate::Palette {
923        self.theme.palette()
924    }
925
926    /// Logical-pixel viewport `(width, height)` the host attached for
927    /// this frame, or `None` for headless render paths. Apps use this
928    /// to branch layout on viewport metrics — see [`Self::viewport_below`]
929    /// for the common phone-vs-desktop breakpoint case.
930    pub fn viewport(&self) -> Option<(f32, f32)> {
931        self.viewport
932    }
933
934    /// Logical-pixel viewport width the host attached for this frame,
935    /// or `None` when no viewport is available. Convenience for the
936    /// common single-axis branch (`cx.viewport_width().map_or(false,
937    /// |w| w < 600.0)`).
938    pub fn viewport_width(&self) -> Option<f32> {
939        self.viewport.map(|(w, _)| w)
940    }
941
942    /// Logical-pixel viewport height the host attached for this frame,
943    /// or `None` when no viewport is available.
944    pub fn viewport_height(&self) -> Option<f32> {
945        self.viewport.map(|(_, h)| h)
946    }
947
948    /// True iff the attached viewport's width is strictly less than
949    /// `threshold` logical pixels. Returns `false` when no viewport is
950    /// attached so headless / desktop-default paths fall through to
951    /// the wider branch — apps that want the opposite default can
952    /// match on [`Self::viewport_width`] directly.
953    ///
954    /// Use for the common breakpoint split:
955    /// ```ignore
956    /// if cx.viewport_below(600.0) {
957    ///     phone_layout()
958    /// } else {
959    ///     desktop_layout()
960    /// }
961    /// ```
962    pub fn viewport_below(&self, threshold: f32) -> bool {
963        self.viewport_width().is_some_and(|w| w < threshold)
964    }
965
966    /// Logical-pixel safe-area insets the host reports for this frame
967    /// (`Sides::zero()` when nothing was attached). Today this is
968    /// populated only by aetna-web when the on-screen keyboard
969    /// shrinks the visual viewport — `bottom` carries the keyboard
970    /// height; future native mobile hosts will additionally populate
971    /// `top` for status-bar / notch and `bottom` for home-indicator.
972    ///
973    /// Apps inset their root layout (or just the focused-input
974    /// region) by these amounts so interactive content doesn't sit
975    /// underneath platform chrome. The runtime does not auto-apply
976    /// this — apps decide where the inset matters.
977    pub fn safe_area(&self) -> crate::tree::Sides {
978        self.safe_area.unwrap_or_default()
979    }
980
981    /// Convenience: just the bottom inset, in logical pixels. Most
982    /// commonly the soft-keyboard height.
983    pub fn safe_area_bottom(&self) -> f32 {
984        self.safe_area().bottom
985    }
986
987    /// Key of the leaf node currently under the pointer, or `None`
988    /// when nothing is hovered or this `BuildCx` was built without a
989    /// `UiState` (headless rendering paths).
990    ///
991    /// Use for branching the build output on hover state without
992    /// mirroring it via `App::on_event` handlers — e.g., a sidebar
993    /// row that previews details in a side pane based on what's
994    /// currently hovered.
995    ///
996    /// For region-aware queries (parent stays "hot" while a child is
997    /// hovered), prefer [`Self::is_hovering_within`].
998    pub fn hovered_key(&self) -> Option<&str> {
999        self.ui_state?.hovered_key()
1000    }
1001
1002    /// True iff `key`'s node — or any descendant of it — is the
1003    /// current hover target. Subtree-aware, matching the semantics of
1004    /// [`crate::tree::El::hover_alpha`]. Returns `false` when this
1005    /// `BuildCx` has no attached `UiState` or when `key` isn't in the
1006    /// current tree.
1007    ///
1008    /// Reads the underlying tracker, not the eased subtree envelope —
1009    /// the boolean flips immediately on hit-test identity change.
1010    pub fn is_hovering_within(&self, key: &str) -> bool {
1011        self.ui_state
1012            .is_some_and(|state| state.is_hovering_within(key))
1013    }
1014}
1015
1016/// The application contract. Implement this on your state struct and
1017/// pass it to a host runner (e.g., `aetna_winit_wgpu::run`).
1018pub trait App {
1019    /// Refresh app-owned external state immediately before a frame is
1020    /// built.
1021    ///
1022    /// Hosts call this once per redraw before [`Self::build`]. Use it
1023    /// for polling an external source, reconciling optimistic local
1024    /// state with a backend snapshot, or advancing host-owned live data
1025    /// that should be visible in the next tree. Keep expensive work
1026    /// outside the render loop; this hook is still on the frame path.
1027    ///
1028    /// Default: no-op.
1029    fn before_build(&mut self) {}
1030
1031    /// Project current state into a scene tree. Called whenever the
1032    /// host requests a redraw, after [`Self::before_build`]. Prefer to
1033    /// keep this pure: read current state and return a fresh tree.
1034    ///
1035    /// `cx` carries per-frame, read-only context (active theme, future
1036    /// viewport / phase metadata). Apps that don't need to branch on
1037    /// the theme during construction can ignore the parameter — token
1038    /// references in widget code resolve through the palette
1039    /// automatically.
1040    fn build(&self, cx: &BuildCx) -> El;
1041
1042    /// Update state in response to a routed event. Default: no-op.
1043    fn on_event(&mut self, _event: UiEvent) {}
1044
1045    /// The application's current text [`crate::selection::Selection`].
1046    /// Read by the host once per frame so the library can paint
1047    /// highlight bands and resolve `selected_text` for clipboard.
1048    /// Apps that own a `Selection` field return a clone here; the
1049    /// default returns the empty selection.
1050    fn selection(&self) -> crate::selection::Selection {
1051        crate::selection::Selection::default()
1052    }
1053
1054    /// App-level hotkey registry. The library matches incoming key
1055    /// presses against this list before its own focus-activation
1056    /// routing; a match emits a [`UiEvent`] with `kind =
1057    /// UiEventKind::Hotkey` and `key = Some(name)`.
1058    ///
1059    /// Called once per build cycle; the host runner snapshots the list
1060    /// alongside `build()` so the chords stay in sync with state.
1061    /// Default: no hotkeys.
1062    fn hotkeys(&self) -> Vec<(KeyChord, String)> {
1063        Vec::new()
1064    }
1065
1066    /// Drain pending toast notifications produced since the last
1067    /// frame. The runtime calls this once per `prepare_layout`,
1068    /// stamps each spec with a monotonic id and `expires_at = now +
1069    /// ttl`, queues it in the runtime toast state, and
1070    /// synthesizes a `toast_stack` layer at the El root so the
1071    /// rendered tree mirrors the visible state. Apps typically
1072    /// accumulate specs in a `Vec<ToastSpec>` field from event
1073    /// handlers, then `mem::take` it here.
1074    ///
1075    /// **Root requirement:** apps that produce toasts (or use
1076    /// `.tooltip(text)` on any node) must wrap their
1077    /// [`Self::build`] return value in `overlays(main, [])` so the
1078    /// runtime can append the floating layer as an overlay sibling
1079    /// — same convention used for popovers and modals. Debug
1080    /// builds panic if the synthesizer runs against a non-overlay
1081    /// root.
1082    ///
1083    /// Default: no toasts.
1084    fn drain_toasts(&mut self) -> Vec<crate::toast::ToastSpec> {
1085        Vec::new()
1086    }
1087
1088    /// Drain pending programmatic focus requests produced since the
1089    /// last frame. The runtime calls this once per `prepare_layout`,
1090    /// after the focus order has been rebuilt from the new tree, and
1091    /// resolves each entry against the keyed focusables. Unmatched
1092    /// keys (widget absent from the rebuilt tree, or not focusable)
1093    /// are dropped silently.
1094    ///
1095    /// This is the imperative companion to keyboard `Tab` traversal:
1096    /// use it for affordances like *Ctrl+F → focus the search input*,
1097    /// *jump-to-match → focus the matched row*, or *open inline edit
1098    /// → focus the field*. Apps typically accumulate keys in a
1099    /// `Vec<String>` field from event handlers and `mem::take` it
1100    /// here.
1101    ///
1102    /// Multiple requests in one frame resolve in order; the last
1103    /// successfully-resolved key is the one focused.
1104    ///
1105    /// Default: no requests.
1106    fn drain_focus_requests(&mut self) -> Vec<String> {
1107        Vec::new()
1108    }
1109
1110    /// Drain pending programmatic scroll requests. The runtime
1111    /// resolves each request during layout, using live viewport rects
1112    /// and row-height/content geometry that apps should not duplicate.
1113    /// Unmatched keys and out-of-range row indices drop silently.
1114    ///
1115    /// Use [`crate::scroll::ScrollRequest::ToRow`] for virtual-list
1116    /// affordances such as jump-to-search-result, reveal selected row,
1117    /// or scroll-to-top-on-tab-change. Use
1118    /// [`crate::scroll::ScrollRequest::EnsureVisible`] for widgets
1119    /// with an internal scroll viewport, including fixed-height
1120    /// [`crate::widgets::text_area`] caret-into-view after accepted
1121    /// edit/navigation events. Apps typically accumulate requests in a
1122    /// `Vec<ScrollRequest>` field from event handlers and
1123    /// `mem::take` it here.
1124    ///
1125    /// Default: no requests.
1126    fn drain_scroll_requests(&mut self) -> Vec<crate::scroll::ScrollRequest> {
1127        Vec::new()
1128    }
1129
1130    /// Drain pending URL-open requests produced since the last frame.
1131    /// Hosts call this once per frame and route each URL to a
1132    /// platform-appropriate opener — `window.open` in the wasm host,
1133    /// the `open` crate (or equivalent) on native.
1134    ///
1135    /// The library emits [`UiEventKind::LinkActivated`] when a click
1136    /// lands on a text run carrying a link URL, but it does not act
1137    /// on the URL itself: opening a link is an app concern (apps may
1138    /// want to confirm, filter by scheme, route through an internal
1139    /// router, or no-op entirely). Apps that want the default
1140    /// browser-style behavior accumulate URLs from
1141    /// [`UiEventKind::LinkActivated`] in their `on_event` handler and
1142    /// return them here; apps that don't override this method drop
1143    /// link clicks on the floor.
1144    ///
1145    /// Default: no requests.
1146    fn drain_link_opens(&mut self) -> Vec<String> {
1147        Vec::new()
1148    }
1149
1150    /// Custom shaders this app needs registered. Each entry carries
1151    /// the shader name, its WGSL source, and per-flag opt-ins
1152    /// (backdrop sampling, time-driven motion). The host runner
1153    /// registers them once at startup via
1154    /// `Runner::register_shader_with(name, wgsl, samples_backdrop, samples_time)`.
1155    ///
1156    /// Backends that don't support backdrop sampling skip entries with
1157    /// `samples_backdrop=true`; any node bound to such a shader will
1158    /// draw nothing on those backends rather than mis-render.
1159    /// `samples_time=true` declares that the shader's output depends
1160    /// on `frame.time`, which keeps the host idle loop ticking while
1161    /// any node is bound to it.
1162    ///
1163    /// Default: no shaders.
1164    fn shaders(&self) -> Vec<AppShader> {
1165        Vec::new()
1166    }
1167
1168    /// Runtime paint theme for this app. Hosts apply it to the renderer
1169    /// before preparing each frame so stateful apps can switch global
1170    /// material routing without backend-specific calls.
1171    fn theme(&self) -> crate::Theme {
1172        crate::Theme::default()
1173    }
1174}
1175
1176/// One custom shader registration, returned from [`App::shaders`].
1177#[derive(Clone, Copy, Debug)]
1178pub struct AppShader {
1179    pub name: &'static str,
1180    pub wgsl: &'static str,
1181    /// Reads the prior pass's color target (`@group(2) backdrop_tex`).
1182    /// Backends without backdrop support skip these.
1183    pub samples_backdrop: bool,
1184    /// Reads `frame.time` and so requires continuous redraw whenever
1185    /// any node is bound to it. The runtime ORs this into
1186    /// `PrepareResult::needs_redraw` per frame.
1187    pub samples_time: bool,
1188}
1189
1190#[cfg(test)]
1191mod tests {
1192    use super::*;
1193    use crate::Theme;
1194
1195    #[test]
1196    fn viewport_unset_returns_none_and_breakpoint_returns_false() {
1197        let theme = Theme::default();
1198        let cx = BuildCx::new(&theme);
1199        assert!(cx.viewport().is_none());
1200        assert!(cx.viewport_width().is_none());
1201        assert!(!cx.viewport_below(600.0));
1202    }
1203
1204    #[test]
1205    fn viewport_set_exposes_width_and_height() {
1206        let theme = Theme::default();
1207        let cx = BuildCx::new(&theme).with_viewport(420.0, 800.0);
1208        assert_eq!(cx.viewport(), Some((420.0, 800.0)));
1209        assert_eq!(cx.viewport_width(), Some(420.0));
1210        assert_eq!(cx.viewport_height(), Some(800.0));
1211    }
1212
1213    #[test]
1214    fn viewport_below_uses_strict_less_than() {
1215        let theme = Theme::default();
1216        let cx = BuildCx::new(&theme).with_viewport(600.0, 800.0);
1217        assert!(!cx.viewport_below(600.0), "boundary is exclusive");
1218        assert!(cx.viewport_below(601.0));
1219        assert!(!cx.viewport_below(599.0));
1220    }
1221}