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}