Skip to main content

aetna_core/
runtime.rs

1//! `RunnerCore` — the backend-agnostic half of every Aetna runner.
2//!
3//! Holds interaction state ([`UiState`], `last_tree`) and paint scratch
4//! buffers (`quad_scratch`, `runs`, `paint_items`) plus the geometry
5//! context (`viewport_px`, `surface_size_override`) needed to project
6//! layout's logical-pixel rects into physical-pixel scissors. Exposes
7//! the identical interaction methods both backends ship: `pointer_*`,
8//! `key_down`, `set_hotkeys`, `set_animation_mode`, `ui_state`,
9//! `rect_of_key`, `debug_summary`, `set_surface_size`, plus the layout
10//! / paint-stream stages that are pure CPU work.
11//!
12//! Each backend's `Runner` *contains* a `RunnerCore` and forwards the
13//! interaction methods to it; only the GPU resources (pipelines,
14//! buffers, atlases) and the actual GPU upload + draw work stay
15//! per-backend. The split shares what's identical without a trait —
16//! same shape as `crate::paint`, larger surface.
17//!
18//! ## What this module does NOT own
19//!
20//! - **Pipeline registration.** Each backend builds its own
21//!   `pipelines: HashMap<ShaderHandle, BackendPipeline>` because the
22//!   pipeline value type is GPU-specific.
23//! - **Text upload.** Glyph atlas pages live on the GPU as backend
24//!   images; the `TextPaint` that owns them is per-backend. Core
25//!   reaches into it through the [`TextRecorder`] trait during the
26//!   paint stream loop, then the backend flushes its atlas separately.
27//! - **GPU upload of `quad_scratch` / frame uniforms.** Backend
28//!   responsibility — `prepare()` orchestrates the full sequence.
29//! - **`draw()`.** Both backends walk `core.paint_items` + `core.runs`
30//!   themselves because the encoder type (and lifetime) diverges.
31//!
32//! ## Why no `Painter` trait
33//!
34//! Extracting a `trait Painter { fn prepare(...); fn draw(...); fn
35//! set_scissor(...); }` was considered so backends would share *one*
36//! abstraction surface. We declined: the only call sites left after
37//! this module + [`crate::paint`] are the two
38//! `prepare()` GPU-upload tails and the two `draw()` walks, and both
39//! need backend-typed handles (`wgpu::RenderPass<'_>` /
40//! `AutoCommandBufferBuilder<...>`) that no trait can hide without
41//! generics that re-fragment the surface. A `Painter` trait would
42//! reduce to a 1-method `set_scissor` indirection plus host-side
43//! ceremony — dead weight. The duplication that *is* worth abstracting
44//! is the host harness (winit init, swapchain management,
45//! `aetna-{wgpu,vulkano}-demo::run`) — and that lives a layer above
46//! the paint surface, not inside it. Revisit if a third backend lands
47//! or if the GPU-upload sequences diverge enough to make a typed-state
48//! interface earn its keep.
49
50use std::ops::Range;
51use std::time::Duration;
52
53use web_time::Instant;
54
55use crate::draw_ops;
56use crate::event::{KeyChord, KeyModifiers, PointerButton, UiEvent, UiEventKind, UiKey, UiTarget};
57use crate::focus;
58use crate::hit_test;
59use crate::ir::{DrawOp, TextAnchor};
60use crate::layout;
61use crate::paint::{
62    InstanceRun, PaintItem, PhysicalScissor, QuadInstance, close_run, pack_instance,
63    physical_scissor,
64};
65use crate::shader::ShaderHandle;
66use crate::state::{AnimationMode, UiState};
67use crate::text::atlas::RunStyle;
68use crate::theme::Theme;
69use crate::toast;
70use crate::tooltip;
71use crate::tree::{Color, El, FontWeight, Rect, TextWrap};
72
73/// Logical-pixel overlap kept between the pre-page and post-page
74/// viewport when the user clicks the scroll track above/below the
75/// thumb. Matches browser convention: paging by `viewport_h - overlap`
76/// preserves the bottom (resp. top) row across the jump so context
77/// isn't lost.
78const SCROLL_PAGE_OVERLAP: f32 = 24.0;
79
80/// Reported back from each backend's `prepare(...)` per frame.
81///
82/// Two redraw deadlines:
83///
84/// - [`Self::next_layout_redraw_in`] — the next frame that needs a
85///   full rebuild + layout pass. Driven by widget
86///   [`crate::tree::El::redraw_within`] requests, animations still
87///   settling, and pending tooltip / toast fades. The host must call
88///   the backend's full `prepare(...)` (build → layout → paint →
89///   render) when this elapses.
90/// - [`Self::next_paint_redraw_in`] — the next frame a time-driven
91///   shader needs but layout state is unchanged (e.g. spinner /
92///   skeleton / progress-indeterminate / `samples_time=true` custom
93///   shaders). The host can call the backend's lighter `repaint(...)`
94///   path which reuses the cached `DrawOp` list, advances
95///   `frame.time`, and skips rebuild + layout. Skipping the layout
96///   path is only safe when no input has been processed since the
97///   last full prepare; hosts must upgrade to the full path on any
98///   input event.
99///
100/// Legacy aggregates [`Self::needs_redraw`] and [`Self::next_redraw_in`]
101/// fold both lanes (OR / `min`) for hosts that don't want to split paths.
102#[derive(Clone, Copy, Debug, Default)]
103pub struct PrepareResult {
104    /// Legacy "any redraw needed?" — OR of `next_layout_redraw_in.is_some()`
105    /// and `next_paint_redraw_in.is_some()`, plus animation-settling /
106    /// tooltip-pending bools the runtime tracks internally.
107    pub needs_redraw: bool,
108    /// Legacy combined deadline — `min(next_layout_redraw_in,
109    /// next_paint_redraw_in)`. Hosts that don't distinguish layout
110    /// from paint-only redraws can keep reading this.
111    pub next_redraw_in: Option<std::time::Duration>,
112    /// Tightest deadline among signals that need a full rebuild +
113    /// layout: widget `redraw_within`, animations still settling,
114    /// tooltip / toast pending. `Some(ZERO)` for "now."
115    pub next_layout_redraw_in: Option<std::time::Duration>,
116    /// Tightest deadline among time-driven shaders. The host can
117    /// service this with a paint-only frame (reuse cached ops, just
118    /// advance `frame.time`). `Some(ZERO)` for "every frame" (the
119    /// default for `is_continuous()` shaders today).
120    pub next_paint_redraw_in: Option<std::time::Duration>,
121    pub timings: PrepareTimings,
122}
123
124/// Outcome of a pointer-move dispatch through
125/// [`RunnerCore::pointer_moved`] (or its backend wrappers).
126///
127/// Wayland and most X11 compositors deliver `CursorMoved` at very
128/// high frequency while the cursor sits over the surface — even
129/// sub-pixel jitter or per-frame compositor sync ticks count as
130/// movement. The vast majority of those moves are visual no-ops
131/// (the hovered node didn't change, no drag is active, no scrollbar
132/// is dragging), so hosts must gate `request_redraw` on
133/// `needs_redraw` to avoid spinning the rebuild + layout + render
134/// pipeline on every cursor sample.
135#[derive(Debug, Default)]
136pub struct PointerMove {
137    /// Events to dispatch through `App::on_event`. Empty when the
138    /// move didn't trigger a `Drag` or selection update.
139    pub events: Vec<UiEvent>,
140    /// `true` when the runtime's visual state changed enough to
141    /// warrant a redraw — hovered identity changed, scrollbar drag
142    /// updated a scroll offset, or `events` is non-empty.
143    pub needs_redraw: bool,
144}
145
146/// What [`RunnerCore::prepare_layout`] returns: the resolved
147/// [`DrawOp`] list plus the redraw deadlines split into two lanes (see
148/// [`PrepareResult`] for the lane semantics).
149///
150/// Wrapped in a struct so additions (new redraw signals, lane
151/// metadata) don't churn every backend's `prepare` call site.
152pub struct LayoutPrepared {
153    pub ops: Vec<DrawOp>,
154    pub needs_redraw: bool,
155    pub next_layout_redraw_in: Option<std::time::Duration>,
156    pub next_paint_redraw_in: Option<std::time::Duration>,
157}
158
159/// Per-stage CPU timing inside each backend's `prepare`. Cheap to
160/// compute (a handful of `Instant::now()` calls per frame) and useful
161/// for finding the dominant cost when frame budget is tight.
162///
163/// Stages:
164/// - `layout`: layout pass + focus order sync + state apply + animation tick.
165/// - `draw_ops`: tree → DrawOp[] resolution.
166/// - `paint`: paint-stream loop (quad packing + text shaping via cosmic-text).
167/// - `gpu_upload`: backend-side instance buffer write + atlas flush + frame uniforms.
168/// - `snapshot`: cloning the laid-out tree for next-frame hit-testing.
169#[derive(Clone, Copy, Debug, Default)]
170pub struct PrepareTimings {
171    pub layout: Duration,
172    pub draw_ops: Duration,
173    pub paint: Duration,
174    pub gpu_upload: Duration,
175    pub snapshot: Duration,
176}
177
178/// Backend-agnostic runner state.
179///
180/// Each backend's `Runner` owns one of these as its `core` field and
181/// forwards the public interaction surface to it. The fields are `pub`
182/// so backends can read them in `draw()` (which has to traverse
183/// `paint_items` + `runs` against backend-specific pipeline and
184/// instance-buffer objects).
185pub struct RunnerCore {
186    pub ui_state: UiState,
187    /// Snapshot of the last laid-out tree, kept so pointer events
188    /// arriving between frames hit-test against the geometry the user
189    /// is actually looking at.
190    pub last_tree: Option<El>,
191
192    /// Per-frame quad instance scratch — backends `bytemuck::cast_slice`
193    /// this into their VBO upload.
194    pub quad_scratch: Vec<QuadInstance>,
195    pub runs: Vec<InstanceRun>,
196    pub paint_items: Vec<PaintItem>,
197
198    /// Cached [`DrawOp`] list, reused by [`Self::prepare_paint_cached`]
199    /// for paint-only frames (time-driven shader animation when layout
200    /// state is unchanged — only `frame.time` advances). Backends are
201    /// expected to overwrite this with the ops returned from
202    /// [`Self::prepare_layout`] once they're done with the frame's
203    /// `prepare_paint` call.
204    pub last_ops: Vec<DrawOp>,
205
206    /// Physical viewport size in pixels. Backends use this for `draw()`
207    /// scissor binding (logical scissors get projected into this space
208    /// inside `prepare_paint`).
209    pub viewport_px: (u32, u32),
210    /// When set, overrides the physical viewport derived from
211    /// `viewport.w * scale_factor` so paint-side scissor math matches
212    /// the actual swapchain extent. Backends call
213    /// [`Self::set_surface_size`] from their host's surface-config /
214    /// resize hook to keep this in lockstep.
215    pub surface_size_override: Option<(u32, u32)>,
216
217    /// Theme used when resolving implicit widget surfaces to shaders.
218    pub theme: Theme,
219}
220
221impl Default for RunnerCore {
222    fn default() -> Self {
223        Self::new()
224    }
225}
226
227impl RunnerCore {
228    pub fn new() -> Self {
229        Self {
230            ui_state: UiState::default(),
231            last_tree: None,
232            quad_scratch: Vec::new(),
233            runs: Vec::new(),
234            paint_items: Vec::new(),
235            last_ops: Vec::new(),
236            viewport_px: (1, 1),
237            surface_size_override: None,
238            theme: Theme::default(),
239        }
240    }
241
242    pub fn set_theme(&mut self, theme: Theme) {
243        self.theme = theme;
244    }
245
246    pub fn theme(&self) -> &Theme {
247        &self.theme
248    }
249
250    /// Override the physical viewport size. Call after the host's
251    /// surface configure or resize so scissor math sees the swapchain's
252    /// real extent (fractional `scale_factor` round-trips can otherwise
253    /// land `viewport_px` one pixel off and trip
254    /// `set_scissor_rect` validation).
255    pub fn set_surface_size(&mut self, width: u32, height: u32) {
256        self.surface_size_override = Some((width.max(1), height.max(1)));
257    }
258
259    pub fn ui_state(&self) -> &UiState {
260        &self.ui_state
261    }
262
263    pub fn debug_summary(&self) -> String {
264        self.ui_state.debug_summary()
265    }
266
267    pub fn rect_of_key(&self, key: &str) -> Option<Rect> {
268        self.last_tree
269            .as_ref()
270            .and_then(|t| self.ui_state.rect_of_key(t, key))
271    }
272
273    // ---- Input plumbing ----
274
275    /// Pointer moved to `(x, y)` (logical px). Updates the hovered
276    /// node (readable via `ui_state().hovered`) and, if the primary
277    /// button is currently held, returns a `Drag` event routed to the
278    /// originally pressed target. The event's `modifiers` field
279    /// reflects the mask currently tracked on `UiState` (set by the
280    /// host via `set_modifiers`).
281    pub fn pointer_moved(&mut self, x: f32, y: f32) -> PointerMove {
282        self.ui_state.pointer_pos = Some((x, y));
283
284        // Active scrollbar drag: translate cursor delta into
285        // `scroll.offsets` updates. The drag is captured at
286        // `pointer_down` so we can map directly onto the scroll
287        // container without going through hit-test, and we suppress
288        // the normal hover/Drag event emission while it's in flight.
289        if let Some(drag) = self.ui_state.scroll.thumb_drag.clone() {
290            let dy = y - drag.start_pointer_y;
291            let new_offset = if drag.track_remaining > 0.0 {
292                drag.start_offset + dy * (drag.max_offset / drag.track_remaining)
293            } else {
294                drag.start_offset
295            };
296            let clamped = new_offset.clamp(0.0, drag.max_offset);
297            let prev = self.ui_state.scroll.offsets.insert(drag.scroll_id, clamped);
298            let changed = prev.is_none_or(|old| (old - clamped).abs() > f32::EPSILON);
299            return PointerMove {
300                events: Vec::new(),
301                needs_redraw: changed,
302            };
303        }
304
305        let hit = self
306            .last_tree
307            .as_ref()
308            .and_then(|t| hit_test::hit_test_target(t, &self.ui_state, (x, y)));
309        // Stash the previous hover target so we can pair Leave/Enter
310        // events on identity change. `set_hovered` mutates the state
311        // and only returns whether identity flipped.
312        let prev_hover = self.ui_state.hovered.clone();
313        let hover_changed = self.ui_state.set_hovered(hit, Instant::now());
314        // Track the link URL under the pointer separately from keyed
315        // hover so the cursor resolver can flip to `Pointer` over text
316        // runs that aren't themselves hit-test targets. A change here
317        // (entering or leaving a link) needs a redraw so the host's
318        // per-frame cursor resolution reads the new value.
319        let prev_hovered_link = self.ui_state.hovered_link.clone();
320        let new_hovered_link = self
321            .last_tree
322            .as_ref()
323            .and_then(|t| hit_test::link_at(t, &self.ui_state, (x, y)));
324        let link_hover_changed = new_hovered_link != prev_hovered_link;
325        self.ui_state.hovered_link = new_hovered_link;
326        let modifiers = self.ui_state.modifiers;
327
328        let mut out = Vec::new();
329
330        // Hover-transition events: Leave on the prior target (when
331        // there was one), Enter on the new target (when there is one).
332        // Both fire on identity change only — cursor moves *within* the
333        // same hovered node are visual no-ops here, matching the
334        // redraw-debouncing semantics. Always Leave-then-Enter so apps
335        // observe the cleared state before the new one.
336        if hover_changed {
337            if let Some(prev) = prev_hover {
338                out.push(UiEvent {
339                    key: Some(prev.key.clone()),
340                    target: Some(prev),
341                    pointer: Some((x, y)),
342                    key_press: None,
343                    text: None,
344                    selection: None,
345                    modifiers,
346                    click_count: 0,
347                    path: None,
348                    kind: UiEventKind::PointerLeave,
349                });
350            }
351            if let Some(new) = self.ui_state.hovered.clone() {
352                out.push(UiEvent {
353                    key: Some(new.key.clone()),
354                    target: Some(new),
355                    pointer: Some((x, y)),
356                    key_press: None,
357                    text: None,
358                    selection: None,
359                    modifiers,
360                    click_count: 0,
361                    path: None,
362                    kind: UiEventKind::PointerEnter,
363                });
364            }
365        }
366
367        // Selection drag-extend takes precedence over the focusable
368        // Drag emission. Cross-leaf: if the pointer hits a selectable
369        // leaf, head migrates there. Otherwise we project the pointer
370        // onto the closest selectable leaf in document order so that
371        // dragging *past* the last leaf extends to its end (rather
372        // than snapping the head home to the anchor leaf).
373        if let Some(drag) = self.ui_state.selection.drag.clone()
374            && let Some(tree) = self.last_tree.as_ref()
375        {
376            let head_point =
377                head_for_drag(tree, &self.ui_state, (x, y)).unwrap_or_else(|| drag.anchor.clone());
378            let new_sel = crate::selection::Selection {
379                range: Some(crate::selection::SelectionRange {
380                    anchor: drag.anchor.clone(),
381                    head: head_point,
382                }),
383            };
384            if new_sel != self.ui_state.current_selection {
385                self.ui_state.current_selection = new_sel.clone();
386                out.push(selection_event(new_sel, modifiers, Some((x, y))));
387            }
388        }
389
390        // Drag: pointer moved while primary button is down → emit Drag
391        // to the originally pressed target. Cursor escape from the
392        // pressed node is the *normal* drag-extend case (e.g. text
393        // selection inside an editable widget); we keep emitting until
394        // pointer_up clears `pressed`.
395        if let Some(p) = self.ui_state.pressed.clone() {
396            // Caret-blink reset: drag-selecting inside a text input
397            // is ongoing editing activity, so keep the caret solid
398            // for the duration of the drag.
399            if self.focused_captures_keys() {
400                self.ui_state.bump_caret_activity(Instant::now());
401            }
402            out.push(UiEvent {
403                key: Some(p.key.clone()),
404                target: Some(p),
405                pointer: Some((x, y)),
406                key_press: None,
407                text: None,
408                selection: None,
409                modifiers,
410                click_count: 0,
411                path: None,
412                kind: UiEventKind::Drag,
413            });
414        }
415
416        let needs_redraw = hover_changed || link_hover_changed || !out.is_empty();
417        PointerMove {
418            events: out,
419            needs_redraw,
420        }
421    }
422
423    /// Pointer left the window — clear hover / press trackers.
424    /// Returns a `PointerLeave` event for the previously hovered
425    /// target (when there was one) so apps can run hover-leave side
426    /// effects symmetrically with `PointerEnter`. Cursor positions on
427    /// the leave event are the last known pointer position before the
428    /// pointer exited, since winit no longer reports coordinates once
429    /// the cursor is outside the window.
430    pub fn pointer_left(&mut self) -> Vec<UiEvent> {
431        let last_pos = self.ui_state.pointer_pos;
432        let prev_hover = self.ui_state.hovered.clone();
433        let modifiers = self.ui_state.modifiers;
434        self.ui_state.pointer_pos = None;
435        self.ui_state.set_hovered(None, Instant::now());
436        self.ui_state.pressed = None;
437        self.ui_state.pressed_secondary = None;
438        // Pointer leaves the window → no link is hovered or pressed
439        // anymore. Clearing here keeps a stale `Pointer` cursor from
440        // sticking after the user moves the mouse out of the canvas
441        // and lets re-entry recompute against the actual current
442        // position.
443        self.ui_state.hovered_link = None;
444        self.ui_state.pressed_link = None;
445
446        let mut out = Vec::new();
447        if let Some(prev) = prev_hover {
448            out.push(UiEvent {
449                key: Some(prev.key.clone()),
450                target: Some(prev),
451                pointer: last_pos,
452                key_press: None,
453                text: None,
454                selection: None,
455                modifiers,
456                click_count: 0,
457                path: None,
458                kind: UiEventKind::PointerLeave,
459            });
460        }
461        out
462    }
463
464    /// A file is being dragged over the window at logical-pixel
465    /// coordinates `(x, y)`. Hosts call this from
466    /// `WindowEvent::HoveredFile`. Hit-tests at the cursor position and
467    /// emits a `FileHovered` event routed to the keyed leaf at that
468    /// point (or window-level when the cursor is over no keyed
469    /// surface). Multi-file drags fire one event per file — winit
470    /// reports each file separately and the host forwards each call
471    /// into this method.
472    ///
473    /// The hover state is *not* tracked across files; apps that want
474    /// to count active hovered files do so themselves between
475    /// `FileHovered` and the eventual `FileHoverCancelled` /
476    /// `FileDropped`.
477    pub fn file_hovered(&mut self, path: std::path::PathBuf, x: f32, y: f32) -> Vec<UiEvent> {
478        self.ui_state.pointer_pos = Some((x, y));
479        let target = self
480            .last_tree
481            .as_ref()
482            .and_then(|t| hit_test::hit_test_target(t, &self.ui_state, (x, y)));
483        let key = target.as_ref().map(|t| t.key.clone());
484        vec![UiEvent {
485            key,
486            target,
487            pointer: Some((x, y)),
488            key_press: None,
489            text: None,
490            selection: None,
491            modifiers: self.ui_state.modifiers,
492            click_count: 0,
493            path: Some(path),
494            kind: UiEventKind::FileHovered,
495        }]
496    }
497
498    /// The user moved a hovered file off the window without dropping
499    /// (or pressed Escape). Window-level event — not routed to any
500    /// keyed leaf, since winit doesn't tell us which file was being
501    /// dragged. Apps clear any drop-zone affordance state.
502    pub fn file_hover_cancelled(&mut self) -> Vec<UiEvent> {
503        vec![UiEvent {
504            key: None,
505            target: None,
506            pointer: self.ui_state.pointer_pos,
507            key_press: None,
508            text: None,
509            selection: None,
510            modifiers: self.ui_state.modifiers,
511            click_count: 0,
512            path: None,
513            kind: UiEventKind::FileHoverCancelled,
514        }]
515    }
516
517    /// A file was dropped on the window at logical-pixel coordinates
518    /// `(x, y)`. Hosts call this from `WindowEvent::DroppedFile`.
519    /// Same routing as [`Self::file_hovered`] — keyed leaf at the drop
520    /// point, or window-level. One event per file.
521    pub fn file_dropped(&mut self, path: std::path::PathBuf, x: f32, y: f32) -> Vec<UiEvent> {
522        self.ui_state.pointer_pos = Some((x, y));
523        let target = self
524            .last_tree
525            .as_ref()
526            .and_then(|t| hit_test::hit_test_target(t, &self.ui_state, (x, y)));
527        let key = target.as_ref().map(|t| t.key.clone());
528        vec![UiEvent {
529            key,
530            target,
531            pointer: Some((x, y)),
532            key_press: None,
533            text: None,
534            selection: None,
535            modifiers: self.ui_state.modifiers,
536            click_count: 0,
537            path: Some(path),
538            kind: UiEventKind::FileDropped,
539        }]
540    }
541
542    /// Primary/secondary/middle pointer button pressed at `(x, y)`.
543    /// For the primary button, focuses the hit target and stashes it
544    /// as the pressed target; emits a `PointerDown` event so widgets
545    /// like text_input can react at down-time (e.g., set the selection
546    /// anchor before any drag extends it). Secondary/middle store on a
547    /// separate channel and never emit a `PointerDown`.
548    ///
549    /// Also drives the library's text-selection manager: a primary
550    /// press on a `selectable` text leaf starts a drag and produces a
551    /// `SelectionChanged` event; a press on any other element clears
552    /// any active static-text selection by emitting a
553    /// `SelectionChanged` with an empty range.
554    pub fn pointer_down(&mut self, x: f32, y: f32, button: PointerButton) -> Vec<UiEvent> {
555        // Scrollbar track pre-empts normal hit-test: a primary press
556        // inside a scrollable's track column either captures a thumb
557        // drag (when the press lands inside the visible thumb rect)
558        // or pages the scroll offset by a viewport (when it lands
559        // above or below the thumb). Both branches suppress focus /
560        // press / event chains for the press itself; `pointer_moved`
561        // then drives the drag (no-op for paged clicks) and
562        // `pointer_up` clears the drag.
563        if matches!(button, PointerButton::Primary)
564            && let Some((scroll_id, _track, thumb_rect)) = self.ui_state.thumb_at(x, y)
565        {
566            let metrics = self
567                .ui_state
568                .scroll
569                .metrics
570                .get(&scroll_id)
571                .copied()
572                .unwrap_or_default();
573            let start_offset = self
574                .ui_state
575                .scroll
576                .offsets
577                .get(&scroll_id)
578                .copied()
579                .unwrap_or(0.0);
580
581            // Grab when the press lands inside the visible thumb;
582            // page otherwise. The track is wider than the thumb
583            // horizontally, so this branch is decided by `y` alone.
584            let grabbed = y >= thumb_rect.y && y <= thumb_rect.y + thumb_rect.h;
585            if grabbed {
586                let track_remaining = (metrics.viewport_h - thumb_rect.h).max(0.0);
587                self.ui_state.scroll.thumb_drag = Some(crate::state::ThumbDrag {
588                    scroll_id,
589                    start_pointer_y: y,
590                    start_offset,
591                    track_remaining,
592                    max_offset: metrics.max_offset,
593                });
594            } else {
595                // Click-to-page. Browser convention: each press
596                // shifts the offset by ~one viewport with a small
597                // overlap so context isn't lost. Direction is
598                // decided by which side of the thumb the press
599                // landed on.
600                let page = (metrics.viewport_h - SCROLL_PAGE_OVERLAP).max(0.0);
601                let delta = if y < thumb_rect.y { -page } else { page };
602                let new_offset = (start_offset + delta).clamp(0.0, metrics.max_offset);
603                self.ui_state.scroll.offsets.insert(scroll_id, new_offset);
604            }
605            return Vec::new();
606        }
607
608        let hit = self
609            .last_tree
610            .as_ref()
611            .and_then(|t| hit_test::hit_test_target(t, &self.ui_state, (x, y)));
612        // Only the primary button drives focus + the visual press
613        // envelope. Secondary/middle clicks shouldn't yank focus from
614        // the currently-focused element (matches browser/native behavior
615        // where right-clicking a button doesn't take focus).
616        if !matches!(button, PointerButton::Primary) {
617            // Stash the down-target on the secondary/middle channel so
618            // pointer_up can confirm the click landed on the same node.
619            self.ui_state.pressed_secondary = hit.map(|h| (h, button));
620            return Vec::new();
621        }
622
623        // Stash any link URL the press lands on before the keyed-
624        // target walk consumes the press. Cleared in `pointer_up`,
625        // which only emits `LinkActivated` if the up position resolves
626        // to the same URL — same press-then-confirm contract as a
627        // normal `Click`. A press that misses every link clears any
628        // stale value from the previous press so a drag-released-
629        // elsewhere never fires a link from an earlier interaction.
630        self.ui_state.pressed_link = self
631            .last_tree
632            .as_ref()
633            .and_then(|t| hit_test::link_at(t, &self.ui_state, (x, y)));
634        self.ui_state.set_focus(hit.clone());
635        // `:focus-visible` rule: pointer-driven focus suppresses the
636        // ring; widgets that want it on click opt in via
637        // `always_show_focus_ring`.
638        self.ui_state.set_focus_visible(false);
639        self.ui_state.pressed = hit.clone();
640        // A press on the hovered node dismisses any tooltip for
641        // the rest of this hover session — matches native UIs.
642        self.ui_state.tooltip.dismissed_for_hover = true;
643        let modifiers = self.ui_state.modifiers;
644
645        // Click counting: extend a multi-click sequence when the press
646        // lands on the same target inside the time + distance window.
647        let now = Instant::now();
648        let click_count =
649            self.ui_state
650                .next_click_count(now, (x, y), hit.as_ref().map(|t| t.node_id.as_str()));
651
652        let mut out = Vec::new();
653        if let Some(p) = hit.clone() {
654            // Caret-blink reset: a press inside the focused widget
655            // (e.g., to reposition the caret in an already-focused
656            // input) is editing activity. The earlier `set_focus`
657            // call bumps when focus *changes*; this catches the
658            // same-target case so click-to-move-caret resets the
659            // blink too.
660            if self.focused_captures_keys() {
661                self.ui_state.bump_caret_activity(now);
662            }
663            out.push(UiEvent {
664                key: Some(p.key.clone()),
665                target: Some(p),
666                pointer: Some((x, y)),
667                key_press: None,
668                text: None,
669                selection: None,
670                modifiers,
671                click_count,
672                path: None,
673                kind: UiEventKind::PointerDown,
674            });
675        }
676
677        // Selection routing. The selection hit-test is independent of
678        // the focusable hit: a `text(...).key("p").selectable()` leaf is
679        // both a (non-focusable) keyed PointerDown target and a
680        // selectable text leaf. Apps see both events; selection drag
681        // starts in either case. A press that lands on neither a
682        // selectable nor a focusable widget clears any active
683        // selection.
684        if let Some(point) = self
685            .last_tree
686            .as_ref()
687            .and_then(|t| hit_test::selection_point_at(t, &self.ui_state, (x, y)))
688        {
689            self.start_selection_drag(point, &mut out, modifiers, (x, y), click_count);
690        } else if !self.ui_state.current_selection.is_empty() {
691            // Clear-on-click only when the press lands somewhere that
692            // can't take selection ownership itself.
693            //
694            // - If the press is on the widget that already owns the
695            //   selection (same key), the widget's PointerDown
696            //   handler updates its own caret; a runtime clear here
697            //   races and collapses the app's selection back to
698            //   default. (User-visible bug: caret alternated between
699            //   the click position and byte 0 on every other click.)
700            //
701            // - If the press is on a *different* capture_keys widget
702            //   (e.g., dragging from one text_input into another),
703            //   that widget's PointerDown will replace the selection
704            //   with one anchored at the click position. The runtime
705            //   clear would arrive after the replace and wipe the
706            //   anchor — so when the drag began, only `head` would
707            //   advance and `anchor` would default to 0, jumping the
708            //   selection start to the beginning of the text.
709            //
710            // Press on a regular focusable (button, etc.) or in dead
711            // space still clears, matching the browser idiom.
712            let click_handles_selection = match (&hit, &self.ui_state.current_selection.range) {
713                (Some(h), Some(range)) => {
714                    h.key == range.anchor.key
715                        || h.key == range.head.key
716                        || self
717                            .last_tree
718                            .as_ref()
719                            .and_then(|t| find_capture_keys(t, &h.node_id))
720                            .unwrap_or(false)
721                }
722                _ => false,
723            };
724            if !click_handles_selection {
725                out.push(selection_event(
726                    crate::selection::Selection::default(),
727                    modifiers,
728                    Some((x, y)),
729                ));
730                self.ui_state.current_selection = crate::selection::Selection::default();
731                self.ui_state.selection.drag = None;
732            }
733        }
734
735        out
736    }
737
738    /// Stamp a new [`crate::state::SelectionDrag`] and emit a
739    /// `SelectionChanged` event seeded by `point`. For
740    /// `click_count == 2` the anchor / head pair expands to the word
741    /// range around `point.byte`; for `click_count >= 3` it expands to
742    /// the whole leaf (static-text triple-click typically wants the
743    /// paragraph). For other counts (single click, default) the
744    /// selection is collapsed at `point`.
745    fn start_selection_drag(
746        &mut self,
747        point: crate::selection::SelectionPoint,
748        out: &mut Vec<UiEvent>,
749        modifiers: KeyModifiers,
750        pointer: (f32, f32),
751        click_count: u8,
752    ) {
753        let leaf_text = self
754            .last_tree
755            .as_ref()
756            .and_then(|t| crate::selection::find_keyed_text(t, &point.key))
757            .unwrap_or_default();
758        let (anchor_byte, head_byte) = match click_count {
759            2 => crate::selection::word_range_at(&leaf_text, point.byte),
760            n if n >= 3 => (0, leaf_text.len()),
761            _ => (point.byte, point.byte),
762        };
763        let anchor = crate::selection::SelectionPoint::new(point.key.clone(), anchor_byte);
764        let head = crate::selection::SelectionPoint::new(point.key.clone(), head_byte);
765        let new_sel = crate::selection::Selection {
766            range: Some(crate::selection::SelectionRange {
767                anchor: anchor.clone(),
768                head,
769            }),
770        };
771        self.ui_state.current_selection = new_sel.clone();
772        // The drag anchors at the multi-click range's start so a
773        // subsequent drag extends from there rather than from the
774        // initial click position.
775        self.ui_state.selection.drag = Some(crate::state::SelectionDrag { anchor });
776        out.push(selection_event(new_sel, modifiers, Some(pointer)));
777    }
778
779    /// Pointer released. For the primary button, fires `PointerUp`
780    /// (always, with the originally pressed target so drag-aware
781    /// widgets see drag-end) and additionally `Click` if the release
782    /// landed on the same node as the down. For secondary / middle,
783    /// fires the corresponding click variant when the up landed on the
784    /// same node; no analogue of `PointerUp` since drag is a primary-
785    /// button concept here.
786    pub fn pointer_up(&mut self, x: f32, y: f32, button: PointerButton) -> Vec<UiEvent> {
787        // Scrollbar drag ends without producing app-level events —
788        // the press never went through `pressed` / `pressed_secondary`
789        // so there's nothing else to clean up. Released from anywhere;
790        // the drag is global once captured, matching native scrollbars.
791        if matches!(button, PointerButton::Primary) && self.ui_state.scroll.thumb_drag.is_some() {
792            self.ui_state.scroll.thumb_drag = None;
793            return Vec::new();
794        }
795
796        // End any active text-selection drag. The selection itself
797        // persists; only the "currently dragging" flag goes away.
798        if matches!(button, PointerButton::Primary) {
799            self.ui_state.selection.drag = None;
800        }
801
802        let hit = self
803            .last_tree
804            .as_ref()
805            .and_then(|t| hit_test::hit_test_target(t, &self.ui_state, (x, y)));
806        let modifiers = self.ui_state.modifiers;
807        let mut out = Vec::new();
808        match button {
809            PointerButton::Primary => {
810                let pressed = self.ui_state.pressed.take();
811                let click_count = self.ui_state.current_click_count();
812                if let Some(p) = pressed.clone() {
813                    out.push(UiEvent {
814                        key: Some(p.key.clone()),
815                        target: Some(p),
816                        pointer: Some((x, y)),
817                        key_press: None,
818                        text: None,
819                        selection: None,
820                        modifiers,
821                        click_count,
822                        path: None,
823                        kind: UiEventKind::PointerUp,
824                    });
825                }
826                if let (Some(p), Some(h)) = (pressed, hit)
827                    && p.node_id == h.node_id
828                {
829                    // Toast dismiss buttons are runtime-managed —
830                    // the click drops the matching toast from the
831                    // queue and is *not* surfaced to the app, so
832                    // `on_event` doesn't have to know about toast
833                    // bookkeeping.
834                    if let Some(id) = toast::parse_dismiss_key(&p.key) {
835                        self.ui_state.dismiss_toast(id);
836                    } else {
837                        out.push(UiEvent {
838                            key: Some(p.key.clone()),
839                            target: Some(p),
840                            pointer: Some((x, y)),
841                            key_press: None,
842                            text: None,
843                            selection: None,
844                            modifiers,
845                            click_count,
846                            path: None,
847                            kind: UiEventKind::Click,
848                        });
849                    }
850                }
851                // Link click — surface the URL as a separate event so
852                // the app's link policy is independent of any keyed
853                // ancestor's `Click`. Press-then-confirm: the up
854                // position must resolve to the same URL as the down
855                // (cancel-on-drag-away, matching native link UX).
856                if let Some(pressed_url) = self.ui_state.pressed_link.take() {
857                    let up_link = self
858                        .last_tree
859                        .as_ref()
860                        .and_then(|t| hit_test::link_at(t, &self.ui_state, (x, y)));
861                    if up_link.as_ref() == Some(&pressed_url) {
862                        out.push(UiEvent {
863                            key: Some(pressed_url),
864                            target: None,
865                            pointer: Some((x, y)),
866                            key_press: None,
867                            text: None,
868                            selection: None,
869                            modifiers,
870                            click_count: 1,
871                            path: None,
872                            kind: UiEventKind::LinkActivated,
873                        });
874                    }
875                }
876            }
877            PointerButton::Secondary | PointerButton::Middle => {
878                let pressed = self.ui_state.pressed_secondary.take();
879                if let (Some((p, b)), Some(h)) = (pressed, hit)
880                    && b == button
881                    && p.node_id == h.node_id
882                {
883                    let kind = match button {
884                        PointerButton::Secondary => UiEventKind::SecondaryClick,
885                        PointerButton::Middle => UiEventKind::MiddleClick,
886                        PointerButton::Primary => unreachable!(),
887                    };
888                    out.push(UiEvent {
889                        key: Some(p.key.clone()),
890                        target: Some(p),
891                        pointer: Some((x, y)),
892                        key_press: None,
893                        text: None,
894                        selection: None,
895                        modifiers,
896                        click_count: 1,
897                        path: None,
898                        kind,
899                    });
900                }
901            }
902        }
903        out
904    }
905
906    pub fn key_down(&mut self, key: UiKey, modifiers: KeyModifiers, repeat: bool) -> Vec<UiEvent> {
907        // Capture path: when the focused node opted into raw key
908        // capture, the library's Tab/Enter/Escape interpretation is
909        // bypassed and the event is delivered as a raw `KeyDown` to
910        // the focused target. Hotkeys still match first — an app's
911        // global Ctrl+S beats a text input's local consumption of S.
912        if self.focused_captures_keys() {
913            if let Some(event) = self.ui_state.try_hotkey(&key, modifiers, repeat) {
914                return vec![event];
915            }
916            // Caret-blink reset: any key arriving at a capture_keys
917            // widget is text-editing activity (caret motion, edit,
918            // shortcut), so the caret should snap back to solid even
919            // when the app doesn't propagate its `Selection` back via
920            // `App::selection()`. Without this, hammering arrow keys
921            // produces no visible blink reset.
922            self.ui_state.bump_caret_activity(Instant::now());
923            self.ui_state.set_focus_visible(true);
924            return self
925                .ui_state
926                .key_down_raw(key, modifiers, repeat)
927                .into_iter()
928                .collect();
929        }
930
931        // Arrow-nav: if the focused node sits inside an arrow-navigable
932        // group (typically a popover_panel of menu items), Up / Down /
933        // Home / End move focus among its focusable siblings rather
934        // than emitting a `KeyDown` event. Hotkeys are still matched
935        // first so a global Ctrl+ArrowUp chord beats menu navigation.
936        if matches!(
937            key,
938            UiKey::ArrowUp | UiKey::ArrowDown | UiKey::Home | UiKey::End
939        ) && let Some(siblings) = self.focused_arrow_nav_group()
940        {
941            if let Some(event) = self.ui_state.try_hotkey(&key, modifiers, repeat) {
942                return vec![event];
943            }
944            self.move_focus_in_group(&key, &siblings);
945            return Vec::new();
946        }
947
948        let mut out: Vec<UiEvent> = self
949            .ui_state
950            .key_down(key, modifiers, repeat)
951            .into_iter()
952            .collect();
953
954        // Esc clears any active text selection (parallels the
955        // pointer_down "press lands outside selectable+focusable"
956        // path). The Escape event itself still fires so apps can
957        // dismiss popovers / modals; the SelectionChanged is emitted
958        // alongside it. This only runs in the non-capture-keys path,
959        // so pressing Esc while typing in an input doesn't clobber
960        // the input's selection — matching browser behavior.
961        if matches!(out.first().map(|e| e.kind), Some(UiEventKind::Escape))
962            && !self.ui_state.current_selection.is_empty()
963        {
964            self.ui_state.current_selection = crate::selection::Selection::default();
965            self.ui_state.selection.drag = None;
966            out.push(selection_event(
967                crate::selection::Selection::default(),
968                modifiers,
969                None,
970            ));
971        }
972
973        out
974    }
975
976    /// Look up the focused node's nearest [`El::arrow_nav_siblings`]
977    /// parent in the last laid-out tree and return the focusable
978    /// siblings (the navigation targets for Up / Down / Home / End).
979    /// Returns `None` when no node is focused, the tree hasn't been
980    /// built yet, or the focused element isn't inside an
981    /// arrow-navigable parent.
982    fn focused_arrow_nav_group(&self) -> Option<Vec<UiTarget>> {
983        let focused = self.ui_state.focused.as_ref()?;
984        let tree = self.last_tree.as_ref()?;
985        focus::arrow_nav_group(tree, &self.ui_state, &focused.node_id)
986    }
987
988    /// Move the focused element to the appropriate sibling for `key`.
989    /// `Up` / `Down` step by one (saturating at the ends — no wrap, so
990    /// holding the key doesn't loop visually); `Home` / `End` jump to
991    /// the first / last sibling.
992    fn move_focus_in_group(&mut self, key: &UiKey, siblings: &[UiTarget]) {
993        if siblings.is_empty() {
994            return;
995        }
996        let focused_id = match self.ui_state.focused.as_ref() {
997            Some(t) => t.node_id.clone(),
998            None => return,
999        };
1000        let idx = siblings.iter().position(|t| t.node_id == focused_id);
1001        let next_idx = match (key, idx) {
1002            (UiKey::ArrowUp, Some(i)) => i.saturating_sub(1),
1003            (UiKey::ArrowDown, Some(i)) => (i + 1).min(siblings.len() - 1),
1004            (UiKey::Home, _) => 0,
1005            (UiKey::End, _) => siblings.len() - 1,
1006            _ => return,
1007        };
1008        if Some(next_idx) != idx {
1009            self.ui_state.set_focus(Some(siblings[next_idx].clone()));
1010            self.ui_state.set_focus_visible(true);
1011        }
1012    }
1013
1014    /// Look up the focused node in the last laid-out tree and return
1015    /// its `capture_keys` flag. False when no node is focused or the
1016    /// tree hasn't been built yet.
1017    fn focused_captures_keys(&self) -> bool {
1018        let Some(focused) = self.ui_state.focused.as_ref() else {
1019            return false;
1020        };
1021        let Some(tree) = self.last_tree.as_ref() else {
1022            return false;
1023        };
1024        find_capture_keys(tree, &focused.node_id).unwrap_or(false)
1025    }
1026
1027    /// OS-composed text input (printable characters after dead-key /
1028    /// shift / IME composition). Routed to the focused element as a
1029    /// `TextInput` event. Returns `None` if no node has focus, or if
1030    /// `text` is empty (some platforms emit empty composition strings
1031    /// during IME selection).
1032    pub fn text_input(&mut self, text: String) -> Option<UiEvent> {
1033        if text.is_empty() {
1034            return None;
1035        }
1036        let target = self.ui_state.focused.clone()?;
1037        let modifiers = self.ui_state.modifiers;
1038        // Caret-blink reset: typing into the focused widget is
1039        // text-editing activity. See the matching bump in `key_down`.
1040        self.ui_state.bump_caret_activity(Instant::now());
1041        Some(UiEvent {
1042            key: Some(target.key.clone()),
1043            target: Some(target),
1044            pointer: None,
1045            key_press: None,
1046            text: Some(text),
1047            selection: None,
1048            modifiers,
1049            click_count: 0,
1050            path: None,
1051            kind: UiEventKind::TextInput,
1052        })
1053    }
1054
1055    pub fn set_hotkeys(&mut self, hotkeys: Vec<(KeyChord, String)>) {
1056        self.ui_state.set_hotkeys(hotkeys);
1057    }
1058
1059    /// Push the app's current [`crate::selection::Selection`] into the
1060    /// runtime so the painter can draw highlight bands. Hosts call
1061    /// this once per frame alongside `set_hotkeys`, sourcing the value
1062    /// from [`crate::event::App::selection`].
1063    pub fn set_selection(&mut self, selection: crate::selection::Selection) {
1064        if self.ui_state.current_selection != selection {
1065            self.ui_state.bump_caret_activity(Instant::now());
1066        }
1067        self.ui_state.current_selection = selection;
1068    }
1069
1070    /// Queue toast specs onto the runtime's toast stack. Each spec
1071    /// is stamped with a monotonic id and `expires_at = now + ttl`;
1072    /// the next `prepare_layout` call drops expired entries and
1073    /// synthesizes a `toast_stack` floating layer over the rest.
1074    /// Hosts wire this from `App::drain_toasts` once per frame.
1075    pub fn push_toasts(&mut self, specs: Vec<crate::toast::ToastSpec>) {
1076        let now = Instant::now();
1077        for spec in specs {
1078            self.ui_state.push_toast(spec, now);
1079        }
1080    }
1081
1082    /// Programmatically dismiss a single toast by id. Mostly useful
1083    /// when the app wants to cancel a long-TTL toast in response to
1084    /// some external event (e.g., the connection reconnected).
1085    pub fn dismiss_toast(&mut self, id: u64) {
1086        self.ui_state.dismiss_toast(id);
1087    }
1088
1089    /// Queue programmatic focus requests by widget key. Each entry is
1090    /// resolved during the next `prepare_layout`, after the focus
1091    /// order has been rebuilt from the new tree; unmatched keys drop
1092    /// silently. Hosts wire this from [`crate::event::App::drain_focus_requests`]
1093    /// once per frame, alongside `push_toasts`.
1094    pub fn push_focus_requests(&mut self, keys: Vec<String>) {
1095        self.ui_state.push_focus_requests(keys);
1096    }
1097
1098    /// Queue programmatic scroll-to-row requests targeting virtual
1099    /// lists by key. Each request is consumed during layout of the
1100    /// matching list, where viewport height and row heights are
1101    /// known. Hosts wire this from [`crate::event::App::drain_scroll_requests`]
1102    /// once per frame, alongside `push_focus_requests`.
1103    pub fn push_scroll_requests(&mut self, requests: Vec<crate::scroll::ScrollRequest>) {
1104        self.ui_state.push_scroll_requests(requests);
1105    }
1106
1107    pub fn set_animation_mode(&mut self, mode: AnimationMode) {
1108        self.ui_state.set_animation_mode(mode);
1109    }
1110
1111    pub fn pointer_wheel(&mut self, x: f32, y: f32, dy: f32) -> bool {
1112        let Some(tree) = self.last_tree.as_ref() else {
1113            return false;
1114        };
1115        self.ui_state.pointer_wheel(tree, (x, y), dy)
1116    }
1117
1118    // ---- Per-frame staging ----
1119
1120    /// Layout + state apply + animation tick + viewport projection +
1121    /// `DrawOp` resolution. Returns the resolved op list and whether
1122    /// visual animations need another frame; writes per-stage timings
1123    /// into `timings` (`layout` + `draw_ops`).
1124    ///
1125    /// `samples_time` answers "does this shader's output depend on
1126    /// `frame.time`?" The runtime calls it once per draw op when no
1127    /// other in-flight motion has already requested a redraw; any
1128    /// `true` answer keeps `needs_redraw` set so the host idle loop
1129    /// keeps ticking. Stock shaders self-report through
1130    /// [`crate::shader::StockShader::is_continuous`]; backends layer
1131    /// on the registered set of `samples_time=true` custom shaders.
1132    /// Callers that have no time-driven shaders pass
1133    /// [`Self::no_time_shaders`].
1134    pub fn prepare_layout<F>(
1135        &mut self,
1136        root: &mut El,
1137        viewport: Rect,
1138        scale_factor: f32,
1139        timings: &mut PrepareTimings,
1140        samples_time: F,
1141    ) -> LayoutPrepared
1142    where
1143        F: Fn(&ShaderHandle) -> bool,
1144    {
1145        let t0 = Instant::now();
1146        // Tooltip + toast synthesis run before the real layout: assign
1147        // ids first so the tooltip pass can resolve the hover anchor
1148        // by computed_id, then append the runtime-managed floating
1149        // layers. The subsequent `layout::layout` call re-assigns
1150        // (idempotently — same path shapes produce the same ids) and
1151        // lays out the appended layers alongside everything else.
1152        let mut needs_redraw = {
1153            crate::profile_span!("prepare::layout");
1154            {
1155                crate::profile_span!("prepare::layout::assign_ids");
1156                layout::assign_ids(root);
1157            }
1158            let tooltip_pending = {
1159                crate::profile_span!("prepare::layout::tooltip");
1160                tooltip::synthesize_tooltip(root, &self.ui_state, t0)
1161            };
1162            let toast_pending = {
1163                crate::profile_span!("prepare::layout::toast");
1164                toast::synthesize_toasts(root, &mut self.ui_state, t0)
1165            };
1166            {
1167                crate::profile_span!("prepare::layout::apply_metrics");
1168                self.theme.apply_metrics(root);
1169            }
1170            {
1171                crate::profile_span!("prepare::layout::layout");
1172                // `assign_ids` ran above (so tooltip/toast synthesis
1173                // could resolve nodes by id), and the synthesize
1174                // functions called `assign_id_appended` on the layers
1175                // they pushed — so the recursive id walk inside
1176                // `layout::layout` would be a wasted second pass over
1177                // the entire tree. Use `layout_post_assign` to skip it.
1178                layout::layout_post_assign(root, &mut self.ui_state, viewport);
1179                // Drop scroll requests that didn't match any virtual
1180                // list this frame (the matching list may have been
1181                // removed from the tree, or the app may have raced a
1182                // state change that retired the key).
1183                self.ui_state.clear_pending_scroll_requests();
1184            }
1185            {
1186                crate::profile_span!("prepare::layout::sync_focus_order");
1187                self.ui_state.sync_focus_order(root);
1188            }
1189            {
1190                crate::profile_span!("prepare::layout::sync_selection_order");
1191                self.ui_state.sync_selection_order(root);
1192            }
1193            {
1194                crate::profile_span!("prepare::layout::sync_popover_focus");
1195                focus::sync_popover_focus(root, &mut self.ui_state);
1196            }
1197            {
1198                // Drain after popover auto-focus so explicit app
1199                // requests win when both fire on the same frame
1200                // (e.g. a hotkey opens a popover and then jumps focus
1201                // to a non-default child).
1202                crate::profile_span!("prepare::layout::drain_focus_requests");
1203                self.ui_state.drain_focus_requests();
1204            }
1205            {
1206                crate::profile_span!("prepare::layout::apply_state");
1207                self.ui_state.apply_to_state();
1208            }
1209            self.viewport_px = self.surface_size_override.unwrap_or_else(|| {
1210                (
1211                    (viewport.w * scale_factor).ceil().max(1.0) as u32,
1212                    (viewport.h * scale_factor).ceil().max(1.0) as u32,
1213                )
1214            });
1215            let animations = {
1216                crate::profile_span!("prepare::layout::tick_animations");
1217                self.ui_state.tick_visual_animations(root, Instant::now())
1218            };
1219            animations || tooltip_pending || toast_pending
1220        };
1221        let t_after_layout = Instant::now();
1222        let ops = {
1223            crate::profile_span!("prepare::draw_ops");
1224            draw_ops::draw_ops_with_theme(root, &self.ui_state, &self.theme)
1225        };
1226        let t_after_draw_ops = Instant::now();
1227        timings.layout = t_after_layout - t0;
1228        timings.draw_ops = t_after_draw_ops - t_after_layout;
1229
1230        // Two-lane deadline split:
1231        //
1232        // - **Layout lane**: signals that require a rebuild + layout
1233        //   pass to render correctly on the next frame. Animation
1234        //   settling, tooltip / toast pending, and widget
1235        //   `redraw_within` requests all change the El tree's visual
1236        //   state at their deadline.
1237        // - **Paint lane**: time-driven shaders (stock continuous, or
1238        //   `samples_time=true` custom). The El tree is unchanged; only
1239        //   `frame.time` needs to advance. Hosts that want to skip
1240        //   layout for these can run a paint-only frame via
1241        //   [`Self::prepare_paint_cached`] + [`Self::last_ops`].
1242        //
1243        // Bool-shaped layout signals (animations settling, tooltip /
1244        // toast pending) map to `Duration::ZERO`. The widget
1245        // `redraw_within` aggregate is folded in via `min`.
1246        let shader_needs_redraw = ops.iter().any(|op| op_is_continuous(op, &samples_time));
1247        let widget_redraw =
1248            aggregate_redraw_within(root, viewport, &self.ui_state.layout.computed_rects);
1249
1250        let next_layout_redraw_in = match (needs_redraw, widget_redraw) {
1251            (true, Some(d)) => Some(d.min(std::time::Duration::ZERO)),
1252            (true, None) => Some(std::time::Duration::ZERO),
1253            (false, d) => d,
1254        };
1255        let next_paint_redraw_in = if shader_needs_redraw {
1256            Some(std::time::Duration::ZERO)
1257        } else {
1258            None
1259        };
1260        if next_layout_redraw_in.is_some() || next_paint_redraw_in.is_some() {
1261            needs_redraw = true;
1262        }
1263
1264        // Ops are returned by value (not cached on `self`) so the
1265        // caller can borrow them into the per-frame `prepare_paint`
1266        // without also locking `&mut self`. The wrapper hands them
1267        // back to `self.last_ops` after paint — see [`Self::last_ops`].
1268        LayoutPrepared {
1269            ops,
1270            needs_redraw,
1271            next_layout_redraw_in,
1272            next_paint_redraw_in,
1273        }
1274    }
1275
1276    /// Run [`Self::prepare_paint`] against the cached
1277    /// [`Self::last_ops`] from the most recent
1278    /// [`Self::prepare_layout`] call. Used by hosts that service a
1279    /// paint-only redraw (driven by
1280    /// [`PrepareResult::next_paint_redraw_in`]) without re-running
1281    /// build + layout.
1282    ///
1283    /// The caller is responsible for the same paint-time invariants as
1284    /// [`Self::prepare_paint`]: call `text.frame_begin()` first, and
1285    /// ensure no input has been processed since the last
1286    /// `prepare_layout` (otherwise hover / press state is stale and a
1287    /// full prepare is required instead).
1288    pub fn prepare_paint_cached<F1, F2>(
1289        &mut self,
1290        is_registered: F1,
1291        samples_backdrop: F2,
1292        text: &mut dyn TextRecorder,
1293        scale_factor: f32,
1294        timings: &mut PrepareTimings,
1295    ) where
1296        F1: Fn(&ShaderHandle) -> bool,
1297        F2: Fn(&ShaderHandle) -> bool,
1298    {
1299        // `prepare_paint` only touches `self.{quad_scratch, runs,
1300        // paint_items}`, not `self.last_ops`, but the borrow checker
1301        // can't see that — split-borrow via `mem::take` + restore.
1302        let ops = std::mem::take(&mut self.last_ops);
1303        self.prepare_paint(
1304            &ops,
1305            is_registered,
1306            samples_backdrop,
1307            text,
1308            scale_factor,
1309            timings,
1310        );
1311        self.last_ops = ops;
1312    }
1313
1314    /// Standard "no custom time-driven shaders" closure for
1315    /// [`Self::prepare_layout`]. Backends that haven't wired up the
1316    /// custom-shader registry yet pass this; only stock shaders that
1317    /// self-report via `is_continuous()` participate in the scan.
1318    pub fn no_time_shaders(_shader: &ShaderHandle) -> bool {
1319        false
1320    }
1321
1322    /// Re-evaluate the paint-lane deadline against the currently-cached
1323    /// [`Self::last_ops`]. Used by backends serving a paint-only frame
1324    /// (`repaint(...)`) so they can re-arm
1325    /// [`PrepareResult::next_paint_redraw_in`] without re-running
1326    /// `prepare_layout`. Returns `Some(Duration::ZERO)` when any cached
1327    /// op still binds a continuous shader.
1328    pub fn scan_continuous_shaders<F>(&self, samples_time: F) -> Option<std::time::Duration>
1329    where
1330        F: Fn(&ShaderHandle) -> bool,
1331    {
1332        let any = self
1333            .last_ops
1334            .iter()
1335            .any(|op| op_is_continuous(op, &samples_time));
1336        if any {
1337            Some(std::time::Duration::ZERO)
1338        } else {
1339            None
1340        }
1341    }
1342
1343    /// Walk the resolved `DrawOp` list, packing quads into
1344    /// `quad_scratch` + grouping them into `runs`, interleaving text
1345    /// records via the backend-supplied [`TextRecorder`]. Returns the
1346    /// number of quad instances written (so the backend can size its
1347    /// instance buffer).
1348    ///
1349    /// Callers must call `text.frame_begin()` themselves *before*
1350    /// invoking this — `prepare_paint` does not call it for them
1351    /// because backends often want to clear other per-frame text
1352    /// scratch in the same step.
1353    pub fn prepare_paint<F1, F2>(
1354        &mut self,
1355        ops: &[DrawOp],
1356        is_registered: F1,
1357        samples_backdrop: F2,
1358        text: &mut dyn TextRecorder,
1359        scale_factor: f32,
1360        timings: &mut PrepareTimings,
1361    ) where
1362        F1: Fn(&ShaderHandle) -> bool,
1363        F2: Fn(&ShaderHandle) -> bool,
1364    {
1365        crate::profile_span!("prepare::paint");
1366        let t0 = Instant::now();
1367        self.quad_scratch.clear();
1368        self.runs.clear();
1369        self.paint_items.clear();
1370
1371        let mut current: Option<(ShaderHandle, Option<PhysicalScissor>)> = None;
1372        let mut run_first: u32 = 0;
1373        // At most one snapshot per frame. Auto-inserted before
1374        // the first paint that samples the backdrop.
1375        let mut snapshot_emitted = false;
1376
1377        for op in ops {
1378            match op {
1379                DrawOp::Quad {
1380                    rect,
1381                    scissor,
1382                    shader,
1383                    uniforms,
1384                    ..
1385                } => {
1386                    if !is_registered(shader) {
1387                        continue;
1388                    }
1389                    let phys = physical_scissor(*scissor, scale_factor, self.viewport_px);
1390                    if matches!(phys, Some(s) if s.w == 0 || s.h == 0) {
1391                        continue;
1392                    }
1393                    if !snapshot_emitted && samples_backdrop(shader) {
1394                        close_run(
1395                            &mut self.runs,
1396                            &mut self.paint_items,
1397                            current,
1398                            run_first,
1399                            self.quad_scratch.len() as u32,
1400                        );
1401                        current = None;
1402                        run_first = self.quad_scratch.len() as u32;
1403                        self.paint_items.push(PaintItem::BackdropSnapshot);
1404                        snapshot_emitted = true;
1405                    }
1406                    let inst = pack_instance(*rect, *shader, uniforms);
1407
1408                    let key = (*shader, phys);
1409                    if current != Some(key) {
1410                        close_run(
1411                            &mut self.runs,
1412                            &mut self.paint_items,
1413                            current,
1414                            run_first,
1415                            self.quad_scratch.len() as u32,
1416                        );
1417                        current = Some(key);
1418                        run_first = self.quad_scratch.len() as u32;
1419                    }
1420                    self.quad_scratch.push(inst);
1421                }
1422                DrawOp::GlyphRun {
1423                    rect,
1424                    scissor,
1425                    color,
1426                    text: glyph_text,
1427                    size,
1428                    line_height,
1429                    family,
1430                    mono_family,
1431                    weight,
1432                    mono,
1433                    wrap,
1434                    anchor,
1435                    underline,
1436                    strikethrough,
1437                    link,
1438                    ..
1439                } => {
1440                    close_run(
1441                        &mut self.runs,
1442                        &mut self.paint_items,
1443                        current,
1444                        run_first,
1445                        self.quad_scratch.len() as u32,
1446                    );
1447                    current = None;
1448                    run_first = self.quad_scratch.len() as u32;
1449
1450                    let phys = physical_scissor(*scissor, scale_factor, self.viewport_px);
1451                    if matches!(phys, Some(s) if s.w == 0 || s.h == 0) {
1452                        continue;
1453                    }
1454                    let mut style = crate::text::atlas::RunStyle::new(*weight, *color)
1455                        .family(*family)
1456                        .mono_family(*mono_family);
1457                    if *mono {
1458                        style = style.mono();
1459                    }
1460                    if *underline {
1461                        style = style.underline();
1462                    }
1463                    if *strikethrough {
1464                        style = style.strikethrough();
1465                    }
1466                    if let Some(url) = link {
1467                        style = style.with_link(url.clone());
1468                    }
1469                    let layers = text.record(
1470                        *rect,
1471                        phys,
1472                        &style,
1473                        glyph_text,
1474                        *size,
1475                        *line_height,
1476                        *wrap,
1477                        *anchor,
1478                        scale_factor,
1479                    );
1480                    for index in layers {
1481                        self.paint_items.push(PaintItem::Text(index));
1482                    }
1483                }
1484                DrawOp::AttributedText {
1485                    rect,
1486                    scissor,
1487                    runs,
1488                    size,
1489                    line_height,
1490                    wrap,
1491                    anchor,
1492                    ..
1493                } => {
1494                    close_run(
1495                        &mut self.runs,
1496                        &mut self.paint_items,
1497                        current,
1498                        run_first,
1499                        self.quad_scratch.len() as u32,
1500                    );
1501                    current = None;
1502                    run_first = self.quad_scratch.len() as u32;
1503
1504                    let phys = physical_scissor(*scissor, scale_factor, self.viewport_px);
1505                    if matches!(phys, Some(s) if s.w == 0 || s.h == 0) {
1506                        continue;
1507                    }
1508                    let layers = text.record_runs(
1509                        *rect,
1510                        phys,
1511                        runs,
1512                        *size,
1513                        *line_height,
1514                        *wrap,
1515                        *anchor,
1516                        scale_factor,
1517                    );
1518                    for index in layers {
1519                        self.paint_items.push(PaintItem::Text(index));
1520                    }
1521                }
1522                DrawOp::Icon {
1523                    rect,
1524                    scissor,
1525                    source,
1526                    color,
1527                    size,
1528                    stroke_width,
1529                    ..
1530                } => {
1531                    close_run(
1532                        &mut self.runs,
1533                        &mut self.paint_items,
1534                        current,
1535                        run_first,
1536                        self.quad_scratch.len() as u32,
1537                    );
1538                    current = None;
1539                    run_first = self.quad_scratch.len() as u32;
1540
1541                    let phys = physical_scissor(*scissor, scale_factor, self.viewport_px);
1542                    if matches!(phys, Some(s) if s.w == 0 || s.h == 0) {
1543                        continue;
1544                    }
1545                    let recorded = text.record_icon(
1546                        *rect,
1547                        phys,
1548                        source,
1549                        *color,
1550                        *size,
1551                        *stroke_width,
1552                        scale_factor,
1553                    );
1554                    match recorded {
1555                        RecordedPaint::Text(layers) => {
1556                            for index in layers {
1557                                self.paint_items.push(PaintItem::Text(index));
1558                            }
1559                        }
1560                        RecordedPaint::Icon(runs) => {
1561                            for index in runs {
1562                                self.paint_items.push(PaintItem::IconRun(index));
1563                            }
1564                        }
1565                    }
1566                }
1567                DrawOp::Image {
1568                    rect,
1569                    scissor,
1570                    image,
1571                    tint,
1572                    radius,
1573                    fit,
1574                    ..
1575                } => {
1576                    close_run(
1577                        &mut self.runs,
1578                        &mut self.paint_items,
1579                        current,
1580                        run_first,
1581                        self.quad_scratch.len() as u32,
1582                    );
1583                    current = None;
1584                    run_first = self.quad_scratch.len() as u32;
1585
1586                    let phys = physical_scissor(*scissor, scale_factor, self.viewport_px);
1587                    if matches!(phys, Some(s) if s.w == 0 || s.h == 0) {
1588                        continue;
1589                    }
1590                    let recorded =
1591                        text.record_image(*rect, phys, image, *tint, *radius, *fit, scale_factor);
1592                    for index in recorded {
1593                        self.paint_items.push(PaintItem::Image(index));
1594                    }
1595                }
1596                DrawOp::AppTexture {
1597                    rect,
1598                    scissor,
1599                    texture,
1600                    alpha,
1601                    transform,
1602                    ..
1603                } => {
1604                    close_run(
1605                        &mut self.runs,
1606                        &mut self.paint_items,
1607                        current,
1608                        run_first,
1609                        self.quad_scratch.len() as u32,
1610                    );
1611                    current = None;
1612                    run_first = self.quad_scratch.len() as u32;
1613
1614                    let phys = physical_scissor(*scissor, scale_factor, self.viewport_px);
1615                    if matches!(phys, Some(s) if s.w == 0 || s.h == 0) {
1616                        continue;
1617                    }
1618                    let recorded = text.record_app_texture(
1619                        *rect,
1620                        phys,
1621                        texture,
1622                        *alpha,
1623                        *transform,
1624                        scale_factor,
1625                    );
1626                    for index in recorded {
1627                        self.paint_items.push(PaintItem::AppTexture(index));
1628                    }
1629                }
1630                DrawOp::Vector {
1631                    rect,
1632                    scissor,
1633                    asset,
1634                    render_mode,
1635                    ..
1636                } => {
1637                    close_run(
1638                        &mut self.runs,
1639                        &mut self.paint_items,
1640                        current,
1641                        run_first,
1642                        self.quad_scratch.len() as u32,
1643                    );
1644                    current = None;
1645                    run_first = self.quad_scratch.len() as u32;
1646
1647                    let phys = physical_scissor(*scissor, scale_factor, self.viewport_px);
1648                    if matches!(phys, Some(s) if s.w == 0 || s.h == 0) {
1649                        continue;
1650                    }
1651                    let recorded =
1652                        text.record_vector(*rect, phys, asset, *render_mode, scale_factor);
1653                    for index in recorded {
1654                        self.paint_items.push(PaintItem::Vector(index));
1655                    }
1656                }
1657                DrawOp::BackdropSnapshot => {
1658                    close_run(
1659                        &mut self.runs,
1660                        &mut self.paint_items,
1661                        current,
1662                        run_first,
1663                        self.quad_scratch.len() as u32,
1664                    );
1665                    current = None;
1666                    run_first = self.quad_scratch.len() as u32;
1667                    // Cap at one snapshot per frame; an explicit op only
1668                    // lands if the auto-emitter hasn't fired yet.
1669                    if !snapshot_emitted {
1670                        self.paint_items.push(PaintItem::BackdropSnapshot);
1671                        snapshot_emitted = true;
1672                    }
1673                }
1674            }
1675        }
1676        close_run(
1677            &mut self.runs,
1678            &mut self.paint_items,
1679            current,
1680            run_first,
1681            self.quad_scratch.len() as u32,
1682        );
1683        timings.paint = Instant::now() - t0;
1684    }
1685
1686    /// Take a clone of the laid-out tree for next-frame hit-testing.
1687    /// Call after the per-frame work completes (GPU upload, atlas
1688    /// flush, etc.) so the snapshot reflects final geometry. Writes
1689    /// `timings.snapshot`.
1690    pub fn snapshot(&mut self, root: &El, timings: &mut PrepareTimings) {
1691        crate::profile_span!("prepare::snapshot");
1692        let t0 = Instant::now();
1693        self.last_tree = Some(root.clone());
1694        timings.snapshot = Instant::now() - t0;
1695    }
1696}
1697
1698/// Whether this op binds a shader whose output depends on `frame.time`.
1699/// Stock shaders self-report through
1700/// [`crate::shader::StockShader::is_continuous`]; custom shaders
1701/// answer through the host-supplied closure (which the backend wires
1702/// to its `samples_time=true` registration set). See
1703/// [`RunnerCore::prepare_layout`].
1704fn op_is_continuous<F>(op: &DrawOp, samples_time: &F) -> bool
1705where
1706    F: Fn(&ShaderHandle) -> bool,
1707{
1708    match op.shader() {
1709        Some(handle @ ShaderHandle::Stock(s)) => s.is_continuous() || samples_time(handle),
1710        Some(handle @ ShaderHandle::Custom(_)) => samples_time(handle),
1711        None => false,
1712    }
1713}
1714
1715/// Walk the El tree and return the tightest [`El::redraw_within`]
1716/// deadline among visible widgets (rect intersects the viewport, both
1717/// dimensions positive). Used by [`RunnerCore::prepare_layout`] to
1718/// surface the inside-out redraw aggregate as
1719/// [`PrepareResult::next_redraw_in`].
1720fn aggregate_redraw_within(
1721    node: &El,
1722    viewport: Rect,
1723    rects: &rustc_hash::FxHashMap<String, Rect>,
1724) -> Option<std::time::Duration> {
1725    let mut acc: Option<std::time::Duration> = None;
1726    visit_redraw_within(node, viewport, rects, VisibilityClip::Unclipped, &mut acc);
1727    acc
1728}
1729
1730#[derive(Clone, Copy)]
1731enum VisibilityClip {
1732    Unclipped,
1733    Clipped(Rect),
1734    Empty,
1735}
1736
1737impl VisibilityClip {
1738    fn intersect(self, rect: Rect) -> Self {
1739        if rect.w <= 0.0 || rect.h <= 0.0 {
1740            return Self::Empty;
1741        }
1742        match self {
1743            Self::Unclipped => Self::Clipped(rect),
1744            Self::Clipped(prev) => prev
1745                .intersect(rect)
1746                .map(Self::Clipped)
1747                .unwrap_or(Self::Empty),
1748            Self::Empty => Self::Empty,
1749        }
1750    }
1751
1752    fn permits(self, rect: Rect) -> bool {
1753        if rect.w <= 0.0 || rect.h <= 0.0 {
1754            return false;
1755        }
1756        match self {
1757            Self::Unclipped => true,
1758            Self::Clipped(clip) => rect.intersect(clip).is_some(),
1759            Self::Empty => false,
1760        }
1761    }
1762}
1763
1764fn visit_redraw_within(
1765    node: &El,
1766    viewport: Rect,
1767    rects: &rustc_hash::FxHashMap<String, Rect>,
1768    inherited_clip: VisibilityClip,
1769    acc: &mut Option<std::time::Duration>,
1770) {
1771    let rect = rects.get(&node.computed_id).copied();
1772    if let Some(d) = node.redraw_within {
1773        if let Some(rect) = rect
1774            && rect.w > 0.0
1775            && rect.h > 0.0
1776            && rect.intersect(viewport).is_some()
1777            && inherited_clip.permits(rect)
1778        {
1779            *acc = Some(match *acc {
1780                Some(prev) => prev.min(d),
1781                None => d,
1782            });
1783        }
1784    }
1785    let child_clip = if node.clip {
1786        rect.map(|r| inherited_clip.intersect(r))
1787            .unwrap_or(VisibilityClip::Empty)
1788    } else {
1789        inherited_clip
1790    };
1791    for child in &node.children {
1792        visit_redraw_within(child, viewport, rects, child_clip, acc);
1793    }
1794}
1795
1796/// Find the `capture_keys` flag of the node whose `computed_id`
1797/// equals `id`, walking the laid-out tree. Returns `None` when the id
1798/// isn't found (the focused target outlived its node — a one-frame
1799/// race after a rebuild).
1800pub(crate) fn find_capture_keys(node: &El, id: &str) -> Option<bool> {
1801    if node.computed_id == id {
1802        return Some(node.capture_keys);
1803    }
1804    node.children.iter().find_map(|c| find_capture_keys(c, id))
1805}
1806
1807/// Construct a `SelectionChanged` event carrying the new selection.
1808fn selection_event(
1809    new_sel: crate::selection::Selection,
1810    modifiers: KeyModifiers,
1811    pointer: Option<(f32, f32)>,
1812) -> UiEvent {
1813    UiEvent {
1814        kind: UiEventKind::SelectionChanged,
1815        key: None,
1816        target: None,
1817        pointer,
1818        key_press: None,
1819        text: None,
1820        selection: Some(new_sel),
1821        modifiers,
1822        click_count: 0,
1823        path: None,
1824    }
1825}
1826
1827/// Resolve the head's [`SelectionPoint`] for the current pointer
1828/// position during a drag. Browser-style projection rules:
1829///
1830/// - If the pointer hits a selectable leaf, head goes there.
1831/// - Otherwise, head goes to the closest selectable leaf in document
1832///   order, with `(x, y)` projected onto that leaf's vertical extent.
1833///   Above all leaves → first leaf at byte 0; below all → last leaf
1834///   at end; in the gap between two adjacent leaves → whichever is
1835///   nearer in y.
1836/// - Horizontally outside the chosen leaf's text → snap to the
1837///   leaf's left edge (byte 0) or right edge (`text.len()`).
1838fn head_for_drag(
1839    root: &El,
1840    ui_state: &UiState,
1841    point: (f32, f32),
1842) -> Option<crate::selection::SelectionPoint> {
1843    if let Some(p) = hit_test::selection_point_at(root, ui_state, point) {
1844        return Some(p);
1845    }
1846
1847    let order = &ui_state.selection.order;
1848    if order.is_empty() {
1849        return None;
1850    }
1851    // Prefer a leaf whose vertical extent contains the pointer's y;
1852    // otherwise pick the y-closest leaf. min_by visits in document
1853    // order so ties (multiple leaves at the same y-distance) resolve
1854    // to the earliest one.
1855    let target = order
1856        .iter()
1857        .find(|t| point.1 >= t.rect.y && point.1 < t.rect.y + t.rect.h)
1858        .or_else(|| {
1859            order.iter().min_by(|a, b| {
1860                let da = y_distance(a.rect, point.1);
1861                let db = y_distance(b.rect, point.1);
1862                da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
1863            })
1864        })?;
1865    let target_rect = target.rect;
1866    let cy = point
1867        .1
1868        .clamp(target_rect.y, target_rect.y + target_rect.h - 1.0);
1869    if let Some(p) = hit_test::selection_point_at(root, ui_state, (point.0, cy)) {
1870        return Some(p);
1871    }
1872    // Couldn't hit-test (likely because the pointer's x is outside
1873    // the leaf's rendered text width). Snap to the nearest edge.
1874    let leaf_len = find_text_len(root, &target.node_id).unwrap_or(0);
1875    let byte = if point.0 < target_rect.x { 0 } else { leaf_len };
1876    Some(crate::selection::SelectionPoint {
1877        key: target.key.clone(),
1878        byte,
1879    })
1880}
1881
1882fn y_distance(rect: Rect, y: f32) -> f32 {
1883    if y < rect.y {
1884        rect.y - y
1885    } else if y > rect.y + rect.h {
1886        y - (rect.y + rect.h)
1887    } else {
1888        0.0
1889    }
1890}
1891
1892fn find_text_len(node: &El, id: &str) -> Option<usize> {
1893    if node.computed_id == id {
1894        if let Some(source) = &node.selection_source {
1895            return Some(source.visible_len());
1896        }
1897        return node.text.as_ref().map(|t| t.len());
1898    }
1899    node.children.iter().find_map(|c| find_text_len(c, id))
1900}
1901
1902/// Recorded output from an icon draw op. Backends without a vector-icon
1903/// path use `Text` fallback layers; wgpu can return dedicated icon runs.
1904pub enum RecordedPaint {
1905    Text(Range<usize>),
1906    Icon(Range<usize>),
1907}
1908
1909/// Glyph-recording surface implemented by each backend's `TextPaint`.
1910/// `prepare_paint` calls into it exactly the same way wgpu and vulkano
1911/// would call their per-backend equivalents.
1912pub trait TextRecorder {
1913    /// Append per-glyph instances for `text` and return the range of
1914    /// indices written into the backend's `TextLayer` storage. Each
1915    /// returned index lands in `paint_items` as a `PaintItem::Text`.
1916    ///
1917    /// `style` carries weight + color + (optional) decoration flags
1918    /// — backends fold it into a single-element `(text, style)` slice
1919    /// and run the same shaping path as [`Self::record_runs`].
1920    #[allow(clippy::too_many_arguments)]
1921    fn record(
1922        &mut self,
1923        rect: Rect,
1924        scissor: Option<PhysicalScissor>,
1925        style: &RunStyle,
1926        text: &str,
1927        size: f32,
1928        line_height: f32,
1929        wrap: TextWrap,
1930        anchor: TextAnchor,
1931        scale_factor: f32,
1932    ) -> Range<usize>;
1933
1934    /// Append per-glyph instances for an attributed paragraph (one
1935    /// shaped run with per-character RunStyle metadata). Wrapping
1936    /// decisions cross run boundaries — the result is one ShapedRun
1937    /// just like a single-style call.
1938    #[allow(clippy::too_many_arguments)]
1939    fn record_runs(
1940        &mut self,
1941        rect: Rect,
1942        scissor: Option<PhysicalScissor>,
1943        runs: &[(String, RunStyle)],
1944        size: f32,
1945        line_height: f32,
1946        wrap: TextWrap,
1947        anchor: TextAnchor,
1948        scale_factor: f32,
1949    ) -> Range<usize>;
1950
1951    /// Append a vector icon. Backends with a native vector painter
1952    /// override this; the default keeps experimental/simple backends on
1953    /// the previous text-symbol fallback. Built-in icons fall back to
1954    /// their named glyph; app-supplied SVG icons fall back to a
1955    /// generic placeholder since they have no canonical glyph.
1956    #[allow(clippy::too_many_arguments)]
1957    fn record_icon(
1958        &mut self,
1959        rect: Rect,
1960        scissor: Option<PhysicalScissor>,
1961        source: &crate::svg_icon::IconSource,
1962        color: Color,
1963        size: f32,
1964        _stroke_width: f32,
1965        scale_factor: f32,
1966    ) -> RecordedPaint {
1967        let glyph = match source {
1968            crate::svg_icon::IconSource::Builtin(name) => name.fallback_glyph(),
1969            crate::svg_icon::IconSource::Custom(_) => "?",
1970        };
1971        RecordedPaint::Text(self.record(
1972            rect,
1973            scissor,
1974            &RunStyle::new(FontWeight::Regular, color),
1975            glyph,
1976            size,
1977            crate::text::metrics::line_height(size),
1978            TextWrap::NoWrap,
1979            TextAnchor::Middle,
1980            scale_factor,
1981        ))
1982    }
1983
1984    /// Append a raster image draw. Backends with texture sampling
1985    /// override this and return one or more indices into their image
1986    /// storage (each index lands in `paint_items` as
1987    /// `PaintItem::Image`). The default returns an empty range —
1988    /// backends without raster support paint nothing for image Els
1989    /// (the SVG fallback emits a labelled placeholder rect on its own).
1990    #[allow(clippy::too_many_arguments)]
1991    fn record_image(
1992        &mut self,
1993        _rect: Rect,
1994        _scissor: Option<PhysicalScissor>,
1995        _image: &crate::image::Image,
1996        _tint: Option<Color>,
1997        _radius: crate::tree::Corners,
1998        _fit: crate::image::ImageFit,
1999        _scale_factor: f32,
2000    ) -> Range<usize> {
2001        0..0
2002    }
2003
2004    /// Append an app-owned-texture composite. Backends with surface
2005    /// support override this and return one or more indices into their
2006    /// surface storage (each lands in `paint_items` as
2007    /// `PaintItem::AppTexture`). The default returns an empty range so
2008    /// backends without surface support paint nothing for surface Els.
2009    fn record_app_texture(
2010        &mut self,
2011        _rect: Rect,
2012        _scissor: Option<PhysicalScissor>,
2013        _texture: &crate::surface::AppTexture,
2014        _alpha: crate::surface::SurfaceAlpha,
2015        _transform: crate::affine::Affine2,
2016        _scale_factor: f32,
2017    ) -> Range<usize> {
2018        0..0
2019    }
2020
2021    /// Append an app-supplied vector draw. Backends with vector
2022    /// support override this and return one or more indices into their
2023    /// vector storage (each lands in `paint_items` as
2024    /// `PaintItem::Vector`). The default returns an empty range so
2025    /// backends without vector support paint nothing.
2026    fn record_vector(
2027        &mut self,
2028        _rect: Rect,
2029        _scissor: Option<PhysicalScissor>,
2030        _asset: &crate::vector::VectorAsset,
2031        _render_mode: crate::vector::VectorRenderMode,
2032        _scale_factor: f32,
2033    ) -> Range<usize> {
2034        0..0
2035    }
2036}
2037
2038#[cfg(test)]
2039mod tests {
2040    use super::*;
2041    use crate::shader::{ShaderHandle, StockShader, UniformBlock};
2042
2043    /// Minimal recorder for tests that don't exercise the text path.
2044    struct NoText;
2045    impl TextRecorder for NoText {
2046        fn record(
2047            &mut self,
2048            _rect: Rect,
2049            _scissor: Option<PhysicalScissor>,
2050            _style: &RunStyle,
2051            _text: &str,
2052            _size: f32,
2053            _line_height: f32,
2054            _wrap: TextWrap,
2055            _anchor: TextAnchor,
2056            _scale_factor: f32,
2057        ) -> Range<usize> {
2058            0..0
2059        }
2060        fn record_runs(
2061            &mut self,
2062            _rect: Rect,
2063            _scissor: Option<PhysicalScissor>,
2064            _runs: &[(String, RunStyle)],
2065            _size: f32,
2066            _line_height: f32,
2067            _wrap: TextWrap,
2068            _anchor: TextAnchor,
2069            _scale_factor: f32,
2070        ) -> Range<usize> {
2071            0..0
2072        }
2073    }
2074
2075    // ---- input plumbing ----
2076
2077    /// A tree with one focusable button at (10,10,80,40) keyed "btn",
2078    /// plus an optional capture_keys text input at (10,60,80,40) keyed
2079    /// "ti". layout() runs against a 200x200 viewport so the rects
2080    /// land where we expect.
2081    fn lay_out_input_tree(capture: bool) -> RunnerCore {
2082        use crate::tree::*;
2083        let ti = if capture {
2084            crate::widgets::text::text("input").key("ti").capture_keys()
2085        } else {
2086            crate::widgets::text::text("noop").key("ti").focusable()
2087        };
2088        let mut tree =
2089            crate::column([crate::widgets::button::button("Btn").key("btn"), ti]).padding(10.0);
2090        let mut core = RunnerCore::new();
2091        crate::layout::layout(
2092            &mut tree,
2093            &mut core.ui_state,
2094            Rect::new(0.0, 0.0, 200.0, 200.0),
2095        );
2096        core.ui_state.sync_focus_order(&tree);
2097        let mut t = PrepareTimings::default();
2098        core.snapshot(&tree, &mut t);
2099        core
2100    }
2101
2102    #[test]
2103    fn pointer_up_emits_pointer_up_then_click() {
2104        let mut core = lay_out_input_tree(false);
2105        let btn_rect = core.rect_of_key("btn").expect("btn rect");
2106        let cx = btn_rect.x + btn_rect.w * 0.5;
2107        let cy = btn_rect.y + btn_rect.h * 0.5;
2108        core.pointer_moved(cx, cy);
2109        core.pointer_down(cx, cy, PointerButton::Primary);
2110        let events = core.pointer_up(cx, cy, PointerButton::Primary);
2111        let kinds: Vec<UiEventKind> = events.iter().map(|e| e.kind).collect();
2112        assert_eq!(kinds, vec![UiEventKind::PointerUp, UiEventKind::Click]);
2113    }
2114
2115    /// Build a tree containing a single inline paragraph with one
2116    /// linked run, layout to a fixed viewport, and return the runner +
2117    /// the absolute rect of the paragraph. The linked text is long
2118    /// enough that probes well into the paragraph land safely inside
2119    /// the link for any plausible proportional font.
2120    fn lay_out_link_tree() -> (RunnerCore, Rect, &'static str) {
2121        use crate::tree::*;
2122        const URL: &str = "https://github.com/computer-whisperer/aetna";
2123        let mut tree = crate::column([crate::text_runs([
2124            crate::text("Visit "),
2125            crate::text("github.com/computer-whisperer/aetna").link(URL),
2126            crate::text("."),
2127        ])])
2128        .padding(10.0);
2129        let mut core = RunnerCore::new();
2130        crate::layout::layout(
2131            &mut tree,
2132            &mut core.ui_state,
2133            Rect::new(0.0, 0.0, 600.0, 200.0),
2134        );
2135        core.ui_state.sync_focus_order(&tree);
2136        let mut t = PrepareTimings::default();
2137        core.snapshot(&tree, &mut t);
2138        let para = core
2139            .last_tree
2140            .as_ref()
2141            .and_then(|t| t.children.first())
2142            .map(|p| core.ui_state.rect(&p.computed_id))
2143            .expect("paragraph rect");
2144        (core, para, URL)
2145    }
2146
2147    #[test]
2148    fn pointer_up_on_link_emits_link_activated_with_url() {
2149        let (mut core, para, url) = lay_out_link_tree();
2150        // Probe ~100 logical pixels in — past the "Visit " prefix
2151        // (~40px in default UI font) and well inside the long linked
2152        // run, which extends ~250+px from there.
2153        let cx = para.x + 100.0;
2154        let cy = para.y + para.h * 0.5;
2155        core.pointer_moved(cx, cy);
2156        core.pointer_down(cx, cy, PointerButton::Primary);
2157        let events = core.pointer_up(cx, cy, PointerButton::Primary);
2158        let link = events
2159            .iter()
2160            .find(|e| e.kind == UiEventKind::LinkActivated)
2161            .expect("LinkActivated event");
2162        assert_eq!(link.key.as_deref(), Some(url));
2163    }
2164
2165    #[test]
2166    fn pointer_up_after_drag_off_link_does_not_activate() {
2167        let (mut core, para, _url) = lay_out_link_tree();
2168        let press_x = para.x + 100.0;
2169        let cy = para.y + para.h * 0.5;
2170        core.pointer_moved(press_x, cy);
2171        core.pointer_down(press_x, cy, PointerButton::Primary);
2172        // Release far below the paragraph — the user dragged off the
2173        // link before letting go, which native browsers treat as
2174        // cancel.
2175        let events = core.pointer_up(press_x, 180.0, PointerButton::Primary);
2176        let kinds: Vec<UiEventKind> = events.iter().map(|e| e.kind).collect();
2177        assert!(
2178            !kinds.contains(&UiEventKind::LinkActivated),
2179            "drag-off-link should cancel the link activation; got {kinds:?}",
2180        );
2181    }
2182
2183    #[test]
2184    fn pointer_moved_over_link_resolves_cursor_to_pointer_and_requests_redraw() {
2185        use crate::cursor::Cursor;
2186        let (mut core, para, _url) = lay_out_link_tree();
2187        let cx = para.x + 100.0;
2188        let cy = para.y + para.h * 0.5;
2189        // Pointer initially well outside the paragraph.
2190        let initial = core.pointer_moved(para.x - 50.0, cy);
2191        assert!(
2192            !initial.needs_redraw,
2193            "moving in empty space shouldn't request a redraw"
2194        );
2195        let tree = core.last_tree.as_ref().expect("tree").clone();
2196        assert_eq!(
2197            core.ui_state.cursor(&tree),
2198            Cursor::Default,
2199            "no link under pointer → default cursor"
2200        );
2201        // Move onto the link — needs_redraw flips so the host
2202        // re-resolves the cursor on the next frame.
2203        let onto = core.pointer_moved(cx, cy);
2204        assert!(
2205            onto.needs_redraw,
2206            "entering a link region should flag a redraw so the cursor refresh isn't stale"
2207        );
2208        assert_eq!(
2209            core.ui_state.cursor(&tree),
2210            Cursor::Pointer,
2211            "pointer over a link → Pointer cursor"
2212        );
2213        // Move back off — should flag a redraw again so the cursor
2214        // returns to Default.
2215        let off = core.pointer_moved(para.x - 50.0, cy);
2216        assert!(
2217            off.needs_redraw,
2218            "leaving a link region should flag a redraw"
2219        );
2220        assert_eq!(core.ui_state.cursor(&tree), Cursor::Default);
2221    }
2222
2223    #[test]
2224    fn pointer_up_on_unlinked_text_does_not_emit_link_activated() {
2225        let (mut core, para, _url) = lay_out_link_tree();
2226        // Click 1px in from the left edge — inside the "Visit "
2227        // prefix, before the linked run starts.
2228        let cx = para.x + 1.0;
2229        let cy = para.y + para.h * 0.5;
2230        core.pointer_moved(cx, cy);
2231        core.pointer_down(cx, cy, PointerButton::Primary);
2232        let events = core.pointer_up(cx, cy, PointerButton::Primary);
2233        let kinds: Vec<UiEventKind> = events.iter().map(|e| e.kind).collect();
2234        assert!(
2235            !kinds.contains(&UiEventKind::LinkActivated),
2236            "click on the unlinked prefix should not surface a link event; got {kinds:?}",
2237        );
2238    }
2239
2240    #[test]
2241    fn pointer_up_off_target_emits_only_pointer_up() {
2242        let mut core = lay_out_input_tree(false);
2243        let btn_rect = core.rect_of_key("btn").expect("btn rect");
2244        let cx = btn_rect.x + btn_rect.w * 0.5;
2245        let cy = btn_rect.y + btn_rect.h * 0.5;
2246        core.pointer_down(cx, cy, PointerButton::Primary);
2247        // Release off-target (well outside any keyed node).
2248        let events = core.pointer_up(180.0, 180.0, PointerButton::Primary);
2249        let kinds: Vec<UiEventKind> = events.iter().map(|e| e.kind).collect();
2250        assert_eq!(
2251            kinds,
2252            vec![UiEventKind::PointerUp],
2253            "drag-off-target should still surface PointerUp so widgets see drag-end"
2254        );
2255    }
2256
2257    #[test]
2258    fn pointer_moved_while_pressed_emits_drag() {
2259        let mut core = lay_out_input_tree(false);
2260        let btn_rect = core.rect_of_key("btn").expect("btn rect");
2261        let cx = btn_rect.x + btn_rect.w * 0.5;
2262        let cy = btn_rect.y + btn_rect.h * 0.5;
2263        core.pointer_down(cx, cy, PointerButton::Primary);
2264        let drag = core
2265            .pointer_moved(cx + 30.0, cy)
2266            .events
2267            .into_iter()
2268            .find(|e| e.kind == UiEventKind::Drag)
2269            .expect("drag while pressed");
2270        assert_eq!(drag.target.as_ref().map(|t| t.key.as_str()), Some("btn"));
2271        assert_eq!(drag.pointer, Some((cx + 30.0, cy)));
2272    }
2273
2274    #[test]
2275    fn toast_dismiss_click_removes_toast_and_suppresses_click_event() {
2276        use crate::toast::ToastSpec;
2277        use crate::tree::Size;
2278        // Build a fresh runner, queue a toast, prepare once so the
2279        // toast layer is laid out, then synthesize a click on its
2280        // dismiss button.
2281        let mut core = RunnerCore::new();
2282        core.ui_state
2283            .push_toast(ToastSpec::success("hi"), Instant::now());
2284        let toast_id = core.ui_state.toasts()[0].id;
2285
2286        // Build & lay out a tree with the toast layer appended.
2287        // Root is `stack(...)` (Axis::Overlay) so the synthesized
2288        // toast layer overlays rather than competing for flex space.
2289        let mut tree: El = crate::stack(std::iter::empty::<El>())
2290            .width(Size::Fill(1.0))
2291            .height(Size::Fill(1.0));
2292        crate::layout::assign_ids(&mut tree);
2293        let _ = crate::toast::synthesize_toasts(&mut tree, &mut core.ui_state, Instant::now());
2294        crate::layout::layout(
2295            &mut tree,
2296            &mut core.ui_state,
2297            Rect::new(0.0, 0.0, 800.0, 600.0),
2298        );
2299        core.ui_state.sync_focus_order(&tree);
2300        let mut t = PrepareTimings::default();
2301        core.snapshot(&tree, &mut t);
2302
2303        let dismiss_key = format!("toast-dismiss-{toast_id}");
2304        let dismiss_rect = core.rect_of_key(&dismiss_key).expect("dismiss button");
2305        let cx = dismiss_rect.x + dismiss_rect.w * 0.5;
2306        let cy = dismiss_rect.y + dismiss_rect.h * 0.5;
2307
2308        core.pointer_down(cx, cy, PointerButton::Primary);
2309        let events = core.pointer_up(cx, cy, PointerButton::Primary);
2310        let kinds: Vec<UiEventKind> = events.iter().map(|e| e.kind).collect();
2311        // PointerUp still fires (kept generic so drag-aware widgets
2312        // observe drag-end); Click is intercepted by the toast
2313        // bookkeeping.
2314        assert!(
2315            !kinds.contains(&UiEventKind::Click),
2316            "Click on toast-dismiss should not be surfaced: {kinds:?}",
2317        );
2318        assert!(
2319            core.ui_state.toasts().iter().all(|t| t.id != toast_id),
2320            "toast {toast_id} should be dropped after dismiss-click",
2321        );
2322    }
2323
2324    #[test]
2325    fn pointer_moved_without_press_emits_no_drag() {
2326        let mut core = lay_out_input_tree(false);
2327        let events = core.pointer_moved(50.0, 50.0).events;
2328        // No press → no Drag emission. Hover-transition events
2329        // (PointerEnter/Leave) may fire; just assert nothing in the
2330        // out vec carries the Drag kind.
2331        assert!(!events.iter().any(|e| e.kind == UiEventKind::Drag));
2332    }
2333
2334    #[test]
2335    fn spinner_in_tree_keeps_needs_redraw_set() {
2336        // stock::spinner reads frame.time, so the host must keep
2337        // calling prepare() even when no animation is in flight. Pin
2338        // the contract: a tree with no other motion still reports
2339        // needs_redraw=true when a spinner is present.
2340        use crate::widgets::spinner::spinner;
2341        let mut tree = crate::column([spinner()]);
2342        let mut core = RunnerCore::new();
2343        let mut t = PrepareTimings::default();
2344        let LayoutPrepared { needs_redraw, .. } = core.prepare_layout(
2345            &mut tree,
2346            Rect::new(0.0, 0.0, 200.0, 200.0),
2347            1.0,
2348            &mut t,
2349            RunnerCore::no_time_shaders,
2350        );
2351        assert!(
2352            needs_redraw,
2353            "tree with a spinner must request continuous redraw",
2354        );
2355
2356        // Same shape without a spinner — needs_redraw stays false once
2357        // any state envelopes settle, demonstrating the signal is
2358        // spinner-driven rather than always-on.
2359        let mut bare = crate::column([crate::widgets::text::text("idle")]);
2360        let mut core2 = RunnerCore::new();
2361        let mut t2 = PrepareTimings::default();
2362        let LayoutPrepared {
2363            needs_redraw: needs_redraw2,
2364            ..
2365        } = core2.prepare_layout(
2366            &mut bare,
2367            Rect::new(0.0, 0.0, 200.0, 200.0),
2368            1.0,
2369            &mut t2,
2370            RunnerCore::no_time_shaders,
2371        );
2372        assert!(
2373            !needs_redraw2,
2374            "tree without time-driven shaders should idle: got needs_redraw={needs_redraw2}",
2375        );
2376    }
2377
2378    #[test]
2379    fn custom_samples_time_shader_keeps_needs_redraw_set() {
2380        // Pin the generalization: a tree binding a *custom* shader
2381        // whose name appears in the host's `samples_time` set must
2382        // request continuous redraw the same way stock::spinner does.
2383        let mut tree = crate::column([crate::tree::El::new(crate::tree::Kind::Custom("anim"))
2384            .shader(crate::shader::ShaderBinding::custom("my_animated_glow"))
2385            .width(crate::tree::Size::Fixed(32.0))
2386            .height(crate::tree::Size::Fixed(32.0))]);
2387        let mut core = RunnerCore::new();
2388        let mut t = PrepareTimings::default();
2389
2390        let LayoutPrepared {
2391            needs_redraw: idle, ..
2392        } = core.prepare_layout(
2393            &mut tree,
2394            Rect::new(0.0, 0.0, 200.0, 200.0),
2395            1.0,
2396            &mut t,
2397            RunnerCore::no_time_shaders,
2398        );
2399        assert!(
2400            !idle,
2401            "without a samples_time registration the host should idle",
2402        );
2403
2404        let mut t2 = PrepareTimings::default();
2405        let LayoutPrepared {
2406            needs_redraw: animated,
2407            ..
2408        } = core.prepare_layout(
2409            &mut tree,
2410            Rect::new(0.0, 0.0, 200.0, 200.0),
2411            1.0,
2412            &mut t2,
2413            |handle| matches!(handle, ShaderHandle::Custom("my_animated_glow")),
2414        );
2415        assert!(
2416            animated,
2417            "custom shader registered as samples_time=true must request continuous redraw",
2418        );
2419    }
2420
2421    #[test]
2422    fn redraw_within_aggregates_to_minimum_visible_deadline() {
2423        use std::time::Duration;
2424        let mut tree = crate::column([
2425            // 16ms
2426            crate::widgets::text::text("a")
2427                .redraw_within(Duration::from_millis(16))
2428                .width(crate::tree::Size::Fixed(20.0))
2429                .height(crate::tree::Size::Fixed(20.0)),
2430            // 50ms — the slower request should NOT win against 16ms.
2431            crate::widgets::text::text("b")
2432                .redraw_within(Duration::from_millis(50))
2433                .width(crate::tree::Size::Fixed(20.0))
2434                .height(crate::tree::Size::Fixed(20.0)),
2435        ]);
2436        let mut core = RunnerCore::new();
2437        let mut t = PrepareTimings::default();
2438        let LayoutPrepared {
2439            needs_redraw,
2440            next_layout_redraw_in,
2441            ..
2442        } = core.prepare_layout(
2443            &mut tree,
2444            Rect::new(0.0, 0.0, 200.0, 200.0),
2445            1.0,
2446            &mut t,
2447            RunnerCore::no_time_shaders,
2448        );
2449        assert!(needs_redraw, "redraw_within must lift the legacy bool");
2450        assert_eq!(
2451            next_layout_redraw_in,
2452            Some(Duration::from_millis(16)),
2453            "tightest visible deadline wins, on the layout lane",
2454        );
2455    }
2456
2457    #[test]
2458    fn redraw_within_off_screen_widget_is_ignored() {
2459        use std::time::Duration;
2460        // Layout-rect-based visibility: place the animated widget below
2461        // the viewport via a tall preceding spacer in a hugging
2462        // column. The child's computed rect is at y≈150, which lies
2463        // outside a 0..100 viewport, so the visibility filter must
2464        // skip it and the host must idle.
2465        let mut tree = crate::column([
2466            crate::tree::spacer().height(crate::tree::Size::Fixed(150.0)),
2467            crate::widgets::text::text("offscreen")
2468                .redraw_within(Duration::from_millis(16))
2469                .width(crate::tree::Size::Fixed(10.0))
2470                .height(crate::tree::Size::Fixed(10.0)),
2471        ]);
2472        let mut core = RunnerCore::new();
2473        let mut t = PrepareTimings::default();
2474        let LayoutPrepared {
2475            next_layout_redraw_in,
2476            ..
2477        } = core.prepare_layout(
2478            &mut tree,
2479            Rect::new(0.0, 0.0, 100.0, 100.0),
2480            1.0,
2481            &mut t,
2482            RunnerCore::no_time_shaders,
2483        );
2484        assert_eq!(
2485            next_layout_redraw_in, None,
2486            "off-screen redraw_within must not contribute to the aggregate",
2487        );
2488    }
2489
2490    #[test]
2491    fn redraw_within_clipped_out_widget_is_ignored() {
2492        use std::time::Duration;
2493
2494        let clipped = crate::column([crate::widgets::text::text("clipped")
2495            .redraw_within(Duration::from_millis(16))
2496            .width(crate::tree::Size::Fixed(10.0))
2497            .height(crate::tree::Size::Fixed(10.0))])
2498        .clip()
2499        .width(crate::tree::Size::Fixed(100.0))
2500        .height(crate::tree::Size::Fixed(20.0))
2501        .layout(|ctx| {
2502            vec![Rect::new(
2503                ctx.container.x,
2504                ctx.container.y + 30.0,
2505                10.0,
2506                10.0,
2507            )]
2508        });
2509        let mut tree = crate::column([clipped]);
2510
2511        let mut core = RunnerCore::new();
2512        let mut t = PrepareTimings::default();
2513        let LayoutPrepared {
2514            next_layout_redraw_in,
2515            ..
2516        } = core.prepare_layout(
2517            &mut tree,
2518            Rect::new(0.0, 0.0, 100.0, 100.0),
2519            1.0,
2520            &mut t,
2521            RunnerCore::no_time_shaders,
2522        );
2523        assert_eq!(
2524            next_layout_redraw_in, None,
2525            "redraw_within inside an inherited clip but outside the clip rect must not contribute",
2526        );
2527    }
2528
2529    #[test]
2530    fn pointer_moved_within_same_hovered_node_does_not_request_redraw() {
2531        // Wayland delivers CursorMoved at very high frequency while
2532        // the cursor sits over the surface. Hosts gate request_redraw
2533        // on `needs_redraw`; this test pins the contract so we don't
2534        // regress to the unconditional-redraw behaviour that pegged
2535        // settings_modal at 100% CPU under cursor activity.
2536        let mut core = lay_out_input_tree(false);
2537        let btn = core.rect_of_key("btn").expect("btn rect");
2538        let (cx, cy) = (btn.x + btn.w * 0.5, btn.y + btn.h * 0.5);
2539
2540        // First move enters the button — hover identity changes, so a
2541        // PointerEnter fires (no preceding Leave because no prior
2542        // hover target).
2543        let first = core.pointer_moved(cx, cy);
2544        assert_eq!(first.events.len(), 1);
2545        assert_eq!(first.events[0].kind, UiEventKind::PointerEnter);
2546        assert_eq!(first.events[0].key.as_deref(), Some("btn"));
2547        assert!(
2548            first.needs_redraw,
2549            "entering a focusable should warrant a redraw",
2550        );
2551
2552        // Same node, slightly different coords. Hover identity is
2553        // unchanged, no drag is active — must not redraw or emit any
2554        // events.
2555        let second = core.pointer_moved(cx + 1.0, cy);
2556        assert!(second.events.is_empty());
2557        assert!(
2558            !second.needs_redraw,
2559            "identical hover, no drag → host should idle",
2560        );
2561
2562        // Moving off the button into empty space changes hover to
2563        // None — that's a visible transition (envelope ramps down)
2564        // and a PointerLeave fires.
2565        let off = core.pointer_moved(0.0, 0.0);
2566        assert_eq!(off.events.len(), 1);
2567        assert_eq!(off.events[0].kind, UiEventKind::PointerLeave);
2568        assert_eq!(off.events[0].key.as_deref(), Some("btn"));
2569        assert!(
2570            off.needs_redraw,
2571            "leaving a hovered node still warrants a redraw",
2572        );
2573    }
2574
2575    #[test]
2576    fn pointer_moved_between_keyed_targets_emits_leave_then_enter() {
2577        // Cursor crossing from one keyed node to another emits a paired
2578        // PointerLeave (old target) followed by PointerEnter (new
2579        // target). Apps can observe the cleared state before the new
2580        // one — important for things like cancelling a hover-intent
2581        // prefetch on the old target before kicking off one for the
2582        // new.
2583        let mut core = lay_out_input_tree(false);
2584        let btn = core.rect_of_key("btn").expect("btn rect");
2585        let ti = core.rect_of_key("ti").expect("ti rect");
2586
2587        // Enter btn first.
2588        let _ = core.pointer_moved(btn.x + 4.0, btn.y + 4.0);
2589
2590        // Cross to ti.
2591        let cross = core.pointer_moved(ti.x + 4.0, ti.y + 4.0);
2592        let kinds: Vec<UiEventKind> = cross.events.iter().map(|e| e.kind).collect();
2593        assert_eq!(
2594            kinds,
2595            vec![UiEventKind::PointerLeave, UiEventKind::PointerEnter],
2596            "paired Leave-then-Enter on cross-target hover transition",
2597        );
2598        assert_eq!(cross.events[0].key.as_deref(), Some("btn"));
2599        assert_eq!(cross.events[1].key.as_deref(), Some("ti"));
2600        assert!(cross.needs_redraw);
2601    }
2602
2603    #[test]
2604    fn pointer_left_emits_leave_for_prior_hover() {
2605        let mut core = lay_out_input_tree(false);
2606        let btn = core.rect_of_key("btn").expect("btn rect");
2607        let _ = core.pointer_moved(btn.x + 4.0, btn.y + 4.0);
2608
2609        let events = core.pointer_left();
2610        assert_eq!(events.len(), 1);
2611        assert_eq!(events[0].kind, UiEventKind::PointerLeave);
2612        assert_eq!(events[0].key.as_deref(), Some("btn"));
2613    }
2614
2615    #[test]
2616    fn pointer_left_with_no_prior_hover_emits_nothing() {
2617        let mut core = lay_out_input_tree(false);
2618        // No prior pointer_moved into a keyed target — pointer_left
2619        // should be a no-op event-wise (state still gets cleared).
2620        let events = core.pointer_left();
2621        assert!(events.is_empty());
2622    }
2623
2624    #[test]
2625    fn ui_state_hovered_key_returns_leaf_key() {
2626        let mut core = lay_out_input_tree(false);
2627        assert_eq!(core.ui_state().hovered_key(), None);
2628
2629        let btn = core.rect_of_key("btn").expect("btn rect");
2630        core.pointer_moved(btn.x + 4.0, btn.y + 4.0);
2631        assert_eq!(core.ui_state().hovered_key(), Some("btn"));
2632
2633        // Off-target → None again.
2634        core.pointer_moved(0.0, 0.0);
2635        assert_eq!(core.ui_state().hovered_key(), None);
2636    }
2637
2638    #[test]
2639    fn ui_state_is_hovering_within_walks_subtree() {
2640        // Card (keyed, focusable) wraps an inner icon-button (keyed).
2641        // is_hovering_within("card") should be true whenever the
2642        // cursor is on the card body OR on the inner button.
2643        use crate::tree::*;
2644        let mut tree = crate::column([crate::stack([
2645            crate::widgets::button::button("Inner").key("inner_btn")
2646        ])
2647        .key("card")
2648        .focusable()
2649        .width(Size::Fixed(120.0))
2650        .height(Size::Fixed(60.0))])
2651        .padding(20.0);
2652        let mut core = RunnerCore::new();
2653        crate::layout::layout(
2654            &mut tree,
2655            &mut core.ui_state,
2656            Rect::new(0.0, 0.0, 400.0, 200.0),
2657        );
2658        core.ui_state.sync_focus_order(&tree);
2659        let mut t = PrepareTimings::default();
2660        core.snapshot(&tree, &mut t);
2661
2662        // Pre-hover: false everywhere.
2663        assert!(!core.ui_state().is_hovering_within("card"));
2664        assert!(!core.ui_state().is_hovering_within("inner_btn"));
2665
2666        // Hover the inner button. Both the leaf and its ancestor card
2667        // should report subtree-hover true.
2668        let inner = core.rect_of_key("inner_btn").expect("inner rect");
2669        core.pointer_moved(inner.x + 4.0, inner.y + 4.0);
2670        assert!(core.ui_state().is_hovering_within("card"));
2671        assert!(core.ui_state().is_hovering_within("inner_btn"));
2672
2673        // Unrelated / unknown keys read as false.
2674        assert!(!core.ui_state().is_hovering_within("not_a_key"));
2675
2676        // Off the tree — both flip back to false.
2677        core.pointer_moved(0.0, 0.0);
2678        assert!(!core.ui_state().is_hovering_within("card"));
2679        assert!(!core.ui_state().is_hovering_within("inner_btn"));
2680    }
2681
2682    #[test]
2683    fn hover_driven_scale_via_is_hovering_within_plus_animate() {
2684        // gh#10. The recipe that replaces a declarative
2685        // hover_translate / hover_scale / hover_tint API: the build
2686        // closure reads `cx.is_hovering_within(key)` and writes the
2687        // target prop value; `.animate(...)` eases between build
2688        // values across frames. End-to-end check that hover transition
2689        // → eased scale settle.
2690        use crate::Theme;
2691        use crate::anim::Timing;
2692        use crate::tree::*;
2693
2694        // Helper that mirrors the documented recipe — closure over a
2695        // hover boolean so the test can drive the rebuild deterministically.
2696        let build_card = |hovering: bool| -> El {
2697            let scale = if hovering { 1.05 } else { 1.0 };
2698            crate::column([crate::stack(
2699                [crate::widgets::button::button("Inner").key("inner_btn")],
2700            )
2701            .key("card")
2702            .focusable()
2703            .scale(scale)
2704            .animate(Timing::SPRING_QUICK)
2705            .width(Size::Fixed(120.0))
2706            .height(Size::Fixed(60.0))])
2707            .padding(20.0)
2708        };
2709
2710        let mut core = RunnerCore::new();
2711        // Settled mode so the animate tick snaps each retarget to its
2712        // value — lets us verify final-state values without timing.
2713        core.ui_state
2714            .set_animation_mode(crate::state::AnimationMode::Settled);
2715
2716        // Frame 1: not hovering → app builds with scale=1.0.
2717        let theme = Theme::default();
2718        let cx_pre = crate::BuildCx::new(&theme).with_ui_state(core.ui_state());
2719        assert!(!cx_pre.is_hovering_within("card"));
2720        let mut tree = build_card(cx_pre.is_hovering_within("card"));
2721        crate::layout::layout(
2722            &mut tree,
2723            &mut core.ui_state,
2724            Rect::new(0.0, 0.0, 400.0, 200.0),
2725        );
2726        core.ui_state.sync_focus_order(&tree);
2727        let mut t = PrepareTimings::default();
2728        core.snapshot(&tree, &mut t);
2729        core.ui_state
2730            .tick_visual_animations(&mut tree, web_time::Instant::now());
2731        let card_at_rest = tree.children[0].clone();
2732        assert!((card_at_rest.scale - 1.0).abs() < 1e-3);
2733
2734        // Hover the card. is_hovering_within flips true.
2735        let card_rect = core.rect_of_key("card").expect("card rect");
2736        core.pointer_moved(card_rect.x + 4.0, card_rect.y + 4.0);
2737
2738        // Frame 2: app sees hovering=true, rebuilds with scale=1.05.
2739        // Settled animate tick snaps scale to the new target.
2740        let cx_hot = crate::BuildCx::new(&theme).with_ui_state(core.ui_state());
2741        assert!(cx_hot.is_hovering_within("card"));
2742        let mut tree = build_card(cx_hot.is_hovering_within("card"));
2743        crate::layout::layout(
2744            &mut tree,
2745            &mut core.ui_state,
2746            Rect::new(0.0, 0.0, 400.0, 200.0),
2747        );
2748        core.ui_state.sync_focus_order(&tree);
2749        core.snapshot(&tree, &mut t);
2750        core.ui_state
2751            .tick_visual_animations(&mut tree, web_time::Instant::now());
2752        let card_hot = tree.children[0].clone();
2753        assert!(
2754            (card_hot.scale - 1.05).abs() < 1e-3,
2755            "hover should drive card scale to 1.05 via animate; got {}",
2756            card_hot.scale,
2757        );
2758
2759        // Unhover → app rebuilds with scale=1.0; settled tick snaps back.
2760        core.pointer_moved(0.0, 0.0);
2761        let cx_cold = crate::BuildCx::new(&theme).with_ui_state(core.ui_state());
2762        assert!(!cx_cold.is_hovering_within("card"));
2763        let mut tree = build_card(cx_cold.is_hovering_within("card"));
2764        crate::layout::layout(
2765            &mut tree,
2766            &mut core.ui_state,
2767            Rect::new(0.0, 0.0, 400.0, 200.0),
2768        );
2769        core.ui_state.sync_focus_order(&tree);
2770        core.snapshot(&tree, &mut t);
2771        core.ui_state
2772            .tick_visual_animations(&mut tree, web_time::Instant::now());
2773        let card_after = tree.children[0].clone();
2774        assert!((card_after.scale - 1.0).abs() < 1e-3);
2775    }
2776
2777    #[test]
2778    fn file_dropped_routes_to_keyed_leaf_at_pointer() {
2779        let mut core = lay_out_input_tree(false);
2780        let btn = core.rect_of_key("btn").expect("btn rect");
2781        let path = std::path::PathBuf::from("/tmp/screenshot.png");
2782        let events = core.file_dropped(path.clone(), btn.x + 4.0, btn.y + 4.0);
2783        assert_eq!(events.len(), 1);
2784        let event = &events[0];
2785        assert_eq!(event.kind, UiEventKind::FileDropped);
2786        assert_eq!(event.key.as_deref(), Some("btn"));
2787        assert_eq!(event.path.as_deref(), Some(path.as_path()));
2788        assert_eq!(event.pointer, Some((btn.x + 4.0, btn.y + 4.0)));
2789    }
2790
2791    #[test]
2792    fn file_dropped_outside_keyed_surface_emits_window_level_event() {
2793        let mut core = lay_out_input_tree(false);
2794        // Drop in the padding band — outside any keyed leaf.
2795        let path = std::path::PathBuf::from("/tmp/screenshot.png");
2796        let events = core.file_dropped(path.clone(), 1.0, 1.0);
2797        assert_eq!(events.len(), 1);
2798        let event = &events[0];
2799        assert_eq!(event.kind, UiEventKind::FileDropped);
2800        assert!(
2801            event.target.is_none(),
2802            "drop outside any keyed surface routes window-level",
2803        );
2804        assert!(event.key.is_none());
2805        // Path still flows through so a global drop sink can pick it up.
2806        assert_eq!(event.path.as_deref(), Some(path.as_path()));
2807    }
2808
2809    #[test]
2810    fn file_hovered_then_cancelled_pair() {
2811        let mut core = lay_out_input_tree(false);
2812        let btn = core.rect_of_key("btn").expect("btn rect");
2813        let path = std::path::PathBuf::from("/tmp/a.png");
2814
2815        let hover = core.file_hovered(path.clone(), btn.x + 4.0, btn.y + 4.0);
2816        assert_eq!(hover.len(), 1);
2817        assert_eq!(hover[0].kind, UiEventKind::FileHovered);
2818        assert_eq!(hover[0].key.as_deref(), Some("btn"));
2819        assert_eq!(hover[0].path.as_deref(), Some(path.as_path()));
2820
2821        let cancel = core.file_hover_cancelled();
2822        assert_eq!(cancel.len(), 1);
2823        assert_eq!(cancel[0].kind, UiEventKind::FileHoverCancelled);
2824        assert!(cancel[0].target.is_none());
2825        assert!(cancel[0].path.is_none());
2826    }
2827
2828    #[test]
2829    fn build_cx_hover_accessors_default_off_without_state() {
2830        use crate::Theme;
2831        let theme = Theme::default();
2832        let cx = crate::BuildCx::new(&theme);
2833        assert_eq!(cx.hovered_key(), None);
2834        assert!(!cx.is_hovering_within("anything"));
2835    }
2836
2837    #[test]
2838    fn build_cx_hover_accessors_delegate_when_state_attached() {
2839        use crate::Theme;
2840        let mut core = lay_out_input_tree(false);
2841        let btn = core.rect_of_key("btn").expect("btn rect");
2842        core.pointer_moved(btn.x + 4.0, btn.y + 4.0);
2843
2844        let theme = Theme::default();
2845        let cx = crate::BuildCx::new(&theme).with_ui_state(core.ui_state());
2846        assert_eq!(cx.hovered_key(), Some("btn"));
2847        assert!(cx.is_hovering_within("btn"));
2848        assert!(!cx.is_hovering_within("ti"));
2849    }
2850
2851    fn lay_out_paragraph_tree() -> RunnerCore {
2852        use crate::tree::*;
2853        let mut tree = crate::column([
2854            crate::widgets::text::text("First paragraph of text.")
2855                .key("p1")
2856                .selectable(),
2857            crate::widgets::text::text("Second paragraph of text.")
2858                .key("p2")
2859                .selectable(),
2860        ])
2861        .padding(20.0);
2862        let mut core = RunnerCore::new();
2863        crate::layout::layout(
2864            &mut tree,
2865            &mut core.ui_state,
2866            Rect::new(0.0, 0.0, 400.0, 300.0),
2867        );
2868        core.ui_state.sync_focus_order(&tree);
2869        core.ui_state.sync_selection_order(&tree);
2870        let mut t = PrepareTimings::default();
2871        core.snapshot(&tree, &mut t);
2872        core
2873    }
2874
2875    #[test]
2876    fn pointer_down_on_selectable_text_emits_selection_changed() {
2877        let mut core = lay_out_paragraph_tree();
2878        let p1 = core.rect_of_key("p1").expect("p1 rect");
2879        let cx = p1.x + 4.0;
2880        let cy = p1.y + p1.h * 0.5;
2881        let events = core.pointer_down(cx, cy, PointerButton::Primary);
2882        let sel_event = events
2883            .iter()
2884            .find(|e| e.kind == UiEventKind::SelectionChanged)
2885            .expect("SelectionChanged emitted");
2886        let new_sel = sel_event
2887            .selection
2888            .as_ref()
2889            .expect("SelectionChanged carries a selection");
2890        let range = new_sel.range.as_ref().expect("collapsed selection at hit");
2891        assert_eq!(range.anchor.key, "p1");
2892        assert_eq!(range.head.key, "p1");
2893        assert_eq!(range.anchor.byte, range.head.byte);
2894        assert!(core.ui_state.selection.drag.is_some());
2895    }
2896
2897    #[test]
2898    fn pointer_drag_on_selectable_text_extends_head() {
2899        let mut core = lay_out_paragraph_tree();
2900        let p1 = core.rect_of_key("p1").expect("p1 rect");
2901        let cx = p1.x + 4.0;
2902        let cy = p1.y + p1.h * 0.5;
2903        core.pointer_moved(cx, cy);
2904        core.pointer_down(cx, cy, PointerButton::Primary);
2905
2906        // Drag to the right inside p1.
2907        let events = core.pointer_moved(p1.x + p1.w - 10.0, cy).events;
2908        let sel_event = events
2909            .iter()
2910            .find(|e| e.kind == UiEventKind::SelectionChanged)
2911            .expect("Drag emits SelectionChanged");
2912        let new_sel = sel_event.selection.as_ref().unwrap();
2913        let range = new_sel.range.as_ref().unwrap();
2914        assert_eq!(range.anchor.key, "p1");
2915        assert_eq!(range.head.key, "p1");
2916        assert!(
2917            range.head.byte > range.anchor.byte,
2918            "head should advance past anchor (anchor={}, head={})",
2919            range.anchor.byte,
2920            range.head.byte
2921        );
2922    }
2923
2924    #[test]
2925    fn pointer_up_clears_drag_but_keeps_selection() {
2926        let mut core = lay_out_paragraph_tree();
2927        let p1 = core.rect_of_key("p1").expect("p1 rect");
2928        let cx = p1.x + 4.0;
2929        let cy = p1.y + p1.h * 0.5;
2930        core.pointer_down(cx, cy, PointerButton::Primary);
2931        core.pointer_moved(p1.x + p1.w - 10.0, cy);
2932        let _ = core.pointer_up(p1.x + p1.w - 10.0, cy, PointerButton::Primary);
2933        assert!(
2934            core.ui_state.selection.drag.is_none(),
2935            "drag flag should clear on pointer_up"
2936        );
2937        assert!(
2938            !core.ui_state.current_selection.is_empty(),
2939            "selection itself should persist after pointer_up"
2940        );
2941    }
2942
2943    #[test]
2944    fn drag_past_a_leaf_bottom_keeps_head_in_that_leaf_not_anchor() {
2945        // Regression: a previous helper (`byte_in_anchor_leaf`)
2946        // projected any out-of-leaf pointer back onto the anchor leaf.
2947        // That meant moving the cursor below p2's bottom edge while
2948        // dragging from p1 caused the head to snap home to p1 — the
2949        // selection band visibly shrank back instead of extending.
2950        let mut core = lay_out_paragraph_tree();
2951        let p1 = core.rect_of_key("p1").expect("p1 rect");
2952        let p2 = core.rect_of_key("p2").expect("p2 rect");
2953        // Anchor in p1.
2954        core.pointer_down(p1.x + 4.0, p1.y + p1.h * 0.5, PointerButton::Primary);
2955        // Drag into p2 first — head migrates.
2956        core.pointer_moved(p2.x + 8.0, p2.y + p2.h * 0.5);
2957        // Now move WELL BELOW p2's rect (well below all selectables).
2958        // Head should remain in p2 (last leaf in this fixture is p2).
2959        let events = core.pointer_moved(p2.x + 8.0, p2.y + p2.h + 200.0).events;
2960        let sel = events
2961            .iter()
2962            .find(|e| e.kind == UiEventKind::SelectionChanged)
2963            .map(|e| e.selection.as_ref().unwrap().clone())
2964            // No SelectionChanged emitted means the value didn't move
2965            // — read it back from the live UiState directly.
2966            .unwrap_or_else(|| core.ui_state.current_selection.clone());
2967        let r = sel.range.as_ref().expect("selection still active");
2968        assert_eq!(r.anchor.key, "p1", "anchor unchanged");
2969        assert_eq!(
2970            r.head.key, "p2",
2971            "head must stay in p2 even when pointer is below p2's rect"
2972        );
2973    }
2974
2975    #[test]
2976    fn drag_into_a_sibling_selectable_extends_head_into_that_leaf() {
2977        let mut core = lay_out_paragraph_tree();
2978        let p1 = core.rect_of_key("p1").expect("p1 rect");
2979        let p2 = core.rect_of_key("p2").expect("p2 rect");
2980        // Anchor at the start of p1.
2981        core.pointer_down(p1.x + 4.0, p1.y + p1.h * 0.5, PointerButton::Primary);
2982        // Drag down into p2.
2983        let events = core.pointer_moved(p2.x + 8.0, p2.y + p2.h * 0.5).events;
2984        let sel_event = events
2985            .iter()
2986            .find(|e| e.kind == UiEventKind::SelectionChanged)
2987            .expect("Drag emits SelectionChanged");
2988        let new_sel = sel_event.selection.as_ref().unwrap();
2989        let range = new_sel.range.as_ref().unwrap();
2990        assert_eq!(range.anchor.key, "p1", "anchor stays in p1");
2991        assert_eq!(range.head.key, "p2", "head migrates into p2");
2992    }
2993
2994    #[test]
2995    fn pointer_down_on_focusable_owning_selection_does_not_clear_it() {
2996        // Regression: clicking inside a text_input (focusable but not
2997        // a `.selectable()` leaf) used to fire SelectionChanged-empty
2998        // because selection_point_at missed and the runtime's
2999        // clear-fallback didn't notice the click landed on the same
3000        // widget that owned the active selection. The input's
3001        // PointerDown set the caret, then the empty SelectionChanged
3002        // collapsed it back to byte 0 every other click.
3003        let mut core = lay_out_input_tree(true);
3004        // Seed a selection in the input's key — this is what the
3005        // text_input would have written back via apply_event_with.
3006        core.set_selection(crate::selection::Selection::caret("ti", 3));
3007        let ti = core.rect_of_key("ti").expect("ti rect");
3008        let cx = ti.x + ti.w * 0.5;
3009        let cy = ti.y + ti.h * 0.5;
3010
3011        let events = core.pointer_down(cx, cy, PointerButton::Primary);
3012        let cleared = events.iter().find(|e| {
3013            e.kind == UiEventKind::SelectionChanged
3014                && e.selection.as_ref().map(|s| s.is_empty()).unwrap_or(false)
3015        });
3016        assert!(
3017            cleared.is_none(),
3018            "click on the selection-owning input must not emit a clearing SelectionChanged"
3019        );
3020        assert_eq!(
3021            core.ui_state.current_selection,
3022            crate::selection::Selection::caret("ti", 3),
3023            "runtime mirror is preserved when the click owns the selection"
3024        );
3025    }
3026
3027    #[test]
3028    fn pointer_down_into_a_different_capture_keys_widget_does_not_clear_first() {
3029        // Regression: clicking into text_input A while the selection
3030        // lives in text_input B used to trigger the runtime's
3031        // clear-fallback. The empty SelectionChanged arrived after
3032        // A's PointerDown (which had set anchor = head = click pos),
3033        // collapsing the app's selection to default. The next Drag
3034        // event then read `selection.within(A) = None`, defaulted
3035        // anchor to 0, and only advanced head — so dragging into A
3036        // started the selection from byte 0 of the text instead of
3037        // the click position.
3038        let mut core = lay_out_input_tree(true);
3039        // Active selection lives in some other key, not "ti".
3040        core.set_selection(crate::selection::Selection::caret("other", 4));
3041        let ti = core.rect_of_key("ti").expect("ti rect");
3042        let cx = ti.x + ti.w * 0.5;
3043        let cy = ti.y + ti.h * 0.5;
3044
3045        let events = core.pointer_down(cx, cy, PointerButton::Primary);
3046        let cleared = events.iter().any(|e| {
3047            e.kind == UiEventKind::SelectionChanged
3048                && e.selection.as_ref().map(|s| s.is_empty()).unwrap_or(false)
3049        });
3050        assert!(
3051            !cleared,
3052            "click on a different capture_keys widget must not race-clear the selection"
3053        );
3054    }
3055
3056    #[test]
3057    fn pointer_down_on_non_selectable_clears_existing_selection() {
3058        let mut core = lay_out_paragraph_tree();
3059        let p1 = core.rect_of_key("p1").expect("p1 rect");
3060        let cy = p1.y + p1.h * 0.5;
3061        // Establish a selection in p1.
3062        core.pointer_down(p1.x + 4.0, cy, PointerButton::Primary);
3063        core.pointer_up(p1.x + 4.0, cy, PointerButton::Primary);
3064        assert!(!core.ui_state.current_selection.is_empty());
3065
3066        // Press in empty space (no selectable, no focusable).
3067        let events = core.pointer_down(2.0, 2.0, PointerButton::Primary);
3068        let cleared = events
3069            .iter()
3070            .find(|e| e.kind == UiEventKind::SelectionChanged)
3071            .expect("clearing emits SelectionChanged");
3072        let new_sel = cleared.selection.as_ref().unwrap();
3073        assert!(new_sel.is_empty(), "new selection should be empty");
3074        assert!(core.ui_state.current_selection.is_empty());
3075    }
3076
3077    #[test]
3078    fn key_down_bumps_caret_activity_when_focused_widget_captures_keys() {
3079        // Showcase-style scenario: the app doesn't propagate its
3080        // Selection back via App::selection(), so set_selection always
3081        // sees the default-empty value and never bumps. The runtime
3082        // bump path catches arrow-key navigation directly.
3083        let mut core = lay_out_input_tree(true);
3084        let target = core
3085            .ui_state
3086            .focus
3087            .order
3088            .iter()
3089            .find(|t| t.key == "ti")
3090            .cloned();
3091        core.ui_state.set_focus(target); // focus moves → first bump
3092        let after_focus = core.ui_state.caret.activity_at.expect("focus bump");
3093
3094        std::thread::sleep(std::time::Duration::from_millis(2));
3095        let _ = core.key_down(UiKey::ArrowRight, KeyModifiers::default(), false);
3096        let after_arrow = core
3097            .ui_state
3098            .caret
3099            .activity_at
3100            .expect("arrow key bumps even without app-side selection");
3101        assert!(
3102            after_arrow > after_focus,
3103            "ArrowRight to a capture_keys focused widget bumps caret activity"
3104        );
3105    }
3106
3107    #[test]
3108    fn text_input_bumps_caret_activity_when_focused() {
3109        let mut core = lay_out_input_tree(true);
3110        let target = core
3111            .ui_state
3112            .focus
3113            .order
3114            .iter()
3115            .find(|t| t.key == "ti")
3116            .cloned();
3117        core.ui_state.set_focus(target);
3118        let after_focus = core.ui_state.caret.activity_at.unwrap();
3119
3120        std::thread::sleep(std::time::Duration::from_millis(2));
3121        let _ = core.text_input("a".into());
3122        let after_text = core.ui_state.caret.activity_at.unwrap();
3123        assert!(
3124            after_text > after_focus,
3125            "TextInput to focused widget bumps caret activity"
3126        );
3127    }
3128
3129    #[test]
3130    fn pointer_down_inside_focused_input_bumps_caret_activity() {
3131        // Clicking again inside an already-focused capture_keys widget
3132        // doesn't change the focus target, so set_focus is a no-op
3133        // for activity. The runtime catches this so click-to-move-
3134        // caret resets the blink.
3135        let mut core = lay_out_input_tree(true);
3136        let ti = core.rect_of_key("ti").expect("ti rect");
3137        let cx = ti.x + ti.w * 0.5;
3138        let cy = ti.y + ti.h * 0.5;
3139
3140        // First click → focus moves → bump.
3141        core.pointer_down(cx, cy, PointerButton::Primary);
3142        let _ = core.pointer_up(cx, cy, PointerButton::Primary);
3143        let after_first = core.ui_state.caret.activity_at.unwrap();
3144
3145        // Second click on the same input → focus doesn't move, but
3146        // it's still caret-relevant activity.
3147        std::thread::sleep(std::time::Duration::from_millis(2));
3148        core.pointer_down(cx + 1.0, cy, PointerButton::Primary);
3149        let after_second = core
3150            .ui_state
3151            .caret
3152            .activity_at
3153            .expect("second click bumps too");
3154        assert!(
3155            after_second > after_first,
3156            "click within already-focused capture_keys widget still bumps"
3157        );
3158    }
3159
3160    #[test]
3161    fn arrow_key_through_apply_event_mutates_selection_and_bumps_on_set() {
3162        // End-to-end check that the path used by the text_input
3163        // example does in fact differ-then-bump on each arrow-key
3164        // press. If this regresses, the caret won't reset its blink
3165        // when the user moves the cursor — exactly what the polish
3166        // pass is meant to fix.
3167        use crate::widgets::text_input;
3168        let mut sel = crate::selection::Selection::caret("ti", 2);
3169        let mut value = String::from("hello");
3170
3171        let mut core = RunnerCore::new();
3172        // Seed the runtime mirror so the first set_selection below
3173        // doesn't bump from "default → caret(2)".
3174        core.set_selection(sel.clone());
3175        let baseline = core.ui_state.caret.activity_at;
3176
3177        // Build a synthetic ArrowRight KeyDown for the input's key.
3178        let arrow_right = UiEvent {
3179            key: Some("ti".into()),
3180            target: None,
3181            pointer: None,
3182            key_press: Some(crate::event::KeyPress {
3183                key: UiKey::ArrowRight,
3184                modifiers: KeyModifiers::default(),
3185                repeat: false,
3186            }),
3187            text: None,
3188            selection: None,
3189            modifiers: KeyModifiers::default(),
3190            click_count: 0,
3191            path: None,
3192            kind: UiEventKind::KeyDown,
3193        };
3194
3195        // 1. App's on_event would call into this path:
3196        let mutated = text_input::apply_event(&mut value, &mut sel, "ti", &arrow_right);
3197        assert!(mutated, "ArrowRight should mutate selection");
3198        assert_eq!(
3199            sel.within("ti").unwrap().head,
3200            3,
3201            "head moved one char right (h-e-l-l-o, byte 2 → 3)"
3202        );
3203
3204        // 2. Next frame's set_selection sees the new value → bump.
3205        std::thread::sleep(std::time::Duration::from_millis(2));
3206        core.set_selection(sel);
3207        let after = core.ui_state.caret.activity_at.unwrap();
3208        // If a baseline existed, the new bump must be later. Either
3209        // way the activity is now Some, which the .unwrap() above
3210        // already enforced.
3211        if let Some(b) = baseline {
3212            assert!(after > b, "arrow-key flow should bump activity");
3213        }
3214    }
3215
3216    #[test]
3217    fn set_selection_bumps_caret_activity_only_when_value_changes() {
3218        let mut core = lay_out_paragraph_tree();
3219        // First call with the default selection — no bump (mirror is
3220        // already default-empty).
3221        core.set_selection(crate::selection::Selection::default());
3222        assert!(
3223            core.ui_state.caret.activity_at.is_none(),
3224            "no-op set_selection should not bump activity"
3225        );
3226
3227        // Move the selection to a real range — bump.
3228        let sel_a = crate::selection::Selection::caret("p1", 3);
3229        core.set_selection(sel_a.clone());
3230        let bumped_at = core
3231            .ui_state
3232            .caret
3233            .activity_at
3234            .expect("first real selection bumps");
3235
3236        // Same selection again — must NOT bump (else every frame
3237        // re-bumps and the caret never blinks).
3238        core.set_selection(sel_a.clone());
3239        assert_eq!(
3240            core.ui_state.caret.activity_at,
3241            Some(bumped_at),
3242            "set_selection with same value is a no-op"
3243        );
3244
3245        // Caret at a different byte (simulating arrow-key motion) →
3246        // bump again.
3247        std::thread::sleep(std::time::Duration::from_millis(2));
3248        let sel_b = crate::selection::Selection::caret("p1", 7);
3249        core.set_selection(sel_b);
3250        let new_bump = core.ui_state.caret.activity_at.expect("second bump");
3251        assert!(
3252            new_bump > bumped_at,
3253            "moving the caret bumps activity again",
3254        );
3255    }
3256
3257    #[test]
3258    fn escape_clears_active_selection_and_emits_selection_changed() {
3259        let mut core = lay_out_paragraph_tree();
3260        let p1 = core.rect_of_key("p1").expect("p1 rect");
3261        let cy = p1.y + p1.h * 0.5;
3262        // Drag-select inside p1 to establish a non-empty selection.
3263        core.pointer_down(p1.x + 4.0, cy, PointerButton::Primary);
3264        core.pointer_moved(p1.x + p1.w - 10.0, cy);
3265        core.pointer_up(p1.x + p1.w - 10.0, cy, PointerButton::Primary);
3266        assert!(!core.ui_state.current_selection.is_empty());
3267
3268        let events = core.key_down(UiKey::Escape, KeyModifiers::default(), false);
3269        let kinds: Vec<UiEventKind> = events.iter().map(|e| e.kind).collect();
3270        assert_eq!(
3271            kinds,
3272            vec![UiEventKind::Escape, UiEventKind::SelectionChanged],
3273            "Esc emits Escape (for popover dismiss) AND SelectionChanged"
3274        );
3275        let cleared = events
3276            .iter()
3277            .find(|e| e.kind == UiEventKind::SelectionChanged)
3278            .unwrap();
3279        assert!(cleared.selection.as_ref().unwrap().is_empty());
3280        assert!(core.ui_state.current_selection.is_empty());
3281    }
3282
3283    #[test]
3284    fn consecutive_clicks_on_same_target_extend_count() {
3285        let mut core = lay_out_input_tree(false);
3286        let btn = core.rect_of_key("btn").expect("btn rect");
3287        let cx = btn.x + btn.w * 0.5;
3288        let cy = btn.y + btn.h * 0.5;
3289
3290        // First press: count = 1.
3291        let down1 = core.pointer_down(cx, cy, PointerButton::Primary);
3292        let pd1 = down1
3293            .iter()
3294            .find(|e| e.kind == UiEventKind::PointerDown)
3295            .expect("PointerDown emitted");
3296        assert_eq!(pd1.click_count, 1, "first press starts the sequence");
3297        let up1 = core.pointer_up(cx, cy, PointerButton::Primary);
3298        let click1 = up1
3299            .iter()
3300            .find(|e| e.kind == UiEventKind::Click)
3301            .expect("Click emitted");
3302        assert_eq!(
3303            click1.click_count, 1,
3304            "Click carries the same count as its PointerDown"
3305        );
3306
3307        // Second press immediately after, same target: count = 2.
3308        let down2 = core.pointer_down(cx, cy, PointerButton::Primary);
3309        let pd2 = down2
3310            .iter()
3311            .find(|e| e.kind == UiEventKind::PointerDown)
3312            .unwrap();
3313        assert_eq!(pd2.click_count, 2, "second press extends the sequence");
3314        let up2 = core.pointer_up(cx, cy, PointerButton::Primary);
3315        assert_eq!(
3316            up2.iter()
3317                .find(|e| e.kind == UiEventKind::Click)
3318                .unwrap()
3319                .click_count,
3320            2
3321        );
3322
3323        // Third: count = 3.
3324        let down3 = core.pointer_down(cx, cy, PointerButton::Primary);
3325        let pd3 = down3
3326            .iter()
3327            .find(|e| e.kind == UiEventKind::PointerDown)
3328            .unwrap();
3329        assert_eq!(pd3.click_count, 3, "third press → triple-click");
3330        core.pointer_up(cx, cy, PointerButton::Primary);
3331    }
3332
3333    #[test]
3334    fn click_count_resets_when_target_changes() {
3335        let mut core = lay_out_input_tree(false);
3336        let btn = core.rect_of_key("btn").expect("btn rect");
3337        let ti = core.rect_of_key("ti").expect("ti rect");
3338
3339        // Press on btn → count=1.
3340        let down1 = core.pointer_down(
3341            btn.x + btn.w * 0.5,
3342            btn.y + btn.h * 0.5,
3343            PointerButton::Primary,
3344        );
3345        assert_eq!(
3346            down1
3347                .iter()
3348                .find(|e| e.kind == UiEventKind::PointerDown)
3349                .unwrap()
3350                .click_count,
3351            1
3352        );
3353        let _ = core.pointer_up(
3354            btn.x + btn.w * 0.5,
3355            btn.y + btn.h * 0.5,
3356            PointerButton::Primary,
3357        );
3358
3359        // Press on ti (different target) → count resets to 1.
3360        let down2 = core.pointer_down(ti.x + ti.w * 0.5, ti.y + ti.h * 0.5, PointerButton::Primary);
3361        let pd2 = down2
3362            .iter()
3363            .find(|e| e.kind == UiEventKind::PointerDown)
3364            .unwrap();
3365        assert_eq!(
3366            pd2.click_count, 1,
3367            "press on a new target resets the multi-click sequence"
3368        );
3369    }
3370
3371    #[test]
3372    fn double_click_on_selectable_text_selects_word_at_hit() {
3373        let mut core = lay_out_paragraph_tree();
3374        let p1 = core.rect_of_key("p1").expect("p1 rect");
3375        let cy = p1.y + p1.h * 0.5;
3376        // Click near the start of "First paragraph of text." — twice
3377        // within the multi-click window.
3378        let cx = p1.x + 4.0;
3379        core.pointer_down(cx, cy, PointerButton::Primary);
3380        core.pointer_up(cx, cy, PointerButton::Primary);
3381        core.pointer_down(cx, cy, PointerButton::Primary);
3382        // The current selection should now span the first word.
3383        let sel = &core.ui_state.current_selection;
3384        let r = sel.range.as_ref().expect("selection set");
3385        assert_eq!(r.anchor.key, "p1");
3386        assert_eq!(r.head.key, "p1");
3387        // "First" is 5 bytes.
3388        assert_eq!(r.anchor.byte.min(r.head.byte), 0);
3389        assert_eq!(r.anchor.byte.max(r.head.byte), 5);
3390    }
3391
3392    #[test]
3393    fn triple_click_on_selectable_text_selects_whole_leaf() {
3394        let mut core = lay_out_paragraph_tree();
3395        let p1 = core.rect_of_key("p1").expect("p1 rect");
3396        let cy = p1.y + p1.h * 0.5;
3397        let cx = p1.x + 4.0;
3398        core.pointer_down(cx, cy, PointerButton::Primary);
3399        core.pointer_up(cx, cy, PointerButton::Primary);
3400        core.pointer_down(cx, cy, PointerButton::Primary);
3401        core.pointer_up(cx, cy, PointerButton::Primary);
3402        core.pointer_down(cx, cy, PointerButton::Primary);
3403        let sel = &core.ui_state.current_selection;
3404        let r = sel.range.as_ref().expect("selection set");
3405        assert_eq!(r.anchor.byte, 0);
3406        // "First paragraph of text." is 24 bytes.
3407        assert_eq!(r.head.byte, 24);
3408    }
3409
3410    #[test]
3411    fn click_count_resets_when_press_drifts_outside_distance_window() {
3412        let mut core = lay_out_input_tree(false);
3413        let btn = core.rect_of_key("btn").expect("btn rect");
3414        let cx = btn.x + btn.w * 0.5;
3415        let cy = btn.y + btn.h * 0.5;
3416
3417        let _ = core.pointer_down(cx, cy, PointerButton::Primary);
3418        let _ = core.pointer_up(cx, cy, PointerButton::Primary);
3419
3420        // Move 10 px (well outside MULTI_CLICK_DIST=4.0). Even if same
3421        // target, the second press starts a fresh sequence.
3422        let down2 = core.pointer_down(cx + 10.0, cy, PointerButton::Primary);
3423        let pd2 = down2
3424            .iter()
3425            .find(|e| e.kind == UiEventKind::PointerDown)
3426            .unwrap();
3427        assert_eq!(pd2.click_count, 1);
3428    }
3429
3430    #[test]
3431    fn escape_with_no_selection_emits_only_escape() {
3432        let mut core = lay_out_paragraph_tree();
3433        assert!(core.ui_state.current_selection.is_empty());
3434        let events = core.key_down(UiKey::Escape, KeyModifiers::default(), false);
3435        let kinds: Vec<UiEventKind> = events.iter().map(|e| e.kind).collect();
3436        assert_eq!(
3437            kinds,
3438            vec![UiEventKind::Escape],
3439            "no selection → no SelectionChanged side-effect"
3440        );
3441    }
3442
3443    /// Build a 200x200 viewport hosting a `scroll([rows...])` whose
3444    /// content overflows so the thumb is present.
3445    fn lay_out_scroll_tree() -> (RunnerCore, String) {
3446        use crate::tree::*;
3447        let mut tree = crate::scroll(
3448            (0..6)
3449                .map(|i| crate::widgets::text::text(format!("row {i}")).height(Size::Fixed(50.0))),
3450        )
3451        .gap(12.0)
3452        .height(Size::Fixed(200.0));
3453        let mut core = RunnerCore::new();
3454        crate::layout::layout(
3455            &mut tree,
3456            &mut core.ui_state,
3457            Rect::new(0.0, 0.0, 300.0, 200.0),
3458        );
3459        let scroll_id = tree.computed_id.clone();
3460        let mut t = PrepareTimings::default();
3461        core.snapshot(&tree, &mut t);
3462        (core, scroll_id)
3463    }
3464
3465    #[test]
3466    fn thumb_pointer_down_captures_drag_and_suppresses_events() {
3467        let (mut core, scroll_id) = lay_out_scroll_tree();
3468        let thumb = core
3469            .ui_state
3470            .scroll
3471            .thumb_rects
3472            .get(&scroll_id)
3473            .copied()
3474            .expect("scrollable should have a thumb");
3475        let event = core.pointer_down(
3476            thumb.x + thumb.w * 0.5,
3477            thumb.y + thumb.h * 0.5,
3478            PointerButton::Primary,
3479        );
3480        assert!(
3481            event.is_empty(),
3482            "thumb press should not emit PointerDown to the app"
3483        );
3484        let drag = core
3485            .ui_state
3486            .scroll
3487            .thumb_drag
3488            .as_ref()
3489            .expect("scroll.thumb_drag should be set after pointer_down on thumb");
3490        assert_eq!(drag.scroll_id, scroll_id);
3491    }
3492
3493    #[test]
3494    fn track_click_above_thumb_pages_up_below_pages_down() {
3495        let (mut core, scroll_id) = lay_out_scroll_tree();
3496        let track = core
3497            .ui_state
3498            .scroll
3499            .thumb_tracks
3500            .get(&scroll_id)
3501            .copied()
3502            .expect("scrollable should have a track");
3503        let thumb = core
3504            .ui_state
3505            .scroll
3506            .thumb_rects
3507            .get(&scroll_id)
3508            .copied()
3509            .unwrap();
3510        let metrics = core
3511            .ui_state
3512            .scroll
3513            .metrics
3514            .get(&scroll_id)
3515            .copied()
3516            .unwrap();
3517
3518        // Press in the track below the thumb at offset 0 → page down.
3519        let evt = core.pointer_down(
3520            track.x + track.w * 0.5,
3521            thumb.y + thumb.h + 10.0,
3522            PointerButton::Primary,
3523        );
3524        assert!(evt.is_empty(), "track press should not surface PointerDown");
3525        assert!(
3526            core.ui_state.scroll.thumb_drag.is_none(),
3527            "track click outside the thumb should not start a drag",
3528        );
3529        let after_down = core.ui_state.scroll_offset(&scroll_id);
3530        let expected_page = (metrics.viewport_h - SCROLL_PAGE_OVERLAP).max(0.0);
3531        assert!(
3532            (after_down - expected_page.min(metrics.max_offset)).abs() < 0.5,
3533            "page-down offset = {after_down} (expected ~{expected_page})",
3534        );
3535        // pointer_up after a track-page is a no-op (no drag to clear).
3536        let _ = core.pointer_up(0.0, 0.0, PointerButton::Primary);
3537
3538        // Re-layout to refresh the thumb position at the new offset,
3539        // then click-to-page up.
3540        let mut tree = lay_out_scroll_tree_only();
3541        crate::layout::layout(
3542            &mut tree,
3543            &mut core.ui_state,
3544            Rect::new(0.0, 0.0, 300.0, 200.0),
3545        );
3546        let mut t = PrepareTimings::default();
3547        core.snapshot(&tree, &mut t);
3548        let track = core
3549            .ui_state
3550            .scroll
3551            .thumb_tracks
3552            .get(&tree.computed_id)
3553            .copied()
3554            .unwrap();
3555        let thumb = core
3556            .ui_state
3557            .scroll
3558            .thumb_rects
3559            .get(&tree.computed_id)
3560            .copied()
3561            .unwrap();
3562
3563        core.pointer_down(
3564            track.x + track.w * 0.5,
3565            thumb.y - 4.0,
3566            PointerButton::Primary,
3567        );
3568        let after_up = core.ui_state.scroll_offset(&tree.computed_id);
3569        assert!(
3570            after_up < after_down,
3571            "page-up should reduce offset: before={after_down} after={after_up}",
3572        );
3573    }
3574
3575    /// Same fixture as `lay_out_scroll_tree` but doesn't build a
3576    /// fresh `RunnerCore` — useful when tests want to re-layout
3577    /// against an existing core to refresh thumb rects after a
3578    /// scroll offset change.
3579    fn lay_out_scroll_tree_only() -> El {
3580        use crate::tree::*;
3581        crate::scroll(
3582            (0..6)
3583                .map(|i| crate::widgets::text::text(format!("row {i}")).height(Size::Fixed(50.0))),
3584        )
3585        .gap(12.0)
3586        .height(Size::Fixed(200.0))
3587    }
3588
3589    #[test]
3590    fn thumb_drag_translates_pointer_delta_into_scroll_offset() {
3591        let (mut core, scroll_id) = lay_out_scroll_tree();
3592        let thumb = core
3593            .ui_state
3594            .scroll
3595            .thumb_rects
3596            .get(&scroll_id)
3597            .copied()
3598            .unwrap();
3599        let metrics = core
3600            .ui_state
3601            .scroll
3602            .metrics
3603            .get(&scroll_id)
3604            .copied()
3605            .unwrap();
3606        let track_remaining = (metrics.viewport_h - thumb.h).max(0.0);
3607
3608        let press_y = thumb.y + thumb.h * 0.5;
3609        core.pointer_down(thumb.x + thumb.w * 0.5, press_y, PointerButton::Primary);
3610        // Drag 20 px down — offset should advance by `20 * max_offset / track_remaining`.
3611        let evt = core.pointer_moved(thumb.x + thumb.w * 0.5, press_y + 20.0);
3612        assert!(
3613            evt.events.is_empty(),
3614            "thumb-drag move should suppress Drag event",
3615        );
3616        let offset = core.ui_state.scroll_offset(&scroll_id);
3617        let expected = 20.0 * (metrics.max_offset / track_remaining);
3618        assert!(
3619            (offset - expected).abs() < 0.5,
3620            "offset {offset} (expected {expected})",
3621        );
3622        // Overshooting clamps to max_offset.
3623        core.pointer_moved(thumb.x + thumb.w * 0.5, press_y + 9999.0);
3624        let offset = core.ui_state.scroll_offset(&scroll_id);
3625        assert!(
3626            (offset - metrics.max_offset).abs() < 0.5,
3627            "overshoot offset {offset} (expected {})",
3628            metrics.max_offset
3629        );
3630        // Release clears the drag without emitting events.
3631        let events = core.pointer_up(thumb.x, press_y, PointerButton::Primary);
3632        assert!(events.is_empty(), "thumb release shouldn't emit events");
3633        assert!(core.ui_state.scroll.thumb_drag.is_none());
3634    }
3635
3636    #[test]
3637    fn secondary_click_does_not_steal_focus_or_press() {
3638        let mut core = lay_out_input_tree(false);
3639        let btn_rect = core.rect_of_key("btn").expect("btn rect");
3640        let cx = btn_rect.x + btn_rect.w * 0.5;
3641        let cy = btn_rect.y + btn_rect.h * 0.5;
3642        // Focus elsewhere first via primary click on the input.
3643        let ti_rect = core.rect_of_key("ti").expect("ti rect");
3644        let tx = ti_rect.x + ti_rect.w * 0.5;
3645        let ty = ti_rect.y + ti_rect.h * 0.5;
3646        core.pointer_down(tx, ty, PointerButton::Primary);
3647        let _ = core.pointer_up(tx, ty, PointerButton::Primary);
3648        let focused_before = core.ui_state.focused.as_ref().map(|t| t.key.clone());
3649        // Right-click on the button.
3650        core.pointer_down(cx, cy, PointerButton::Secondary);
3651        let events = core.pointer_up(cx, cy, PointerButton::Secondary);
3652        let kinds: Vec<UiEventKind> = events.iter().map(|e| e.kind).collect();
3653        assert_eq!(kinds, vec![UiEventKind::SecondaryClick]);
3654        let focused_after = core.ui_state.focused.as_ref().map(|t| t.key.clone());
3655        assert_eq!(
3656            focused_before, focused_after,
3657            "right-click must not steal focus"
3658        );
3659        assert!(
3660            core.ui_state.pressed.is_none(),
3661            "right-click must not set primary press"
3662        );
3663    }
3664
3665    #[test]
3666    fn text_input_routes_to_focused_only() {
3667        let mut core = lay_out_input_tree(false);
3668        // No focus yet → no event.
3669        assert!(core.text_input("a".into()).is_none());
3670        // Focus the button via primary click.
3671        let btn_rect = core.rect_of_key("btn").expect("btn rect");
3672        let cx = btn_rect.x + btn_rect.w * 0.5;
3673        let cy = btn_rect.y + btn_rect.h * 0.5;
3674        core.pointer_down(cx, cy, PointerButton::Primary);
3675        let _ = core.pointer_up(cx, cy, PointerButton::Primary);
3676        let event = core.text_input("hi".into()).expect("focused → event");
3677        assert_eq!(event.kind, UiEventKind::TextInput);
3678        assert_eq!(event.text.as_deref(), Some("hi"));
3679        assert_eq!(event.target.as_ref().map(|t| t.key.as_str()), Some("btn"));
3680        // Empty text → no event (some IME paths emit empty composition).
3681        assert!(core.text_input(String::new()).is_none());
3682    }
3683
3684    #[test]
3685    fn capture_keys_bypasses_tab_traversal_for_focused_node() {
3686        // Focus the capture_keys input. Tab should NOT move focus —
3687        // it should be delivered as a raw KeyDown to the input.
3688        let mut core = lay_out_input_tree(true);
3689        let ti_rect = core.rect_of_key("ti").expect("ti rect");
3690        let tx = ti_rect.x + ti_rect.w * 0.5;
3691        let ty = ti_rect.y + ti_rect.h * 0.5;
3692        core.pointer_down(tx, ty, PointerButton::Primary);
3693        let _ = core.pointer_up(tx, ty, PointerButton::Primary);
3694        assert_eq!(
3695            core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
3696            Some("ti"),
3697            "primary click on capture_keys node still focuses it"
3698        );
3699
3700        let events = core.key_down(UiKey::Tab, KeyModifiers::default(), false);
3701        assert_eq!(events.len(), 1, "Tab → exactly one KeyDown");
3702        let event = &events[0];
3703        assert_eq!(event.kind, UiEventKind::KeyDown);
3704        assert_eq!(event.target.as_ref().map(|t| t.key.as_str()), Some("ti"));
3705        assert_eq!(
3706            core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
3707            Some("ti"),
3708            "Tab inside capture_keys must NOT move focus"
3709        );
3710    }
3711
3712    #[test]
3713    fn pointer_down_focus_does_not_raise_focus_visible() {
3714        // `:focus-visible` semantics: clicking a widget focuses it but
3715        // does NOT light up the focus ring. Verify the runtime flag.
3716        let mut core = lay_out_input_tree(false);
3717        let btn_rect = core.rect_of_key("btn").expect("btn rect");
3718        let cx = btn_rect.x + btn_rect.w * 0.5;
3719        let cy = btn_rect.y + btn_rect.h * 0.5;
3720        core.pointer_down(cx, cy, PointerButton::Primary);
3721        assert_eq!(
3722            core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
3723            Some("btn"),
3724            "primary click focuses the button",
3725        );
3726        assert!(
3727            !core.ui_state.focus_visible,
3728            "click focus must not raise focus_visible — ring stays off",
3729        );
3730    }
3731
3732    #[test]
3733    fn tab_key_raises_focus_visible_so_ring_appears() {
3734        let mut core = lay_out_input_tree(false);
3735        // Pre-focus via click so focus_visible starts low.
3736        let btn_rect = core.rect_of_key("btn").expect("btn rect");
3737        let cx = btn_rect.x + btn_rect.w * 0.5;
3738        let cy = btn_rect.y + btn_rect.h * 0.5;
3739        core.pointer_down(cx, cy, PointerButton::Primary);
3740        assert!(!core.ui_state.focus_visible);
3741        // Tab moves focus and should raise the ring.
3742        let _ = core.key_down(UiKey::Tab, KeyModifiers::default(), false);
3743        assert!(
3744            core.ui_state.focus_visible,
3745            "Tab must raise focus_visible so the ring paints on the new target",
3746        );
3747    }
3748
3749    #[test]
3750    fn click_after_tab_clears_focus_visible_again() {
3751        // Tab raises the ring; a subsequent click on a focusable widget
3752        // suppresses it again — the user is back on the pointer.
3753        let mut core = lay_out_input_tree(false);
3754        let _ = core.key_down(UiKey::Tab, KeyModifiers::default(), false);
3755        assert!(core.ui_state.focus_visible, "Tab raises ring");
3756        let btn_rect = core.rect_of_key("btn").expect("btn rect");
3757        let cx = btn_rect.x + btn_rect.w * 0.5;
3758        let cy = btn_rect.y + btn_rect.h * 0.5;
3759        core.pointer_down(cx, cy, PointerButton::Primary);
3760        assert!(
3761            !core.ui_state.focus_visible,
3762            "pointer-down clears focus_visible — ring fades back out",
3763        );
3764    }
3765
3766    #[test]
3767    fn keypress_on_focused_widget_raises_focus_visible_after_click() {
3768        // Click a focused-but-non-text widget, then nudge with a key
3769        // (e.g. arrow on a slider). The keypress is keyboard
3770        // interaction → ring lights up even though focus didn't move.
3771        let mut core = lay_out_input_tree(false);
3772        let btn_rect = core.rect_of_key("btn").expect("btn rect");
3773        let cx = btn_rect.x + btn_rect.w * 0.5;
3774        let cy = btn_rect.y + btn_rect.h * 0.5;
3775        core.pointer_down(cx, cy, PointerButton::Primary);
3776        assert!(!core.ui_state.focus_visible);
3777        let _ = core.key_down(UiKey::ArrowRight, KeyModifiers::default(), false);
3778        assert!(
3779            core.ui_state.focus_visible,
3780            "non-Tab key on focused widget raises focus_visible",
3781        );
3782    }
3783
3784    #[test]
3785    fn arrow_nav_in_sibling_group_raises_focus_visible() {
3786        let mut core = lay_out_arrow_nav_tree();
3787        // The fixture pre-sets focus directly without going through
3788        // the runtime; ensure the flag starts low.
3789        core.ui_state.set_focus_visible(false);
3790        let _ = core.key_down(UiKey::ArrowDown, KeyModifiers::default(), false);
3791        assert!(
3792            core.ui_state.focus_visible,
3793            "arrow-nav within an arrow_nav_siblings group is keyboard navigation",
3794        );
3795    }
3796
3797    #[test]
3798    fn capture_keys_falls_back_to_default_when_focus_off_capturing_node() {
3799        // Tree has both a normal-focusable button and a capture_keys
3800        // input. Focus the button (normal focusable). Tab should then
3801        // do library-default focus traversal.
3802        let mut core = lay_out_input_tree(true);
3803        let btn_rect = core.rect_of_key("btn").expect("btn rect");
3804        let cx = btn_rect.x + btn_rect.w * 0.5;
3805        let cy = btn_rect.y + btn_rect.h * 0.5;
3806        core.pointer_down(cx, cy, PointerButton::Primary);
3807        let _ = core.pointer_up(cx, cy, PointerButton::Primary);
3808        assert_eq!(
3809            core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
3810            Some("btn"),
3811            "primary click focuses button"
3812        );
3813        // Tab should move focus to the next focusable (the input).
3814        let _ = core.key_down(UiKey::Tab, KeyModifiers::default(), false);
3815        assert_eq!(
3816            core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
3817            Some("ti"),
3818            "Tab from non-capturing focused does library-default traversal"
3819        );
3820    }
3821
3822    /// A column whose three buttons sit inside an `arrow_nav_siblings`
3823    /// parent (the shape `popover_panel` produces). Layout runs against
3824    /// a 200x300 viewport with 10px padding; each button is 80px wide
3825    /// and 36px tall stacked vertically, plenty inside the clip.
3826    fn lay_out_arrow_nav_tree() -> RunnerCore {
3827        use crate::tree::*;
3828        let mut tree = crate::column([
3829            crate::widgets::button::button("Red").key("opt-red"),
3830            crate::widgets::button::button("Green").key("opt-green"),
3831            crate::widgets::button::button("Blue").key("opt-blue"),
3832        ])
3833        .arrow_nav_siblings()
3834        .padding(10.0);
3835        let mut core = RunnerCore::new();
3836        crate::layout::layout(
3837            &mut tree,
3838            &mut core.ui_state,
3839            Rect::new(0.0, 0.0, 200.0, 300.0),
3840        );
3841        core.ui_state.sync_focus_order(&tree);
3842        let mut t = PrepareTimings::default();
3843        core.snapshot(&tree, &mut t);
3844        // Pre-focus the middle option (the typical state right after a
3845        // popover opens — we'll exercise transitions from there).
3846        let target = core
3847            .ui_state
3848            .focus
3849            .order
3850            .iter()
3851            .find(|t| t.key == "opt-green")
3852            .cloned();
3853        core.ui_state.set_focus(target);
3854        core
3855    }
3856
3857    #[test]
3858    fn arrow_nav_moves_focus_among_siblings() {
3859        let mut core = lay_out_arrow_nav_tree();
3860
3861        // ArrowDown moves to next sibling, no event emitted (it was
3862        // consumed by the navigation path).
3863        let down = core.key_down(UiKey::ArrowDown, KeyModifiers::default(), false);
3864        assert!(down.is_empty(), "arrow-nav consumes the key event");
3865        assert_eq!(
3866            core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
3867            Some("opt-blue"),
3868        );
3869
3870        // ArrowUp moves back.
3871        core.key_down(UiKey::ArrowUp, KeyModifiers::default(), false);
3872        assert_eq!(
3873            core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
3874            Some("opt-green"),
3875        );
3876
3877        // Home jumps to first.
3878        core.key_down(UiKey::Home, KeyModifiers::default(), false);
3879        assert_eq!(
3880            core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
3881            Some("opt-red"),
3882        );
3883
3884        // End jumps to last.
3885        core.key_down(UiKey::End, KeyModifiers::default(), false);
3886        assert_eq!(
3887            core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
3888            Some("opt-blue"),
3889        );
3890    }
3891
3892    #[test]
3893    fn arrow_nav_saturates_at_ends() {
3894        let mut core = lay_out_arrow_nav_tree();
3895        // Walk to the first option and try to go before it.
3896        core.key_down(UiKey::Home, KeyModifiers::default(), false);
3897        core.key_down(UiKey::ArrowUp, KeyModifiers::default(), false);
3898        assert_eq!(
3899            core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
3900            Some("opt-red"),
3901            "ArrowUp at top stays at top — no wrap",
3902        );
3903        // Same at the bottom.
3904        core.key_down(UiKey::End, KeyModifiers::default(), false);
3905        core.key_down(UiKey::ArrowDown, KeyModifiers::default(), false);
3906        assert_eq!(
3907            core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
3908            Some("opt-blue"),
3909            "ArrowDown at bottom stays at bottom — no wrap",
3910        );
3911    }
3912
3913    /// Build a tree shaped like a real app's `build()` output: a
3914    /// background row with a "Trigger" button, optionally with a
3915    /// dropdown popover layered on top.
3916    fn build_popover_tree(open: bool) -> El {
3917        use crate::widgets::button::button;
3918        use crate::widgets::overlay::overlay;
3919        use crate::widgets::popover::{dropdown, menu_item};
3920        let mut layers: Vec<El> = vec![button("Trigger").key("trigger")];
3921        if open {
3922            layers.push(dropdown(
3923                "menu",
3924                "trigger",
3925                [
3926                    menu_item("A").key("item-a"),
3927                    menu_item("B").key("item-b"),
3928                    menu_item("C").key("item-c"),
3929                ],
3930            ));
3931        }
3932        overlay(layers).padding(20.0)
3933    }
3934
3935    /// Run a full per-frame layout pass against `tree` so all the
3936    /// post-layout hooks (focus order sync, popover focus stack, etc.)
3937    /// fire just like a real frame.
3938    fn run_frame(core: &mut RunnerCore, tree: &mut El) {
3939        let mut t = PrepareTimings::default();
3940        core.prepare_layout(
3941            tree,
3942            Rect::new(0.0, 0.0, 400.0, 300.0),
3943            1.0,
3944            &mut t,
3945            RunnerCore::no_time_shaders,
3946        );
3947        core.snapshot(tree, &mut t);
3948    }
3949
3950    #[test]
3951    fn popover_open_pushes_focus_and_auto_focuses_first_item() {
3952        let mut core = RunnerCore::new();
3953        let mut closed = build_popover_tree(false);
3954        run_frame(&mut core, &mut closed);
3955        // Pre-focus the trigger as if the user tabbed to it before
3956        // opening the menu.
3957        let trigger = core
3958            .ui_state
3959            .focus
3960            .order
3961            .iter()
3962            .find(|t| t.key == "trigger")
3963            .cloned();
3964        core.ui_state.set_focus(trigger);
3965        assert_eq!(
3966            core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
3967            Some("trigger"),
3968        );
3969
3970        // Open the popover. The runtime should snapshot the trigger
3971        // onto the focus stack and auto-focus the first menu item.
3972        let mut open = build_popover_tree(true);
3973        run_frame(&mut core, &mut open);
3974        assert_eq!(
3975            core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
3976            Some("item-a"),
3977            "popover open should auto-focus the first menu item",
3978        );
3979        assert_eq!(
3980            core.ui_state.popover_focus.focus_stack.len(),
3981            1,
3982            "trigger should be saved on the focus stack",
3983        );
3984        assert_eq!(
3985            core.ui_state.popover_focus.focus_stack[0].key.as_str(),
3986            "trigger",
3987            "saved focus should be the pre-open target",
3988        );
3989    }
3990
3991    #[test]
3992    fn popover_close_restores_focus_to_trigger() {
3993        let mut core = RunnerCore::new();
3994        let mut closed = build_popover_tree(false);
3995        run_frame(&mut core, &mut closed);
3996        let trigger = core
3997            .ui_state
3998            .focus
3999            .order
4000            .iter()
4001            .find(|t| t.key == "trigger")
4002            .cloned();
4003        core.ui_state.set_focus(trigger);
4004
4005        // Open → focus walks to the menu.
4006        let mut open = build_popover_tree(true);
4007        run_frame(&mut core, &mut open);
4008        assert_eq!(
4009            core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
4010            Some("item-a"),
4011        );
4012
4013        // Close → focus restored to trigger, stack drained.
4014        let mut closed_again = build_popover_tree(false);
4015        run_frame(&mut core, &mut closed_again);
4016        assert_eq!(
4017            core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
4018            Some("trigger"),
4019            "closing the popover should pop the saved focus",
4020        );
4021        assert!(
4022            core.ui_state.popover_focus.focus_stack.is_empty(),
4023            "focus stack should be drained after restore",
4024        );
4025    }
4026
4027    #[test]
4028    fn popover_close_does_not_override_intentional_focus_move() {
4029        let mut core = RunnerCore::new();
4030        // Tree with a second focusable button outside the popover so
4031        // the user can "click somewhere else" while the menu is open.
4032        let build = |open: bool| -> El {
4033            use crate::widgets::button::button;
4034            use crate::widgets::overlay::overlay;
4035            use crate::widgets::popover::{dropdown, menu_item};
4036            let main = crate::row([
4037                button("Trigger").key("trigger"),
4038                button("Other").key("other"),
4039            ]);
4040            let mut layers: Vec<El> = vec![main];
4041            if open {
4042                layers.push(dropdown("menu", "trigger", [menu_item("A").key("item-a")]));
4043            }
4044            overlay(layers).padding(20.0)
4045        };
4046
4047        let mut closed = build(false);
4048        run_frame(&mut core, &mut closed);
4049        let trigger = core
4050            .ui_state
4051            .focus
4052            .order
4053            .iter()
4054            .find(|t| t.key == "trigger")
4055            .cloned();
4056        core.ui_state.set_focus(trigger);
4057
4058        let mut open = build(true);
4059        run_frame(&mut core, &mut open);
4060        assert_eq!(core.ui_state.popover_focus.focus_stack.len(), 1);
4061
4062        // Simulate an intentional focus move to a sibling that is
4063        // outside the popover (e.g. the user re-tabbed somewhere). Do
4064        // this by setting focus directly while the popover is still in
4065        // the tree — the existing focus-order contains "other".
4066        let other = core
4067            .ui_state
4068            .focus
4069            .order
4070            .iter()
4071            .find(|t| t.key == "other")
4072            .cloned();
4073        core.ui_state.set_focus(other);
4074
4075        let mut closed_again = build(false);
4076        run_frame(&mut core, &mut closed_again);
4077        assert_eq!(
4078            core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
4079            Some("other"),
4080            "focus moved before close should not be overridden by restore",
4081        );
4082        assert!(core.ui_state.popover_focus.focus_stack.is_empty());
4083    }
4084
4085    #[test]
4086    fn nested_popovers_stack_and_unwind_focus_correctly() {
4087        let mut core = RunnerCore::new();
4088        // Two siblings layered at El root: an outer popover anchored to
4089        // the trigger, and an inner popover anchored to a button inside
4090        // the outer panel. Both are real popovers — separate
4091        // popover_layer ids — so the runtime sees them stack.
4092        let build = |outer: bool, inner: bool| -> El {
4093            use crate::widgets::button::button;
4094            use crate::widgets::overlay::overlay;
4095            use crate::widgets::popover::{Anchor, popover, popover_panel};
4096            let main = button("Trigger").key("trigger");
4097            let mut layers: Vec<El> = vec![main];
4098            if outer {
4099                layers.push(popover(
4100                    "outer",
4101                    Anchor::below_key("trigger"),
4102                    popover_panel([button("Open inner").key("inner-trigger")]),
4103                ));
4104            }
4105            if inner {
4106                layers.push(popover(
4107                    "inner",
4108                    Anchor::below_key("inner-trigger"),
4109                    popover_panel([button("X").key("inner-a"), button("Y").key("inner-b")]),
4110                ));
4111            }
4112            overlay(layers).padding(20.0)
4113        };
4114
4115        // Frame 1: nothing open, focus on the trigger.
4116        let mut closed = build(false, false);
4117        run_frame(&mut core, &mut closed);
4118        let trigger = core
4119            .ui_state
4120            .focus
4121            .order
4122            .iter()
4123            .find(|t| t.key == "trigger")
4124            .cloned();
4125        core.ui_state.set_focus(trigger);
4126
4127        // Frame 2: outer opens. Save trigger, focus inner-trigger.
4128        let mut outer = build(true, false);
4129        run_frame(&mut core, &mut outer);
4130        assert_eq!(
4131            core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
4132            Some("inner-trigger"),
4133        );
4134        assert_eq!(core.ui_state.popover_focus.focus_stack.len(), 1);
4135
4136        // Frame 3: inner also opens. Save inner-trigger, focus inner-a.
4137        let mut both = build(true, true);
4138        run_frame(&mut core, &mut both);
4139        assert_eq!(
4140            core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
4141            Some("inner-a"),
4142        );
4143        assert_eq!(core.ui_state.popover_focus.focus_stack.len(), 2);
4144
4145        // Frame 4: inner closes. Pop → restore inner-trigger.
4146        let mut outer_only = build(true, false);
4147        run_frame(&mut core, &mut outer_only);
4148        assert_eq!(
4149            core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
4150            Some("inner-trigger"),
4151        );
4152        assert_eq!(core.ui_state.popover_focus.focus_stack.len(), 1);
4153
4154        // Frame 5: outer closes. Pop → restore trigger.
4155        let mut none = build(false, false);
4156        run_frame(&mut core, &mut none);
4157        assert_eq!(
4158            core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
4159            Some("trigger"),
4160        );
4161        assert!(core.ui_state.popover_focus.focus_stack.is_empty());
4162    }
4163
4164    #[test]
4165    fn arrow_nav_does_not_intercept_outside_navigable_groups() {
4166        // Reuse the input tree (no arrow_nav_siblings parent). Arrow
4167        // keys must produce a regular `KeyDown` event so a
4168        // capture_keys widget can interpret them as caret motion.
4169        let mut core = lay_out_input_tree(false);
4170        let target = core
4171            .ui_state
4172            .focus
4173            .order
4174            .iter()
4175            .find(|t| t.key == "btn")
4176            .cloned();
4177        core.ui_state.set_focus(target);
4178        let events = core.key_down(UiKey::ArrowDown, KeyModifiers::default(), false);
4179        assert_eq!(
4180            events.len(),
4181            1,
4182            "ArrowDown without navigable parent → event"
4183        );
4184        assert_eq!(events[0].kind, UiEventKind::KeyDown);
4185    }
4186
4187    fn quad(shader: ShaderHandle) -> DrawOp {
4188        DrawOp::Quad {
4189            id: "q".into(),
4190            rect: Rect::new(0.0, 0.0, 10.0, 10.0),
4191            scissor: None,
4192            shader,
4193            uniforms: UniformBlock::new(),
4194        }
4195    }
4196
4197    #[test]
4198    fn samples_backdrop_inserts_snapshot_before_first_glass_quad() {
4199        let mut core = RunnerCore::new();
4200        core.set_surface_size(100, 100);
4201        let ops = vec![
4202            quad(ShaderHandle::Stock(StockShader::RoundedRect)),
4203            quad(ShaderHandle::Stock(StockShader::RoundedRect)),
4204            quad(ShaderHandle::Custom("liquid_glass")),
4205            quad(ShaderHandle::Custom("liquid_glass")),
4206            quad(ShaderHandle::Stock(StockShader::RoundedRect)),
4207        ];
4208        let mut timings = PrepareTimings::default();
4209        core.prepare_paint(
4210            &ops,
4211            |_| true,
4212            |s| matches!(s, ShaderHandle::Custom(name) if *name == "liquid_glass"),
4213            &mut NoText,
4214            1.0,
4215            &mut timings,
4216        );
4217
4218        let kinds: Vec<&'static str> = core
4219            .paint_items
4220            .iter()
4221            .map(|p| match p {
4222                PaintItem::QuadRun(_) => "Q",
4223                PaintItem::IconRun(_) => "I",
4224                PaintItem::Text(_) => "T",
4225                PaintItem::Image(_) => "M",
4226                PaintItem::AppTexture(_) => "A",
4227                PaintItem::Vector(_) => "V",
4228                PaintItem::BackdropSnapshot => "S",
4229            })
4230            .collect();
4231        assert_eq!(
4232            kinds,
4233            vec!["Q", "S", "Q", "Q"],
4234            "expected one stock run, snapshot, then a glass run, then a foreground stock run"
4235        );
4236    }
4237
4238    #[test]
4239    fn no_snapshot_when_no_glass_drawn() {
4240        let mut core = RunnerCore::new();
4241        core.set_surface_size(100, 100);
4242        let ops = vec![
4243            quad(ShaderHandle::Stock(StockShader::RoundedRect)),
4244            quad(ShaderHandle::Stock(StockShader::RoundedRect)),
4245        ];
4246        let mut timings = PrepareTimings::default();
4247        core.prepare_paint(&ops, |_| true, |_| false, &mut NoText, 1.0, &mut timings);
4248        assert!(
4249            !core
4250                .paint_items
4251                .iter()
4252                .any(|p| matches!(p, PaintItem::BackdropSnapshot)),
4253            "no glass shader registered → no snapshot"
4254        );
4255    }
4256
4257    #[test]
4258    fn at_most_one_snapshot_per_frame() {
4259        let mut core = RunnerCore::new();
4260        core.set_surface_size(100, 100);
4261        let ops = vec![
4262            quad(ShaderHandle::Stock(StockShader::RoundedRect)),
4263            quad(ShaderHandle::Custom("g")),
4264            quad(ShaderHandle::Stock(StockShader::RoundedRect)),
4265            quad(ShaderHandle::Custom("g")),
4266        ];
4267        let mut timings = PrepareTimings::default();
4268        core.prepare_paint(
4269            &ops,
4270            |_| true,
4271            |s| matches!(s, ShaderHandle::Custom("g")),
4272            &mut NoText,
4273            1.0,
4274            &mut timings,
4275        );
4276        let snapshots = core
4277            .paint_items
4278            .iter()
4279            .filter(|p| matches!(p, PaintItem::BackdropSnapshot))
4280            .count();
4281        assert_eq!(snapshots, 1, "backdrop depth is capped at 1");
4282    }
4283}