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}