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