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        return node.text.as_ref().map(|t| t.len());
1895    }
1896    node.children.iter().find_map(|c| find_text_len(c, id))
1897}
1898
1899/// Recorded output from an icon draw op. Backends without a vector-icon
1900/// path use `Text` fallback layers; wgpu can return dedicated icon runs.
1901pub enum RecordedPaint {
1902    Text(Range<usize>),
1903    Icon(Range<usize>),
1904}
1905
1906/// Glyph-recording surface implemented by each backend's `TextPaint`.
1907/// `prepare_paint` calls into it exactly the same way wgpu and vulkano
1908/// would call their per-backend equivalents.
1909pub trait TextRecorder {
1910    /// Append per-glyph instances for `text` and return the range of
1911    /// indices written into the backend's `TextLayer` storage. Each
1912    /// returned index lands in `paint_items` as a `PaintItem::Text`.
1913    ///
1914    /// `style` carries weight + color + (optional) decoration flags
1915    /// — backends fold it into a single-element `(text, style)` slice
1916    /// and run the same shaping path as [`Self::record_runs`].
1917    #[allow(clippy::too_many_arguments)]
1918    fn record(
1919        &mut self,
1920        rect: Rect,
1921        scissor: Option<PhysicalScissor>,
1922        style: &RunStyle,
1923        text: &str,
1924        size: f32,
1925        line_height: f32,
1926        wrap: TextWrap,
1927        anchor: TextAnchor,
1928        scale_factor: f32,
1929    ) -> Range<usize>;
1930
1931    /// Append per-glyph instances for an attributed paragraph (one
1932    /// shaped run with per-character RunStyle metadata). Wrapping
1933    /// decisions cross run boundaries — the result is one ShapedRun
1934    /// just like a single-style call.
1935    #[allow(clippy::too_many_arguments)]
1936    fn record_runs(
1937        &mut self,
1938        rect: Rect,
1939        scissor: Option<PhysicalScissor>,
1940        runs: &[(String, RunStyle)],
1941        size: f32,
1942        line_height: f32,
1943        wrap: TextWrap,
1944        anchor: TextAnchor,
1945        scale_factor: f32,
1946    ) -> Range<usize>;
1947
1948    /// Append a vector icon. Backends with a native vector painter
1949    /// override this; the default keeps experimental/simple backends on
1950    /// the previous text-symbol fallback. Built-in icons fall back to
1951    /// their named glyph; app-supplied SVG icons fall back to a
1952    /// generic placeholder since they have no canonical glyph.
1953    #[allow(clippy::too_many_arguments)]
1954    fn record_icon(
1955        &mut self,
1956        rect: Rect,
1957        scissor: Option<PhysicalScissor>,
1958        source: &crate::svg_icon::IconSource,
1959        color: Color,
1960        size: f32,
1961        _stroke_width: f32,
1962        scale_factor: f32,
1963    ) -> RecordedPaint {
1964        let glyph = match source {
1965            crate::svg_icon::IconSource::Builtin(name) => name.fallback_glyph(),
1966            crate::svg_icon::IconSource::Custom(_) => "?",
1967        };
1968        RecordedPaint::Text(self.record(
1969            rect,
1970            scissor,
1971            &RunStyle::new(FontWeight::Regular, color),
1972            glyph,
1973            size,
1974            crate::text::metrics::line_height(size),
1975            TextWrap::NoWrap,
1976            TextAnchor::Middle,
1977            scale_factor,
1978        ))
1979    }
1980
1981    /// Append a raster image draw. Backends with texture sampling
1982    /// override this and return one or more indices into their image
1983    /// storage (each index lands in `paint_items` as
1984    /// `PaintItem::Image`). The default returns an empty range —
1985    /// backends without raster support paint nothing for image Els
1986    /// (the SVG fallback emits a labelled placeholder rect on its own).
1987    #[allow(clippy::too_many_arguments)]
1988    fn record_image(
1989        &mut self,
1990        _rect: Rect,
1991        _scissor: Option<PhysicalScissor>,
1992        _image: &crate::image::Image,
1993        _tint: Option<Color>,
1994        _radius: crate::tree::Corners,
1995        _fit: crate::image::ImageFit,
1996        _scale_factor: f32,
1997    ) -> Range<usize> {
1998        0..0
1999    }
2000
2001    /// Append an app-owned-texture composite. Backends with surface
2002    /// support override this and return one or more indices into their
2003    /// surface storage (each lands in `paint_items` as
2004    /// `PaintItem::AppTexture`). The default returns an empty range so
2005    /// backends without surface support paint nothing for surface Els.
2006    fn record_app_texture(
2007        &mut self,
2008        _rect: Rect,
2009        _scissor: Option<PhysicalScissor>,
2010        _texture: &crate::surface::AppTexture,
2011        _alpha: crate::surface::SurfaceAlpha,
2012        _transform: crate::affine::Affine2,
2013        _scale_factor: f32,
2014    ) -> Range<usize> {
2015        0..0
2016    }
2017
2018    /// Append an app-supplied vector draw. Backends with vector
2019    /// support override this and return one or more indices into their
2020    /// vector storage (each lands in `paint_items` as
2021    /// `PaintItem::Vector`). The default returns an empty range so
2022    /// backends without vector support paint nothing.
2023    fn record_vector(
2024        &mut self,
2025        _rect: Rect,
2026        _scissor: Option<PhysicalScissor>,
2027        _asset: &crate::vector::VectorAsset,
2028        _render_mode: crate::vector::VectorRenderMode,
2029        _scale_factor: f32,
2030    ) -> Range<usize> {
2031        0..0
2032    }
2033}
2034
2035#[cfg(test)]
2036mod tests {
2037    use super::*;
2038    use crate::shader::{ShaderHandle, StockShader, UniformBlock};
2039
2040    /// Minimal recorder for tests that don't exercise the text path.
2041    struct NoText;
2042    impl TextRecorder for NoText {
2043        fn record(
2044            &mut self,
2045            _rect: Rect,
2046            _scissor: Option<PhysicalScissor>,
2047            _style: &RunStyle,
2048            _text: &str,
2049            _size: f32,
2050            _line_height: f32,
2051            _wrap: TextWrap,
2052            _anchor: TextAnchor,
2053            _scale_factor: f32,
2054        ) -> Range<usize> {
2055            0..0
2056        }
2057        fn record_runs(
2058            &mut self,
2059            _rect: Rect,
2060            _scissor: Option<PhysicalScissor>,
2061            _runs: &[(String, RunStyle)],
2062            _size: f32,
2063            _line_height: f32,
2064            _wrap: TextWrap,
2065            _anchor: TextAnchor,
2066            _scale_factor: f32,
2067        ) -> Range<usize> {
2068            0..0
2069        }
2070    }
2071
2072    // ---- input plumbing ----
2073
2074    /// A tree with one focusable button at (10,10,80,40) keyed "btn",
2075    /// plus an optional capture_keys text input at (10,60,80,40) keyed
2076    /// "ti". layout() runs against a 200x200 viewport so the rects
2077    /// land where we expect.
2078    fn lay_out_input_tree(capture: bool) -> RunnerCore {
2079        use crate::tree::*;
2080        let ti = if capture {
2081            crate::widgets::text::text("input").key("ti").capture_keys()
2082        } else {
2083            crate::widgets::text::text("noop").key("ti").focusable()
2084        };
2085        let mut tree =
2086            crate::column([crate::widgets::button::button("Btn").key("btn"), ti]).padding(10.0);
2087        let mut core = RunnerCore::new();
2088        crate::layout::layout(
2089            &mut tree,
2090            &mut core.ui_state,
2091            Rect::new(0.0, 0.0, 200.0, 200.0),
2092        );
2093        core.ui_state.sync_focus_order(&tree);
2094        let mut t = PrepareTimings::default();
2095        core.snapshot(&tree, &mut t);
2096        core
2097    }
2098
2099    #[test]
2100    fn pointer_up_emits_pointer_up_then_click() {
2101        let mut core = lay_out_input_tree(false);
2102        let btn_rect = core.rect_of_key("btn").expect("btn rect");
2103        let cx = btn_rect.x + btn_rect.w * 0.5;
2104        let cy = btn_rect.y + btn_rect.h * 0.5;
2105        core.pointer_moved(cx, cy);
2106        core.pointer_down(cx, cy, PointerButton::Primary);
2107        let events = core.pointer_up(cx, cy, PointerButton::Primary);
2108        let kinds: Vec<UiEventKind> = events.iter().map(|e| e.kind).collect();
2109        assert_eq!(kinds, vec![UiEventKind::PointerUp, UiEventKind::Click]);
2110    }
2111
2112    /// Build a tree containing a single inline paragraph with one
2113    /// linked run, layout to a fixed viewport, and return the runner +
2114    /// the absolute rect of the paragraph. The linked text is long
2115    /// enough that probes well into the paragraph land safely inside
2116    /// the link for any plausible proportional font.
2117    fn lay_out_link_tree() -> (RunnerCore, Rect, &'static str) {
2118        use crate::tree::*;
2119        const URL: &str = "https://github.com/computer-whisperer/aetna";
2120        let mut tree = crate::column([crate::text_runs([
2121            crate::text("Visit "),
2122            crate::text("github.com/computer-whisperer/aetna").link(URL),
2123            crate::text("."),
2124        ])])
2125        .padding(10.0);
2126        let mut core = RunnerCore::new();
2127        crate::layout::layout(
2128            &mut tree,
2129            &mut core.ui_state,
2130            Rect::new(0.0, 0.0, 600.0, 200.0),
2131        );
2132        core.ui_state.sync_focus_order(&tree);
2133        let mut t = PrepareTimings::default();
2134        core.snapshot(&tree, &mut t);
2135        let para = core
2136            .last_tree
2137            .as_ref()
2138            .and_then(|t| t.children.first())
2139            .map(|p| core.ui_state.rect(&p.computed_id))
2140            .expect("paragraph rect");
2141        (core, para, URL)
2142    }
2143
2144    #[test]
2145    fn pointer_up_on_link_emits_link_activated_with_url() {
2146        let (mut core, para, url) = lay_out_link_tree();
2147        // Probe ~100 logical pixels in — past the "Visit " prefix
2148        // (~40px in default UI font) and well inside the long linked
2149        // run, which extends ~250+px from there.
2150        let cx = para.x + 100.0;
2151        let cy = para.y + para.h * 0.5;
2152        core.pointer_moved(cx, cy);
2153        core.pointer_down(cx, cy, PointerButton::Primary);
2154        let events = core.pointer_up(cx, cy, PointerButton::Primary);
2155        let link = events
2156            .iter()
2157            .find(|e| e.kind == UiEventKind::LinkActivated)
2158            .expect("LinkActivated event");
2159        assert_eq!(link.key.as_deref(), Some(url));
2160    }
2161
2162    #[test]
2163    fn pointer_up_after_drag_off_link_does_not_activate() {
2164        let (mut core, para, _url) = lay_out_link_tree();
2165        let press_x = para.x + 100.0;
2166        let cy = para.y + para.h * 0.5;
2167        core.pointer_moved(press_x, cy);
2168        core.pointer_down(press_x, cy, PointerButton::Primary);
2169        // Release far below the paragraph — the user dragged off the
2170        // link before letting go, which native browsers treat as
2171        // cancel.
2172        let events = core.pointer_up(press_x, 180.0, PointerButton::Primary);
2173        let kinds: Vec<UiEventKind> = events.iter().map(|e| e.kind).collect();
2174        assert!(
2175            !kinds.contains(&UiEventKind::LinkActivated),
2176            "drag-off-link should cancel the link activation; got {kinds:?}",
2177        );
2178    }
2179
2180    #[test]
2181    fn pointer_moved_over_link_resolves_cursor_to_pointer_and_requests_redraw() {
2182        use crate::cursor::Cursor;
2183        let (mut core, para, _url) = lay_out_link_tree();
2184        let cx = para.x + 100.0;
2185        let cy = para.y + para.h * 0.5;
2186        // Pointer initially well outside the paragraph.
2187        let initial = core.pointer_moved(para.x - 50.0, cy);
2188        assert!(
2189            !initial.needs_redraw,
2190            "moving in empty space shouldn't request a redraw"
2191        );
2192        let tree = core.last_tree.as_ref().expect("tree").clone();
2193        assert_eq!(
2194            core.ui_state.cursor(&tree),
2195            Cursor::Default,
2196            "no link under pointer → default cursor"
2197        );
2198        // Move onto the link — needs_redraw flips so the host
2199        // re-resolves the cursor on the next frame.
2200        let onto = core.pointer_moved(cx, cy);
2201        assert!(
2202            onto.needs_redraw,
2203            "entering a link region should flag a redraw so the cursor refresh isn't stale"
2204        );
2205        assert_eq!(
2206            core.ui_state.cursor(&tree),
2207            Cursor::Pointer,
2208            "pointer over a link → Pointer cursor"
2209        );
2210        // Move back off — should flag a redraw again so the cursor
2211        // returns to Default.
2212        let off = core.pointer_moved(para.x - 50.0, cy);
2213        assert!(
2214            off.needs_redraw,
2215            "leaving a link region should flag a redraw"
2216        );
2217        assert_eq!(core.ui_state.cursor(&tree), Cursor::Default);
2218    }
2219
2220    #[test]
2221    fn pointer_up_on_unlinked_text_does_not_emit_link_activated() {
2222        let (mut core, para, _url) = lay_out_link_tree();
2223        // Click 1px in from the left edge — inside the "Visit "
2224        // prefix, before the linked run starts.
2225        let cx = para.x + 1.0;
2226        let cy = para.y + para.h * 0.5;
2227        core.pointer_moved(cx, cy);
2228        core.pointer_down(cx, cy, PointerButton::Primary);
2229        let events = core.pointer_up(cx, cy, PointerButton::Primary);
2230        let kinds: Vec<UiEventKind> = events.iter().map(|e| e.kind).collect();
2231        assert!(
2232            !kinds.contains(&UiEventKind::LinkActivated),
2233            "click on the unlinked prefix should not surface a link event; got {kinds:?}",
2234        );
2235    }
2236
2237    #[test]
2238    fn pointer_up_off_target_emits_only_pointer_up() {
2239        let mut core = lay_out_input_tree(false);
2240        let btn_rect = core.rect_of_key("btn").expect("btn rect");
2241        let cx = btn_rect.x + btn_rect.w * 0.5;
2242        let cy = btn_rect.y + btn_rect.h * 0.5;
2243        core.pointer_down(cx, cy, PointerButton::Primary);
2244        // Release off-target (well outside any keyed node).
2245        let events = core.pointer_up(180.0, 180.0, PointerButton::Primary);
2246        let kinds: Vec<UiEventKind> = events.iter().map(|e| e.kind).collect();
2247        assert_eq!(
2248            kinds,
2249            vec![UiEventKind::PointerUp],
2250            "drag-off-target should still surface PointerUp so widgets see drag-end"
2251        );
2252    }
2253
2254    #[test]
2255    fn pointer_moved_while_pressed_emits_drag() {
2256        let mut core = lay_out_input_tree(false);
2257        let btn_rect = core.rect_of_key("btn").expect("btn rect");
2258        let cx = btn_rect.x + btn_rect.w * 0.5;
2259        let cy = btn_rect.y + btn_rect.h * 0.5;
2260        core.pointer_down(cx, cy, PointerButton::Primary);
2261        let drag = core
2262            .pointer_moved(cx + 30.0, cy)
2263            .events
2264            .into_iter()
2265            .find(|e| e.kind == UiEventKind::Drag)
2266            .expect("drag while pressed");
2267        assert_eq!(drag.target.as_ref().map(|t| t.key.as_str()), Some("btn"));
2268        assert_eq!(drag.pointer, Some((cx + 30.0, cy)));
2269    }
2270
2271    #[test]
2272    fn toast_dismiss_click_removes_toast_and_suppresses_click_event() {
2273        use crate::toast::ToastSpec;
2274        use crate::tree::Size;
2275        // Build a fresh runner, queue a toast, prepare once so the
2276        // toast layer is laid out, then synthesize a click on its
2277        // dismiss button.
2278        let mut core = RunnerCore::new();
2279        core.ui_state
2280            .push_toast(ToastSpec::success("hi"), Instant::now());
2281        let toast_id = core.ui_state.toasts()[0].id;
2282
2283        // Build & lay out a tree with the toast layer appended.
2284        // Root is `stack(...)` (Axis::Overlay) so the synthesized
2285        // toast layer overlays rather than competing for flex space.
2286        let mut tree: El = crate::stack(std::iter::empty::<El>())
2287            .width(Size::Fill(1.0))
2288            .height(Size::Fill(1.0));
2289        crate::layout::assign_ids(&mut tree);
2290        let _ = crate::toast::synthesize_toasts(&mut tree, &mut core.ui_state, Instant::now());
2291        crate::layout::layout(
2292            &mut tree,
2293            &mut core.ui_state,
2294            Rect::new(0.0, 0.0, 800.0, 600.0),
2295        );
2296        core.ui_state.sync_focus_order(&tree);
2297        let mut t = PrepareTimings::default();
2298        core.snapshot(&tree, &mut t);
2299
2300        let dismiss_key = format!("toast-dismiss-{toast_id}");
2301        let dismiss_rect = core.rect_of_key(&dismiss_key).expect("dismiss button");
2302        let cx = dismiss_rect.x + dismiss_rect.w * 0.5;
2303        let cy = dismiss_rect.y + dismiss_rect.h * 0.5;
2304
2305        core.pointer_down(cx, cy, PointerButton::Primary);
2306        let events = core.pointer_up(cx, cy, PointerButton::Primary);
2307        let kinds: Vec<UiEventKind> = events.iter().map(|e| e.kind).collect();
2308        // PointerUp still fires (kept generic so drag-aware widgets
2309        // observe drag-end); Click is intercepted by the toast
2310        // bookkeeping.
2311        assert!(
2312            !kinds.contains(&UiEventKind::Click),
2313            "Click on toast-dismiss should not be surfaced: {kinds:?}",
2314        );
2315        assert!(
2316            core.ui_state.toasts().iter().all(|t| t.id != toast_id),
2317            "toast {toast_id} should be dropped after dismiss-click",
2318        );
2319    }
2320
2321    #[test]
2322    fn pointer_moved_without_press_emits_no_drag() {
2323        let mut core = lay_out_input_tree(false);
2324        let events = core.pointer_moved(50.0, 50.0).events;
2325        // No press → no Drag emission. Hover-transition events
2326        // (PointerEnter/Leave) may fire; just assert nothing in the
2327        // out vec carries the Drag kind.
2328        assert!(!events.iter().any(|e| e.kind == UiEventKind::Drag));
2329    }
2330
2331    #[test]
2332    fn spinner_in_tree_keeps_needs_redraw_set() {
2333        // stock::spinner reads frame.time, so the host must keep
2334        // calling prepare() even when no animation is in flight. Pin
2335        // the contract: a tree with no other motion still reports
2336        // needs_redraw=true when a spinner is present.
2337        use crate::widgets::spinner::spinner;
2338        let mut tree = crate::column([spinner()]);
2339        let mut core = RunnerCore::new();
2340        let mut t = PrepareTimings::default();
2341        let LayoutPrepared { needs_redraw, .. } = core.prepare_layout(
2342            &mut tree,
2343            Rect::new(0.0, 0.0, 200.0, 200.0),
2344            1.0,
2345            &mut t,
2346            RunnerCore::no_time_shaders,
2347        );
2348        assert!(
2349            needs_redraw,
2350            "tree with a spinner must request continuous redraw",
2351        );
2352
2353        // Same shape without a spinner — needs_redraw stays false once
2354        // any state envelopes settle, demonstrating the signal is
2355        // spinner-driven rather than always-on.
2356        let mut bare = crate::column([crate::widgets::text::text("idle")]);
2357        let mut core2 = RunnerCore::new();
2358        let mut t2 = PrepareTimings::default();
2359        let LayoutPrepared {
2360            needs_redraw: needs_redraw2,
2361            ..
2362        } = core2.prepare_layout(
2363            &mut bare,
2364            Rect::new(0.0, 0.0, 200.0, 200.0),
2365            1.0,
2366            &mut t2,
2367            RunnerCore::no_time_shaders,
2368        );
2369        assert!(
2370            !needs_redraw2,
2371            "tree without time-driven shaders should idle: got needs_redraw={needs_redraw2}",
2372        );
2373    }
2374
2375    #[test]
2376    fn custom_samples_time_shader_keeps_needs_redraw_set() {
2377        // Pin the generalization: a tree binding a *custom* shader
2378        // whose name appears in the host's `samples_time` set must
2379        // request continuous redraw the same way stock::spinner does.
2380        let mut tree = crate::column([crate::tree::El::new(crate::tree::Kind::Custom("anim"))
2381            .shader(crate::shader::ShaderBinding::custom("my_animated_glow"))
2382            .width(crate::tree::Size::Fixed(32.0))
2383            .height(crate::tree::Size::Fixed(32.0))]);
2384        let mut core = RunnerCore::new();
2385        let mut t = PrepareTimings::default();
2386
2387        let LayoutPrepared {
2388            needs_redraw: idle, ..
2389        } = core.prepare_layout(
2390            &mut tree,
2391            Rect::new(0.0, 0.0, 200.0, 200.0),
2392            1.0,
2393            &mut t,
2394            RunnerCore::no_time_shaders,
2395        );
2396        assert!(
2397            !idle,
2398            "without a samples_time registration the host should idle",
2399        );
2400
2401        let mut t2 = PrepareTimings::default();
2402        let LayoutPrepared {
2403            needs_redraw: animated,
2404            ..
2405        } = core.prepare_layout(
2406            &mut tree,
2407            Rect::new(0.0, 0.0, 200.0, 200.0),
2408            1.0,
2409            &mut t2,
2410            |handle| matches!(handle, ShaderHandle::Custom("my_animated_glow")),
2411        );
2412        assert!(
2413            animated,
2414            "custom shader registered as samples_time=true must request continuous redraw",
2415        );
2416    }
2417
2418    #[test]
2419    fn redraw_within_aggregates_to_minimum_visible_deadline() {
2420        use std::time::Duration;
2421        let mut tree = crate::column([
2422            // 16ms
2423            crate::widgets::text::text("a")
2424                .redraw_within(Duration::from_millis(16))
2425                .width(crate::tree::Size::Fixed(20.0))
2426                .height(crate::tree::Size::Fixed(20.0)),
2427            // 50ms — the slower request should NOT win against 16ms.
2428            crate::widgets::text::text("b")
2429                .redraw_within(Duration::from_millis(50))
2430                .width(crate::tree::Size::Fixed(20.0))
2431                .height(crate::tree::Size::Fixed(20.0)),
2432        ]);
2433        let mut core = RunnerCore::new();
2434        let mut t = PrepareTimings::default();
2435        let LayoutPrepared {
2436            needs_redraw,
2437            next_layout_redraw_in,
2438            ..
2439        } = core.prepare_layout(
2440            &mut tree,
2441            Rect::new(0.0, 0.0, 200.0, 200.0),
2442            1.0,
2443            &mut t,
2444            RunnerCore::no_time_shaders,
2445        );
2446        assert!(needs_redraw, "redraw_within must lift the legacy bool");
2447        assert_eq!(
2448            next_layout_redraw_in,
2449            Some(Duration::from_millis(16)),
2450            "tightest visible deadline wins, on the layout lane",
2451        );
2452    }
2453
2454    #[test]
2455    fn redraw_within_off_screen_widget_is_ignored() {
2456        use std::time::Duration;
2457        // Layout-rect-based visibility: place the animated widget below
2458        // the viewport via a tall preceding spacer in a hugging
2459        // column. The child's computed rect is at y≈150, which lies
2460        // outside a 0..100 viewport, so the visibility filter must
2461        // skip it and the host must idle.
2462        let mut tree = crate::column([
2463            crate::tree::spacer().height(crate::tree::Size::Fixed(150.0)),
2464            crate::widgets::text::text("offscreen")
2465                .redraw_within(Duration::from_millis(16))
2466                .width(crate::tree::Size::Fixed(10.0))
2467                .height(crate::tree::Size::Fixed(10.0)),
2468        ]);
2469        let mut core = RunnerCore::new();
2470        let mut t = PrepareTimings::default();
2471        let LayoutPrepared {
2472            next_layout_redraw_in,
2473            ..
2474        } = core.prepare_layout(
2475            &mut tree,
2476            Rect::new(0.0, 0.0, 100.0, 100.0),
2477            1.0,
2478            &mut t,
2479            RunnerCore::no_time_shaders,
2480        );
2481        assert_eq!(
2482            next_layout_redraw_in, None,
2483            "off-screen redraw_within must not contribute to the aggregate",
2484        );
2485    }
2486
2487    #[test]
2488    fn redraw_within_clipped_out_widget_is_ignored() {
2489        use std::time::Duration;
2490
2491        let clipped = crate::column([crate::widgets::text::text("clipped")
2492            .redraw_within(Duration::from_millis(16))
2493            .width(crate::tree::Size::Fixed(10.0))
2494            .height(crate::tree::Size::Fixed(10.0))])
2495        .clip()
2496        .width(crate::tree::Size::Fixed(100.0))
2497        .height(crate::tree::Size::Fixed(20.0))
2498        .layout(|ctx| {
2499            vec![Rect::new(
2500                ctx.container.x,
2501                ctx.container.y + 30.0,
2502                10.0,
2503                10.0,
2504            )]
2505        });
2506        let mut tree = crate::column([clipped]);
2507
2508        let mut core = RunnerCore::new();
2509        let mut t = PrepareTimings::default();
2510        let LayoutPrepared {
2511            next_layout_redraw_in,
2512            ..
2513        } = core.prepare_layout(
2514            &mut tree,
2515            Rect::new(0.0, 0.0, 100.0, 100.0),
2516            1.0,
2517            &mut t,
2518            RunnerCore::no_time_shaders,
2519        );
2520        assert_eq!(
2521            next_layout_redraw_in, None,
2522            "redraw_within inside an inherited clip but outside the clip rect must not contribute",
2523        );
2524    }
2525
2526    #[test]
2527    fn pointer_moved_within_same_hovered_node_does_not_request_redraw() {
2528        // Wayland delivers CursorMoved at very high frequency while
2529        // the cursor sits over the surface. Hosts gate request_redraw
2530        // on `needs_redraw`; this test pins the contract so we don't
2531        // regress to the unconditional-redraw behaviour that pegged
2532        // settings_modal at 100% CPU under cursor activity.
2533        let mut core = lay_out_input_tree(false);
2534        let btn = core.rect_of_key("btn").expect("btn rect");
2535        let (cx, cy) = (btn.x + btn.w * 0.5, btn.y + btn.h * 0.5);
2536
2537        // First move enters the button — hover identity changes, so a
2538        // PointerEnter fires (no preceding Leave because no prior
2539        // hover target).
2540        let first = core.pointer_moved(cx, cy);
2541        assert_eq!(first.events.len(), 1);
2542        assert_eq!(first.events[0].kind, UiEventKind::PointerEnter);
2543        assert_eq!(first.events[0].key.as_deref(), Some("btn"));
2544        assert!(
2545            first.needs_redraw,
2546            "entering a focusable should warrant a redraw",
2547        );
2548
2549        // Same node, slightly different coords. Hover identity is
2550        // unchanged, no drag is active — must not redraw or emit any
2551        // events.
2552        let second = core.pointer_moved(cx + 1.0, cy);
2553        assert!(second.events.is_empty());
2554        assert!(
2555            !second.needs_redraw,
2556            "identical hover, no drag → host should idle",
2557        );
2558
2559        // Moving off the button into empty space changes hover to
2560        // None — that's a visible transition (envelope ramps down)
2561        // and a PointerLeave fires.
2562        let off = core.pointer_moved(0.0, 0.0);
2563        assert_eq!(off.events.len(), 1);
2564        assert_eq!(off.events[0].kind, UiEventKind::PointerLeave);
2565        assert_eq!(off.events[0].key.as_deref(), Some("btn"));
2566        assert!(
2567            off.needs_redraw,
2568            "leaving a hovered node still warrants a redraw",
2569        );
2570    }
2571
2572    #[test]
2573    fn pointer_moved_between_keyed_targets_emits_leave_then_enter() {
2574        // Cursor crossing from one keyed node to another emits a paired
2575        // PointerLeave (old target) followed by PointerEnter (new
2576        // target). Apps can observe the cleared state before the new
2577        // one — important for things like cancelling a hover-intent
2578        // prefetch on the old target before kicking off one for the
2579        // new.
2580        let mut core = lay_out_input_tree(false);
2581        let btn = core.rect_of_key("btn").expect("btn rect");
2582        let ti = core.rect_of_key("ti").expect("ti rect");
2583
2584        // Enter btn first.
2585        let _ = core.pointer_moved(btn.x + 4.0, btn.y + 4.0);
2586
2587        // Cross to ti.
2588        let cross = core.pointer_moved(ti.x + 4.0, ti.y + 4.0);
2589        let kinds: Vec<UiEventKind> = cross.events.iter().map(|e| e.kind).collect();
2590        assert_eq!(
2591            kinds,
2592            vec![UiEventKind::PointerLeave, UiEventKind::PointerEnter],
2593            "paired Leave-then-Enter on cross-target hover transition",
2594        );
2595        assert_eq!(cross.events[0].key.as_deref(), Some("btn"));
2596        assert_eq!(cross.events[1].key.as_deref(), Some("ti"));
2597        assert!(cross.needs_redraw);
2598    }
2599
2600    #[test]
2601    fn pointer_left_emits_leave_for_prior_hover() {
2602        let mut core = lay_out_input_tree(false);
2603        let btn = core.rect_of_key("btn").expect("btn rect");
2604        let _ = core.pointer_moved(btn.x + 4.0, btn.y + 4.0);
2605
2606        let events = core.pointer_left();
2607        assert_eq!(events.len(), 1);
2608        assert_eq!(events[0].kind, UiEventKind::PointerLeave);
2609        assert_eq!(events[0].key.as_deref(), Some("btn"));
2610    }
2611
2612    #[test]
2613    fn pointer_left_with_no_prior_hover_emits_nothing() {
2614        let mut core = lay_out_input_tree(false);
2615        // No prior pointer_moved into a keyed target — pointer_left
2616        // should be a no-op event-wise (state still gets cleared).
2617        let events = core.pointer_left();
2618        assert!(events.is_empty());
2619    }
2620
2621    #[test]
2622    fn ui_state_hovered_key_returns_leaf_key() {
2623        let mut core = lay_out_input_tree(false);
2624        assert_eq!(core.ui_state().hovered_key(), None);
2625
2626        let btn = core.rect_of_key("btn").expect("btn rect");
2627        core.pointer_moved(btn.x + 4.0, btn.y + 4.0);
2628        assert_eq!(core.ui_state().hovered_key(), Some("btn"));
2629
2630        // Off-target → None again.
2631        core.pointer_moved(0.0, 0.0);
2632        assert_eq!(core.ui_state().hovered_key(), None);
2633    }
2634
2635    #[test]
2636    fn ui_state_is_hovering_within_walks_subtree() {
2637        // Card (keyed, focusable) wraps an inner icon-button (keyed).
2638        // is_hovering_within("card") should be true whenever the
2639        // cursor is on the card body OR on the inner button.
2640        use crate::tree::*;
2641        let mut tree = crate::column([crate::stack([
2642            crate::widgets::button::button("Inner").key("inner_btn")
2643        ])
2644        .key("card")
2645        .focusable()
2646        .width(Size::Fixed(120.0))
2647        .height(Size::Fixed(60.0))])
2648        .padding(20.0);
2649        let mut core = RunnerCore::new();
2650        crate::layout::layout(
2651            &mut tree,
2652            &mut core.ui_state,
2653            Rect::new(0.0, 0.0, 400.0, 200.0),
2654        );
2655        core.ui_state.sync_focus_order(&tree);
2656        let mut t = PrepareTimings::default();
2657        core.snapshot(&tree, &mut t);
2658
2659        // Pre-hover: false everywhere.
2660        assert!(!core.ui_state().is_hovering_within("card"));
2661        assert!(!core.ui_state().is_hovering_within("inner_btn"));
2662
2663        // Hover the inner button. Both the leaf and its ancestor card
2664        // should report subtree-hover true.
2665        let inner = core.rect_of_key("inner_btn").expect("inner rect");
2666        core.pointer_moved(inner.x + 4.0, inner.y + 4.0);
2667        assert!(core.ui_state().is_hovering_within("card"));
2668        assert!(core.ui_state().is_hovering_within("inner_btn"));
2669
2670        // Unrelated / unknown keys read as false.
2671        assert!(!core.ui_state().is_hovering_within("not_a_key"));
2672
2673        // Off the tree — both flip back to false.
2674        core.pointer_moved(0.0, 0.0);
2675        assert!(!core.ui_state().is_hovering_within("card"));
2676        assert!(!core.ui_state().is_hovering_within("inner_btn"));
2677    }
2678
2679    #[test]
2680    fn hover_driven_scale_via_is_hovering_within_plus_animate() {
2681        // gh#10. The recipe that replaces a declarative
2682        // hover_translate / hover_scale / hover_tint API: the build
2683        // closure reads `cx.is_hovering_within(key)` and writes the
2684        // target prop value; `.animate(...)` eases between build
2685        // values across frames. End-to-end check that hover transition
2686        // → eased scale settle.
2687        use crate::Theme;
2688        use crate::anim::Timing;
2689        use crate::tree::*;
2690
2691        // Helper that mirrors the documented recipe — closure over a
2692        // hover boolean so the test can drive the rebuild deterministically.
2693        let build_card = |hovering: bool| -> El {
2694            let scale = if hovering { 1.05 } else { 1.0 };
2695            crate::column([crate::stack(
2696                [crate::widgets::button::button("Inner").key("inner_btn")],
2697            )
2698            .key("card")
2699            .focusable()
2700            .scale(scale)
2701            .animate(Timing::SPRING_QUICK)
2702            .width(Size::Fixed(120.0))
2703            .height(Size::Fixed(60.0))])
2704            .padding(20.0)
2705        };
2706
2707        let mut core = RunnerCore::new();
2708        // Settled mode so the animate tick snaps each retarget to its
2709        // value — lets us verify final-state values without timing.
2710        core.ui_state
2711            .set_animation_mode(crate::state::AnimationMode::Settled);
2712
2713        // Frame 1: not hovering → app builds with scale=1.0.
2714        let theme = Theme::default();
2715        let cx_pre = crate::BuildCx::new(&theme).with_ui_state(core.ui_state());
2716        assert!(!cx_pre.is_hovering_within("card"));
2717        let mut tree = build_card(cx_pre.is_hovering_within("card"));
2718        crate::layout::layout(
2719            &mut tree,
2720            &mut core.ui_state,
2721            Rect::new(0.0, 0.0, 400.0, 200.0),
2722        );
2723        core.ui_state.sync_focus_order(&tree);
2724        let mut t = PrepareTimings::default();
2725        core.snapshot(&tree, &mut t);
2726        core.ui_state
2727            .tick_visual_animations(&mut tree, web_time::Instant::now());
2728        let card_at_rest = tree.children[0].clone();
2729        assert!((card_at_rest.scale - 1.0).abs() < 1e-3);
2730
2731        // Hover the card. is_hovering_within flips true.
2732        let card_rect = core.rect_of_key("card").expect("card rect");
2733        core.pointer_moved(card_rect.x + 4.0, card_rect.y + 4.0);
2734
2735        // Frame 2: app sees hovering=true, rebuilds with scale=1.05.
2736        // Settled animate tick snaps scale to the new target.
2737        let cx_hot = crate::BuildCx::new(&theme).with_ui_state(core.ui_state());
2738        assert!(cx_hot.is_hovering_within("card"));
2739        let mut tree = build_card(cx_hot.is_hovering_within("card"));
2740        crate::layout::layout(
2741            &mut tree,
2742            &mut core.ui_state,
2743            Rect::new(0.0, 0.0, 400.0, 200.0),
2744        );
2745        core.ui_state.sync_focus_order(&tree);
2746        core.snapshot(&tree, &mut t);
2747        core.ui_state
2748            .tick_visual_animations(&mut tree, web_time::Instant::now());
2749        let card_hot = tree.children[0].clone();
2750        assert!(
2751            (card_hot.scale - 1.05).abs() < 1e-3,
2752            "hover should drive card scale to 1.05 via animate; got {}",
2753            card_hot.scale,
2754        );
2755
2756        // Unhover → app rebuilds with scale=1.0; settled tick snaps back.
2757        core.pointer_moved(0.0, 0.0);
2758        let cx_cold = crate::BuildCx::new(&theme).with_ui_state(core.ui_state());
2759        assert!(!cx_cold.is_hovering_within("card"));
2760        let mut tree = build_card(cx_cold.is_hovering_within("card"));
2761        crate::layout::layout(
2762            &mut tree,
2763            &mut core.ui_state,
2764            Rect::new(0.0, 0.0, 400.0, 200.0),
2765        );
2766        core.ui_state.sync_focus_order(&tree);
2767        core.snapshot(&tree, &mut t);
2768        core.ui_state
2769            .tick_visual_animations(&mut tree, web_time::Instant::now());
2770        let card_after = tree.children[0].clone();
2771        assert!((card_after.scale - 1.0).abs() < 1e-3);
2772    }
2773
2774    #[test]
2775    fn file_dropped_routes_to_keyed_leaf_at_pointer() {
2776        let mut core = lay_out_input_tree(false);
2777        let btn = core.rect_of_key("btn").expect("btn rect");
2778        let path = std::path::PathBuf::from("/tmp/screenshot.png");
2779        let events = core.file_dropped(path.clone(), btn.x + 4.0, btn.y + 4.0);
2780        assert_eq!(events.len(), 1);
2781        let event = &events[0];
2782        assert_eq!(event.kind, UiEventKind::FileDropped);
2783        assert_eq!(event.key.as_deref(), Some("btn"));
2784        assert_eq!(event.path.as_deref(), Some(path.as_path()));
2785        assert_eq!(event.pointer, Some((btn.x + 4.0, btn.y + 4.0)));
2786    }
2787
2788    #[test]
2789    fn file_dropped_outside_keyed_surface_emits_window_level_event() {
2790        let mut core = lay_out_input_tree(false);
2791        // Drop in the padding band — outside any keyed leaf.
2792        let path = std::path::PathBuf::from("/tmp/screenshot.png");
2793        let events = core.file_dropped(path.clone(), 1.0, 1.0);
2794        assert_eq!(events.len(), 1);
2795        let event = &events[0];
2796        assert_eq!(event.kind, UiEventKind::FileDropped);
2797        assert!(
2798            event.target.is_none(),
2799            "drop outside any keyed surface routes window-level",
2800        );
2801        assert!(event.key.is_none());
2802        // Path still flows through so a global drop sink can pick it up.
2803        assert_eq!(event.path.as_deref(), Some(path.as_path()));
2804    }
2805
2806    #[test]
2807    fn file_hovered_then_cancelled_pair() {
2808        let mut core = lay_out_input_tree(false);
2809        let btn = core.rect_of_key("btn").expect("btn rect");
2810        let path = std::path::PathBuf::from("/tmp/a.png");
2811
2812        let hover = core.file_hovered(path.clone(), btn.x + 4.0, btn.y + 4.0);
2813        assert_eq!(hover.len(), 1);
2814        assert_eq!(hover[0].kind, UiEventKind::FileHovered);
2815        assert_eq!(hover[0].key.as_deref(), Some("btn"));
2816        assert_eq!(hover[0].path.as_deref(), Some(path.as_path()));
2817
2818        let cancel = core.file_hover_cancelled();
2819        assert_eq!(cancel.len(), 1);
2820        assert_eq!(cancel[0].kind, UiEventKind::FileHoverCancelled);
2821        assert!(cancel[0].target.is_none());
2822        assert!(cancel[0].path.is_none());
2823    }
2824
2825    #[test]
2826    fn build_cx_hover_accessors_default_off_without_state() {
2827        use crate::Theme;
2828        let theme = Theme::default();
2829        let cx = crate::BuildCx::new(&theme);
2830        assert_eq!(cx.hovered_key(), None);
2831        assert!(!cx.is_hovering_within("anything"));
2832    }
2833
2834    #[test]
2835    fn build_cx_hover_accessors_delegate_when_state_attached() {
2836        use crate::Theme;
2837        let mut core = lay_out_input_tree(false);
2838        let btn = core.rect_of_key("btn").expect("btn rect");
2839        core.pointer_moved(btn.x + 4.0, btn.y + 4.0);
2840
2841        let theme = Theme::default();
2842        let cx = crate::BuildCx::new(&theme).with_ui_state(core.ui_state());
2843        assert_eq!(cx.hovered_key(), Some("btn"));
2844        assert!(cx.is_hovering_within("btn"));
2845        assert!(!cx.is_hovering_within("ti"));
2846    }
2847
2848    fn lay_out_paragraph_tree() -> RunnerCore {
2849        use crate::tree::*;
2850        let mut tree = crate::column([
2851            crate::widgets::text::text("First paragraph of text.")
2852                .key("p1")
2853                .selectable(),
2854            crate::widgets::text::text("Second paragraph of text.")
2855                .key("p2")
2856                .selectable(),
2857        ])
2858        .padding(20.0);
2859        let mut core = RunnerCore::new();
2860        crate::layout::layout(
2861            &mut tree,
2862            &mut core.ui_state,
2863            Rect::new(0.0, 0.0, 400.0, 300.0),
2864        );
2865        core.ui_state.sync_focus_order(&tree);
2866        core.ui_state.sync_selection_order(&tree);
2867        let mut t = PrepareTimings::default();
2868        core.snapshot(&tree, &mut t);
2869        core
2870    }
2871
2872    #[test]
2873    fn pointer_down_on_selectable_text_emits_selection_changed() {
2874        let mut core = lay_out_paragraph_tree();
2875        let p1 = core.rect_of_key("p1").expect("p1 rect");
2876        let cx = p1.x + 4.0;
2877        let cy = p1.y + p1.h * 0.5;
2878        let events = core.pointer_down(cx, cy, PointerButton::Primary);
2879        let sel_event = events
2880            .iter()
2881            .find(|e| e.kind == UiEventKind::SelectionChanged)
2882            .expect("SelectionChanged emitted");
2883        let new_sel = sel_event
2884            .selection
2885            .as_ref()
2886            .expect("SelectionChanged carries a selection");
2887        let range = new_sel.range.as_ref().expect("collapsed selection at hit");
2888        assert_eq!(range.anchor.key, "p1");
2889        assert_eq!(range.head.key, "p1");
2890        assert_eq!(range.anchor.byte, range.head.byte);
2891        assert!(core.ui_state.selection.drag.is_some());
2892    }
2893
2894    #[test]
2895    fn pointer_drag_on_selectable_text_extends_head() {
2896        let mut core = lay_out_paragraph_tree();
2897        let p1 = core.rect_of_key("p1").expect("p1 rect");
2898        let cx = p1.x + 4.0;
2899        let cy = p1.y + p1.h * 0.5;
2900        core.pointer_moved(cx, cy);
2901        core.pointer_down(cx, cy, PointerButton::Primary);
2902
2903        // Drag to the right inside p1.
2904        let events = core.pointer_moved(p1.x + p1.w - 10.0, cy).events;
2905        let sel_event = events
2906            .iter()
2907            .find(|e| e.kind == UiEventKind::SelectionChanged)
2908            .expect("Drag emits SelectionChanged");
2909        let new_sel = sel_event.selection.as_ref().unwrap();
2910        let range = new_sel.range.as_ref().unwrap();
2911        assert_eq!(range.anchor.key, "p1");
2912        assert_eq!(range.head.key, "p1");
2913        assert!(
2914            range.head.byte > range.anchor.byte,
2915            "head should advance past anchor (anchor={}, head={})",
2916            range.anchor.byte,
2917            range.head.byte
2918        );
2919    }
2920
2921    #[test]
2922    fn pointer_up_clears_drag_but_keeps_selection() {
2923        let mut core = lay_out_paragraph_tree();
2924        let p1 = core.rect_of_key("p1").expect("p1 rect");
2925        let cx = p1.x + 4.0;
2926        let cy = p1.y + p1.h * 0.5;
2927        core.pointer_down(cx, cy, PointerButton::Primary);
2928        core.pointer_moved(p1.x + p1.w - 10.0, cy);
2929        let _ = core.pointer_up(p1.x + p1.w - 10.0, cy, PointerButton::Primary);
2930        assert!(
2931            core.ui_state.selection.drag.is_none(),
2932            "drag flag should clear on pointer_up"
2933        );
2934        assert!(
2935            !core.ui_state.current_selection.is_empty(),
2936            "selection itself should persist after pointer_up"
2937        );
2938    }
2939
2940    #[test]
2941    fn drag_past_a_leaf_bottom_keeps_head_in_that_leaf_not_anchor() {
2942        // Regression: a previous helper (`byte_in_anchor_leaf`)
2943        // projected any out-of-leaf pointer back onto the anchor leaf.
2944        // That meant moving the cursor below p2's bottom edge while
2945        // dragging from p1 caused the head to snap home to p1 — the
2946        // selection band visibly shrank back instead of extending.
2947        let mut core = lay_out_paragraph_tree();
2948        let p1 = core.rect_of_key("p1").expect("p1 rect");
2949        let p2 = core.rect_of_key("p2").expect("p2 rect");
2950        // Anchor in p1.
2951        core.pointer_down(p1.x + 4.0, p1.y + p1.h * 0.5, PointerButton::Primary);
2952        // Drag into p2 first — head migrates.
2953        core.pointer_moved(p2.x + 8.0, p2.y + p2.h * 0.5);
2954        // Now move WELL BELOW p2's rect (well below all selectables).
2955        // Head should remain in p2 (last leaf in this fixture is p2).
2956        let events = core.pointer_moved(p2.x + 8.0, p2.y + p2.h + 200.0).events;
2957        let sel = events
2958            .iter()
2959            .find(|e| e.kind == UiEventKind::SelectionChanged)
2960            .map(|e| e.selection.as_ref().unwrap().clone())
2961            // No SelectionChanged emitted means the value didn't move
2962            // — read it back from the live UiState directly.
2963            .unwrap_or_else(|| core.ui_state.current_selection.clone());
2964        let r = sel.range.as_ref().expect("selection still active");
2965        assert_eq!(r.anchor.key, "p1", "anchor unchanged");
2966        assert_eq!(
2967            r.head.key, "p2",
2968            "head must stay in p2 even when pointer is below p2's rect"
2969        );
2970    }
2971
2972    #[test]
2973    fn drag_into_a_sibling_selectable_extends_head_into_that_leaf() {
2974        let mut core = lay_out_paragraph_tree();
2975        let p1 = core.rect_of_key("p1").expect("p1 rect");
2976        let p2 = core.rect_of_key("p2").expect("p2 rect");
2977        // Anchor at the start of p1.
2978        core.pointer_down(p1.x + 4.0, p1.y + p1.h * 0.5, PointerButton::Primary);
2979        // Drag down into p2.
2980        let events = core.pointer_moved(p2.x + 8.0, p2.y + p2.h * 0.5).events;
2981        let sel_event = events
2982            .iter()
2983            .find(|e| e.kind == UiEventKind::SelectionChanged)
2984            .expect("Drag emits SelectionChanged");
2985        let new_sel = sel_event.selection.as_ref().unwrap();
2986        let range = new_sel.range.as_ref().unwrap();
2987        assert_eq!(range.anchor.key, "p1", "anchor stays in p1");
2988        assert_eq!(range.head.key, "p2", "head migrates into p2");
2989    }
2990
2991    #[test]
2992    fn pointer_down_on_focusable_owning_selection_does_not_clear_it() {
2993        // Regression: clicking inside a text_input (focusable but not
2994        // a `.selectable()` leaf) used to fire SelectionChanged-empty
2995        // because selection_point_at missed and the runtime's
2996        // clear-fallback didn't notice the click landed on the same
2997        // widget that owned the active selection. The input's
2998        // PointerDown set the caret, then the empty SelectionChanged
2999        // collapsed it back to byte 0 every other click.
3000        let mut core = lay_out_input_tree(true);
3001        // Seed a selection in the input's key — this is what the
3002        // text_input would have written back via apply_event_with.
3003        core.set_selection(crate::selection::Selection::caret("ti", 3));
3004        let ti = core.rect_of_key("ti").expect("ti rect");
3005        let cx = ti.x + ti.w * 0.5;
3006        let cy = ti.y + ti.h * 0.5;
3007
3008        let events = core.pointer_down(cx, cy, PointerButton::Primary);
3009        let cleared = events.iter().find(|e| {
3010            e.kind == UiEventKind::SelectionChanged
3011                && e.selection.as_ref().map(|s| s.is_empty()).unwrap_or(false)
3012        });
3013        assert!(
3014            cleared.is_none(),
3015            "click on the selection-owning input must not emit a clearing SelectionChanged"
3016        );
3017        assert_eq!(
3018            core.ui_state.current_selection,
3019            crate::selection::Selection::caret("ti", 3),
3020            "runtime mirror is preserved when the click owns the selection"
3021        );
3022    }
3023
3024    #[test]
3025    fn pointer_down_into_a_different_capture_keys_widget_does_not_clear_first() {
3026        // Regression: clicking into text_input A while the selection
3027        // lives in text_input B used to trigger the runtime's
3028        // clear-fallback. The empty SelectionChanged arrived after
3029        // A's PointerDown (which had set anchor = head = click pos),
3030        // collapsing the app's selection to default. The next Drag
3031        // event then read `selection.within(A) = None`, defaulted
3032        // anchor to 0, and only advanced head — so dragging into A
3033        // started the selection from byte 0 of the text instead of
3034        // the click position.
3035        let mut core = lay_out_input_tree(true);
3036        // Active selection lives in some other key, not "ti".
3037        core.set_selection(crate::selection::Selection::caret("other", 4));
3038        let ti = core.rect_of_key("ti").expect("ti rect");
3039        let cx = ti.x + ti.w * 0.5;
3040        let cy = ti.y + ti.h * 0.5;
3041
3042        let events = core.pointer_down(cx, cy, PointerButton::Primary);
3043        let cleared = events.iter().any(|e| {
3044            e.kind == UiEventKind::SelectionChanged
3045                && e.selection.as_ref().map(|s| s.is_empty()).unwrap_or(false)
3046        });
3047        assert!(
3048            !cleared,
3049            "click on a different capture_keys widget must not race-clear the selection"
3050        );
3051    }
3052
3053    #[test]
3054    fn pointer_down_on_non_selectable_clears_existing_selection() {
3055        let mut core = lay_out_paragraph_tree();
3056        let p1 = core.rect_of_key("p1").expect("p1 rect");
3057        let cy = p1.y + p1.h * 0.5;
3058        // Establish a selection in p1.
3059        core.pointer_down(p1.x + 4.0, cy, PointerButton::Primary);
3060        core.pointer_up(p1.x + 4.0, cy, PointerButton::Primary);
3061        assert!(!core.ui_state.current_selection.is_empty());
3062
3063        // Press in empty space (no selectable, no focusable).
3064        let events = core.pointer_down(2.0, 2.0, PointerButton::Primary);
3065        let cleared = events
3066            .iter()
3067            .find(|e| e.kind == UiEventKind::SelectionChanged)
3068            .expect("clearing emits SelectionChanged");
3069        let new_sel = cleared.selection.as_ref().unwrap();
3070        assert!(new_sel.is_empty(), "new selection should be empty");
3071        assert!(core.ui_state.current_selection.is_empty());
3072    }
3073
3074    #[test]
3075    fn key_down_bumps_caret_activity_when_focused_widget_captures_keys() {
3076        // Showcase-style scenario: the app doesn't propagate its
3077        // Selection back via App::selection(), so set_selection always
3078        // sees the default-empty value and never bumps. The runtime
3079        // bump path catches arrow-key navigation directly.
3080        let mut core = lay_out_input_tree(true);
3081        let target = core
3082            .ui_state
3083            .focus
3084            .order
3085            .iter()
3086            .find(|t| t.key == "ti")
3087            .cloned();
3088        core.ui_state.set_focus(target); // focus moves → first bump
3089        let after_focus = core.ui_state.caret.activity_at.expect("focus bump");
3090
3091        std::thread::sleep(std::time::Duration::from_millis(2));
3092        let _ = core.key_down(UiKey::ArrowRight, KeyModifiers::default(), false);
3093        let after_arrow = core
3094            .ui_state
3095            .caret
3096            .activity_at
3097            .expect("arrow key bumps even without app-side selection");
3098        assert!(
3099            after_arrow > after_focus,
3100            "ArrowRight to a capture_keys focused widget bumps caret activity"
3101        );
3102    }
3103
3104    #[test]
3105    fn text_input_bumps_caret_activity_when_focused() {
3106        let mut core = lay_out_input_tree(true);
3107        let target = core
3108            .ui_state
3109            .focus
3110            .order
3111            .iter()
3112            .find(|t| t.key == "ti")
3113            .cloned();
3114        core.ui_state.set_focus(target);
3115        let after_focus = core.ui_state.caret.activity_at.unwrap();
3116
3117        std::thread::sleep(std::time::Duration::from_millis(2));
3118        let _ = core.text_input("a".into());
3119        let after_text = core.ui_state.caret.activity_at.unwrap();
3120        assert!(
3121            after_text > after_focus,
3122            "TextInput to focused widget bumps caret activity"
3123        );
3124    }
3125
3126    #[test]
3127    fn pointer_down_inside_focused_input_bumps_caret_activity() {
3128        // Clicking again inside an already-focused capture_keys widget
3129        // doesn't change the focus target, so set_focus is a no-op
3130        // for activity. The runtime catches this so click-to-move-
3131        // caret resets the blink.
3132        let mut core = lay_out_input_tree(true);
3133        let ti = core.rect_of_key("ti").expect("ti rect");
3134        let cx = ti.x + ti.w * 0.5;
3135        let cy = ti.y + ti.h * 0.5;
3136
3137        // First click → focus moves → bump.
3138        core.pointer_down(cx, cy, PointerButton::Primary);
3139        let _ = core.pointer_up(cx, cy, PointerButton::Primary);
3140        let after_first = core.ui_state.caret.activity_at.unwrap();
3141
3142        // Second click on the same input → focus doesn't move, but
3143        // it's still caret-relevant activity.
3144        std::thread::sleep(std::time::Duration::from_millis(2));
3145        core.pointer_down(cx + 1.0, cy, PointerButton::Primary);
3146        let after_second = core
3147            .ui_state
3148            .caret
3149            .activity_at
3150            .expect("second click bumps too");
3151        assert!(
3152            after_second > after_first,
3153            "click within already-focused capture_keys widget still bumps"
3154        );
3155    }
3156
3157    #[test]
3158    fn arrow_key_through_apply_event_mutates_selection_and_bumps_on_set() {
3159        // End-to-end check that the path used by the text_input
3160        // example does in fact differ-then-bump on each arrow-key
3161        // press. If this regresses, the caret won't reset its blink
3162        // when the user moves the cursor — exactly what the polish
3163        // pass is meant to fix.
3164        use crate::widgets::text_input;
3165        let mut sel = crate::selection::Selection::caret("ti", 2);
3166        let mut value = String::from("hello");
3167
3168        let mut core = RunnerCore::new();
3169        // Seed the runtime mirror so the first set_selection below
3170        // doesn't bump from "default → caret(2)".
3171        core.set_selection(sel.clone());
3172        let baseline = core.ui_state.caret.activity_at;
3173
3174        // Build a synthetic ArrowRight KeyDown for the input's key.
3175        let arrow_right = UiEvent {
3176            key: Some("ti".into()),
3177            target: None,
3178            pointer: None,
3179            key_press: Some(crate::event::KeyPress {
3180                key: UiKey::ArrowRight,
3181                modifiers: KeyModifiers::default(),
3182                repeat: false,
3183            }),
3184            text: None,
3185            selection: None,
3186            modifiers: KeyModifiers::default(),
3187            click_count: 0,
3188            path: None,
3189            kind: UiEventKind::KeyDown,
3190        };
3191
3192        // 1. App's on_event would call into this path:
3193        let mutated = text_input::apply_event(&mut value, &mut sel, "ti", &arrow_right);
3194        assert!(mutated, "ArrowRight should mutate selection");
3195        assert_eq!(
3196            sel.within("ti").unwrap().head,
3197            3,
3198            "head moved one char right (h-e-l-l-o, byte 2 → 3)"
3199        );
3200
3201        // 2. Next frame's set_selection sees the new value → bump.
3202        std::thread::sleep(std::time::Duration::from_millis(2));
3203        core.set_selection(sel);
3204        let after = core.ui_state.caret.activity_at.unwrap();
3205        // If a baseline existed, the new bump must be later. Either
3206        // way the activity is now Some, which the .unwrap() above
3207        // already enforced.
3208        if let Some(b) = baseline {
3209            assert!(after > b, "arrow-key flow should bump activity");
3210        }
3211    }
3212
3213    #[test]
3214    fn set_selection_bumps_caret_activity_only_when_value_changes() {
3215        let mut core = lay_out_paragraph_tree();
3216        // First call with the default selection — no bump (mirror is
3217        // already default-empty).
3218        core.set_selection(crate::selection::Selection::default());
3219        assert!(
3220            core.ui_state.caret.activity_at.is_none(),
3221            "no-op set_selection should not bump activity"
3222        );
3223
3224        // Move the selection to a real range — bump.
3225        let sel_a = crate::selection::Selection::caret("p1", 3);
3226        core.set_selection(sel_a.clone());
3227        let bumped_at = core
3228            .ui_state
3229            .caret
3230            .activity_at
3231            .expect("first real selection bumps");
3232
3233        // Same selection again — must NOT bump (else every frame
3234        // re-bumps and the caret never blinks).
3235        core.set_selection(sel_a.clone());
3236        assert_eq!(
3237            core.ui_state.caret.activity_at,
3238            Some(bumped_at),
3239            "set_selection with same value is a no-op"
3240        );
3241
3242        // Caret at a different byte (simulating arrow-key motion) →
3243        // bump again.
3244        std::thread::sleep(std::time::Duration::from_millis(2));
3245        let sel_b = crate::selection::Selection::caret("p1", 7);
3246        core.set_selection(sel_b);
3247        let new_bump = core.ui_state.caret.activity_at.expect("second bump");
3248        assert!(
3249            new_bump > bumped_at,
3250            "moving the caret bumps activity again",
3251        );
3252    }
3253
3254    #[test]
3255    fn escape_clears_active_selection_and_emits_selection_changed() {
3256        let mut core = lay_out_paragraph_tree();
3257        let p1 = core.rect_of_key("p1").expect("p1 rect");
3258        let cy = p1.y + p1.h * 0.5;
3259        // Drag-select inside p1 to establish a non-empty selection.
3260        core.pointer_down(p1.x + 4.0, cy, PointerButton::Primary);
3261        core.pointer_moved(p1.x + p1.w - 10.0, cy);
3262        core.pointer_up(p1.x + p1.w - 10.0, cy, PointerButton::Primary);
3263        assert!(!core.ui_state.current_selection.is_empty());
3264
3265        let events = core.key_down(UiKey::Escape, KeyModifiers::default(), false);
3266        let kinds: Vec<UiEventKind> = events.iter().map(|e| e.kind).collect();
3267        assert_eq!(
3268            kinds,
3269            vec![UiEventKind::Escape, UiEventKind::SelectionChanged],
3270            "Esc emits Escape (for popover dismiss) AND SelectionChanged"
3271        );
3272        let cleared = events
3273            .iter()
3274            .find(|e| e.kind == UiEventKind::SelectionChanged)
3275            .unwrap();
3276        assert!(cleared.selection.as_ref().unwrap().is_empty());
3277        assert!(core.ui_state.current_selection.is_empty());
3278    }
3279
3280    #[test]
3281    fn consecutive_clicks_on_same_target_extend_count() {
3282        let mut core = lay_out_input_tree(false);
3283        let btn = core.rect_of_key("btn").expect("btn rect");
3284        let cx = btn.x + btn.w * 0.5;
3285        let cy = btn.y + btn.h * 0.5;
3286
3287        // First press: count = 1.
3288        let down1 = core.pointer_down(cx, cy, PointerButton::Primary);
3289        let pd1 = down1
3290            .iter()
3291            .find(|e| e.kind == UiEventKind::PointerDown)
3292            .expect("PointerDown emitted");
3293        assert_eq!(pd1.click_count, 1, "first press starts the sequence");
3294        let up1 = core.pointer_up(cx, cy, PointerButton::Primary);
3295        let click1 = up1
3296            .iter()
3297            .find(|e| e.kind == UiEventKind::Click)
3298            .expect("Click emitted");
3299        assert_eq!(
3300            click1.click_count, 1,
3301            "Click carries the same count as its PointerDown"
3302        );
3303
3304        // Second press immediately after, same target: count = 2.
3305        let down2 = core.pointer_down(cx, cy, PointerButton::Primary);
3306        let pd2 = down2
3307            .iter()
3308            .find(|e| e.kind == UiEventKind::PointerDown)
3309            .unwrap();
3310        assert_eq!(pd2.click_count, 2, "second press extends the sequence");
3311        let up2 = core.pointer_up(cx, cy, PointerButton::Primary);
3312        assert_eq!(
3313            up2.iter()
3314                .find(|e| e.kind == UiEventKind::Click)
3315                .unwrap()
3316                .click_count,
3317            2
3318        );
3319
3320        // Third: count = 3.
3321        let down3 = core.pointer_down(cx, cy, PointerButton::Primary);
3322        let pd3 = down3
3323            .iter()
3324            .find(|e| e.kind == UiEventKind::PointerDown)
3325            .unwrap();
3326        assert_eq!(pd3.click_count, 3, "third press → triple-click");
3327        core.pointer_up(cx, cy, PointerButton::Primary);
3328    }
3329
3330    #[test]
3331    fn click_count_resets_when_target_changes() {
3332        let mut core = lay_out_input_tree(false);
3333        let btn = core.rect_of_key("btn").expect("btn rect");
3334        let ti = core.rect_of_key("ti").expect("ti rect");
3335
3336        // Press on btn → count=1.
3337        let down1 = core.pointer_down(
3338            btn.x + btn.w * 0.5,
3339            btn.y + btn.h * 0.5,
3340            PointerButton::Primary,
3341        );
3342        assert_eq!(
3343            down1
3344                .iter()
3345                .find(|e| e.kind == UiEventKind::PointerDown)
3346                .unwrap()
3347                .click_count,
3348            1
3349        );
3350        let _ = core.pointer_up(
3351            btn.x + btn.w * 0.5,
3352            btn.y + btn.h * 0.5,
3353            PointerButton::Primary,
3354        );
3355
3356        // Press on ti (different target) → count resets to 1.
3357        let down2 = core.pointer_down(ti.x + ti.w * 0.5, ti.y + ti.h * 0.5, PointerButton::Primary);
3358        let pd2 = down2
3359            .iter()
3360            .find(|e| e.kind == UiEventKind::PointerDown)
3361            .unwrap();
3362        assert_eq!(
3363            pd2.click_count, 1,
3364            "press on a new target resets the multi-click sequence"
3365        );
3366    }
3367
3368    #[test]
3369    fn double_click_on_selectable_text_selects_word_at_hit() {
3370        let mut core = lay_out_paragraph_tree();
3371        let p1 = core.rect_of_key("p1").expect("p1 rect");
3372        let cy = p1.y + p1.h * 0.5;
3373        // Click near the start of "First paragraph of text." — twice
3374        // within the multi-click window.
3375        let cx = p1.x + 4.0;
3376        core.pointer_down(cx, cy, PointerButton::Primary);
3377        core.pointer_up(cx, cy, PointerButton::Primary);
3378        core.pointer_down(cx, cy, PointerButton::Primary);
3379        // The current selection should now span the first word.
3380        let sel = &core.ui_state.current_selection;
3381        let r = sel.range.as_ref().expect("selection set");
3382        assert_eq!(r.anchor.key, "p1");
3383        assert_eq!(r.head.key, "p1");
3384        // "First" is 5 bytes.
3385        assert_eq!(r.anchor.byte.min(r.head.byte), 0);
3386        assert_eq!(r.anchor.byte.max(r.head.byte), 5);
3387    }
3388
3389    #[test]
3390    fn triple_click_on_selectable_text_selects_whole_leaf() {
3391        let mut core = lay_out_paragraph_tree();
3392        let p1 = core.rect_of_key("p1").expect("p1 rect");
3393        let cy = p1.y + p1.h * 0.5;
3394        let cx = p1.x + 4.0;
3395        core.pointer_down(cx, cy, PointerButton::Primary);
3396        core.pointer_up(cx, cy, PointerButton::Primary);
3397        core.pointer_down(cx, cy, PointerButton::Primary);
3398        core.pointer_up(cx, cy, PointerButton::Primary);
3399        core.pointer_down(cx, cy, PointerButton::Primary);
3400        let sel = &core.ui_state.current_selection;
3401        let r = sel.range.as_ref().expect("selection set");
3402        assert_eq!(r.anchor.byte, 0);
3403        // "First paragraph of text." is 24 bytes.
3404        assert_eq!(r.head.byte, 24);
3405    }
3406
3407    #[test]
3408    fn click_count_resets_when_press_drifts_outside_distance_window() {
3409        let mut core = lay_out_input_tree(false);
3410        let btn = core.rect_of_key("btn").expect("btn rect");
3411        let cx = btn.x + btn.w * 0.5;
3412        let cy = btn.y + btn.h * 0.5;
3413
3414        let _ = core.pointer_down(cx, cy, PointerButton::Primary);
3415        let _ = core.pointer_up(cx, cy, PointerButton::Primary);
3416
3417        // Move 10 px (well outside MULTI_CLICK_DIST=4.0). Even if same
3418        // target, the second press starts a fresh sequence.
3419        let down2 = core.pointer_down(cx + 10.0, cy, PointerButton::Primary);
3420        let pd2 = down2
3421            .iter()
3422            .find(|e| e.kind == UiEventKind::PointerDown)
3423            .unwrap();
3424        assert_eq!(pd2.click_count, 1);
3425    }
3426
3427    #[test]
3428    fn escape_with_no_selection_emits_only_escape() {
3429        let mut core = lay_out_paragraph_tree();
3430        assert!(core.ui_state.current_selection.is_empty());
3431        let events = core.key_down(UiKey::Escape, KeyModifiers::default(), false);
3432        let kinds: Vec<UiEventKind> = events.iter().map(|e| e.kind).collect();
3433        assert_eq!(
3434            kinds,
3435            vec![UiEventKind::Escape],
3436            "no selection → no SelectionChanged side-effect"
3437        );
3438    }
3439
3440    /// Build a 200x200 viewport hosting a `scroll([rows...])` whose
3441    /// content overflows so the thumb is present.
3442    fn lay_out_scroll_tree() -> (RunnerCore, String) {
3443        use crate::tree::*;
3444        let mut tree = crate::scroll(
3445            (0..6)
3446                .map(|i| crate::widgets::text::text(format!("row {i}")).height(Size::Fixed(50.0))),
3447        )
3448        .gap(12.0)
3449        .height(Size::Fixed(200.0));
3450        let mut core = RunnerCore::new();
3451        crate::layout::layout(
3452            &mut tree,
3453            &mut core.ui_state,
3454            Rect::new(0.0, 0.0, 300.0, 200.0),
3455        );
3456        let scroll_id = tree.computed_id.clone();
3457        let mut t = PrepareTimings::default();
3458        core.snapshot(&tree, &mut t);
3459        (core, scroll_id)
3460    }
3461
3462    #[test]
3463    fn thumb_pointer_down_captures_drag_and_suppresses_events() {
3464        let (mut core, scroll_id) = lay_out_scroll_tree();
3465        let thumb = core
3466            .ui_state
3467            .scroll
3468            .thumb_rects
3469            .get(&scroll_id)
3470            .copied()
3471            .expect("scrollable should have a thumb");
3472        let event = core.pointer_down(
3473            thumb.x + thumb.w * 0.5,
3474            thumb.y + thumb.h * 0.5,
3475            PointerButton::Primary,
3476        );
3477        assert!(
3478            event.is_empty(),
3479            "thumb press should not emit PointerDown to the app"
3480        );
3481        let drag = core
3482            .ui_state
3483            .scroll
3484            .thumb_drag
3485            .as_ref()
3486            .expect("scroll.thumb_drag should be set after pointer_down on thumb");
3487        assert_eq!(drag.scroll_id, scroll_id);
3488    }
3489
3490    #[test]
3491    fn track_click_above_thumb_pages_up_below_pages_down() {
3492        let (mut core, scroll_id) = lay_out_scroll_tree();
3493        let track = core
3494            .ui_state
3495            .scroll
3496            .thumb_tracks
3497            .get(&scroll_id)
3498            .copied()
3499            .expect("scrollable should have a track");
3500        let thumb = core
3501            .ui_state
3502            .scroll
3503            .thumb_rects
3504            .get(&scroll_id)
3505            .copied()
3506            .unwrap();
3507        let metrics = core
3508            .ui_state
3509            .scroll
3510            .metrics
3511            .get(&scroll_id)
3512            .copied()
3513            .unwrap();
3514
3515        // Press in the track below the thumb at offset 0 → page down.
3516        let evt = core.pointer_down(
3517            track.x + track.w * 0.5,
3518            thumb.y + thumb.h + 10.0,
3519            PointerButton::Primary,
3520        );
3521        assert!(evt.is_empty(), "track press should not surface PointerDown");
3522        assert!(
3523            core.ui_state.scroll.thumb_drag.is_none(),
3524            "track click outside the thumb should not start a drag",
3525        );
3526        let after_down = core.ui_state.scroll_offset(&scroll_id);
3527        let expected_page = (metrics.viewport_h - SCROLL_PAGE_OVERLAP).max(0.0);
3528        assert!(
3529            (after_down - expected_page.min(metrics.max_offset)).abs() < 0.5,
3530            "page-down offset = {after_down} (expected ~{expected_page})",
3531        );
3532        // pointer_up after a track-page is a no-op (no drag to clear).
3533        let _ = core.pointer_up(0.0, 0.0, PointerButton::Primary);
3534
3535        // Re-layout to refresh the thumb position at the new offset,
3536        // then click-to-page up.
3537        let mut tree = lay_out_scroll_tree_only();
3538        crate::layout::layout(
3539            &mut tree,
3540            &mut core.ui_state,
3541            Rect::new(0.0, 0.0, 300.0, 200.0),
3542        );
3543        let mut t = PrepareTimings::default();
3544        core.snapshot(&tree, &mut t);
3545        let track = core
3546            .ui_state
3547            .scroll
3548            .thumb_tracks
3549            .get(&tree.computed_id)
3550            .copied()
3551            .unwrap();
3552        let thumb = core
3553            .ui_state
3554            .scroll
3555            .thumb_rects
3556            .get(&tree.computed_id)
3557            .copied()
3558            .unwrap();
3559
3560        core.pointer_down(
3561            track.x + track.w * 0.5,
3562            thumb.y - 4.0,
3563            PointerButton::Primary,
3564        );
3565        let after_up = core.ui_state.scroll_offset(&tree.computed_id);
3566        assert!(
3567            after_up < after_down,
3568            "page-up should reduce offset: before={after_down} after={after_up}",
3569        );
3570    }
3571
3572    /// Same fixture as `lay_out_scroll_tree` but doesn't build a
3573    /// fresh `RunnerCore` — useful when tests want to re-layout
3574    /// against an existing core to refresh thumb rects after a
3575    /// scroll offset change.
3576    fn lay_out_scroll_tree_only() -> El {
3577        use crate::tree::*;
3578        crate::scroll(
3579            (0..6)
3580                .map(|i| crate::widgets::text::text(format!("row {i}")).height(Size::Fixed(50.0))),
3581        )
3582        .gap(12.0)
3583        .height(Size::Fixed(200.0))
3584    }
3585
3586    #[test]
3587    fn thumb_drag_translates_pointer_delta_into_scroll_offset() {
3588        let (mut core, scroll_id) = lay_out_scroll_tree();
3589        let thumb = core
3590            .ui_state
3591            .scroll
3592            .thumb_rects
3593            .get(&scroll_id)
3594            .copied()
3595            .unwrap();
3596        let metrics = core
3597            .ui_state
3598            .scroll
3599            .metrics
3600            .get(&scroll_id)
3601            .copied()
3602            .unwrap();
3603        let track_remaining = (metrics.viewport_h - thumb.h).max(0.0);
3604
3605        let press_y = thumb.y + thumb.h * 0.5;
3606        core.pointer_down(thumb.x + thumb.w * 0.5, press_y, PointerButton::Primary);
3607        // Drag 20 px down — offset should advance by `20 * max_offset / track_remaining`.
3608        let evt = core.pointer_moved(thumb.x + thumb.w * 0.5, press_y + 20.0);
3609        assert!(
3610            evt.events.is_empty(),
3611            "thumb-drag move should suppress Drag event",
3612        );
3613        let offset = core.ui_state.scroll_offset(&scroll_id);
3614        let expected = 20.0 * (metrics.max_offset / track_remaining);
3615        assert!(
3616            (offset - expected).abs() < 0.5,
3617            "offset {offset} (expected {expected})",
3618        );
3619        // Overshooting clamps to max_offset.
3620        core.pointer_moved(thumb.x + thumb.w * 0.5, press_y + 9999.0);
3621        let offset = core.ui_state.scroll_offset(&scroll_id);
3622        assert!(
3623            (offset - metrics.max_offset).abs() < 0.5,
3624            "overshoot offset {offset} (expected {})",
3625            metrics.max_offset
3626        );
3627        // Release clears the drag without emitting events.
3628        let events = core.pointer_up(thumb.x, press_y, PointerButton::Primary);
3629        assert!(events.is_empty(), "thumb release shouldn't emit events");
3630        assert!(core.ui_state.scroll.thumb_drag.is_none());
3631    }
3632
3633    #[test]
3634    fn secondary_click_does_not_steal_focus_or_press() {
3635        let mut core = lay_out_input_tree(false);
3636        let btn_rect = core.rect_of_key("btn").expect("btn rect");
3637        let cx = btn_rect.x + btn_rect.w * 0.5;
3638        let cy = btn_rect.y + btn_rect.h * 0.5;
3639        // Focus elsewhere first via primary click on the input.
3640        let ti_rect = core.rect_of_key("ti").expect("ti rect");
3641        let tx = ti_rect.x + ti_rect.w * 0.5;
3642        let ty = ti_rect.y + ti_rect.h * 0.5;
3643        core.pointer_down(tx, ty, PointerButton::Primary);
3644        let _ = core.pointer_up(tx, ty, PointerButton::Primary);
3645        let focused_before = core.ui_state.focused.as_ref().map(|t| t.key.clone());
3646        // Right-click on the button.
3647        core.pointer_down(cx, cy, PointerButton::Secondary);
3648        let events = core.pointer_up(cx, cy, PointerButton::Secondary);
3649        let kinds: Vec<UiEventKind> = events.iter().map(|e| e.kind).collect();
3650        assert_eq!(kinds, vec![UiEventKind::SecondaryClick]);
3651        let focused_after = core.ui_state.focused.as_ref().map(|t| t.key.clone());
3652        assert_eq!(
3653            focused_before, focused_after,
3654            "right-click must not steal focus"
3655        );
3656        assert!(
3657            core.ui_state.pressed.is_none(),
3658            "right-click must not set primary press"
3659        );
3660    }
3661
3662    #[test]
3663    fn text_input_routes_to_focused_only() {
3664        let mut core = lay_out_input_tree(false);
3665        // No focus yet → no event.
3666        assert!(core.text_input("a".into()).is_none());
3667        // Focus the button via primary click.
3668        let btn_rect = core.rect_of_key("btn").expect("btn rect");
3669        let cx = btn_rect.x + btn_rect.w * 0.5;
3670        let cy = btn_rect.y + btn_rect.h * 0.5;
3671        core.pointer_down(cx, cy, PointerButton::Primary);
3672        let _ = core.pointer_up(cx, cy, PointerButton::Primary);
3673        let event = core.text_input("hi".into()).expect("focused → event");
3674        assert_eq!(event.kind, UiEventKind::TextInput);
3675        assert_eq!(event.text.as_deref(), Some("hi"));
3676        assert_eq!(event.target.as_ref().map(|t| t.key.as_str()), Some("btn"));
3677        // Empty text → no event (some IME paths emit empty composition).
3678        assert!(core.text_input(String::new()).is_none());
3679    }
3680
3681    #[test]
3682    fn capture_keys_bypasses_tab_traversal_for_focused_node() {
3683        // Focus the capture_keys input. Tab should NOT move focus —
3684        // it should be delivered as a raw KeyDown to the input.
3685        let mut core = lay_out_input_tree(true);
3686        let ti_rect = core.rect_of_key("ti").expect("ti rect");
3687        let tx = ti_rect.x + ti_rect.w * 0.5;
3688        let ty = ti_rect.y + ti_rect.h * 0.5;
3689        core.pointer_down(tx, ty, PointerButton::Primary);
3690        let _ = core.pointer_up(tx, ty, PointerButton::Primary);
3691        assert_eq!(
3692            core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
3693            Some("ti"),
3694            "primary click on capture_keys node still focuses it"
3695        );
3696
3697        let events = core.key_down(UiKey::Tab, KeyModifiers::default(), false);
3698        assert_eq!(events.len(), 1, "Tab → exactly one KeyDown");
3699        let event = &events[0];
3700        assert_eq!(event.kind, UiEventKind::KeyDown);
3701        assert_eq!(event.target.as_ref().map(|t| t.key.as_str()), Some("ti"));
3702        assert_eq!(
3703            core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
3704            Some("ti"),
3705            "Tab inside capture_keys must NOT move focus"
3706        );
3707    }
3708
3709    #[test]
3710    fn pointer_down_focus_does_not_raise_focus_visible() {
3711        // `:focus-visible` semantics: clicking a widget focuses it but
3712        // does NOT light up the focus ring. Verify the runtime flag.
3713        let mut core = lay_out_input_tree(false);
3714        let btn_rect = core.rect_of_key("btn").expect("btn rect");
3715        let cx = btn_rect.x + btn_rect.w * 0.5;
3716        let cy = btn_rect.y + btn_rect.h * 0.5;
3717        core.pointer_down(cx, cy, PointerButton::Primary);
3718        assert_eq!(
3719            core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
3720            Some("btn"),
3721            "primary click focuses the button",
3722        );
3723        assert!(
3724            !core.ui_state.focus_visible,
3725            "click focus must not raise focus_visible — ring stays off",
3726        );
3727    }
3728
3729    #[test]
3730    fn tab_key_raises_focus_visible_so_ring_appears() {
3731        let mut core = lay_out_input_tree(false);
3732        // Pre-focus via click so focus_visible starts low.
3733        let btn_rect = core.rect_of_key("btn").expect("btn rect");
3734        let cx = btn_rect.x + btn_rect.w * 0.5;
3735        let cy = btn_rect.y + btn_rect.h * 0.5;
3736        core.pointer_down(cx, cy, PointerButton::Primary);
3737        assert!(!core.ui_state.focus_visible);
3738        // Tab moves focus and should raise the ring.
3739        let _ = core.key_down(UiKey::Tab, KeyModifiers::default(), false);
3740        assert!(
3741            core.ui_state.focus_visible,
3742            "Tab must raise focus_visible so the ring paints on the new target",
3743        );
3744    }
3745
3746    #[test]
3747    fn click_after_tab_clears_focus_visible_again() {
3748        // Tab raises the ring; a subsequent click on a focusable widget
3749        // suppresses it again — the user is back on the pointer.
3750        let mut core = lay_out_input_tree(false);
3751        let _ = core.key_down(UiKey::Tab, KeyModifiers::default(), false);
3752        assert!(core.ui_state.focus_visible, "Tab raises ring");
3753        let btn_rect = core.rect_of_key("btn").expect("btn rect");
3754        let cx = btn_rect.x + btn_rect.w * 0.5;
3755        let cy = btn_rect.y + btn_rect.h * 0.5;
3756        core.pointer_down(cx, cy, PointerButton::Primary);
3757        assert!(
3758            !core.ui_state.focus_visible,
3759            "pointer-down clears focus_visible — ring fades back out",
3760        );
3761    }
3762
3763    #[test]
3764    fn keypress_on_focused_widget_raises_focus_visible_after_click() {
3765        // Click a focused-but-non-text widget, then nudge with a key
3766        // (e.g. arrow on a slider). The keypress is keyboard
3767        // interaction → ring lights up even though focus didn't move.
3768        let mut core = lay_out_input_tree(false);
3769        let btn_rect = core.rect_of_key("btn").expect("btn rect");
3770        let cx = btn_rect.x + btn_rect.w * 0.5;
3771        let cy = btn_rect.y + btn_rect.h * 0.5;
3772        core.pointer_down(cx, cy, PointerButton::Primary);
3773        assert!(!core.ui_state.focus_visible);
3774        let _ = core.key_down(UiKey::ArrowRight, KeyModifiers::default(), false);
3775        assert!(
3776            core.ui_state.focus_visible,
3777            "non-Tab key on focused widget raises focus_visible",
3778        );
3779    }
3780
3781    #[test]
3782    fn arrow_nav_in_sibling_group_raises_focus_visible() {
3783        let mut core = lay_out_arrow_nav_tree();
3784        // The fixture pre-sets focus directly without going through
3785        // the runtime; ensure the flag starts low.
3786        core.ui_state.set_focus_visible(false);
3787        let _ = core.key_down(UiKey::ArrowDown, KeyModifiers::default(), false);
3788        assert!(
3789            core.ui_state.focus_visible,
3790            "arrow-nav within an arrow_nav_siblings group is keyboard navigation",
3791        );
3792    }
3793
3794    #[test]
3795    fn capture_keys_falls_back_to_default_when_focus_off_capturing_node() {
3796        // Tree has both a normal-focusable button and a capture_keys
3797        // input. Focus the button (normal focusable). Tab should then
3798        // do library-default focus traversal.
3799        let mut core = lay_out_input_tree(true);
3800        let btn_rect = core.rect_of_key("btn").expect("btn rect");
3801        let cx = btn_rect.x + btn_rect.w * 0.5;
3802        let cy = btn_rect.y + btn_rect.h * 0.5;
3803        core.pointer_down(cx, cy, PointerButton::Primary);
3804        let _ = core.pointer_up(cx, cy, PointerButton::Primary);
3805        assert_eq!(
3806            core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
3807            Some("btn"),
3808            "primary click focuses button"
3809        );
3810        // Tab should move focus to the next focusable (the input).
3811        let _ = core.key_down(UiKey::Tab, KeyModifiers::default(), false);
3812        assert_eq!(
3813            core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
3814            Some("ti"),
3815            "Tab from non-capturing focused does library-default traversal"
3816        );
3817    }
3818
3819    /// A column whose three buttons sit inside an `arrow_nav_siblings`
3820    /// parent (the shape `popover_panel` produces). Layout runs against
3821    /// a 200x300 viewport with 10px padding; each button is 80px wide
3822    /// and 36px tall stacked vertically, plenty inside the clip.
3823    fn lay_out_arrow_nav_tree() -> RunnerCore {
3824        use crate::tree::*;
3825        let mut tree = crate::column([
3826            crate::widgets::button::button("Red").key("opt-red"),
3827            crate::widgets::button::button("Green").key("opt-green"),
3828            crate::widgets::button::button("Blue").key("opt-blue"),
3829        ])
3830        .arrow_nav_siblings()
3831        .padding(10.0);
3832        let mut core = RunnerCore::new();
3833        crate::layout::layout(
3834            &mut tree,
3835            &mut core.ui_state,
3836            Rect::new(0.0, 0.0, 200.0, 300.0),
3837        );
3838        core.ui_state.sync_focus_order(&tree);
3839        let mut t = PrepareTimings::default();
3840        core.snapshot(&tree, &mut t);
3841        // Pre-focus the middle option (the typical state right after a
3842        // popover opens — we'll exercise transitions from there).
3843        let target = core
3844            .ui_state
3845            .focus
3846            .order
3847            .iter()
3848            .find(|t| t.key == "opt-green")
3849            .cloned();
3850        core.ui_state.set_focus(target);
3851        core
3852    }
3853
3854    #[test]
3855    fn arrow_nav_moves_focus_among_siblings() {
3856        let mut core = lay_out_arrow_nav_tree();
3857
3858        // ArrowDown moves to next sibling, no event emitted (it was
3859        // consumed by the navigation path).
3860        let down = core.key_down(UiKey::ArrowDown, KeyModifiers::default(), false);
3861        assert!(down.is_empty(), "arrow-nav consumes the key event");
3862        assert_eq!(
3863            core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
3864            Some("opt-blue"),
3865        );
3866
3867        // ArrowUp moves back.
3868        core.key_down(UiKey::ArrowUp, KeyModifiers::default(), false);
3869        assert_eq!(
3870            core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
3871            Some("opt-green"),
3872        );
3873
3874        // Home jumps to first.
3875        core.key_down(UiKey::Home, KeyModifiers::default(), false);
3876        assert_eq!(
3877            core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
3878            Some("opt-red"),
3879        );
3880
3881        // End jumps to last.
3882        core.key_down(UiKey::End, KeyModifiers::default(), false);
3883        assert_eq!(
3884            core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
3885            Some("opt-blue"),
3886        );
3887    }
3888
3889    #[test]
3890    fn arrow_nav_saturates_at_ends() {
3891        let mut core = lay_out_arrow_nav_tree();
3892        // Walk to the first option and try to go before it.
3893        core.key_down(UiKey::Home, KeyModifiers::default(), false);
3894        core.key_down(UiKey::ArrowUp, KeyModifiers::default(), false);
3895        assert_eq!(
3896            core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
3897            Some("opt-red"),
3898            "ArrowUp at top stays at top — no wrap",
3899        );
3900        // Same at the bottom.
3901        core.key_down(UiKey::End, KeyModifiers::default(), false);
3902        core.key_down(UiKey::ArrowDown, KeyModifiers::default(), false);
3903        assert_eq!(
3904            core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
3905            Some("opt-blue"),
3906            "ArrowDown at bottom stays at bottom — no wrap",
3907        );
3908    }
3909
3910    /// Build a tree shaped like a real app's `build()` output: a
3911    /// background row with a "Trigger" button, optionally with a
3912    /// dropdown popover layered on top.
3913    fn build_popover_tree(open: bool) -> El {
3914        use crate::widgets::button::button;
3915        use crate::widgets::overlay::overlay;
3916        use crate::widgets::popover::{dropdown, menu_item};
3917        let mut layers: Vec<El> = vec![button("Trigger").key("trigger")];
3918        if open {
3919            layers.push(dropdown(
3920                "menu",
3921                "trigger",
3922                [
3923                    menu_item("A").key("item-a"),
3924                    menu_item("B").key("item-b"),
3925                    menu_item("C").key("item-c"),
3926                ],
3927            ));
3928        }
3929        overlay(layers).padding(20.0)
3930    }
3931
3932    /// Run a full per-frame layout pass against `tree` so all the
3933    /// post-layout hooks (focus order sync, popover focus stack, etc.)
3934    /// fire just like a real frame.
3935    fn run_frame(core: &mut RunnerCore, tree: &mut El) {
3936        let mut t = PrepareTimings::default();
3937        core.prepare_layout(
3938            tree,
3939            Rect::new(0.0, 0.0, 400.0, 300.0),
3940            1.0,
3941            &mut t,
3942            RunnerCore::no_time_shaders,
3943        );
3944        core.snapshot(tree, &mut t);
3945    }
3946
3947    #[test]
3948    fn popover_open_pushes_focus_and_auto_focuses_first_item() {
3949        let mut core = RunnerCore::new();
3950        let mut closed = build_popover_tree(false);
3951        run_frame(&mut core, &mut closed);
3952        // Pre-focus the trigger as if the user tabbed to it before
3953        // opening the menu.
3954        let trigger = core
3955            .ui_state
3956            .focus
3957            .order
3958            .iter()
3959            .find(|t| t.key == "trigger")
3960            .cloned();
3961        core.ui_state.set_focus(trigger);
3962        assert_eq!(
3963            core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
3964            Some("trigger"),
3965        );
3966
3967        // Open the popover. The runtime should snapshot the trigger
3968        // onto the focus stack and auto-focus the first menu item.
3969        let mut open = build_popover_tree(true);
3970        run_frame(&mut core, &mut open);
3971        assert_eq!(
3972            core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
3973            Some("item-a"),
3974            "popover open should auto-focus the first menu item",
3975        );
3976        assert_eq!(
3977            core.ui_state.popover_focus.focus_stack.len(),
3978            1,
3979            "trigger should be saved on the focus stack",
3980        );
3981        assert_eq!(
3982            core.ui_state.popover_focus.focus_stack[0].key.as_str(),
3983            "trigger",
3984            "saved focus should be the pre-open target",
3985        );
3986    }
3987
3988    #[test]
3989    fn popover_close_restores_focus_to_trigger() {
3990        let mut core = RunnerCore::new();
3991        let mut closed = build_popover_tree(false);
3992        run_frame(&mut core, &mut closed);
3993        let trigger = core
3994            .ui_state
3995            .focus
3996            .order
3997            .iter()
3998            .find(|t| t.key == "trigger")
3999            .cloned();
4000        core.ui_state.set_focus(trigger);
4001
4002        // Open → focus walks to the menu.
4003        let mut open = build_popover_tree(true);
4004        run_frame(&mut core, &mut open);
4005        assert_eq!(
4006            core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
4007            Some("item-a"),
4008        );
4009
4010        // Close → focus restored to trigger, stack drained.
4011        let mut closed_again = build_popover_tree(false);
4012        run_frame(&mut core, &mut closed_again);
4013        assert_eq!(
4014            core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
4015            Some("trigger"),
4016            "closing the popover should pop the saved focus",
4017        );
4018        assert!(
4019            core.ui_state.popover_focus.focus_stack.is_empty(),
4020            "focus stack should be drained after restore",
4021        );
4022    }
4023
4024    #[test]
4025    fn popover_close_does_not_override_intentional_focus_move() {
4026        let mut core = RunnerCore::new();
4027        // Tree with a second focusable button outside the popover so
4028        // the user can "click somewhere else" while the menu is open.
4029        let build = |open: bool| -> El {
4030            use crate::widgets::button::button;
4031            use crate::widgets::overlay::overlay;
4032            use crate::widgets::popover::{dropdown, menu_item};
4033            let main = crate::row([
4034                button("Trigger").key("trigger"),
4035                button("Other").key("other"),
4036            ]);
4037            let mut layers: Vec<El> = vec![main];
4038            if open {
4039                layers.push(dropdown("menu", "trigger", [menu_item("A").key("item-a")]));
4040            }
4041            overlay(layers).padding(20.0)
4042        };
4043
4044        let mut closed = build(false);
4045        run_frame(&mut core, &mut closed);
4046        let trigger = core
4047            .ui_state
4048            .focus
4049            .order
4050            .iter()
4051            .find(|t| t.key == "trigger")
4052            .cloned();
4053        core.ui_state.set_focus(trigger);
4054
4055        let mut open = build(true);
4056        run_frame(&mut core, &mut open);
4057        assert_eq!(core.ui_state.popover_focus.focus_stack.len(), 1);
4058
4059        // Simulate an intentional focus move to a sibling that is
4060        // outside the popover (e.g. the user re-tabbed somewhere). Do
4061        // this by setting focus directly while the popover is still in
4062        // the tree — the existing focus-order contains "other".
4063        let other = core
4064            .ui_state
4065            .focus
4066            .order
4067            .iter()
4068            .find(|t| t.key == "other")
4069            .cloned();
4070        core.ui_state.set_focus(other);
4071
4072        let mut closed_again = build(false);
4073        run_frame(&mut core, &mut closed_again);
4074        assert_eq!(
4075            core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
4076            Some("other"),
4077            "focus moved before close should not be overridden by restore",
4078        );
4079        assert!(core.ui_state.popover_focus.focus_stack.is_empty());
4080    }
4081
4082    #[test]
4083    fn nested_popovers_stack_and_unwind_focus_correctly() {
4084        let mut core = RunnerCore::new();
4085        // Two siblings layered at El root: an outer popover anchored to
4086        // the trigger, and an inner popover anchored to a button inside
4087        // the outer panel. Both are real popovers — separate
4088        // popover_layer ids — so the runtime sees them stack.
4089        let build = |outer: bool, inner: bool| -> El {
4090            use crate::widgets::button::button;
4091            use crate::widgets::overlay::overlay;
4092            use crate::widgets::popover::{Anchor, popover, popover_panel};
4093            let main = button("Trigger").key("trigger");
4094            let mut layers: Vec<El> = vec![main];
4095            if outer {
4096                layers.push(popover(
4097                    "outer",
4098                    Anchor::below_key("trigger"),
4099                    popover_panel([button("Open inner").key("inner-trigger")]),
4100                ));
4101            }
4102            if inner {
4103                layers.push(popover(
4104                    "inner",
4105                    Anchor::below_key("inner-trigger"),
4106                    popover_panel([button("X").key("inner-a"), button("Y").key("inner-b")]),
4107                ));
4108            }
4109            overlay(layers).padding(20.0)
4110        };
4111
4112        // Frame 1: nothing open, focus on the trigger.
4113        let mut closed = build(false, false);
4114        run_frame(&mut core, &mut closed);
4115        let trigger = core
4116            .ui_state
4117            .focus
4118            .order
4119            .iter()
4120            .find(|t| t.key == "trigger")
4121            .cloned();
4122        core.ui_state.set_focus(trigger);
4123
4124        // Frame 2: outer opens. Save trigger, focus inner-trigger.
4125        let mut outer = build(true, false);
4126        run_frame(&mut core, &mut outer);
4127        assert_eq!(
4128            core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
4129            Some("inner-trigger"),
4130        );
4131        assert_eq!(core.ui_state.popover_focus.focus_stack.len(), 1);
4132
4133        // Frame 3: inner also opens. Save inner-trigger, focus inner-a.
4134        let mut both = build(true, true);
4135        run_frame(&mut core, &mut both);
4136        assert_eq!(
4137            core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
4138            Some("inner-a"),
4139        );
4140        assert_eq!(core.ui_state.popover_focus.focus_stack.len(), 2);
4141
4142        // Frame 4: inner closes. Pop → restore inner-trigger.
4143        let mut outer_only = build(true, false);
4144        run_frame(&mut core, &mut outer_only);
4145        assert_eq!(
4146            core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
4147            Some("inner-trigger"),
4148        );
4149        assert_eq!(core.ui_state.popover_focus.focus_stack.len(), 1);
4150
4151        // Frame 5: outer closes. Pop → restore trigger.
4152        let mut none = build(false, false);
4153        run_frame(&mut core, &mut none);
4154        assert_eq!(
4155            core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
4156            Some("trigger"),
4157        );
4158        assert!(core.ui_state.popover_focus.focus_stack.is_empty());
4159    }
4160
4161    #[test]
4162    fn arrow_nav_does_not_intercept_outside_navigable_groups() {
4163        // Reuse the input tree (no arrow_nav_siblings parent). Arrow
4164        // keys must produce a regular `KeyDown` event so a
4165        // capture_keys widget can interpret them as caret motion.
4166        let mut core = lay_out_input_tree(false);
4167        let target = core
4168            .ui_state
4169            .focus
4170            .order
4171            .iter()
4172            .find(|t| t.key == "btn")
4173            .cloned();
4174        core.ui_state.set_focus(target);
4175        let events = core.key_down(UiKey::ArrowDown, KeyModifiers::default(), false);
4176        assert_eq!(
4177            events.len(),
4178            1,
4179            "ArrowDown without navigable parent → event"
4180        );
4181        assert_eq!(events[0].kind, UiEventKind::KeyDown);
4182    }
4183
4184    fn quad(shader: ShaderHandle) -> DrawOp {
4185        DrawOp::Quad {
4186            id: "q".into(),
4187            rect: Rect::new(0.0, 0.0, 10.0, 10.0),
4188            scissor: None,
4189            shader,
4190            uniforms: UniformBlock::new(),
4191        }
4192    }
4193
4194    #[test]
4195    fn samples_backdrop_inserts_snapshot_before_first_glass_quad() {
4196        let mut core = RunnerCore::new();
4197        core.set_surface_size(100, 100);
4198        let ops = vec![
4199            quad(ShaderHandle::Stock(StockShader::RoundedRect)),
4200            quad(ShaderHandle::Stock(StockShader::RoundedRect)),
4201            quad(ShaderHandle::Custom("liquid_glass")),
4202            quad(ShaderHandle::Custom("liquid_glass")),
4203            quad(ShaderHandle::Stock(StockShader::RoundedRect)),
4204        ];
4205        let mut timings = PrepareTimings::default();
4206        core.prepare_paint(
4207            &ops,
4208            |_| true,
4209            |s| matches!(s, ShaderHandle::Custom(name) if *name == "liquid_glass"),
4210            &mut NoText,
4211            1.0,
4212            &mut timings,
4213        );
4214
4215        let kinds: Vec<&'static str> = core
4216            .paint_items
4217            .iter()
4218            .map(|p| match p {
4219                PaintItem::QuadRun(_) => "Q",
4220                PaintItem::IconRun(_) => "I",
4221                PaintItem::Text(_) => "T",
4222                PaintItem::Image(_) => "M",
4223                PaintItem::AppTexture(_) => "A",
4224                PaintItem::Vector(_) => "V",
4225                PaintItem::BackdropSnapshot => "S",
4226            })
4227            .collect();
4228        assert_eq!(
4229            kinds,
4230            vec!["Q", "S", "Q", "Q"],
4231            "expected one stock run, snapshot, then a glass run, then a foreground stock run"
4232        );
4233    }
4234
4235    #[test]
4236    fn no_snapshot_when_no_glass_drawn() {
4237        let mut core = RunnerCore::new();
4238        core.set_surface_size(100, 100);
4239        let ops = vec![
4240            quad(ShaderHandle::Stock(StockShader::RoundedRect)),
4241            quad(ShaderHandle::Stock(StockShader::RoundedRect)),
4242        ];
4243        let mut timings = PrepareTimings::default();
4244        core.prepare_paint(&ops, |_| true, |_| false, &mut NoText, 1.0, &mut timings);
4245        assert!(
4246            !core
4247                .paint_items
4248                .iter()
4249                .any(|p| matches!(p, PaintItem::BackdropSnapshot)),
4250            "no glass shader registered → no snapshot"
4251        );
4252    }
4253
4254    #[test]
4255    fn at_most_one_snapshot_per_frame() {
4256        let mut core = RunnerCore::new();
4257        core.set_surface_size(100, 100);
4258        let ops = vec![
4259            quad(ShaderHandle::Stock(StockShader::RoundedRect)),
4260            quad(ShaderHandle::Custom("g")),
4261            quad(ShaderHandle::Stock(StockShader::RoundedRect)),
4262            quad(ShaderHandle::Custom("g")),
4263        ];
4264        let mut timings = PrepareTimings::default();
4265        core.prepare_paint(
4266            &ops,
4267            |_| true,
4268            |s| matches!(s, ShaderHandle::Custom("g")),
4269            &mut NoText,
4270            1.0,
4271            &mut timings,
4272        );
4273        let snapshots = core
4274            .paint_items
4275            .iter()
4276            .filter(|p| matches!(p, PaintItem::BackdropSnapshot))
4277            .count();
4278        assert_eq!(snapshots, 1, "backdrop depth is capped at 1");
4279    }
4280}