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 live pose of a keyed scene camera by computed id — see
1278 /// [`UiState::scene_camera`](crate::state::UiState::scene_camera).
1279 /// Combined with the node rect and a pointer position, an app builds a
1280 /// screen→world ray (`view_proj().inverse()`) to pick or place geometry on
1281 /// a plane. The id is a node's computed id, e.g. a build-time
1282 /// [`BuildCx::rect_of_key`]-keyed scene's resolved id.
1283 pub fn scene_camera(&self, id: &str) -> Option<crate::scene::CameraState> {
1284 self.ui_state?.scene_camera(id)
1285 }
1286
1287 /// The half-open row-index range the keyed virtual list realized in
1288 /// the previous frame's layout, or `None` when the key isn't a
1289 /// laid-out virtual list (or no `UiState` is attached). One frame
1290 /// stale by construction, same contract as [`Self::rect_of_key`].
1291 ///
1292 /// The cache-eviction hook for media-heavy lists: rows outside this
1293 /// range are off-screen, so their decoded images/thumbnails can be
1294 /// dropped and recreated by the row builder when scrolled back.
1295 pub fn visible_range(&self, key: &str) -> Option<std::ops::Range<usize>> {
1296 self.ui_state?.visible_range(key)
1297 }
1298
1299 /// The current pan/zoom of the [`viewport`](crate::tree::viewport)
1300 /// keyed `key`, from the last layout — for a zoom-percentage readout
1301 /// or to project content into screen space. `None` until the keyed
1302 /// viewport has been laid out.
1303 pub fn viewport_view(&self, key: &str) -> Option<crate::viewport::ViewportView> {
1304 self.ui_state?.viewport_view_by_key(key)
1305 }
1306
1307 /// The user-dragged size of a keyed
1308 /// [`user_resizable`](crate::tree::El::user_resizable) pane, in
1309 /// logical pixels. `None` until the user's first drag — the pane
1310 /// is still at its declared size. See
1311 /// [`UiState::user_size`](crate::UiState::user_size).
1312 pub fn user_size(&self, key: &str) -> Option<f32> {
1313 self.ui_state?.user_size(key)
1314 }
1315}
1316
1317/// Read-only context passed to [`App::on_event`] /
1318/// [`App::on_wheel_event`].
1319///
1320/// Event handlers regularly need post-layout geometry to make a
1321/// decision — "which room row is under this drop?", "what size did
1322/// the lightbox body actually get?" — and the handler's state owns no
1323/// node, so it can't have carried the rect itself. `EventCx` is the
1324/// damascene analogue of the DOM's ambient `document`: a handle into
1325/// the retained layout the user is currently looking at, queryable by
1326/// key (`element.getBoundingClientRect()` shape). Geometry answers
1327/// from the last laid-out frame — exactly what's on screen when the
1328/// event fires.
1329///
1330/// Like [`BuildCx`], the struct is opaque so the API stays additive:
1331/// new accessors don't break apps that ignore the context.
1332#[derive(Copy, Clone, Debug, Default)]
1333pub struct EventCx<'a> {
1334 ui_state: Option<&'a crate::state::UiState>,
1335 diagnostics: Option<&'a HostDiagnostics>,
1336 viewport: Option<(f32, f32)>,
1337}
1338
1339impl<'a> EventCx<'a> {
1340 /// Construct an empty context. Headless tests that drive
1341 /// [`App::on_event`] directly use this; real hosts chain
1342 /// [`Self::with_ui_state`] so geometry queries can answer.
1343 pub fn new() -> Self {
1344 Self::default()
1345 }
1346
1347 /// Attach the runtime's [`crate::state::UiState`] so geometry
1348 /// accessors can answer. Hosts call this at every dispatch site;
1349 /// when omitted, the accessors return `None`.
1350 pub fn with_ui_state(mut self, ui_state: &'a crate::state::UiState) -> Self {
1351 self.ui_state = Some(ui_state);
1352 self
1353 }
1354
1355 /// Attach the host's most recent [`HostDiagnostics`] snapshot —
1356 /// the one from the last built frame. Hosts chain this at every
1357 /// dispatch site so handlers can branch on negotiated output
1358 /// state (e.g. [`HostDiagnostics::hdr_active`], working color
1359 /// space) without mirroring it from `build` through app state.
1360 pub fn with_diagnostics(mut self, diagnostics: &'a HostDiagnostics) -> Self {
1361 self.diagnostics = Some(diagnostics);
1362 self
1363 }
1364
1365 /// Attach the logical-pixel viewport size the user is currently
1366 /// looking at. Hosts chain this so handlers that make
1367 /// layout-dependent decisions (grid-column navigation, breakpoint
1368 /// branches) don't have to stash the viewport from `build` in app
1369 /// state.
1370 pub fn with_viewport(mut self, width: f32, height: f32) -> Self {
1371 self.viewport = Some((width, height));
1372 self
1373 }
1374
1375 /// The host's diagnostic snapshot from the last built frame, or
1376 /// `None` when the host did not attach one (headless dispatch).
1377 /// Same data as [`BuildCx::diagnostics`], one frame stale by
1378 /// construction — events fire between frames.
1379 pub fn diagnostics(&self) -> Option<&HostDiagnostics> {
1380 self.diagnostics
1381 }
1382
1383 /// Logical-pixel viewport `(width, height)` at the time this
1384 /// event fires, or `None` when the host attached none. Same
1385 /// contract as [`BuildCx::viewport`].
1386 pub fn viewport(&self) -> Option<(f32, f32)> {
1387 self.viewport
1388 }
1389
1390 /// Logical-pixel viewport width, or `None` when no viewport is
1391 /// attached. Convenience mirroring [`BuildCx::viewport_width`] —
1392 /// the common case is event-time navigation math that must agree
1393 /// with build-time layout (e.g. grid column count).
1394 pub fn viewport_width(&self) -> Option<f32> {
1395 self.viewport.map(|(w, _)| w)
1396 }
1397
1398 /// Logical-pixel viewport height, or `None` when no viewport is
1399 /// attached.
1400 pub fn viewport_height(&self) -> Option<f32> {
1401 self.viewport.map(|(_, h)| h)
1402 }
1403
1404 /// True iff the attached viewport's width is strictly less than
1405 /// `threshold` logical pixels; `false` when no viewport is
1406 /// attached. Same semantics as [`BuildCx::viewport_below`] so
1407 /// build- and event-time breakpoint branches agree.
1408 pub fn viewport_below(&self, threshold: f32) -> bool {
1409 self.viewport_width().is_some_and(|w| w < threshold)
1410 }
1411
1412 /// The laid-out rect of the keyed node `key`, from the layout the
1413 /// user is looking at as this event fires. `None` when the key is
1414 /// absent from that tree (or no `UiState` is attached).
1415 ///
1416 /// This is the first-class shape for "the handler needs to know
1417 /// where a keyed thing landed": resolving a drop target against
1418 /// row rects on `PointerUp`, stepping zoom from a body's fitted
1419 /// size, anchoring app-drawn chrome to a control. The event's own
1420 /// target rect is already on [`UiEvent::target`]; this answers
1421 /// for *other* keys.
1422 pub fn rect_of_key(&self, key: &str) -> Option<Rect> {
1423 self.ui_state?.rect_of_key(key)
1424 }
1425
1426 /// The live pose of a keyed scene camera by computed id (the moment the
1427 /// handler runs) — see
1428 /// [`UiState::scene_camera`](crate::state::UiState::scene_camera).
1429 /// Combined with [`UiEvent::target`]'s rect + pointer, build a
1430 /// screen→world ray for picking/placement; `id` is typically that
1431 /// target's `node_id`.
1432 ///
1433 /// [`UiEvent::target`]: crate::event::UiEvent::target
1434 pub fn scene_camera(&self, id: &str) -> Option<crate::scene::CameraState> {
1435 self.ui_state?.scene_camera(id)
1436 }
1437
1438 /// The half-open row-index range the keyed virtual list realized in
1439 /// the layout the user is looking at — see
1440 /// [`BuildCx::visible_range`].
1441 pub fn visible_range(&self, key: &str) -> Option<std::ops::Range<usize>> {
1442 self.ui_state?.visible_range(key)
1443 }
1444
1445 /// The current pan/zoom of the [`viewport`](crate::tree::viewport)
1446 /// keyed `key`, from the last layout — for a zoom-percentage readout
1447 /// or to project content into screen space. `None` until the keyed
1448 /// viewport has been laid out.
1449 pub fn viewport_view(&self, key: &str) -> Option<crate::viewport::ViewportView> {
1450 self.ui_state?.viewport_view_by_key(key)
1451 }
1452
1453 /// The user-dragged size of a keyed
1454 /// [`user_resizable`](crate::tree::El::user_resizable) pane — the
1455 /// value to persist when a [`UiEventKind::Resized`] arrives. See
1456 /// [`BuildCx::user_size`].
1457 pub fn user_size(&self, key: &str) -> Option<f32> {
1458 self.ui_state?.user_size(key)
1459 }
1460}
1461
1462/// The application contract. Implement this on your state struct and
1463/// pass it to a host runner (e.g., `damascene_winit_wgpu::run`).
1464pub trait App {
1465 /// Refresh app-owned external state immediately before a frame is
1466 /// built.
1467 ///
1468 /// Hosts call this once per redraw before [`Self::build`]. Use it
1469 /// for polling an external source, reconciling optimistic local
1470 /// state with a backend snapshot, or advancing host-owned live data
1471 /// that should be visible in the next tree. Keep expensive work
1472 /// outside the render loop; this hook is still on the frame path.
1473 ///
1474 /// This is the drain half of the **mailbox pattern** — background
1475 /// work posts messages over a channel and wakes the host (native:
1476 /// a channel + the winit host's external `Wakeup`; web:
1477 /// `damascene_web::Mailbox`), and this hook folds them into app
1478 /// state so the frame being built sees them. See "Patterns real
1479 /// apps converge on" in the damascene-core README.
1480 ///
1481 /// Default: no-op.
1482 fn before_build(&mut self) {}
1483
1484 /// Project current state into a scene tree. Called whenever the
1485 /// host requests a redraw, after [`Self::before_build`]. Prefer to
1486 /// keep this pure: read current state and return a fresh tree.
1487 ///
1488 /// `cx` carries per-frame, read-only context (active theme, future
1489 /// viewport / phase metadata). Apps that don't need to branch on
1490 /// the theme during construction can ignore the parameter — token
1491 /// references in widget code resolve through the palette
1492 /// automatically.
1493 ///
1494 /// # Page anatomy
1495 ///
1496 /// The returned tree is the *whole window*, and a bare
1497 /// `column([...])` root is almost never what a window wants: it
1498 /// has no padding (content sits flush against window edges and
1499 /// clips under rounded window corners) and no overlay root for
1500 /// `.tooltip()` layers to mount on. Return
1501 /// [`page`](crate::widgets::page::page) — it bakes the window
1502 /// padding + overlay root — and wrap it in
1503 /// [`overlays`](crate::overlays) when the app drives modals or
1504 /// dropdowns:
1505 ///
1506 /// ```ignore
1507 /// fn build(&self, _cx: &BuildCx) -> El {
1508 /// overlays(
1509 /// page([toolbar([...]), content()]),
1510 /// [self.modal_open.then(|| modal("confirm", "Sure?", [...]))],
1511 /// )
1512 /// }
1513 /// ```
1514 ///
1515 /// For custom anatomy (full-bleed canvases, centered Hug-sized
1516 /// cards), compose `stack([background, content])` by hand — see
1517 /// `damascene-fixtures/src/hero.rs` for the expanded idiom.
1518 fn build(&self, cx: &BuildCx) -> El;
1519
1520 /// Update state in response to a routed event. Default: no-op.
1521 ///
1522 /// `cx` carries read-only frame context — most usefully
1523 /// [`EventCx::rect_of_key`], for decisions that depend on where a
1524 /// keyed node landed in the layout the user is looking at
1525 /// (resolving a drop target on `PointerUp`, stepping zoom from a
1526 /// measured size). Handlers that don't consult layout ignore it.
1527 fn on_event(&mut self, _event: UiEvent, _cx: &EventCx) {}
1528
1529 /// Update state in response to routed wheel input.
1530 ///
1531 /// Return `true` to consume the wheel and suppress Damascene's default
1532 /// scroll routing. The default forwards to [`Self::on_event`] and
1533 /// returns `false`, so existing apps can observe wheel events
1534 /// without opting out of normal scrolling.
1535 fn on_wheel_event(&mut self, event: UiEvent, cx: &EventCx) -> bool {
1536 self.on_event(event, cx);
1537 false
1538 }
1539
1540 /// The application's current text [`crate::selection::Selection`].
1541 /// Read by the host once per frame so the library can paint
1542 /// highlight bands and resolve `selected_text` for clipboard.
1543 /// Apps that own a `Selection` field return a clone here; the
1544 /// default returns the empty selection.
1545 fn selection(&self) -> crate::selection::Selection {
1546 crate::selection::Selection::default()
1547 }
1548
1549 /// App-level hotkey registry. The library matches incoming key
1550 /// presses against this list before its own focus-activation
1551 /// routing; a match emits a [`UiEvent`] with `kind =
1552 /// UiEventKind::Hotkey` and `key = Some(name)`.
1553 ///
1554 /// Called once per build cycle; the host runner snapshots the list
1555 /// alongside `build()` so the chords stay in sync with state.
1556 /// Default: no hotkeys.
1557 ///
1558 /// # Multi-window scoping
1559 ///
1560 /// Hotkeys are scoped per `Runner`, and a multi-window host owns
1561 /// one `Runner` per window — so the contract is simply: **feed
1562 /// each window's `Runner` only that window's hotkey list**, and
1563 /// route each window's key events only to its own `Runner` (which
1564 /// a winit host does naturally, keyed by `WindowId`). A chord then
1565 /// fires in the window the OS focused, never globally. There is no
1566 /// cross-window registry to deduplicate or shadow; "global"
1567 /// accelerators are app policy — register the chord in every
1568 /// window's list and treat the resulting per-window `Hotkey` event
1569 /// as the same action.
1570 fn hotkeys(&self) -> Vec<(KeyChord, String)> {
1571 Vec::new()
1572 }
1573
1574 /// Drain pending toast notifications produced since the last
1575 /// frame. The runtime calls this once per `prepare_layout`,
1576 /// stamps each spec with a monotonic id and `expires_at = now +
1577 /// ttl`, queues it in the runtime toast state, and
1578 /// synthesizes a `toast_stack` layer at the El root so the
1579 /// rendered tree mirrors the visible state. Apps typically
1580 /// accumulate specs in a `Vec<ToastSpec>` field from event
1581 /// handlers, then `mem::take` it here.
1582 ///
1583 /// **Root requirement:** apps that produce toasts (or use
1584 /// `.tooltip(text)` on any node) must wrap their
1585 /// [`Self::build`] return value in `overlays(main, [])` so the
1586 /// runtime can append the floating layer as an overlay sibling
1587 /// — same convention used for popovers and modals. Debug
1588 /// builds panic if the synthesizer runs against a non-overlay
1589 /// root.
1590 ///
1591 /// Default: no toasts.
1592 fn drain_toasts(&mut self) -> Vec<crate::toast::ToastSpec> {
1593 Vec::new()
1594 }
1595
1596 /// Drain pending programmatic focus requests produced since the
1597 /// last frame. The runtime calls this once per `prepare_layout`,
1598 /// after the focus order has been rebuilt from the new tree, and
1599 /// resolves each entry against the keyed focusables. Unmatched
1600 /// keys (widget absent from the rebuilt tree, or not focusable)
1601 /// are dropped silently.
1602 ///
1603 /// This is the imperative companion to keyboard `Tab` traversal:
1604 /// use it for affordances like *Ctrl+F → focus the search input*,
1605 /// *jump-to-match → focus the matched row*, or *open inline edit
1606 /// → focus the field*. Apps typically accumulate keys in a
1607 /// `Vec<String>` field from event handlers and `mem::take` it
1608 /// here.
1609 ///
1610 /// Multiple requests in one frame resolve in order; the last
1611 /// successfully-resolved key is the one focused.
1612 ///
1613 /// Default: no requests.
1614 fn drain_focus_requests(&mut self) -> Vec<String> {
1615 Vec::new()
1616 }
1617
1618 /// Drain pending programmatic scroll requests. The runtime
1619 /// resolves each request during layout, using live viewport rects
1620 /// and row-height/content geometry that apps should not duplicate.
1621 /// Unmatched keys and out-of-range row indices drop silently.
1622 ///
1623 /// Use [`crate::scroll::ScrollRequest::ToRow`] for virtual-list
1624 /// affordances such as jump-to-search-result, reveal selected row,
1625 /// or scroll-to-top-on-tab-change. Use
1626 /// [`crate::scroll::ScrollRequest::EnsureVisible`] for widgets
1627 /// with an internal scroll viewport, including fixed-height
1628 /// [`crate::widgets::text_area`] caret-into-view after accepted
1629 /// edit/navigation events. Apps typically accumulate requests in a
1630 /// `Vec<ScrollRequest>` field from event handlers and
1631 /// `mem::take` it here.
1632 ///
1633 /// Default: no requests.
1634 fn drain_scroll_requests(&mut self) -> Vec<crate::scroll::ScrollRequest> {
1635 Vec::new()
1636 }
1637
1638 /// Drain programmatic [`crate::viewport::ViewportRequest`]s produced
1639 /// since the last frame — fit-to-content, reset, or center a
1640 /// [`crate::tree::viewport`] by its `.key(...)`. Hosts call this once
1641 /// per frame and forward to
1642 /// [`crate::runtime::RunnerCore::push_viewport_requests`]; each
1643 /// request is consumed during layout of the matching viewport, where
1644 /// its live rect and content extents are known. Apps accumulate
1645 /// requests from event handlers (e.g. a "Fit" toolbar button) in a
1646 /// `Vec<ViewportRequest>` field and `mem::take` it here, mirroring
1647 /// [`Self::drain_scroll_requests`].
1648 ///
1649 /// Default: no requests.
1650 fn drain_viewport_requests(&mut self) -> Vec<crate::viewport::ViewportRequest> {
1651 Vec::new()
1652 }
1653
1654 /// Drain pending URL-open requests produced since the last frame.
1655 /// Hosts call this once per frame and route each URL to a
1656 /// platform-appropriate opener — `window.open` in the wasm host,
1657 /// the `open` crate (or equivalent) on native.
1658 ///
1659 /// The library emits [`UiEventKind::LinkActivated`] when a click
1660 /// lands on a text run carrying a link URL, but it does not act
1661 /// on the URL itself: opening a link is an app concern (apps may
1662 /// want to confirm, filter by scheme, route through an internal
1663 /// router, or no-op entirely). Apps that want the default
1664 /// browser-style behavior accumulate URLs from
1665 /// [`UiEventKind::LinkActivated`] in their `on_event` handler and
1666 /// return them here; apps that don't override this method drop
1667 /// link clicks on the floor.
1668 ///
1669 /// Default: no requests.
1670 fn drain_link_opens(&mut self) -> Vec<String> {
1671 Vec::new()
1672 }
1673
1674 /// Custom shaders this app needs registered. Each entry carries
1675 /// the shader name, its WGSL source, and per-flag opt-ins
1676 /// (backdrop sampling, time-driven motion). The host runner
1677 /// registers them once at startup via
1678 /// `Runner::register_shader_with(name, wgsl, samples_backdrop, samples_time)`.
1679 ///
1680 /// Backends that don't support backdrop sampling skip entries with
1681 /// `samples_backdrop=true`; any node bound to such a shader will
1682 /// draw nothing on those backends rather than mis-render.
1683 /// `samples_time=true` declares that the shader's output depends
1684 /// on `frame.time`, which keeps the host idle loop ticking while
1685 /// any node is bound to it.
1686 ///
1687 /// Default: no shaders.
1688 fn shaders(&self) -> Vec<AppShader> {
1689 Vec::new()
1690 }
1691
1692 /// Runtime paint theme for this app. Hosts apply it to the renderer
1693 /// before preparing each frame so stateful apps can switch global
1694 /// material routing without backend-specific calls.
1695 fn theme(&self) -> crate::Theme {
1696 crate::Theme::default()
1697 }
1698}
1699
1700/// One custom shader registration, returned from [`App::shaders`].
1701#[derive(Clone, Copy, Debug)]
1702pub struct AppShader {
1703 /// Registration name that nodes reference to bind the shader.
1704 pub name: &'static str,
1705 /// WGSL source the host registers with the backend at startup.
1706 pub wgsl: &'static str,
1707 /// Reads the prior pass's color target (`@group(2) backdrop_tex`).
1708 /// Backends without backdrop support skip these.
1709 pub samples_backdrop: bool,
1710 /// Reads `frame.time` and so requires continuous redraw whenever
1711 /// any node is bound to it. The runtime ORs this into
1712 /// `PrepareResult::needs_redraw` per frame.
1713 pub samples_time: bool,
1714}
1715
1716#[cfg(test)]
1717mod tests {
1718 use super::*;
1719
1720 #[test]
1721 fn pointer_button_from_linux_evdev_codes() {
1722 assert_eq!(
1723 PointerButton::from_linux_button(0x110),
1724 Some(PointerButton::Primary)
1725 );
1726 assert_eq!(
1727 PointerButton::from_linux_button(0x111),
1728 Some(PointerButton::Secondary)
1729 );
1730 assert_eq!(
1731 PointerButton::from_linux_button(0x112),
1732 Some(PointerButton::Middle)
1733 );
1734 // BTN_SIDE / BTN_EXTRA — not surfaced.
1735 assert_eq!(PointerButton::from_linux_button(0x113), None);
1736 assert_eq!(PointerButton::from_linux_button(0), None);
1737 }
1738 use crate::Theme;
1739
1740 #[test]
1741 fn viewport_unset_returns_none_and_breakpoint_returns_false() {
1742 let theme = Theme::default();
1743 let cx = BuildCx::new(&theme);
1744 assert!(cx.viewport().is_none());
1745 assert!(cx.viewport_width().is_none());
1746 assert!(!cx.viewport_below(600.0));
1747 }
1748
1749 #[test]
1750 fn build_cx_surfaces_hovered_scene_point() {
1751 use crate::scene::ScenePointPick;
1752 let theme = Theme::default();
1753 let mut ui = crate::state::UiState::new();
1754
1755 // No attached state, and none stored → nothing to surface.
1756 assert!(BuildCx::new(&theme).hovered_scene_point().is_none());
1757 assert!(
1758 BuildCx::new(&theme)
1759 .with_ui_state(&ui)
1760 .hovered_scene_point()
1761 .is_none()
1762 );
1763
1764 // After the runtime stores a pick, the app reads it at build.
1765 ui.set_hovered_scene_point(Some(ScenePointPick {
1766 scene: "scene".into(),
1767 mark: 0,
1768 point: 4,
1769 }));
1770 let cx = BuildCx::new(&theme).with_ui_state(&ui);
1771 let pick = cx.hovered_scene_point().expect("pick surfaced");
1772 assert_eq!(
1773 (pick.scene.as_str(), pick.mark, pick.point),
1774 ("scene", 0, 4)
1775 );
1776 }
1777
1778 #[test]
1779 fn viewport_set_exposes_width_and_height() {
1780 let theme = Theme::default();
1781 let cx = BuildCx::new(&theme).with_viewport(420.0, 800.0);
1782 assert_eq!(cx.viewport(), Some((420.0, 800.0)));
1783 assert_eq!(cx.viewport_width(), Some(420.0));
1784 assert_eq!(cx.viewport_height(), Some(800.0));
1785 }
1786
1787 #[test]
1788 fn hdr_active_needs_output_evidence_and_wide_chosen_format() {
1789 use crate::color::{ColorManagementStatus, CompositorColorTargets, TransferFunction};
1790
1791 let hdr_targets = CompositorColorTargets {
1792 preferred_transfer: Some(TransferFunction::Pq),
1793 ..Default::default()
1794 };
1795 let scrgb_surface = SurfaceColorInfo {
1796 formats: vec![
1797 SurfaceFormatInfo {
1798 name: "Bgra8UnormSrgb".into(),
1799 srgb: true,
1800 wide: false,
1801 },
1802 SurfaceFormatInfo {
1803 name: "Rgba16Float".into(),
1804 srgb: false,
1805 wide: true,
1806 },
1807 ],
1808 chosen_format: "Rgba16Float".into(),
1809 ..Default::default()
1810 };
1811
1812 let mut d = HostDiagnostics::default();
1813 // Default: no protocol, no surface info.
1814 assert!(!d.hdr_active());
1815
1816 // HDR output + scRGB swapchain → active. `attached` stays None
1817 // on the no-attach host — it must not factor in.
1818 d.color_management = ColorManagementStatus::Available {
1819 capabilities: Default::default(),
1820 attached: None,
1821 targets: hdr_targets.clone(),
1822 };
1823 d.surface_color = Some(scrgb_surface.clone());
1824 assert!(d.hdr_active());
1825
1826 // HDR output but the negotiator stayed on 8-bit sRGB (e.g. the
1827 // app is sdr_only) → not active.
1828 d.surface_color = Some(SurfaceColorInfo {
1829 chosen_format: "Bgra8UnormSrgb".into(),
1830 ..scrgb_surface.clone()
1831 });
1832 assert!(!d.hdr_active());
1833
1834 // Wide swapchain but no HDR evidence from the output → not active.
1835 d.color_management = ColorManagementStatus::Available {
1836 capabilities: Default::default(),
1837 attached: None,
1838 targets: CompositorColorTargets::default(),
1839 };
1840 d.surface_color = Some(scrgb_surface);
1841 assert!(!d.hdr_active());
1842 }
1843
1844 #[test]
1845 fn viewport_below_uses_strict_less_than() {
1846 let theme = Theme::default();
1847 let cx = BuildCx::new(&theme).with_viewport(600.0, 800.0);
1848 assert!(!cx.viewport_below(600.0), "boundary is exclusive");
1849 assert!(cx.viewport_below(601.0));
1850 assert!(!cx.viewport_below(599.0));
1851 }
1852}