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