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