Skip to main content

stipple_core/
runtime.rs

1//! The reactive runtime: interaction context, retained layout tree, and event
2//! dispatch.
3//!
4//! Turns the static [`Element`](crate::Element) IR into an interactive UI. A
5//! [`Cx`] is threaded through the view-building closure so widgets can register
6//! `on_tap` (pointer) and `on_key` (keyboard/focus) handlers; building yields
7//! an [`Element`] tree plus a parallel [`Handlers`] table. Laying the tree out
8//! produces a retained [`LayoutNode`] tree that [`hit_test`] (pointer) and
9//! [`focus_at`] / [`collect_focusables`] (keyboard focus) query to route events
10//! back to the registered handlers.
11//!
12//! Handlers stay out of the [`Element`] IR (they live in the `Cx` tables,
13//! addressed by [`ActionId`] / [`FocusId`]) so `Element` stays `Clone`/`Debug`
14//! and the IR remains diff-friendly for a future reconciler.
15
16use crate::element::{BoxStyle, Element};
17use std::collections::{HashMap, HashSet};
18use stipple_geometry::{Point, Rect};
19use stipple_render::Color;
20use stipple_style::Theme;
21
22/// A boxed pointer-tap handler that mutates the app state `S`.
23type TapFn<S> = Box<dyn FnMut(&mut S)>;
24/// A boxed keyboard handler: receives the [`KeyInput`] for the focused element.
25type KeyFn<S> = Box<dyn FnMut(&mut S, &KeyInput)>;
26/// A boxed drag handler: receives the pointer position as a fraction (0..=1)
27/// along the element's width.
28type DragFn<S> = Box<dyn FnMut(&mut S, f64)>;
29/// A boxed text-pointer handler: receives a resolved byte index into the
30/// element's text and whether the gesture *extends* a selection (drag) or
31/// *places* the caret (initial press).
32type TextPosFn<S> = Box<dyn FnMut(&mut S, usize, bool)>;
33/// A boxed secondary-click (context) handler: receives the click position in
34/// logical pixels, so it can open a context menu there.
35type ContextFn<S> = Box<dyn FnMut(&mut S, Point)>;
36
37/// An opaque handle to a registered tap handler, stamped onto the element that
38/// owns it and resolved against the [`Cx`] tap table on dispatch.
39#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
40pub struct ActionId(pub(crate) u32);
41
42/// An opaque handle to a focusable element with a registered key handler.
43#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
44pub struct FocusId(pub(crate) u32);
45
46/// An opaque handle to an element with a registered drag handler.
47#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
48pub struct DragId(pub(crate) u32);
49
50/// An opaque handle to an editable text element with a registered text-pointer
51/// handler (click-to-position / drag-to-select).
52#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
53pub struct TextPosId(pub(crate) u32);
54
55/// An opaque handle to an element with a registered secondary-click (context)
56/// handler, resolved against the [`Cx`] context table on a right-click.
57#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
58pub struct ContextId(pub(crate) u32);
59
60/// An opaque handle to a scroll container. The app keeps a scroll offset per id
61/// (adjusted by wheel events) and re-applies it each frame; the id is stable as
62/// long as the view registers scroll containers in the same order.
63#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
64pub struct ScrollId(pub(crate) u32);
65
66/// A **caller-chosen** handle to an embedded-content viewport — a rectangle the
67/// app fills with externally-rendered pixels (a browser page, video frame, or a
68/// sandboxed content process's GPU surface). Unlike the auto-registered handler
69/// ids, the value is chosen by the app so it stays stable across frames and can
70/// be correlated with the content source that feeds it (see
71/// [`Element::viewport`](crate::Element::viewport)).
72#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
73pub struct ViewportId(pub u32);
74
75/// Where an [`OverlaySpec`] is positioned within the window.
76#[derive(Clone, Copy, Debug, PartialEq)]
77pub enum Anchor {
78    /// Place the overlay's top-left at an absolute window point (e.g. a menu
79    /// dropped below its button).
80    At(Point),
81    /// Center the overlay in the window (e.g. a modal dialog).
82    Center,
83}
84
85/// A floating layer drawn above the main tree — a menu, popover, tooltip, or
86/// dialog. Declared during a build via [`Cx::overlay`]; the app lays it out at
87/// its [`Anchor`] and paints it last (topmost). Its `content`'s handlers
88/// register through the same [`Cx`], so taps/keys inside it work normally.
89#[derive(Clone, Debug)]
90pub struct OverlaySpec {
91    pub content: Element,
92    pub anchor: Anchor,
93    /// When `true`, a translucent scrim is painted behind the overlay and blocks
94    /// pointer events from reaching the main tree (a modal dialog).
95    pub modal: bool,
96    /// Action fired when the scrim (modal) or the area outside the overlay
97    /// (non-modal) is pressed — typically a dismiss handler.
98    pub dismiss: Option<ActionId>,
99}
100
101/// A platform-neutral keyboard input, delivered to the focused element. The
102/// app/platform layer translates raw key events into these.
103#[derive(Clone, Debug, PartialEq, Eq)]
104pub enum KeyInput {
105    /// Committed text (one or more characters), e.g. from a key press or IME.
106    Text(String),
107    Backspace,
108    Delete,
109    Left,
110    Right,
111    Up,
112    Down,
113    Home,
114    End,
115    /// Caret motion that *extends the selection* (Shift held). The `Select*`
116    /// variants mirror the plain motions but keep the selection anchor.
117    SelectLeft,
118    SelectRight,
119    SelectUp,
120    SelectDown,
121    SelectHome,
122    SelectEnd,
123    /// Select everything (e.g. Ctrl/Cmd+A).
124    SelectAll,
125    /// Copy the selection to the clipboard (Ctrl/Cmd+C).
126    Copy,
127    /// Cut the selection to the clipboard (Ctrl/Cmd+X).
128    Cut,
129    /// Paste the clipboard at the caret, replacing the selection (Ctrl/Cmd+V).
130    Paste,
131    Enter,
132    Escape,
133}
134
135/// A platform-neutral input event forwarded to an embedded
136/// [`viewport`](crate::Element::viewport)'s content — a sandboxed browser/content
137/// process that renders into the viewport. Pointer positions are in
138/// **viewport-local** logical pixels (origin at the viewport's top-left), so the
139/// content can route them without knowing where the viewport sits in the window.
140#[derive(Clone, Debug, PartialEq)]
141pub enum ViewportEvent {
142    /// Pointer pressed at `local`. `button`: 0 = primary/left, 1 = secondary/
143    /// right, 2 = middle.
144    PointerDown { local: Point, button: u8 },
145    /// Pointer released at `local` (same `button` encoding as [`PointerDown`]).
146    ///
147    /// [`PointerDown`]: ViewportEvent::PointerDown
148    PointerUp { local: Point, button: u8 },
149    /// Pointer moved to `local` while over the viewport.
150    PointerMove { local: Point },
151    /// Wheel scrolled by `delta_y` logical pixels with the pointer at `local`.
152    Wheel { local: Point, delta_y: f64 },
153    /// Keyboard input delivered while this viewport held input focus (acquired
154    /// when the content was last pressed).
155    Key(KeyInput),
156}
157
158/// Build context threaded through a view closure.
159///
160/// Carries the active [`Theme`] and accumulates event handlers. Registering a
161/// handler returns an id the caller stamps onto an element (see
162/// [`Element::on_tap`](crate::Element::on_tap) /
163/// [`Element::on_key`](crate::Element::on_key)).
164pub struct Cx<'a, S> {
165    theme: &'a Theme,
166    taps: Vec<TapFn<S>>,
167    keys: Vec<KeyFn<S>>,
168    drags: Vec<DragFn<S>>,
169    text_pos: Vec<TextPosFn<S>>,
170    contexts: Vec<ContextFn<S>>,
171    /// Next scroll-container id to hand out (scroll offsets live in the app, not
172    /// here, so we only need a stable per-frame counter).
173    next_scroll: u32,
174    /// Floating layers (menus/dialogs/…) declared this frame via [`Cx::overlay`].
175    overlays: Vec<OverlaySpec>,
176    /// Cross-frame cache for [`Cx::memo`]: cached subtrees by key, plus the keys
177    /// touched this frame (so stale entries can be evicted afterward).
178    memo: HashMap<u64, Element>,
179    memo_used: HashSet<u64>,
180}
181
182impl<'a, S> Cx<'a, S> {
183    /// Create a context borrowing `theme`.
184    pub fn new(theme: &'a Theme) -> Self {
185        Self {
186            theme,
187            taps: Vec::new(),
188            keys: Vec::new(),
189            drags: Vec::new(),
190            text_pos: Vec::new(),
191            contexts: Vec::new(),
192            next_scroll: 0,
193            overlays: Vec::new(),
194            memo: HashMap::new(),
195            memo_used: HashSet::new(),
196        }
197    }
198
199    /// The active theme.
200    pub fn theme(&self) -> &Theme {
201        self.theme
202    }
203
204    /// Register a pointer-tap handler, returning its [`ActionId`].
205    pub fn register(&mut self, handler: impl FnMut(&mut S) + 'static) -> ActionId {
206        let id = ActionId(self.taps.len() as u32);
207        self.taps.push(Box::new(handler));
208        id
209    }
210
211    /// Register a keyboard handler for a focusable element, returning its
212    /// [`FocusId`].
213    pub fn register_key(&mut self, handler: impl FnMut(&mut S, &KeyInput) + 'static) -> FocusId {
214        let id = FocusId(self.keys.len() as u32);
215        self.keys.push(Box::new(handler));
216        id
217    }
218
219    /// Register a drag handler, returning its [`DragId`]. The handler receives
220    /// the pointer's fractional x position (0..=1) across the element.
221    pub fn register_drag(&mut self, handler: impl FnMut(&mut S, f64) + 'static) -> DragId {
222        let id = DragId(self.drags.len() as u32);
223        self.drags.push(Box::new(handler));
224        id
225    }
226
227    /// Register a text-pointer handler, returning its [`TextPosId`]. The handler
228    /// receives a resolved byte index into the element's text and an `extend`
229    /// flag (`false` = place caret, `true` = extend selection).
230    pub fn register_text_pos(
231        &mut self,
232        handler: impl FnMut(&mut S, usize, bool) + 'static,
233    ) -> TextPosId {
234        let id = TextPosId(self.text_pos.len() as u32);
235        self.text_pos.push(Box::new(handler));
236        id
237    }
238
239    /// Register a secondary-click (context) handler, returning its
240    /// [`ContextId`]. The handler receives the right-click position in logical
241    /// pixels — typically used to open a context menu there via [`Cx::overlay`].
242    pub fn register_context(&mut self, handler: impl FnMut(&mut S, Point) + 'static) -> ContextId {
243        let id = ContextId(self.contexts.len() as u32);
244        self.contexts.push(Box::new(handler));
245        id
246    }
247
248    /// Register a scroll container, returning a stable [`ScrollId`]. The app
249    /// keeps the scroll offset for this id and re-applies it each frame; there is
250    /// no handler closure (scrolling adjusts the offset directly).
251    pub fn register_scroll(&mut self) -> ScrollId {
252        let id = ScrollId(self.next_scroll);
253        self.next_scroll += 1;
254        id
255    }
256
257    /// Declare a floating overlay layer (menu/popover/tooltip/dialog) drawn above
258    /// the main tree this frame. Build `spec.content` with this same `Cx` first
259    /// so its handlers register normally.
260    pub fn overlay(&mut self, spec: OverlaySpec) {
261        self.overlays.push(spec);
262    }
263
264    /// Take the overlays declared this frame (the app lays them out + paints them
265    /// on top). Call before [`into_handlers`](Cx::into_handlers).
266    pub fn take_overlays(&mut self) -> Vec<OverlaySpec> {
267        std::mem::take(&mut self.overlays)
268    }
269
270    /// Return a cached, **static** subtree for `key`, building it with `build`
271    /// only when the key is new (or after the cache was seeded from a prior
272    /// frame). On an unchanged key the `build` closure is skipped entirely — the
273    /// previous frame's [`Element`] is cloned — so unchanged branches aren't
274    /// rebuilt. `build` receives no [`Cx`], so a memoized subtree can't register
275    /// event handlers (their ids would desync); use it for display-only content
276    /// like icons, labels, or decorative panels whose look depends on `key`.
277    pub fn memo(&mut self, key: u64, build: impl FnOnce() -> Element) -> Element {
278        self.memo_used.insert(key);
279        if let Some(cached) = self.memo.get(&key) {
280            return cached.clone();
281        }
282        let element = build();
283        self.memo.insert(key, element.clone());
284        element
285    }
286
287    /// Seed the memo cache from the previous frame (see [`Cx::memo`]).
288    pub fn set_memo_cache(&mut self, cache: HashMap<u64, Element>) {
289        self.memo = cache;
290        self.memo_used.clear();
291    }
292
293    /// Take the memo cache back, dropping entries not touched this frame so it
294    /// doesn't grow without bound.
295    pub fn take_memo_cache(&mut self) -> HashMap<u64, Element> {
296        let used = std::mem::take(&mut self.memo_used);
297        let mut cache = std::mem::take(&mut self.memo);
298        cache.retain(|k, _| used.contains(k));
299        cache
300    }
301
302    /// Consume the context, yielding the accumulated [`Handlers`] table.
303    pub fn into_handlers(self) -> Handlers<S> {
304        Handlers {
305            taps: self.taps,
306            keys: self.keys,
307            drags: self.drags,
308            text_pos: self.text_pos,
309            contexts: self.contexts,
310        }
311    }
312}
313
314impl<S> core::fmt::Debug for Cx<'_, S> {
315    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
316        f.debug_struct("Cx")
317            .field("taps", &self.taps.len())
318            .field("keys", &self.keys.len())
319            .field("drags", &self.drags.len())
320            .finish_non_exhaustive()
321    }
322}
323
324/// The handler tables produced by building a frame. Dispatch resolves an
325/// [`ActionId`] / [`FocusId`] (from [`hit_test`] / [`focus_at`]) to its handler
326/// and invokes it against the app state.
327pub struct Handlers<S> {
328    taps: Vec<TapFn<S>>,
329    keys: Vec<KeyFn<S>>,
330    drags: Vec<DragFn<S>>,
331    text_pos: Vec<TextPosFn<S>>,
332    contexts: Vec<ContextFn<S>>,
333}
334
335impl<S> Handlers<S> {
336    /// Invoke the tap handler for `id`. Returns `true` if one existed and ran.
337    pub fn dispatch(&mut self, id: ActionId, state: &mut S) -> bool {
338        if let Some(handler) = self.taps.get_mut(id.0 as usize) {
339            handler(state);
340            true
341        } else {
342            false
343        }
344    }
345
346    /// Invoke the key handler for focused element `id` with `input`. Returns
347    /// `true` if one existed and ran.
348    pub fn dispatch_key(&mut self, id: FocusId, input: &KeyInput, state: &mut S) -> bool {
349        if let Some(handler) = self.keys.get_mut(id.0 as usize) {
350            handler(state, input);
351            true
352        } else {
353            false
354        }
355    }
356
357    /// Invoke the drag handler for `id` with `fraction` (0..=1 across the
358    /// element width). Returns `true` if one existed and ran.
359    pub fn dispatch_drag(&mut self, id: DragId, fraction: f64, state: &mut S) -> bool {
360        if let Some(handler) = self.drags.get_mut(id.0 as usize) {
361            handler(state, fraction);
362            true
363        } else {
364            false
365        }
366    }
367
368    /// Invoke the text-pointer handler for `id` with a resolved byte `index` and
369    /// the `extend` flag. Returns `true` if one existed and ran.
370    pub fn dispatch_text_pos(
371        &mut self,
372        id: TextPosId,
373        index: usize,
374        extend: bool,
375        state: &mut S,
376    ) -> bool {
377        if let Some(handler) = self.text_pos.get_mut(id.0 as usize) {
378            handler(state, index, extend);
379            true
380        } else {
381            false
382        }
383    }
384
385    /// Invoke the context (secondary-click) handler for `id` with the click
386    /// `pos`. Returns `true` if one existed and ran.
387    pub fn dispatch_context(&mut self, id: ContextId, pos: Point, state: &mut S) -> bool {
388        if let Some(handler) = self.contexts.get_mut(id.0 as usize) {
389            handler(state, pos);
390            true
391        } else {
392            false
393        }
394    }
395
396    /// Total number of registered handlers (taps + keys + drags + text-pointer
397    /// + context).
398    pub fn len(&self) -> usize {
399        self.taps.len()
400            + self.keys.len()
401            + self.drags.len()
402            + self.text_pos.len()
403            + self.contexts.len()
404    }
405
406    pub fn is_empty(&self) -> bool {
407        self.taps.is_empty()
408            && self.keys.is_empty()
409            && self.drags.is_empty()
410            && self.text_pos.is_empty()
411            && self.contexts.is_empty()
412    }
413}
414
415impl<S> Default for Handlers<S> {
416    fn default() -> Self {
417        Self {
418            taps: Vec::new(),
419            keys: Vec::new(),
420            drags: Vec::new(),
421            text_pos: Vec::new(),
422            contexts: Vec::new(),
423        }
424    }
425}
426
427impl<S> core::fmt::Debug for Handlers<S> {
428    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
429        f.debug_struct("Handlers")
430            .field("taps", &self.taps.len())
431            .field("keys", &self.keys.len())
432            .field("drags", &self.drags.len())
433            .field("text_pos", &self.text_pos.len())
434            .finish()
435    }
436}
437
438/// Paintable leaf content carried by a [`LayoutNode`] beyond its decoration.
439#[derive(Clone, Debug, Default, PartialEq)]
440pub enum NodeContent {
441    /// Decoration only (the common case).
442    #[default]
443    None,
444    /// A single line of text, painted at the node's bounds origin.
445    Text {
446        text: String,
447        size: f64,
448        color: Color,
449    },
450    /// An embedded-content viewport: the node's bounds reserve an area the
451    /// compositor fills with externally-rendered pixels for this
452    /// [`ViewportId`]. Painted via
453    /// [`Scene::fill_viewport`](stipple_render::Scene::fill_viewport).
454    Viewport(ViewportId),
455}
456
457/// A laid-out, retained node: absolute bounds, paint decoration, optional text
458/// content, the optional tap/focus handles it routes to, and laid-out children.
459/// Produced by [`layout`](crate::layout) and consumed by paint, [`hit_test`],
460/// and the focus queries.
461#[derive(Clone, Debug)]
462pub struct LayoutNode {
463    pub bounds: Rect,
464    pub decoration: BoxStyle,
465    pub content: NodeContent,
466    pub action: Option<ActionId>,
467    pub focus: Option<FocusId>,
468    pub drag: Option<DragId>,
469    /// Secondary-click (context) handle: this element opens a context menu on
470    /// right-click.
471    pub context: Option<ContextId>,
472    /// Caret byte index for an editable text leaf (drawn by the focus overlay).
473    pub caret: Option<usize>,
474    /// Selected byte range `[start, end)` for an editable text leaf (the focus
475    /// overlay highlights it).
476    pub selection: Option<(usize, usize)>,
477    /// Text-pointer handle: this element resolves pointer presses/drags to a
478    /// byte index in its text (click-to-position / drag-to-select).
479    pub text_pos: Option<TextPosId>,
480    /// When `true`, the text content word-wraps to `bounds.width` when painted.
481    pub wrap: bool,
482    /// Scroll container handle: wheel events over this node adjust the app's
483    /// offset for `id`, and its children are laid out at natural size + shifted.
484    pub scroll: Option<ScrollId>,
485    /// When `true`, children are clipped to this node's `bounds` when painted
486    /// (set for scroll containers and overlay panels).
487    pub clip: bool,
488    pub children: Vec<LayoutNode>,
489}
490
491impl LayoutNode {
492    /// A bare container: bounds + `children`, no decoration or handlers. Used to
493    /// stack the main tree and overlay layers under one routable/paintable root.
494    pub fn container(bounds: Rect, children: Vec<LayoutNode>) -> LayoutNode {
495        LayoutNode {
496            bounds,
497            decoration: BoxStyle::default(),
498            content: NodeContent::None,
499            action: None,
500            focus: None,
501            drag: None,
502            context: None,
503            caret: None,
504            selection: None,
505            text_pos: None,
506            wrap: false,
507            scroll: None,
508            clip: false,
509            children,
510        }
511    }
512}
513
514/// Find the [`ActionId`] of the top-most tappable node containing `point`.
515///
516/// Children are painted after (on top of) their parent, so they are tested
517/// first, last-to-first, mirroring paint order.
518pub fn hit_test(node: &LayoutNode, point: Point) -> Option<ActionId> {
519    for child in node.children.iter().rev() {
520        if let Some(id) = hit_test(child, point) {
521            return Some(id);
522        }
523    }
524    if node.action.is_some() && node.bounds.contains(point) {
525        node.action
526    } else {
527        None
528    }
529}
530
531/// Find the [`ContextId`] of the top-most node with a secondary-click handler
532/// containing `point` (mirrors [`hit_test`], for right-clicks).
533pub fn context_at(node: &LayoutNode, point: Point) -> Option<ContextId> {
534    for child in node.children.iter().rev() {
535        if let Some(id) = context_at(child, point) {
536            return Some(id);
537        }
538    }
539    if node.context.is_some() && node.bounds.contains(point) {
540        node.context
541    } else {
542        None
543    }
544}
545
546/// Find the top-most text-pointer node containing `point`, returning its
547/// [`TextPosId`] and the node (so the caller can resolve a byte index from the
548/// node's text and bounds).
549pub fn text_pos_at(node: &LayoutNode, point: Point) -> Option<(TextPosId, &LayoutNode)> {
550    for child in node.children.iter().rev() {
551        if let Some(hit) = text_pos_at(child, point) {
552            return Some(hit);
553        }
554    }
555    match node.text_pos {
556        Some(id) if node.bounds.contains(point) => Some((id, node)),
557        _ => None,
558    }
559}
560
561/// Find the [`FocusId`] of the top-most focusable node containing `point`
562/// (used for click-to-focus).
563pub fn focus_at(node: &LayoutNode, point: Point) -> Option<FocusId> {
564    for child in node.children.iter().rev() {
565        if let Some(id) = focus_at(child, point) {
566            return Some(id);
567        }
568    }
569    if node.focus.is_some() && node.bounds.contains(point) {
570        node.focus
571    } else {
572        None
573    }
574}
575
576/// Find the top-most draggable node containing `point`, returning its
577/// [`DragId`] and bounds (so the caller can compute the drag fraction).
578pub fn drag_at(node: &LayoutNode, point: Point) -> Option<(DragId, Rect)> {
579    for child in node.children.iter().rev() {
580        if let Some(hit) = drag_at(child, point) {
581            return Some(hit);
582        }
583    }
584    match node.drag {
585        Some(id) if node.bounds.contains(point) => Some((id, node.bounds)),
586        _ => None,
587    }
588}
589
590/// Find the [`ScrollId`] of the top-most scroll container containing `point`
591/// (the wheel target). Children are tested first so a nested scroll area wins.
592pub fn scroll_at(node: &LayoutNode, point: Point) -> Option<ScrollId> {
593    for child in node.children.iter().rev() {
594        if let Some(id) = scroll_at(child, point) {
595            return Some(id);
596        }
597    }
598    match node.scroll {
599        Some(id) if node.bounds.contains(point) => Some(id),
600        _ => None,
601    }
602}
603
604/// Find the scroll-container node carrying `id`, if present.
605pub fn find_scroll(node: &LayoutNode, id: ScrollId) -> Option<&LayoutNode> {
606    if node.scroll == Some(id) {
607        return Some(node);
608    }
609    node.children.iter().find_map(|c| find_scroll(c, id))
610}
611
612/// Find the node carrying focus `id`, if present.
613pub fn find_focus(node: &LayoutNode, id: FocusId) -> Option<&LayoutNode> {
614    if node.focus == Some(id) {
615        return Some(node);
616    }
617    node.children.iter().find_map(|c| find_focus(c, id))
618}
619
620/// Find the node carrying tap-action `id`, if present (for hover highlight).
621pub fn find_action(node: &LayoutNode, id: ActionId) -> Option<&LayoutNode> {
622    if node.action == Some(id) {
623        return Some(node);
624    }
625    node.children.iter().find_map(|c| find_action(c, id))
626}
627
628/// Find the node carrying text-pointer `id`, if present (for continuing a
629/// drag-selection after the pointer leaves the element bounds).
630pub fn find_text_pos(node: &LayoutNode, id: TextPosId) -> Option<&LayoutNode> {
631    if node.text_pos == Some(id) {
632        return Some(node);
633    }
634    node.children.iter().find_map(|c| find_text_pos(c, id))
635}
636
637/// The first text-bearing [`LayoutNode`] at or under `node`, in tree order.
638/// Used to position the caret and selection highlight inside a focused text
639/// field (read its `content` text/size plus `bounds`/`caret`/`selection`).
640pub fn first_text(node: &LayoutNode) -> Option<&LayoutNode> {
641    if matches!(node.content, NodeContent::Text { .. }) {
642        return Some(node);
643    }
644    node.children.iter().find_map(first_text)
645}
646
647/// Collect every embedded-content viewport in the tree as `(id, bounds)`, in
648/// paint order, so the app can composite each one's registered content into its
649/// laid-out rect (and route input landing inside it to that content). Bounds are
650/// in absolute logical pixels.
651pub fn collect_viewports(node: &LayoutNode, out: &mut Vec<(ViewportId, Rect)>) {
652    if let NodeContent::Viewport(id) = node.content {
653        out.push((id, node.bounds));
654    }
655    for child in &node.children {
656        collect_viewports(child, out);
657    }
658}
659
660/// Find the top-most embedded-content viewport containing `point`, returning its
661/// [`ViewportId`] and bounds (so the app can forward the event with
662/// viewport-local coordinates). Children are tested first, mirroring paint order.
663pub fn viewport_at(node: &LayoutNode, point: Point) -> Option<(ViewportId, Rect)> {
664    for child in node.children.iter().rev() {
665        if let Some(hit) = viewport_at(child, point) {
666            return Some(hit);
667        }
668    }
669    match node.content {
670        NodeContent::Viewport(id) if node.bounds.contains(point) => Some((id, node.bounds)),
671        _ => None,
672    }
673}
674
675/// Collect every focusable [`FocusId`] in paint/tree order, for Tab traversal.
676pub fn collect_focusables(node: &LayoutNode, out: &mut Vec<FocusId>) {
677    if let Some(id) = node.focus {
678        out.push(id);
679    }
680    for child in &node.children {
681        collect_focusables(child, out);
682    }
683}
684
685#[cfg(test)]
686mod tests {
687    use super::*;
688
689    fn leaf(bounds: Rect, action: Option<ActionId>, focus: Option<FocusId>) -> LayoutNode {
690        LayoutNode {
691            bounds,
692            decoration: BoxStyle::default(),
693            content: NodeContent::None,
694            action,
695            focus,
696            drag: None,
697            context: None,
698            caret: None,
699            selection: None,
700            text_pos: None,
701            wrap: false,
702            scroll: None,
703            clip: false,
704            children: Vec::new(),
705        }
706    }
707
708    #[derive(Default)]
709    struct St {
710        n: i32,
711        s: String,
712    }
713
714    #[test]
715    fn dispatch_tap_and_key() {
716        let theme = Theme::light();
717        let mut cx = Cx::new(&theme);
718        let tap = cx.register(|st: &mut St| st.n += 5);
719        let focus = cx.register_key(|st: &mut St, k: &KeyInput| {
720            if let KeyInput::Text(t) = k {
721                st.s.push_str(t);
722            }
723        });
724        let mut handlers = cx.into_handlers();
725
726        let mut st = St::default();
727        assert!(handlers.dispatch(tap, &mut st));
728        assert_eq!(st.n, 5);
729
730        assert!(handlers.dispatch_key(focus, &KeyInput::Text("hi".into()), &mut st));
731        assert_eq!(st.s, "hi");
732        assert!(!handlers.dispatch_key(FocusId(99), &KeyInput::Backspace, &mut st));
733    }
734
735    #[test]
736    fn memo_skips_rebuild_for_unchanged_keys() {
737        use std::cell::Cell;
738        let theme = Theme::light();
739        let builds = Cell::new(0);
740        let make = |cx: &mut Cx<St>, key: u64| {
741            cx.memo(key, || {
742                builds.set(builds.get() + 1);
743                Element::text("static", 14.0, Color::BLACK)
744            })
745        };
746
747        // Frame 1: builds the subtree once.
748        let mut cache = std::collections::HashMap::new();
749        let mut cx = Cx::<St>::new(&theme);
750        cx.set_memo_cache(cache);
751        let _ = make(&mut cx, 1);
752        cache = cx.take_memo_cache();
753        assert_eq!(builds.get(), 1);
754        assert!(cache.contains_key(&1));
755
756        // Frame 2: same key → the closure is skipped (cache hit).
757        let mut cx = Cx::<St>::new(&theme);
758        cx.set_memo_cache(cache);
759        let _ = make(&mut cx, 1);
760        cache = cx.take_memo_cache();
761        assert_eq!(builds.get(), 1, "unchanged key must not rebuild");
762
763        // Frame 3: a different key rebuilds, and the now-unused key 1 is evicted.
764        let mut cx = Cx::<St>::new(&theme);
765        cx.set_memo_cache(cache);
766        let _ = make(&mut cx, 2);
767        cache = cx.take_memo_cache();
768        assert_eq!(builds.get(), 2, "changed key rebuilds");
769        assert!(cache.contains_key(&2));
770        assert!(!cache.contains_key(&1), "stale key should be evicted");
771    }
772
773    #[test]
774    fn hit_test_and_focus_prefer_topmost() {
775        let root = LayoutNode {
776            bounds: Rect::from_xywh(0.0, 0.0, 100.0, 100.0),
777            decoration: BoxStyle::default(),
778            content: NodeContent::None,
779            action: Some(ActionId(0)),
780            focus: None,
781            drag: None,
782            context: None,
783            caret: None,
784            selection: None,
785            text_pos: None,
786            wrap: false,
787            scroll: None,
788            clip: false,
789            children: vec![
790                leaf(
791                    Rect::from_xywh(10.0, 10.0, 30.0, 30.0),
792                    Some(ActionId(1)),
793                    Some(FocusId(0)),
794                ),
795                leaf(
796                    Rect::from_xywh(20.0, 20.0, 30.0, 30.0),
797                    Some(ActionId(2)),
798                    Some(FocusId(1)),
799                ),
800            ],
801        };
802        assert_eq!(hit_test(&root, Point::new(25.0, 25.0)), Some(ActionId(2)));
803        assert_eq!(focus_at(&root, Point::new(12.0, 12.0)), Some(FocusId(0)));
804
805        let mut focusables = Vec::new();
806        collect_focusables(&root, &mut focusables);
807        assert_eq!(focusables, vec![FocusId(0), FocusId(1)]);
808    }
809
810    #[test]
811    fn context_handler_resolves_and_receives_the_click_point() {
812        struct St {
813            at: Option<Point>,
814        }
815        let theme = Theme::light();
816        let mut cx = Cx::<St>::new(&theme);
817        // A right-click handler that records where it was invoked.
818        let id = cx.register_context(|s: &mut St, p: Point| s.at = Some(p));
819        let mut handlers = cx.into_handlers();
820
821        // A node carrying that context handle.
822        let mut node = leaf(Rect::from_xywh(0.0, 0.0, 100.0, 100.0), None, None);
823        node.context = Some(id);
824
825        // context_at finds it; a point outside misses.
826        assert_eq!(context_at(&node, Point::new(50.0, 50.0)), Some(id));
827        assert_eq!(context_at(&node, Point::new(150.0, 50.0)), None);
828
829        // Dispatch passes the click position through to the handler.
830        let mut st = St { at: None };
831        assert!(handlers.dispatch_context(id, Point::new(12.0, 34.0), &mut st));
832        assert_eq!(st.at, Some(Point::new(12.0, 34.0)));
833    }
834}