Skip to main content

damascene_core/
runtime.rs

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