Skip to main content

damascene_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 damascene_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, _cx: &EventCx) {
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 damascene_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, _cx: &EventCx) {
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    /// Wheel delta in logical pixels for [`UiEventKind::PointerWheel`].
405    ///
406    /// Positive `dy` means "scroll down" in the same coordinate system
407    /// used by Damascene's scroll containers. Hosts normalize line-based
408    /// and pixel-based wheel input before setting this field.
409    pub wheel_delta: Option<(f32, f32)>,
410    pub kind: UiEventKind,
411}
412
413impl UiEvent {
414    /// Synthesize a click event for the given route key.
415    ///
416    /// Intended for tests, headless automation, and snapshot
417    /// fixtures that drive UI logic without a real pointer history.
418    /// All optional fields default to `None`; modifiers are empty.
419    pub fn synthetic_click(key: impl Into<String>) -> Self {
420        Self {
421            kind: UiEventKind::Click,
422            key: Some(key.into()),
423            target: None,
424            pointer: None,
425            key_press: None,
426            text: None,
427            selection: None,
428            modifiers: KeyModifiers::default(),
429            click_count: 1,
430            path: None,
431            pointer_kind: None,
432            wheel_delta: None,
433        }
434    }
435
436    /// Route string for this event, if any.
437    ///
438    /// For pointer/focus events this is the target element key. For
439    /// hotkeys this is the registered action name.
440    pub fn route(&self) -> Option<&str> {
441        self.key.as_deref()
442    }
443
444    /// Target element key, if this event was routed to an element.
445    ///
446    /// Unlike [`Self::route`], this returns `None` for app-level
447    /// hotkey actions because those do not have a concrete element
448    /// target.
449    pub fn target_key(&self) -> Option<&str> {
450        self.target.as_ref().map(|t| t.key.as_str())
451    }
452
453    /// True when this event's route equals `key`.
454    pub fn is_route(&self, key: &str) -> bool {
455        self.route() == Some(key)
456    }
457
458    /// True for a primary click or keyboard activation on `key`.
459    ///
460    /// This is the most common button/menu route in app code.
461    pub fn is_click_or_activate(&self, key: &str) -> bool {
462        matches!(self.kind, UiEventKind::Click | UiEventKind::Activate) && self.is_route(key)
463    }
464
465    /// True for a registered hotkey action name.
466    pub fn is_hotkey(&self, action: &str) -> bool {
467        self.kind == UiEventKind::Hotkey && self.is_route(action)
468    }
469
470    /// Pointer position in logical pixels, if this event carries one.
471    pub fn pointer_pos(&self) -> Option<(f32, f32)> {
472        self.pointer
473    }
474
475    /// Pointer x coordinate in logical pixels, if this event carries one.
476    pub fn pointer_x(&self) -> Option<f32> {
477        self.pointer.map(|(x, _)| x)
478    }
479
480    /// Pointer y coordinate in logical pixels, if this event carries one.
481    pub fn pointer_y(&self) -> Option<f32> {
482        self.pointer.map(|(_, y)| y)
483    }
484
485    /// Wheel delta in logical pixels, if this is a pointer wheel event.
486    pub fn wheel_delta(&self) -> Option<(f32, f32)> {
487        self.wheel_delta
488    }
489
490    /// Vertical wheel delta in logical pixels, if this is a pointer
491    /// wheel event.
492    pub fn wheel_dy(&self) -> Option<f32> {
493        self.wheel_delta.map(|(_, dy)| dy)
494    }
495
496    /// Rectangle of the routed target from the last layout pass.
497    /// This is the target's transformed visual rect, not any
498    /// `hit_overflow` band that may also route pointer events to it.
499    pub fn target_rect(&self) -> Option<Rect> {
500        self.target.as_ref().map(|t| t.rect)
501    }
502
503    /// OS-composed text payload for [`UiEventKind::TextInput`].
504    pub fn text(&self) -> Option<&str> {
505        self.text.as_deref()
506    }
507}
508
509/// What kind of event happened.
510///
511/// This enum is non-exhaustive so Damascene can add new input events
512/// without breaking downstream apps. Match the variants you handle and
513/// include a wildcard arm for everything else.
514#[derive(Clone, Copy, Debug, PartialEq, Eq)]
515#[non_exhaustive]
516pub enum UiEventKind {
517    /// Primary-button pointer down + up landed on the same node.
518    Click,
519    /// Primary-button click landed on a text run carrying a
520    /// [`crate::tree::El::text_link`] URL. The URL is in [`UiEvent::key`].
521    /// Apps decide whether to honor it (filtering, confirmation,
522    /// platform-appropriate open via [`App::drain_link_opens`] +
523    /// host-side opener). Damascene doesn't open URLs itself — it surfaces
524    /// the click and lets the app route it.
525    LinkActivated,
526    /// Secondary-button (right-click) pointer down + up landed on the
527    /// same node. Used for context menus.
528    SecondaryClick,
529    /// Middle-button pointer down + up landed on the same node.
530    MiddleClick,
531    /// Focused element was activated by keyboard (Enter/Space).
532    Activate,
533    /// Escape was pressed. Routed to the focused element when present,
534    /// otherwise emitted as a window-level event.
535    Escape,
536    /// A registered hotkey chord matched. `event.key` is the registered
537    /// name (the second element of the `(KeyChord, String)` pair).
538    Hotkey,
539    /// Other keyboard input.
540    KeyDown,
541    /// Composed text input — printable characters from the OS, after
542    /// dead-key composition / IME / shift mapping. Routed to the
543    /// focused element. Distinct from `KeyDown(Character(_))`: the
544    /// latter is the raw key event used for shortcuts and navigation;
545    /// `TextInput` is the grapheme stream a text field should consume.
546    TextInput,
547    /// Pointer moved while the primary button was held down. Routed
548    /// to the originally pressed target so a widget can extend a
549    /// selection / scrub a slider / move a draggable. `event.pointer`
550    /// carries the current logical-pixel position; `event.target` is
551    /// the node where the drag began.
552    Drag,
553    /// Primary pointer button released. Fires regardless of whether
554    /// the up landed on the same node as the down — paired with
555    /// `Click` (which only fires on a same-node match), this lets
556    /// drag-aware widgets always observe drag-end.
557    /// `event.target` is the originally pressed node;
558    /// `event.pointer` is the up position.
559    PointerUp,
560    /// Primary pointer button pressed on a hit-test target. Routed
561    /// before the eventual `Click` (which fires on up-on-same-target).
562    /// Used by widgets like text_input that need to react at
563    /// down-time — e.g., to set the selection anchor before any drag
564    /// extends it. `event.target` is the down-target,
565    /// `event.pointer` is the down position, and `event.modifiers`
566    /// carries the modifier mask (Shift+click for extend-selection).
567    PointerDown,
568    /// Mouse wheel / trackpad scroll input routed to the keyed element
569    /// under the pointer. Emitted before Damascene's default scroll
570    /// handling; apps can consume it by returning `true` from
571    /// [`App::on_wheel_event`]. `event.wheel_delta` carries the
572    /// normalized logical-pixel delta.
573    PointerWheel,
574    /// The library's selection manager resolved a pointer interaction
575    /// on selectable text and wants the app to update its
576    /// [`crate::selection::Selection`] state. `event.selection`
577    /// carries the new value (an empty `Selection` clears).
578    /// Emitted by `pointer_down`, `pointer_moved` (during a drag),
579    /// and the runtime's escape / dismiss paths.
580    SelectionChanged,
581    /// Pointer crossed onto a keyed hit-test target. Routed to the
582    /// newly hovered leaf — `event.target` is the new hover target,
583    /// `event.pointer` is the current pointer position. Fires
584    /// once per identity change, including the initial hover when the
585    /// pointer first enters a keyed region from nothing.
586    ///
587    /// Use for transition-driven side effects (sound on hover-enter,
588    /// analytics, hover-intent prefetch) — read state via
589    /// [`crate::BuildCx::hovered_key`] /
590    /// [`crate::BuildCx::is_hovering_within`] when you just need to
591    /// branch the build output. Both surfaces stay coherent because
592    /// the runtime debounces redraws and events to the same
593    /// hover-identity transitions.
594    ///
595    /// Always paired with a preceding `PointerLeave` for the previous
596    /// target (when there was one). Apps that want subtree-aware
597    /// behavior (parent stays "hot" while a child is hovered) should
598    /// query `is_hovering_within` rather than tracking enter/leave on
599    /// every keyed descendant.
600    PointerEnter,
601    /// Pointer crossed off a keyed hit-test target — either onto a
602    /// different keyed target (paired with a following `PointerEnter`)
603    /// or off any keyed surface entirely. Routed to the leaf that
604    /// just lost hover — `event.target` is the previous hover target,
605    /// `event.pointer` is the current pointer position (or the last
606    /// known position when the pointer left the window).
607    PointerLeave,
608    /// The runner is abandoning a press because the gesture became
609    /// something else — currently only fired when a touch contact's
610    /// movement crosses the touch-scroll threshold and the press
611    /// target did not opt in via `consumes_touch_drag`. The contact
612    /// has *not* lifted; the user is still touching the screen, but
613    /// from the widget's perspective the press is gone (no
614    /// subsequent `Drag`, no `Click`, no `PointerUp`). Routed to the
615    /// originally pressed target — apps that handle `PointerDown`
616    /// for in-flight visual / state setup should also handle
617    /// `PointerCancel` to roll it back.
618    ///
619    /// Browser-initiated pointer cancels (OS gesture takeover, etc.)
620    /// currently come through as `PointerUp` rather than this event;
621    /// that may change.
622    PointerCancel,
623    /// A touch contact has been held in place past
624    /// [`crate::state::LONG_PRESS_DELAY`] without lifting or moving
625    /// past the gesture threshold. Fired exactly once per qualifying
626    /// press. For normal targets this is fired immediately after a
627    /// `PointerCancel` is dispatched to the originally pressed target;
628    /// the underlying primary press is consumed by the long-press, so
629    /// no subsequent `Click` or `PointerUp` follows. Capture-keys
630    /// editable targets keep the press captured so movement after the
631    /// long-press can emit `Drag` to extend text selection. The
632    /// eventual finger lift is silently swallowed.
633    ///
634    /// `event.target` is the keyed leaf at the press point (same
635    /// node that received the cancelled `PointerDown`), `event.pointer`
636    /// is the original press coords (not the current finger position
637    /// — the contact may have drifted within the gesture-threshold
638    /// radius before firing), and `event.pointer_kind` is always
639    /// `PointerKind::Touch`.
640    ///
641    /// Mouse and pen pointers never produce this event — right-click
642    /// goes through `PointerDown` with [`PointerButton::Secondary`]
643    /// instead, which is the desktop-shape signal for the same
644    /// "open a context menu here" intent. Apps that want both paths
645    /// to drive the same menu match on either kind.
646    LongPress,
647    /// A file is being dragged over the window (the user hasn't
648    /// released yet). `event.path` carries the file's path; multi-file
649    /// drags fire one event per file, matching the underlying winit
650    /// semantics. `event.target` is the keyed leaf at the current
651    /// pointer position when one was hit, otherwise `None`
652    /// (drop-zone overlays that span the window can match on
653    /// `event.target.is_none()` or filter by their own key).
654    ///
655    /// Apps use this to highlight a drop zone before the drop lands.
656    /// Always paired with either a later `FileHoverCancelled` (the
657    /// user moved off without releasing) or `FileDropped` (the user
658    /// released).
659    FileHovered,
660    /// The user moved a hovered file off the window without releasing,
661    /// or pressed Escape. Window-level event (`event.target` is
662    /// `None`) — apps clear any drop-zone affordance state regardless
663    /// of which keyed leaf was previously highlighted.
664    FileHoverCancelled,
665    /// A file was dropped on the window. `event.path` carries the
666    /// path; multi-file drops fire one event per file. `event.target`
667    /// is the keyed leaf at the drop position, or `None` if the drop
668    /// landed outside any keyed surface — apps that want a global drop
669    /// target match on `target.is_none()` or treat unrouted events as
670    /// hits to a single window-level upload sink.
671    FileDropped,
672}
673
674/// Per-frame, read-only context for [`App::build`].
675///
676/// The runner snapshots the app's [`crate::Theme`] before calling
677/// `build` and exposes it through `cx.theme()` / `cx.palette()` so app
678/// code can branch on the active palette (a custom widget that picks
679/// between two non-token colors based on dark vs. light, for instance).
680/// `BuildCx` is the explicit handle for this — token references inside
681/// widgets resolve through the palette automatically and don't need it.
682///
683/// Future fields like viewport metrics or frame phase will live here so
684/// the API stays additive: adding a new accessor on `BuildCx` doesn't
685/// break apps that ignore the context.
686#[derive(Copy, Clone, Debug)]
687pub struct BuildCx<'a> {
688    theme: &'a crate::Theme,
689    ui_state: Option<&'a crate::state::UiState>,
690    diagnostics: Option<&'a HostDiagnostics>,
691    /// Logical-pixel viewport this frame is being built for, when the
692    /// host attached one. Apps query this via [`Self::viewport`] /
693    /// [`Self::viewport_below`] to branch layout on phone-vs-desktop
694    /// without threading the surface size through their own state.
695    viewport: Option<(f32, f32)>,
696    /// Logical-pixel insets the host wants the app to inset its
697    /// layout by — content underneath these bands is obscured by
698    /// platform chrome and shouldn't host interactive widgets.
699    /// Today only the bottom inset is populated, by the web host's
700    /// VisualViewport listener when the on-screen keyboard appears;
701    /// the same field will carry status-bar / notch / home-indicator
702    /// insets when native mobile hosts land.
703    safe_area: Option<crate::tree::Sides>,
704}
705
706/// Why the current frame is being built. Hosts set this before each
707/// `request_redraw` so apps that surface a diagnostic overlay can show
708/// what kind of input is driving the redraw cadence.
709///
710/// `Other` is the conservative default: it covers redraws the host
711/// can't attribute. Specific variants narrow the reason when the
712/// host can.
713#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
714pub enum FrameTrigger {
715    /// Host can't attribute the redraw to a specific cause.
716    #[default]
717    Other,
718    /// Initial paint after surface configuration.
719    Initial,
720    /// Surface resize / DPI change.
721    Resize,
722    /// Pointer move, button, or wheel.
723    Pointer,
724    /// Keyboard / IME input.
725    Keyboard,
726    /// Inside-out animation deadline elapsed (one of the visible
727    /// widgets asked for a future frame via `redraw_within`, or a
728    /// visual animation is still settling). Drives the layout-path
729    /// (full rebuild + prepare).
730    Animation,
731    /// Time-driven shader deadline elapsed (e.g. stock spinner /
732    /// skeleton / progress-indeterminate, or a custom shader
733    /// registered with `samples_time=true`). Drives the paint-only
734    /// path: `frame.time` advances but layout state is unchanged.
735    ShaderPaint,
736    /// Periodic host-config cadence (`HostConfig::redraw_interval`).
737    Periodic,
738    /// Application code asked for a frame through the host's external
739    /// wakeup handle (push-driven event-class data — a chat message
740    /// arrived, a background task advanced state). Data changed
741    /// outside the tree, so this drives the layout path (full rebuild
742    /// + prepare), never paint-only.
743    External,
744}
745
746impl FrameTrigger {
747    /// Short, fixed-width tag for diagnostic overlays.
748    pub fn label(self) -> &'static str {
749        match self {
750            FrameTrigger::Other => "other",
751            FrameTrigger::Initial => "initial",
752            FrameTrigger::Resize => "resize",
753            FrameTrigger::Pointer => "pointer",
754            FrameTrigger::Keyboard => "keyboard",
755            FrameTrigger::Animation => "animation",
756            FrameTrigger::ShaderPaint => "shader-paint",
757            FrameTrigger::Periodic => "periodic",
758            FrameTrigger::External => "external",
759        }
760    }
761}
762
763/// Per-frame diagnostic snapshot the host hands the app via
764/// [`BuildCx::diagnostics`]. Apps that surface a debug overlay (e.g.
765/// the showcase status block) read this each build to display the
766/// active backend, frame cadence, and what triggered the redraw.
767/// Timing fields describe the last completed rendered frame, not the
768/// frame currently being built; the host cannot know current layout /
769/// paint timings until after `App::build` returns.
770///
771/// Hosts populate every field they can; `backend` is a static string
772/// (`"WebGPU"`, `"Vulkan"`, `"Metal"`, `"DX12"`, `"GL"`) so the app
773/// doesn't need to depend on `wgpu` to read it. Time fields use
774/// `std::time::Duration`, which works on both native and wasm32 — only
775/// `Instant::now()` is the wasm-incompatible piece, and that stays on
776/// the host side.
777#[derive(Clone, Debug)]
778pub struct HostDiagnostics {
779    /// Render backend in human-readable form.
780    pub backend: &'static str,
781    /// Current surface size in physical pixels.
782    pub surface_size: (u32, u32),
783    /// Display scale factor (`physical / logical`).
784    pub scale_factor: f32,
785    /// Active MSAA sample count (1 = MSAA off).
786    pub msaa_samples: u32,
787    /// Frame counter; increments every redraw the host actually
788    /// renders. Useful for verifying that an animated source is
789    /// progressing.
790    pub frame_index: u64,
791    /// Wall-clock time between this redraw and the previous one.
792    /// `Duration::ZERO` for the first frame (no prior frame).
793    pub last_frame_dt: std::time::Duration,
794    /// Time spent in the app's `build` method for the last completed
795    /// frame. `Duration::ZERO` before the first full frame and on
796    /// paint-only frames that skipped build.
797    pub last_build: std::time::Duration,
798    /// Total time spent in the backend `prepare` call for the last
799    /// completed frame.
800    pub last_prepare: std::time::Duration,
801    /// Sub-stage inside `prepare`: layout pass, focus/selection sync,
802    /// state application, and animation tick.
803    pub last_layout: std::time::Duration,
804    /// Intrinsic-measurement cache hits during the last layout pass.
805    pub last_layout_intrinsic_cache_hits: u64,
806    /// Intrinsic-measurement cache misses during the last layout pass.
807    pub last_layout_intrinsic_cache_misses: u64,
808    /// Direct scroll children whose descendants were skipped during
809    /// layout because the child was outside the scroll viewport.
810    pub last_layout_pruned_subtrees: u64,
811    /// Descendant nodes assigned zero rects as part of scroll layout
812    /// pruning during the last layout pass.
813    pub last_layout_pruned_nodes: u64,
814    /// Sub-stage inside `prepare`: laid-out tree to backend-neutral
815    /// `DrawOp` list.
816    pub last_draw_ops: std::time::Duration,
817    /// Text draw ops skipped during draw-op generation because their
818    /// glyph rect did not intersect the inherited clip.
819    pub last_draw_ops_culled_text_ops: u64,
820    /// Sub-stage inside `prepare`: paint-stream packing and text
821    /// shaping/rasterization recording.
822    pub last_paint: std::time::Duration,
823    /// Paint ops skipped because their painted rect did not intersect
824    /// the effective clip/viewport in the last completed frame.
825    pub last_paint_culled_ops: u64,
826    /// Sub-stage inside `prepare`: backend-side buffer writes, glyph
827    /// atlas uploads, and frame uniforms.
828    pub last_gpu_upload: std::time::Duration,
829    /// Sub-stage inside `prepare`: clone the laid-out tree for
830    /// next-frame hit-testing.
831    pub last_snapshot: std::time::Duration,
832    /// Time spent encoding/submitting/presenting the last completed
833    /// frame after `prepare`.
834    pub last_submit: std::time::Duration,
835    /// Layout-side text-cache hits during the last completed full
836    /// prepare.
837    pub last_text_layout_cache_hits: u64,
838    /// Layout-side text-cache misses during the last completed full
839    /// prepare.
840    pub last_text_layout_cache_misses: u64,
841    /// Estimated layout-side text-cache evictions during the last
842    /// completed full prepare.
843    pub last_text_layout_cache_evictions: u64,
844    /// Total UTF-8 bytes shaped on layout-cache misses during the last
845    /// completed full prepare.
846    pub last_text_layout_shaped_bytes: u64,
847    /// Why the host triggered this frame.
848    pub trigger: FrameTrigger,
849    /// What the renderer composites in. The paint stream converts every
850    /// [`crate::color::Color`] into this space exactly once at the
851    /// upload boundary. Defaults to [`crate::color::ColorSpace::SRGB_LINEAR`].
852    pub working_color_space: crate::color::ColorSpace,
853    /// Wire-side color-management state the host negotiated with the
854    /// display server. [`crate::color::ColorManagementStatus::Unavailable`]
855    /// on hosts without a color-management protocol (X11, plain Wayland,
856    /// macOS / Windows today). See [`crate::color::ColorPreferences`]
857    /// for how apps influence the negotiation.
858    pub color_management: crate::color::ColorManagementStatus,
859    /// Color-relevant facts about the host's GPU presentation surface —
860    /// the wgpu / WSI half of color negotiation (advertised formats,
861    /// chosen swapchain format, present/alpha mode, adapter). `None` on
862    /// hosts that don't present through a wgpu surface (headless render
863    /// bins, the vulkano demo). See [`SurfaceColorInfo`].
864    pub surface_color: Option<SurfaceColorInfo>,
865}
866
867impl Default for HostDiagnostics {
868    fn default() -> Self {
869        Self {
870            backend: "?",
871            surface_size: (0, 0),
872            scale_factor: 1.0,
873            msaa_samples: 1,
874            frame_index: 0,
875            last_frame_dt: std::time::Duration::ZERO,
876            last_build: std::time::Duration::ZERO,
877            last_prepare: std::time::Duration::ZERO,
878            last_layout: std::time::Duration::ZERO,
879            last_layout_intrinsic_cache_hits: 0,
880            last_layout_intrinsic_cache_misses: 0,
881            last_layout_pruned_subtrees: 0,
882            last_layout_pruned_nodes: 0,
883            last_draw_ops: std::time::Duration::ZERO,
884            last_draw_ops_culled_text_ops: 0,
885            last_paint: std::time::Duration::ZERO,
886            last_paint_culled_ops: 0,
887            last_gpu_upload: std::time::Duration::ZERO,
888            last_snapshot: std::time::Duration::ZERO,
889            last_submit: std::time::Duration::ZERO,
890            last_text_layout_cache_hits: 0,
891            last_text_layout_cache_misses: 0,
892            last_text_layout_cache_evictions: 0,
893            last_text_layout_shaped_bytes: 0,
894            trigger: FrameTrigger::default(),
895            working_color_space: crate::paint::DEFAULT_WORKING_COLOR_SPACE,
896            color_management: crate::color::ColorManagementStatus::default(),
897            surface_color: None,
898        }
899    }
900}
901
902impl HostDiagnostics {
903    /// Is this app actually rendering HDR right now — an extended-range
904    /// swapchain on an output with HDR evidence?
905    ///
906    /// This is the check, encoded once so apps never re-derive it:
907    /// the compositor's preferred description indicates an HDR output
908    /// ([`CompositorColorTargets::indicates_hdr`]) **and** the
909    /// negotiated swapchain format can carry extended-range output
910    /// ([`SurfaceFormatInfo::wide`], e.g. `Rgba16Float` scRGB). Do
911    /// *not* infer HDR from `ColorManagementStatus::Available {
912    /// attached }` — on every current host the WSI owns the surface
913    /// tag and `attached` stays `None` even in full HDR operation.
914    ///
915    /// Live: hosts refresh these diagnostics when the compositor's
916    /// preferred description changes (`preferred_changed2` — window
917    /// moved to another output, HDR toggled), so this flips with the
918    /// window. HDR is opt-in via [`crate::color::ColorPreferences`];
919    /// a default `sdr_only` app reports `false` even on an HDR output.
920    ///
921    /// [`CompositorColorTargets::indicates_hdr`]: crate::color::CompositorColorTargets::indicates_hdr
922    pub fn hdr_active(&self) -> bool {
923        let crate::color::ColorManagementStatus::Available { targets, .. } = &self.color_management
924        else {
925            return false;
926        };
927        targets.indicates_hdr()
928            && self.surface_color.as_ref().is_some_and(|s| {
929                s.formats
930                    .iter()
931                    .any(|f| f.wide && f.name == s.chosen_format)
932            })
933    }
934}
935
936/// Color-relevant facts about the host's GPU presentation surface — the
937/// wgpu / WSI half of color negotiation. The compositor (via
938/// [`crate::color::ColorManagementStatus`]) says what it *accepts*; this
939/// says what the *swapchain* can represent. The intersection is what the
940/// negotiator can actually pick — e.g. a compositor that ingests linear
941/// BT.2020 is moot if the surface offers no float format.
942///
943/// Strings throughout so `damascene-core` needn't depend on `wgpu`.
944#[derive(Clone, Debug, Default)]
945pub struct SurfaceColorInfo {
946    /// Adapter / device name (e.g. `"Intel Graphics (ADL GT2)"`).
947    pub adapter: String,
948    /// Driver name + version, when the backend reports it.
949    pub driver: String,
950    /// Color formats the surface advertised, in wgpu's reported order.
951    pub formats: Vec<SurfaceFormatInfo>,
952    /// The swapchain format negotiation actually chose.
953    pub chosen_format: String,
954    /// Present mode in use.
955    pub present_mode: String,
956    /// Composite alpha mode in use.
957    pub alpha_mode: String,
958}
959
960/// One surface texture format, classified by how it can carry color
961/// output. See [`SurfaceColorInfo`].
962#[derive(Clone, Debug)]
963pub struct SurfaceFormatInfo {
964    /// wgpu format name (e.g. `"Rgba16Float"`).
965    pub name: String,
966    /// Carries an sRGB EOTF in hardware (`*_unorm_srgb`): the GPU encodes
967    /// linear → sRGB on store.
968    pub srgb: bool,
969    /// Can carry wide-gamut / HDR output: a float format (linear-direct —
970    /// the compositor does the output encode) or a ≥10-bit format (a
971    /// PQ-encode target). 8-bit unorm formats are SDR-only.
972    pub wide: bool,
973}
974
975impl<'a> BuildCx<'a> {
976    /// Construct a [`BuildCx`] borrowing the supplied theme. Hosts call
977    /// this once per frame after [`App::theme`] and before [`App::build`].
978    /// Hosts that own a [`crate::state::UiState`] should chain
979    /// [`Self::with_ui_state`] so the app can read interaction state
980    /// (hover) during build via [`Self::hovered_key`] /
981    /// [`Self::is_hovering_within`].
982    pub fn new(theme: &'a crate::Theme) -> Self {
983        Self {
984            theme,
985            ui_state: None,
986            diagnostics: None,
987            viewport: None,
988            safe_area: None,
989        }
990    }
991
992    /// Attach the runtime's [`crate::state::UiState`] so build-time
993    /// accessors (`hovered_key`, `is_hovering_within`) can answer.
994    /// When omitted, those accessors return `None` / `false` — useful
995    /// for headless rendering paths that don't track interaction
996    /// state.
997    pub fn with_ui_state(mut self, ui_state: &'a crate::state::UiState) -> Self {
998        self.ui_state = Some(ui_state);
999        self
1000    }
1001
1002    /// Attach a [`HostDiagnostics`] snapshot for this frame. Hosts call
1003    /// this when they want apps to surface debug overlays (e.g. the
1004    /// showcase status block); apps that don't read `diagnostics()`
1005    /// pay nothing for it. Headless render paths leave it `None`.
1006    pub fn with_diagnostics(mut self, diagnostics: &'a HostDiagnostics) -> Self {
1007        self.diagnostics = Some(diagnostics);
1008        self
1009    }
1010
1011    /// Attach the logical-pixel viewport size for this frame. Hosts
1012    /// chain this so apps can branch on viewport metrics during build
1013    /// (responsive layout, phone-vs-desktop splits) without threading
1014    /// surface size through their own state. Headless render paths
1015    /// without a meaningful viewport leave it unset.
1016    pub fn with_viewport(mut self, width: f32, height: f32) -> Self {
1017        self.viewport = Some((width, height));
1018        self
1019    }
1020
1021    /// Attach the host's reported safe-area insets in logical pixels.
1022    /// Hosts chain this when platform chrome (on-screen keyboard,
1023    /// notch, status bar, home indicator) is obscuring some band of
1024    /// the viewport. Apps read it via [`Self::safe_area`] /
1025    /// [`Self::safe_area_bottom`] and inset their interactive content
1026    /// accordingly. Hosts that don't report safe-area metrics omit
1027    /// this; apps see `Sides::zero()` from the read accessors.
1028    pub fn with_safe_area(mut self, sides: crate::tree::Sides) -> Self {
1029        self.safe_area = Some(sides);
1030        self
1031    }
1032
1033    /// Per-frame diagnostic snapshot from the host (backend, frame
1034    /// cadence, trigger reason, etc.), or `None` when the host did
1035    /// not attach one. Apps display this in optional debug overlays.
1036    pub fn diagnostics(&self) -> Option<&HostDiagnostics> {
1037        self.diagnostics
1038    }
1039
1040    /// The active runtime theme for this frame.
1041    pub fn theme(&self) -> &crate::Theme {
1042        self.theme
1043    }
1044
1045    /// Shorthand for `self.theme().palette()`.
1046    pub fn palette(&self) -> &crate::Palette {
1047        self.theme.palette()
1048    }
1049
1050    /// Logical-pixel viewport `(width, height)` the host attached for
1051    /// this frame, or `None` for headless render paths. Apps use this
1052    /// to branch layout on viewport metrics — see [`Self::viewport_below`]
1053    /// for the common phone-vs-desktop breakpoint case.
1054    pub fn viewport(&self) -> Option<(f32, f32)> {
1055        self.viewport
1056    }
1057
1058    /// Logical-pixel viewport width the host attached for this frame,
1059    /// or `None` when no viewport is available. Convenience for the
1060    /// common single-axis branch (`cx.viewport_width().map_or(false,
1061    /// |w| w < 600.0)`).
1062    pub fn viewport_width(&self) -> Option<f32> {
1063        self.viewport.map(|(w, _)| w)
1064    }
1065
1066    /// Logical-pixel viewport height the host attached for this frame,
1067    /// or `None` when no viewport is available.
1068    pub fn viewport_height(&self) -> Option<f32> {
1069        self.viewport.map(|(_, h)| h)
1070    }
1071
1072    /// True iff the attached viewport's width is strictly less than
1073    /// `threshold` logical pixels. Returns `false` when no viewport is
1074    /// attached so headless / desktop-default paths fall through to
1075    /// the wider branch — apps that want the opposite default can
1076    /// match on [`Self::viewport_width`] directly.
1077    ///
1078    /// Use for the common breakpoint split:
1079    /// ```ignore
1080    /// if cx.viewport_below(600.0) {
1081    ///     phone_layout()
1082    /// } else {
1083    ///     desktop_layout()
1084    /// }
1085    /// ```
1086    pub fn viewport_below(&self, threshold: f32) -> bool {
1087        self.viewport_width().is_some_and(|w| w < threshold)
1088    }
1089
1090    /// Logical-pixel safe-area insets the host reports for this frame
1091    /// (`Sides::zero()` when nothing was attached). Today this is
1092    /// populated only by damascene-web when the on-screen keyboard
1093    /// shrinks the visual viewport — `bottom` carries the keyboard
1094    /// height; future native mobile hosts will additionally populate
1095    /// `top` for status-bar / notch and `bottom` for home-indicator.
1096    ///
1097    /// Apps inset their root layout (or just the focused-input
1098    /// region) by these amounts so interactive content doesn't sit
1099    /// underneath platform chrome. The runtime does not auto-apply
1100    /// this — apps decide where the inset matters.
1101    pub fn safe_area(&self) -> crate::tree::Sides {
1102        self.safe_area.unwrap_or_default()
1103    }
1104
1105    /// Convenience: just the bottom inset, in logical pixels. Most
1106    /// commonly the soft-keyboard height.
1107    pub fn safe_area_bottom(&self) -> f32 {
1108        self.safe_area().bottom
1109    }
1110
1111    /// Key of the leaf node currently under the pointer, or `None`
1112    /// when nothing is hovered or this `BuildCx` was built without a
1113    /// `UiState` (headless rendering paths).
1114    ///
1115    /// Use for branching the build output on hover state without
1116    /// mirroring it via `App::on_event` handlers — e.g., a sidebar
1117    /// row that previews details in a side pane based on what's
1118    /// currently hovered.
1119    ///
1120    /// For region-aware queries (parent stays "hot" while a child is
1121    /// hovered), prefer [`Self::is_hovering_within`].
1122    pub fn hovered_key(&self) -> Option<&str> {
1123        self.ui_state?.hovered_key()
1124    }
1125
1126    /// True iff `key`'s node — or any descendant of it — is the
1127    /// current hover target. Subtree-aware, matching the semantics of
1128    /// [`crate::tree::El::hover_alpha`]. Returns `false` when this
1129    /// `BuildCx` has no attached `UiState` or when `key` isn't in the
1130    /// current tree.
1131    ///
1132    /// Reads the underlying tracker, not the eased subtree envelope —
1133    /// the boolean flips immediately on hit-test identity change.
1134    pub fn is_hovering_within(&self, key: &str) -> bool {
1135        self.ui_state
1136            .is_some_and(|state| state.is_hovering_within(key))
1137    }
1138
1139    /// The scatter point currently under the cursor in a `chart3d` scene, if
1140    /// any — the 3D analogue of [`hovered_key`](Self::hovered_key).
1141    ///
1142    /// Scene points aren't `El`s, so they can't emit `PointerEnter`/`Leave`
1143    /// like 2D widgets; this surfaces the same hover pick that draws the
1144    /// built-in tooltip chip ([`ScenePointPick`] carries the scene id + mark +
1145    /// point index). Use it to drive a detail panel / highlight / linked view
1146    /// on hover — branch the build on `cx.hovered_scene_point()` without an
1147    /// `on_event` handler. Picked a frame late (fine for hover UI) and honours
1148    /// the chip's depth-occlusion + behind-camera culling.
1149    ///
1150    /// [`ScenePointPick`]: crate::scene::ScenePointPick
1151    pub fn hovered_scene_point(&self) -> Option<&crate::scene::ScenePointPick> {
1152        self.ui_state?.hovered_scene_point()
1153    }
1154
1155    /// The laid-out rect of the keyed node `key` from the *previous*
1156    /// frame's layout, or `None` when the key wasn't in that tree (or
1157    /// this `BuildCx` has no attached `UiState` — headless paths).
1158    ///
1159    /// The damascene analogue of the DOM's
1160    /// `getBoundingClientRect()`: layout geometry is retained between
1161    /// frames, so build code can read where a keyed thing actually
1162    /// landed. One frame stale by construction — `build` runs before
1163    /// this frame's layout — which is fine for the usual uses
1164    /// (branching on a measured-once size, sizing a dependent pane).
1165    /// Same staleness contract as [`Self::hovered_key`]. For
1166    /// event-time decisions prefer [`EventCx::rect_of_key`], which
1167    /// answers at the moment the handler runs.
1168    pub fn rect_of_key(&self, key: &str) -> Option<Rect> {
1169        self.ui_state?.rect_of_key(key)
1170    }
1171}
1172
1173/// Read-only context passed to [`App::on_event`] /
1174/// [`App::on_wheel_event`].
1175///
1176/// Event handlers regularly need post-layout geometry to make a
1177/// decision — "which room row is under this drop?", "what size did
1178/// the lightbox body actually get?" — and the handler's state owns no
1179/// node, so it can't have carried the rect itself. `EventCx` is the
1180/// damascene analogue of the DOM's ambient `document`: a handle into
1181/// the retained layout the user is currently looking at, queryable by
1182/// key (`element.getBoundingClientRect()` shape). Geometry answers
1183/// from the last laid-out frame — exactly what's on screen when the
1184/// event fires.
1185///
1186/// Like [`BuildCx`], the struct is opaque so the API stays additive:
1187/// new accessors don't break apps that ignore the context.
1188#[derive(Copy, Clone, Debug, Default)]
1189pub struct EventCx<'a> {
1190    ui_state: Option<&'a crate::state::UiState>,
1191}
1192
1193impl<'a> EventCx<'a> {
1194    /// Construct an empty context. Headless tests that drive
1195    /// [`App::on_event`] directly use this; real hosts chain
1196    /// [`Self::with_ui_state`] so geometry queries can answer.
1197    pub fn new() -> Self {
1198        Self::default()
1199    }
1200
1201    /// Attach the runtime's [`crate::state::UiState`] so geometry
1202    /// accessors can answer. Hosts call this at every dispatch site;
1203    /// when omitted, the accessors return `None`.
1204    pub fn with_ui_state(mut self, ui_state: &'a crate::state::UiState) -> Self {
1205        self.ui_state = Some(ui_state);
1206        self
1207    }
1208
1209    /// The laid-out rect of the keyed node `key`, from the layout the
1210    /// user is looking at as this event fires. `None` when the key is
1211    /// absent from that tree (or no `UiState` is attached).
1212    ///
1213    /// This is the first-class shape for "the handler needs to know
1214    /// where a keyed thing landed": resolving a drop target against
1215    /// row rects on `PointerUp`, stepping zoom from a body's fitted
1216    /// size, anchoring app-drawn chrome to a control. The event's own
1217    /// target rect is already on [`UiEvent::target`]; this answers
1218    /// for *other* keys.
1219    pub fn rect_of_key(&self, key: &str) -> Option<Rect> {
1220        self.ui_state?.rect_of_key(key)
1221    }
1222}
1223
1224/// The application contract. Implement this on your state struct and
1225/// pass it to a host runner (e.g., `damascene_winit_wgpu::run`).
1226pub trait App {
1227    /// Refresh app-owned external state immediately before a frame is
1228    /// built.
1229    ///
1230    /// Hosts call this once per redraw before [`Self::build`]. Use it
1231    /// for polling an external source, reconciling optimistic local
1232    /// state with a backend snapshot, or advancing host-owned live data
1233    /// that should be visible in the next tree. Keep expensive work
1234    /// outside the render loop; this hook is still on the frame path.
1235    ///
1236    /// Default: no-op.
1237    fn before_build(&mut self) {}
1238
1239    /// Project current state into a scene tree. Called whenever the
1240    /// host requests a redraw, after [`Self::before_build`]. Prefer to
1241    /// keep this pure: read current state and return a fresh tree.
1242    ///
1243    /// `cx` carries per-frame, read-only context (active theme, future
1244    /// viewport / phase metadata). Apps that don't need to branch on
1245    /// the theme during construction can ignore the parameter — token
1246    /// references in widget code resolve through the palette
1247    /// automatically.
1248    ///
1249    /// # Page anatomy
1250    ///
1251    /// The returned tree is the *whole window*, and a bare
1252    /// `column([...])` root is almost never what a window wants: it
1253    /// has no padding (content sits flush against window edges and
1254    /// clips under rounded window corners) and no overlay root for
1255    /// `.tooltip()` layers to mount on. Return
1256    /// [`page`](crate::widgets::page::page) — it bakes the window
1257    /// padding + overlay root — and wrap it in
1258    /// [`overlays`](crate::overlays) when the app drives modals or
1259    /// dropdowns:
1260    ///
1261    /// ```ignore
1262    /// fn build(&self, _cx: &BuildCx) -> El {
1263    ///     overlays(
1264    ///         page([toolbar([...]), content()]),
1265    ///         [self.modal_open.then(|| modal("confirm", "Sure?", [...]))],
1266    ///     )
1267    /// }
1268    /// ```
1269    ///
1270    /// For custom anatomy (full-bleed canvases, centered Hug-sized
1271    /// cards), compose `stack([background, content])` by hand — see
1272    /// `damascene-fixtures/src/hero.rs` for the expanded idiom.
1273    fn build(&self, cx: &BuildCx) -> El;
1274
1275    /// Update state in response to a routed event. Default: no-op.
1276    ///
1277    /// `cx` carries read-only frame context — most usefully
1278    /// [`EventCx::rect_of_key`], for decisions that depend on where a
1279    /// keyed node landed in the layout the user is looking at
1280    /// (resolving a drop target on `PointerUp`, stepping zoom from a
1281    /// measured size). Handlers that don't consult layout ignore it.
1282    fn on_event(&mut self, _event: UiEvent, _cx: &EventCx) {}
1283
1284    /// Update state in response to routed wheel input.
1285    ///
1286    /// Return `true` to consume the wheel and suppress Damascene's default
1287    /// scroll routing. The default forwards to [`Self::on_event`] and
1288    /// returns `false`, so existing apps can observe wheel events
1289    /// without opting out of normal scrolling.
1290    fn on_wheel_event(&mut self, event: UiEvent, cx: &EventCx) -> bool {
1291        self.on_event(event, cx);
1292        false
1293    }
1294
1295    /// The application's current text [`crate::selection::Selection`].
1296    /// Read by the host once per frame so the library can paint
1297    /// highlight bands and resolve `selected_text` for clipboard.
1298    /// Apps that own a `Selection` field return a clone here; the
1299    /// default returns the empty selection.
1300    fn selection(&self) -> crate::selection::Selection {
1301        crate::selection::Selection::default()
1302    }
1303
1304    /// App-level hotkey registry. The library matches incoming key
1305    /// presses against this list before its own focus-activation
1306    /// routing; a match emits a [`UiEvent`] with `kind =
1307    /// UiEventKind::Hotkey` and `key = Some(name)`.
1308    ///
1309    /// Called once per build cycle; the host runner snapshots the list
1310    /// alongside `build()` so the chords stay in sync with state.
1311    /// Default: no hotkeys.
1312    fn hotkeys(&self) -> Vec<(KeyChord, String)> {
1313        Vec::new()
1314    }
1315
1316    /// Drain pending toast notifications produced since the last
1317    /// frame. The runtime calls this once per `prepare_layout`,
1318    /// stamps each spec with a monotonic id and `expires_at = now +
1319    /// ttl`, queues it in the runtime toast state, and
1320    /// synthesizes a `toast_stack` layer at the El root so the
1321    /// rendered tree mirrors the visible state. Apps typically
1322    /// accumulate specs in a `Vec<ToastSpec>` field from event
1323    /// handlers, then `mem::take` it here.
1324    ///
1325    /// **Root requirement:** apps that produce toasts (or use
1326    /// `.tooltip(text)` on any node) must wrap their
1327    /// [`Self::build`] return value in `overlays(main, [])` so the
1328    /// runtime can append the floating layer as an overlay sibling
1329    /// — same convention used for popovers and modals. Debug
1330    /// builds panic if the synthesizer runs against a non-overlay
1331    /// root.
1332    ///
1333    /// Default: no toasts.
1334    fn drain_toasts(&mut self) -> Vec<crate::toast::ToastSpec> {
1335        Vec::new()
1336    }
1337
1338    /// Drain pending programmatic focus requests produced since the
1339    /// last frame. The runtime calls this once per `prepare_layout`,
1340    /// after the focus order has been rebuilt from the new tree, and
1341    /// resolves each entry against the keyed focusables. Unmatched
1342    /// keys (widget absent from the rebuilt tree, or not focusable)
1343    /// are dropped silently.
1344    ///
1345    /// This is the imperative companion to keyboard `Tab` traversal:
1346    /// use it for affordances like *Ctrl+F → focus the search input*,
1347    /// *jump-to-match → focus the matched row*, or *open inline edit
1348    /// → focus the field*. Apps typically accumulate keys in a
1349    /// `Vec<String>` field from event handlers and `mem::take` it
1350    /// here.
1351    ///
1352    /// Multiple requests in one frame resolve in order; the last
1353    /// successfully-resolved key is the one focused.
1354    ///
1355    /// Default: no requests.
1356    fn drain_focus_requests(&mut self) -> Vec<String> {
1357        Vec::new()
1358    }
1359
1360    /// Drain pending programmatic scroll requests. The runtime
1361    /// resolves each request during layout, using live viewport rects
1362    /// and row-height/content geometry that apps should not duplicate.
1363    /// Unmatched keys and out-of-range row indices drop silently.
1364    ///
1365    /// Use [`crate::scroll::ScrollRequest::ToRow`] for virtual-list
1366    /// affordances such as jump-to-search-result, reveal selected row,
1367    /// or scroll-to-top-on-tab-change. Use
1368    /// [`crate::scroll::ScrollRequest::EnsureVisible`] for widgets
1369    /// with an internal scroll viewport, including fixed-height
1370    /// [`crate::widgets::text_area`] caret-into-view after accepted
1371    /// edit/navigation events. Apps typically accumulate requests in a
1372    /// `Vec<ScrollRequest>` field from event handlers and
1373    /// `mem::take` it here.
1374    ///
1375    /// Default: no requests.
1376    fn drain_scroll_requests(&mut self) -> Vec<crate::scroll::ScrollRequest> {
1377        Vec::new()
1378    }
1379
1380    /// Drain pending URL-open requests produced since the last frame.
1381    /// Hosts call this once per frame and route each URL to a
1382    /// platform-appropriate opener — `window.open` in the wasm host,
1383    /// the `open` crate (or equivalent) on native.
1384    ///
1385    /// The library emits [`UiEventKind::LinkActivated`] when a click
1386    /// lands on a text run carrying a link URL, but it does not act
1387    /// on the URL itself: opening a link is an app concern (apps may
1388    /// want to confirm, filter by scheme, route through an internal
1389    /// router, or no-op entirely). Apps that want the default
1390    /// browser-style behavior accumulate URLs from
1391    /// [`UiEventKind::LinkActivated`] in their `on_event` handler and
1392    /// return them here; apps that don't override this method drop
1393    /// link clicks on the floor.
1394    ///
1395    /// Default: no requests.
1396    fn drain_link_opens(&mut self) -> Vec<String> {
1397        Vec::new()
1398    }
1399
1400    /// Custom shaders this app needs registered. Each entry carries
1401    /// the shader name, its WGSL source, and per-flag opt-ins
1402    /// (backdrop sampling, time-driven motion). The host runner
1403    /// registers them once at startup via
1404    /// `Runner::register_shader_with(name, wgsl, samples_backdrop, samples_time)`.
1405    ///
1406    /// Backends that don't support backdrop sampling skip entries with
1407    /// `samples_backdrop=true`; any node bound to such a shader will
1408    /// draw nothing on those backends rather than mis-render.
1409    /// `samples_time=true` declares that the shader's output depends
1410    /// on `frame.time`, which keeps the host idle loop ticking while
1411    /// any node is bound to it.
1412    ///
1413    /// Default: no shaders.
1414    fn shaders(&self) -> Vec<AppShader> {
1415        Vec::new()
1416    }
1417
1418    /// Runtime paint theme for this app. Hosts apply it to the renderer
1419    /// before preparing each frame so stateful apps can switch global
1420    /// material routing without backend-specific calls.
1421    fn theme(&self) -> crate::Theme {
1422        crate::Theme::default()
1423    }
1424}
1425
1426/// One custom shader registration, returned from [`App::shaders`].
1427#[derive(Clone, Copy, Debug)]
1428pub struct AppShader {
1429    pub name: &'static str,
1430    pub wgsl: &'static str,
1431    /// Reads the prior pass's color target (`@group(2) backdrop_tex`).
1432    /// Backends without backdrop support skip these.
1433    pub samples_backdrop: bool,
1434    /// Reads `frame.time` and so requires continuous redraw whenever
1435    /// any node is bound to it. The runtime ORs this into
1436    /// `PrepareResult::needs_redraw` per frame.
1437    pub samples_time: bool,
1438}
1439
1440#[cfg(test)]
1441mod tests {
1442    use super::*;
1443    use crate::Theme;
1444
1445    #[test]
1446    fn viewport_unset_returns_none_and_breakpoint_returns_false() {
1447        let theme = Theme::default();
1448        let cx = BuildCx::new(&theme);
1449        assert!(cx.viewport().is_none());
1450        assert!(cx.viewport_width().is_none());
1451        assert!(!cx.viewport_below(600.0));
1452    }
1453
1454    #[test]
1455    fn build_cx_surfaces_hovered_scene_point() {
1456        use crate::scene::ScenePointPick;
1457        let theme = Theme::default();
1458        let mut ui = crate::state::UiState::new();
1459
1460        // No attached state, and none stored → nothing to surface.
1461        assert!(BuildCx::new(&theme).hovered_scene_point().is_none());
1462        assert!(
1463            BuildCx::new(&theme)
1464                .with_ui_state(&ui)
1465                .hovered_scene_point()
1466                .is_none()
1467        );
1468
1469        // After the runtime stores a pick, the app reads it at build.
1470        ui.set_hovered_scene_point(Some(ScenePointPick {
1471            scene: "scene".into(),
1472            mark: 0,
1473            point: 4,
1474        }));
1475        let cx = BuildCx::new(&theme).with_ui_state(&ui);
1476        let pick = cx.hovered_scene_point().expect("pick surfaced");
1477        assert_eq!(
1478            (pick.scene.as_str(), pick.mark, pick.point),
1479            ("scene", 0, 4)
1480        );
1481    }
1482
1483    #[test]
1484    fn viewport_set_exposes_width_and_height() {
1485        let theme = Theme::default();
1486        let cx = BuildCx::new(&theme).with_viewport(420.0, 800.0);
1487        assert_eq!(cx.viewport(), Some((420.0, 800.0)));
1488        assert_eq!(cx.viewport_width(), Some(420.0));
1489        assert_eq!(cx.viewport_height(), Some(800.0));
1490    }
1491
1492    #[test]
1493    fn hdr_active_needs_output_evidence_and_wide_chosen_format() {
1494        use crate::color::{ColorManagementStatus, CompositorColorTargets, TransferFunction};
1495
1496        let hdr_targets = CompositorColorTargets {
1497            preferred_transfer: Some(TransferFunction::Pq),
1498            ..Default::default()
1499        };
1500        let scrgb_surface = SurfaceColorInfo {
1501            formats: vec![
1502                SurfaceFormatInfo {
1503                    name: "Bgra8UnormSrgb".into(),
1504                    srgb: true,
1505                    wide: false,
1506                },
1507                SurfaceFormatInfo {
1508                    name: "Rgba16Float".into(),
1509                    srgb: false,
1510                    wide: true,
1511                },
1512            ],
1513            chosen_format: "Rgba16Float".into(),
1514            ..Default::default()
1515        };
1516
1517        let mut d = HostDiagnostics::default();
1518        // Default: no protocol, no surface info.
1519        assert!(!d.hdr_active());
1520
1521        // HDR output + scRGB swapchain → active. `attached` stays None
1522        // on the no-attach host — it must not factor in.
1523        d.color_management = ColorManagementStatus::Available {
1524            capabilities: Default::default(),
1525            attached: None,
1526            targets: hdr_targets.clone(),
1527        };
1528        d.surface_color = Some(scrgb_surface.clone());
1529        assert!(d.hdr_active());
1530
1531        // HDR output but the negotiator stayed on 8-bit sRGB (e.g. the
1532        // app is sdr_only) → not active.
1533        d.surface_color = Some(SurfaceColorInfo {
1534            chosen_format: "Bgra8UnormSrgb".into(),
1535            ..scrgb_surface.clone()
1536        });
1537        assert!(!d.hdr_active());
1538
1539        // Wide swapchain but no HDR evidence from the output → not active.
1540        d.color_management = ColorManagementStatus::Available {
1541            capabilities: Default::default(),
1542            attached: None,
1543            targets: CompositorColorTargets::default(),
1544        };
1545        d.surface_color = Some(scrgb_surface);
1546        assert!(!d.hdr_active());
1547    }
1548
1549    #[test]
1550    fn viewport_below_uses_strict_less_than() {
1551        let theme = Theme::default();
1552        let cx = BuildCx::new(&theme).with_viewport(600.0, 800.0);
1553        assert!(!cx.viewport_below(600.0), "boundary is exclusive");
1554        assert!(cx.viewport_below(601.0));
1555        assert!(!cx.viewport_below(599.0));
1556    }
1557}