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
51// Lock in full per-item documentation for this module (issue #73).
52#![warn(missing_docs)]
53
54use crate::tree::{El, Rect};
55
56/// Hit-test target metadata. `key` is the author-facing route, while
57/// `node_id` is the stable laid-out tree path used by artifacts.
58///
59/// `tooltip` snapshots the node's tooltip text at the moment the
60/// target was constructed, so the tooltip pass doesn't have to walk
61/// the live tree to resolve it. This is what makes tooltips work on
62/// virtual-list rows: hit-testing reads `last_tree` (where the row
63/// has been realized), and the cached text survives into the next
64/// frame's `synthesize_tooltip` even though that frame's tree hasn't
65/// rebuilt its virtual-list children yet.
66#[derive(Clone, Debug, PartialEq)]
67#[non_exhaustive]
68pub struct UiTarget {
69    /// The [`El::key`][method@El::key] route string of the hit node —
70    /// what app code matches events against.
71    pub key: String,
72    /// Stable laid-out tree path of the node, used by artifacts and
73    /// tooling that must survive key renames.
74    pub node_id: String,
75    /// The node's laid-out rect in logical pixels, from the layout
76    /// pass this target was hit-tested against.
77    pub rect: Rect,
78    /// Tooltip text snapshotted from the node when the target was
79    /// constructed (see the struct docs for why it's cached).
80    pub tooltip: Option<String>,
81    /// Scroll offset of the deepest scroll subtree inside this hit
82    /// target, in logical pixels. `0.0` for widgets that don't
83    /// contain a scroll. Used by widgets like
84    /// [`crate::widgets::text_area`] to convert a pointer in viewport
85    /// space (what the user clicks) into content space (what
86    /// cosmic-text's `hit_byte` and `caret_xy` work in) — without
87    /// this, clicks after scrolling land on the wrong line because
88    /// the content has been shifted up by `scroll_offset_y` while
89    /// the outer's `rect` hasn't moved.
90    pub scroll_offset_y: f32,
91}
92
93/// Which mouse button (or pointer button) generated a pointer event.
94/// The host backend translates its native button id to one of these
95/// before calling `pointer_down` / `pointer_up`.
96#[derive(Clone, Copy, Debug, PartialEq, Eq)]
97pub enum PointerButton {
98    /// Left mouse, primary touch, or pen tip. Drives `Click`.
99    Primary,
100    /// Right mouse or two-finger touch. Drives `SecondaryClick` —
101    /// typically opens a context menu.
102    Secondary,
103    /// Middle mouse / scroll-wheel click. No library default; surfaced
104    /// as `MiddleClick` for apps that want it (autoscroll, paste-on-X).
105    Middle,
106}
107
108impl PointerButton {
109    /// Translate a Linux evdev button code (the `button` field of
110    /// `wl_pointer.button`, `<linux/input-event-codes.h>`'s `BTN_*`)
111    /// to a damascene button: `BTN_LEFT` (0x110) → `Primary`,
112    /// `BTN_RIGHT` (0x111) → `Secondary`, `BTN_MIDDLE` (0x112) →
113    /// `Middle`. Side/extra/task buttons return `None` — not surfaced
114    /// today.
115    ///
116    /// For raw Wayland hosts (layer-shell bars, notification daemons)
117    /// that read pointer buttons off the wire without winit in the
118    /// loop.
119    pub const fn from_linux_button(code: u32) -> Option<Self> {
120        match code {
121            0x110 => Some(Self::Primary),
122            0x111 => Some(Self::Secondary),
123            0x112 => Some(Self::Middle),
124            _ => None,
125        }
126    }
127}
128
129/// Physical kind of pointer that produced an event. Mirrors the DOM
130/// `PointerEvent.pointerType`. Backends without a real signal pass
131/// [`PointerKind::Mouse`].
132///
133/// The runtime uses this to specialize behavior that does not transfer
134/// across modalities — for example, `Touch` has no resting hover state
135/// and gates `PointerEnter`/`PointerLeave` accordingly.
136#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
137pub enum PointerKind {
138    /// Mouse, trackpad, or any device that reports continuous hover.
139    #[default]
140    Mouse,
141    /// Touchscreen. No hover state; contact starts with `pointer_down`.
142    Touch,
143    /// Pen / stylus. Behaves like `Mouse` for hover, but backends may
144    /// surface pressure in [`Pointer::pressure`].
145    Pen,
146}
147
148/// Stable per-pointer identifier within a frame. Mirrors the DOM
149/// `PointerEvent.pointerId`. Backends with only one pointer pass
150/// [`PointerId::PRIMARY`]; multi-touch backends keep IDs stable for the
151/// lifetime of a single contact.
152///
153/// The runtime currently routes only the primary contact; secondary IDs
154/// are reserved for future multi-touch / gesture work.
155#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)]
156pub struct PointerId(pub u32);
157
158impl PointerId {
159    /// The conventional ID for backends that have only one pointer
160    /// (mouse-only hosts, synthetic test events, the first touch
161    /// contact when multi-touch IDs are not tracked).
162    pub const PRIMARY: PointerId = PointerId(0);
163}
164
165/// One pointer sample, in logical pixels. The argument shape for
166/// [`crate::runtime::RunnerCore::pointer_moved`],
167/// [`crate::runtime::RunnerCore::pointer_down`], and
168/// [`crate::runtime::RunnerCore::pointer_up`].
169///
170/// Modeled on the DOM `PointerEvent` interface so backends that
171/// already speak browser pointer events can map fields directly.
172/// `button` is meaningful on `pointer_down` / `pointer_up` and is
173/// ignored on `pointer_moved`; constructors default it to
174/// [`PointerButton::Primary`] for that case.
175#[derive(Clone, Copy, Debug, PartialEq)]
176pub struct Pointer {
177    /// X coordinate in logical pixels relative to the window origin.
178    pub x: f32,
179    /// Y coordinate in logical pixels relative to the window origin.
180    pub y: f32,
181    /// Which button this event refers to. Ignored by `pointer_moved`.
182    pub button: PointerButton,
183    /// Physical kind of pointer (mouse / touch / pen).
184    pub kind: PointerKind,
185    /// Stable per-pointer ID. Use [`PointerId::PRIMARY`] for
186    /// single-pointer backends.
187    pub id: PointerId,
188    /// Normalized pressure in `0.0..=1.0` when the device reports it
189    /// (pen, force-touch). `None` when unavailable; mouse backends
190    /// always pass `None`.
191    pub pressure: Option<f32>,
192}
193
194impl Pointer {
195    /// A mouse-driven pointer at `(x, y)` for the given button. Use
196    /// from mouse-only hosts and synthetic tests.
197    pub fn mouse(x: f32, y: f32, button: PointerButton) -> Self {
198        Self {
199            x,
200            y,
201            button,
202            kind: PointerKind::Mouse,
203            id: PointerId::PRIMARY,
204            pressure: None,
205        }
206    }
207
208    /// A mouse pointer for `pointer_moved`, where `button` is
209    /// irrelevant. Equivalent to
210    /// [`Pointer::mouse(x, y, PointerButton::Primary)`][Self::mouse].
211    pub fn moving(x: f32, y: f32) -> Self {
212        Self::mouse(x, y, PointerButton::Primary)
213    }
214
215    /// A touch contact at `(x, y)` carrying the given pointer ID.
216    /// Backends translating browser `PointerEvent` should pass the
217    /// browser's `pointerId` directly.
218    pub fn touch(x: f32, y: f32, button: PointerButton, id: PointerId) -> Self {
219        Self {
220            x,
221            y,
222            button,
223            kind: PointerKind::Touch,
224            id,
225            pressure: None,
226        }
227    }
228}
229
230/// Keyboard key values normalized by the core library. This keeps the
231/// core independent from host/windowing crates while covering the
232/// navigation and activation keys the library owns.
233#[derive(Clone, Debug, PartialEq, Eq)]
234pub enum UiKey {
235    /// Enter / Return — activates the focused element.
236    Enter,
237    /// Escape — dismiss; drives [`UiEventKind::Escape`] routing.
238    Escape,
239    /// Tab — focus traversal (Shift+Tab traverses backwards).
240    Tab,
241    /// Space bar — activates the focused element, like `Enter`.
242    Space,
243    /// Up arrow — directional navigation (lists, sliders, roving groups).
244    ArrowUp,
245    /// Down arrow — directional navigation (lists, sliders, roving groups).
246    ArrowDown,
247    /// Left arrow — directional navigation / caret movement.
248    ArrowLeft,
249    /// Right arrow — directional navigation / caret movement.
250    ArrowRight,
251    /// Backspace — deletes the grapheme before the caret.
252    Backspace,
253    /// Forward delete — deletes the grapheme after the caret.
254    Delete,
255    /// Home — caret to start of line.
256    Home,
257    /// End — caret to end of line.
258    End,
259    /// PageUp — coarse-step navigation (sliders adjust by a larger
260    /// amount; lists scroll a viewport).
261    PageUp,
262    /// PageDown — coarse-step navigation (sliders adjust by a larger
263    /// amount; lists scroll a viewport).
264    PageDown,
265    /// A character-producing key, carrying its text. Hotkey matching
266    /// compares ASCII case-insensitively, so `Character("f")` and
267    /// `Character("F")` match the same chord.
268    Character(String),
269    /// Any key not normalized above, carrying the host's name for it
270    /// (the winit-based hosts pass the named-key debug name, e.g.
271    /// `"F1"`, `"Shift"`).
272    Other(String),
273}
274
275/// OS modifier-key mask. The four fields mirror the platform-standard
276/// modifier set; this struct is intentionally **not** `#[non_exhaustive]`
277/// so callers can use struct-literal syntax with `..Default::default()`
278/// to spell precise modifier combinations.
279#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
280pub struct KeyModifiers {
281    /// Shift key held.
282    pub shift: bool,
283    /// Control key held.
284    pub ctrl: bool,
285    /// Alt / Option key held.
286    pub alt: bool,
287    /// Logo key held — Super / Windows key / Command.
288    pub logo: bool,
289}
290
291/// One keyboard key-down, as delivered on [`UiEvent::key_press`].
292/// Hosts feed the constituent parts through
293/// [`crate::runtime::RunnerCore::key_down`]; the runtime packages them
294/// into this struct on the events it emits.
295#[derive(Clone, Debug, PartialEq, Eq)]
296#[non_exhaustive]
297pub struct KeyPress {
298    /// The normalized key that went down.
299    pub key: UiKey,
300    /// Modifier mask at the moment of the press.
301    pub modifiers: KeyModifiers,
302    /// True when this press is an OS auto-repeat of a held key rather
303    /// than a fresh key-down.
304    pub repeat: bool,
305}
306
307/// A keyboard chord for app-level hotkey registration. Match a key with
308/// an exact modifier mask: `KeyChord::ctrl('f')` does not also match
309/// `Ctrl+Shift+F`, and `KeyChord::vim('j')` does not match if any
310/// modifier is held.
311///
312/// Register chords from [`App::hotkeys`]; the library matches them
313/// against incoming key presses ahead of focus activation routing and
314/// emits a [`UiEvent`] with `kind = UiEventKind::Hotkey` and `key`
315/// equal to the registered name.
316#[derive(Clone, Debug, PartialEq, Eq)]
317#[non_exhaustive]
318pub struct KeyChord {
319    /// The key the chord matches.
320    pub key: UiKey,
321    /// Exact modifier mask that must be held — extra modifiers do not
322    /// match (see [`Self::matches`]).
323    pub modifiers: KeyModifiers,
324}
325
326impl KeyChord {
327    /// A bare key with no modifiers (vim-style). `KeyChord::vim('j')`
328    /// matches the `j` key with no Ctrl/Shift/Alt/Logo held.
329    pub fn vim(c: char) -> Self {
330        Self {
331            key: UiKey::Character(c.to_string()),
332            modifiers: KeyModifiers::default(),
333        }
334    }
335
336    /// `Ctrl+<char>`.
337    pub fn ctrl(c: char) -> Self {
338        Self {
339            key: UiKey::Character(c.to_string()),
340            modifiers: KeyModifiers {
341                ctrl: true,
342                ..Default::default()
343            },
344        }
345    }
346
347    /// `Ctrl+Shift+<char>`.
348    pub fn ctrl_shift(c: char) -> Self {
349        Self {
350            key: UiKey::Character(c.to_string()),
351            modifiers: KeyModifiers {
352                ctrl: true,
353                shift: true,
354                ..Default::default()
355            },
356        }
357    }
358
359    /// A named key with no modifiers (e.g. `KeyChord::named(UiKey::Escape)`).
360    pub fn named(key: UiKey) -> Self {
361        Self {
362            key,
363            modifiers: KeyModifiers::default(),
364        }
365    }
366
367    /// Builder-style: replace the chord's modifier mask.
368    pub fn with_modifiers(mut self, modifiers: KeyModifiers) -> Self {
369        self.modifiers = modifiers;
370        self
371    }
372
373    /// Strict match: keys equal AND modifier mask is identical. Holding
374    /// extra modifiers does not match a chord that didn't request them.
375    pub fn matches(&self, key: &UiKey, modifiers: KeyModifiers) -> bool {
376        key_eq(&self.key, key) && self.modifiers == modifiers
377    }
378}
379
380fn key_eq(a: &UiKey, b: &UiKey) -> bool {
381    match (a, b) {
382        (UiKey::Character(x), UiKey::Character(y)) => x.eq_ignore_ascii_case(y),
383        _ => a == b,
384    }
385}
386
387/// User-facing event. The host's [`App::on_event`] receives one of these
388/// per discrete user action.
389///
390/// Most apps should not destructure every field. Prefer the convenience
391/// methods on this type for common routes:
392///
393/// ```
394/// # use damascene_core::prelude::*;
395/// # struct Counter { value: i32 }
396/// # impl App for Counter {
397/// # fn build(&self, _cx: &BuildCx) -> El { button("+").key("inc") }
398/// fn on_event(&mut self, event: UiEvent, _cx: &EventCx) {
399///     if event.is_click_or_activate("inc") {
400///         self.value += 1;
401///     }
402/// }
403/// # }
404/// ```
405#[derive(Clone, Debug)]
406#[non_exhaustive]
407pub struct UiEvent {
408    /// Route string for this event.
409    ///
410    /// For pointer and focus events, this is the [`El::key`][crate::El::key]
411    /// of the target node. For [`UiEventKind::Hotkey`], this is the
412    /// action name returned from [`App::hotkeys`]. For window-level
413    /// keyboard events such as Escape with no focused target, this is
414    /// `None`.
415    ///
416    /// Prefer [`Self::route`] or [`Self::is_click_or_activate`] in app
417    /// code. The field remains public for direct pattern matching.
418    pub key: Option<String>,
419    /// Full hit-test target for events routed to a concrete element.
420    pub target: Option<UiTarget>,
421    /// Pointer position in logical pixels when the event was emitted.
422    pub pointer: Option<(f32, f32)>,
423    /// Keyboard payload for key events.
424    pub key_press: Option<KeyPress>,
425    /// Composed text payload for [`UiEventKind::TextInput`] events.
426    pub text: Option<String>,
427    /// Library-emitted selection state for
428    /// [`UiEventKind::SelectionChanged`] events. Carries the new
429    /// [`crate::selection::Selection`] after the runtime resolved a
430    /// pointer interaction. The app folds this into its
431    /// `Selection` field the same way it folds `apply_event` results
432    /// into a [`crate::widgets::text_input::TextSelection`].
433    pub selection: Option<crate::selection::Selection>,
434    /// Modifier mask captured at the moment this event was emitted. For
435    /// keyboard events this duplicates `key_press.modifiers`; for
436    /// pointer events it's the host-tracked modifier state at the time
437    /// of the click / drag (used by widgets like text_input that need
438    /// to detect Shift+click for "extend selection").
439    pub modifiers: KeyModifiers,
440    /// Click number within a multi-click sequence. Set to 1 for single
441    /// click, 2 for double-click, 3 for triple-click, etc. The runtime
442    /// increments this when consecutive `PointerDown`s land on the same
443    /// target within ~500ms and ~4px of the previous click. `Drag`
444    /// events emitted while the final click is held keep the active
445    /// sequence count so text widgets can preserve word / line
446    /// granularity. `0` means "not applicable" — set on events outside
447    /// pointer click / drag routing.
448    ///
449    /// `text_input` / `text_area` and the static-text selection
450    /// manager read this to map double-click → select word, triple-
451    /// click → select line.
452    pub click_count: u8,
453    /// File system path for [`UiEventKind::FileHovered`] /
454    /// [`UiEventKind::FileDropped`] events. Multi-file drag-drops fire
455    /// one event per file (matching the underlying winit semantics);
456    /// each event carries one path. `PathBuf` rather than `String`
457    /// because Windows wide-char paths and unusual Unix paths aren't
458    /// guaranteed to be UTF-8.
459    pub path: Option<std::path::PathBuf>,
460    /// Modality of the pointer that produced this event. `None` for
461    /// non-pointer events (hotkeys, keyboard activation, file drops
462    /// without a tracked pointer). Apps that need to specialize for
463    /// touch (accessibility, analytics, alternate affordances) read
464    /// this; most app code can ignore it.
465    pub pointer_kind: Option<PointerKind>,
466    /// Wheel delta in logical pixels for [`UiEventKind::PointerWheel`].
467    ///
468    /// Positive `dy` means "scroll down" in the same coordinate system
469    /// used by Damascene's scroll containers. Hosts normalize line-based
470    /// and pixel-based wheel input before setting this field.
471    pub wheel_delta: Option<(f32, f32)>,
472    /// What kind of event happened. See [`UiEventKind`] for the
473    /// per-variant routing contracts.
474    pub kind: UiEventKind,
475}
476
477impl UiEvent {
478    /// Synthesize a click event for the given route key.
479    ///
480    /// Intended for tests, headless automation, and snapshot
481    /// fixtures that drive UI logic without a real pointer history.
482    /// All optional fields default to `None`; modifiers are empty.
483    pub fn synthetic_click(key: impl Into<String>) -> Self {
484        Self {
485            kind: UiEventKind::Click,
486            key: Some(key.into()),
487            target: None,
488            pointer: None,
489            key_press: None,
490            text: None,
491            selection: None,
492            modifiers: KeyModifiers::default(),
493            click_count: 1,
494            path: None,
495            pointer_kind: None,
496            wheel_delta: None,
497        }
498    }
499
500    /// Route string for this event, if any.
501    ///
502    /// For pointer/focus events this is the target element key. For
503    /// hotkeys this is the registered action name.
504    pub fn route(&self) -> Option<&str> {
505        self.key.as_deref()
506    }
507
508    /// Target element key, if this event was routed to an element.
509    ///
510    /// Unlike [`Self::route`], this returns `None` for app-level
511    /// hotkey actions because those do not have a concrete element
512    /// target.
513    pub fn target_key(&self) -> Option<&str> {
514        self.target.as_ref().map(|t| t.key.as_str())
515    }
516
517    /// True when this event's route equals `key`.
518    pub fn is_route(&self, key: &str) -> bool {
519        self.route() == Some(key)
520    }
521
522    /// If this event's route is `prefix:rest`, the rest — see
523    /// [`crate::key::suffix`]. The per-item dispatch shape without the
524    /// hand-rolled `strip_prefix` chain:
525    ///
526    /// ```
527    /// # use damascene_core::UiEvent;
528    /// let event = UiEvent::synthetic_click("thumb:42");
529    /// assert_eq!(event.route_suffix("thumb"), Some("42"));
530    /// assert_eq!(event.route_suffix("row"), None);
531    /// ```
532    pub fn route_suffix(&self, prefix: &str) -> Option<&str> {
533        crate::key::suffix(self.route()?, prefix)
534    }
535
536    /// If this event's route is `prefix:index…`, the first segment
537    /// after the prefix parsed as `T` — see [`crate::key::index`].
538    /// Unlike the hand-rolled `strip_prefix` + `parse().ok()` chain, a
539    /// prefix match whose index fails to parse logs a warning instead
540    /// of silently dropping the event.
541    ///
542    /// ```
543    /// # use damascene_core::UiEvent;
544    /// let event = UiEvent::synthetic_click("thumb:42");
545    /// assert_eq!(event.route_index::<usize>("thumb"), Some(42));
546    /// // Select-style routed keys: the leading id still parses.
547    /// let event = UiEvent::synthetic_click("profile:7:option:3");
548    /// assert_eq!(event.route_index::<u32>("profile"), Some(7));
549    /// ```
550    pub fn route_index<T: std::str::FromStr>(&self, prefix: &str) -> Option<T> {
551        crate::key::index(self.route()?, prefix)
552    }
553
554    /// True for a primary click or keyboard activation on `key`.
555    ///
556    /// This is the most common button/menu route in app code.
557    pub fn is_click_or_activate(&self, key: &str) -> bool {
558        matches!(self.kind, UiEventKind::Click | UiEventKind::Activate) && self.is_route(key)
559    }
560
561    /// True for a registered hotkey action name.
562    pub fn is_hotkey(&self, action: &str) -> bool {
563        self.kind == UiEventKind::Hotkey && self.is_route(action)
564    }
565
566    /// Pointer position in logical pixels, if this event carries one.
567    pub fn pointer_pos(&self) -> Option<(f32, f32)> {
568        self.pointer
569    }
570
571    /// Pointer x coordinate in logical pixels, if this event carries one.
572    pub fn pointer_x(&self) -> Option<f32> {
573        self.pointer.map(|(x, _)| x)
574    }
575
576    /// Pointer y coordinate in logical pixels, if this event carries one.
577    pub fn pointer_y(&self) -> Option<f32> {
578        self.pointer.map(|(_, y)| y)
579    }
580
581    /// Wheel delta in logical pixels, if this is a pointer wheel event.
582    pub fn wheel_delta(&self) -> Option<(f32, f32)> {
583        self.wheel_delta
584    }
585
586    /// Vertical wheel delta in logical pixels, if this is a pointer
587    /// wheel event.
588    pub fn wheel_dy(&self) -> Option<f32> {
589        self.wheel_delta.map(|(_, dy)| dy)
590    }
591
592    /// Rectangle of the routed target from the last layout pass.
593    /// This is the target's transformed visual rect, not any
594    /// `hit_overflow` band that may also route pointer events to it.
595    pub fn target_rect(&self) -> Option<Rect> {
596        self.target.as_ref().map(|t| t.rect)
597    }
598
599    /// OS-composed text payload for [`UiEventKind::TextInput`].
600    pub fn text(&self) -> Option<&str> {
601        self.text.as_deref()
602    }
603}
604
605/// What kind of event happened.
606///
607/// This enum is non-exhaustive so Damascene can add new input events
608/// without breaking downstream apps. Match the variants you handle and
609/// include a wildcard arm for everything else.
610#[derive(Clone, Copy, Debug, PartialEq, Eq)]
611#[non_exhaustive]
612pub enum UiEventKind {
613    /// Primary-button pointer down + up landed on the same node.
614    Click,
615    /// Primary-button click landed on a text run carrying a
616    /// [`crate::tree::El::text_link`] URL. The URL is in [`UiEvent::key`].
617    /// Apps decide whether to honor it (filtering, confirmation,
618    /// platform-appropriate open via [`App::drain_link_opens`] +
619    /// host-side opener). Damascene doesn't open URLs itself — it surfaces
620    /// the click and lets the app route it.
621    LinkActivated,
622    /// Secondary-button (right-click) pointer down + up landed on the
623    /// same node. Used for context menus.
624    SecondaryClick,
625    /// Middle-button pointer down + up landed on the same node.
626    MiddleClick,
627    /// Focused element was activated by keyboard (Enter/Space).
628    Activate,
629    /// Escape was pressed. Routed to the focused element when present,
630    /// otherwise emitted as a window-level event.
631    Escape,
632    /// A registered hotkey chord matched. `event.key` is the registered
633    /// name (the second element of the `(KeyChord, String)` pair).
634    Hotkey,
635    /// Other keyboard input.
636    KeyDown,
637    /// Composed text input — printable characters from the OS, after
638    /// dead-key composition / IME / shift mapping. Routed to the
639    /// focused element. Distinct from `KeyDown(Character(_))`: the
640    /// latter is the raw key event used for shortcuts and navigation;
641    /// `TextInput` is the grapheme stream a text field should consume.
642    TextInput,
643    /// Pointer moved while the primary button was held down. Routed
644    /// to the originally pressed target so a widget can extend a
645    /// selection / scrub a slider / move a draggable. `event.pointer`
646    /// carries the current logical-pixel position; `event.target` is
647    /// the node where the drag began.
648    Drag,
649    /// Primary pointer button released. Fires regardless of whether
650    /// the up landed on the same node as the down — paired with
651    /// `Click` (which only fires on a same-node match), this lets
652    /// drag-aware widgets always observe drag-end.
653    /// `event.target` is the originally pressed node;
654    /// `event.pointer` is the up position.
655    PointerUp,
656    /// Primary pointer button pressed on a hit-test target. Routed
657    /// before the eventual `Click` (which fires on up-on-same-target).
658    /// Used by widgets like text_input that need to react at
659    /// down-time — e.g., to set the selection anchor before any drag
660    /// extends it. `event.target` is the down-target,
661    /// `event.pointer` is the down position, and `event.modifiers`
662    /// carries the modifier mask (Shift+click for extend-selection).
663    PointerDown,
664    /// Mouse wheel / trackpad scroll input routed to the keyed element
665    /// under the pointer. Emitted before Damascene's default scroll
666    /// handling; apps can consume it by returning `true` from
667    /// [`App::on_wheel_event`]. `event.wheel_delta` carries the
668    /// normalized logical-pixel delta.
669    PointerWheel,
670    /// The library's selection manager resolved a pointer interaction
671    /// on selectable text and wants the app to update its
672    /// [`crate::selection::Selection`] state. `event.selection`
673    /// carries the new value (an empty `Selection` clears).
674    /// Emitted by `pointer_down`, `pointer_moved` (during a drag),
675    /// and the runtime's escape / dismiss paths.
676    SelectionChanged,
677    /// Pointer crossed onto a keyed hit-test target. Routed to the
678    /// newly hovered leaf — `event.target` is the new hover target,
679    /// `event.pointer` is the current pointer position. Fires
680    /// once per identity change, including the initial hover when the
681    /// pointer first enters a keyed region from nothing.
682    ///
683    /// Use for transition-driven side effects (sound on hover-enter,
684    /// analytics, hover-intent prefetch) — read state via
685    /// [`crate::BuildCx::hovered_key`] /
686    /// [`crate::BuildCx::is_hovering_within`] when you just need to
687    /// branch the build output. Both surfaces stay coherent because
688    /// the runtime debounces redraws and events to the same
689    /// hover-identity transitions.
690    ///
691    /// Always paired with a preceding `PointerLeave` for the previous
692    /// target (when there was one). Apps that want subtree-aware
693    /// behavior (parent stays "hot" while a child is hovered) should
694    /// query `is_hovering_within` rather than tracking enter/leave on
695    /// every keyed descendant.
696    PointerEnter,
697    /// Pointer crossed off a keyed hit-test target — either onto a
698    /// different keyed target (paired with a following `PointerEnter`)
699    /// or off any keyed surface entirely. Routed to the leaf that
700    /// just lost hover — `event.target` is the previous hover target,
701    /// `event.pointer` is the current pointer position (or the last
702    /// known position when the pointer left the window).
703    PointerLeave,
704    /// The runner is abandoning a press because the gesture became
705    /// something else — currently only fired when a touch contact's
706    /// movement crosses the touch-scroll threshold and the press
707    /// target did not opt in via `consumes_touch_drag`. The contact
708    /// has *not* lifted; the user is still touching the screen, but
709    /// from the widget's perspective the press is gone (no
710    /// subsequent `Drag`, no `Click`, no `PointerUp`). Routed to the
711    /// originally pressed target — apps that handle `PointerDown`
712    /// for in-flight visual / state setup should also handle
713    /// `PointerCancel` to roll it back.
714    ///
715    /// Browser-initiated pointer cancels (OS gesture takeover, etc.)
716    /// currently come through as `PointerUp` rather than this event;
717    /// that may change.
718    PointerCancel,
719    /// A touch contact has been held in place past
720    /// [`crate::state::LONG_PRESS_DELAY`] without lifting or moving
721    /// past the gesture threshold. Fired exactly once per qualifying
722    /// press. For normal targets this is fired immediately after a
723    /// `PointerCancel` is dispatched to the originally pressed target;
724    /// the underlying primary press is consumed by the long-press, so
725    /// no subsequent `Click` or `PointerUp` follows. Capture-keys
726    /// editable targets keep the press captured so movement after the
727    /// long-press can emit `Drag` to extend text selection. The
728    /// eventual finger lift is silently swallowed.
729    ///
730    /// `event.target` is the keyed leaf at the press point (same
731    /// node that received the cancelled `PointerDown`), `event.pointer`
732    /// is the original press coords (not the current finger position
733    /// — the contact may have drifted within the gesture-threshold
734    /// radius before firing), and `event.pointer_kind` is always
735    /// `PointerKind::Touch`.
736    ///
737    /// Mouse and pen pointers never produce this event — right-click
738    /// goes through `PointerDown` with [`PointerButton::Secondary`]
739    /// instead, which is the desktop-shape signal for the same
740    /// "open a context menu here" intent. Apps that want both paths
741    /// to drive the same menu match on either kind.
742    LongPress,
743    /// A file is being dragged over the window (the user hasn't
744    /// released yet). `event.path` carries the file's path; multi-file
745    /// drags fire one event per file, matching the underlying winit
746    /// semantics. `event.target` is the keyed leaf at the current
747    /// pointer position when one was hit, otherwise `None`
748    /// (drop-zone overlays that span the window can match on
749    /// `event.target.is_none()` or filter by their own key).
750    ///
751    /// Apps use this to highlight a drop zone before the drop lands.
752    /// Always paired with either a later `FileHoverCancelled` (the
753    /// user moved off without releasing) or `FileDropped` (the user
754    /// released).
755    FileHovered,
756    /// The user moved a hovered file off the window without releasing,
757    /// or pressed Escape. Window-level event (`event.target` is
758    /// `None`) — apps clear any drop-zone affordance state regardless
759    /// of which keyed leaf was previously highlighted.
760    FileHoverCancelled,
761    /// A file was dropped on the window. `event.path` carries the
762    /// path; multi-file drops fire one event per file. `event.target`
763    /// is the keyed leaf at the drop position, or `None` if the drop
764    /// landed outside any keyed surface — apps that want a global drop
765    /// target match on `target.is_none()` or treat unrouted events as
766    /// hits to a single window-level upload sink.
767    FileDropped,
768    /// A [`user_resizable`](crate::tree::El::user_resizable) pane's
769    /// edge drag was released. Routed to the pane's key (unkeyed panes
770    /// resize fine but emit nothing); fires once per completed drag,
771    /// not per move. Read the final size via
772    /// [`EventCx::user_size`]/[`crate::UiState::user_size`] — this is
773    /// the natural moment to persist it. The size mutation itself is
774    /// runtime-owned, like scrolling: apps only listen when they want
775    /// to save the value.
776    Resized,
777}
778
779/// Per-frame, read-only context for [`App::build`].
780///
781/// The runner snapshots the app's [`crate::Theme`] before calling
782/// `build` and exposes it through `cx.theme()` / `cx.palette()` so app
783/// code can branch on the active palette (a custom widget that picks
784/// between two non-token colors based on dark vs. light, for instance).
785/// `BuildCx` is the explicit handle for this — token references inside
786/// widgets resolve through the palette automatically and don't need it.
787///
788/// Future fields like viewport metrics or frame phase will live here so
789/// the API stays additive: adding a new accessor on `BuildCx` doesn't
790/// break apps that ignore the context.
791#[derive(Copy, Clone, Debug)]
792pub struct BuildCx<'a> {
793    theme: &'a crate::Theme,
794    ui_state: Option<&'a crate::state::UiState>,
795    diagnostics: Option<&'a HostDiagnostics>,
796    /// Logical-pixel viewport this frame is being built for, when the
797    /// host attached one. Apps query this via [`Self::viewport`] /
798    /// [`Self::viewport_below`] to branch layout on phone-vs-desktop
799    /// without threading the surface size through their own state.
800    viewport: Option<(f32, f32)>,
801    /// Logical-pixel insets the host wants the app to inset its
802    /// layout by — content underneath these bands is obscured by
803    /// platform chrome and shouldn't host interactive widgets.
804    /// Today only the bottom inset is populated, by the web host's
805    /// VisualViewport listener when the on-screen keyboard appears;
806    /// the same field will carry status-bar / notch / home-indicator
807    /// insets when native mobile hosts land.
808    safe_area: Option<crate::tree::Sides>,
809}
810
811/// Why the current frame is being built. Hosts set this before each
812/// `request_redraw` so apps that surface a diagnostic overlay can show
813/// what kind of input is driving the redraw cadence.
814///
815/// `Other` is the conservative default: it covers redraws the host
816/// can't attribute. Specific variants narrow the reason when the
817/// host can.
818#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
819pub enum FrameTrigger {
820    /// Host can't attribute the redraw to a specific cause.
821    #[default]
822    Other,
823    /// Initial paint after surface configuration.
824    Initial,
825    /// Surface resize / DPI change.
826    Resize,
827    /// Pointer move, button, or wheel.
828    Pointer,
829    /// Keyboard / IME input.
830    Keyboard,
831    /// Inside-out animation deadline elapsed (one of the visible
832    /// widgets asked for a future frame via `redraw_within`, or a
833    /// visual animation is still settling). Drives the layout-path
834    /// (full rebuild + prepare).
835    Animation,
836    /// Time-driven shader deadline elapsed (e.g. stock spinner /
837    /// skeleton / progress-indeterminate, or a custom shader
838    /// registered with `samples_time=true`). Drives the paint-only
839    /// path: `frame.time` advances but layout state is unchanged.
840    ShaderPaint,
841    /// Periodic host-config cadence (`HostConfig::redraw_interval`).
842    Periodic,
843    /// Application code asked for a frame through the host's external
844    /// wakeup handle (push-driven event-class data — a chat message
845    /// arrived, a background task advanced state). Data changed
846    /// outside the tree, so this drives the layout path (full rebuild
847    /// + prepare), never paint-only.
848    External,
849}
850
851impl FrameTrigger {
852    /// Short, fixed-width tag for diagnostic overlays.
853    pub fn label(self) -> &'static str {
854        match self {
855            FrameTrigger::Other => "other",
856            FrameTrigger::Initial => "initial",
857            FrameTrigger::Resize => "resize",
858            FrameTrigger::Pointer => "pointer",
859            FrameTrigger::Keyboard => "keyboard",
860            FrameTrigger::Animation => "animation",
861            FrameTrigger::ShaderPaint => "shader-paint",
862            FrameTrigger::Periodic => "periodic",
863            FrameTrigger::External => "external",
864        }
865    }
866}
867
868/// Per-frame diagnostic snapshot the host hands the app via
869/// [`BuildCx::diagnostics`]. Apps that surface a debug overlay (e.g.
870/// the showcase status block) read this each build to display the
871/// active backend, frame cadence, and what triggered the redraw.
872/// Timing fields describe the last completed rendered frame, not the
873/// frame currently being built; the host cannot know current layout /
874/// paint timings until after `App::build` returns.
875///
876/// Hosts populate every field they can; `backend` is a static string
877/// (`"WebGPU"`, `"Vulkan"`, `"Metal"`, `"DX12"`, `"GL"`) so the app
878/// doesn't need to depend on `wgpu` to read it. Time fields use
879/// `std::time::Duration`, which works on both native and wasm32 — only
880/// `Instant::now()` is the wasm-incompatible piece, and that stays on
881/// the host side.
882#[derive(Clone, Debug)]
883pub struct HostDiagnostics {
884    /// Render backend in human-readable form.
885    pub backend: &'static str,
886    /// Current surface size in physical pixels.
887    pub surface_size: (u32, u32),
888    /// Display scale factor (`physical / logical`).
889    pub scale_factor: f32,
890    /// Active MSAA sample count (1 = MSAA off).
891    pub msaa_samples: u32,
892    /// Frame counter; increments every redraw the host actually
893    /// renders. Useful for verifying that an animated source is
894    /// progressing.
895    pub frame_index: u64,
896    /// Wall-clock time between this redraw and the previous one.
897    /// `Duration::ZERO` for the first frame (no prior frame).
898    pub last_frame_dt: std::time::Duration,
899    /// Time spent in the app's `build` method for the last completed
900    /// frame. `Duration::ZERO` before the first full frame and on
901    /// paint-only frames that skipped build.
902    pub last_build: std::time::Duration,
903    /// Total time spent in the backend `prepare` call for the last
904    /// completed frame.
905    pub last_prepare: std::time::Duration,
906    /// Sub-stage inside `prepare`: layout pass, focus/selection sync,
907    /// state application, and animation tick.
908    pub last_layout: std::time::Duration,
909    /// Intrinsic-measurement cache hits during the last layout pass.
910    pub last_layout_intrinsic_cache_hits: u64,
911    /// Intrinsic-measurement cache misses during the last layout pass.
912    pub last_layout_intrinsic_cache_misses: u64,
913    /// Direct scroll children whose descendants were skipped during
914    /// layout because the child was outside the scroll viewport.
915    pub last_layout_pruned_subtrees: u64,
916    /// Descendant nodes assigned zero rects as part of scroll layout
917    /// pruning during the last layout pass.
918    pub last_layout_pruned_nodes: u64,
919    /// Sub-stage inside `prepare`: laid-out tree to backend-neutral
920    /// `DrawOp` list.
921    pub last_draw_ops: std::time::Duration,
922    /// Text draw ops skipped during draw-op generation because their
923    /// glyph rect did not intersect the inherited clip.
924    pub last_draw_ops_culled_text_ops: u64,
925    /// Sub-stage inside `prepare`: paint-stream packing and text
926    /// shaping/rasterization recording.
927    pub last_paint: std::time::Duration,
928    /// Paint ops skipped because their painted rect did not intersect
929    /// the effective clip/viewport in the last completed frame.
930    pub last_paint_culled_ops: u64,
931    /// Sub-stage inside `prepare`: backend-side buffer writes, glyph
932    /// atlas uploads, and frame uniforms.
933    pub last_gpu_upload: std::time::Duration,
934    /// Sub-stage inside `prepare`: clone the laid-out tree for
935    /// next-frame hit-testing.
936    pub last_snapshot: std::time::Duration,
937    /// Time spent encoding/submitting/presenting the last completed
938    /// frame after `prepare`.
939    pub last_submit: std::time::Duration,
940    /// Layout-side text-cache hits during the last completed full
941    /// prepare.
942    pub last_text_layout_cache_hits: u64,
943    /// Layout-side text-cache misses during the last completed full
944    /// prepare.
945    pub last_text_layout_cache_misses: u64,
946    /// Estimated layout-side text-cache evictions during the last
947    /// completed full prepare.
948    pub last_text_layout_cache_evictions: u64,
949    /// Total UTF-8 bytes shaped on layout-cache misses during the last
950    /// completed full prepare.
951    pub last_text_layout_shaped_bytes: u64,
952    /// Why the host triggered this frame.
953    pub trigger: FrameTrigger,
954    /// What the renderer composites in. The paint stream converts every
955    /// [`crate::color::Color`] into this space exactly once at the
956    /// upload boundary. Defaults to [`crate::color::ColorSpace::SRGB_LINEAR`].
957    pub working_color_space: crate::color::ColorSpace,
958    /// Wire-side color-management state the host negotiated with the
959    /// display server. [`crate::color::ColorManagementStatus::Unavailable`]
960    /// on hosts without a color-management protocol (X11, plain Wayland,
961    /// macOS / Windows today). See [`crate::color::ColorPreferences`]
962    /// for how apps influence the negotiation.
963    pub color_management: crate::color::ColorManagementStatus,
964    /// Color-relevant facts about the host's GPU presentation surface —
965    /// the wgpu / WSI half of color negotiation (advertised formats,
966    /// chosen swapchain format, present/alpha mode, adapter). `None` on
967    /// hosts that don't present through a wgpu surface (headless render
968    /// bins, the vulkano demo). See [`SurfaceColorInfo`].
969    pub surface_color: Option<SurfaceColorInfo>,
970}
971
972impl Default for HostDiagnostics {
973    fn default() -> Self {
974        Self {
975            backend: "?",
976            surface_size: (0, 0),
977            scale_factor: 1.0,
978            msaa_samples: 1,
979            frame_index: 0,
980            last_frame_dt: std::time::Duration::ZERO,
981            last_build: std::time::Duration::ZERO,
982            last_prepare: std::time::Duration::ZERO,
983            last_layout: std::time::Duration::ZERO,
984            last_layout_intrinsic_cache_hits: 0,
985            last_layout_intrinsic_cache_misses: 0,
986            last_layout_pruned_subtrees: 0,
987            last_layout_pruned_nodes: 0,
988            last_draw_ops: std::time::Duration::ZERO,
989            last_draw_ops_culled_text_ops: 0,
990            last_paint: std::time::Duration::ZERO,
991            last_paint_culled_ops: 0,
992            last_gpu_upload: std::time::Duration::ZERO,
993            last_snapshot: std::time::Duration::ZERO,
994            last_submit: std::time::Duration::ZERO,
995            last_text_layout_cache_hits: 0,
996            last_text_layout_cache_misses: 0,
997            last_text_layout_cache_evictions: 0,
998            last_text_layout_shaped_bytes: 0,
999            trigger: FrameTrigger::default(),
1000            working_color_space: crate::paint::DEFAULT_WORKING_COLOR_SPACE,
1001            color_management: crate::color::ColorManagementStatus::default(),
1002            surface_color: None,
1003        }
1004    }
1005}
1006
1007impl HostDiagnostics {
1008    /// Is this app actually rendering HDR right now — an extended-range
1009    /// swapchain on an output with HDR evidence?
1010    ///
1011    /// This is the check, encoded once so apps never re-derive it:
1012    /// the compositor's preferred description indicates an HDR output
1013    /// ([`CompositorColorTargets::indicates_hdr`]) **and** the
1014    /// negotiated swapchain format can carry extended-range output
1015    /// ([`SurfaceFormatInfo::wide`], e.g. `Rgba16Float` scRGB). Do
1016    /// *not* infer HDR from `ColorManagementStatus::Available {
1017    /// attached }` — on every current host the WSI owns the surface
1018    /// tag and `attached` stays `None` even in full HDR operation.
1019    ///
1020    /// Live: hosts refresh these diagnostics when the compositor's
1021    /// preferred description changes (`preferred_changed2` — window
1022    /// moved to another output, HDR toggled), so this flips with the
1023    /// window. HDR is opt-in via [`crate::color::ColorPreferences`];
1024    /// a default `sdr_only` app reports `false` even on an HDR output.
1025    ///
1026    /// [`CompositorColorTargets::indicates_hdr`]: crate::color::CompositorColorTargets::indicates_hdr
1027    pub fn hdr_active(&self) -> bool {
1028        let crate::color::ColorManagementStatus::Available { targets, .. } = &self.color_management
1029        else {
1030            return false;
1031        };
1032        targets.indicates_hdr()
1033            && self.surface_color.as_ref().is_some_and(|s| {
1034                s.formats
1035                    .iter()
1036                    .any(|f| f.wide && f.name == s.chosen_format)
1037            })
1038    }
1039}
1040
1041/// Color-relevant facts about the host's GPU presentation surface — the
1042/// wgpu / WSI half of color negotiation. The compositor (via
1043/// [`crate::color::ColorManagementStatus`]) says what it *accepts*; this
1044/// says what the *swapchain* can represent. The intersection is what the
1045/// negotiator can actually pick — e.g. a compositor that ingests linear
1046/// BT.2020 is moot if the surface offers no float format.
1047///
1048/// Strings throughout so `damascene-core` needn't depend on `wgpu`.
1049#[derive(Clone, Debug, Default)]
1050pub struct SurfaceColorInfo {
1051    /// Adapter / device name (e.g. `"Intel Graphics (ADL GT2)"`).
1052    pub adapter: String,
1053    /// Driver name + version, when the backend reports it.
1054    pub driver: String,
1055    /// Color formats the surface advertised, in wgpu's reported order.
1056    pub formats: Vec<SurfaceFormatInfo>,
1057    /// The swapchain format negotiation actually chose.
1058    pub chosen_format: String,
1059    /// Present mode in use.
1060    pub present_mode: String,
1061    /// Composite alpha mode in use.
1062    pub alpha_mode: String,
1063}
1064
1065/// One surface texture format, classified by how it can carry color
1066/// output. See [`SurfaceColorInfo`].
1067#[derive(Clone, Debug)]
1068pub struct SurfaceFormatInfo {
1069    /// wgpu format name (e.g. `"Rgba16Float"`).
1070    pub name: String,
1071    /// Carries an sRGB EOTF in hardware (`*_unorm_srgb`): the GPU encodes
1072    /// linear → sRGB on store.
1073    pub srgb: bool,
1074    /// Can carry wide-gamut / HDR output: a float format (linear-direct —
1075    /// the compositor does the output encode) or a ≥10-bit format (a
1076    /// PQ-encode target). 8-bit unorm formats are SDR-only.
1077    pub wide: bool,
1078}
1079
1080impl<'a> BuildCx<'a> {
1081    /// Construct a [`BuildCx`] borrowing the supplied theme. Hosts call
1082    /// this once per frame after [`App::theme`] and before [`App::build`].
1083    /// Hosts that own a [`crate::state::UiState`] should chain
1084    /// [`Self::with_ui_state`] so the app can read interaction state
1085    /// (hover) during build via [`Self::hovered_key`] /
1086    /// [`Self::is_hovering_within`].
1087    pub fn new(theme: &'a crate::Theme) -> Self {
1088        Self {
1089            theme,
1090            ui_state: None,
1091            diagnostics: None,
1092            viewport: None,
1093            safe_area: None,
1094        }
1095    }
1096
1097    /// Attach the runtime's [`crate::state::UiState`] so build-time
1098    /// accessors (`hovered_key`, `is_hovering_within`) can answer.
1099    /// When omitted, those accessors return `None` / `false` — useful
1100    /// for headless rendering paths that don't track interaction
1101    /// state.
1102    pub fn with_ui_state(mut self, ui_state: &'a crate::state::UiState) -> Self {
1103        self.ui_state = Some(ui_state);
1104        self
1105    }
1106
1107    /// Attach a [`HostDiagnostics`] snapshot for this frame. Hosts call
1108    /// this when they want apps to surface debug overlays (e.g. the
1109    /// showcase status block); apps that don't read `diagnostics()`
1110    /// pay nothing for it. Headless render paths leave it `None`.
1111    pub fn with_diagnostics(mut self, diagnostics: &'a HostDiagnostics) -> Self {
1112        self.diagnostics = Some(diagnostics);
1113        self
1114    }
1115
1116    /// Attach the logical-pixel viewport size for this frame. Hosts
1117    /// chain this so apps can branch on viewport metrics during build
1118    /// (responsive layout, phone-vs-desktop splits) without threading
1119    /// surface size through their own state. Headless render paths
1120    /// without a meaningful viewport leave it unset.
1121    pub fn with_viewport(mut self, width: f32, height: f32) -> Self {
1122        self.viewport = Some((width, height));
1123        self
1124    }
1125
1126    /// Attach the host's reported safe-area insets in logical pixels.
1127    /// Hosts chain this when platform chrome (on-screen keyboard,
1128    /// notch, status bar, home indicator) is obscuring some band of
1129    /// the viewport. Apps read it via [`Self::safe_area`] /
1130    /// [`Self::safe_area_bottom`] and inset their interactive content
1131    /// accordingly. Hosts that don't report safe-area metrics omit
1132    /// this; apps see `Sides::zero()` from the read accessors.
1133    pub fn with_safe_area(mut self, sides: crate::tree::Sides) -> Self {
1134        self.safe_area = Some(sides);
1135        self
1136    }
1137
1138    /// Per-frame diagnostic snapshot from the host (backend, frame
1139    /// cadence, trigger reason, etc.), or `None` when the host did
1140    /// not attach one. Apps display this in optional debug overlays.
1141    pub fn diagnostics(&self) -> Option<&HostDiagnostics> {
1142        self.diagnostics
1143    }
1144
1145    /// The active runtime theme for this frame.
1146    pub fn theme(&self) -> &crate::Theme {
1147        self.theme
1148    }
1149
1150    /// Shorthand for `self.theme().palette()`.
1151    pub fn palette(&self) -> &crate::Palette {
1152        self.theme.palette()
1153    }
1154
1155    /// Logical-pixel viewport `(width, height)` the host attached for
1156    /// this frame, or `None` for headless render paths. Apps use this
1157    /// to branch layout on viewport metrics — see [`Self::viewport_below`]
1158    /// for the common phone-vs-desktop breakpoint case.
1159    pub fn viewport(&self) -> Option<(f32, f32)> {
1160        self.viewport
1161    }
1162
1163    /// Logical-pixel viewport width the host attached for this frame,
1164    /// or `None` when no viewport is available. Convenience for the
1165    /// common single-axis branch (`cx.viewport_width().map_or(false,
1166    /// |w| w < 600.0)`).
1167    pub fn viewport_width(&self) -> Option<f32> {
1168        self.viewport.map(|(w, _)| w)
1169    }
1170
1171    /// Logical-pixel viewport height the host attached for this frame,
1172    /// or `None` when no viewport is available.
1173    pub fn viewport_height(&self) -> Option<f32> {
1174        self.viewport.map(|(_, h)| h)
1175    }
1176
1177    /// True iff the attached viewport's width is strictly less than
1178    /// `threshold` logical pixels. Returns `false` when no viewport is
1179    /// attached so headless / desktop-default paths fall through to
1180    /// the wider branch — apps that want the opposite default can
1181    /// match on [`Self::viewport_width`] directly.
1182    ///
1183    /// Use for the common breakpoint split:
1184    /// ```ignore
1185    /// if cx.viewport_below(600.0) {
1186    ///     phone_layout()
1187    /// } else {
1188    ///     desktop_layout()
1189    /// }
1190    /// ```
1191    pub fn viewport_below(&self, threshold: f32) -> bool {
1192        self.viewport_width().is_some_and(|w| w < threshold)
1193    }
1194
1195    /// Logical-pixel safe-area insets the host reports for this frame
1196    /// (`Sides::zero()` when nothing was attached). Today this is
1197    /// populated only by damascene-web when the on-screen keyboard
1198    /// shrinks the visual viewport — `bottom` carries the keyboard
1199    /// height; future native mobile hosts will additionally populate
1200    /// `top` for status-bar / notch and `bottom` for home-indicator.
1201    ///
1202    /// Apps inset their root layout (or just the focused-input
1203    /// region) by these amounts so interactive content doesn't sit
1204    /// underneath platform chrome. The runtime does not auto-apply
1205    /// this — apps decide where the inset matters.
1206    pub fn safe_area(&self) -> crate::tree::Sides {
1207        self.safe_area.unwrap_or_default()
1208    }
1209
1210    /// Convenience: just the bottom inset, in logical pixels. Most
1211    /// commonly the soft-keyboard height.
1212    pub fn safe_area_bottom(&self) -> f32 {
1213        self.safe_area().bottom
1214    }
1215
1216    /// Key of the leaf node currently under the pointer, or `None`
1217    /// when nothing is hovered or this `BuildCx` was built without a
1218    /// `UiState` (headless rendering paths).
1219    ///
1220    /// Use for branching the build output on hover state without
1221    /// mirroring it via `App::on_event` handlers — e.g., a sidebar
1222    /// row that previews details in a side pane based on what's
1223    /// currently hovered.
1224    ///
1225    /// For region-aware queries (parent stays "hot" while a child is
1226    /// hovered), prefer [`Self::is_hovering_within`].
1227    pub fn hovered_key(&self) -> Option<&str> {
1228        self.ui_state?.hovered_key()
1229    }
1230
1231    /// True iff `key`'s node — or any descendant of it — is the
1232    /// current hover target. Subtree-aware, matching the semantics of
1233    /// [`crate::tree::El::hover_alpha`]. Returns `false` when this
1234    /// `BuildCx` has no attached `UiState` or when `key` isn't in the
1235    /// current tree.
1236    ///
1237    /// Reads the underlying tracker, not the eased subtree envelope —
1238    /// the boolean flips immediately on hit-test identity change.
1239    pub fn is_hovering_within(&self, key: &str) -> bool {
1240        self.ui_state
1241            .is_some_and(|state| state.is_hovering_within(key))
1242    }
1243
1244    /// The scatter point currently under the cursor in a `chart3d` scene, if
1245    /// any — the 3D analogue of [`hovered_key`](Self::hovered_key).
1246    ///
1247    /// Scene points aren't `El`s, so they can't emit `PointerEnter`/`Leave`
1248    /// like 2D widgets; this surfaces the same hover pick that draws the
1249    /// built-in tooltip chip ([`ScenePointPick`] carries the scene id + mark +
1250    /// point index). Use it to drive a detail panel / highlight / linked view
1251    /// on hover — branch the build on `cx.hovered_scene_point()` without an
1252    /// `on_event` handler. Picked a frame late (fine for hover UI) and honours
1253    /// the chip's depth-occlusion + behind-camera culling.
1254    ///
1255    /// [`ScenePointPick`]: crate::scene::ScenePointPick
1256    pub fn hovered_scene_point(&self) -> Option<&crate::scene::ScenePointPick> {
1257        self.ui_state?.hovered_scene_point()
1258    }
1259
1260    /// The laid-out rect of the keyed node `key` from the *previous*
1261    /// frame's layout, or `None` when the key wasn't in that tree (or
1262    /// this `BuildCx` has no attached `UiState` — headless paths).
1263    ///
1264    /// The damascene analogue of the DOM's
1265    /// `getBoundingClientRect()`: layout geometry is retained between
1266    /// frames, so build code can read where a keyed thing actually
1267    /// landed. One frame stale by construction — `build` runs before
1268    /// this frame's layout — which is fine for the usual uses
1269    /// (branching on a measured-once size, sizing a dependent pane).
1270    /// Same staleness contract as [`Self::hovered_key`]. For
1271    /// event-time decisions prefer [`EventCx::rect_of_key`], which
1272    /// answers at the moment the handler runs.
1273    pub fn rect_of_key(&self, key: &str) -> Option<Rect> {
1274        self.ui_state?.rect_of_key(key)
1275    }
1276
1277    /// The half-open row-index range the keyed virtual list realized in
1278    /// the previous frame's layout, or `None` when the key isn't a
1279    /// laid-out virtual list (or no `UiState` is attached). One frame
1280    /// stale by construction, same contract as [`Self::rect_of_key`].
1281    ///
1282    /// The cache-eviction hook for media-heavy lists: rows outside this
1283    /// range are off-screen, so their decoded images/thumbnails can be
1284    /// dropped and recreated by the row builder when scrolled back.
1285    pub fn visible_range(&self, key: &str) -> Option<std::ops::Range<usize>> {
1286        self.ui_state?.visible_range(key)
1287    }
1288
1289    /// The user-dragged size of a keyed
1290    /// [`user_resizable`](crate::tree::El::user_resizable) pane, in
1291    /// logical pixels. `None` until the user's first drag — the pane
1292    /// is still at its declared size. See
1293    /// [`UiState::user_size`](crate::UiState::user_size).
1294    pub fn user_size(&self, key: &str) -> Option<f32> {
1295        self.ui_state?.user_size(key)
1296    }
1297}
1298
1299/// Read-only context passed to [`App::on_event`] /
1300/// [`App::on_wheel_event`].
1301///
1302/// Event handlers regularly need post-layout geometry to make a
1303/// decision — "which room row is under this drop?", "what size did
1304/// the lightbox body actually get?" — and the handler's state owns no
1305/// node, so it can't have carried the rect itself. `EventCx` is the
1306/// damascene analogue of the DOM's ambient `document`: a handle into
1307/// the retained layout the user is currently looking at, queryable by
1308/// key (`element.getBoundingClientRect()` shape). Geometry answers
1309/// from the last laid-out frame — exactly what's on screen when the
1310/// event fires.
1311///
1312/// Like [`BuildCx`], the struct is opaque so the API stays additive:
1313/// new accessors don't break apps that ignore the context.
1314#[derive(Copy, Clone, Debug, Default)]
1315pub struct EventCx<'a> {
1316    ui_state: Option<&'a crate::state::UiState>,
1317    diagnostics: Option<&'a HostDiagnostics>,
1318    viewport: Option<(f32, f32)>,
1319}
1320
1321impl<'a> EventCx<'a> {
1322    /// Construct an empty context. Headless tests that drive
1323    /// [`App::on_event`] directly use this; real hosts chain
1324    /// [`Self::with_ui_state`] so geometry queries can answer.
1325    pub fn new() -> Self {
1326        Self::default()
1327    }
1328
1329    /// Attach the runtime's [`crate::state::UiState`] so geometry
1330    /// accessors can answer. Hosts call this at every dispatch site;
1331    /// when omitted, the accessors return `None`.
1332    pub fn with_ui_state(mut self, ui_state: &'a crate::state::UiState) -> Self {
1333        self.ui_state = Some(ui_state);
1334        self
1335    }
1336
1337    /// Attach the host's most recent [`HostDiagnostics`] snapshot —
1338    /// the one from the last built frame. Hosts chain this at every
1339    /// dispatch site so handlers can branch on negotiated output
1340    /// state (e.g. [`HostDiagnostics::hdr_active`], working color
1341    /// space) without mirroring it from `build` through app state.
1342    pub fn with_diagnostics(mut self, diagnostics: &'a HostDiagnostics) -> Self {
1343        self.diagnostics = Some(diagnostics);
1344        self
1345    }
1346
1347    /// Attach the logical-pixel viewport size the user is currently
1348    /// looking at. Hosts chain this so handlers that make
1349    /// layout-dependent decisions (grid-column navigation, breakpoint
1350    /// branches) don't have to stash the viewport from `build` in app
1351    /// state.
1352    pub fn with_viewport(mut self, width: f32, height: f32) -> Self {
1353        self.viewport = Some((width, height));
1354        self
1355    }
1356
1357    /// The host's diagnostic snapshot from the last built frame, or
1358    /// `None` when the host did not attach one (headless dispatch).
1359    /// Same data as [`BuildCx::diagnostics`], one frame stale by
1360    /// construction — events fire between frames.
1361    pub fn diagnostics(&self) -> Option<&HostDiagnostics> {
1362        self.diagnostics
1363    }
1364
1365    /// Logical-pixel viewport `(width, height)` at the time this
1366    /// event fires, or `None` when the host attached none. Same
1367    /// contract as [`BuildCx::viewport`].
1368    pub fn viewport(&self) -> Option<(f32, f32)> {
1369        self.viewport
1370    }
1371
1372    /// Logical-pixel viewport width, or `None` when no viewport is
1373    /// attached. Convenience mirroring [`BuildCx::viewport_width`] —
1374    /// the common case is event-time navigation math that must agree
1375    /// with build-time layout (e.g. grid column count).
1376    pub fn viewport_width(&self) -> Option<f32> {
1377        self.viewport.map(|(w, _)| w)
1378    }
1379
1380    /// Logical-pixel viewport height, or `None` when no viewport is
1381    /// attached.
1382    pub fn viewport_height(&self) -> Option<f32> {
1383        self.viewport.map(|(_, h)| h)
1384    }
1385
1386    /// True iff the attached viewport's width is strictly less than
1387    /// `threshold` logical pixels; `false` when no viewport is
1388    /// attached. Same semantics as [`BuildCx::viewport_below`] so
1389    /// build- and event-time breakpoint branches agree.
1390    pub fn viewport_below(&self, threshold: f32) -> bool {
1391        self.viewport_width().is_some_and(|w| w < threshold)
1392    }
1393
1394    /// The laid-out rect of the keyed node `key`, from the layout the
1395    /// user is looking at as this event fires. `None` when the key is
1396    /// absent from that tree (or no `UiState` is attached).
1397    ///
1398    /// This is the first-class shape for "the handler needs to know
1399    /// where a keyed thing landed": resolving a drop target against
1400    /// row rects on `PointerUp`, stepping zoom from a body's fitted
1401    /// size, anchoring app-drawn chrome to a control. The event's own
1402    /// target rect is already on [`UiEvent::target`]; this answers
1403    /// for *other* keys.
1404    pub fn rect_of_key(&self, key: &str) -> Option<Rect> {
1405        self.ui_state?.rect_of_key(key)
1406    }
1407
1408    /// The half-open row-index range the keyed virtual list realized in
1409    /// the layout the user is looking at — see
1410    /// [`BuildCx::visible_range`].
1411    pub fn visible_range(&self, key: &str) -> Option<std::ops::Range<usize>> {
1412        self.ui_state?.visible_range(key)
1413    }
1414
1415    /// The user-dragged size of a keyed
1416    /// [`user_resizable`](crate::tree::El::user_resizable) pane — the
1417    /// value to persist when a [`UiEventKind::Resized`] arrives. See
1418    /// [`BuildCx::user_size`].
1419    pub fn user_size(&self, key: &str) -> Option<f32> {
1420        self.ui_state?.user_size(key)
1421    }
1422}
1423
1424/// The application contract. Implement this on your state struct and
1425/// pass it to a host runner (e.g., `damascene_winit_wgpu::run`).
1426pub trait App {
1427    /// Refresh app-owned external state immediately before a frame is
1428    /// built.
1429    ///
1430    /// Hosts call this once per redraw before [`Self::build`]. Use it
1431    /// for polling an external source, reconciling optimistic local
1432    /// state with a backend snapshot, or advancing host-owned live data
1433    /// that should be visible in the next tree. Keep expensive work
1434    /// outside the render loop; this hook is still on the frame path.
1435    ///
1436    /// This is the drain half of the **mailbox pattern** — background
1437    /// work posts messages over a channel and wakes the host (native:
1438    /// a channel + the winit host's external `Wakeup`; web:
1439    /// `damascene_web::Mailbox`), and this hook folds them into app
1440    /// state so the frame being built sees them. See "Patterns real
1441    /// apps converge on" in the damascene-core README.
1442    ///
1443    /// Default: no-op.
1444    fn before_build(&mut self) {}
1445
1446    /// Project current state into a scene tree. Called whenever the
1447    /// host requests a redraw, after [`Self::before_build`]. Prefer to
1448    /// keep this pure: read current state and return a fresh tree.
1449    ///
1450    /// `cx` carries per-frame, read-only context (active theme, future
1451    /// viewport / phase metadata). Apps that don't need to branch on
1452    /// the theme during construction can ignore the parameter — token
1453    /// references in widget code resolve through the palette
1454    /// automatically.
1455    ///
1456    /// # Page anatomy
1457    ///
1458    /// The returned tree is the *whole window*, and a bare
1459    /// `column([...])` root is almost never what a window wants: it
1460    /// has no padding (content sits flush against window edges and
1461    /// clips under rounded window corners) and no overlay root for
1462    /// `.tooltip()` layers to mount on. Return
1463    /// [`page`](crate::widgets::page::page) — it bakes the window
1464    /// padding + overlay root — and wrap it in
1465    /// [`overlays`](crate::overlays) when the app drives modals or
1466    /// dropdowns:
1467    ///
1468    /// ```ignore
1469    /// fn build(&self, _cx: &BuildCx) -> El {
1470    ///     overlays(
1471    ///         page([toolbar([...]), content()]),
1472    ///         [self.modal_open.then(|| modal("confirm", "Sure?", [...]))],
1473    ///     )
1474    /// }
1475    /// ```
1476    ///
1477    /// For custom anatomy (full-bleed canvases, centered Hug-sized
1478    /// cards), compose `stack([background, content])` by hand — see
1479    /// `damascene-fixtures/src/hero.rs` for the expanded idiom.
1480    fn build(&self, cx: &BuildCx) -> El;
1481
1482    /// Update state in response to a routed event. Default: no-op.
1483    ///
1484    /// `cx` carries read-only frame context — most usefully
1485    /// [`EventCx::rect_of_key`], for decisions that depend on where a
1486    /// keyed node landed in the layout the user is looking at
1487    /// (resolving a drop target on `PointerUp`, stepping zoom from a
1488    /// measured size). Handlers that don't consult layout ignore it.
1489    fn on_event(&mut self, _event: UiEvent, _cx: &EventCx) {}
1490
1491    /// Update state in response to routed wheel input.
1492    ///
1493    /// Return `true` to consume the wheel and suppress Damascene's default
1494    /// scroll routing. The default forwards to [`Self::on_event`] and
1495    /// returns `false`, so existing apps can observe wheel events
1496    /// without opting out of normal scrolling.
1497    fn on_wheel_event(&mut self, event: UiEvent, cx: &EventCx) -> bool {
1498        self.on_event(event, cx);
1499        false
1500    }
1501
1502    /// The application's current text [`crate::selection::Selection`].
1503    /// Read by the host once per frame so the library can paint
1504    /// highlight bands and resolve `selected_text` for clipboard.
1505    /// Apps that own a `Selection` field return a clone here; the
1506    /// default returns the empty selection.
1507    fn selection(&self) -> crate::selection::Selection {
1508        crate::selection::Selection::default()
1509    }
1510
1511    /// App-level hotkey registry. The library matches incoming key
1512    /// presses against this list before its own focus-activation
1513    /// routing; a match emits a [`UiEvent`] with `kind =
1514    /// UiEventKind::Hotkey` and `key = Some(name)`.
1515    ///
1516    /// Called once per build cycle; the host runner snapshots the list
1517    /// alongside `build()` so the chords stay in sync with state.
1518    /// Default: no hotkeys.
1519    ///
1520    /// # Multi-window scoping
1521    ///
1522    /// Hotkeys are scoped per `Runner`, and a multi-window host owns
1523    /// one `Runner` per window — so the contract is simply: **feed
1524    /// each window's `Runner` only that window's hotkey list**, and
1525    /// route each window's key events only to its own `Runner` (which
1526    /// a winit host does naturally, keyed by `WindowId`). A chord then
1527    /// fires in the window the OS focused, never globally. There is no
1528    /// cross-window registry to deduplicate or shadow; "global"
1529    /// accelerators are app policy — register the chord in every
1530    /// window's list and treat the resulting per-window `Hotkey` event
1531    /// as the same action.
1532    fn hotkeys(&self) -> Vec<(KeyChord, String)> {
1533        Vec::new()
1534    }
1535
1536    /// Drain pending toast notifications produced since the last
1537    /// frame. The runtime calls this once per `prepare_layout`,
1538    /// stamps each spec with a monotonic id and `expires_at = now +
1539    /// ttl`, queues it in the runtime toast state, and
1540    /// synthesizes a `toast_stack` layer at the El root so the
1541    /// rendered tree mirrors the visible state. Apps typically
1542    /// accumulate specs in a `Vec<ToastSpec>` field from event
1543    /// handlers, then `mem::take` it here.
1544    ///
1545    /// **Root requirement:** apps that produce toasts (or use
1546    /// `.tooltip(text)` on any node) must wrap their
1547    /// [`Self::build`] return value in `overlays(main, [])` so the
1548    /// runtime can append the floating layer as an overlay sibling
1549    /// — same convention used for popovers and modals. Debug
1550    /// builds panic if the synthesizer runs against a non-overlay
1551    /// root.
1552    ///
1553    /// Default: no toasts.
1554    fn drain_toasts(&mut self) -> Vec<crate::toast::ToastSpec> {
1555        Vec::new()
1556    }
1557
1558    /// Drain pending programmatic focus requests produced since the
1559    /// last frame. The runtime calls this once per `prepare_layout`,
1560    /// after the focus order has been rebuilt from the new tree, and
1561    /// resolves each entry against the keyed focusables. Unmatched
1562    /// keys (widget absent from the rebuilt tree, or not focusable)
1563    /// are dropped silently.
1564    ///
1565    /// This is the imperative companion to keyboard `Tab` traversal:
1566    /// use it for affordances like *Ctrl+F → focus the search input*,
1567    /// *jump-to-match → focus the matched row*, or *open inline edit
1568    /// → focus the field*. Apps typically accumulate keys in a
1569    /// `Vec<String>` field from event handlers and `mem::take` it
1570    /// here.
1571    ///
1572    /// Multiple requests in one frame resolve in order; the last
1573    /// successfully-resolved key is the one focused.
1574    ///
1575    /// Default: no requests.
1576    fn drain_focus_requests(&mut self) -> Vec<String> {
1577        Vec::new()
1578    }
1579
1580    /// Drain pending programmatic scroll requests. The runtime
1581    /// resolves each request during layout, using live viewport rects
1582    /// and row-height/content geometry that apps should not duplicate.
1583    /// Unmatched keys and out-of-range row indices drop silently.
1584    ///
1585    /// Use [`crate::scroll::ScrollRequest::ToRow`] for virtual-list
1586    /// affordances such as jump-to-search-result, reveal selected row,
1587    /// or scroll-to-top-on-tab-change. Use
1588    /// [`crate::scroll::ScrollRequest::EnsureVisible`] for widgets
1589    /// with an internal scroll viewport, including fixed-height
1590    /// [`crate::widgets::text_area`] caret-into-view after accepted
1591    /// edit/navigation events. Apps typically accumulate requests in a
1592    /// `Vec<ScrollRequest>` field from event handlers and
1593    /// `mem::take` it here.
1594    ///
1595    /// Default: no requests.
1596    fn drain_scroll_requests(&mut self) -> Vec<crate::scroll::ScrollRequest> {
1597        Vec::new()
1598    }
1599
1600    /// Drain pending URL-open requests produced since the last frame.
1601    /// Hosts call this once per frame and route each URL to a
1602    /// platform-appropriate opener — `window.open` in the wasm host,
1603    /// the `open` crate (or equivalent) on native.
1604    ///
1605    /// The library emits [`UiEventKind::LinkActivated`] when a click
1606    /// lands on a text run carrying a link URL, but it does not act
1607    /// on the URL itself: opening a link is an app concern (apps may
1608    /// want to confirm, filter by scheme, route through an internal
1609    /// router, or no-op entirely). Apps that want the default
1610    /// browser-style behavior accumulate URLs from
1611    /// [`UiEventKind::LinkActivated`] in their `on_event` handler and
1612    /// return them here; apps that don't override this method drop
1613    /// link clicks on the floor.
1614    ///
1615    /// Default: no requests.
1616    fn drain_link_opens(&mut self) -> Vec<String> {
1617        Vec::new()
1618    }
1619
1620    /// Custom shaders this app needs registered. Each entry carries
1621    /// the shader name, its WGSL source, and per-flag opt-ins
1622    /// (backdrop sampling, time-driven motion). The host runner
1623    /// registers them once at startup via
1624    /// `Runner::register_shader_with(name, wgsl, samples_backdrop, samples_time)`.
1625    ///
1626    /// Backends that don't support backdrop sampling skip entries with
1627    /// `samples_backdrop=true`; any node bound to such a shader will
1628    /// draw nothing on those backends rather than mis-render.
1629    /// `samples_time=true` declares that the shader's output depends
1630    /// on `frame.time`, which keeps the host idle loop ticking while
1631    /// any node is bound to it.
1632    ///
1633    /// Default: no shaders.
1634    fn shaders(&self) -> Vec<AppShader> {
1635        Vec::new()
1636    }
1637
1638    /// Runtime paint theme for this app. Hosts apply it to the renderer
1639    /// before preparing each frame so stateful apps can switch global
1640    /// material routing without backend-specific calls.
1641    fn theme(&self) -> crate::Theme {
1642        crate::Theme::default()
1643    }
1644}
1645
1646/// One custom shader registration, returned from [`App::shaders`].
1647#[derive(Clone, Copy, Debug)]
1648pub struct AppShader {
1649    /// Registration name that nodes reference to bind the shader.
1650    pub name: &'static str,
1651    /// WGSL source the host registers with the backend at startup.
1652    pub wgsl: &'static str,
1653    /// Reads the prior pass's color target (`@group(2) backdrop_tex`).
1654    /// Backends without backdrop support skip these.
1655    pub samples_backdrop: bool,
1656    /// Reads `frame.time` and so requires continuous redraw whenever
1657    /// any node is bound to it. The runtime ORs this into
1658    /// `PrepareResult::needs_redraw` per frame.
1659    pub samples_time: bool,
1660}
1661
1662#[cfg(test)]
1663mod tests {
1664    use super::*;
1665
1666    #[test]
1667    fn pointer_button_from_linux_evdev_codes() {
1668        assert_eq!(
1669            PointerButton::from_linux_button(0x110),
1670            Some(PointerButton::Primary)
1671        );
1672        assert_eq!(
1673            PointerButton::from_linux_button(0x111),
1674            Some(PointerButton::Secondary)
1675        );
1676        assert_eq!(
1677            PointerButton::from_linux_button(0x112),
1678            Some(PointerButton::Middle)
1679        );
1680        // BTN_SIDE / BTN_EXTRA — not surfaced.
1681        assert_eq!(PointerButton::from_linux_button(0x113), None);
1682        assert_eq!(PointerButton::from_linux_button(0), None);
1683    }
1684    use crate::Theme;
1685
1686    #[test]
1687    fn viewport_unset_returns_none_and_breakpoint_returns_false() {
1688        let theme = Theme::default();
1689        let cx = BuildCx::new(&theme);
1690        assert!(cx.viewport().is_none());
1691        assert!(cx.viewport_width().is_none());
1692        assert!(!cx.viewport_below(600.0));
1693    }
1694
1695    #[test]
1696    fn build_cx_surfaces_hovered_scene_point() {
1697        use crate::scene::ScenePointPick;
1698        let theme = Theme::default();
1699        let mut ui = crate::state::UiState::new();
1700
1701        // No attached state, and none stored → nothing to surface.
1702        assert!(BuildCx::new(&theme).hovered_scene_point().is_none());
1703        assert!(
1704            BuildCx::new(&theme)
1705                .with_ui_state(&ui)
1706                .hovered_scene_point()
1707                .is_none()
1708        );
1709
1710        // After the runtime stores a pick, the app reads it at build.
1711        ui.set_hovered_scene_point(Some(ScenePointPick {
1712            scene: "scene".into(),
1713            mark: 0,
1714            point: 4,
1715        }));
1716        let cx = BuildCx::new(&theme).with_ui_state(&ui);
1717        let pick = cx.hovered_scene_point().expect("pick surfaced");
1718        assert_eq!(
1719            (pick.scene.as_str(), pick.mark, pick.point),
1720            ("scene", 0, 4)
1721        );
1722    }
1723
1724    #[test]
1725    fn viewport_set_exposes_width_and_height() {
1726        let theme = Theme::default();
1727        let cx = BuildCx::new(&theme).with_viewport(420.0, 800.0);
1728        assert_eq!(cx.viewport(), Some((420.0, 800.0)));
1729        assert_eq!(cx.viewport_width(), Some(420.0));
1730        assert_eq!(cx.viewport_height(), Some(800.0));
1731    }
1732
1733    #[test]
1734    fn hdr_active_needs_output_evidence_and_wide_chosen_format() {
1735        use crate::color::{ColorManagementStatus, CompositorColorTargets, TransferFunction};
1736
1737        let hdr_targets = CompositorColorTargets {
1738            preferred_transfer: Some(TransferFunction::Pq),
1739            ..Default::default()
1740        };
1741        let scrgb_surface = SurfaceColorInfo {
1742            formats: vec![
1743                SurfaceFormatInfo {
1744                    name: "Bgra8UnormSrgb".into(),
1745                    srgb: true,
1746                    wide: false,
1747                },
1748                SurfaceFormatInfo {
1749                    name: "Rgba16Float".into(),
1750                    srgb: false,
1751                    wide: true,
1752                },
1753            ],
1754            chosen_format: "Rgba16Float".into(),
1755            ..Default::default()
1756        };
1757
1758        let mut d = HostDiagnostics::default();
1759        // Default: no protocol, no surface info.
1760        assert!(!d.hdr_active());
1761
1762        // HDR output + scRGB swapchain → active. `attached` stays None
1763        // on the no-attach host — it must not factor in.
1764        d.color_management = ColorManagementStatus::Available {
1765            capabilities: Default::default(),
1766            attached: None,
1767            targets: hdr_targets.clone(),
1768        };
1769        d.surface_color = Some(scrgb_surface.clone());
1770        assert!(d.hdr_active());
1771
1772        // HDR output but the negotiator stayed on 8-bit sRGB (e.g. the
1773        // app is sdr_only) → not active.
1774        d.surface_color = Some(SurfaceColorInfo {
1775            chosen_format: "Bgra8UnormSrgb".into(),
1776            ..scrgb_surface.clone()
1777        });
1778        assert!(!d.hdr_active());
1779
1780        // Wide swapchain but no HDR evidence from the output → not active.
1781        d.color_management = ColorManagementStatus::Available {
1782            capabilities: Default::default(),
1783            attached: None,
1784            targets: CompositorColorTargets::default(),
1785        };
1786        d.surface_color = Some(scrgb_surface);
1787        assert!(!d.hdr_active());
1788    }
1789
1790    #[test]
1791    fn viewport_below_uses_strict_less_than() {
1792        let theme = Theme::default();
1793        let cx = BuildCx::new(&theme).with_viewport(600.0, 800.0);
1794        assert!(!cx.viewport_below(600.0), "boundary is exclusive");
1795        assert!(cx.viewport_below(601.0));
1796        assert!(!cx.viewport_below(599.0));
1797    }
1798}