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