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