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