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, LogicalKey, NamedKey, PhysicalKey, Pointer, PointerButton, PointerKind,
60    UiEvent, UiEventKind, 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, LatentViewportPan, MOUSE_DRAG_SLOP, SelectionDragGranularity,
73    TOUCH_DRAG_THRESHOLD, 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    /// Whether [`Self::last_tree`] contains any text-link run, computed
206    /// once per [`Self::snapshot`]. Gates the per-pointer-event
207    /// [`hit_test::link_at`] tree walks so apps without links (most)
208    /// don't pay a full-tree walk on every cursor move.
209    last_tree_has_links: bool,
210
211    /// Per-frame quad instance scratch — backends `bytemuck::cast_slice`
212    /// this into their VBO upload.
213    pub quad_scratch: Vec<QuadInstance>,
214    pub runs: Vec<InstanceRun>,
215    pub paint_items: Vec<PaintItem>,
216
217    /// Cached [`DrawOp`] list, reused by [`Self::prepare_paint_cached`]
218    /// for paint-only frames (time-driven shader animation when layout
219    /// state is unchanged — only `frame.time` advances). Backends are
220    /// expected to overwrite this with the ops returned from
221    /// [`Self::prepare_layout`] once they're done with the frame's
222    /// `prepare_paint` call.
223    pub last_ops: Vec<DrawOp>,
224
225    /// Physical viewport size in pixels. Backends use this for `draw()`
226    /// scissor binding (logical scissors get projected into this space
227    /// inside `prepare_paint`).
228    pub viewport_px: (u32, u32),
229    /// When set, overrides the physical viewport derived from
230    /// `viewport.w * scale_factor` so paint-side scissor math matches
231    /// the actual swapchain extent. Backends call
232    /// [`Self::set_surface_size`] from their host's surface-config /
233    /// resize hook to keep this in lockstep.
234    pub surface_size_override: Option<(u32, u32)>,
235
236    /// Theme used when resolving implicit widget surfaces to shaders.
237    pub theme: Theme,
238
239    /// The color space the renderer composites in. Every authored
240    /// [`Color`](crate::color::Color) is converted into this space exactly
241    /// once at the paint boundary. Defaults to
242    /// [`DEFAULT_WORKING_COLOR_SPACE`] (sRGB-linear).
243    ///
244    /// This field governs the *quad* path ([`pack_instance_in`]) that all
245    /// backends share. Backends are responsible for honoring it in their
246    /// own text / icon / image color packing too — read it via
247    /// [`Self::working_color_space`] and pass it to
248    /// [`crate::paint::rgba_f32_in`]. The `damascene-wgpu` backend does this;
249    /// `damascene-vulkano` and `damascene-ash` currently assume sRGB-linear and
250    /// must be updated before driving a wide-gamut surface.
251    pub working_color_space: ColorSpace,
252
253    /// Introspected instance-attribute name maps per custom shader,
254    /// registered by the backend's `register_shader_with` (issue #99).
255    /// When present for a shader, the quad fold routes its uniforms by
256    /// WGSL field name ([`crate::paint::pack_instance_named`]) instead
257    /// of positional `vec_a`..`vec_e` only.
258    shader_slot_maps: rustc_hash::FxHashMap<&'static str, crate::paint::slots::ShaderSlotMap>,
259    /// `(shader, key)` pairs already warned about, so a standing
260    /// Rust↔WGSL mismatch logs once instead of every frame.
261    warned_shader_uniforms: rustc_hash::FxHashSet<(&'static str, &'static str)>,
262}
263
264impl Default for RunnerCore {
265    fn default() -> Self {
266        Self::new()
267    }
268}
269
270impl RunnerCore {
271    pub fn new() -> Self {
272        Self {
273            ui_state: UiState::default(),
274            last_tree: None,
275            last_tree_has_links: false,
276            quad_scratch: Vec::new(),
277            runs: Vec::new(),
278            paint_items: Vec::new(),
279            last_ops: Vec::new(),
280            viewport_px: (1, 1),
281            surface_size_override: None,
282            theme: Theme::default(),
283            working_color_space: DEFAULT_WORKING_COLOR_SPACE,
284            shader_slot_maps: Default::default(),
285            warned_shader_uniforms: Default::default(),
286        }
287    }
288
289    /// Install the introspected instance-attribute name map for a
290    /// custom shader. Backends call this from `register_shader_with`
291    /// (after [`crate::paint::slots::introspect_wgsl`]); from then on
292    /// the shader's uniforms route by WGSL field name and keys that
293    /// match no declared attribute warn at first draw.
294    pub fn register_shader_slots(
295        &mut self,
296        name: &'static str,
297        map: crate::paint::slots::ShaderSlotMap,
298    ) {
299        self.shader_slot_maps.insert(name, map);
300    }
301
302    pub fn set_theme(&mut self, theme: Theme) {
303        self.theme = theme;
304    }
305
306    pub fn theme(&self) -> &Theme {
307        &self.theme
308    }
309
310    /// The color space the renderer composites in. Backends read this to
311    /// pack text / icon / image colors into the same working space the
312    /// shared quad path uses.
313    pub fn working_color_space(&self) -> ColorSpace {
314        self.working_color_space
315    }
316
317    /// Set the working color space. Hosts call this after negotiating a
318    /// surface format with the display server (see
319    /// `damascene-winit-wgpu`). Backends must also refresh any recorder-local
320    /// copy of the working space they hold.
321    pub fn set_working_color_space(&mut self, space: ColorSpace) {
322        self.working_color_space = space;
323    }
324
325    /// Override the physical viewport size. Call after the host's
326    /// surface configure or resize so scissor math sees the swapchain's
327    /// real extent (fractional `scale_factor` round-trips can otherwise
328    /// land `viewport_px` one pixel off and trip
329    /// `set_scissor_rect` validation).
330    pub fn set_surface_size(&mut self, width: u32, height: u32) {
331        self.surface_size_override = Some((width.max(1), height.max(1)));
332    }
333
334    pub fn ui_state(&self) -> &UiState {
335        &self.ui_state
336    }
337
338    pub fn debug_summary(&self) -> String {
339        self.ui_state.debug_summary()
340    }
341
342    pub fn rect_of_key(&self, key: &str) -> Option<Rect> {
343        self.ui_state.rect_of_key(key)
344    }
345
346    /// Whether a primary press at `(x, y)` (logical pixels) would
347    /// land on a node that opted into [`crate::tree::El::capture_keys`]
348    /// — the marker the library uses to identify text-input-style
349    /// widgets that consume raw key events when focused.
350    ///
351    /// Hosts use this to make focus-driven side-effect decisions in
352    /// the user-gesture context of a DOM pointerdown listener before
353    /// the press is actually dispatched. The most common use is the
354    /// web host's soft-keyboard plumbing: a hidden textarea must be
355    /// focused synchronously inside the pointerdown handler for iOS
356    /// to summon the on-screen keyboard, but only when the tap will
357    /// actually focus an Damascene text input. Pure read — does not
358    /// mutate any state.
359    ///
360    /// Returns `false` when the press misses every hit-test target
361    /// or the laid-out tree is not yet available.
362    pub fn would_press_focus_text_input(&self, x: f32, y: f32) -> bool {
363        let Some(tree) = self.last_tree.as_ref() else {
364            return false;
365        };
366        let Some(target) = hit_test::hit_test_target(tree, &self.ui_state, (x, y)) else {
367            return false;
368        };
369        find_capture_keys(tree, &target.node_id).unwrap_or(false)
370    }
371
372    // ---- Input plumbing ----
373
374    /// Pointer moved to `p.x, p.y` (logical px). Updates the hovered
375    /// node (readable via `ui_state().hovered`) and, if the primary
376    /// button is currently held, returns a `Drag` event routed to the
377    /// originally pressed target. The event's `modifiers` field
378    /// reflects the mask currently tracked on `UiState` (set by the
379    /// host via `set_modifiers`).
380    ///
381    /// `p.button` is ignored — pointer move events do not carry a
382    /// button press. `p.kind` is recorded on emitted events as
383    /// [`UiEvent::pointer_kind`] so apps can specialize for touch
384    /// vs. mouse / pen.
385    pub fn pointer_moved(&mut self, p: Pointer) -> PointerMove {
386        let Pointer { x, y, kind, .. } = p;
387        // Previous position, before we overwrite it — used to clear a scene
388        // hover tooltip on the move that leaves the scene (scenes aren't
389        // hover hit-targets, so `hover_changed` may not fire on exit).
390        let prev_pos = self.ui_state.pointer_pos;
391        self.ui_state.pointer_pos = Some((x, y));
392        self.ui_state.pointer_kind = kind;
393
394        // Active scrollbar drag: translate cursor delta into
395        // `scroll.offsets` updates. The drag is captured at
396        // `pointer_down` so we can map directly onto the scroll
397        // container without going through hit-test, and we suppress
398        // the normal hover/Drag event emission while it's in flight.
399        if let Some(drag) = self.ui_state.scroll.thumb_drag.clone() {
400            let dy = y - drag.start_pointer_y;
401            let new_offset = if drag.track_remaining > 0.0 {
402                drag.start_offset + dy * (drag.max_offset / drag.track_remaining)
403            } else {
404                drag.start_offset
405            };
406            let clamped = new_offset.clamp(0.0, drag.max_offset);
407            let prev = self.ui_state.scroll.offsets.insert(drag.scroll_id, clamped);
408            let changed = prev.is_none_or(|old| (old - clamped).abs() > f32::EPSILON);
409            return PointerMove {
410                events: Vec::new(),
411                needs_redraw: changed,
412            };
413        }
414
415        // Active edge-resize drag (issue #106): translate the pointer
416        // delta along the resize axis into a size override, exactly as
417        // the thumb drag translates into a scroll offset. Captured at
418        // `pointer_down`, suppresses hover/Drag emission while in
419        // flight; the next layout reads the override back as `Fixed`.
420        if let Some(drag) = self.ui_state.resize.drag.clone() {
421            let pos = match drag.axis {
422                crate::tree::Axis::Column => y,
423                _ => x,
424            };
425            let next = (drag.initial + (pos - drag.anchor) * drag.sign).clamp(drag.min, drag.max);
426            let prev = self.ui_state.resize.overrides.insert(drag.id, next);
427            let changed = prev.is_none_or(|old| (old - next).abs() > f32::EPSILON);
428            return PointerMove {
429                events: Vec::new(),
430                needs_redraw: changed,
431            };
432        }
433
434        // Active camera drag: orbit/pan the captured scene. Like the
435        // scrollbar drag, this is global once captured and suppresses
436        // hover/Drag emission while in flight.
437        if self.ui_state.camera_drag_active() {
438            let changed = self.ui_state.drag_camera_to(x, y);
439            return PointerMove {
440                events: Vec::new(),
441                needs_redraw: changed,
442            };
443        }
444
445        // Active viewport pan: translate the cursor delta into the
446        // viewport's pan. Global once captured, like the scrollbar /
447        // camera drags; suppresses hover/Drag emission while in flight.
448        if self.ui_state.viewport_pan_active() {
449            let changed = self.ui_state.drag_viewport_to(x, y);
450            return PointerMove {
451                events: Vec::new(),
452                needs_redraw: changed,
453            };
454        }
455
456        // Active plot pan: same discipline as the viewport pan above.
457        if self.ui_state.plot_pan_active() {
458            let changed = self.ui_state.drag_plot_to(x, y);
459            return PointerMove {
460                events: Vec::new(),
461                needs_redraw: changed,
462            };
463        }
464
465        // Active plot box-zoom selection: track the cursor so the selection
466        // band redraws; the zoom itself applies on release.
467        if self.ui_state.plot_zoom_active() {
468            let changed = self.ui_state.drag_plot_zoom_to(x, y);
469            return PointerMove {
470                events: Vec::new(),
471                needs_redraw: changed,
472            };
473        }
474
475        // Latent viewport pan → real pan: the press was captured toward
476        // a click on a keyed child (see `pointer_down`), and the pointer
477        // has now travelled past the mouse slop while held — the gesture
478        // is a drag, not a click. Cancel the pending click the same way
479        // a touch drift commits to scroll (`PointerCancel` to the
480        // pressed target, `PointerLeave` to the hovered one), then begin
481        // the pan anchored at the original press point so the sub-slop
482        // motion isn't lost. Below the slop the move falls through and
483        // behaves exactly as before (Drag emission, hover, styling).
484        if let Some(latent) = self.ui_state.viewport.latent_pan.clone() {
485            let (dx, dy) = (x - latent.press.0, y - latent.press.1);
486            if (dx * dx + dy * dy).sqrt() >= MOUSE_DRAG_SLOP {
487                self.ui_state.viewport.latent_pan = None;
488                let modifiers = self.ui_state.modifiers;
489                let mut out = Vec::new();
490                self.cancel_press_for_scroll(&mut out, x, y, kind, modifiers);
491                // A press that became a drag can't seed a multi-click:
492                // MULTI_CLICK_DIST is as wide as the slop, so without
493                // this reset a re-press near the original point would
494                // read as a double-click across the pan.
495                self.ui_state.click.last = None;
496                self.ui_state.begin_viewport_pan(
497                    latent.viewport_id,
498                    PointerButton::Primary,
499                    latent.press.0,
500                    latent.press.1,
501                );
502                self.ui_state.drag_viewport_to(x, y);
503                // Redraw unconditionally: even a bounds-clamped pan must
504                // repaint to clear the cancelled press styling.
505                return PointerMove {
506                    events: out,
507                    needs_redraw: true,
508                };
509            }
510        }
511
512        let hit = self
513            .last_tree
514            .as_ref()
515            .and_then(|t| hit_test::hit_test_target(t, &self.ui_state, (x, y)));
516        // Stash the previous hover target so we can pair Leave/Enter
517        // events on identity change. `set_hovered` mutates the state
518        // and only returns whether identity flipped.
519        let prev_hover = self.ui_state.hovered.clone();
520        let hover_changed = self.ui_state.set_hovered(hit, Instant::now());
521        // Track the link URL under the pointer separately from keyed
522        // hover so the cursor resolver can flip to `Pointer` over text
523        // runs that aren't themselves hit-test targets. A change here
524        // (entering or leaving a link) needs a redraw so the host's
525        // per-frame cursor resolution reads the new value.
526        let prev_hovered_link = self.ui_state.hovered_link.clone();
527        let new_hovered_link = self
528            .linkable_tree()
529            .and_then(|t| hit_test::link_at(t, (x, y)));
530        let link_hover_changed = new_hovered_link != prev_hovered_link;
531        self.ui_state.hovered_link = new_hovered_link;
532        // Same tracking for edge-resize grab bands: crossing a band
533        // edge changes the resolved cursor (resize arrows), and the
534        // host only re-resolves the cursor after a redraw — so the
535        // transition itself must request one even though no hover
536        // identity changed (the band straddles inside a single node).
537        let prev_hovered_band = self.ui_state.resize.hovered_band.clone();
538        let new_hovered_band = self.ui_state.resize_band_at(x, y).map(|b| b.id.clone());
539        let band_hover_changed = new_hovered_band != prev_hovered_band;
540        self.ui_state.resize.hovered_band = new_hovered_band;
541        let modifiers = self.ui_state.modifiers;
542
543        let mut out = Vec::new();
544
545        // Hover-transition events: Leave on the prior target (when
546        // there was one), Enter on the new target (when there is one).
547        // Both fire on identity change only — cursor moves *within* the
548        // same hovered node are visual no-ops here, matching the
549        // redraw-debouncing semantics. Always Leave-then-Enter so apps
550        // observe the cleared state before the new one.
551        //
552        // Touch gating: a touchscreen has no resting hover. Without a
553        // press, a stray pointermove (very rare on touch — most
554        // platforms only fire pointermove during contact) should not
555        // synthesize a hover transition. With a press, hover identity
556        // changes during a drag are real and fire normally so widgets
557        // along the drag path can react. `pointer_down` and
558        // `pointer_up` separately stamp the contact-driven enter and
559        // leave for touch.
560        let touch_no_press = matches!(kind, PointerKind::Touch) && self.ui_state.pressed.is_none();
561        if hover_changed && !touch_no_press {
562            if let Some(prev) = prev_hover {
563                out.push(UiEvent {
564                    key: Some(prev.key.clone()),
565                    target: Some(prev),
566                    pointer: Some((x, y)),
567                    key_press: None,
568                    text: None,
569                    selection: None,
570                    modifiers,
571                    click_count: 0,
572                    path: None,
573                    pointer_kind: Some(kind),
574                    wheel_delta: None,
575                    kind: UiEventKind::PointerLeave,
576                });
577            }
578            if let Some(new) = self.ui_state.hovered.clone() {
579                out.push(UiEvent {
580                    key: Some(new.key.clone()),
581                    target: Some(new),
582                    pointer: Some((x, y)),
583                    key_press: None,
584                    text: None,
585                    selection: None,
586                    modifiers,
587                    click_count: 0,
588                    path: None,
589                    pointer_kind: Some(kind),
590                    wheel_delta: None,
591                    kind: UiEventKind::PointerEnter,
592                });
593            }
594        }
595
596        // Touch gesture state machine: resolve the tap / drag / scroll
597        // ambiguity before falling through to selection / drag
598        // emission. Mouse and pen pointers stay at `None` here and
599        // bypass the machine entirely.
600        if matches!(kind, PointerKind::Touch) {
601            match self.ui_state.touch_gesture.clone() {
602                TouchGestureState::Pending {
603                    initial,
604                    consumes_drag,
605                    started_at,
606                } => {
607                    let dx = x - initial.0;
608                    let dy = y - initial.1;
609                    if (dx * dx + dy * dy).sqrt() < TOUCH_DRAG_THRESHOLD {
610                        // Below threshold — could still be a tap.
611                        // Suppress selection / drag emission for this
612                        // move; return only any hover events that
613                        // already accumulated.
614                        let needs_redraw = hover_changed
615                            || link_hover_changed
616                            || band_hover_changed
617                            || !out.is_empty();
618                        return PointerMove {
619                            events: out,
620                            needs_redraw,
621                        };
622                    }
623                    if consumes_drag {
624                        // The press target opted in via
625                        // `consumes_touch_drag` — commit to drag and
626                        // fall through to the normal drag emission
627                        // below (this move and subsequent ones).
628                        self.ui_state.touch_gesture = TouchGestureState::None;
629                    } else {
630                        // Commit to scroll — or to a viewport pan.
631                        // Cancel the press either way so the widget
632                        // that thought it was being clicked sees
633                        // `PointerCancel` + `PointerLeave` and stops
634                        // receiving further events for this gesture.
635                        let now = Instant::now();
636                        // The press target, before the cancel below
637                        // consumes it — the pan takeover must know
638                        // whether the contact began on the viewport's
639                        // own content or on a widget floated over it.
640                        let press_target = self.ui_state.pressed.clone();
641                        self.cancel_press_for_scroll(&mut out, x, y, kind, modifiers);
642                        // Sign: a finger dragging *down* should expose
643                        // content above (scroll position decreases).
644                        // `pointer_wheel`'s `dy` matches mouse-wheel
645                        // convention where positive = scroll-down, so
646                        // we negate the finger's positive Δy.
647                        let scroll_dy = initial.1 - y;
648                        // A pannable viewport under the contact takes
649                        // the drag (issue #126 — touch analog of the
650                        // mouse latent pan) unless a scrollable *inside*
651                        // it consumes the motion first: lists laid over
652                        // a map scroll, the map pans from anywhere
653                        // else, and any *outer* page scroll loses to
654                        // the viewport (the embedded-map convention).
655                        // Touch contacts arrive as the primary button,
656                        // so dedicated-trigger viewports keep today's
657                        // scroll routing; so does an overlay-occluded
658                        // press (same rule as the mouse paths, #127),
659                        // and so does a press whose target lives in a
660                        // layer floated *over* the viewport (a toast
661                        // card, an overlay list) rather than inside it
662                        // — the same subtree rule the mouse latent pan
663                        // applies at arming.
664                        let pan_vp =
665                            self.ui_state
666                                .viewport_at(initial.0, initial.1)
667                                .filter(|(id, cfg)| {
668                                    cfg.pan_trigger.matches(PointerButton::Primary, modifiers)
669                                        && self.last_tree.as_ref().is_none_or(|t| {
670                                            !hit_test::occluded_by_overlay(t, initial, id)
671                                        })
672                                        && press_target.as_ref().is_none_or(|p| {
673                                            self.last_tree.as_ref().is_some_and(|t| {
674                                                find_inside_subtree(t, id, &p.node_id, false)
675                                                    .unwrap_or(false)
676                                            })
677                                        })
678                                });
679                        if let Some((vp_id, _)) = pan_vp {
680                            // Axis dominance (issue #130): the inner
681                            // scrollable is offered the gesture only
682                            // when the initial motion is vertical-
683                            // dominant. Scrollables on this path move
684                            // vertically only, so a mostly-horizontal
685                            // swipe has nothing a list can consume —
686                            // without this gate, a few pixels of
687                            // vertical drift would commit the whole
688                            // gesture to jittering the list instead of
689                            // panning the viewport. Ties go to the
690                            // scrollable, matching the platforms'
691                            // scroll bias. (If horizontal scrollables
692                            // ever grow on this path, this should ask
693                            // the scrollable for its movable axes
694                            // rather than assume vertical.)
695                            let vertical_dominant = dy.abs() >= dx.abs();
696                            // Innermost scrollable descendant of the
697                            // viewport that can consume this delta, if
698                            // any (targets come back outer→inner).
699                            let mut inner_step = None;
700                            if vertical_dominant && let Some(tree) = self.last_tree.as_ref() {
701                                for id in
702                                    hit_test::scroll_targets_at(tree, initial).into_iter().rev()
703                                {
704                                    if find_inside_subtree(tree, &vp_id, &id, false)
705                                        .unwrap_or(false)
706                                        && let Some(step) =
707                                            self.ui_state.scroll_by_id(&id, scroll_dy)
708                                    {
709                                        inner_step = Some(step);
710                                        break;
711                                    }
712                                }
713                            }
714                            if let Some(step) = inner_step {
715                                let dt = now
716                                    .duration_since(started_at)
717                                    .as_secs_f32()
718                                    .max(1.0 / 120.0);
719                                self.ui_state.touch_gesture = TouchGestureState::Scrolling {
720                                    last_pos: (x, y),
721                                    last_time: now,
722                                    velocity: step.applied_delta / dt,
723                                    scroll_id: Some(step.scroll_id),
724                                };
725                            } else {
726                                // Pan, anchored at the initial contact
727                                // so the pre-threshold motion isn't
728                                // lost. `pan_drag` pre-empts everything
729                                // from here (moves early-return on it;
730                                // release ends it in `pointer_up`), so
731                                // the gesture machine stands down.
732                                self.ui_state.touch_gesture = TouchGestureState::None;
733                                self.ui_state.begin_viewport_pan(
734                                    vp_id,
735                                    PointerButton::Primary,
736                                    initial.0,
737                                    initial.1,
738                                );
739                                self.ui_state.drag_viewport_to(x, y);
740                            }
741                            return PointerMove {
742                                events: out,
743                                needs_redraw: true,
744                            };
745                        }
746                        let step = self.last_tree.as_ref().and_then(|tree| {
747                            self.ui_state.scroll_by_pointer(tree, initial, scroll_dy)
748                        });
749                        let dt = now
750                            .duration_since(started_at)
751                            .as_secs_f32()
752                            .max(1.0 / 120.0);
753                        let velocity = step.as_ref().map(|s| s.applied_delta / dt).unwrap_or(0.0);
754                        self.ui_state.touch_gesture = TouchGestureState::Scrolling {
755                            last_pos: (x, y),
756                            last_time: now,
757                            velocity,
758                            scroll_id: step.map(|s| s.scroll_id),
759                        };
760                        return PointerMove {
761                            events: out,
762                            needs_redraw: true,
763                        };
764                    }
765                }
766                TouchGestureState::Scrolling {
767                    last_pos,
768                    last_time,
769                    velocity,
770                    scroll_id,
771                } => {
772                    let now = Instant::now();
773                    let scroll_dy = last_pos.1 - y;
774                    let step = scroll_id
775                        .as_ref()
776                        .and_then(|id| self.ui_state.scroll_by_id(id, scroll_dy))
777                        .or_else(|| {
778                            self.last_tree.as_ref().and_then(|tree| {
779                                self.ui_state.scroll_by_pointer(tree, (x, y), scroll_dy)
780                            })
781                        });
782                    let dt = now.duration_since(last_time).as_secs_f32().max(1.0 / 240.0);
783                    let sample_velocity =
784                        step.as_ref().map(|s| s.applied_delta / dt).unwrap_or(0.0);
785                    let velocity = sample_velocity * 0.65 + velocity * 0.35;
786                    self.ui_state.touch_gesture = TouchGestureState::Scrolling {
787                        last_pos: (x, y),
788                        last_time: now,
789                        velocity,
790                        scroll_id: step.map(|s| s.scroll_id).or(scroll_id),
791                    };
792                    return PointerMove {
793                        events: out,
794                        needs_redraw: true,
795                    };
796                }
797                TouchGestureState::None => {
798                    // Already committed to drag (or there was no press
799                    // to gate). Fall through.
800                }
801                TouchGestureState::LongPressed => {
802                    // The long-press already fired. For static text it
803                    // may have started a runtime-owned selection drag;
804                    // for editable text it leaves the original press
805                    // captured so movement can emit Drag to the input.
806                    self.extend_selection_drag_at(x, y, kind, modifiers, &mut out);
807                    if self.ui_state.pressed.is_none() {
808                        let needs_redraw = hover_changed
809                            || link_hover_changed
810                            || band_hover_changed
811                            || !out.is_empty();
812                        return PointerMove {
813                            events: out,
814                            needs_redraw,
815                        };
816                    }
817                }
818            }
819        }
820
821        // Selection drag-extend takes precedence over the focusable
822        // Drag emission. Cross-leaf: if the pointer hits a selectable
823        // leaf, head migrates there. Otherwise we project the pointer
824        // onto the closest selectable leaf in document order so that
825        // dragging *past* the last leaf extends to its end (rather
826        // than snapping the head home to the anchor leaf).
827        self.extend_selection_drag_at(x, y, kind, modifiers, &mut out);
828
829        // Drag: pointer moved while primary button is down → emit Drag
830        // to the originally pressed target. Cursor escape from the
831        // pressed node is the *normal* drag-extend case (e.g. text
832        // selection inside an editable widget); we keep emitting until
833        // pointer_up clears `pressed`.
834        if let Some(p) = self.ui_state.pressed.clone() {
835            // Caret-blink reset: drag-selecting inside a text input
836            // is ongoing editing activity, so keep the caret solid
837            // for the duration of the drag.
838            if self.focused_captures_keys() {
839                self.ui_state.bump_caret_activity(Instant::now());
840            }
841            out.push(UiEvent {
842                key: Some(p.key.clone()),
843                target: Some(p),
844                pointer: Some((x, y)),
845                key_press: None,
846                text: None,
847                selection: None,
848                modifiers,
849                click_count: self.ui_state.current_click_count(),
850                path: None,
851                pointer_kind: Some(kind),
852                wheel_delta: None,
853                kind: UiEventKind::Drag,
854            });
855        }
856
857        // Scenes with hover tooltips redraw on every move over them (and on
858        // the move that leaves) so the tooltip tracks / clears — a move
859        // within a single node otherwise reports no change.
860        let over_hover_scene = self.ui_state.pointer_over_hover_scene(x, y)
861            || prev_pos.is_some_and(|(px, py)| self.ui_state.pointer_over_hover_scene(px, py));
862        // Plots with a crosshair likewise redraw on every move over them (and
863        // on the move that leaves) so the crosshair tracks / clears.
864        let over_crosshair_plot = self.ui_state.pointer_over_crosshair_plot(x, y)
865            || prev_pos.is_some_and(|(px, py)| self.ui_state.pointer_over_crosshair_plot(px, py));
866        let needs_redraw = hover_changed
867            || link_hover_changed
868            || band_hover_changed
869            || !out.is_empty()
870            || over_hover_scene
871            || over_crosshair_plot;
872        PointerMove {
873            events: out,
874            needs_redraw,
875        }
876    }
877
878    /// Pointer left the window — clear hover / press trackers.
879    /// Returns a `PointerLeave` event for the previously hovered
880    /// target (when there was one) so apps can run hover-leave side
881    /// effects symmetrically with `PointerEnter`. Cursor positions on
882    /// the leave event are the last known pointer position before the
883    /// pointer exited, since winit no longer reports coordinates once
884    /// the cursor is outside the window.
885    pub fn pointer_left(&mut self) -> Vec<UiEvent> {
886        let last_pos = self.ui_state.pointer_pos;
887        let prev_hover = self.ui_state.hovered.clone();
888        let modifiers = self.ui_state.modifiers;
889        // pointer_left is a mouse-only signal — touch has no "cursor
890        // outside the window" state. Tag the leave event with the
891        // last-known modality so apps that branch on touch don't see
892        // a phantom Mouse-tagged leave for what was a touch session.
893        let kind = self.ui_state.pointer_kind;
894        self.ui_state.pointer_pos = None;
895        self.ui_state.set_hovered(None, Instant::now());
896        self.ui_state.pressed = None;
897        self.ui_state.pressed_secondary = None;
898        self.ui_state.touch_gesture = TouchGestureState::None;
899        // The press the latent pan was arbitrating is gone with it.
900        self.ui_state.viewport.latent_pan = None;
901        self.ui_state.cancel_scroll_momentum();
902        // Pointer leaves the window → no link is hovered or pressed
903        // anymore. Clearing here keeps a stale `Pointer` cursor from
904        // sticking after the user moves the mouse out of the canvas
905        // and lets re-entry recompute against the actual current
906        // position.
907        self.ui_state.hovered_link = None;
908        self.ui_state.pressed_link = None;
909        // Resize-band hover clears with the pointer; an in-flight edge
910        // drag is abandoned at whatever size it reached (the override
911        // is already stored), mirroring the press cleanup above.
912        self.ui_state.resize.hovered_band = None;
913        self.ui_state.resize.drag = None;
914
915        let mut out = Vec::new();
916        if let Some(prev) = prev_hover {
917            out.push(UiEvent {
918                key: Some(prev.key.clone()),
919                target: Some(prev),
920                pointer: last_pos,
921                key_press: None,
922                text: None,
923                selection: None,
924                modifiers,
925                click_count: 0,
926                path: None,
927                pointer_kind: Some(kind),
928                wheel_delta: None,
929                kind: UiEventKind::PointerLeave,
930            });
931        }
932        out
933    }
934
935    /// The platform cancelled the active pointer sequence — a touch
936    /// contact stolen by a system gesture, a pen palm-rejection, a
937    /// `pointercancel` on web. Abandons every in-flight gesture without
938    /// synthesizing clicks or applying release effects: the pressed
939    /// target sees `PointerCancel`, the hovered target `PointerLeave`,
940    /// and all captures release where they are — pans and camera drags
941    /// stay at the pose they reached, a box-zoom selection is discarded
942    /// (never applied), an edge-resize keeps its stored override.
943    ///
944    /// Hosts route `TouchPhase::Cancelled` / `pointercancel` here rather
945    /// than synthesizing a `pointer_up` (which would apply release
946    /// semantics like the box-zoom, and only ends the matching button's
947    /// capture) or a `pointer_left` (which leaves gesture captures alive
948    /// for the next contact to inherit).
949    pub fn pointer_cancelled(&mut self) -> Vec<UiEvent> {
950        let (x, y) = self.ui_state.pointer_pos.unwrap_or_default();
951        let kind = self.ui_state.pointer_kind;
952        let modifiers = self.ui_state.modifiers;
953        let mut out = Vec::new();
954        // PointerCancel to the pressed target, PointerLeave to the
955        // hovered one; clears pressed / links / selection drag.
956        self.cancel_press_for_scroll(&mut out, x, y, kind, modifiers);
957        self.ui_state.viewport.latent_pan = None;
958        self.ui_state.viewport.pan_drag = None;
959        self.ui_state.cancel_camera_drag();
960        self.ui_state.cancel_plot_gestures();
961        self.ui_state.scroll.thumb_drag = None;
962        self.ui_state.resize.drag = None;
963        self.ui_state.resize.hovered_band = None;
964        self.ui_state.touch_gesture = TouchGestureState::None;
965        self.ui_state.cancel_scroll_momentum();
966        self.ui_state.pointer_pos = None;
967        out
968    }
969
970    /// A file is being dragged over the window at logical-pixel
971    /// coordinates `(x, y)`. Hosts call this from
972    /// `WindowEvent::HoveredFile`. Hit-tests at the cursor position and
973    /// emits a `FileHovered` event routed to the keyed leaf at that
974    /// point (or window-level when the cursor is over no keyed
975    /// surface). Multi-file drags fire one event per file — winit
976    /// reports each file separately and the host forwards each call
977    /// into this method.
978    ///
979    /// The hover state is *not* tracked across files; apps that want
980    /// to count active hovered files do so themselves between
981    /// `FileHovered` and the eventual `FileHoverCancelled` /
982    /// `FileDropped`.
983    pub fn file_hovered(&mut self, path: std::path::PathBuf, x: f32, y: f32) -> Vec<UiEvent> {
984        self.ui_state.pointer_pos = Some((x, y));
985        let target = self
986            .last_tree
987            .as_ref()
988            .and_then(|t| hit_test::hit_test_target(t, &self.ui_state, (x, y)));
989        let key = target.as_ref().map(|t| t.key.clone());
990        vec![UiEvent {
991            key,
992            target,
993            pointer: Some((x, y)),
994            key_press: None,
995            text: None,
996            selection: None,
997            modifiers: self.ui_state.modifiers,
998            click_count: 0,
999            path: Some(path),
1000            pointer_kind: None,
1001            wheel_delta: None,
1002            kind: UiEventKind::FileHovered,
1003        }]
1004    }
1005
1006    /// The user moved a hovered file off the window without dropping
1007    /// (or pressed Escape). Window-level event — not routed to any
1008    /// keyed leaf, since winit doesn't tell us which file was being
1009    /// dragged. Apps clear any drop-zone affordance state.
1010    pub fn file_hover_cancelled(&mut self) -> Vec<UiEvent> {
1011        vec![UiEvent {
1012            key: None,
1013            target: None,
1014            pointer: self.ui_state.pointer_pos,
1015            key_press: None,
1016            text: None,
1017            selection: None,
1018            modifiers: self.ui_state.modifiers,
1019            click_count: 0,
1020            path: None,
1021            pointer_kind: None,
1022            wheel_delta: None,
1023            kind: UiEventKind::FileHoverCancelled,
1024        }]
1025    }
1026
1027    /// A file was dropped on the window at logical-pixel coordinates
1028    /// `(x, y)`. Hosts call this from `WindowEvent::DroppedFile`.
1029    /// Same routing as [`Self::file_hovered`] — keyed leaf at the drop
1030    /// point, or window-level. One event per file.
1031    pub fn file_dropped(&mut self, path: std::path::PathBuf, x: f32, y: f32) -> Vec<UiEvent> {
1032        self.ui_state.pointer_pos = Some((x, y));
1033        let target = self
1034            .last_tree
1035            .as_ref()
1036            .and_then(|t| hit_test::hit_test_target(t, &self.ui_state, (x, y)));
1037        let key = target.as_ref().map(|t| t.key.clone());
1038        vec![UiEvent {
1039            key,
1040            target,
1041            pointer: Some((x, y)),
1042            key_press: None,
1043            text: None,
1044            selection: None,
1045            modifiers: self.ui_state.modifiers,
1046            click_count: 0,
1047            path: Some(path),
1048            pointer_kind: None,
1049            wheel_delta: None,
1050            kind: UiEventKind::FileDropped,
1051        }]
1052    }
1053
1054    /// Primary/secondary/middle pointer button pressed at `(x, y)`.
1055    /// For the primary button, focuses the hit target and stashes it
1056    /// as the pressed target; emits a `PointerDown` event so widgets
1057    /// like text_input can react at down-time (e.g., set the selection
1058    /// anchor before any drag extends it). Secondary/middle store on a
1059    /// separate channel and never emit a `PointerDown`.
1060    ///
1061    /// Also drives the library's text-selection manager: a primary
1062    /// press on a `selectable` text leaf starts a drag and produces a
1063    /// `SelectionChanged` event; a press on any other element clears
1064    /// any active static-text selection by emitting a
1065    /// `SelectionChanged` with an empty range.
1066    pub fn pointer_down(&mut self, p: Pointer) -> Vec<UiEvent> {
1067        let Pointer {
1068            x, y, button, kind, ..
1069        } = p;
1070        self.ui_state.pointer_kind = kind;
1071        self.ui_state.cancel_scroll_momentum();
1072        // Any new press invalidates a pending click-vs-pan decision from
1073        // an earlier one (re-armed below if this press qualifies).
1074        self.ui_state.viewport.latent_pan = None;
1075        // Scrollbar track pre-empts normal hit-test: a primary press
1076        // inside a scrollable's track column either captures a thumb
1077        // drag (when the press lands inside the visible thumb rect)
1078        // or pages the scroll offset by a viewport (when it lands
1079        // above or below the thumb). Both branches suppress focus /
1080        // press / event chains for the press itself; `pointer_moved`
1081        // then drives the drag (no-op for paged clicks) and
1082        // `pointer_up` clears the drag.
1083        if matches!(button, PointerButton::Primary)
1084            && let Some((scroll_id, _track, thumb_rect)) = self
1085                .ui_state
1086                .thumb_at(x, y)
1087                .filter(|(scroll_id, _, _)| self.scrollbar_can_capture(scroll_id, x, y))
1088        {
1089            let metrics = self
1090                .ui_state
1091                .scroll
1092                .metrics
1093                .get(&scroll_id)
1094                .copied()
1095                .unwrap_or_default();
1096            let start_offset = self
1097                .ui_state
1098                .scroll
1099                .offsets
1100                .get(&scroll_id)
1101                .copied()
1102                .unwrap_or(0.0);
1103
1104            // Grab when the press lands inside the visible thumb;
1105            // page otherwise. The track is wider than the thumb
1106            // horizontally, so this branch is decided by `y` alone.
1107            let grabbed = y >= thumb_rect.y && y <= thumb_rect.y + thumb_rect.h;
1108            if grabbed {
1109                let track_remaining = (metrics.viewport_h - thumb_rect.h).max(0.0);
1110                self.ui_state.scroll.thumb_drag = Some(crate::state::ThumbDrag {
1111                    scroll_id,
1112                    start_pointer_y: y,
1113                    start_offset,
1114                    track_remaining,
1115                    max_offset: metrics.max_offset,
1116                });
1117            } else {
1118                // Click-to-page. Browser convention: each press
1119                // shifts the offset by ~one viewport with a small
1120                // overlap so context isn't lost. Direction is
1121                // decided by which side of the thumb the press
1122                // landed on.
1123                let page = (metrics.viewport_h - SCROLL_PAGE_OVERLAP).max(0.0);
1124                let delta = if y < thumb_rect.y { -page } else { page };
1125                let new_offset = (start_offset + delta).clamp(0.0, metrics.max_offset);
1126                self.ui_state.scroll.offsets.insert(scroll_id, new_offset);
1127            }
1128            return Vec::new();
1129        }
1130
1131        // Edge-resize grab band (issue #106): a primary press inside a
1132        // `.user_resizable()` pane's seam band captures a resize drag.
1133        // Same pre-emption slot as the scrollbar thumb (which wins
1134        // where the two overlap — it's painted on top): suppresses
1135        // focus / press / event chains for the press itself;
1136        // `pointer_moved` drives the drag, `pointer_up` releases it.
1137        if matches!(button, PointerButton::Primary)
1138            && let Some(band) = self
1139                .ui_state
1140                .resize_band_at(x, y)
1141                .filter(|b| self.resize_band_can_capture(b, x, y))
1142                .cloned()
1143        {
1144            let anchor = match band.axis {
1145                crate::tree::Axis::Column => y,
1146                _ => x,
1147            };
1148            self.ui_state.resize.drag = Some(crate::state::resize::EdgeResizeDrag {
1149                id: band.id,
1150                key: band.key,
1151                axis: band.axis,
1152                sign: band.sign,
1153                anchor,
1154                initial: band.current,
1155                min: band.min,
1156                max: band.max,
1157            });
1158            return Vec::new();
1159        }
1160
1161        let hit = self
1162            .last_tree
1163            .as_ref()
1164            .and_then(|t| hit_test::hit_test_target(t, &self.ui_state, (x, y)));
1165
1166        // Camera gesture: a press over a 3D scene viewport — and not on a
1167        // more-specific interactive target laid over it — may capture an
1168        // orbit/pan/dolly, per the scene's navigation scheme. Like the
1169        // scrollbar drag it suppresses focus/press for the press itself;
1170        // `pointer_moved` drives it and `pointer_up` clears it.
1171        //
1172        // The hit may be nothing, or the scene's own keyed node: a scene
1173        // given `.key(...)` becomes a hit-test target, but that key is the
1174        // scene itself, so it must not suppress its own camera drag. Any
1175        // *other* hit (a real widget layered over the scene) still does.
1176        // An overlay's *dead space* hits nothing, though, so it needs the
1177        // explicit occlusion check the wheel path uses (issue #127): a
1178        // press on a modal floated over the scene must not orbit the
1179        // canvas underneath it.
1180        if let Some(id) = self.ui_state.scene_at(x, y)
1181            && hit.as_ref().is_none_or(|h| *h.node_id == *id)
1182            && let Some(mode) = self
1183                .ui_state
1184                .scene_drag_mode(&id, button, self.ui_state.modifiers)
1185            && self
1186                .last_tree
1187                .as_ref()
1188                .is_none_or(|t| !hit_test::occluded_by_overlay(t, (x, y), &id))
1189        {
1190            self.ui_state.begin_camera_drag(id, mode, button, x, y);
1191            return Vec::new();
1192        }
1193
1194        // Viewport pan: a press over a `viewport()` whose pan trigger
1195        // matches the button + modifiers begins a pan. Pre-empts focus /
1196        // press like the other captures.
1197        //
1198        // A *dedicated* trigger — any non-primary button or a modifier
1199        // mask — can't collide with a plain content click, so it pans
1200        // anywhere over the viewport (even over a keyed child). The
1201        // *default* trigger (plain primary, no modifiers) would steal
1202        // every child click, so it only pans on the viewport background:
1203        // the press must hit nothing or the viewport's own node, the same
1204        // "not on a more-specific target" rule the camera drag uses. This
1205        // is what keeps content clickable while empty space drags it. A
1206        // default-trigger press on a keyed child isn't dead, though — it
1207        // arms a latent pan at the end of this method, converted past
1208        // the mouse slop in `pointer_moved` (issue #125).
1209        //
1210        // Overlay dead space hits nothing too, so like the camera and
1211        // wheel paths this needs the explicit occlusion check (issue
1212        // #127): a press-drag on a modal floated over the canvas must
1213        // not pan the viewport underneath it.
1214        if let Some((vp_id, cfg)) = self.ui_state.viewport_at(x, y)
1215            && cfg.pan_trigger.matches(button, self.ui_state.modifiers)
1216            && self
1217                .last_tree
1218                .as_ref()
1219                .is_none_or(|t| !hit_test::occluded_by_overlay(t, (x, y), &vp_id))
1220        {
1221            let dedicated = cfg.pan_trigger.button != PointerButton::Primary
1222                || cfg.pan_trigger.modifiers != crate::event::KeyModifiers::default();
1223            let on_background = hit.as_ref().is_none_or(|h| *h.node_id == *vp_id);
1224            if dedicated || on_background {
1225                self.ui_state.begin_viewport_pan(vp_id, button, x, y);
1226                return Vec::new();
1227            }
1228        }
1229
1230        // Plot interaction: a primary press on a plot's data rect resets the
1231        // view (double-click) or begins a drag whose action depends on the
1232        // plot's control scheme — the primary drag does one of box-zoom / pan
1233        // and `Shift` does the other. Plots have no interactive children, so
1234        // the press hits the plot node itself. Overlay dead space hits
1235        // nothing too, so the same occlusion rule as the camera / pan
1236        // captures applies (issue #127): a press on a modal floated over
1237        // the plot must not zoom or reset it.
1238        if matches!(button, PointerButton::Primary)
1239            && let Some((plot_id, metrics)) = self.ui_state.plot_at(x, y)
1240            && hit.as_ref().is_none_or(|h| *h.node_id == *plot_id)
1241            && self
1242                .last_tree
1243                .as_ref()
1244                .is_none_or(|t| !hit_test::occluded_by_overlay(t, (x, y), &plot_id))
1245        {
1246            // Count clicks ourselves: this branch returns before the shared
1247            // click-count call below, and a double-click resets the view.
1248            let clicks =
1249                self.ui_state
1250                    .next_click_count(Instant::now(), (x, y), Some(plot_id.as_str()));
1251            if clicks >= 2 {
1252                self.ui_state.reset_plot_view(&plot_id);
1253            } else {
1254                // The scheme picks what the unmodified drag does; Shift inverts.
1255                let pan = match metrics.controls {
1256                    crate::plot::PlotControls::ZoomDrag => self.ui_state.modifiers.shift,
1257                    crate::plot::PlotControls::PanDrag => !self.ui_state.modifiers.shift,
1258                };
1259                if pan {
1260                    self.ui_state.begin_plot_pan(plot_id, x, y);
1261                } else {
1262                    self.ui_state.begin_plot_zoom(plot_id, x, y);
1263                }
1264            }
1265            return Vec::new();
1266        }
1267
1268        // Only the primary button drives focus + the visual press
1269        // envelope. Secondary/middle clicks shouldn't yank focus from
1270        // the currently-focused element (matches browser/native behavior
1271        // where right-clicking a button doesn't take focus).
1272        if !matches!(button, PointerButton::Primary) {
1273            // Stash the down-target on the secondary/middle channel so
1274            // pointer_up can confirm the click landed on the same node.
1275            self.ui_state.pressed_secondary = hit.map(|h| (h, button));
1276            return Vec::new();
1277        }
1278
1279        // Stash any link URL the press lands on before the keyed-
1280        // target walk consumes the press. Cleared in `pointer_up`,
1281        // which only emits `LinkActivated` if the up position resolves
1282        // to the same URL — same press-then-confirm contract as a
1283        // normal `Click`. A press that misses every link clears any
1284        // stale value from the previous press so a drag-released-
1285        // elsewhere never fires a link from an earlier interaction.
1286        self.ui_state.pressed_link = self
1287            .linkable_tree()
1288            .and_then(|t| hit_test::link_at(t, (x, y)));
1289        self.ui_state.set_focus_from_pointer(hit.clone());
1290        // `:focus-visible` rule: pointer-driven focus suppresses the
1291        // ring; widgets that want it on click opt in via
1292        // `always_show_focus_ring`.
1293        self.ui_state.set_focus_visible(false);
1294        self.ui_state.pressed = hit.clone();
1295        // A press on the hovered node dismisses any tooltip for
1296        // the rest of this hover session — matches native UIs.
1297        self.ui_state.tooltip.dismissed_for_hover = true;
1298        let modifiers = self.ui_state.modifiers;
1299
1300        // Click counting: extend a multi-click sequence when the press
1301        // lands on the same target inside the time + distance window.
1302        let now = Instant::now();
1303        let click_count =
1304            self.ui_state
1305                .next_click_count(now, (x, y), hit.as_ref().map(|t| t.node_id.as_ref()));
1306
1307        let mut out = Vec::new();
1308
1309        // Touch contact starts hover for this gesture. Mouse / pen
1310        // already track hover continuously through `pointer_moved`,
1311        // so this branch is touch-only — without it, a touch tap
1312        // would fire `PointerDown` and `Click` with no preceding
1313        // `PointerEnter`, and any hover-driven visual envelope on
1314        // the target would never advance for the duration of the
1315        // contact.
1316        if matches!(kind, PointerKind::Touch) {
1317            let prev_hover = self.ui_state.hovered.clone();
1318            let hover_changed = self.ui_state.set_hovered(hit.clone(), now);
1319            if hover_changed {
1320                if let Some(prev) = prev_hover {
1321                    out.push(UiEvent {
1322                        key: Some(prev.key.clone()),
1323                        target: Some(prev),
1324                        pointer: Some((x, y)),
1325                        key_press: None,
1326                        text: None,
1327                        selection: None,
1328                        modifiers,
1329                        click_count: 0,
1330                        path: None,
1331                        pointer_kind: Some(kind),
1332                        wheel_delta: None,
1333                        kind: UiEventKind::PointerLeave,
1334                    });
1335                }
1336                if let Some(new) = hit.clone() {
1337                    out.push(UiEvent {
1338                        key: Some(new.key.clone()),
1339                        target: Some(new),
1340                        pointer: Some((x, y)),
1341                        key_press: None,
1342                        text: None,
1343                        selection: None,
1344                        modifiers,
1345                        click_count: 0,
1346                        path: None,
1347                        pointer_kind: Some(kind),
1348                        wheel_delta: None,
1349                        kind: UiEventKind::PointerEnter,
1350                    });
1351                }
1352            }
1353            // Enter the gesture state machine. Decide upfront whether
1354            // the press target (or any ancestor) consumes touch drag,
1355            // so the threshold-cross branch in `pointer_moved` doesn't
1356            // re-walk the tree once per move. A press that hits dead
1357            // space (no keyed leaf) defaults to "doesn't consume" —
1358            // scroll wins, matching the natural mobile expectation
1359            // that swiping over background pans the page.
1360            let consumes_drag = hit
1361                .as_ref()
1362                .and_then(|t| {
1363                    self.last_tree
1364                        .as_ref()
1365                        .and_then(|tree| find_consumes_touch_drag(tree, &t.node_id, false))
1366                })
1367                .unwrap_or(false);
1368            self.ui_state.touch_gesture = TouchGestureState::Pending {
1369                initial: (x, y),
1370                consumes_drag,
1371                started_at: now,
1372            };
1373        }
1374
1375        if let Some(p) = hit.clone() {
1376            // Caret-blink reset: a press inside the focused widget
1377            // (e.g., to reposition the caret in an already-focused
1378            // input) is editing activity. The earlier `set_focus`
1379            // call bumps when focus *changes*; this catches the
1380            // same-target case so click-to-move-caret resets the
1381            // blink too.
1382            if self.focused_captures_keys() {
1383                self.ui_state.bump_caret_activity(now);
1384            }
1385            out.push(UiEvent {
1386                key: Some(p.key.clone()),
1387                target: Some(p),
1388                pointer: Some((x, y)),
1389                key_press: None,
1390                text: None,
1391                selection: None,
1392                modifiers,
1393                click_count,
1394                path: None,
1395                pointer_kind: Some(kind),
1396                wheel_delta: None,
1397                kind: UiEventKind::PointerDown,
1398            });
1399        }
1400
1401        // Selection routing. The selection hit-test is independent of
1402        // the focusable hit: a `text(...).key("p").selectable()` leaf is
1403        // both a (non-focusable) keyed PointerDown target and a
1404        // selectable text leaf. Apps see both events; selection drag
1405        // starts in either case. A press that lands on neither a
1406        // selectable nor a focusable widget clears any active
1407        // selection.
1408        if let Some(point) = self
1409            .last_tree
1410            .as_ref()
1411            .and_then(|t| hit_test::selection_point_at(t, (x, y)))
1412        {
1413            self.start_selection_drag(point, &mut out, modifiers, (x, y), click_count, kind);
1414        } else if !self.ui_state.current_selection.is_empty() {
1415            // Clear-on-click only when the press lands somewhere that
1416            // can't take selection ownership itself.
1417            //
1418            // - If the press is on the widget that already owns the
1419            //   selection (same key), the widget's PointerDown
1420            //   handler updates its own caret; a runtime clear here
1421            //   races and collapses the app's selection back to
1422            //   default. (User-visible bug: caret alternated between
1423            //   the click position and byte 0 on every other click.)
1424            //
1425            // - If the press is on a *different* capture_keys widget
1426            //   (e.g., dragging from one text_input into another),
1427            //   that widget's PointerDown will replace the selection
1428            //   with one anchored at the click position. The runtime
1429            //   clear would arrive after the replace and wipe the
1430            //   anchor — so when the drag began, only `head` would
1431            //   advance and `anchor` would default to 0, jumping the
1432            //   selection start to the beginning of the text.
1433            //
1434            // Press on a regular focusable (button, etc.) or in dead
1435            // space still clears, matching the browser idiom.
1436            let click_handles_selection = match (&hit, &self.ui_state.current_selection.range) {
1437                (Some(h), Some(range)) => {
1438                    h.key == range.anchor.key
1439                        || h.key == range.head.key
1440                        || self
1441                            .last_tree
1442                            .as_ref()
1443                            .and_then(|t| find_capture_keys(t, &h.node_id))
1444                            .unwrap_or(false)
1445                }
1446                _ => false,
1447            };
1448            if !click_handles_selection {
1449                out.push(selection_event(
1450                    crate::selection::Selection::default(),
1451                    modifiers,
1452                    Some((x, y)),
1453                    Some(kind),
1454                ));
1455                self.ui_state.current_selection = crate::selection::Selection::default();
1456                self.ui_state.selection.drag = None;
1457            }
1458        }
1459
1460        // Latent viewport pan (issue #125): the viewport branch above
1461        // deliberately lets a default-trigger press on a keyed child fall
1462        // through to the press-toward-click capture — but decided at
1463        // press time, that would leave a drag *starting* on a child dead.
1464        // Arm the movement-time arbitration instead: `pointer_moved`
1465        // converts this press into a real pan once it travels past
1466        // MOUSE_DRAG_SLOP; released within the slop it clicks as normal.
1467        //
1468        // Armed only when the drag has no more specific meaning. Every
1469        // capture above (scrollbar, edge-resize, camera, plot, immediate
1470        // pan) returned before this point; touch runs its own Pending
1471        // machine; a selection drag started this press means motion
1472        // extends the selection; a drag-consuming or capture-keys target
1473        // (slider scrub, text-input drag-select) owns its own drag; and
1474        // the pressed node must actually live inside the viewport — a
1475        // widget floating *over* it keeps its gesture, matching the
1476        // camera-drag rule.
1477        if !matches!(kind, PointerKind::Touch)
1478            && self.ui_state.selection.drag.is_none()
1479            && let Some(h) = self.ui_state.pressed.as_ref()
1480            && let Some((vp_id, cfg)) = self.ui_state.viewport_at(x, y)
1481            && cfg.pan_trigger.matches(button, modifiers)
1482            && let Some(tree) = self.last_tree.as_ref()
1483            && find_inside_subtree(tree, &vp_id, &h.node_id, false).unwrap_or(false)
1484            && !find_consumes_touch_drag(tree, &h.node_id, false).unwrap_or(false)
1485            && !find_capture_keys(tree, &h.node_id).unwrap_or(false)
1486        {
1487            self.ui_state.viewport.latent_pan = Some(LatentViewportPan {
1488                viewport_id: vp_id,
1489                press: (x, y),
1490            });
1491        }
1492
1493        out
1494    }
1495
1496    /// Stamp a new [`crate::state::SelectionDrag`] and emit a
1497    /// `SelectionChanged` event seeded by `point`. For
1498    /// `click_count == 2` the anchor / head pair expands to the word
1499    /// range around `point.byte`; for `click_count >= 3` it expands to
1500    /// the whole leaf (static-text triple-click typically wants the
1501    /// paragraph). For other counts (single click, default) the
1502    /// selection is collapsed at `point`.
1503    fn start_selection_drag(
1504        &mut self,
1505        point: crate::selection::SelectionPoint,
1506        out: &mut Vec<UiEvent>,
1507        modifiers: KeyModifiers,
1508        pointer: (f32, f32),
1509        click_count: u8,
1510        kind: PointerKind,
1511    ) {
1512        let leaf_text = self
1513            .last_tree
1514            .as_ref()
1515            .and_then(|t| crate::selection::find_keyed_text(t, &point.key))
1516            .unwrap_or_default();
1517        let (anchor_byte, head_byte) = match click_count {
1518            2 => crate::selection::word_range_at(&leaf_text, point.byte),
1519            n if n >= 3 => (0, leaf_text.len()),
1520            _ => (point.byte, point.byte),
1521        };
1522        let granularity = match click_count {
1523            2 => SelectionDragGranularity::Word,
1524            n if n >= 3 => SelectionDragGranularity::Leaf,
1525            _ => SelectionDragGranularity::Character,
1526        };
1527        let anchor = crate::selection::SelectionPoint::new(point.key.clone(), anchor_byte);
1528        let head = crate::selection::SelectionPoint::new(point.key.clone(), head_byte);
1529        let new_sel = crate::selection::Selection {
1530            range: Some(crate::selection::SelectionRange {
1531                anchor: anchor.clone(),
1532                head: head.clone(),
1533            }),
1534        };
1535        self.ui_state.current_selection = new_sel.clone();
1536        self.ui_state.selection.drag = Some(crate::state::SelectionDrag {
1537            anchor,
1538            head,
1539            granularity,
1540        });
1541        out.push(selection_event(
1542            new_sel,
1543            modifiers,
1544            Some(pointer),
1545            Some(kind),
1546        ));
1547    }
1548
1549    fn extend_selection_drag_at(
1550        &mut self,
1551        x: f32,
1552        y: f32,
1553        kind: PointerKind,
1554        modifiers: KeyModifiers,
1555        out: &mut Vec<UiEvent>,
1556    ) {
1557        let Some(drag) = self.ui_state.selection.drag.clone() else {
1558            return;
1559        };
1560        let Some(tree) = self.last_tree.as_ref() else {
1561            return;
1562        };
1563        let raw_head =
1564            head_for_drag(tree, &self.ui_state, (x, y)).unwrap_or_else(|| drag.anchor.clone());
1565        let (anchor, head) = selection_range_for_drag(tree, &self.ui_state, &drag, raw_head);
1566        let new_sel = crate::selection::Selection {
1567            range: Some(crate::selection::SelectionRange { anchor, head }),
1568        };
1569        if new_sel != self.ui_state.current_selection {
1570            self.ui_state.current_selection = new_sel.clone();
1571            out.push(selection_event(
1572                new_sel,
1573                modifiers,
1574                Some((x, y)),
1575                Some(kind),
1576            ));
1577        }
1578    }
1579
1580    /// Whether an edge-resize band may capture a press at `(x, y)` —
1581    /// the covered-scrollbar guard adapted to bands: when an overlay
1582    /// (dialog, popover) covers the seam, the hit-test target at the
1583    /// press point is outside the band's parent container, and the
1584    /// band must not steal the press from it. A band straddles two
1585    /// sibling panes, so the guard checks containment in the shared
1586    /// parent rather than in the resizable pane itself.
1587    fn resize_band_can_capture(
1588        &self,
1589        band: &crate::state::resize::ResizeBand,
1590        x: f32,
1591        y: f32,
1592    ) -> bool {
1593        let Some(tree) = self.last_tree.as_ref() else {
1594            return false;
1595        };
1596        hit_test::hit_test_target(tree, &self.ui_state, (x, y))
1597            .is_none_or(|target| target_id_in_subtree(&band.container_id, &target.node_id))
1598    }
1599
1600    fn scrollbar_can_capture(&self, scroll_id: &str, x: f32, y: f32) -> bool {
1601        let Some(tree) = self.last_tree.as_ref() else {
1602            return false;
1603        };
1604        let target_allows_capture = hit_test::hit_test_target(tree, &self.ui_state, (x, y))
1605            .is_none_or(|target| target_id_in_subtree(scroll_id, &target.node_id));
1606        if !target_allows_capture {
1607            return false;
1608        }
1609        hit_test::scroll_targets_at(tree, (x, y))
1610            .iter()
1611            .any(|id| id == scroll_id)
1612    }
1613
1614    /// Cancel an in-flight touch press because the gesture committed
1615    /// to scrolling. Emits `PointerCancel` for the pressed target
1616    /// (so widgets can roll back any setup they did at
1617    /// `PointerDown`) and `PointerLeave` for the hovered target
1618    /// (mirroring the contact-driven hover model from
1619    /// [`Self::pointer_up`]). Clears `pressed` so subsequent moves
1620    /// don't emit `Drag`, and clears the selection drag so the press
1621    /// doesn't keep extending a text selection from inside the
1622    /// scroll motion.
1623    fn cancel_press_for_scroll(
1624        &mut self,
1625        out: &mut Vec<UiEvent>,
1626        x: f32,
1627        y: f32,
1628        kind: PointerKind,
1629        modifiers: KeyModifiers,
1630    ) {
1631        let pressed = self.ui_state.pressed.take();
1632        let hovered = self.ui_state.hovered.clone();
1633        self.ui_state.set_hovered(None, Instant::now());
1634        self.ui_state.pressed_secondary = None;
1635        self.ui_state.pressed_link = None;
1636        // Clear link hover too: moves during the committed gesture
1637        // return before the link-hover update, so a stale value would
1638        // pin the hand cursor for the whole drag.
1639        self.ui_state.hovered_link = None;
1640        self.ui_state.selection.drag = None;
1641        if let Some(p) = pressed {
1642            out.push(UiEvent {
1643                key: Some(p.key.clone()),
1644                target: Some(p),
1645                pointer: Some((x, y)),
1646                key_press: None,
1647                text: None,
1648                selection: None,
1649                modifiers,
1650                click_count: 0,
1651                path: None,
1652                pointer_kind: Some(kind),
1653                wheel_delta: None,
1654                kind: UiEventKind::PointerCancel,
1655            });
1656        }
1657        if let Some(h) = hovered {
1658            out.push(UiEvent {
1659                key: Some(h.key.clone()),
1660                target: Some(h),
1661                pointer: Some((x, y)),
1662                key_press: None,
1663                text: None,
1664                selection: None,
1665                modifiers,
1666                click_count: 0,
1667                path: None,
1668                pointer_kind: Some(kind),
1669                wheel_delta: None,
1670                kind: UiEventKind::PointerLeave,
1671            });
1672        }
1673    }
1674
1675    /// Pointer released. For the primary button, fires `PointerUp`
1676    /// (always, with the originally pressed target so drag-aware
1677    /// widgets see drag-end) and additionally `Click` if the release
1678    /// landed on the same node as the down. For secondary / middle,
1679    /// fires the corresponding click variant when the up landed on the
1680    /// same node; no analogue of `PointerUp` since drag is a primary-
1681    /// button concept here.
1682    pub fn pointer_up(&mut self, p: Pointer) -> Vec<UiEvent> {
1683        let Pointer {
1684            x, y, button, kind, ..
1685        } = p;
1686        self.ui_state.pointer_kind = kind;
1687        // Any primary release ends the click-vs-pan arbitration. Disarm
1688        // before the capture-release early-returns below: the pan /
1689        // camera ends are button-agnostic, so one of them can consume
1690        // this release (a capture another button started), and a latent
1691        // pan stranded past that point would convert on a later
1692        // button-less hover move. Within the slop the disarm is the
1693        // whole story — the pressed/hit compare below clicks as normal.
1694        if matches!(button, PointerButton::Primary) {
1695            self.ui_state.viewport.latent_pan = None;
1696        }
1697        // Scrollbar drag ends without producing app-level events —
1698        // the press never went through `pressed` / `pressed_secondary`
1699        // so there's nothing else to clean up. Released from anywhere;
1700        // the drag is global once captured, matching native scrollbars.
1701        if matches!(button, PointerButton::Primary) && self.ui_state.scroll.thumb_drag.is_some() {
1702            self.ui_state.scroll.thumb_drag = None;
1703            self.ui_state.touch_gesture = TouchGestureState::None;
1704            return Vec::new();
1705        }
1706
1707        // An edge-resize drag releases like the scrollbar drag — the
1708        // press never reached `pressed`, so no Click/PointerUp chain.
1709        // The one event it does produce: `Resized`, routed to the
1710        // pane's key (when it has one), so apps know the natural
1711        // moment to read `user_size(key)` and persist it.
1712        if matches!(button, PointerButton::Primary)
1713            && let Some(drag) = self.ui_state.resize.drag.take()
1714        {
1715            self.ui_state.touch_gesture = TouchGestureState::None;
1716            let Some(key) = drag.key else {
1717                return Vec::new();
1718            };
1719            return vec![UiEvent {
1720                key: Some(key),
1721                target: None,
1722                pointer: Some((x, y)),
1723                key_press: None,
1724                text: None,
1725                selection: None,
1726                modifiers: self.ui_state.modifiers,
1727                click_count: 0,
1728                path: None,
1729                pointer_kind: Some(kind),
1730                wheel_delta: None,
1731                kind: UiEventKind::Resized,
1732            }];
1733        }
1734
1735        // A camera drag releases without producing app-level events — the
1736        // press was captured before focus/press, so there's nothing to
1737        // confirm. Released from anywhere (the drag is global once
1738        // captured), but only by the button that began it (issue #128):
1739        // another button's release falls through to its own handling
1740        // below instead of killing the drag.
1741        if self.ui_state.end_camera_drag(button) {
1742            self.ui_state.touch_gesture = TouchGestureState::None;
1743            return Vec::new();
1744        }
1745
1746        // A viewport pan releases like the camera drag — captured before
1747        // focus/press, so there's nothing to confirm. Same button
1748        // discipline.
1749        if self.ui_state.end_viewport_pan(button) {
1750            self.ui_state.touch_gesture = TouchGestureState::None;
1751            return Vec::new();
1752        }
1753
1754        // A plot pan releases the same way. Plot gestures only ever
1755        // begin on a primary press, so the gate is a plain button match.
1756        if matches!(button, PointerButton::Primary) && self.ui_state.end_plot_pan() {
1757            self.ui_state.touch_gesture = TouchGestureState::None;
1758            return Vec::new();
1759        }
1760
1761        // A box-zoom selection applies its zoom on release.
1762        if matches!(button, PointerButton::Primary) && self.ui_state.end_plot_zoom() {
1763            self.ui_state.touch_gesture = TouchGestureState::None;
1764            return Vec::new();
1765        }
1766
1767        // Touch gesture cleanup. Reset the state machine first so the
1768        // logic below sees a fresh slate; if the gesture had already
1769        // committed to scrolling or fired a long-press, release should
1770        // not synthesize Click / PointerUp. Editable long-press keeps a
1771        // press captured for drag-extension, so clear it here.
1772        let was_long_pressed =
1773            matches!(self.ui_state.touch_gesture, TouchGestureState::LongPressed);
1774        let momentum = match &self.ui_state.touch_gesture {
1775            TouchGestureState::Scrolling {
1776                velocity,
1777                scroll_id,
1778                ..
1779            } if matches!(kind, PointerKind::Touch) => {
1780                Some((scroll_id.clone(), *velocity, Instant::now()))
1781            }
1782            _ => None,
1783        };
1784        let was_scrolling_or_long = matches!(
1785            self.ui_state.touch_gesture,
1786            TouchGestureState::Scrolling { .. } | TouchGestureState::LongPressed
1787        );
1788        self.ui_state.touch_gesture = TouchGestureState::None;
1789        if was_scrolling_or_long {
1790            if let Some((scroll_id, velocity, now)) = momentum {
1791                self.ui_state
1792                    .start_scroll_momentum(scroll_id, velocity, now);
1793            }
1794            if was_long_pressed {
1795                self.ui_state.pressed = None;
1796                self.ui_state.pressed_secondary = None;
1797                self.ui_state.pressed_link = None;
1798                self.ui_state.selection.drag = None;
1799                self.ui_state.set_hovered(None, Instant::now());
1800            }
1801            return Vec::new();
1802        }
1803
1804        // End any active text-selection drag. The selection itself
1805        // persists; only the "currently dragging" flag goes away.
1806        if matches!(button, PointerButton::Primary) {
1807            self.ui_state.selection.drag = None;
1808        }
1809
1810        let hit = self
1811            .last_tree
1812            .as_ref()
1813            .and_then(|t| hit_test::hit_test_target(t, &self.ui_state, (x, y)));
1814        let modifiers = self.ui_state.modifiers;
1815        let mut out = Vec::new();
1816        match button {
1817            PointerButton::Primary => {
1818                let pressed = self.ui_state.pressed.take();
1819                let click_count = self.ui_state.current_click_count();
1820                if let Some(p) = pressed.clone() {
1821                    out.push(UiEvent {
1822                        key: Some(p.key.clone()),
1823                        target: Some(p),
1824                        pointer: Some((x, y)),
1825                        key_press: None,
1826                        text: None,
1827                        selection: None,
1828                        modifiers,
1829                        click_count,
1830                        path: None,
1831                        pointer_kind: Some(kind),
1832                        wheel_delta: None,
1833                        kind: UiEventKind::PointerUp,
1834                    });
1835                }
1836                if let (Some(p), Some(h)) = (pressed, hit)
1837                    && p.node_id == h.node_id
1838                {
1839                    // Toast dismiss buttons are runtime-managed —
1840                    // the click drops the matching toast from the
1841                    // queue and is *not* surfaced to the app, so
1842                    // `on_event` doesn't have to know about toast
1843                    // bookkeeping.
1844                    if let Some(id) = toast::parse_dismiss_key(&p.key) {
1845                        self.ui_state.dismiss_toast(id);
1846                    } else {
1847                        out.push(UiEvent {
1848                            key: Some(p.key.clone()),
1849                            target: Some(p),
1850                            pointer: Some((x, y)),
1851                            key_press: None,
1852                            text: None,
1853                            selection: None,
1854                            modifiers,
1855                            click_count,
1856                            path: None,
1857                            pointer_kind: Some(kind),
1858                            wheel_delta: None,
1859                            kind: UiEventKind::Click,
1860                        });
1861                    }
1862                }
1863                // Link click — surface the URL as a separate event so
1864                // the app's link policy is independent of any keyed
1865                // ancestor's `Click`. Press-then-confirm: the up
1866                // position must resolve to the same URL as the down
1867                // (cancel-on-drag-away, matching native link UX).
1868                if let Some(pressed_url) = self.ui_state.pressed_link.take() {
1869                    let up_link = self
1870                        .linkable_tree()
1871                        .and_then(|t| hit_test::link_at(t, (x, y)));
1872                    if up_link.as_ref() == Some(&pressed_url) {
1873                        out.push(UiEvent {
1874                            key: Some(pressed_url),
1875                            target: None,
1876                            pointer: Some((x, y)),
1877                            key_press: None,
1878                            text: None,
1879                            selection: None,
1880                            modifiers,
1881                            click_count: 1,
1882                            path: None,
1883                            pointer_kind: Some(kind),
1884                            wheel_delta: None,
1885                            kind: UiEventKind::LinkActivated,
1886                        });
1887                    }
1888                }
1889            }
1890            PointerButton::Secondary | PointerButton::Middle => {
1891                let pressed = self.ui_state.pressed_secondary.take();
1892                if let (Some((p, b)), Some(h)) = (pressed, hit)
1893                    && b == button
1894                    && p.node_id == h.node_id
1895                {
1896                    let event_kind = match button {
1897                        PointerButton::Secondary => UiEventKind::SecondaryClick,
1898                        PointerButton::Middle => UiEventKind::MiddleClick,
1899                        PointerButton::Primary => unreachable!(),
1900                    };
1901                    out.push(UiEvent {
1902                        key: Some(p.key.clone()),
1903                        target: Some(p),
1904                        pointer: Some((x, y)),
1905                        key_press: None,
1906                        text: None,
1907                        selection: None,
1908                        modifiers,
1909                        click_count: 1,
1910                        path: None,
1911                        pointer_kind: Some(kind),
1912                        wheel_delta: None,
1913                        kind: event_kind,
1914                    });
1915                }
1916            }
1917        }
1918
1919        // Touch contact ends → clear hover. Mouse / pen keep tracking
1920        // hover after a release because the pointer is still over
1921        // something; a finger lifting off the screen has no analog,
1922        // so the hover envelope must wind down. Mirrors the synthetic
1923        // `PointerEnter` that `pointer_down` emits for touch.
1924        if matches!(kind, PointerKind::Touch)
1925            && let Some(prev) = self.ui_state.hovered.clone()
1926        {
1927            self.ui_state.set_hovered(None, Instant::now());
1928            out.push(UiEvent {
1929                key: Some(prev.key.clone()),
1930                target: Some(prev),
1931                pointer: Some((x, y)),
1932                key_press: None,
1933                text: None,
1934                selection: None,
1935                modifiers,
1936                click_count: 0,
1937                path: None,
1938                pointer_kind: Some(kind),
1939                wheel_delta: None,
1940                kind: UiEventKind::PointerLeave,
1941            });
1942        }
1943
1944        out
1945    }
1946
1947    pub fn key_down(
1948        &mut self,
1949        logical: LogicalKey,
1950        physical: PhysicalKey,
1951        modifiers: KeyModifiers,
1952        repeat: bool,
1953    ) -> Vec<UiEvent> {
1954        // Capture path: when the focused node opted into raw key
1955        // capture, editing keys are delivered as raw `KeyDown` events
1956        // to the focused target. Hotkeys still match first — an app's
1957        // global Ctrl+S beats a text input's local consumption of S.
1958        // Escape is both an editing key and the generic "exit editing"
1959        // command: route it to the widget first so it can collapse a
1960        // selection, then clear focus.
1961        if self.focused_captures_keys() {
1962            if let Some(event) = self
1963                .ui_state
1964                .try_hotkey(&logical, physical, modifiers, repeat)
1965            {
1966                return vec![event];
1967            }
1968            // Caret-blink reset: any key arriving at a capture_keys
1969            // widget is text-editing activity (caret motion, edit,
1970            // shortcut), so the caret should snap back to solid even
1971            // when the app doesn't propagate its `Selection` back via
1972            // `App::selection()`. Without this, hammering arrow keys
1973            // produces no visible blink reset.
1974            self.ui_state.bump_caret_activity(Instant::now());
1975            self.ui_state.set_focus_visible(true);
1976            let blur_after = logical.named() == Some(NamedKey::Escape);
1977            let out = self
1978                .ui_state
1979                .key_down_raw(logical, physical, modifiers, repeat)
1980                .into_iter()
1981                .collect();
1982            if blur_after {
1983                self.ui_state.set_focus(None);
1984                self.ui_state.set_focus_visible(false);
1985            }
1986            return out;
1987        }
1988
1989        // Arrow-nav: if the focused node sits inside an arrow-navigable
1990        // group (a popover_panel of menu items, a tabs_list, a radio
1991        // group, a calendar grid), the arrows the group's orientation
1992        // covers plus Home / End move focus among its focusable members
1993        // rather than emitting a `KeyDown` event. Keys outside the
1994        // orientation fall through (Left/Right in a vertical menu stay
1995        // routable so a menubar can switch menus). Hotkeys are still
1996        // matched first so a global Ctrl+ArrowUp chord beats group
1997        // navigation.
1998        if matches!(
1999            logical.named(),
2000            Some(
2001                NamedKey::ArrowUp
2002                    | NamedKey::ArrowDown
2003                    | NamedKey::ArrowLeft
2004                    | NamedKey::ArrowRight
2005                    | NamedKey::Home
2006                    | NamedKey::End
2007            )
2008        ) && let Some((mode, members)) = self.focused_arrow_nav_group()
2009            && mode.handles(&logical)
2010        {
2011            if let Some(event) = self
2012                .ui_state
2013                .try_hotkey(&logical, physical, modifiers, repeat)
2014            {
2015                return vec![event];
2016            }
2017            self.move_focus_in_group(&logical, mode, &members);
2018            return Vec::new();
2019        }
2020
2021        let mut out: Vec<UiEvent> = self
2022            .ui_state
2023            .key_down(logical, physical, modifiers, repeat)
2024            .into_iter()
2025            .collect();
2026
2027        // Esc clears any active text selection (parallels the
2028        // pointer_down "press lands outside selectable+focusable"
2029        // path). The Escape event itself still fires so apps can
2030        // dismiss popovers / modals; the SelectionChanged is emitted
2031        // alongside it. This only runs in the non-capture-keys path,
2032        // so pressing Esc while typing in an input doesn't clobber
2033        // the input's selection — matching browser behavior.
2034        if matches!(out.first().map(|e| e.kind), Some(UiEventKind::Escape))
2035            && !self.ui_state.current_selection.is_empty()
2036        {
2037            self.ui_state.current_selection = crate::selection::Selection::default();
2038            self.ui_state.selection.drag = None;
2039            out.push(selection_event(
2040                crate::selection::Selection::default(),
2041                modifiers,
2042                None,
2043                None,
2044            ));
2045        }
2046
2047        out
2048    }
2049
2050    /// Look up the focused node's nearest [`El::arrow_nav`] parent in
2051    /// the last laid-out tree and return the group's orientation plus
2052    /// its focusable members (the navigation targets). Returns `None`
2053    /// when no node is focused, the tree hasn't been built yet, or the
2054    /// focused element isn't inside an arrow-navigable parent.
2055    fn focused_arrow_nav_group(&self) -> Option<(crate::tree::ArrowNav, Vec<UiTarget>)> {
2056        let focused = self.ui_state.focused.as_ref()?;
2057        let tree = self.last_tree.as_ref()?;
2058        focus::arrow_nav_group(tree, &focused.node_id)
2059    }
2060
2061    /// Move the focused element to the appropriate group member for
2062    /// `key`. Linear modes step by one in tree order (saturating at
2063    /// the ends — no wrap, so holding the key doesn't loop visually);
2064    /// `Home` / `End` jump to the first / last member. In a
2065    /// [`Grid`](crate::tree::ArrowNav::Grid) group, `Up` / `Down` move
2066    /// to the geometrically nearest member in the row above / below —
2067    /// disabled (unfocusable) cells aren't members, so navigation
2068    /// lands on the nearest enabled day rather than dead-ending.
2069    fn move_focus_in_group(
2070        &mut self,
2071        logical: &LogicalKey,
2072        mode: crate::tree::ArrowNav,
2073        members: &[UiTarget],
2074    ) {
2075        if members.is_empty() {
2076            return;
2077        }
2078        let focused_id = match self.ui_state.focused.as_ref() {
2079            Some(t) => t.node_id.clone(),
2080            None => return,
2081        };
2082        let idx = members.iter().position(|t| t.node_id == focused_id);
2083        let grid = mode == crate::tree::ArrowNav::Grid;
2084        let next_idx = match (logical.named(), idx) {
2085            (Some(NamedKey::ArrowUp), Some(i)) if grid => {
2086                match grid_vertical_step(members, i, -1.0) {
2087                    Some(j) => j,
2088                    None => return,
2089                }
2090            }
2091            (Some(NamedKey::ArrowDown), Some(i)) if grid => {
2092                match grid_vertical_step(members, i, 1.0) {
2093                    Some(j) => j,
2094                    None => return,
2095                }
2096            }
2097            (Some(NamedKey::ArrowUp | NamedKey::ArrowLeft), Some(i)) => i.saturating_sub(1),
2098            (Some(NamedKey::ArrowDown | NamedKey::ArrowRight), Some(i)) => {
2099                (i + 1).min(members.len() - 1)
2100            }
2101            (Some(NamedKey::Home), _) => 0,
2102            (Some(NamedKey::End), _) => members.len() - 1,
2103            _ => return,
2104        };
2105        if Some(next_idx) != idx {
2106            self.ui_state.set_focus(Some(members[next_idx].clone()));
2107            self.ui_state.set_focus_visible(true);
2108        }
2109    }
2110
2111    /// Look up the focused node in the last laid-out tree and return
2112    /// its `capture_keys` flag — i.e. whether the focused widget is a
2113    /// text-input-style consumer of raw key events. False when no
2114    /// node is focused or the tree hasn't been built yet. Hosts use
2115    /// this each frame to mirror "is a text input active?" into
2116    /// platform UI affordances (most notably the on-screen keyboard).
2117    pub fn focused_captures_keys(&self) -> bool {
2118        let Some(focused) = self.ui_state.focused.as_ref() else {
2119            return false;
2120        };
2121        let Some(tree) = self.last_tree.as_ref() else {
2122            return false;
2123        };
2124        find_capture_keys(tree, &focused.node_id).unwrap_or(false)
2125    }
2126
2127    /// OS-composed text input (printable characters after dead-key /
2128    /// shift / IME composition). Routed to the focused element as a
2129    /// `TextInput` event. Returns `None` if no node has focus, or if
2130    /// `text` is empty (some platforms emit empty composition strings
2131    /// during IME selection).
2132    pub fn text_input(&mut self, text: String) -> Option<UiEvent> {
2133        if text.is_empty() {
2134            return None;
2135        }
2136        let target = self.ui_state.focused.clone()?;
2137        let modifiers = self.ui_state.modifiers;
2138        // Caret-blink reset: typing into the focused widget is
2139        // text-editing activity. See the matching bump in `key_down`.
2140        self.ui_state.bump_caret_activity(Instant::now());
2141        Some(UiEvent {
2142            key: Some(target.key.clone()),
2143            target: Some(target),
2144            pointer: None,
2145            key_press: None,
2146            text: Some(text),
2147            selection: None,
2148            modifiers,
2149            click_count: 0,
2150            path: None,
2151            pointer_kind: None,
2152            wheel_delta: None,
2153            kind: UiEventKind::TextInput,
2154        })
2155    }
2156
2157    pub fn set_hotkeys(&mut self, hotkeys: Vec<(KeyChord, String)>) {
2158        self.ui_state.set_hotkeys(hotkeys);
2159    }
2160
2161    /// Push the app's current [`crate::selection::Selection`] into the
2162    /// runtime so the painter can draw highlight bands. Hosts call
2163    /// this once per frame alongside `set_hotkeys`, sourcing the value
2164    /// from [`crate::event::App::selection`].
2165    pub fn set_selection(&mut self, selection: crate::selection::Selection) {
2166        if self.ui_state.current_selection != selection {
2167            self.ui_state.bump_caret_activity(Instant::now());
2168        }
2169        self.ui_state.current_selection = selection;
2170    }
2171
2172    /// Resolve the runtime's current selection to a text payload using
2173    /// the most recently laid-out tree. Returns `None` when nothing is
2174    /// selected or the selection's keyed leaves are missing from the
2175    /// snapshot (typically because they scrolled out of a
2176    /// [`crate::widgets::virtual_list`] since the selection was made).
2177    ///
2178    /// This is the wiring `Ctrl+C` / `Ctrl+X` should use from a host.
2179    /// A naive "rebuild the app tree and walk it" approach silently
2180    /// breaks for virtualized panes: virtual_list rows are realized
2181    /// during layout, not build, so a freshly built tree doesn't
2182    /// contain them and selections inside a chat-style virtualized
2183    /// pane resolve to `None`. `last_tree` already has the visible
2184    /// rows realized at their live scroll offset.
2185    pub fn selected_text(&self) -> Option<String> {
2186        self.selected_text_for(&self.ui_state.current_selection)
2187    }
2188
2189    /// Like [`Self::selected_text`], but resolves an explicit
2190    /// [`crate::selection::Selection`] against the last laid-out tree —
2191    /// useful immediately after an event handler updates
2192    /// [`crate::event::App::selection`] but before the host has
2193    /// rebroadcast it via [`Self::set_selection`].
2194    pub fn selected_text_for(&self, selection: &crate::selection::Selection) -> Option<String> {
2195        let tree = self.last_tree.as_ref()?;
2196        crate::selection::selected_text(tree, selection)
2197    }
2198
2199    /// Queue toast specs onto the runtime's toast stack. Each spec
2200    /// is stamped with a monotonic id and `expires_at = now + ttl`;
2201    /// the next `prepare_layout` call drops expired entries and
2202    /// synthesizes a `toast_stack` floating layer over the rest.
2203    /// Hosts wire this from `App::drain_toasts` once per frame.
2204    pub fn push_toasts(&mut self, specs: Vec<crate::toast::ToastSpec>) {
2205        let now = Instant::now();
2206        for spec in specs {
2207            self.ui_state.push_toast(spec, now);
2208        }
2209    }
2210
2211    /// Programmatically dismiss a single toast by id. Mostly useful
2212    /// when the app wants to cancel a long-TTL toast in response to
2213    /// some external event (e.g., the connection reconnected).
2214    pub fn dismiss_toast(&mut self, id: u64) {
2215        self.ui_state.dismiss_toast(id);
2216    }
2217
2218    /// Queue programmatic focus requests by widget key. Each entry is
2219    /// resolved during the next `prepare_layout`, after the focus
2220    /// order has been rebuilt from the new tree; unmatched keys drop
2221    /// silently. Hosts wire this from [`crate::event::App::drain_focus_requests`]
2222    /// once per frame, alongside `push_toasts`.
2223    pub fn push_focus_requests(&mut self, keys: Vec<String>) {
2224        self.ui_state.push_focus_requests(keys);
2225    }
2226
2227    /// Queue programmatic scroll-to-row requests targeting virtual
2228    /// lists by key. Each request is consumed during layout of the
2229    /// matching list, where viewport height and row heights are
2230    /// known. Hosts wire this from [`crate::event::App::drain_scroll_requests`]
2231    /// once per frame, alongside `push_focus_requests`.
2232    pub fn push_scroll_requests(&mut self, requests: Vec<crate::scroll::ScrollRequest>) {
2233        self.ui_state.push_scroll_requests(requests);
2234    }
2235
2236    /// Queue programmatic [`crate::viewport::ViewportRequest`]s
2237    /// (fit-to-content / reset / center) targeting `viewport()` containers
2238    /// by key. Each is consumed during layout of the matching viewport,
2239    /// where its live rect and content extents are known. Hosts wire this
2240    /// from [`crate::event::App::drain_viewport_requests`] once per frame,
2241    /// alongside `push_scroll_requests`.
2242    pub fn push_viewport_requests(&mut self, requests: Vec<crate::viewport::ViewportRequest>) {
2243        self.ui_state.push_viewport_requests(requests);
2244    }
2245
2246    /// Queue programmatic plot view requests for the next prepare pass.
2247    /// Hosts forward [`crate::event::App::drain_plot_requests`] here once
2248    /// per frame, next to the viewport requests.
2249    pub fn push_plot_requests(&mut self, requests: Vec<crate::plot::PlotRequest>) {
2250        self.ui_state.push_plot_requests(requests);
2251    }
2252
2253    pub fn set_animation_mode(&mut self, mode: AnimationMode) {
2254        self.ui_state.set_animation_mode(mode);
2255    }
2256
2257    pub fn pointer_wheel(&mut self, x: f32, y: f32, dy: f32) -> bool {
2258        let Some(tree) = self.last_tree.as_ref() else {
2259            return false;
2260        };
2261        self.ui_state.cancel_scroll_momentum();
2262        // A 3D scene under the pointer takes the wheel as zoom, before any
2263        // scroll routing (so the scene doesn't also scroll its container).
2264        if self.ui_state.camera_wheel_zoom(tree, x, y, dy) {
2265            return true;
2266        }
2267        // A `viewport()` under the pointer likewise consumes the wheel as
2268        // cursor-anchored zoom before scroll routing, so the canvas zooms
2269        // instead of scrolling an enclosing container.
2270        if self.ui_state.viewport_wheel_zoom(tree, x, y, dy) {
2271            return true;
2272        }
2273        // A plot under the pointer consumes the wheel as cursor-anchored
2274        // zoom (the time axis, with Y auto-scaling), before scroll routing.
2275        if self.ui_state.plot_wheel_zoom(tree, x, y, dy) {
2276            return true;
2277        }
2278        self.ui_state.pointer_wheel(tree, (x, y), dy)
2279    }
2280
2281    /// Build a routed wheel event for the keyed target under `(x, y)`.
2282    ///
2283    /// Hosts should dispatch this before calling [`Self::pointer_wheel`].
2284    /// If the app consumes the returned event, skip the fallback scroll
2285    /// call; otherwise, call `pointer_wheel` to preserve Damascene's default
2286    /// scroll behavior.
2287    pub fn pointer_wheel_event(&mut self, x: f32, y: f32, dx: f32, dy: f32) -> Option<UiEvent> {
2288        if dx.abs() <= f32::EPSILON && dy.abs() <= f32::EPSILON {
2289            return None;
2290        }
2291        let tree = self.last_tree.as_ref()?;
2292        let target = hit_test::hit_test_target(tree, &self.ui_state, (x, y))?;
2293        self.ui_state.cancel_scroll_momentum();
2294        Some(UiEvent {
2295            key: Some(target.key.clone()),
2296            target: Some(target),
2297            pointer: Some((x, y)),
2298            key_press: None,
2299            text: None,
2300            selection: None,
2301            modifiers: self.ui_state.modifiers,
2302            click_count: 0,
2303            path: None,
2304            pointer_kind: Some(self.ui_state.pointer_kind),
2305            wheel_delta: Some((dx, dy)),
2306            kind: UiEventKind::PointerWheel,
2307        })
2308    }
2309
2310    /// Drain any time-driven input events whose deadline has passed
2311    /// at `now`. Currently the only such event is the touch
2312    /// long-press: a `Pending` touch held in place past
2313    /// [`LONG_PRESS_DELAY`] fires `LongPress` at the original press
2314    /// coords, usually after cancelling the originally pressed target.
2315    /// Editable capture-keys targets keep their press captured so
2316    /// movement can emit `Drag` for selection extension. The gesture
2317    /// state transitions to `LongPressed` so the eventual finger lift
2318    /// produces no further events.
2319    ///
2320    /// Hosts call this once per frame *before* dispatching pointer /
2321    /// keyboard events so the long-press fires deterministically
2322    /// before any subsequent input. Returns `Vec::new()` when no
2323    /// deadline has elapsed; cheap to call every frame.
2324    pub fn poll_input(&mut self, now: Instant) -> Vec<UiEvent> {
2325        let TouchGestureState::Pending {
2326            initial,
2327            started_at,
2328            ..
2329        } = self.ui_state.touch_gesture.clone()
2330        else {
2331            return Vec::new();
2332        };
2333        if now.duration_since(started_at) < LONG_PRESS_DELAY {
2334            return Vec::new();
2335        }
2336        let mut out = Vec::new();
2337        let modifiers = self.ui_state.modifiers;
2338        let kind = PointerKind::Touch;
2339        let (x, y) = initial;
2340        let press_target = self.ui_state.pressed.clone();
2341        let preserves_press_for_drag = press_target.as_ref().is_some_and(|t| {
2342            self.last_tree
2343                .as_ref()
2344                .and_then(|tree| find_capture_keys(tree, &t.node_id))
2345                .unwrap_or(false)
2346        });
2347        if preserves_press_for_drag {
2348            self.ui_state.pressed_secondary = None;
2349            self.ui_state.pressed_link = None;
2350            self.ui_state.selection.drag = None;
2351        } else {
2352            // PointerCancel + LongPress to the originally pressed
2353            // target. `cancel_press_for_scroll` already does the
2354            // bookkeeping (clear pressed / pressed_secondary / hovered /
2355            // selection.drag and emit PointerCancel + PointerLeave);
2356            // reuse it so scroll-cancel and non-editable long-press
2357            // cancellation stay aligned.
2358            self.cancel_press_for_scroll(&mut out, x, y, kind, modifiers);
2359        }
2360        if let Some(t) = press_target {
2361            out.push(UiEvent {
2362                key: Some(t.key.clone()),
2363                target: Some(t),
2364                pointer: Some((x, y)),
2365                key_press: None,
2366                text: None,
2367                selection: None,
2368                modifiers,
2369                click_count: 0,
2370                path: None,
2371                pointer_kind: Some(kind),
2372                wheel_delta: None,
2373                kind: UiEventKind::LongPress,
2374            });
2375        } else {
2376            // Press landed in dead space (no keyed leaf). Still fire
2377            // the LongPress with no target so window-level handlers
2378            // (drop zones, full-viewport context menus) can react.
2379            out.push(UiEvent {
2380                key: None,
2381                target: None,
2382                pointer: Some((x, y)),
2383                key_press: None,
2384                text: None,
2385                selection: None,
2386                modifiers,
2387                click_count: 0,
2388                path: None,
2389                pointer_kind: Some(kind),
2390                wheel_delta: None,
2391                kind: UiEventKind::LongPress,
2392            });
2393        }
2394        if !preserves_press_for_drag
2395            && let Some(point) = self
2396                .last_tree
2397                .as_ref()
2398                .and_then(|t| hit_test::selection_point_at(t, (x, y)))
2399        {
2400            self.start_selection_drag(point, &mut out, modifiers, (x, y), 2, kind);
2401        }
2402        self.ui_state.touch_gesture = TouchGestureState::LongPressed;
2403        out
2404    }
2405
2406    /// Time remaining until the next time-driven input deadline at
2407    /// `now`, or `None` when nothing is pending. Hosts fold this into
2408    /// their redraw scheduling so a held touch fires its long-press
2409    /// even when the user holds perfectly still — without it,
2410    /// `request_redraw` is never called and the deadline never
2411    /// fires.
2412    ///
2413    /// `Some(Duration::ZERO)` means "deadline already elapsed; call
2414    /// `poll_input` immediately."
2415    pub fn next_input_deadline(&self, now: Instant) -> Option<std::time::Duration> {
2416        if self.ui_state.has_scroll_momentum() {
2417            return Some(std::time::Duration::ZERO);
2418        }
2419        let TouchGestureState::Pending { started_at, .. } = self.ui_state.touch_gesture.clone()
2420        else {
2421            return None;
2422        };
2423        let elapsed = now.duration_since(started_at);
2424        Some(LONG_PRESS_DELAY.saturating_sub(elapsed))
2425    }
2426
2427    // ---- Per-frame staging ----
2428
2429    /// Layout + state apply + animation tick + viewport projection +
2430    /// `DrawOp` resolution. Returns the resolved op list and whether
2431    /// visual animations need another frame; writes per-stage timings
2432    /// into `timings` (`layout` + `draw_ops`).
2433    ///
2434    /// `samples_time` answers "does this shader's output depend on
2435    /// `frame.time`?" The runtime calls it once per draw op when no
2436    /// other in-flight motion has already requested a redraw; any
2437    /// `true` answer keeps `needs_redraw` set so the host idle loop
2438    /// keeps ticking. Stock shaders self-report through
2439    /// [`crate::shader::StockShader::is_continuous`]; backends layer
2440    /// on the registered set of `samples_time=true` custom shaders.
2441    /// Callers that have no time-driven shaders pass
2442    /// [`Self::no_time_shaders`].
2443    pub fn prepare_layout<F>(
2444        &mut self,
2445        root: &mut El,
2446        viewport: Rect,
2447        scale_factor: f32,
2448        timings: &mut PrepareTimings,
2449        samples_time: F,
2450    ) -> LayoutPrepared
2451    where
2452        F: Fn(&ShaderHandle) -> bool,
2453    {
2454        let t0 = Instant::now();
2455        let scroll_momentum_pending = self.ui_state.tick_scroll_momentum(t0);
2456        // Tooltip + toast synthesis run before the real layout: assign
2457        // ids first so the tooltip pass can resolve the hover anchor
2458        // by computed_id, then append the runtime-managed floating
2459        // layers. The subsequent `layout::layout` call re-assigns
2460        // (idempotently — same path shapes produce the same ids) and
2461        // lays out the appended layers alongside everything else.
2462        let mut needs_redraw = {
2463            crate::profile_span!("prepare::layout");
2464            {
2465                crate::profile_span!("prepare::layout::assign_ids");
2466                layout::assign_ids(root);
2467            }
2468            let tooltip_pending = {
2469                crate::profile_span!("prepare::layout::tooltip");
2470                tooltip::synthesize_tooltip(root, &self.ui_state, t0)
2471            };
2472            let toast_pending = {
2473                crate::profile_span!("prepare::layout::toast");
2474                toast::synthesize_toasts(root, &mut self.ui_state, t0)
2475            };
2476            {
2477                crate::profile_span!("prepare::layout::apply_metrics");
2478                self.theme.apply_metrics(root);
2479            }
2480            {
2481                crate::profile_span!("prepare::layout::layout");
2482                // `assign_ids` ran above (so tooltip/toast synthesis
2483                // could resolve nodes by id), and the synthesize
2484                // functions called `assign_id_appended` on the layers
2485                // they pushed — so the recursive id walk inside
2486                // `layout::layout` would be a wasted second pass over
2487                // the entire tree. Use `layout_post_assign` to skip it.
2488                layout::layout_post_assign(root, &mut self.ui_state, viewport);
2489                // Drop scroll requests that didn't match any virtual
2490                // list this frame (the matching list may have been
2491                // removed from the tree, or the app may have raced a
2492                // state change that retired the key).
2493                self.ui_state.clear_pending_scroll_requests();
2494                // Same for viewport requests that matched no viewport.
2495                self.ui_state.clear_pending_viewport_requests();
2496                // Bound the persistent scroll side-maps (LRU over
2497                // absent identities; issue #57).
2498                // (plus the viewport pan/zoom and plot view maps) —
2499                // one fused walk for all three.
2500                self.ui_state.gc_side_maps(root);
2501                // Resolve each plot's view (auto-fit / Y-autoscale) and
2502                // data-rect metrics now that layout has run, so draw_ops
2503                // and the gesture router read a settled view.
2504                self.ui_state.prepare_plots(root);
2505                // Drop plot requests that matched no plot this frame,
2506                // like the scroll / viewport queues above.
2507                self.ui_state.clear_pending_plot_requests();
2508            }
2509            {
2510                crate::profile_span!("prepare::layout::sync_orders");
2511                self.ui_state.sync_focus_and_selection_order(root);
2512            }
2513            {
2514                crate::profile_span!("prepare::layout::sync_popover_focus");
2515                focus::sync_popover_focus(root, &mut self.ui_state);
2516            }
2517            {
2518                // Drain after popover auto-focus so explicit app
2519                // requests win when both fire on the same frame
2520                // (e.g. a hotkey opens a popover and then jumps focus
2521                // to a non-default child).
2522                crate::profile_span!("prepare::layout::drain_focus_requests");
2523                self.ui_state.drain_focus_requests();
2524            }
2525            {
2526                crate::profile_span!("prepare::layout::apply_state");
2527                self.ui_state.apply_to_state();
2528            }
2529            self.viewport_px = self.surface_size_override.unwrap_or_else(|| {
2530                (
2531                    (viewport.w * scale_factor).ceil().max(1.0) as u32,
2532                    (viewport.h * scale_factor).ceil().max(1.0) as u32,
2533                )
2534            });
2535            let animations = {
2536                crate::profile_span!("prepare::layout::tick_animations");
2537                self.ui_state
2538                    .tick_visual_animations(root, Instant::now(), self.theme.palette())
2539            };
2540            // Advance keyed scene cameras toward their goals (data
2541            // re-centre / focus requests spring; gestures land in slice c).
2542            // Unsettled cameras keep the frame requesting redraw, like a
2543            // settling visual animation.
2544            let cameras_animating = {
2545                crate::profile_span!("prepare::layout::tick_cameras");
2546                self.ui_state.tick_scene_cameras(root, Instant::now())
2547            };
2548            // A viewport smooth navigation in flight keeps frames
2549            // coming until it lands (the flight is sampled inside the
2550            // layout pass above; arrival removes the entry). Expired
2551            // flights layout didn't reach (scroll-pruned subtrees)
2552            // don't pin frames — they snap lazily when next laid out.
2553            let viewport_flights = self.ui_state.viewport.any_flight_animating(Instant::now());
2554            animations
2555                || cameras_animating
2556                || viewport_flights
2557                || tooltip_pending
2558                || toast_pending
2559                || scroll_momentum_pending
2560        };
2561        let t_after_layout = Instant::now();
2562        timings.layout_intrinsic_cache = layout::take_intrinsic_cache_stats();
2563        timings.layout_prune = layout::take_prune_stats();
2564        let (ops, draw_ops_stats) = {
2565            crate::profile_span!("prepare::draw_ops");
2566            let mut stats = DrawOpsStats::default();
2567            let ops = draw_ops::draw_ops_with_theme_and_stats(
2568                root,
2569                &self.ui_state,
2570                &self.theme,
2571                &mut stats,
2572            );
2573            (ops, stats)
2574        };
2575        let t_after_draw_ops = Instant::now();
2576        timings.layout = t_after_layout - t0;
2577        timings.draw_ops = t_after_draw_ops - t_after_layout;
2578        timings.draw_ops_culled_text_ops = draw_ops_stats.culled_text_ops;
2579        // Surface the hovered scatter point the draw-op pass picked (a frame
2580        // late, like the depth map) so the app can read it next build.
2581        self.ui_state
2582            .set_hovered_scene_point(draw_ops_stats.hovered_scene_point);
2583        timings.text_layout_cache = crate::text::metrics::take_shape_cache_stats();
2584
2585        // Two-lane deadline split:
2586        //
2587        // - **Layout lane**: signals that require a rebuild + layout
2588        //   pass to render correctly on the next frame. Animation
2589        //   settling, tooltip / toast pending, and widget
2590        //   `redraw_within` requests all change the El tree's visual
2591        //   state at their deadline.
2592        // - **Paint lane**: time-driven shaders (stock continuous, or
2593        //   `samples_time=true` custom). The El tree is unchanged; only
2594        //   `frame.time` needs to advance. Hosts that want to skip
2595        //   layout for these can run a paint-only frame via
2596        //   [`Self::prepare_paint_cached`] + [`Self::last_ops`].
2597        //
2598        // Bool-shaped layout signals (animations settling, tooltip /
2599        // toast pending) map to `Duration::ZERO`. The widget
2600        // `redraw_within` aggregate is folded in via `min`.
2601        let shader_needs_redraw = ops.iter().any(|op| op_is_continuous(op, &samples_time));
2602        let widget_redraw = draw_ops_stats.redraw_within;
2603        // Fold time-driven input in so held touches and active touch
2604        // momentum drive redraws even when no other animation / shader
2605        // / widget signal is asking for one. Otherwise the host falls
2606        // idle and input physics never advance until the next pointer
2607        // event.
2608        let input_deadline = self.next_input_deadline(Instant::now());
2609        let widget_redraw = match (widget_redraw, input_deadline) {
2610            (Some(a), Some(b)) => Some(a.min(b)),
2611            (a, b) => a.or(b),
2612        };
2613
2614        let next_layout_redraw_in = match (needs_redraw, widget_redraw) {
2615            // An immediate redraw is already due; a widget deadline can
2616            // only be later, so it never extends the wait.
2617            (true, _) => Some(std::time::Duration::ZERO),
2618            (false, d) => d,
2619        };
2620        let next_paint_redraw_in = if shader_needs_redraw {
2621            Some(std::time::Duration::ZERO)
2622        } else {
2623            None
2624        };
2625        if next_layout_redraw_in.is_some() || next_paint_redraw_in.is_some() {
2626            needs_redraw = true;
2627        }
2628
2629        // Ops are returned by value (not cached on `self`) so the
2630        // caller can borrow them into the per-frame `prepare_paint`
2631        // without also locking `&mut self`. The wrapper hands them
2632        // back to `self.last_ops` after paint — see [`Self::last_ops`].
2633        LayoutPrepared {
2634            ops,
2635            needs_redraw,
2636            next_layout_redraw_in,
2637            next_paint_redraw_in,
2638        }
2639    }
2640
2641    /// Run [`Self::prepare_paint`] against the cached
2642    /// [`Self::last_ops`] from the most recent
2643    /// [`Self::prepare_layout`] call. Used by hosts that service a
2644    /// paint-only redraw (driven by
2645    /// [`PrepareResult::next_paint_redraw_in`]) without re-running
2646    /// build + layout.
2647    ///
2648    /// The caller is responsible for the same paint-time invariants as
2649    /// [`Self::prepare_paint`]: call `text.frame_begin()` first, and
2650    /// ensure no input has been processed since the last
2651    /// `prepare_layout` (otherwise hover / press state is stale and a
2652    /// full prepare is required instead).
2653    pub fn prepare_paint_cached<F1, F2>(
2654        &mut self,
2655        is_registered: F1,
2656        samples_backdrop: F2,
2657        text: &mut dyn TextRecorder,
2658        scale_factor: f32,
2659        timings: &mut PrepareTimings,
2660    ) where
2661        F1: Fn(&ShaderHandle) -> bool,
2662        F2: Fn(&ShaderHandle) -> bool,
2663    {
2664        // `prepare_paint` only touches `self.{quad_scratch, runs,
2665        // paint_items}`, not `self.last_ops`, but the borrow checker
2666        // can't see that — split-borrow via `mem::take` + restore.
2667        let ops = std::mem::take(&mut self.last_ops);
2668        self.prepare_paint(
2669            &ops,
2670            is_registered,
2671            samples_backdrop,
2672            text,
2673            scale_factor,
2674            timings,
2675        );
2676        self.last_ops = ops;
2677    }
2678
2679    /// Standard "no custom time-driven shaders" closure for
2680    /// [`Self::prepare_layout`]. Backends that haven't wired up the
2681    /// custom-shader registry yet pass this; only stock shaders that
2682    /// self-report via `is_continuous()` participate in the scan.
2683    pub fn no_time_shaders(_shader: &ShaderHandle) -> bool {
2684        false
2685    }
2686
2687    /// Re-evaluate the paint-lane deadline against the currently-cached
2688    /// [`Self::last_ops`]. Used by backends serving a paint-only frame
2689    /// (`repaint(...)`) so they can re-arm
2690    /// [`PrepareResult::next_paint_redraw_in`] without re-running
2691    /// `prepare_layout`. Returns `Some(Duration::ZERO)` when any cached
2692    /// op still binds a continuous shader.
2693    pub fn scan_continuous_shaders<F>(&self, samples_time: F) -> Option<std::time::Duration>
2694    where
2695        F: Fn(&ShaderHandle) -> bool,
2696    {
2697        let any = self
2698            .last_ops
2699            .iter()
2700            .any(|op| op_is_continuous(op, &samples_time));
2701        if any {
2702            Some(std::time::Duration::ZERO)
2703        } else {
2704            None
2705        }
2706    }
2707
2708    /// Walk the resolved `DrawOp` list, packing quads into
2709    /// `quad_scratch` + grouping them into `runs`, interleaving text
2710    /// records via the backend-supplied [`TextRecorder`]. Returns the
2711    /// number of quad instances written (so the backend can size its
2712    /// instance buffer).
2713    ///
2714    /// Callers must call `text.frame_begin()` themselves *before*
2715    /// invoking this — `prepare_paint` does not call it for them
2716    /// because backends often want to clear other per-frame text
2717    /// scratch in the same step.
2718    pub fn prepare_paint<F1, F2>(
2719        &mut self,
2720        ops: &[DrawOp],
2721        is_registered: F1,
2722        samples_backdrop: F2,
2723        text: &mut dyn TextRecorder,
2724        scale_factor: f32,
2725        timings: &mut PrepareTimings,
2726    ) where
2727        F1: Fn(&ShaderHandle) -> bool,
2728        F2: Fn(&ShaderHandle) -> bool,
2729    {
2730        crate::profile_span!("prepare::paint");
2731        let t0 = Instant::now();
2732        self.quad_scratch.clear();
2733        self.runs.clear();
2734        self.paint_items.clear();
2735
2736        let mut current: Option<(ShaderHandle, Option<PhysicalScissor>)> = None;
2737        let mut run_first: u32 = 0;
2738        // At most one snapshot per frame. Auto-inserted before
2739        // the first paint that samples the backdrop.
2740        let mut snapshot_emitted = false;
2741
2742        for op in ops {
2743            match op {
2744                DrawOp::Quad {
2745                    rect,
2746                    scissor,
2747                    shader,
2748                    uniforms,
2749                    ..
2750                } => {
2751                    if !is_registered(shader) {
2752                        continue;
2753                    }
2754                    if !paint_rect_visible(*rect, *scissor, self.viewport_px, scale_factor) {
2755                        timings.paint_culled_ops += 1;
2756                        continue;
2757                    }
2758                    let phys = physical_scissor(*scissor, scale_factor, self.viewport_px);
2759                    if matches!(phys, Some(s) if s.w == 0 || s.h == 0) {
2760                        timings.paint_culled_ops += 1;
2761                        continue;
2762                    }
2763                    if !snapshot_emitted && samples_backdrop(shader) {
2764                        close_run(
2765                            &mut self.runs,
2766                            &mut self.paint_items,
2767                            current,
2768                            run_first,
2769                            self.quad_scratch.len() as u32,
2770                        );
2771                        current = None;
2772                        run_first = self.quad_scratch.len() as u32;
2773                        self.paint_items.push(PaintItem::BackdropSnapshot);
2774                        snapshot_emitted = true;
2775                    }
2776                    // Custom shaders with an introspected slot map get
2777                    // name-routed uniforms with drift detection; stock
2778                    // shaders and unintrospected customs keep the
2779                    // positional path (issue #99).
2780                    let inst = match shader {
2781                        ShaderHandle::Custom(name) => match self.shader_slot_maps.get(name) {
2782                            Some(slot_map) => {
2783                                let mut unknown = Vec::new();
2784                                let inst = crate::paint::pack_instance_named(
2785                                    *rect,
2786                                    uniforms,
2787                                    self.working_color_space,
2788                                    slot_map,
2789                                    &mut unknown,
2790                                );
2791                                for key in unknown {
2792                                    if self.warned_shader_uniforms.insert((name, key)) {
2793                                        let declared: Vec<&str> =
2794                                            slot_map.declared().map(|(n, _)| n).collect();
2795                                        log::warn!(
2796                                            "damascene: shader `{name}` declares no \
2797                                                 instance attribute named `{key}` — the \
2798                                                 value lands nowhere. Declared names: \
2799                                                 {declared:?} (plus the positional aliases \
2800                                                 vec_a..vec_e). Rename one side so the Rust \
2801                                                 packing and WGSL unpacking agree."
2802                                        );
2803                                    }
2804                                }
2805                                inst
2806                            }
2807                            None => {
2808                                pack_instance_in(*rect, *shader, uniforms, self.working_color_space)
2809                            }
2810                        },
2811                        _ => pack_instance_in(*rect, *shader, uniforms, self.working_color_space),
2812                    };
2813
2814                    let key = (*shader, phys);
2815                    if current != Some(key) {
2816                        close_run(
2817                            &mut self.runs,
2818                            &mut self.paint_items,
2819                            current,
2820                            run_first,
2821                            self.quad_scratch.len() as u32,
2822                        );
2823                        current = Some(key);
2824                        run_first = self.quad_scratch.len() as u32;
2825                    }
2826                    self.quad_scratch.push(inst);
2827                }
2828                DrawOp::GlyphRun {
2829                    rect,
2830                    scissor,
2831                    color,
2832                    text: glyph_text,
2833                    size,
2834                    line_height,
2835                    family,
2836                    mono_family,
2837                    weight,
2838                    mono,
2839                    wrap,
2840                    anchor,
2841                    underline,
2842                    strikethrough,
2843                    link,
2844                    tabular_numerals,
2845                    letter_spacing,
2846                    ..
2847                } => {
2848                    let phys = physical_scissor(*scissor, scale_factor, self.viewport_px);
2849                    if matches!(phys, Some(s) if s.w == 0 || s.h == 0) {
2850                        timings.paint_culled_ops += 1;
2851                        continue;
2852                    }
2853                    if !paint_rect_visible(*rect, *scissor, self.viewport_px, scale_factor) {
2854                        timings.paint_culled_ops += 1;
2855                        continue;
2856                    }
2857                    close_run(
2858                        &mut self.runs,
2859                        &mut self.paint_items,
2860                        current,
2861                        run_first,
2862                        self.quad_scratch.len() as u32,
2863                    );
2864                    current = None;
2865                    run_first = self.quad_scratch.len() as u32;
2866
2867                    let mut style = crate::text::atlas::RunStyle::new(*weight, *color)
2868                        .family(*family)
2869                        .mono_family(*mono_family);
2870                    if *mono {
2871                        style = style.mono();
2872                    }
2873                    if *tabular_numerals {
2874                        style = style.tabular_numerals();
2875                    }
2876                    if *letter_spacing != 0.0 {
2877                        style = style.with_letter_spacing(*letter_spacing);
2878                    }
2879                    if *underline {
2880                        style = style.underline();
2881                    }
2882                    if *strikethrough {
2883                        style = style.strikethrough();
2884                    }
2885                    if let Some(url) = link {
2886                        style = style.with_link(url.clone());
2887                    }
2888                    let layers = text.record(
2889                        *rect,
2890                        phys,
2891                        &style,
2892                        glyph_text,
2893                        *size,
2894                        *line_height,
2895                        *wrap,
2896                        *anchor,
2897                        scale_factor,
2898                    );
2899                    for index in layers {
2900                        self.paint_items.push(PaintItem::Text(index));
2901                    }
2902                }
2903                DrawOp::AttributedText {
2904                    rect,
2905                    scissor,
2906                    runs,
2907                    size,
2908                    line_height,
2909                    wrap,
2910                    anchor,
2911                    ..
2912                } => {
2913                    let phys = physical_scissor(*scissor, scale_factor, self.viewport_px);
2914                    if matches!(phys, Some(s) if s.w == 0 || s.h == 0) {
2915                        timings.paint_culled_ops += 1;
2916                        continue;
2917                    }
2918                    if !paint_rect_visible(*rect, *scissor, self.viewport_px, scale_factor) {
2919                        timings.paint_culled_ops += 1;
2920                        continue;
2921                    }
2922                    close_run(
2923                        &mut self.runs,
2924                        &mut self.paint_items,
2925                        current,
2926                        run_first,
2927                        self.quad_scratch.len() as u32,
2928                    );
2929                    current = None;
2930                    run_first = self.quad_scratch.len() as u32;
2931
2932                    let layers = text.record_runs(
2933                        *rect,
2934                        phys,
2935                        runs,
2936                        *size,
2937                        *line_height,
2938                        *wrap,
2939                        *anchor,
2940                        scale_factor,
2941                    );
2942                    for index in layers {
2943                        self.paint_items.push(PaintItem::Text(index));
2944                    }
2945                }
2946                DrawOp::Icon {
2947                    rect,
2948                    scissor,
2949                    source,
2950                    color,
2951                    size,
2952                    stroke_width,
2953                    ..
2954                } => {
2955                    let phys = physical_scissor(*scissor, scale_factor, self.viewport_px);
2956                    if matches!(phys, Some(s) if s.w == 0 || s.h == 0) {
2957                        timings.paint_culled_ops += 1;
2958                        continue;
2959                    }
2960                    if !paint_rect_visible(*rect, *scissor, self.viewport_px, scale_factor) {
2961                        timings.paint_culled_ops += 1;
2962                        continue;
2963                    }
2964                    close_run(
2965                        &mut self.runs,
2966                        &mut self.paint_items,
2967                        current,
2968                        run_first,
2969                        self.quad_scratch.len() as u32,
2970                    );
2971                    current = None;
2972                    run_first = self.quad_scratch.len() as u32;
2973
2974                    let recorded = text.record_icon(
2975                        *rect,
2976                        phys,
2977                        source,
2978                        *color,
2979                        *size,
2980                        *stroke_width,
2981                        scale_factor,
2982                    );
2983                    match recorded {
2984                        RecordedPaint::Text(layers) => {
2985                            for index in layers {
2986                                self.paint_items.push(PaintItem::Text(index));
2987                            }
2988                        }
2989                        RecordedPaint::Icon(runs) => {
2990                            for index in runs {
2991                                self.paint_items.push(PaintItem::IconRun(index));
2992                            }
2993                        }
2994                    }
2995                }
2996                DrawOp::Image {
2997                    rect,
2998                    scissor,
2999                    image,
3000                    tint,
3001                    radius,
3002                    fit,
3003                    range_limit,
3004                    ..
3005                } => {
3006                    let phys = physical_scissor(*scissor, scale_factor, self.viewport_px);
3007                    if matches!(phys, Some(s) if s.w == 0 || s.h == 0) {
3008                        timings.paint_culled_ops += 1;
3009                        continue;
3010                    }
3011                    if !paint_rect_visible(*rect, *scissor, self.viewport_px, scale_factor) {
3012                        timings.paint_culled_ops += 1;
3013                        continue;
3014                    }
3015                    close_run(
3016                        &mut self.runs,
3017                        &mut self.paint_items,
3018                        current,
3019                        run_first,
3020                        self.quad_scratch.len() as u32,
3021                    );
3022                    current = None;
3023                    run_first = self.quad_scratch.len() as u32;
3024
3025                    let recorded = text.record_image(
3026                        *rect,
3027                        phys,
3028                        image,
3029                        *tint,
3030                        *radius,
3031                        *fit,
3032                        *range_limit,
3033                        scale_factor,
3034                    );
3035                    for index in recorded {
3036                        self.paint_items.push(PaintItem::Image(index));
3037                    }
3038                }
3039                DrawOp::AppTexture {
3040                    rect,
3041                    scissor,
3042                    texture,
3043                    alpha,
3044                    transform,
3045                    ..
3046                } => {
3047                    let phys = physical_scissor(*scissor, scale_factor, self.viewport_px);
3048                    if matches!(phys, Some(s) if s.w == 0 || s.h == 0) {
3049                        timings.paint_culled_ops += 1;
3050                        continue;
3051                    }
3052                    if !paint_rect_visible(*rect, *scissor, self.viewport_px, scale_factor) {
3053                        timings.paint_culled_ops += 1;
3054                        continue;
3055                    }
3056                    close_run(
3057                        &mut self.runs,
3058                        &mut self.paint_items,
3059                        current,
3060                        run_first,
3061                        self.quad_scratch.len() as u32,
3062                    );
3063                    current = None;
3064                    run_first = self.quad_scratch.len() as u32;
3065
3066                    let recorded = text.record_app_texture(
3067                        *rect,
3068                        phys,
3069                        texture,
3070                        *alpha,
3071                        *transform,
3072                        scale_factor,
3073                    );
3074                    for index in recorded {
3075                        self.paint_items.push(PaintItem::AppTexture(index));
3076                    }
3077                }
3078                DrawOp::Vector {
3079                    rect,
3080                    scissor,
3081                    asset,
3082                    render_mode,
3083                    ..
3084                } => {
3085                    let phys = physical_scissor(*scissor, scale_factor, self.viewport_px);
3086                    if matches!(phys, Some(s) if s.w == 0 || s.h == 0) {
3087                        timings.paint_culled_ops += 1;
3088                        continue;
3089                    }
3090                    if !paint_rect_visible(*rect, *scissor, self.viewport_px, scale_factor) {
3091                        timings.paint_culled_ops += 1;
3092                        continue;
3093                    }
3094                    close_run(
3095                        &mut self.runs,
3096                        &mut self.paint_items,
3097                        current,
3098                        run_first,
3099                        self.quad_scratch.len() as u32,
3100                    );
3101                    current = None;
3102                    run_first = self.quad_scratch.len() as u32;
3103
3104                    let recorded =
3105                        text.record_vector(*rect, phys, asset, *render_mode, scale_factor);
3106                    for index in recorded {
3107                        self.paint_items.push(PaintItem::Vector(index));
3108                    }
3109                }
3110                DrawOp::Scene3D {
3111                    id,
3112                    rect,
3113                    scissor,
3114                    scene,
3115                } => {
3116                    let phys = physical_scissor(*scissor, scale_factor, self.viewport_px);
3117                    if matches!(phys, Some(s) if s.w == 0 || s.h == 0) {
3118                        timings.paint_culled_ops += 1;
3119                        continue;
3120                    }
3121                    if !paint_rect_visible(*rect, *scissor, self.viewport_px, scale_factor) {
3122                        timings.paint_culled_ops += 1;
3123                        continue;
3124                    }
3125                    // Close the current quad run so paint ordering stays
3126                    // correct: the scene composites at this position, after
3127                    // everything painted beneath it. Backends without a
3128                    // scene renderer leave the default no-op recorder, so no
3129                    // `PaintItem` is emitted and the scene draws nothing.
3130                    close_run(
3131                        &mut self.runs,
3132                        &mut self.paint_items,
3133                        current,
3134                        run_first,
3135                        self.quad_scratch.len() as u32,
3136                    );
3137                    current = None;
3138                    run_first = self.quad_scratch.len() as u32;
3139
3140                    let recorded = text.record_scene3d(*rect, phys, id, scene, scale_factor);
3141                    for index in recorded {
3142                        self.paint_items.push(PaintItem::Scene3D(index));
3143                    }
3144                }
3145                DrawOp::BackdropSnapshot => {
3146                    close_run(
3147                        &mut self.runs,
3148                        &mut self.paint_items,
3149                        current,
3150                        run_first,
3151                        self.quad_scratch.len() as u32,
3152                    );
3153                    current = None;
3154                    run_first = self.quad_scratch.len() as u32;
3155                    // Cap at one snapshot per frame; an explicit op only
3156                    // lands if the auto-emitter hasn't fired yet.
3157                    if !snapshot_emitted {
3158                        self.paint_items.push(PaintItem::BackdropSnapshot);
3159                        snapshot_emitted = true;
3160                    }
3161                }
3162            }
3163        }
3164        close_run(
3165            &mut self.runs,
3166            &mut self.paint_items,
3167            current,
3168            run_first,
3169            self.quad_scratch.len() as u32,
3170        );
3171        timings.paint = Instant::now() - t0;
3172    }
3173
3174    /// Take a clone of the laid-out tree for next-frame hit-testing.
3175    /// Call after the per-frame work completes (GPU upload, atlas
3176    /// flush, etc.) so the snapshot reflects final geometry. Writes
3177    /// `timings.snapshot`.
3178    ///
3179    /// Prefer [`Self::snapshot_owned`] when the caller is done with the
3180    /// tree — it skips the whole-tree deep clone.
3181    pub fn snapshot(&mut self, root: &El, timings: &mut PrepareTimings) {
3182        self.snapshot_owned(root.clone(), timings);
3183    }
3184
3185    /// Like [`Self::snapshot`] but takes ownership of the laid-out
3186    /// tree, avoiding the deep clone entirely. This is the per-frame
3187    /// path for the backends: the tree is rebuilt from scratch next
3188    /// frame anyway, so handing it over costs nothing.
3189    pub fn snapshot_owned(&mut self, root: El, timings: &mut PrepareTimings) {
3190        crate::profile_span!("prepare::snapshot");
3191        let t0 = Instant::now();
3192        self.last_tree_has_links = tree_has_links(&root);
3193        self.last_tree = Some(root);
3194        timings.snapshot = Instant::now() - t0;
3195    }
3196
3197    /// Resolve the pointer cursor from the snapshot tree. Hosts call
3198    /// this right after `prepare` (which consumes the tree into the
3199    /// snapshot); paint-only frames keep the previous cursor.
3200    pub fn snapshot_cursor(&self) -> crate::cursor::Cursor {
3201        self.last_tree
3202            .as_ref()
3203            .map(|tree| self.ui_state.cursor(tree))
3204            .unwrap_or_default()
3205    }
3206
3207    /// The snapshot tree, but only when it can possibly yield a link —
3208    /// the read side of the once-per-frame `last_tree_has_links` flag.
3209    /// Per-pointer-event `link_at` walks go through this so link-free
3210    /// apps skip the walk entirely.
3211    fn linkable_tree(&self) -> Option<&El> {
3212        self.last_tree.as_ref().filter(|_| self.last_tree_has_links)
3213    }
3214}
3215
3216fn paint_rect_visible(
3217    rect: Rect,
3218    scissor: Option<Rect>,
3219    viewport_px: (u32, u32),
3220    scale_factor: f32,
3221) -> bool {
3222    if rect.w <= 0.0 || rect.h <= 0.0 {
3223        return false;
3224    }
3225    let scale = scale_factor.max(f32::EPSILON);
3226    let viewport = Rect::new(
3227        0.0,
3228        0.0,
3229        viewport_px.0 as f32 / scale,
3230        viewport_px.1 as f32 / scale,
3231    );
3232    let Some(clip) = scissor.map_or(Some(viewport), |s| s.intersect(viewport)) else {
3233        return false;
3234    };
3235    rect.intersect(clip).is_some()
3236}
3237
3238fn target_id_in_subtree(root_id: &str, target_id: &str) -> bool {
3239    target_id == root_id
3240        || target_id
3241            .strip_prefix(root_id)
3242            .is_some_and(|rest| rest.starts_with('.'))
3243}
3244
3245/// Whether this op binds a shader whose output depends on `frame.time`.
3246/// Stock shaders self-report through
3247/// [`crate::shader::StockShader::is_continuous`]; custom shaders
3248/// answer through the host-supplied closure (which the backend wires
3249/// to its `samples_time=true` registration set). See
3250/// [`RunnerCore::prepare_layout`].
3251fn op_is_continuous<F>(op: &DrawOp, samples_time: &F) -> bool
3252where
3253    F: Fn(&ShaderHandle) -> bool,
3254{
3255    match op.shader() {
3256        Some(handle @ ShaderHandle::Stock(s)) => s.is_continuous() || samples_time(handle),
3257        Some(handle @ ShaderHandle::Custom(_)) => samples_time(handle),
3258        None => false,
3259    }
3260}
3261
3262/// Vertical step inside an [`crate::tree::ArrowNav::Grid`] group:
3263/// from member `from`, find the geometrically nearest member whose
3264/// center sits strictly above (`dir < 0`) or below (`dir > 0`) — first
3265/// by vertical distance (nearest row), then by horizontal distance
3266/// (nearest column within it). Layout geometry rather than index
3267/// arithmetic keeps the walk correct when rows have gaps: unfocusable
3268/// cells (disabled days) simply aren't members. Returns `None` at the
3269/// grid's edge — the focus stays put, matching the linear modes'
3270/// saturating ends.
3271fn grid_vertical_step(members: &[UiTarget], from: usize, dir: f32) -> Option<usize> {
3272    let cur = &members[from].rect;
3273    let (cx, cy) = (cur.x + cur.w / 2.0, cur.y + cur.h / 2.0);
3274    members
3275        .iter()
3276        .enumerate()
3277        .filter(|(i, t)| {
3278            let ty = t.rect.y + t.rect.h / 2.0;
3279            // Strictly past the focused cell's own row in `dir`; half
3280            // the cell height as the row threshold tolerates sub-pixel
3281            // layout jitter without matching same-row neighbors.
3282            *i != from && (ty - cy) * dir > cur.h / 2.0
3283        })
3284        .min_by(|(_, a), (_, b)| {
3285            let key = |t: &UiTarget| {
3286                let tx = t.rect.x + t.rect.w / 2.0;
3287                let ty = t.rect.y + t.rect.h / 2.0;
3288                ((ty - cy).abs(), (tx - cx).abs())
3289            };
3290            key(a)
3291                .partial_cmp(&key(b))
3292                .unwrap_or(std::cmp::Ordering::Equal)
3293        })
3294        .map(|(i, _)| i)
3295}
3296
3297/// Find the `capture_keys` flag of the node whose `computed_id`
3298/// equals `id`, walking the laid-out tree. Returns `None` when the id
3299/// isn't found (the focused target outlived its node — a one-frame
3300/// race after a rebuild).
3301/// Whether any node in the tree carries a text-link run. Computed once
3302/// per [`RunnerCore::snapshot`] to gate the per-pointer-event
3303/// [`hit_test::link_at`] walks.
3304fn tree_has_links(node: &El) -> bool {
3305    node.text_link.is_some() || node.children.iter().any(tree_has_links)
3306}
3307
3308pub(crate) fn find_capture_keys(node: &El, id: &str) -> Option<bool> {
3309    if node.computed_id == id.into() {
3310        return Some(node.capture_keys);
3311    }
3312    node.children.iter().find_map(|c| find_capture_keys(c, id))
3313}
3314
3315/// Walk the tree looking for the node with `computed_id == id` and
3316/// return whether it (or any ancestor on the path to it) opted into
3317/// [`crate::tree::El::consumes_touch_drag`]. Returns `None` if the
3318/// id isn't in the tree.
3319///
3320/// Inheritance lets a compound widget mark its outer surface and
3321/// have presses on inner keyed children — a slider's thumb, the
3322/// number-scrubber's handle — also consume touch drag without each
3323/// piece needing to flip the flag.
3324fn find_consumes_touch_drag(node: &El, id: &str, ancestor_consumes: bool) -> Option<bool> {
3325    let consumes = ancestor_consumes || node.consumes_touch_drag;
3326    if node.computed_id == id.into() {
3327        return Some(consumes);
3328    }
3329    node.children
3330        .iter()
3331        .find_map(|c| find_consumes_touch_drag(c, id, consumes))
3332}
3333
3334/// Walk the tree looking for the node with `computed_id == id` and
3335/// return whether it sits inside the subtree rooted at the node with
3336/// `computed_id == ancestor_id` (the ancestor itself counts). Returns
3337/// `None` if `id` isn't in the tree. Same shape as
3338/// [`find_consumes_touch_drag`], accumulating containment instead of a
3339/// flag.
3340fn find_inside_subtree(
3341    node: &El,
3342    ancestor_id: &str,
3343    id: &str,
3344    inside_ancestor: bool,
3345) -> Option<bool> {
3346    let inside = inside_ancestor || node.computed_id == ancestor_id.into();
3347    if node.computed_id == id.into() {
3348        return Some(inside);
3349    }
3350    node.children
3351        .iter()
3352        .find_map(|c| find_inside_subtree(c, ancestor_id, id, inside))
3353}
3354
3355/// Construct a `SelectionChanged` event carrying the new selection.
3356fn selection_event(
3357    new_sel: crate::selection::Selection,
3358    modifiers: KeyModifiers,
3359    pointer: Option<(f32, f32)>,
3360    pointer_kind: Option<PointerKind>,
3361) -> UiEvent {
3362    UiEvent {
3363        kind: UiEventKind::SelectionChanged,
3364        key: None,
3365        target: None,
3366        pointer,
3367        key_press: None,
3368        text: None,
3369        selection: Some(new_sel),
3370        modifiers,
3371        click_count: 0,
3372        path: None,
3373        pointer_kind,
3374        wheel_delta: None,
3375    }
3376}
3377
3378/// Resolve the head's [`SelectionPoint`] for the current pointer
3379/// position during a drag. Browser-style projection rules:
3380///
3381/// - If the pointer hits a selectable leaf, head goes there.
3382/// - Otherwise, head goes to the closest selectable leaf in document
3383///   order, with `(x, y)` projected onto that leaf's vertical extent.
3384///   Above all leaves → first leaf at byte 0; below all → last leaf
3385///   at end; in the gap between two adjacent leaves → whichever is
3386///   nearer in y.
3387/// - Horizontally outside the chosen leaf's text → snap to the
3388///   leaf's left edge (byte 0) or right edge (`text.len()`).
3389fn head_for_drag(
3390    root: &El,
3391    ui_state: &UiState,
3392    point: (f32, f32),
3393) -> Option<crate::selection::SelectionPoint> {
3394    if let Some(p) = hit_test::selection_point_at(root, point) {
3395        return Some(p);
3396    }
3397
3398    let order = &ui_state.selection.order;
3399    if order.is_empty() {
3400        return None;
3401    }
3402    // Prefer a leaf whose vertical extent contains the pointer's y;
3403    // otherwise pick the y-closest leaf. min_by visits in document
3404    // order so ties (multiple leaves at the same y-distance) resolve
3405    // to the earliest one.
3406    let target = order
3407        .iter()
3408        .find(|t| point.1 >= t.rect.y && point.1 < t.rect.y + t.rect.h)
3409        .or_else(|| {
3410            order.iter().min_by(|a, b| {
3411                let da = y_distance(a.rect, point.1);
3412                let db = y_distance(b.rect, point.1);
3413                da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
3414            })
3415        })?;
3416    let target_rect = target.rect;
3417    let cy = point
3418        .1
3419        .clamp(target_rect.y, target_rect.y + target_rect.h - 1.0);
3420    if let Some(p) = hit_test::selection_point_at(root, (point.0, cy)) {
3421        return Some(p);
3422    }
3423    // Couldn't hit-test (likely because the pointer's x is outside
3424    // the leaf's rendered text width). Snap to the nearest edge.
3425    let leaf_len = find_text_len(root, &target.node_id).unwrap_or(0);
3426    let byte = if point.0 < target_rect.x { 0 } else { leaf_len };
3427    Some(crate::selection::SelectionPoint {
3428        key: target.key.clone(),
3429        byte,
3430    })
3431}
3432
3433fn selection_range_for_drag(
3434    root: &El,
3435    ui_state: &UiState,
3436    drag: &crate::state::SelectionDrag,
3437    raw_head: crate::selection::SelectionPoint,
3438) -> (
3439    crate::selection::SelectionPoint,
3440    crate::selection::SelectionPoint,
3441) {
3442    match drag.granularity {
3443        SelectionDragGranularity::Character => (drag.anchor.clone(), raw_head),
3444        SelectionDragGranularity::Word => {
3445            let text = crate::selection::find_keyed_text(root, &raw_head.key).unwrap_or_default();
3446            let (lo, hi) = crate::selection::word_range_at(&text, raw_head.byte);
3447            if point_cmp(ui_state, &raw_head, &drag.anchor) == Ordering::Less {
3448                (
3449                    drag.head.clone(),
3450                    crate::selection::SelectionPoint::new(raw_head.key, lo),
3451                )
3452            } else {
3453                (
3454                    drag.anchor.clone(),
3455                    crate::selection::SelectionPoint::new(raw_head.key, hi),
3456                )
3457            }
3458        }
3459        SelectionDragGranularity::Leaf => {
3460            let len = crate::selection::find_keyed_text(root, &raw_head.key)
3461                .map(|text| text.len())
3462                .unwrap_or(raw_head.byte);
3463            if point_cmp(ui_state, &raw_head, &drag.anchor) == Ordering::Less {
3464                (
3465                    drag.head.clone(),
3466                    crate::selection::SelectionPoint::new(raw_head.key, 0),
3467                )
3468            } else {
3469                (
3470                    drag.anchor.clone(),
3471                    crate::selection::SelectionPoint::new(raw_head.key, len),
3472                )
3473            }
3474        }
3475    }
3476}
3477
3478fn point_cmp(
3479    ui_state: &UiState,
3480    a: &crate::selection::SelectionPoint,
3481    b: &crate::selection::SelectionPoint,
3482) -> Ordering {
3483    let order_index = |key: &str| {
3484        ui_state
3485            .selection
3486            .order
3487            .iter()
3488            .position(|target| target.key == key)
3489            .unwrap_or(usize::MAX)
3490    };
3491    order_index(&a.key)
3492        .cmp(&order_index(&b.key))
3493        .then_with(|| a.byte.cmp(&b.byte))
3494}
3495
3496fn y_distance(rect: Rect, y: f32) -> f32 {
3497    if y < rect.y {
3498        rect.y - y
3499    } else if y > rect.y + rect.h {
3500        y - (rect.y + rect.h)
3501    } else {
3502        0.0
3503    }
3504}
3505
3506fn find_text_len(node: &El, id: &str) -> Option<usize> {
3507    if node.computed_id == id.into() {
3508        if let Some(source) = &node.selection_source {
3509            return Some(source.visible_len());
3510        }
3511        return node.text.as_ref().map(|t| t.len());
3512    }
3513    node.children.iter().find_map(|c| find_text_len(c, id))
3514}
3515
3516/// Recorded output from an icon draw op. Backends without a vector-icon
3517/// path use `Text` fallback layers; wgpu can return dedicated icon runs.
3518pub enum RecordedPaint {
3519    Text(Range<usize>),
3520    Icon(Range<usize>),
3521}
3522
3523/// Glyph-recording surface implemented by each backend's `TextPaint`.
3524/// `prepare_paint` calls into it exactly the same way wgpu and vulkano
3525/// would call their per-backend equivalents.
3526pub trait TextRecorder {
3527    /// Append per-glyph instances for `text` and return the range of
3528    /// indices written into the backend's `TextLayer` storage. Each
3529    /// returned index lands in `paint_items` as a `PaintItem::Text`.
3530    ///
3531    /// `style` carries weight + color + (optional) decoration flags
3532    /// — backends fold it into a single-element `(text, style)` slice
3533    /// and run the same shaping path as [`Self::record_runs`].
3534    #[allow(clippy::too_many_arguments)]
3535    fn record(
3536        &mut self,
3537        rect: Rect,
3538        scissor: Option<PhysicalScissor>,
3539        style: &RunStyle,
3540        text: &str,
3541        size: f32,
3542        line_height: f32,
3543        wrap: TextWrap,
3544        anchor: TextAnchor,
3545        scale_factor: f32,
3546    ) -> Range<usize>;
3547
3548    /// Append per-glyph instances for an attributed paragraph (one
3549    /// shaped run with per-character RunStyle metadata). Wrapping
3550    /// decisions cross run boundaries — the result is one ShapedRun
3551    /// just like a single-style call.
3552    #[allow(clippy::too_many_arguments)]
3553    fn record_runs(
3554        &mut self,
3555        rect: Rect,
3556        scissor: Option<PhysicalScissor>,
3557        runs: &[(String, RunStyle)],
3558        size: f32,
3559        line_height: f32,
3560        wrap: TextWrap,
3561        anchor: TextAnchor,
3562        scale_factor: f32,
3563    ) -> Range<usize>;
3564
3565    /// Append a vector icon. Backends with a native vector painter
3566    /// override this; the default keeps experimental/simple backends on
3567    /// the previous text-symbol fallback. Built-in icons fall back to
3568    /// their named glyph; app-supplied SVG icons fall back to a
3569    /// generic placeholder since they have no canonical glyph.
3570    #[allow(clippy::too_many_arguments)]
3571    fn record_icon(
3572        &mut self,
3573        rect: Rect,
3574        scissor: Option<PhysicalScissor>,
3575        source: &crate::icons::svg::IconSource,
3576        color: Color,
3577        size: f32,
3578        _stroke_width: f32,
3579        scale_factor: f32,
3580    ) -> RecordedPaint {
3581        let glyph = match source {
3582            crate::icons::svg::IconSource::Builtin(name) => name.fallback_glyph(),
3583            crate::icons::svg::IconSource::Custom(_) => "?",
3584            crate::icons::svg::IconSource::UnknownName(_) => {
3585                crate::tree::IconName::AlertCircle.fallback_glyph()
3586            }
3587        };
3588        RecordedPaint::Text(self.record(
3589            rect,
3590            scissor,
3591            &RunStyle::new(FontWeight::Regular, color),
3592            glyph,
3593            size,
3594            crate::text::metrics::line_height(size),
3595            TextWrap::NoWrap,
3596            TextAnchor::Middle,
3597            scale_factor,
3598        ))
3599    }
3600
3601    /// Append a raster image draw. Backends with texture sampling
3602    /// override this and return one or more indices into their image
3603    /// storage (each index lands in `paint_items` as
3604    /// `PaintItem::Image`). The default returns an empty range —
3605    /// backends without raster support paint nothing for image Els
3606    /// (the SVG fallback emits a labelled placeholder rect on its own).
3607    #[allow(clippy::too_many_arguments)]
3608    fn record_image(
3609        &mut self,
3610        _rect: Rect,
3611        _scissor: Option<PhysicalScissor>,
3612        _image: &crate::image::Image,
3613        _tint: Option<Color>,
3614        _radius: crate::tree::Corners,
3615        _fit: crate::image::ImageFit,
3616        _range_limit: crate::image::DynamicRangeLimit,
3617        _scale_factor: f32,
3618    ) -> Range<usize> {
3619        0..0
3620    }
3621
3622    /// Append an app-owned-texture composite. Backends with surface
3623    /// support override this and return one or more indices into their
3624    /// surface storage (each lands in `paint_items` as
3625    /// `PaintItem::AppTexture`). The default returns an empty range so
3626    /// backends without surface support paint nothing for surface Els.
3627    fn record_app_texture(
3628        &mut self,
3629        _rect: Rect,
3630        _scissor: Option<PhysicalScissor>,
3631        _texture: &crate::surface::AppTexture,
3632        _alpha: crate::surface::SurfaceAlpha,
3633        _transform: crate::affine::Affine2,
3634        _scale_factor: f32,
3635    ) -> Range<usize> {
3636        0..0
3637    }
3638
3639    /// Append an app-supplied vector draw. Backends with vector
3640    /// support override this and return one or more indices into their
3641    /// vector storage (each lands in `paint_items` as
3642    /// `PaintItem::Vector`). The default returns an empty range so
3643    /// backends without vector support paint nothing.
3644    fn record_vector(
3645        &mut self,
3646        _rect: Rect,
3647        _scissor: Option<PhysicalScissor>,
3648        _asset: &crate::vector::VectorAsset,
3649        _render_mode: crate::vector::VectorRenderMode,
3650        _scale_factor: f32,
3651    ) -> Range<usize> {
3652        0..0
3653    }
3654
3655    /// Append a 3D scene composite. Backends with a scene renderer
3656    /// override this: they ensure GPU buffers for the scene's
3657    /// revision-keyed geometry handles, ensure a per-node offscreen
3658    /// target sized to `rect` * `scale_factor`, and return one or more
3659    /// indices into their scene storage (each lands in `paint_items` as
3660    /// `PaintItem::Scene3D`). `id` is the node's stable id — backends key
3661    /// the offscreen-target cache on it so a resized or revisited scene
3662    /// reuses its target across frames. The default returns an empty
3663    /// range so backends without a scene renderer paint nothing (the SVG
3664    /// fallback emits a labelled placeholder rect on its own).
3665    fn record_scene3d(
3666        &mut self,
3667        _rect: Rect,
3668        _scissor: Option<PhysicalScissor>,
3669        _id: &str,
3670        _scene: &std::sync::Arc<crate::scene::Scene3DData>,
3671        _scale_factor: f32,
3672    ) -> Range<usize> {
3673        0..0
3674    }
3675}
3676
3677#[cfg(test)]
3678mod tests {
3679    use super::*;
3680    use crate::event::PointerId;
3681    use crate::shader::{ShaderHandle, StockShader, UniformBlock};
3682
3683    /// Minimal recorder for tests that don't exercise the text path.
3684    struct NoText;
3685    impl TextRecorder for NoText {
3686        fn record(
3687            &mut self,
3688            _rect: Rect,
3689            _scissor: Option<PhysicalScissor>,
3690            _style: &RunStyle,
3691            _text: &str,
3692            _size: f32,
3693            _line_height: f32,
3694            _wrap: TextWrap,
3695            _anchor: TextAnchor,
3696            _scale_factor: f32,
3697        ) -> Range<usize> {
3698            0..0
3699        }
3700        fn record_runs(
3701            &mut self,
3702            _rect: Rect,
3703            _scissor: Option<PhysicalScissor>,
3704            _runs: &[(String, RunStyle)],
3705            _size: f32,
3706            _line_height: f32,
3707            _wrap: TextWrap,
3708            _anchor: TextAnchor,
3709            _scale_factor: f32,
3710        ) -> Range<usize> {
3711            0..0
3712        }
3713    }
3714
3715    #[derive(Default)]
3716    struct CountingText {
3717        records: usize,
3718    }
3719
3720    impl TextRecorder for CountingText {
3721        fn record(
3722            &mut self,
3723            _rect: Rect,
3724            _scissor: Option<PhysicalScissor>,
3725            _style: &RunStyle,
3726            _text: &str,
3727            _size: f32,
3728            _line_height: f32,
3729            _wrap: TextWrap,
3730            _anchor: TextAnchor,
3731            _scale_factor: f32,
3732        ) -> Range<usize> {
3733            self.records += 1;
3734            0..0
3735        }
3736
3737        fn record_runs(
3738            &mut self,
3739            _rect: Rect,
3740            _scissor: Option<PhysicalScissor>,
3741            _runs: &[(String, RunStyle)],
3742            _size: f32,
3743            _line_height: f32,
3744            _wrap: TextWrap,
3745            _anchor: TextAnchor,
3746            _scale_factor: f32,
3747        ) -> Range<usize> {
3748            self.records += 1;
3749            0..0
3750        }
3751    }
3752
3753    fn empty_text_layout(line_height: f32) -> crate::text::metrics::TextLayout {
3754        crate::text::metrics::TextLayout {
3755            lines: Vec::new(),
3756            width: 0.0,
3757            height: 0.0,
3758            line_height,
3759        }
3760    }
3761
3762    // ---- input plumbing ----
3763
3764    /// A tree with one focusable button at (10,10,80,40) keyed "btn",
3765    /// plus an optional capture_keys text input at (10,60,80,40) keyed
3766    /// "ti". layout() runs against a 200x200 viewport so the rects
3767    /// land where we expect.
3768    fn lay_out_input_tree(capture: bool) -> RunnerCore {
3769        use crate::tree::*;
3770        let ti = if capture {
3771            crate::widgets::text::text("input").key("ti").capture_keys()
3772        } else {
3773            crate::widgets::text::text("noop").key("ti").focusable()
3774        };
3775        let mut tree =
3776            crate::column([crate::widgets::button::button("Btn").key("btn"), ti]).padding(10.0);
3777        let mut core = RunnerCore::new();
3778        crate::layout::layout(
3779            &mut tree,
3780            &mut core.ui_state,
3781            Rect::new(0.0, 0.0, 200.0, 200.0),
3782        );
3783        core.ui_state.sync_focus_order(&tree);
3784        let mut t = PrepareTimings::default();
3785        core.snapshot(&tree, &mut t);
3786        core
3787    }
3788
3789    #[test]
3790    fn pointer_up_emits_pointer_up_then_click() {
3791        let mut core = lay_out_input_tree(false);
3792        let btn_rect = core.rect_of_key("btn").expect("btn rect");
3793        let cx = btn_rect.x + btn_rect.w * 0.5;
3794        let cy = btn_rect.y + btn_rect.h * 0.5;
3795        core.pointer_moved(Pointer::moving(cx, cy));
3796        core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
3797        let events = core.pointer_up(Pointer::mouse(cx, cy, PointerButton::Primary));
3798        let kinds: Vec<UiEventKind> = events.iter().map(|e| e.kind).collect();
3799        assert_eq!(kinds, vec![UiEventKind::PointerUp, UiEventKind::Click]);
3800    }
3801
3802    #[test]
3803    fn pointer_wheel_event_routes_to_keyed_target() {
3804        let mut core = lay_out_input_tree(false);
3805        let btn_rect = core.rect_of_key("btn").expect("btn rect");
3806        let cx = btn_rect.center_x();
3807        let cy = btn_rect.center_y();
3808
3809        let event = core
3810            .pointer_wheel_event(cx, cy, 0.0, 40.0)
3811            .expect("wheel over keyed button");
3812
3813        assert_eq!(event.kind, UiEventKind::PointerWheel);
3814        assert_eq!(event.route(), Some("btn"));
3815        assert_eq!(event.pointer_pos(), Some((cx, cy)));
3816        assert_eq!(event.wheel_delta(), Some((0.0, 40.0)));
3817        assert_eq!(event.wheel_dy(), Some(40.0));
3818        assert_eq!(event.target_rect(), Some(btn_rect));
3819        assert_eq!(event.pointer_kind, Some(PointerKind::Mouse));
3820    }
3821
3822    /// Build a tree containing a single inline paragraph with one
3823    /// linked run, layout to a fixed viewport, and return the runner +
3824    /// the absolute rect of the paragraph. The linked text is long
3825    /// enough that probes well into the paragraph land safely inside
3826    /// the link for any plausible proportional font.
3827    fn lay_out_link_tree() -> (RunnerCore, Rect, &'static str) {
3828        use crate::tree::*;
3829        const URL: &str = "https://github.com/computer-whisperer/damascene";
3830        let mut tree = crate::column([crate::text_runs([
3831            crate::text("Visit "),
3832            crate::text("github.com/computer-whisperer/damascene").link(URL),
3833            crate::text("."),
3834        ])])
3835        .padding(10.0);
3836        let mut core = RunnerCore::new();
3837        crate::layout::layout(
3838            &mut tree,
3839            &mut core.ui_state,
3840            Rect::new(0.0, 0.0, 600.0, 200.0),
3841        );
3842        core.ui_state.sync_focus_order(&tree);
3843        let mut t = PrepareTimings::default();
3844        core.snapshot(&tree, &mut t);
3845        let para = core
3846            .last_tree
3847            .as_ref()
3848            .and_then(|t| t.children.first())
3849            .map(|p| p.computed_rect)
3850            .expect("paragraph rect");
3851        (core, para, URL)
3852    }
3853
3854    #[test]
3855    fn snapshot_computes_the_links_flag_that_gates_link_walks() {
3856        // Link-free tree → flag off, so per-pointer-move `link_at`
3857        // walks are skipped entirely; any text-link run flips it on.
3858        let core = lay_out_input_tree(false);
3859        assert!(
3860            !core.last_tree_has_links,
3861            "input tree has no links; the flag must stay off"
3862        );
3863        let (core, ..) = lay_out_link_tree();
3864        assert!(
3865            core.last_tree_has_links,
3866            "linked paragraph must flip the flag on"
3867        );
3868    }
3869
3870    #[test]
3871    fn pointer_up_on_link_emits_link_activated_with_url() {
3872        let (mut core, para, url) = lay_out_link_tree();
3873        // Probe ~100 logical pixels in — past the "Visit " prefix
3874        // (~40px in default UI font) and well inside the long linked
3875        // run, which extends ~250+px from there.
3876        let cx = para.x + 100.0;
3877        let cy = para.y + para.h * 0.5;
3878        core.pointer_moved(Pointer::moving(cx, cy));
3879        core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
3880        let events = core.pointer_up(Pointer::mouse(cx, cy, PointerButton::Primary));
3881        let link = events
3882            .iter()
3883            .find(|e| e.kind == UiEventKind::LinkActivated)
3884            .expect("LinkActivated event");
3885        assert_eq!(link.key.as_deref(), Some(url));
3886    }
3887
3888    #[test]
3889    fn pointer_up_after_drag_off_link_does_not_activate() {
3890        let (mut core, para, _url) = lay_out_link_tree();
3891        let press_x = para.x + 100.0;
3892        let cy = para.y + para.h * 0.5;
3893        core.pointer_moved(Pointer::moving(press_x, cy));
3894        core.pointer_down(Pointer::mouse(press_x, cy, PointerButton::Primary));
3895        // Release far below the paragraph — the user dragged off the
3896        // link before letting go, which native browsers treat as
3897        // cancel.
3898        let events = core.pointer_up(Pointer::mouse(press_x, 180.0, PointerButton::Primary));
3899        let kinds: Vec<UiEventKind> = events.iter().map(|e| e.kind).collect();
3900        assert!(
3901            !kinds.contains(&UiEventKind::LinkActivated),
3902            "drag-off-link should cancel the link activation; got {kinds:?}",
3903        );
3904    }
3905
3906    #[test]
3907    fn disabled_control_resolves_not_allowed_cursor_on_hover() {
3908        // `.disabled()` declares Cursor::NotAllowed; the hit-test still
3909        // returns keyed nodes under `block_pointer` as hover targets
3910        // (only click routing is suppressed), so hovering a disabled
3911        // control must resolve the inert cursor (issue #71).
3912        use crate::cursor::Cursor;
3913        let mut tree = crate::widgets::button::button("Save")
3914            .key("save")
3915            .disabled();
3916        let mut core = RunnerCore::new();
3917        crate::layout::layout(
3918            &mut tree,
3919            &mut core.ui_state,
3920            Rect::new(0.0, 0.0, 200.0, 100.0),
3921        );
3922        let rect = tree.computed_rect;
3923        let mut t = PrepareTimings::default();
3924        core.snapshot(&tree, &mut t);
3925        core.pointer_moved(Pointer::moving(
3926            rect.x + rect.w * 0.5,
3927            rect.y + rect.h * 0.5,
3928        ));
3929        let snap = core.last_tree.as_ref().expect("tree").clone();
3930        assert_eq!(core.ui_state.cursor(&snap), Cursor::NotAllowed);
3931    }
3932
3933    #[test]
3934    fn pointer_moved_over_link_resolves_cursor_to_pointer_and_requests_redraw() {
3935        use crate::cursor::Cursor;
3936        let (mut core, para, _url) = lay_out_link_tree();
3937        let cx = para.x + 100.0;
3938        let cy = para.y + para.h * 0.5;
3939        // Pointer initially well outside the paragraph.
3940        let initial = core.pointer_moved(Pointer::moving(para.x - 50.0, cy));
3941        assert!(
3942            !initial.needs_redraw,
3943            "moving in empty space shouldn't request a redraw"
3944        );
3945        let tree = core.last_tree.as_ref().expect("tree").clone();
3946        assert_eq!(
3947            core.ui_state.cursor(&tree),
3948            Cursor::Default,
3949            "no link under pointer → default cursor"
3950        );
3951        // Move onto the link — needs_redraw flips so the host
3952        // re-resolves the cursor on the next frame.
3953        let onto = core.pointer_moved(Pointer::moving(cx, cy));
3954        assert!(
3955            onto.needs_redraw,
3956            "entering a link region should flag a redraw so the cursor refresh isn't stale"
3957        );
3958        assert_eq!(
3959            core.ui_state.cursor(&tree),
3960            Cursor::Pointer,
3961            "pointer over a link → Pointer cursor"
3962        );
3963        // Move back off — should flag a redraw again so the cursor
3964        // returns to Default.
3965        let off = core.pointer_moved(Pointer::moving(para.x - 50.0, cy));
3966        assert!(
3967            off.needs_redraw,
3968            "leaving a link region should flag a redraw"
3969        );
3970        assert_eq!(core.ui_state.cursor(&tree), Cursor::Default);
3971    }
3972
3973    #[test]
3974    fn pointer_up_on_unlinked_text_does_not_emit_link_activated() {
3975        let (mut core, para, _url) = lay_out_link_tree();
3976        // Click 1px in from the left edge — inside the "Visit "
3977        // prefix, before the linked run starts.
3978        let cx = para.x + 1.0;
3979        let cy = para.y + para.h * 0.5;
3980        core.pointer_moved(Pointer::moving(cx, cy));
3981        core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
3982        let events = core.pointer_up(Pointer::mouse(cx, cy, PointerButton::Primary));
3983        let kinds: Vec<UiEventKind> = events.iter().map(|e| e.kind).collect();
3984        assert!(
3985            !kinds.contains(&UiEventKind::LinkActivated),
3986            "click on the unlinked prefix should not surface a link event; got {kinds:?}",
3987        );
3988    }
3989
3990    #[test]
3991    fn pointer_up_off_target_emits_only_pointer_up() {
3992        let mut core = lay_out_input_tree(false);
3993        let btn_rect = core.rect_of_key("btn").expect("btn rect");
3994        let cx = btn_rect.x + btn_rect.w * 0.5;
3995        let cy = btn_rect.y + btn_rect.h * 0.5;
3996        core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
3997        // Release off-target (well outside any keyed node).
3998        let events = core.pointer_up(Pointer::mouse(180.0, 180.0, PointerButton::Primary));
3999        let kinds: Vec<UiEventKind> = events.iter().map(|e| e.kind).collect();
4000        assert_eq!(
4001            kinds,
4002            vec![UiEventKind::PointerUp],
4003            "drag-off-target should still surface PointerUp so widgets see drag-end"
4004        );
4005    }
4006
4007    #[test]
4008    fn pointer_moved_while_pressed_emits_drag() {
4009        let mut core = lay_out_input_tree(false);
4010        let btn_rect = core.rect_of_key("btn").expect("btn rect");
4011        let cx = btn_rect.x + btn_rect.w * 0.5;
4012        let cy = btn_rect.y + btn_rect.h * 0.5;
4013        core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
4014        let drag = core
4015            .pointer_moved(Pointer::moving(cx + 30.0, cy))
4016            .events
4017            .into_iter()
4018            .find(|e| e.kind == UiEventKind::Drag)
4019            .expect("drag while pressed");
4020        assert_eq!(drag.target.as_ref().map(|t| t.key.as_str()), Some("btn"));
4021        assert_eq!(drag.pointer, Some((cx + 30.0, cy)));
4022    }
4023
4024    #[test]
4025    fn toast_dismiss_click_removes_toast_and_suppresses_click_event() {
4026        use crate::toast::ToastSpec;
4027        use crate::tree::Size;
4028        // Build a fresh runner, queue a toast, prepare once so the
4029        // toast layer is laid out, then synthesize a click on its
4030        // dismiss button.
4031        let mut core = RunnerCore::new();
4032        core.ui_state
4033            .push_toast(ToastSpec::success("hi"), Instant::now());
4034        let toast_id = core.ui_state.toasts()[0].id;
4035
4036        // Build & lay out a tree with the toast layer appended.
4037        // Root is `stack(...)` (Axis::Overlay) so the synthesized
4038        // toast layer overlays rather than competing for flex space.
4039        let mut tree: El = crate::stack(std::iter::empty::<El>())
4040            .width(Size::Fill(1.0))
4041            .height(Size::Fill(1.0));
4042        crate::layout::assign_ids(&mut tree);
4043        let _ = crate::toast::synthesize_toasts(&mut tree, &mut core.ui_state, Instant::now());
4044        crate::layout::layout(
4045            &mut tree,
4046            &mut core.ui_state,
4047            Rect::new(0.0, 0.0, 800.0, 600.0),
4048        );
4049        core.ui_state.sync_focus_order(&tree);
4050        let mut t = PrepareTimings::default();
4051        core.snapshot(&tree, &mut t);
4052
4053        let dismiss_key = format!("toast-dismiss-{toast_id}");
4054        let dismiss_rect = core.rect_of_key(&dismiss_key).expect("dismiss button");
4055        let cx = dismiss_rect.x + dismiss_rect.w * 0.5;
4056        let cy = dismiss_rect.y + dismiss_rect.h * 0.5;
4057
4058        core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
4059        let events = core.pointer_up(Pointer::mouse(cx, cy, PointerButton::Primary));
4060        let kinds: Vec<UiEventKind> = events.iter().map(|e| e.kind).collect();
4061        // PointerUp still fires (kept generic so drag-aware widgets
4062        // observe drag-end); Click is intercepted by the toast
4063        // bookkeeping.
4064        assert!(
4065            !kinds.contains(&UiEventKind::Click),
4066            "Click on toast-dismiss should not be surfaced: {kinds:?}",
4067        );
4068        // Dismissal marks the toast for its exit animation rather than
4069        // removing it; the synthesis pass drops it once the exit
4070        // window elapses.
4071        let toast = core
4072            .ui_state
4073            .toasts()
4074            .iter()
4075            .find(|t| t.id == toast_id)
4076            .expect("dismissed toast stays queued through its exit window");
4077        assert!(toast.dismissed, "dismiss-click marks the toast dismissed");
4078
4079        let mut tree2: El = crate::stack(std::iter::empty::<El>())
4080            .width(Size::Fill(1.0))
4081            .height(Size::Fill(1.0));
4082        crate::layout::assign_ids(&mut tree2);
4083        let exit_open = Instant::now();
4084        let _ = crate::toast::synthesize_toasts(&mut tree2, &mut core.ui_state, exit_open);
4085        let after = exit_open + crate::toast::TOAST_EXIT_WINDOW + Duration::from_millis(10);
4086        let mut tree3: El = crate::stack(std::iter::empty::<El>())
4087            .width(Size::Fill(1.0))
4088            .height(Size::Fill(1.0));
4089        crate::layout::assign_ids(&mut tree3);
4090        let _ = crate::toast::synthesize_toasts(&mut tree3, &mut core.ui_state, after);
4091        assert!(
4092            core.ui_state.toasts().iter().all(|t| t.id != toast_id),
4093            "toast {toast_id} should leave the queue after its exit window",
4094        );
4095    }
4096
4097    #[test]
4098    fn pointer_moved_without_press_emits_no_drag() {
4099        let mut core = lay_out_input_tree(false);
4100        let events = core.pointer_moved(Pointer::moving(50.0, 50.0)).events;
4101        // No press → no Drag emission. Hover-transition events
4102        // (PointerEnter/Leave) may fire; just assert nothing in the
4103        // out vec carries the Drag kind.
4104        assert!(!events.iter().any(|e| e.kind == UiEventKind::Drag));
4105    }
4106
4107    #[test]
4108    fn spinner_in_tree_keeps_needs_redraw_set() {
4109        // stock::spinner reads frame.time, so the host must keep
4110        // calling prepare() even when no animation is in flight. Pin
4111        // the contract: a tree with no other motion still reports
4112        // needs_redraw=true when a spinner is present.
4113        use crate::widgets::spinner::spinner;
4114        let mut tree = crate::column([spinner()]);
4115        let mut core = RunnerCore::new();
4116        let mut t = PrepareTimings::default();
4117        let LayoutPrepared { needs_redraw, .. } = core.prepare_layout(
4118            &mut tree,
4119            Rect::new(0.0, 0.0, 200.0, 200.0),
4120            1.0,
4121            &mut t,
4122            RunnerCore::no_time_shaders,
4123        );
4124        assert!(
4125            needs_redraw,
4126            "tree with a spinner must request continuous redraw",
4127        );
4128
4129        // Same shape without a spinner — needs_redraw stays false once
4130        // any state envelopes settle, demonstrating the signal is
4131        // spinner-driven rather than always-on.
4132        let mut bare = crate::column([crate::widgets::text::text("idle")]);
4133        let mut core2 = RunnerCore::new();
4134        let mut t2 = PrepareTimings::default();
4135        let LayoutPrepared {
4136            needs_redraw: needs_redraw2,
4137            ..
4138        } = core2.prepare_layout(
4139            &mut bare,
4140            Rect::new(0.0, 0.0, 200.0, 200.0),
4141            1.0,
4142            &mut t2,
4143            RunnerCore::no_time_shaders,
4144        );
4145        assert!(
4146            !needs_redraw2,
4147            "tree without time-driven shaders should idle: got needs_redraw={needs_redraw2}",
4148        );
4149    }
4150
4151    #[test]
4152    fn custom_samples_time_shader_keeps_needs_redraw_set() {
4153        // Pin the generalization: a tree binding a *custom* shader
4154        // whose name appears in the host's `samples_time` set must
4155        // request continuous redraw the same way stock::spinner does.
4156        let mut tree = crate::column([crate::tree::El::new(crate::tree::Kind::Custom("anim"))
4157            .shader(crate::shader::ShaderBinding::custom("my_animated_glow"))
4158            .width(crate::tree::Size::Fixed(32.0))
4159            .height(crate::tree::Size::Fixed(32.0))]);
4160        let mut core = RunnerCore::new();
4161        let mut t = PrepareTimings::default();
4162
4163        let LayoutPrepared {
4164            needs_redraw: idle, ..
4165        } = core.prepare_layout(
4166            &mut tree,
4167            Rect::new(0.0, 0.0, 200.0, 200.0),
4168            1.0,
4169            &mut t,
4170            RunnerCore::no_time_shaders,
4171        );
4172        assert!(
4173            !idle,
4174            "without a samples_time registration the host should idle",
4175        );
4176
4177        let mut t2 = PrepareTimings::default();
4178        let LayoutPrepared {
4179            needs_redraw: animated,
4180            ..
4181        } = core.prepare_layout(
4182            &mut tree,
4183            Rect::new(0.0, 0.0, 200.0, 200.0),
4184            1.0,
4185            &mut t2,
4186            |handle| matches!(handle, ShaderHandle::Custom("my_animated_glow")),
4187        );
4188        assert!(
4189            animated,
4190            "custom shader registered as samples_time=true must request continuous redraw",
4191        );
4192    }
4193
4194    #[test]
4195    fn redraw_within_aggregates_to_minimum_visible_deadline() {
4196        use std::time::Duration;
4197        let mut tree = crate::column([
4198            // 16ms
4199            crate::widgets::text::text("a")
4200                .redraw_within(Duration::from_millis(16))
4201                .width(crate::tree::Size::Fixed(20.0))
4202                .height(crate::tree::Size::Fixed(20.0)),
4203            // 50ms — the slower request should NOT win against 16ms.
4204            crate::widgets::text::text("b")
4205                .redraw_within(Duration::from_millis(50))
4206                .width(crate::tree::Size::Fixed(20.0))
4207                .height(crate::tree::Size::Fixed(20.0)),
4208        ]);
4209        let mut core = RunnerCore::new();
4210        let mut t = PrepareTimings::default();
4211        let LayoutPrepared {
4212            needs_redraw,
4213            next_layout_redraw_in,
4214            ..
4215        } = core.prepare_layout(
4216            &mut tree,
4217            Rect::new(0.0, 0.0, 200.0, 200.0),
4218            1.0,
4219            &mut t,
4220            RunnerCore::no_time_shaders,
4221        );
4222        assert!(needs_redraw, "redraw_within must lift the legacy bool");
4223        assert_eq!(
4224            next_layout_redraw_in,
4225            Some(Duration::from_millis(16)),
4226            "tightest visible deadline wins, on the layout lane",
4227        );
4228    }
4229
4230    #[test]
4231    fn redraw_within_off_screen_widget_is_ignored() {
4232        use std::time::Duration;
4233        // Layout-rect-based visibility: place the animated widget below
4234        // the viewport via a tall preceding spacer in a hugging
4235        // column. The child's computed rect is at y≈150, which lies
4236        // outside a 0..100 viewport, so the visibility filter must
4237        // skip it and the host must idle.
4238        let mut tree = crate::column([
4239            crate::tree::spacer().height(crate::tree::Size::Fixed(150.0)),
4240            crate::widgets::text::text("offscreen")
4241                .redraw_within(Duration::from_millis(16))
4242                .width(crate::tree::Size::Fixed(10.0))
4243                .height(crate::tree::Size::Fixed(10.0)),
4244        ]);
4245        let mut core = RunnerCore::new();
4246        let mut t = PrepareTimings::default();
4247        let LayoutPrepared {
4248            next_layout_redraw_in,
4249            ..
4250        } = core.prepare_layout(
4251            &mut tree,
4252            Rect::new(0.0, 0.0, 100.0, 100.0),
4253            1.0,
4254            &mut t,
4255            RunnerCore::no_time_shaders,
4256        );
4257        assert_eq!(
4258            next_layout_redraw_in, None,
4259            "off-screen redraw_within must not contribute to the aggregate",
4260        );
4261    }
4262
4263    #[test]
4264    fn redraw_within_clipped_out_widget_is_ignored() {
4265        use std::time::Duration;
4266
4267        let clipped = crate::column([crate::widgets::text::text("clipped")
4268            .redraw_within(Duration::from_millis(16))
4269            .width(crate::tree::Size::Fixed(10.0))
4270            .height(crate::tree::Size::Fixed(10.0))])
4271        .clip()
4272        .width(crate::tree::Size::Fixed(100.0))
4273        .height(crate::tree::Size::Fixed(20.0))
4274        .layout(|ctx| {
4275            vec![Rect::new(
4276                ctx.container.x,
4277                ctx.container.y + 30.0,
4278                10.0,
4279                10.0,
4280            )]
4281        });
4282        let mut tree = crate::column([clipped]);
4283
4284        let mut core = RunnerCore::new();
4285        let mut t = PrepareTimings::default();
4286        let LayoutPrepared {
4287            next_layout_redraw_in,
4288            ..
4289        } = core.prepare_layout(
4290            &mut tree,
4291            Rect::new(0.0, 0.0, 100.0, 100.0),
4292            1.0,
4293            &mut t,
4294            RunnerCore::no_time_shaders,
4295        );
4296        assert_eq!(
4297            next_layout_redraw_in, None,
4298            "redraw_within inside an inherited clip but outside the clip rect must not contribute",
4299        );
4300    }
4301
4302    #[test]
4303    fn pointer_moved_within_same_hovered_node_does_not_request_redraw() {
4304        // Wayland delivers CursorMoved at very high frequency while
4305        // the cursor sits over the surface. Hosts gate request_redraw
4306        // on `needs_redraw`; this test pins the contract so we don't
4307        // regress to the unconditional-redraw behaviour that pegged
4308        // settings_modal at 100% CPU under cursor activity.
4309        let mut core = lay_out_input_tree(false);
4310        let btn = core.rect_of_key("btn").expect("btn rect");
4311        let (cx, cy) = (btn.x + btn.w * 0.5, btn.y + btn.h * 0.5);
4312
4313        // First move enters the button — hover identity changes, so a
4314        // PointerEnter fires (no preceding Leave because no prior
4315        // hover target).
4316        let first = core.pointer_moved(Pointer::moving(cx, cy));
4317        assert_eq!(first.events.len(), 1);
4318        assert_eq!(first.events[0].kind, UiEventKind::PointerEnter);
4319        assert_eq!(first.events[0].key.as_deref(), Some("btn"));
4320        assert!(
4321            first.needs_redraw,
4322            "entering a focusable should warrant a redraw",
4323        );
4324
4325        // Same node, slightly different coords. Hover identity is
4326        // unchanged, no drag is active — must not redraw or emit any
4327        // events.
4328        let second = core.pointer_moved(Pointer::moving(cx + 1.0, cy));
4329        assert!(second.events.is_empty());
4330        assert!(
4331            !second.needs_redraw,
4332            "identical hover, no drag → host should idle",
4333        );
4334
4335        // Moving off the button into empty space changes hover to
4336        // None — that's a visible transition (envelope ramps down)
4337        // and a PointerLeave fires.
4338        let off = core.pointer_moved(Pointer::moving(0.0, 0.0));
4339        assert_eq!(off.events.len(), 1);
4340        assert_eq!(off.events[0].kind, UiEventKind::PointerLeave);
4341        assert_eq!(off.events[0].key.as_deref(), Some("btn"));
4342        assert!(
4343            off.needs_redraw,
4344            "leaving a hovered node still warrants a redraw",
4345        );
4346    }
4347
4348    #[test]
4349    fn pointer_move_within_hover_label_scene_keeps_redrawing() {
4350        // A scene with hover tooltips isn't a hover hit-target, so moving
4351        // *within* it doesn't change the hovered node — but the tooltip
4352        // tracks the cursor, so each move must still request a redraw (else
4353        // lazy rendering strands the tooltip until some other input fires).
4354        use crate::scene::glam::Vec3;
4355        use crate::scene::{
4356            PointData, PointLabels, PointStyle, PointsHandle, ScenePoint, SceneSpec,
4357        };
4358        let pts = PointsHandle::new(PointData {
4359            points: vec![ScenePoint {
4360                position: Vec3::ZERO,
4361                color: [1.0; 4],
4362            }],
4363        });
4364        let spec = SceneSpec::new().points_labeled(
4365            pts,
4366            PointStyle::default(),
4367            PointLabels::new(["a"]).on_hover(),
4368        );
4369        let mut tree = crate::tree::chart3d(spec).key("scene");
4370        let mut core = RunnerCore::new();
4371        crate::layout::layout(
4372            &mut tree,
4373            &mut core.ui_state,
4374            Rect::new(0.0, 0.0, 200.0, 200.0),
4375        );
4376        let mut t = PrepareTimings::default();
4377        core.snapshot(&tree, &mut t);
4378        // prepare_layout does this each frame; here we drive it directly to
4379        // populate the hover-label rect list.
4380        core.ui_state.tick_scene_cameras(&tree, Instant::now());
4381
4382        let scene = core.rect_of_key("scene").expect("scene rect");
4383        let (cx, cy) = (scene.center_x(), scene.center_y());
4384
4385        let enter = core.pointer_moved(Pointer::moving(cx, cy));
4386        assert!(enter.needs_redraw, "entering a hover-label scene redraws");
4387        // Same hovered node (the scene), different coords → must still redraw
4388        // so the tooltip follows the cursor.
4389        let within = core.pointer_moved(Pointer::moving(cx + 3.0, cy - 2.0));
4390        assert!(
4391            within.needs_redraw,
4392            "moving within a hover-label scene keeps redrawing the tooltip",
4393        );
4394        // Leaving the scene redraws once more to clear the tooltip.
4395        let leave = core.pointer_moved(Pointer::moving(scene.x + scene.w + 20.0, cy));
4396        assert!(leave.needs_redraw, "leaving clears the tooltip");
4397    }
4398
4399    #[test]
4400    fn pointer_moved_between_keyed_targets_emits_leave_then_enter() {
4401        // Cursor crossing from one keyed node to another emits a paired
4402        // PointerLeave (old target) followed by PointerEnter (new
4403        // target). Apps can observe the cleared state before the new
4404        // one — important for things like cancelling a hover-intent
4405        // prefetch on the old target before kicking off one for the
4406        // new.
4407        let mut core = lay_out_input_tree(false);
4408        let btn = core.rect_of_key("btn").expect("btn rect");
4409        let ti = core.rect_of_key("ti").expect("ti rect");
4410
4411        // Enter btn first.
4412        let _ = core.pointer_moved(Pointer::moving(btn.x + 4.0, btn.y + 4.0));
4413
4414        // Cross to ti.
4415        let cross = core.pointer_moved(Pointer::moving(ti.x + 4.0, ti.y + 4.0));
4416        let kinds: Vec<UiEventKind> = cross.events.iter().map(|e| e.kind).collect();
4417        assert_eq!(
4418            kinds,
4419            vec![UiEventKind::PointerLeave, UiEventKind::PointerEnter],
4420            "paired Leave-then-Enter on cross-target hover transition",
4421        );
4422        assert_eq!(cross.events[0].key.as_deref(), Some("btn"));
4423        assert_eq!(cross.events[1].key.as_deref(), Some("ti"));
4424        assert!(cross.needs_redraw);
4425    }
4426
4427    #[test]
4428    fn touch_pointer_down_emits_pointer_enter_then_pointer_down() {
4429        // A touch tap has no preceding `pointer_moved` (most platforms
4430        // only fire pointermove during contact), so `pointer_down`
4431        // itself synthesizes the `PointerEnter` that mouse hosts get
4432        // for free. Without this, hover-driven button visuals would
4433        // never wake up for the duration of the contact.
4434        let mut core = lay_out_input_tree(false);
4435        let btn = core.rect_of_key("btn").expect("btn rect");
4436        let cx = btn.x + btn.w * 0.5;
4437        let cy = btn.y + btn.h * 0.5;
4438        let events = core.pointer_down(Pointer::touch(
4439            cx,
4440            cy,
4441            PointerButton::Primary,
4442            PointerId::PRIMARY,
4443        ));
4444        let kinds: Vec<UiEventKind> = events.iter().map(|e| e.kind).collect();
4445        assert_eq!(
4446            kinds,
4447            vec![UiEventKind::PointerEnter, UiEventKind::PointerDown],
4448        );
4449        for e in &events {
4450            assert_eq!(e.pointer_kind, Some(PointerKind::Touch));
4451        }
4452        assert_eq!(core.ui_state().hovered_key(), Some("btn"));
4453    }
4454
4455    #[test]
4456    fn touch_pointer_up_emits_pointer_leave_after_click() {
4457        // Releasing a touch ends the gesture's hover, mirroring the
4458        // synthetic enter on `pointer_down`. Mouse / pen leave hover
4459        // tracking continuous; touch must wind down explicitly so
4460        // hover envelopes don't latch on after release.
4461        let mut core = lay_out_input_tree(false);
4462        let btn = core.rect_of_key("btn").expect("btn rect");
4463        let cx = btn.x + btn.w * 0.5;
4464        let cy = btn.y + btn.h * 0.5;
4465        let _ = core.pointer_down(Pointer::touch(
4466            cx,
4467            cy,
4468            PointerButton::Primary,
4469            PointerId::PRIMARY,
4470        ));
4471        let events = core.pointer_up(Pointer::touch(
4472            cx,
4473            cy,
4474            PointerButton::Primary,
4475            PointerId::PRIMARY,
4476        ));
4477        let kinds: Vec<UiEventKind> = events.iter().map(|e| e.kind).collect();
4478        assert_eq!(
4479            kinds,
4480            vec![
4481                UiEventKind::PointerUp,
4482                UiEventKind::Click,
4483                UiEventKind::PointerLeave,
4484            ],
4485        );
4486        assert_eq!(core.ui_state().hovered_key(), None);
4487    }
4488
4489    #[test]
4490    fn touch_pointer_moved_without_press_does_not_emit_hover_transitions() {
4491        // A touch-modality `pointer_moved` with no active contact
4492        // (synthetic, mostly — real touch hardware doesn't fire move
4493        // without contact) must not synthesize a hover transition.
4494        // Without this guard, an Apple Pencil hovering over the
4495        // canvas would still drive button hover visuals without ever
4496        // touching, which is the wrong default — pen sets its own
4497        // `PointerKind::Pen` so it falls through to mouse semantics.
4498        let mut core = lay_out_input_tree(false);
4499        let btn = core.rect_of_key("btn").expect("btn rect");
4500        let mut p = Pointer::moving(btn.x + 4.0, btn.y + 4.0);
4501        p.kind = PointerKind::Touch;
4502        let moved = core.pointer_moved(p);
4503        assert!(
4504            moved.events.is_empty(),
4505            "touch move without press should not emit hover events, got {:?}",
4506            moved.events.iter().map(|e| e.kind).collect::<Vec<_>>(),
4507        );
4508    }
4509
4510    #[test]
4511    fn touch_drag_between_targets_still_emits_hover_transitions() {
4512        // Mid-drag identity changes (finger sliding from one keyed
4513        // node to another) ARE real hover transitions on touch — the
4514        // hover gating only suppresses move-without-press, not move-
4515        // with-press. Widgets along the drag path get the same enter
4516        // / leave they would on mouse, in the same order.
4517        //
4518        // Premise: the press target opts into `consumes_touch_drag`
4519        // so the touch gesture commits to drag (not scroll). Without
4520        // that opt-in the runner cancels the press and routes the
4521        // motion to scroll, which is covered by a separate test.
4522        use crate::tree::*;
4523        let mut tree = crate::column([
4524            crate::widgets::button::button("Btn")
4525                .key("btn")
4526                .consumes_touch_drag(),
4527            crate::widgets::button::button("Other").key("other"),
4528        ])
4529        .padding(10.0);
4530        let mut core = RunnerCore::new();
4531        crate::layout::layout(
4532            &mut tree,
4533            &mut core.ui_state,
4534            Rect::new(0.0, 0.0, 200.0, 200.0),
4535        );
4536        core.ui_state.sync_focus_order(&tree);
4537        let mut t = PrepareTimings::default();
4538        core.snapshot(&tree, &mut t);
4539
4540        let btn = core.rect_of_key("btn").expect("btn rect");
4541        let other = core.rect_of_key("other").expect("other rect");
4542        let _ = core.pointer_down(Pointer::touch(
4543            btn.x + 4.0,
4544            btn.y + 4.0,
4545            PointerButton::Primary,
4546            PointerId::PRIMARY,
4547        ));
4548        let mut move_p = Pointer::moving(other.x + 4.0, other.y + 4.0);
4549        move_p.kind = PointerKind::Touch;
4550        let cross = core.pointer_moved(move_p);
4551        let kinds: Vec<UiEventKind> = cross.events.iter().map(|e| e.kind).collect();
4552        assert!(
4553            kinds.contains(&UiEventKind::PointerLeave)
4554                && kinds.contains(&UiEventKind::PointerEnter),
4555            "touch drag across targets should emit Leave + Enter, got {kinds:?}",
4556        );
4557        // Drag also fires because the press is still held on btn
4558        // (consumes_touch_drag commits the gesture to drag rather
4559        // than scroll).
4560        assert!(kinds.contains(&UiEventKind::Drag));
4561    }
4562
4563    #[test]
4564    fn would_press_focus_text_input_distinguishes_capture_keys() {
4565        // The capture-keys variant of `lay_out_input_tree` keys a
4566        // text-input style widget at "ti"; the non-capture variant
4567        // keys a plain focusable. The query distinguishes the two
4568        // by walking find_capture_keys against the hit target.
4569        let core = lay_out_input_tree(true);
4570        let ti = core.rect_of_key("ti").expect("ti rect");
4571        let btn = core.rect_of_key("btn").expect("btn rect");
4572
4573        assert!(
4574            core.would_press_focus_text_input(ti.center_x(), ti.center_y()),
4575            "press on capture_keys widget should report true",
4576        );
4577        assert!(
4578            !core.would_press_focus_text_input(btn.center_x(), btn.center_y()),
4579            "press on plain focusable should report false",
4580        );
4581        // Press in dead space → false (no hit target).
4582        assert!(!core.would_press_focus_text_input(0.0, 0.0));
4583    }
4584
4585    #[test]
4586    fn touch_jiggle_below_threshold_still_taps() {
4587        // Real touch contact has small involuntary movement between
4588        // pointer_down and pointer_up. As long as the total motion
4589        // stays under TOUCH_DRAG_THRESHOLD the gesture must remain a
4590        // tap — Click fires on release just like a perfectly still
4591        // press.
4592        let mut core = lay_out_input_tree(false);
4593        let btn = core.rect_of_key("btn").expect("btn rect");
4594        let cx = btn.x + btn.w * 0.5;
4595        let cy = btn.y + btn.h * 0.5;
4596        let _ = core.pointer_down(Pointer::touch(
4597            cx,
4598            cy,
4599            PointerButton::Primary,
4600            PointerId::PRIMARY,
4601        ));
4602        // Jiggle by a few pixels — well under the 10px threshold.
4603        let mut jiggle = Pointer::moving(cx + 3.0, cy + 2.0);
4604        jiggle.kind = PointerKind::Touch;
4605        let _ = core.pointer_moved(jiggle);
4606        let events = core.pointer_up(Pointer::touch(
4607            cx + 3.0,
4608            cy + 2.0,
4609            PointerButton::Primary,
4610            PointerId::PRIMARY,
4611        ));
4612        let kinds: Vec<UiEventKind> = events.iter().map(|e| e.kind).collect();
4613        assert!(
4614            kinds.contains(&UiEventKind::Click),
4615            "small jiggle should not commit to scroll, expected Click in {kinds:?}",
4616        );
4617    }
4618
4619    #[test]
4620    fn touch_drag_on_consuming_widget_emits_drag_not_cancel() {
4621        // A press on a node opted into `consumes_touch_drag` (slider,
4622        // scrubber, resize handle) commits the gesture to drag once
4623        // the threshold is crossed, so subsequent moves emit the
4624        // normal `Drag` event and the press is *not* cancelled.
4625        use crate::tree::*;
4626        let mut tree = crate::column([crate::widgets::button::button("Drag me")
4627            .key("draggable")
4628            .consumes_touch_drag()])
4629        .padding(10.0);
4630        let mut core = RunnerCore::new();
4631        crate::layout::layout(
4632            &mut tree,
4633            &mut core.ui_state,
4634            Rect::new(0.0, 0.0, 200.0, 200.0),
4635        );
4636        core.ui_state.sync_focus_order(&tree);
4637        let mut t = PrepareTimings::default();
4638        core.snapshot(&tree, &mut t);
4639
4640        let r = core.rect_of_key("draggable").expect("rect");
4641        let cx = r.x + r.w * 0.5;
4642        let cy = r.y + r.h * 0.5;
4643        let _ = core.pointer_down(Pointer::touch(
4644            cx,
4645            cy,
4646            PointerButton::Primary,
4647            PointerId::PRIMARY,
4648        ));
4649        // Move past the threshold along x (still inside the widget
4650        // since the test widget is wide).
4651        let mut over = Pointer::moving(cx + 30.0, cy);
4652        over.kind = PointerKind::Touch;
4653        let moved = core.pointer_moved(over);
4654        let kinds: Vec<UiEventKind> = moved.events.iter().map(|e| e.kind).collect();
4655        assert!(
4656            kinds.contains(&UiEventKind::Drag),
4657            "drag-consuming widget should receive Drag past threshold, got {kinds:?}",
4658        );
4659        assert!(
4660            !kinds.contains(&UiEventKind::PointerCancel),
4661            "drag-consuming widget should not see PointerCancel, got {kinds:?}",
4662        );
4663    }
4664
4665    #[test]
4666    fn touch_drag_in_scrollable_cancels_press_and_scrolls() {
4667        // Press on non-draggable content inside a scroll region:
4668        // crossing the threshold commits to scroll, which means
4669        // (a) the press is cancelled (PointerCancel + PointerLeave
4670        // for the pressed/hovered targets), (b) the scroll offset
4671        // advances by the move delta, and (c) the subsequent
4672        // pointer_up does NOT fire Click.
4673        use crate::tree::*;
4674        let mut tree = crate::scroll([
4675            crate::widgets::button::button("row 0")
4676                .key("row0")
4677                .height(Size::Fixed(50.0)),
4678            crate::widgets::button::button("row 1")
4679                .key("row1")
4680                .height(Size::Fixed(50.0)),
4681            crate::widgets::button::button("row 2")
4682                .key("row2")
4683                .height(Size::Fixed(50.0)),
4684            crate::widgets::button::button("row 3")
4685                .key("row3")
4686                .height(Size::Fixed(50.0)),
4687            crate::widgets::button::button("row 4")
4688                .key("row4")
4689                .height(Size::Fixed(50.0)),
4690        ])
4691        .key("list")
4692        .height(Size::Fixed(120.0));
4693        let mut core = RunnerCore::new();
4694        crate::layout::layout(
4695            &mut tree,
4696            &mut core.ui_state,
4697            Rect::new(0.0, 0.0, 200.0, 120.0),
4698        );
4699        core.ui_state.sync_focus_order(&tree);
4700        let mut t = PrepareTimings::default();
4701        core.snapshot(&tree, &mut t);
4702        let scroll_id = core
4703            .last_tree
4704            .as_ref()
4705            .map(|t| t.computed_id.clone())
4706            .expect("scroll id");
4707
4708        // Press inside row1, near the middle of the viewport, so a
4709        // 40px upward drag still lands inside the scrollable region
4710        // — `pointer_wheel` only routes when the up-finger position
4711        // is inside a scrollable's rect.
4712        let row1 = core.rect_of_key("row1").expect("row1");
4713        let cx = row1.x + row1.w * 0.5;
4714        let cy = row1.y + row1.h * 0.5;
4715
4716        // Press on row1.
4717        let down_events = core.pointer_down(Pointer::touch(
4718            cx,
4719            cy,
4720            PointerButton::Primary,
4721            PointerId::PRIMARY,
4722        ));
4723        // Sanity: PointerDown was emitted.
4724        assert!(
4725            down_events
4726                .iter()
4727                .any(|e| matches!(e.kind, UiEventKind::PointerDown)),
4728            "expected PointerDown on press",
4729        );
4730
4731        // Drag finger upward by 40px (past the 10px threshold). Sign
4732        // convention: finger moving up = positive scroll delta =
4733        // content scrolls down (offset increases).
4734        let mut up_finger = Pointer::moving(cx, cy - 40.0);
4735        up_finger.kind = PointerKind::Touch;
4736        let move_events = core.pointer_moved(up_finger);
4737        let kinds: Vec<UiEventKind> = move_events.events.iter().map(|e| e.kind).collect();
4738        assert!(
4739            kinds.contains(&UiEventKind::PointerCancel),
4740            "scroll commit should fire PointerCancel, got {kinds:?}",
4741        );
4742        assert!(
4743            !kinds.contains(&UiEventKind::Drag),
4744            "scroll commit should NOT emit Drag, got {kinds:?}",
4745        );
4746
4747        // Scroll offset advanced by ~the finger delta (40px).
4748        let offset = core.ui_state().scroll_offset(&scroll_id);
4749        assert!(
4750            offset > 30.0 && offset <= 50.0,
4751            "scroll offset should advance ~40px after a 40px finger drag, got {offset}",
4752        );
4753
4754        // Releasing now does NOT fire Click — the press was already
4755        // cancelled, so pointer_up returns nothing app-facing.
4756        let up_events = core.pointer_up(Pointer::touch(
4757            cx,
4758            cy - 40.0,
4759            PointerButton::Primary,
4760            PointerId::PRIMARY,
4761        ));
4762        let up_kinds: Vec<UiEventKind> = up_events.iter().map(|e| e.kind).collect();
4763        assert!(
4764            !up_kinds.contains(&UiEventKind::Click),
4765            "scroll-committed gesture must not fire Click on release, got {up_kinds:?}",
4766        );
4767    }
4768
4769    #[test]
4770    fn touch_scroll_release_starts_momentum_after_fast_swipe_outside_viewport() {
4771        use crate::tree::*;
4772        let mut tree = crate::scroll((0..20).map(|i| {
4773            crate::widgets::button::button(format!("row {i}"))
4774                .key(format!("row{i}"))
4775                .height(Size::Fixed(50.0))
4776        }))
4777        .key("list")
4778        .height(Size::Fixed(120.0));
4779        let mut core = RunnerCore::new();
4780        crate::layout::layout(
4781            &mut tree,
4782            &mut core.ui_state,
4783            Rect::new(0.0, 0.0, 200.0, 120.0),
4784        );
4785        core.ui_state.sync_focus_order(&tree);
4786        let mut t = PrepareTimings::default();
4787        core.snapshot(&tree, &mut t);
4788        let scroll_id = core
4789            .last_tree
4790            .as_ref()
4791            .map(|t| t.computed_id.clone())
4792            .expect("scroll id");
4793
4794        let row1 = core.rect_of_key("row1").expect("row1");
4795        let cx = row1.x + row1.w * 0.5;
4796        let cy = row1.y + row1.h * 0.5;
4797
4798        core.pointer_down(Pointer::touch(
4799            cx,
4800            cy,
4801            PointerButton::Primary,
4802            PointerId::PRIMARY,
4803        ));
4804        let mut up_finger = Pointer::moving(cx, cy - 80.0);
4805        up_finger.kind = PointerKind::Touch;
4806        core.pointer_moved(up_finger);
4807        let before_release = core.ui_state().scroll_offset(&scroll_id);
4808        core.pointer_up(Pointer::touch(
4809            cx,
4810            cy - 80.0,
4811            PointerButton::Primary,
4812            PointerId::PRIMARY,
4813        ));
4814
4815        assert!(
4816            core.ui_state.has_scroll_momentum(),
4817            "quick touch scroll release should retain inertial velocity"
4818        );
4819        assert_eq!(
4820            core.next_input_deadline(Instant::now()),
4821            Some(Duration::ZERO),
4822            "active scroll momentum should request the next layout frame"
4823        );
4824
4825        let ticked = core
4826            .ui_state
4827            .tick_scroll_momentum(Instant::now() + Duration::from_millis(16));
4828        let after_tick = core.ui_state().scroll_offset(&scroll_id);
4829        assert!(ticked, "momentum tick should report visual work");
4830        assert!(
4831            after_tick > before_release,
4832            "momentum should continue in release direction: before={before_release}, after={after_tick}"
4833        );
4834    }
4835
4836    #[test]
4837    fn pointer_left_emits_leave_for_prior_hover() {
4838        let mut core = lay_out_input_tree(false);
4839        let btn = core.rect_of_key("btn").expect("btn rect");
4840        let _ = core.pointer_moved(Pointer::moving(btn.x + 4.0, btn.y + 4.0));
4841
4842        let events = core.pointer_left();
4843        assert_eq!(events.len(), 1);
4844        assert_eq!(events[0].kind, UiEventKind::PointerLeave);
4845        assert_eq!(events[0].key.as_deref(), Some("btn"));
4846    }
4847
4848    #[test]
4849    fn pointer_left_with_no_prior_hover_emits_nothing() {
4850        let mut core = lay_out_input_tree(false);
4851        // No prior pointer_moved into a keyed target — pointer_left
4852        // should be a no-op event-wise (state still gets cleared).
4853        let events = core.pointer_left();
4854        assert!(events.is_empty());
4855    }
4856
4857    #[test]
4858    fn poll_input_before_long_press_delay_emits_nothing() {
4859        // A held touch that hasn't yet crossed LONG_PRESS_DELAY
4860        // should not produce a long-press event when polled.
4861        let mut core = lay_out_input_tree(false);
4862        let btn = core.rect_of_key("btn").expect("btn rect");
4863        let cx = btn.x + btn.w * 0.5;
4864        let cy = btn.y + btn.h * 0.5;
4865        let _ = core.pointer_down(Pointer::touch(
4866            cx,
4867            cy,
4868            PointerButton::Primary,
4869            PointerId::PRIMARY,
4870        ));
4871        // 100ms < 500ms — too early.
4872        let polled = core.poll_input(Instant::now() + Duration::from_millis(100));
4873        assert!(polled.is_empty(), "should not fire before delay");
4874    }
4875
4876    #[test]
4877    fn poll_input_after_long_press_delay_fires_cancel_then_long_press() {
4878        // After holding past LONG_PRESS_DELAY, poll_input emits
4879        // PointerCancel (cleaning up the in-flight press) followed by
4880        // a LongPress event keyed to the originally pressed target.
4881        let mut core = lay_out_input_tree(false);
4882        let btn = core.rect_of_key("btn").expect("btn rect");
4883        let cx = btn.x + btn.w * 0.5;
4884        let cy = btn.y + btn.h * 0.5;
4885        let _ = core.pointer_down(Pointer::touch(
4886            cx,
4887            cy,
4888            PointerButton::Primary,
4889            PointerId::PRIMARY,
4890        ));
4891        let polled = core.poll_input(Instant::now() + LONG_PRESS_DELAY + Duration::from_millis(10));
4892        let kinds: Vec<UiEventKind> = polled.iter().map(|e| e.kind).collect();
4893        assert!(
4894            kinds.contains(&UiEventKind::PointerCancel),
4895            "expected PointerCancel before LongPress, got {kinds:?}",
4896        );
4897        let long_press = polled
4898            .iter()
4899            .find(|e| matches!(e.kind, UiEventKind::LongPress))
4900            .expect("LongPress event missing");
4901        assert_eq!(
4902            long_press.key.as_deref(),
4903            Some("btn"),
4904            "LongPress should target the originally pressed node",
4905        );
4906        assert_eq!(
4907            long_press.pointer_kind,
4908            Some(PointerKind::Touch),
4909            "LongPress is touch-only",
4910        );
4911    }
4912
4913    #[test]
4914    fn touch_long_press_on_editable_preserves_drag_extension() {
4915        let mut core = lay_out_input_tree(true);
4916        let ti = core.rect_of_key("ti").expect("ti rect");
4917        let cx = ti.x + 4.0;
4918        let cy = ti.y + ti.h * 0.5;
4919        let _ = core.pointer_down(Pointer::touch(
4920            cx,
4921            cy,
4922            PointerButton::Primary,
4923            PointerId::PRIMARY,
4924        ));
4925
4926        let polled = core.poll_input(Instant::now() + LONG_PRESS_DELAY + Duration::from_millis(10));
4927        assert!(
4928            polled.iter().any(|e| e.kind == UiEventKind::LongPress),
4929            "editable long-press should emit LongPress"
4930        );
4931        assert!(
4932            !polled.iter().any(|e| e.kind == UiEventKind::PointerCancel),
4933            "editable long-press keeps the press captured so drag can extend selection"
4934        );
4935
4936        let mut moved = Pointer::moving(cx + 40.0, cy);
4937        moved.kind = PointerKind::Touch;
4938        let drag = core.pointer_moved(moved);
4939        assert!(
4940            drag.events.iter().any(|e| e.kind == UiEventKind::Drag),
4941            "held touch move after editable long-press should emit Drag"
4942        );
4943
4944        let up_events = core.pointer_up(Pointer::touch(
4945            cx + 40.0,
4946            cy,
4947            PointerButton::Primary,
4948            PointerId::PRIMARY,
4949        ));
4950        assert!(
4951            up_events.is_empty(),
4952            "long-press release should not synthesize click or pointer-up"
4953        );
4954    }
4955
4956    #[test]
4957    fn pointer_up_after_long_press_emits_no_click() {
4958        // Once the long-press fires, lifting the finger silently
4959        // releases — no Click, no PointerUp routed to the original
4960        // target.
4961        let mut core = lay_out_input_tree(false);
4962        let btn = core.rect_of_key("btn").expect("btn rect");
4963        let cx = btn.x + btn.w * 0.5;
4964        let cy = btn.y + btn.h * 0.5;
4965        let _ = core.pointer_down(Pointer::touch(
4966            cx,
4967            cy,
4968            PointerButton::Primary,
4969            PointerId::PRIMARY,
4970        ));
4971        let _ = core.poll_input(Instant::now() + LONG_PRESS_DELAY + Duration::from_millis(10));
4972        let up_events = core.pointer_up(Pointer::touch(
4973            cx,
4974            cy,
4975            PointerButton::Primary,
4976            PointerId::PRIMARY,
4977        ));
4978        assert!(
4979            up_events.is_empty(),
4980            "lift after long-press emits nothing, got {:?}",
4981            up_events.iter().map(|e| e.kind).collect::<Vec<_>>(),
4982        );
4983    }
4984
4985    #[test]
4986    fn moving_past_threshold_before_long_press_cancels_the_timer() {
4987        // A drift past TOUCH_DRAG_THRESHOLD before the long-press
4988        // deadline commits the gesture (to scroll or drag), which
4989        // means the long-press should NOT fire even when we later
4990        // poll past LONG_PRESS_DELAY.
4991        let mut core = lay_out_input_tree(false);
4992        let btn = core.rect_of_key("btn").expect("btn rect");
4993        let cx = btn.x + btn.w * 0.5;
4994        let cy = btn.y + btn.h * 0.5;
4995        let _ = core.pointer_down(Pointer::touch(
4996            cx,
4997            cy,
4998            PointerButton::Primary,
4999            PointerId::PRIMARY,
5000        ));
5001        // Move 30px past threshold — gesture commits.
5002        let mut over = Pointer::moving(cx + 30.0, cy);
5003        over.kind = PointerKind::Touch;
5004        let _ = core.pointer_moved(over);
5005        // Poll well past the long-press deadline — should be empty.
5006        let polled = core.poll_input(Instant::now() + LONG_PRESS_DELAY + Duration::from_millis(10));
5007        assert!(
5008            polled.is_empty(),
5009            "long-press should not fire after gesture committed",
5010        );
5011    }
5012
5013    // ---- latent viewport pan: click-vs-pan arbitration (issue #125) ----
5014
5015    /// Lay out a viewport tree against 400x300 and return the runner.
5016    fn lay_out_viewport_tree(mut tree: El) -> RunnerCore {
5017        let mut core = RunnerCore::new();
5018        crate::layout::layout(
5019            &mut tree,
5020            &mut core.ui_state,
5021            Rect::new(0.0, 0.0, 400.0, 300.0),
5022        );
5023        core.ui_state.sync_focus_order(&tree);
5024        core.ui_state.sync_selection_order(&tree);
5025        let mut t = PrepareTimings::default();
5026        core.snapshot(&tree, &mut t);
5027        core
5028    }
5029
5030    /// A default-trigger, free-bounds viewport keyed "vp" around `child`.
5031    /// The child hugs its fixed size at the origin, so everything else
5032    /// inside 400x300 is viewport background.
5033    fn free_viewport(child: El) -> El {
5034        crate::tree::viewport([child])
5035            .key("vp")
5036            .pan_bounds(crate::viewport::PanBounds::Free)
5037    }
5038
5039    /// A 120x60 keyed click target — the "flow card" of the issue.
5040    fn vp_card() -> El {
5041        use crate::tree::*;
5042        crate::widgets::button::button("Card")
5043            .key("card")
5044            .width(Size::Fixed(120.0))
5045            .height(Size::Fixed(60.0))
5046    }
5047
5048    #[test]
5049    fn latent_pan_sub_slop_release_still_clicks() {
5050        let mut core = lay_out_viewport_tree(free_viewport(vp_card()));
5051        let r = core.rect_of_key("card").expect("card rect");
5052        let (cx, cy) = (r.x + r.w * 0.5, r.y + r.h * 0.5);
5053        core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
5054        // Jiggle under MOUSE_DRAG_SLOP — still a click candidate.
5055        core.pointer_moved(Pointer::moving(cx + 2.0, cy + 1.0));
5056        assert!(
5057            !core.ui_state.viewport_pan_active(),
5058            "sub-slop motion must not begin a pan"
5059        );
5060        let events = core.pointer_up(Pointer::mouse(cx + 2.0, cy + 1.0, PointerButton::Primary));
5061        let kinds: Vec<UiEventKind> = events.iter().map(|e| e.kind).collect();
5062        assert!(
5063            kinds.contains(&UiEventKind::Click),
5064            "sub-slop release must still click, got {kinds:?}"
5065        );
5066        let view = core.ui_state.viewport_view_by_key("vp").expect("vp view");
5067        assert_eq!(
5068            view.pan,
5069            (0.0, 0.0),
5070            "sub-slop jiggle must not move the pan"
5071        );
5072    }
5073
5074    #[test]
5075    fn latent_pan_converts_past_slop_cancels_click_and_anchors_at_press() {
5076        let mut core = lay_out_viewport_tree(free_viewport(vp_card()));
5077        let r = core.rect_of_key("card").expect("card rect");
5078        let (cx, cy) = (r.x + r.w * 0.5, r.y + r.h * 0.5);
5079        core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
5080        let moved = core.pointer_moved(Pointer::moving(cx + 20.0, cy));
5081        let kinds: Vec<UiEventKind> = moved.events.iter().map(|e| e.kind).collect();
5082        assert!(
5083            kinds.contains(&UiEventKind::PointerCancel),
5084            "conversion must cancel the pending click, got {kinds:?}"
5085        );
5086        assert!(moved.needs_redraw, "conversion clears press styling");
5087        assert!(core.ui_state.viewport_pan_active());
5088        let view = core.ui_state.viewport_view_by_key("vp").expect("vp view");
5089        // Anchored at the press point: the full 20px lands in the pan —
5090        // no motion lost to the slop.
5091        assert!(
5092            (view.pan.0 - 20.0).abs() < 0.01 && view.pan.1.abs() < 0.01,
5093            "pan should track the full press-to-cursor delta, got {:?}",
5094            view.pan
5095        );
5096        // Further motion keeps driving the pan from the same anchor.
5097        core.pointer_moved(Pointer::moving(cx + 30.0, cy + 5.0));
5098        let view = core.ui_state.viewport_view_by_key("vp").expect("vp view");
5099        assert!(
5100            (view.pan.0 - 30.0).abs() < 0.01 && (view.pan.1 - 5.0).abs() < 0.01,
5101            "pan should keep tracking, got {:?}",
5102            view.pan
5103        );
5104        let up = core.pointer_up(Pointer::mouse(cx + 30.0, cy + 5.0, PointerButton::Primary));
5105        assert!(
5106            up.is_empty(),
5107            "a converted pan releases without click, got {:?}",
5108            up.iter().map(|e| e.kind).collect::<Vec<_>>()
5109        );
5110        assert!(!core.ui_state.viewport_pan_active());
5111    }
5112
5113    #[test]
5114    fn viewport_background_press_still_pans_immediately() {
5115        // Regression guard: the press-time rule for background presses
5116        // is untouched by the latent arbitration — no slop needed.
5117        let mut core = lay_out_viewport_tree(free_viewport(vp_card()));
5118        core.pointer_down(Pointer::mouse(300.0, 250.0, PointerButton::Primary));
5119        assert!(
5120            core.ui_state.viewport_pan_active(),
5121            "background press must begin the pan at press time"
5122        );
5123    }
5124
5125    #[test]
5126    fn dedicated_trigger_still_pans_over_child_at_press() {
5127        // A non-primary trigger can't collide with child clicks, so it
5128        // pans immediately even over the card — no latent conversion.
5129        let mut core =
5130            lay_out_viewport_tree(free_viewport(vp_card()).pan_button(PointerButton::Secondary));
5131        let r = core.rect_of_key("card").expect("card rect");
5132        core.pointer_down(Pointer::mouse(
5133            r.x + r.w * 0.5,
5134            r.y + r.h * 0.5,
5135            PointerButton::Secondary,
5136        ));
5137        assert!(
5138            core.ui_state.viewport_pan_active(),
5139            "dedicated trigger must pan at press time even over a child"
5140        );
5141    }
5142
5143    #[test]
5144    fn latent_pan_yields_to_drag_consuming_child() {
5145        // A widget that owns its drag (slider-style) keeps it: motion
5146        // past the slop stays a Drag to the child, never a pan.
5147        let mut core = lay_out_viewport_tree(free_viewport(vp_card().consumes_touch_drag()));
5148        let r = core.rect_of_key("card").expect("card rect");
5149        let (cx, cy) = (r.x + r.w * 0.5, r.y + r.h * 0.5);
5150        core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
5151        let moved = core.pointer_moved(Pointer::moving(cx + 20.0, cy));
5152        assert!(
5153            !core.ui_state.viewport_pan_active(),
5154            "drag-consumer keeps its drag"
5155        );
5156        let kinds: Vec<UiEventKind> = moved.events.iter().map(|e| e.kind).collect();
5157        assert!(
5158            kinds.contains(&UiEventKind::Drag) && !kinds.contains(&UiEventKind::PointerCancel),
5159            "child should keep receiving Drag, got {kinds:?}"
5160        );
5161    }
5162
5163    #[test]
5164    fn latent_pan_yields_to_capture_keys_child() {
5165        // Drag inside an editable widget is selection extension — a
5166        // text input inside a viewport must not turn drags into pans.
5167        let ti = crate::widgets::text::text("input").key("ti").capture_keys();
5168        let mut core = lay_out_viewport_tree(free_viewport(ti));
5169        let r = core.rect_of_key("ti").expect("ti rect");
5170        let (cx, cy) = (r.x + r.w * 0.5, r.y + r.h * 0.5);
5171        core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
5172        let moved = core.pointer_moved(Pointer::moving(cx + 20.0, cy));
5173        assert!(
5174            !core.ui_state.viewport_pan_active(),
5175            "editable keeps its drag"
5176        );
5177        let kinds: Vec<UiEventKind> = moved.events.iter().map(|e| e.kind).collect();
5178        assert!(
5179            kinds.contains(&UiEventKind::Drag) && !kinds.contains(&UiEventKind::PointerCancel),
5180            "input should keep receiving Drag, got {kinds:?}"
5181        );
5182    }
5183
5184    #[test]
5185    fn latent_pan_yields_to_text_selection_drag() {
5186        // A press that starts a static-text selection keeps selecting;
5187        // dragging across selectable text inside a viewport must not
5188        // convert into a pan.
5189        let p = crate::widgets::text::text("Some selectable paragraph text.")
5190            .key("p")
5191            .selectable();
5192        let mut core = lay_out_viewport_tree(free_viewport(p));
5193        let r = core.rect_of_key("p").expect("p rect");
5194        let (cx, cy) = (r.x + 4.0, r.y + r.h * 0.5);
5195        core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
5196        assert!(
5197            core.ui_state.selection.drag.is_some(),
5198            "press starts selection"
5199        );
5200        core.pointer_moved(Pointer::moving(cx + 20.0, cy));
5201        assert!(
5202            !core.ui_state.viewport_pan_active(),
5203            "selection drag must keep priority over the pan"
5204        );
5205    }
5206
5207    #[test]
5208    fn interleaved_pan_release_does_not_strand_the_latent_pan() {
5209        // Two viewports side by side: A pans on a dedicated secondary
5210        // trigger, B on the default. Hold a secondary pan on A, press
5211        // primary on B's card (arms a latent pan), release primary. The
5212        // latent must disarm on that release (issue #125), the release
5213        // must land as the card's click rather than being swallowed by
5214        // A's capture, and A's pan must survive until its own button
5215        // releases (issue #128). Afterwards, a button-less hover move
5216        // must neither convert a stale latent nor drag a stale press.
5217        use crate::tree::*;
5218        let vp_a = crate::tree::viewport([crate::widgets::button::button("A")
5219            .key("card_a")
5220            .width(Size::Fixed(80.0))
5221            .height(Size::Fixed(40.0))])
5222        .key("vpa")
5223        .pan_button(PointerButton::Secondary)
5224        .pan_bounds(crate::viewport::PanBounds::Free);
5225        let mut core = lay_out_viewport_tree(crate::row([vp_a, free_viewport(vp_card())]));
5226        // Secondary press over A (dedicated: pans from anywhere over it).
5227        core.pointer_down(Pointer::mouse(100.0, 250.0, PointerButton::Secondary));
5228        assert!(core.ui_state.viewport_pan_active(), "dedicated pan on A");
5229        // Primary press on B's card while the pan is held — arms the latent.
5230        let r = core.rect_of_key("card").expect("card rect");
5231        let (cx, cy) = (r.x + r.w * 0.5, r.y + r.h * 0.5);
5232        core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
5233        // Primary release: A's capture must not swallow it — the sub-slop
5234        // press-release is the card's click, and A keeps panning.
5235        let up = core.pointer_up(Pointer::mouse(cx, cy, PointerButton::Primary));
5236        let up_kinds: Vec<UiEventKind> = up.iter().map(|e| e.kind).collect();
5237        assert!(
5238            up_kinds.contains(&UiEventKind::Click),
5239            "primary release clicks the card despite A's pan, got {up_kinds:?}"
5240        );
5241        assert!(
5242            core.ui_state.viewport_pan_active(),
5243            "A's pan survives the unrelated primary release"
5244        );
5245        core.pointer_up(Pointer::mouse(100.0, 250.0, PointerButton::Secondary));
5246        assert!(
5247            !core.ui_state.viewport_pan_active(),
5248            "A's own release ends it"
5249        );
5250        // All buttons up. A hover move past the slop must not convert.
5251        let moved = core.pointer_moved(Pointer::moving(cx + 20.0, cy));
5252        assert!(
5253            !core.ui_state.viewport_pan_active(),
5254            "no button held — a stale latent pan must not begin a drag"
5255        );
5256        let kinds: Vec<UiEventKind> = moved.events.iter().map(|e| e.kind).collect();
5257        assert!(
5258            !kinds.contains(&UiEventKind::PointerCancel),
5259            "no phantom press-cancel on a button-less move, got {kinds:?}"
5260        );
5261        assert!(
5262            !kinds.contains(&UiEventKind::Drag),
5263            "no stale press keeps emitting Drag after release, got {kinds:?}"
5264        );
5265        let view = core.ui_state.viewport_view_by_key("vp").expect("vp view");
5266        assert_eq!(view.pan, (0.0, 0.0), "B must not have panned");
5267    }
5268
5269    #[test]
5270    fn unrelated_release_does_not_end_a_viewport_pan() {
5271        // Issue #128: a secondary click mid-drag must not kill a
5272        // primary-initiated pan — only the initiating button ends it.
5273        let mut core = lay_out_viewport_tree(free_viewport(vp_card()));
5274        core.pointer_down(Pointer::mouse(300.0, 250.0, PointerButton::Primary));
5275        assert!(core.ui_state.viewport_pan_active(), "background pan begins");
5276        core.pointer_down(Pointer::mouse(300.0, 250.0, PointerButton::Secondary));
5277        core.pointer_up(Pointer::mouse(300.0, 250.0, PointerButton::Secondary));
5278        assert!(
5279            core.ui_state.viewport_pan_active(),
5280            "pan survives an unrelated button's release"
5281        );
5282        // Motion keeps driving the pan…
5283        core.pointer_moved(Pointer::moving(320.0, 250.0));
5284        let view = core.ui_state.viewport_view_by_key("vp").expect("vp view");
5285        assert!(
5286            (view.pan.0 - 20.0).abs() < 0.01,
5287            "still panning: {:?}",
5288            view.pan
5289        );
5290        // …until the initiating button releases.
5291        core.pointer_up(Pointer::mouse(320.0, 250.0, PointerButton::Primary));
5292        assert!(!core.ui_state.viewport_pan_active());
5293    }
5294
5295    #[test]
5296    fn latent_pan_conversion_resets_the_multi_click_chain() {
5297        // MULTI_CLICK_DIST is as wide as the slop: without the reset, a
5298        // press → convert-to-pan → release → re-press near the original
5299        // point inside the double-click window would count as click 2.
5300        let mut core = lay_out_viewport_tree(free_viewport(vp_card()));
5301        let r = core.rect_of_key("card").expect("card rect");
5302        let (cx, cy) = (r.x + r.w * 0.5, r.y + r.h * 0.5);
5303        core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
5304        core.pointer_moved(Pointer::moving(cx + 20.0, cy));
5305        assert!(core.ui_state.viewport_pan_active(), "converted");
5306        core.pointer_up(Pointer::mouse(cx + 20.0, cy, PointerButton::Primary));
5307        // Immediate re-press at the original point: a fresh click 1.
5308        let events = core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
5309        let down = events
5310            .iter()
5311            .find(|e| e.kind == UiEventKind::PointerDown)
5312            .expect("PointerDown on re-press");
5313        assert_eq!(
5314            down.click_count, 1,
5315            "a press that became a pan must not seed a double-click"
5316        );
5317    }
5318
5319    /// A touch move (host cursor-move events carry no button).
5320    fn touch_move(x: f32, y: f32) -> Pointer {
5321        let mut p = Pointer::moving(x, y);
5322        p.kind = PointerKind::Touch;
5323        p
5324    }
5325
5326    #[test]
5327    fn touch_drag_on_viewport_child_pans_past_threshold() {
5328        // Issue #126: the touch analog of the mouse latent pan — a drag
5329        // starting on a keyed child of a default-trigger viewport
5330        // commits to a viewport pan (anchored at the initial contact),
5331        // not to scroll routing that has nothing to scroll.
5332        let mut core = lay_out_viewport_tree(free_viewport(vp_card()));
5333        let r = core.rect_of_key("card").expect("card rect");
5334        let (cx, cy) = (r.x + r.w * 0.5, r.y + r.h * 0.5);
5335        core.pointer_down(Pointer::touch(
5336            cx,
5337            cy,
5338            PointerButton::Primary,
5339            PointerId::PRIMARY,
5340        ));
5341        let moved = core.pointer_moved(touch_move(cx + 30.0, cy));
5342        let kinds: Vec<UiEventKind> = moved.events.iter().map(|e| e.kind).collect();
5343        assert!(
5344            kinds.contains(&UiEventKind::PointerCancel),
5345            "commit cancels the pending tap, got {kinds:?}"
5346        );
5347        assert!(core.ui_state.viewport_pan_active(), "drag commits to a pan");
5348        let view = core.ui_state.viewport_view_by_key("vp").expect("vp view");
5349        assert!(
5350            (view.pan.0 - 30.0).abs() < 0.01 && view.pan.1.abs() < 0.01,
5351            "anchored at the initial contact — full delta lands, got {:?}",
5352            view.pan
5353        );
5354        let up = core.pointer_up(Pointer::touch(
5355            cx + 30.0,
5356            cy,
5357            PointerButton::Primary,
5358            PointerId::PRIMARY,
5359        ));
5360        assert!(
5361            up.is_empty(),
5362            "a committed pan releases without click, got {:?}",
5363            up.iter().map(|e| e.kind).collect::<Vec<_>>()
5364        );
5365        assert!(!core.ui_state.viewport_pan_active());
5366    }
5367
5368    #[test]
5369    fn touch_tap_on_viewport_child_still_clicks() {
5370        // Sub-threshold contact stays a tap — the viewport arbitration
5371        // must not eat taps on cards.
5372        let mut core = lay_out_viewport_tree(free_viewport(vp_card()));
5373        let r = core.rect_of_key("card").expect("card rect");
5374        let (cx, cy) = (r.x + r.w * 0.5, r.y + r.h * 0.5);
5375        core.pointer_down(Pointer::touch(
5376            cx,
5377            cy,
5378            PointerButton::Primary,
5379            PointerId::PRIMARY,
5380        ));
5381        core.pointer_moved(touch_move(cx + 3.0, cy));
5382        let up = core.pointer_up(Pointer::touch(
5383            cx + 3.0,
5384            cy,
5385            PointerButton::Primary,
5386            PointerId::PRIMARY,
5387        ));
5388        let kinds: Vec<UiEventKind> = up.iter().map(|e| e.kind).collect();
5389        assert!(
5390            kinds.contains(&UiEventKind::Click),
5391            "tap clicks, got {kinds:?}"
5392        );
5393        let view = core.ui_state.viewport_view_by_key("vp").expect("vp view");
5394        assert_eq!(view.pan, (0.0, 0.0), "tap must not pan");
5395    }
5396
5397    #[test]
5398    fn touch_drag_over_scrollable_inside_viewport_scrolls_it() {
5399        // A scrollable *inside* the viewport content keeps the drag
5400        // (mobile convention: a list laid over a map scrolls; the map
5401        // pans from anywhere else).
5402        use crate::tree::*;
5403        let rows: Vec<El> = (0..20)
5404            .map(|i| {
5405                crate::widgets::button::button("Row")
5406                    .key(format!("row{i}"))
5407                    .width(Size::Fixed(160.0))
5408                    .height(Size::Fixed(40.0))
5409            })
5410            .collect();
5411        let list = crate::scroll(rows)
5412            .key("list")
5413            .width(Size::Fixed(180.0))
5414            .height(Size::Fixed(120.0));
5415        let mut core = lay_out_viewport_tree(free_viewport(list));
5416        let list_id = core
5417            .ui_state
5418            .layout
5419            .key_index
5420            .get("list")
5421            .expect("list id")
5422            .to_string();
5423        // Vertical drag over the list body.
5424        core.pointer_down(Pointer::touch(
5425            90.0,
5426            80.0,
5427            PointerButton::Primary,
5428            PointerId::PRIMARY,
5429        ));
5430        core.pointer_moved(touch_move(90.0, 40.0));
5431        assert!(
5432            !core.ui_state.viewport_pan_active(),
5433            "the inner scrollable wins the vertical drag"
5434        );
5435        assert!(
5436            matches!(
5437                core.ui_state.touch_gesture,
5438                TouchGestureState::Scrolling { .. }
5439            ),
5440            "gesture commits to scrolling the list"
5441        );
5442        let offset = core
5443            .ui_state
5444            .scroll
5445            .offsets
5446            .get(list_id.as_str())
5447            .copied()
5448            .unwrap_or(0.0);
5449        assert!(offset > 1.0, "the list scrolled, offset {offset}");
5450        let view = core.ui_state.viewport_view_by_key("vp").expect("vp view");
5451        assert_eq!(view.pan, (0.0, 0.0), "viewport must not pan");
5452    }
5453
5454    #[test]
5455    fn touch_horizontal_drag_over_scrollable_inside_viewport_pans() {
5456        // Issue #130: axis dominance. A mostly-horizontal swipe over a
5457        // vertical list embedded in a viewport must pan the viewport —
5458        // the list has no horizontal freedom, and without the dominance
5459        // gate its consuming the few pixels of vertical drift would
5460        // lock the whole gesture to jittering the list.
5461        use crate::tree::*;
5462        let rows: Vec<El> = (0..20)
5463            .map(|i| {
5464                crate::widgets::button::button("Row")
5465                    .key(format!("row{i}"))
5466                    .width(Size::Fixed(160.0))
5467                    .height(Size::Fixed(40.0))
5468            })
5469            .collect();
5470        let list = crate::scroll(rows)
5471            .key("list")
5472            .width(Size::Fixed(180.0))
5473            .height(Size::Fixed(120.0));
5474        let mut core = lay_out_viewport_tree(free_viewport(list));
5475        let list_id = core
5476            .ui_state
5477            .layout
5478            .key_index
5479            .get("list")
5480            .expect("list id")
5481            .to_string();
5482        // 30px horizontal with 3px of *upward* drift — upward makes
5483        // the drift consumable by a list at offset 0 (finger up =
5484        // scroll down), so this fails without the dominance gate
5485        // instead of passing vacuously because the list couldn't
5486        // move anyway.
5487        core.pointer_down(Pointer::touch(
5488            90.0,
5489            80.0,
5490            PointerButton::Primary,
5491            PointerId::PRIMARY,
5492        ));
5493        core.pointer_moved(touch_move(120.0, 77.0));
5494        assert!(
5495            core.ui_state.viewport_pan_active(),
5496            "horizontal-dominant drag pans the viewport"
5497        );
5498        let offset = core
5499            .ui_state
5500            .scroll
5501            .offsets
5502            .get(list_id.as_str())
5503            .copied()
5504            .unwrap_or(0.0);
5505        assert!(
5506            offset.abs() < 0.01,
5507            "the list must not consume the vertical drift, offset {offset}"
5508        );
5509        let view = core.ui_state.viewport_view_by_key("vp").expect("vp view");
5510        assert!(
5511            (view.pan.0 - 30.0).abs() < 0.01 && (view.pan.1 + 3.0).abs() < 0.01,
5512            "pan anchored at the initial contact, got {:?}",
5513            view.pan
5514        );
5515    }
5516
5517    #[test]
5518    fn touch_vertical_drag_with_drift_still_scrolls_inner_list() {
5519        // Companion to the axis-dominance gate (issue #130): a
5520        // vertical-dominant drag with a little horizontal drift must
5521        // keep scrolling the embedded list — dominance, not purity.
5522        use crate::tree::*;
5523        let rows: Vec<El> = (0..20)
5524            .map(|i| {
5525                crate::widgets::button::button("Row")
5526                    .key(format!("row{i}"))
5527                    .width(Size::Fixed(160.0))
5528                    .height(Size::Fixed(40.0))
5529            })
5530            .collect();
5531        let list = crate::scroll(rows)
5532            .key("list")
5533            .width(Size::Fixed(180.0))
5534            .height(Size::Fixed(120.0));
5535        let mut core = lay_out_viewport_tree(free_viewport(list));
5536        let list_id = core
5537            .ui_state
5538            .layout
5539            .key_index
5540            .get("list")
5541            .expect("list id")
5542            .to_string();
5543        // 40px up with 3px of horizontal drift.
5544        core.pointer_down(Pointer::touch(
5545            90.0,
5546            80.0,
5547            PointerButton::Primary,
5548            PointerId::PRIMARY,
5549        ));
5550        core.pointer_moved(touch_move(93.0, 40.0));
5551        assert!(
5552            !core.ui_state.viewport_pan_active(),
5553            "vertical-dominant drag stays with the inner scrollable"
5554        );
5555        let offset = core
5556            .ui_state
5557            .scroll
5558            .offsets
5559            .get(list_id.as_str())
5560            .copied()
5561            .unwrap_or(0.0);
5562        assert!(offset > 1.0, "the list scrolled, offset {offset}");
5563    }
5564
5565    #[test]
5566    fn touch_drag_on_scrollable_floating_over_viewport_scrolls_it() {
5567        // A scrollable in a sibling layer painted *over* the viewport
5568        // (no block_pointer) is neither ancestor nor descendant of it —
5569        // the pan takeover must yield to the press target's own layer,
5570        // exactly like the mouse paths do via their hit checks.
5571        use crate::tree::*;
5572        let rows: Vec<El> = (0..20)
5573            .map(|i| {
5574                crate::widgets::button::button("Row")
5575                    .key(format!("row{i}"))
5576                    .width(Size::Fixed(160.0))
5577                    .height(Size::Fixed(40.0))
5578            })
5579            .collect();
5580        let list = crate::scroll(rows)
5581            .key("list")
5582            .width(Size::Fixed(180.0))
5583            .height(Size::Fixed(120.0));
5584        let mut core = lay_out_viewport_tree(crate::stack([free_viewport(vp_card()), list]));
5585        let list_id = core
5586            .ui_state
5587            .layout
5588            .key_index
5589            .get("list")
5590            .expect("list id")
5591            .to_string();
5592        let r = core.rect_of_key("row1").expect("row rect");
5593        let (cx, cy) = (r.x + r.w * 0.5, r.y + r.h * 0.5);
5594        core.pointer_down(Pointer::touch(
5595            cx,
5596            cy,
5597            PointerButton::Primary,
5598            PointerId::PRIMARY,
5599        ));
5600        core.pointer_moved(touch_move(cx, cy - 40.0));
5601        assert!(
5602            !core.ui_state.viewport_pan_active(),
5603            "the floating list keeps its drag — no pan underneath"
5604        );
5605        let offset = core
5606            .ui_state
5607            .scroll
5608            .offsets
5609            .get(list_id.as_str())
5610            .copied()
5611            .unwrap_or(0.0);
5612        assert!(offset > 1.0, "the list scrolled, offset {offset}");
5613        let view = core.ui_state.viewport_view_by_key("vp").expect("vp view");
5614        assert_eq!(view.pan, (0.0, 0.0), "viewport must not pan");
5615    }
5616
5617    #[test]
5618    fn touch_drag_on_child_of_dedicated_trigger_viewport_does_not_pan() {
5619        // Touch contacts arrive as the primary button, so a viewport
5620        // that pans on a dedicated trigger keeps today's scroll routing
5621        // (its pan stays wheel/programmatic-only on touch).
5622        let mut core =
5623            lay_out_viewport_tree(free_viewport(vp_card()).pan_button(PointerButton::Secondary));
5624        let r = core.rect_of_key("card").expect("card rect");
5625        let (cx, cy) = (r.x + r.w * 0.5, r.y + r.h * 0.5);
5626        core.pointer_down(Pointer::touch(
5627            cx,
5628            cy,
5629            PointerButton::Primary,
5630            PointerId::PRIMARY,
5631        ));
5632        core.pointer_moved(touch_move(cx + 30.0, cy));
5633        assert!(
5634            !core.ui_state.viewport_pan_active(),
5635            "dedicated trigger must not pan on a touch drag"
5636        );
5637    }
5638
5639    #[test]
5640    fn touch_drag_over_viewport_child_beats_the_outer_page_scroll() {
5641        // A viewport embedded in a scroll page owns drags over its
5642        // content — the embedded-map convention.
5643        use crate::tree::*;
5644        let vp = free_viewport(vp_card())
5645            .width(Size::Fixed(300.0))
5646            .height(Size::Fixed(150.0));
5647        let mut children: Vec<El> = vec![vp];
5648        children.extend((0..10).map(|i| {
5649            crate::widgets::button::button("Filler")
5650                .key(format!("f{i}"))
5651                .height(Size::Fixed(100.0))
5652        }));
5653        let mut core = lay_out_viewport_tree(crate::scroll(children).key("page"));
5654        let r = core.rect_of_key("card").expect("card rect");
5655        let (cx, cy) = (r.x + r.w * 0.5, r.y + r.h * 0.5);
5656        core.pointer_down(Pointer::touch(
5657            cx,
5658            cy,
5659            PointerButton::Primary,
5660            PointerId::PRIMARY,
5661        ));
5662        core.pointer_moved(touch_move(cx, cy + 30.0));
5663        assert!(
5664            core.ui_state.viewport_pan_active(),
5665            "the viewport wins over the enclosing page scroll"
5666        );
5667        let view = core.ui_state.viewport_view_by_key("vp").expect("vp view");
5668        assert!(
5669            (view.pan.1 - 30.0).abs() < 0.01,
5670            "pan tracks the drag, got {:?}",
5671            view.pan
5672        );
5673    }
5674
5675    #[test]
5676    fn pointer_cancel_abandons_captures_without_release_effects() {
5677        // A system cancel (touch stolen by an OS gesture) mid-pan: the
5678        // capture must release where it is, so the next contact starts
5679        // fresh instead of inheriting a live pan whose release then
5680        // eats a tap.
5681        let mut core = lay_out_viewport_tree(free_viewport(vp_card()));
5682        let r = core.rect_of_key("card").expect("card rect");
5683        let (cx, cy) = (r.x + r.w * 0.5, r.y + r.h * 0.5);
5684        core.pointer_down(Pointer::touch(
5685            cx,
5686            cy,
5687            PointerButton::Primary,
5688            PointerId::PRIMARY,
5689        ));
5690        core.pointer_moved(touch_move(cx + 30.0, cy));
5691        assert!(core.ui_state.viewport_pan_active(), "pan in flight");
5692        core.pointer_cancelled();
5693        assert!(
5694            !core.ui_state.viewport_pan_active(),
5695            "cancel abandons the pan capture"
5696        );
5697        // The view keeps what the drag reached — cancel is not an undo.
5698        let view = core.ui_state.viewport_view_by_key("vp").expect("vp view");
5699        assert!((view.pan.0 - 30.0).abs() < 0.01);
5700        // A fresh tap afterwards clicks normally — nothing inherited.
5701        core.pointer_down(Pointer::touch(
5702            cx,
5703            cy,
5704            PointerButton::Primary,
5705            PointerId::PRIMARY,
5706        ));
5707        let up = core.pointer_up(Pointer::touch(
5708            cx,
5709            cy,
5710            PointerButton::Primary,
5711            PointerId::PRIMARY,
5712        ));
5713        let kinds: Vec<UiEventKind> = up.iter().map(|e| e.kind).collect();
5714        assert!(
5715            kinds.contains(&UiEventKind::Click),
5716            "next tap clicks, got {kinds:?}"
5717        );
5718    }
5719
5720    #[test]
5721    fn pointer_cancel_discards_plot_box_zoom() {
5722        // Unlike release (which applies the swept span), a cancel must
5723        // throw the selection away.
5724        let mut core = plot_under_modal(false);
5725        let before = core.ui_state.plot_view_by_key("p").expect("plot view");
5726        core.pointer_down(Pointer::mouse(120.0, 100.0, PointerButton::Primary));
5727        assert!(core.ui_state.plot_zoom_active(), "box-zoom in flight");
5728        core.pointer_moved(Pointer::moving(280.0, 200.0));
5729        core.pointer_cancelled();
5730        assert!(!core.ui_state.plot_zoom_active(), "cancel clears the drag");
5731        let after = core.ui_state.plot_view_by_key("p").expect("plot view");
5732        assert_eq!(
5733            (before.x.min, before.x.max),
5734            (after.x.min, after.x.max),
5735            "the swept span must not be applied"
5736        );
5737    }
5738
5739    /// The #127 fixture: a full-frame default-trigger viewport with an
5740    /// optional centered modal floated over it. The modal body is
5741    /// unkeyed, so a press on the panel lands on block_pointer dead
5742    /// space; presses outside the panel land on the keyed dismiss
5743    /// scrim (never pan-eligible either — correct modal semantics).
5744    fn vp_under_modal(with_modal: bool) -> RunnerCore {
5745        use crate::tree::*;
5746        let modal = with_modal.then(|| {
5747            crate::widgets::overlay::modal(
5748                "detail",
5749                "Detail",
5750                [crate::widgets::text::text("plain detail text")
5751                    .width(Size::Fixed(200.0))
5752                    .height(Size::Fixed(120.0))],
5753            )
5754        });
5755        lay_out_viewport_tree(crate::widgets::overlay::overlays(
5756            free_viewport(vp_card()),
5757            [modal],
5758        ))
5759    }
5760
5761    #[test]
5762    fn press_on_modal_dead_space_does_not_pan_the_viewport_underneath() {
5763        // Issue #127: a modal's block_pointer dead space hits nothing,
5764        // which the pan branch used to read as "viewport background" —
5765        // press-dragging on the modal panned the canvas underneath it.
5766        // Mirrors the wheel path's occlusion rule.
5767        let mut core = vp_under_modal(true);
5768        // The centered modal panel covers the root center.
5769        core.pointer_down(Pointer::mouse(200.0, 150.0, PointerButton::Primary));
5770        assert!(
5771            !core.ui_state.viewport_pan_active(),
5772            "press on the modal must not pan the canvas under it"
5773        );
5774        // Same point without the modal is viewport background: it pans —
5775        // the guard must not overreach.
5776        let mut core = vp_under_modal(false);
5777        core.pointer_down(Pointer::mouse(200.0, 150.0, PointerButton::Primary));
5778        assert!(
5779            core.ui_state.viewport_pan_active(),
5780            "background press without the modal still pans"
5781        );
5782    }
5783
5784    /// The #127 camera fixture: a chart3d scene with an optional
5785    /// centered modal floated over it.
5786    fn scene_under_modal(with_modal: bool) -> RunnerCore {
5787        use crate::scene::{PointData, PointsHandle, ScenePoint, SceneSpec};
5788        let handle = PointsHandle::new(PointData {
5789            points: vec![
5790                ScenePoint {
5791                    position: crate::scene::glam::Vec3::splat(-1.0),
5792                    color: [1.0; 4],
5793                },
5794                ScenePoint {
5795                    position: crate::scene::glam::Vec3::splat(1.0),
5796                    color: [1.0; 4],
5797                },
5798            ],
5799        });
5800        let modal = with_modal.then(|| {
5801            crate::widgets::overlay::modal(
5802                "detail",
5803                "Detail",
5804                [crate::widgets::text::text("plain detail text")
5805                    .width(crate::tree::Size::Fixed(200.0))
5806                    .height(crate::tree::Size::Fixed(120.0))],
5807            )
5808        });
5809        let mut tree = crate::widgets::overlay::overlays(
5810            crate::tree::chart3d(SceneSpec::new().points(handle)),
5811            [modal],
5812        );
5813        let mut core = RunnerCore::new();
5814        crate::layout::layout(
5815            &mut tree,
5816            &mut core.ui_state,
5817            Rect::new(0.0, 0.0, 400.0, 300.0),
5818        );
5819        core.ui_state.sync_focus_order(&tree);
5820        core.ui_state.tick_scene_cameras(&tree, Instant::now());
5821        let mut t = PrepareTimings::default();
5822        core.snapshot(&tree, &mut t);
5823        core
5824    }
5825
5826    /// The #127 plot fixture: a full-frame plot with an optional
5827    /// centered modal floated over it.
5828    fn plot_under_modal(with_modal: bool) -> RunnerCore {
5829        use crate::plot::{PlotSpec, Sample, Scale, SeriesHandle, line};
5830        let h = SeriesHandle::new(vec![Sample::new(0.0, 0.0), Sample::new(10.0, 10.0)]);
5831        let p = crate::tree::plot(
5832            PlotSpec::new()
5833                .x(Scale::linear())
5834                .y(Scale::linear())
5835                .add_mark(line(&h)),
5836        )
5837        .key("p");
5838        let modal = with_modal.then(|| {
5839            crate::widgets::overlay::modal(
5840                "detail",
5841                "Detail",
5842                [crate::widgets::text::text("plain detail text")
5843                    .width(crate::tree::Size::Fixed(200.0))
5844                    .height(crate::tree::Size::Fixed(120.0))],
5845            )
5846        });
5847        let mut tree = crate::widgets::overlay::overlays(p, [modal]);
5848        let mut core = RunnerCore::new();
5849        crate::layout::layout(
5850            &mut tree,
5851            &mut core.ui_state,
5852            Rect::new(0.0, 0.0, 400.0, 300.0),
5853        );
5854        core.ui_state.prepare_plots(&tree);
5855        let mut t = PrepareTimings::default();
5856        core.snapshot(&tree, &mut t);
5857        core
5858    }
5859
5860    #[test]
5861    fn press_on_modal_dead_space_does_not_zoom_the_plot_underneath() {
5862        // Same occlusion rule for the plot press capture (issue #127):
5863        // the plot wheel path already had the guard, the press path
5864        // could box-zoom (or double-click-reset) under a modal.
5865        let mut core = plot_under_modal(true);
5866        core.pointer_down(Pointer::mouse(200.0, 150.0, PointerButton::Primary));
5867        assert!(
5868            !core.ui_state.plot_zoom_active() && !core.ui_state.plot_pan_active(),
5869            "press on the modal must not start a plot gesture under it"
5870        );
5871        // Same point without the modal starts the scheme's drag — the
5872        // guard must not overreach.
5873        let mut core = plot_under_modal(false);
5874        core.pointer_down(Pointer::mouse(200.0, 150.0, PointerButton::Primary));
5875        assert!(
5876            core.ui_state.plot_zoom_active() || core.ui_state.plot_pan_active(),
5877            "bare plot press still starts the drag without the modal"
5878        );
5879    }
5880
5881    #[test]
5882    fn press_on_modal_dead_space_does_not_orbit_the_scene_underneath() {
5883        // Same guard for the camera-drag capture (issue #127).
5884        let mut core = scene_under_modal(true);
5885        assert!(
5886            core.ui_state.scene_at(200.0, 150.0).is_some(),
5887            "scene covers the center"
5888        );
5889        // Press on the centered modal panel (unkeyed body → dead space).
5890        core.pointer_down(Pointer::mouse(200.0, 150.0, PointerButton::Primary));
5891        assert!(
5892            !core.ui_state.camera_drag_active(),
5893            "press on the modal must not orbit the scene under it"
5894        );
5895        // Same point without the modal captures the orbit — the guard
5896        // must not overreach.
5897        let mut core = scene_under_modal(false);
5898        core.pointer_down(Pointer::mouse(200.0, 150.0, PointerButton::Primary));
5899        assert!(
5900            core.ui_state.camera_drag_active(),
5901            "bare scene press still orbits without the modal"
5902        );
5903    }
5904
5905    #[test]
5906    fn latent_pan_ignores_widget_floating_over_viewport() {
5907        // A keyed widget layered *over* the viewport (not a descendant)
5908        // keeps its own gesture: no latent pan arms for its press.
5909        use crate::tree::*;
5910        let tree = crate::stack([
5911            free_viewport(vp_card()),
5912            crate::widgets::button::button("Hud")
5913                .key("hud")
5914                .width(Size::Fixed(60.0))
5915                .height(Size::Fixed(30.0)),
5916        ]);
5917        let mut core = lay_out_viewport_tree(tree);
5918        let r = core.rect_of_key("hud").expect("hud rect");
5919        let (cx, cy) = (r.x + r.w * 0.5, r.y + r.h * 0.5);
5920        core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
5921        core.pointer_moved(Pointer::moving(cx + 20.0, cy));
5922        assert!(
5923            !core.ui_state.viewport_pan_active(),
5924            "overlay press must not convert into a viewport pan"
5925        );
5926    }
5927
5928    #[test]
5929    fn ui_state_hovered_key_returns_leaf_key() {
5930        let mut core = lay_out_input_tree(false);
5931        assert_eq!(core.ui_state().hovered_key(), None);
5932
5933        let btn = core.rect_of_key("btn").expect("btn rect");
5934        core.pointer_moved(Pointer::moving(btn.x + 4.0, btn.y + 4.0));
5935        assert_eq!(core.ui_state().hovered_key(), Some("btn"));
5936
5937        // Off-target → None again.
5938        core.pointer_moved(Pointer::moving(0.0, 0.0));
5939        assert_eq!(core.ui_state().hovered_key(), None);
5940    }
5941
5942    #[test]
5943    fn ui_state_is_hovering_within_walks_subtree() {
5944        // Card (keyed, focusable) wraps an inner icon-button (keyed).
5945        // is_hovering_within("card") should be true whenever the
5946        // cursor is on the card body OR on the inner button.
5947        use crate::tree::*;
5948        let mut tree = crate::column([crate::stack([
5949            crate::widgets::button::button("Inner").key("inner_btn")
5950        ])
5951        .key("card")
5952        .focusable()
5953        .width(Size::Fixed(120.0))
5954        .height(Size::Fixed(60.0))])
5955        .padding(20.0);
5956        let mut core = RunnerCore::new();
5957        crate::layout::layout(
5958            &mut tree,
5959            &mut core.ui_state,
5960            Rect::new(0.0, 0.0, 400.0, 200.0),
5961        );
5962        core.ui_state.sync_focus_order(&tree);
5963        let mut t = PrepareTimings::default();
5964        core.snapshot(&tree, &mut t);
5965
5966        // Pre-hover: false everywhere.
5967        assert!(!core.ui_state().is_hovering_within("card"));
5968        assert!(!core.ui_state().is_hovering_within("inner_btn"));
5969
5970        // Hover the inner button. Both the leaf and its ancestor card
5971        // should report subtree-hover true.
5972        let inner = core.rect_of_key("inner_btn").expect("inner rect");
5973        core.pointer_moved(Pointer::moving(inner.x + 4.0, inner.y + 4.0));
5974        assert!(core.ui_state().is_hovering_within("card"));
5975        assert!(core.ui_state().is_hovering_within("inner_btn"));
5976
5977        // Unrelated / unknown keys read as false.
5978        assert!(!core.ui_state().is_hovering_within("not_a_key"));
5979
5980        // Off the tree — both flip back to false.
5981        core.pointer_moved(Pointer::moving(0.0, 0.0));
5982        assert!(!core.ui_state().is_hovering_within("card"));
5983        assert!(!core.ui_state().is_hovering_within("inner_btn"));
5984    }
5985
5986    #[test]
5987    fn hover_driven_scale_via_is_hovering_within_plus_animate() {
5988        // gh#10. The recipe that replaces a declarative
5989        // hover_translate / hover_scale / hover_tint API: the build
5990        // closure reads `cx.is_hovering_within(key)` and writes the
5991        // target prop value; `.animate(...)` eases between build
5992        // values across frames. End-to-end check that hover transition
5993        // → eased scale settle.
5994        use crate::Theme;
5995        use crate::anim::Timing;
5996        use crate::tree::*;
5997
5998        // Helper that mirrors the documented recipe — closure over a
5999        // hover boolean so the test can drive the rebuild deterministically.
6000        let build_card = |hovering: bool| -> El {
6001            let scale = if hovering { 1.05 } else { 1.0 };
6002            crate::column([crate::stack(
6003                [crate::widgets::button::button("Inner").key("inner_btn")],
6004            )
6005            .key("card")
6006            .focusable()
6007            .scale(scale)
6008            .animate(Timing::SPRING_QUICK)
6009            .width(Size::Fixed(120.0))
6010            .height(Size::Fixed(60.0))])
6011            .padding(20.0)
6012        };
6013
6014        let mut core = RunnerCore::new();
6015        // Settled mode so the animate tick snaps each retarget to its
6016        // value — lets us verify final-state values without timing.
6017        core.ui_state
6018            .set_animation_mode(crate::state::AnimationMode::Settled);
6019
6020        // Frame 1: not hovering → app builds with scale=1.0.
6021        let theme = Theme::default();
6022        let cx_pre = crate::BuildCx::new(&theme).with_ui_state(core.ui_state());
6023        assert!(!cx_pre.is_hovering_within("card"));
6024        let mut tree = build_card(cx_pre.is_hovering_within("card"));
6025        crate::layout::layout(
6026            &mut tree,
6027            &mut core.ui_state,
6028            Rect::new(0.0, 0.0, 400.0, 200.0),
6029        );
6030        core.ui_state.sync_focus_order(&tree);
6031        let mut t = PrepareTimings::default();
6032        core.snapshot(&tree, &mut t);
6033        core.ui_state
6034            .tick_visual_animations(&mut tree, web_time::Instant::now(), theme.palette());
6035        let card_at_rest = tree.children[0].clone();
6036        assert!((card_at_rest.scale - 1.0).abs() < 1e-3);
6037
6038        // Hover the card. is_hovering_within flips true.
6039        let card_rect = core.rect_of_key("card").expect("card rect");
6040        core.pointer_moved(Pointer::moving(card_rect.x + 4.0, card_rect.y + 4.0));
6041
6042        // Frame 2: app sees hovering=true, rebuilds with scale=1.05.
6043        // Settled animate tick snaps scale to the new target.
6044        let cx_hot = crate::BuildCx::new(&theme).with_ui_state(core.ui_state());
6045        assert!(cx_hot.is_hovering_within("card"));
6046        let mut tree = build_card(cx_hot.is_hovering_within("card"));
6047        crate::layout::layout(
6048            &mut tree,
6049            &mut core.ui_state,
6050            Rect::new(0.0, 0.0, 400.0, 200.0),
6051        );
6052        core.ui_state.sync_focus_order(&tree);
6053        core.snapshot(&tree, &mut t);
6054        core.ui_state
6055            .tick_visual_animations(&mut tree, web_time::Instant::now(), theme.palette());
6056        let card_hot = tree.children[0].clone();
6057        assert!(
6058            (card_hot.scale - 1.05).abs() < 1e-3,
6059            "hover should drive card scale to 1.05 via animate; got {}",
6060            card_hot.scale,
6061        );
6062
6063        // Unhover → app rebuilds with scale=1.0; settled tick snaps back.
6064        core.pointer_moved(Pointer::moving(0.0, 0.0));
6065        let cx_cold = crate::BuildCx::new(&theme).with_ui_state(core.ui_state());
6066        assert!(!cx_cold.is_hovering_within("card"));
6067        let mut tree = build_card(cx_cold.is_hovering_within("card"));
6068        crate::layout::layout(
6069            &mut tree,
6070            &mut core.ui_state,
6071            Rect::new(0.0, 0.0, 400.0, 200.0),
6072        );
6073        core.ui_state.sync_focus_order(&tree);
6074        core.snapshot(&tree, &mut t);
6075        core.ui_state
6076            .tick_visual_animations(&mut tree, web_time::Instant::now(), theme.palette());
6077        let card_after = tree.children[0].clone();
6078        assert!((card_after.scale - 1.0).abs() < 1e-3);
6079    }
6080
6081    #[test]
6082    fn file_dropped_routes_to_keyed_leaf_at_pointer() {
6083        let mut core = lay_out_input_tree(false);
6084        let btn = core.rect_of_key("btn").expect("btn rect");
6085        let path = std::path::PathBuf::from("/tmp/screenshot.png");
6086        let events = core.file_dropped(path.clone(), btn.x + 4.0, btn.y + 4.0);
6087        assert_eq!(events.len(), 1);
6088        let event = &events[0];
6089        assert_eq!(event.kind, UiEventKind::FileDropped);
6090        assert_eq!(event.key.as_deref(), Some("btn"));
6091        assert_eq!(event.path.as_deref(), Some(path.as_path()));
6092        assert_eq!(event.pointer, Some((btn.x + 4.0, btn.y + 4.0)));
6093    }
6094
6095    #[test]
6096    fn file_dropped_outside_keyed_surface_emits_window_level_event() {
6097        let mut core = lay_out_input_tree(false);
6098        // Drop in the padding band — outside any keyed leaf.
6099        let path = std::path::PathBuf::from("/tmp/screenshot.png");
6100        let events = core.file_dropped(path.clone(), 1.0, 1.0);
6101        assert_eq!(events.len(), 1);
6102        let event = &events[0];
6103        assert_eq!(event.kind, UiEventKind::FileDropped);
6104        assert!(
6105            event.target.is_none(),
6106            "drop outside any keyed surface routes window-level",
6107        );
6108        assert!(event.key.is_none());
6109        // Path still flows through so a global drop sink can pick it up.
6110        assert_eq!(event.path.as_deref(), Some(path.as_path()));
6111    }
6112
6113    #[test]
6114    fn file_hovered_then_cancelled_pair() {
6115        let mut core = lay_out_input_tree(false);
6116        let btn = core.rect_of_key("btn").expect("btn rect");
6117        let path = std::path::PathBuf::from("/tmp/a.png");
6118
6119        let hover = core.file_hovered(path.clone(), btn.x + 4.0, btn.y + 4.0);
6120        assert_eq!(hover.len(), 1);
6121        assert_eq!(hover[0].kind, UiEventKind::FileHovered);
6122        assert_eq!(hover[0].key.as_deref(), Some("btn"));
6123        assert_eq!(hover[0].path.as_deref(), Some(path.as_path()));
6124
6125        let cancel = core.file_hover_cancelled();
6126        assert_eq!(cancel.len(), 1);
6127        assert_eq!(cancel[0].kind, UiEventKind::FileHoverCancelled);
6128        assert!(cancel[0].target.is_none());
6129        assert!(cancel[0].path.is_none());
6130    }
6131
6132    #[test]
6133    fn build_cx_hover_accessors_default_off_without_state() {
6134        use crate::Theme;
6135        let theme = Theme::default();
6136        let cx = crate::BuildCx::new(&theme);
6137        assert_eq!(cx.hovered_key(), None);
6138        assert!(!cx.is_hovering_within("anything"));
6139    }
6140
6141    #[test]
6142    fn build_cx_hover_accessors_delegate_when_state_attached() {
6143        use crate::Theme;
6144        let mut core = lay_out_input_tree(false);
6145        let btn = core.rect_of_key("btn").expect("btn rect");
6146        core.pointer_moved(Pointer::moving(btn.x + 4.0, btn.y + 4.0));
6147
6148        let theme = Theme::default();
6149        let cx = crate::BuildCx::new(&theme).with_ui_state(core.ui_state());
6150        assert_eq!(cx.hovered_key(), Some("btn"));
6151        assert!(cx.is_hovering_within("btn"));
6152        assert!(!cx.is_hovering_within("ti"));
6153    }
6154
6155    #[test]
6156    fn build_cx_rect_of_key_reads_previous_layout() {
6157        use crate::Theme;
6158        let core = lay_out_input_tree(false);
6159        let theme = Theme::default();
6160
6161        let detached = crate::BuildCx::new(&theme);
6162        assert_eq!(detached.rect_of_key("btn"), None);
6163
6164        let cx = crate::BuildCx::new(&theme).with_ui_state(core.ui_state());
6165        assert_eq!(cx.rect_of_key("btn"), core.rect_of_key("btn"));
6166        assert_eq!(cx.rect_of_key("missing"), None);
6167    }
6168
6169    #[test]
6170    fn event_cx_rect_of_key_answers_from_laid_out_state() {
6171        let core = lay_out_input_tree(false);
6172
6173        let detached = crate::EventCx::new();
6174        assert_eq!(detached.rect_of_key("btn"), None);
6175
6176        let cx = crate::EventCx::new().with_ui_state(core.ui_state());
6177        let rect = cx.rect_of_key("btn").expect("btn rect via EventCx");
6178        assert_eq!(Some(rect), core.rect_of_key("btn"));
6179        assert_eq!(cx.rect_of_key("missing"), None);
6180    }
6181
6182    #[test]
6183    fn event_cx_viewport_and_diagnostics_mirror_build_cx() {
6184        // Detached context: every frame-context accessor answers None /
6185        // falls through to the wide branch, matching BuildCx semantics.
6186        let detached = crate::EventCx::new();
6187        assert_eq!(detached.viewport(), None);
6188        assert_eq!(detached.viewport_width(), None);
6189        assert_eq!(detached.viewport_height(), None);
6190        assert!(!detached.viewport_below(10_000.0));
6191        assert!(detached.diagnostics().is_none());
6192
6193        let diagnostics = crate::HostDiagnostics {
6194            backend: "Test",
6195            ..Default::default()
6196        };
6197        let cx = crate::EventCx::new()
6198            .with_viewport(800.0, 600.0)
6199            .with_diagnostics(&diagnostics);
6200        assert_eq!(cx.viewport(), Some((800.0, 600.0)));
6201        assert_eq!(cx.viewport_width(), Some(800.0));
6202        assert_eq!(cx.viewport_height(), Some(600.0));
6203        assert!(cx.viewport_below(900.0));
6204        assert!(!cx.viewport_below(800.0));
6205        assert_eq!(cx.diagnostics().map(|d| d.backend), Some("Test"));
6206    }
6207
6208    fn lay_out_paragraph_tree() -> RunnerCore {
6209        use crate::tree::*;
6210        let mut tree = crate::column([
6211            crate::widgets::text::text("First paragraph of text.")
6212                .key("p1")
6213                .selectable(),
6214            crate::widgets::text::text("Second paragraph of text.")
6215                .key("p2")
6216                .selectable(),
6217        ])
6218        .padding(20.0);
6219        let mut core = RunnerCore::new();
6220        crate::layout::layout(
6221            &mut tree,
6222            &mut core.ui_state,
6223            Rect::new(0.0, 0.0, 400.0, 300.0),
6224        );
6225        core.ui_state.sync_focus_order(&tree);
6226        core.ui_state.sync_selection_order(&tree);
6227        let mut t = PrepareTimings::default();
6228        core.snapshot(&tree, &mut t);
6229        core
6230    }
6231
6232    #[test]
6233    fn pointer_down_on_selectable_text_emits_selection_changed() {
6234        let mut core = lay_out_paragraph_tree();
6235        let p1 = core.rect_of_key("p1").expect("p1 rect");
6236        let cx = p1.x + 4.0;
6237        let cy = p1.y + p1.h * 0.5;
6238        let events = core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
6239        let sel_event = events
6240            .iter()
6241            .find(|e| e.kind == UiEventKind::SelectionChanged)
6242            .expect("SelectionChanged emitted");
6243        let new_sel = sel_event
6244            .selection
6245            .as_ref()
6246            .expect("SelectionChanged carries a selection");
6247        let range = new_sel.range.as_ref().expect("collapsed selection at hit");
6248        assert_eq!(range.anchor.key, "p1");
6249        assert_eq!(range.head.key, "p1");
6250        assert_eq!(range.anchor.byte, range.head.byte);
6251        assert!(core.ui_state.selection.drag.is_some());
6252    }
6253
6254    #[test]
6255    fn touch_long_press_on_selectable_text_selects_word_and_drags() {
6256        let mut core = lay_out_paragraph_tree();
6257        let p1 = core.rect_of_key("p1").expect("p1 rect");
6258        let p2 = core.rect_of_key("p2").expect("p2 rect");
6259        let x = p1.x + 4.0;
6260        let y = p1.y + p1.h * 0.5;
6261
6262        let _ = core.pointer_down(Pointer::touch(
6263            x,
6264            y,
6265            PointerButton::Primary,
6266            PointerId::PRIMARY,
6267        ));
6268        let polled = core.poll_input(Instant::now() + LONG_PRESS_DELAY + Duration::from_millis(10));
6269        let selection = polled
6270            .iter()
6271            .rev()
6272            .find(|e| e.kind == UiEventKind::SelectionChanged)
6273            .and_then(|e| e.selection.as_ref())
6274            .expect("touch long-press should select text");
6275        let range = selection.range.as_ref().expect("word selection");
6276        assert_eq!(range.anchor.key, "p1");
6277        assert_eq!(range.head.key, "p1");
6278        assert_eq!(range.anchor.byte, 0);
6279        assert_eq!(range.head.byte, 5);
6280        assert!(
6281            core.ui_state.selection.drag.is_some(),
6282            "long-pressed selectable text should stay ready for drag extension"
6283        );
6284
6285        let mut moved = Pointer::moving(p2.x + 8.0, p2.y + p2.h * 0.5);
6286        moved.kind = PointerKind::Touch;
6287        let events = core.pointer_moved(moved).events;
6288        let selection = events
6289            .iter()
6290            .find(|e| e.kind == UiEventKind::SelectionChanged)
6291            .and_then(|e| e.selection.as_ref())
6292            .unwrap_or(&core.ui_state.current_selection);
6293        let range = selection.range.as_ref().expect("extended selection");
6294        assert_eq!(range.anchor.key, "p1");
6295        assert_eq!(range.head.key, "p2");
6296
6297        let _ = core.pointer_up(Pointer::touch(
6298            p2.x + 8.0,
6299            p2.y + p2.h * 0.5,
6300            PointerButton::Primary,
6301            PointerId::PRIMARY,
6302        ));
6303        assert!(
6304            core.ui_state.selection.drag.is_none(),
6305            "selection drag should end on lift"
6306        );
6307    }
6308
6309    #[test]
6310    fn pointer_drag_on_selectable_text_extends_head() {
6311        let mut core = lay_out_paragraph_tree();
6312        let p1 = core.rect_of_key("p1").expect("p1 rect");
6313        let cx = p1.x + 4.0;
6314        let cy = p1.y + p1.h * 0.5;
6315        core.pointer_moved(Pointer::moving(cx, cy));
6316        core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
6317
6318        // Drag to the right inside p1.
6319        let events = core
6320            .pointer_moved(Pointer::moving(p1.x + p1.w - 10.0, cy))
6321            .events;
6322        let sel_event = events
6323            .iter()
6324            .find(|e| e.kind == UiEventKind::SelectionChanged)
6325            .expect("Drag emits SelectionChanged");
6326        let new_sel = sel_event.selection.as_ref().unwrap();
6327        let range = new_sel.range.as_ref().unwrap();
6328        assert_eq!(range.anchor.key, "p1");
6329        assert_eq!(range.head.key, "p1");
6330        assert!(
6331            range.head.byte > range.anchor.byte,
6332            "head should advance past anchor (anchor={}, head={})",
6333            range.anchor.byte,
6334            range.head.byte
6335        );
6336    }
6337
6338    #[test]
6339    fn double_click_hold_drag_inside_selectable_word_keeps_word_selected() {
6340        let mut core = lay_out_paragraph_tree();
6341        let p1 = core.rect_of_key("p1").expect("p1 rect");
6342        let cx = p1.x + 4.0;
6343        let cy = p1.y + p1.h * 0.5;
6344
6345        core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
6346        core.pointer_up(Pointer::mouse(cx, cy, PointerButton::Primary));
6347        let down = core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
6348        let sel = down
6349            .iter()
6350            .find(|e| e.kind == UiEventKind::SelectionChanged)
6351            .and_then(|e| e.selection.as_ref())
6352            .and_then(|s| s.range.as_ref())
6353            .expect("double-click selects word");
6354        assert_eq!(sel.anchor.byte, 0);
6355        assert_eq!(sel.head.byte, 5);
6356
6357        let events = core.pointer_moved(Pointer::moving(cx + 1.0, cy)).events;
6358        assert!(
6359            !events
6360                .iter()
6361                .any(|e| e.kind == UiEventKind::SelectionChanged),
6362            "drag jitter within the double-clicked word should not collapse the selection"
6363        );
6364        let range = core
6365            .ui_state
6366            .current_selection
6367            .range
6368            .as_ref()
6369            .expect("selection persists");
6370        assert_eq!(range.anchor.byte, 0);
6371        assert_eq!(range.head.byte, 5);
6372    }
6373
6374    #[test]
6375    fn pointer_up_clears_drag_but_keeps_selection() {
6376        let mut core = lay_out_paragraph_tree();
6377        let p1 = core.rect_of_key("p1").expect("p1 rect");
6378        let cx = p1.x + 4.0;
6379        let cy = p1.y + p1.h * 0.5;
6380        core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
6381        core.pointer_moved(Pointer::moving(p1.x + p1.w - 10.0, cy));
6382        let _ = core.pointer_up(Pointer::mouse(
6383            p1.x + p1.w - 10.0,
6384            cy,
6385            PointerButton::Primary,
6386        ));
6387        assert!(
6388            core.ui_state.selection.drag.is_none(),
6389            "drag flag should clear on pointer_up"
6390        );
6391        assert!(
6392            !core.ui_state.current_selection.is_empty(),
6393            "selection itself should persist after pointer_up"
6394        );
6395    }
6396
6397    #[test]
6398    fn drag_past_a_leaf_bottom_keeps_head_in_that_leaf_not_anchor() {
6399        // Regression: a previous helper (`byte_in_anchor_leaf`)
6400        // projected any out-of-leaf pointer back onto the anchor leaf.
6401        // That meant moving the cursor below p2's bottom edge while
6402        // dragging from p1 caused the head to snap home to p1 — the
6403        // selection band visibly shrank back instead of extending.
6404        let mut core = lay_out_paragraph_tree();
6405        let p1 = core.rect_of_key("p1").expect("p1 rect");
6406        let p2 = core.rect_of_key("p2").expect("p2 rect");
6407        // Anchor in p1.
6408        core.pointer_down(Pointer::mouse(
6409            p1.x + 4.0,
6410            p1.y + p1.h * 0.5,
6411            PointerButton::Primary,
6412        ));
6413        // Drag into p2 first — head migrates.
6414        core.pointer_moved(Pointer::moving(p2.x + 8.0, p2.y + p2.h * 0.5));
6415        // Now move WELL BELOW p2's rect (well below all selectables).
6416        // Head should remain in p2 (last leaf in this fixture is p2).
6417        let events = core
6418            .pointer_moved(Pointer::moving(p2.x + 8.0, p2.y + p2.h + 200.0))
6419            .events;
6420        let sel = events
6421            .iter()
6422            .find(|e| e.kind == UiEventKind::SelectionChanged)
6423            .map(|e| e.selection.as_ref().unwrap().clone())
6424            // No SelectionChanged emitted means the value didn't move
6425            // — read it back from the live UiState directly.
6426            .unwrap_or_else(|| core.ui_state.current_selection.clone());
6427        let r = sel.range.as_ref().expect("selection still active");
6428        assert_eq!(r.anchor.key, "p1", "anchor unchanged");
6429        assert_eq!(
6430            r.head.key, "p2",
6431            "head must stay in p2 even when pointer is below p2's rect"
6432        );
6433    }
6434
6435    #[test]
6436    fn drag_into_a_sibling_selectable_extends_head_into_that_leaf() {
6437        let mut core = lay_out_paragraph_tree();
6438        let p1 = core.rect_of_key("p1").expect("p1 rect");
6439        let p2 = core.rect_of_key("p2").expect("p2 rect");
6440        // Anchor at the start of p1.
6441        core.pointer_down(Pointer::mouse(
6442            p1.x + 4.0,
6443            p1.y + p1.h * 0.5,
6444            PointerButton::Primary,
6445        ));
6446        // Drag down into p2.
6447        let events = core
6448            .pointer_moved(Pointer::moving(p2.x + 8.0, p2.y + p2.h * 0.5))
6449            .events;
6450        let sel_event = events
6451            .iter()
6452            .find(|e| e.kind == UiEventKind::SelectionChanged)
6453            .expect("Drag emits SelectionChanged");
6454        let new_sel = sel_event.selection.as_ref().unwrap();
6455        let range = new_sel.range.as_ref().unwrap();
6456        assert_eq!(range.anchor.key, "p1", "anchor stays in p1");
6457        assert_eq!(range.head.key, "p2", "head migrates into p2");
6458    }
6459
6460    #[test]
6461    fn pointer_down_on_focusable_owning_selection_does_not_clear_it() {
6462        // Regression: clicking inside a text_input (focusable but not
6463        // a `.selectable()` leaf) used to fire SelectionChanged-empty
6464        // because selection_point_at missed and the runtime's
6465        // clear-fallback didn't notice the click landed on the same
6466        // widget that owned the active selection. The input's
6467        // PointerDown set the caret, then the empty SelectionChanged
6468        // collapsed it back to byte 0 every other click.
6469        let mut core = lay_out_input_tree(true);
6470        // Seed a selection in the input's key — this is what the
6471        // text_input would have written back via apply_event_with.
6472        core.set_selection(crate::selection::Selection::caret("ti", 3));
6473        let ti = core.rect_of_key("ti").expect("ti rect");
6474        let cx = ti.x + ti.w * 0.5;
6475        let cy = ti.y + ti.h * 0.5;
6476
6477        let events = core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
6478        let cleared = events.iter().find(|e| {
6479            e.kind == UiEventKind::SelectionChanged
6480                && e.selection.as_ref().map(|s| s.is_empty()).unwrap_or(false)
6481        });
6482        assert!(
6483            cleared.is_none(),
6484            "click on the selection-owning input must not emit a clearing SelectionChanged"
6485        );
6486        assert_eq!(
6487            core.ui_state.current_selection,
6488            crate::selection::Selection::caret("ti", 3),
6489            "runtime mirror is preserved when the click owns the selection"
6490        );
6491    }
6492
6493    #[test]
6494    fn pointer_down_into_a_different_capture_keys_widget_does_not_clear_first() {
6495        // Regression: clicking into text_input A while the selection
6496        // lives in text_input B used to trigger the runtime's
6497        // clear-fallback. The empty SelectionChanged arrived after
6498        // A's PointerDown (which had set anchor = head = click pos),
6499        // collapsing the app's selection to default. The next Drag
6500        // event then read `selection.within(A) = None`, defaulted
6501        // anchor to 0, and only advanced head — so dragging into A
6502        // started the selection from byte 0 of the text instead of
6503        // the click position.
6504        let mut core = lay_out_input_tree(true);
6505        // Active selection lives in some other key, not "ti".
6506        core.set_selection(crate::selection::Selection::caret("other", 4));
6507        let ti = core.rect_of_key("ti").expect("ti rect");
6508        let cx = ti.x + ti.w * 0.5;
6509        let cy = ti.y + ti.h * 0.5;
6510
6511        let events = core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
6512        let cleared = events.iter().any(|e| {
6513            e.kind == UiEventKind::SelectionChanged
6514                && e.selection.as_ref().map(|s| s.is_empty()).unwrap_or(false)
6515        });
6516        assert!(
6517            !cleared,
6518            "click on a different capture_keys widget must not race-clear the selection"
6519        );
6520    }
6521
6522    #[test]
6523    fn pointer_down_on_non_selectable_clears_existing_selection() {
6524        let mut core = lay_out_paragraph_tree();
6525        let p1 = core.rect_of_key("p1").expect("p1 rect");
6526        let cy = p1.y + p1.h * 0.5;
6527        // Establish a selection in p1.
6528        core.pointer_down(Pointer::mouse(p1.x + 4.0, cy, PointerButton::Primary));
6529        core.pointer_up(Pointer::mouse(p1.x + 4.0, cy, PointerButton::Primary));
6530        assert!(!core.ui_state.current_selection.is_empty());
6531
6532        // Press in empty space (no selectable, no focusable).
6533        let events = core.pointer_down(Pointer::mouse(2.0, 2.0, PointerButton::Primary));
6534        let cleared = events
6535            .iter()
6536            .find(|e| e.kind == UiEventKind::SelectionChanged)
6537            .expect("clearing emits SelectionChanged");
6538        let new_sel = cleared.selection.as_ref().unwrap();
6539        assert!(new_sel.is_empty(), "new selection should be empty");
6540        assert!(core.ui_state.current_selection.is_empty());
6541    }
6542
6543    #[test]
6544    fn pointer_down_in_dead_space_clears_focus() {
6545        let mut core = lay_out_input_tree(false);
6546        let btn = core.rect_of_key("btn").expect("btn rect");
6547        let cx = btn.x + btn.w * 0.5;
6548        let cy = btn.y + btn.h * 0.5;
6549        core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
6550        let _ = core.pointer_up(Pointer::mouse(cx, cy, PointerButton::Primary));
6551        assert_eq!(
6552            core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
6553            Some("btn")
6554        );
6555
6556        core.pointer_down(Pointer::mouse(2.0, 2.0, PointerButton::Primary));
6557
6558        assert_eq!(core.ui_state.focused.as_ref().map(|t| t.key.as_str()), None);
6559    }
6560
6561    #[test]
6562    fn key_down_bumps_caret_activity_when_focused_widget_captures_keys() {
6563        // Showcase-style scenario: the app doesn't propagate its
6564        // Selection back via App::selection(), so set_selection always
6565        // sees the default-empty value and never bumps. The runtime
6566        // bump path catches arrow-key navigation directly.
6567        let mut core = lay_out_input_tree(true);
6568        let target = core
6569            .ui_state
6570            .focus
6571            .order
6572            .iter()
6573            .find(|t| t.key == "ti")
6574            .cloned();
6575        core.ui_state.set_focus(target); // focus moves → first bump
6576        let after_focus = core.ui_state.caret.activity_at.expect("focus bump");
6577
6578        std::thread::sleep(std::time::Duration::from_millis(2));
6579        let _ = core.key_down(
6580            LogicalKey::Named(NamedKey::ArrowRight),
6581            PhysicalKey::Unidentified,
6582            KeyModifiers::default(),
6583            false,
6584        );
6585        let after_arrow = core
6586            .ui_state
6587            .caret
6588            .activity_at
6589            .expect("arrow key bumps even without app-side selection");
6590        assert!(
6591            after_arrow > after_focus,
6592            "ArrowRight to a capture_keys focused widget bumps caret activity"
6593        );
6594    }
6595
6596    #[test]
6597    fn text_input_bumps_caret_activity_when_focused() {
6598        let mut core = lay_out_input_tree(true);
6599        let target = core
6600            .ui_state
6601            .focus
6602            .order
6603            .iter()
6604            .find(|t| t.key == "ti")
6605            .cloned();
6606        core.ui_state.set_focus(target);
6607        let after_focus = core.ui_state.caret.activity_at.unwrap();
6608
6609        std::thread::sleep(std::time::Duration::from_millis(2));
6610        let _ = core.text_input("a".into());
6611        let after_text = core.ui_state.caret.activity_at.unwrap();
6612        assert!(
6613            after_text > after_focus,
6614            "TextInput to focused widget bumps caret activity"
6615        );
6616    }
6617
6618    #[test]
6619    fn pointer_down_inside_focused_input_bumps_caret_activity() {
6620        // Clicking again inside an already-focused capture_keys widget
6621        // doesn't change the focus target, so set_focus is a no-op
6622        // for activity. The runtime catches this so click-to-move-
6623        // caret resets the blink.
6624        let mut core = lay_out_input_tree(true);
6625        let ti = core.rect_of_key("ti").expect("ti rect");
6626        let cx = ti.x + ti.w * 0.5;
6627        let cy = ti.y + ti.h * 0.5;
6628
6629        // First click → focus moves → bump.
6630        core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
6631        let _ = core.pointer_up(Pointer::mouse(cx, cy, PointerButton::Primary));
6632        let after_first = core.ui_state.caret.activity_at.unwrap();
6633
6634        // Second click on the same input → focus doesn't move, but
6635        // it's still caret-relevant activity.
6636        std::thread::sleep(std::time::Duration::from_millis(2));
6637        core.pointer_down(Pointer::mouse(cx + 1.0, cy, PointerButton::Primary));
6638        let after_second = core
6639            .ui_state
6640            .caret
6641            .activity_at
6642            .expect("second click bumps too");
6643        assert!(
6644            after_second > after_first,
6645            "click within already-focused capture_keys widget still bumps"
6646        );
6647    }
6648
6649    #[test]
6650    fn arrow_key_through_apply_event_mutates_selection_and_bumps_on_set() {
6651        // End-to-end check that the path used by the text_input
6652        // example does in fact differ-then-bump on each arrow-key
6653        // press. If this regresses, the caret won't reset its blink
6654        // when the user moves the cursor — exactly what the polish
6655        // pass is meant to fix.
6656        use crate::widgets::text_input;
6657        let mut sel = crate::selection::Selection::caret("ti", 2);
6658        let mut value = String::from("hello");
6659
6660        let mut core = RunnerCore::new();
6661        // Seed the runtime mirror so the first set_selection below
6662        // doesn't bump from "default → caret(2)".
6663        core.set_selection(sel.clone());
6664        let baseline = core.ui_state.caret.activity_at;
6665
6666        // Build a synthetic ArrowRight KeyDown for the input's key.
6667        let arrow_right = UiEvent {
6668            key: Some("ti".into()),
6669            target: None,
6670            pointer: None,
6671            key_press: Some(crate::event::KeyPress {
6672                logical: LogicalKey::Named(NamedKey::ArrowRight),
6673                physical: PhysicalKey::Unidentified,
6674                modifiers: KeyModifiers::default(),
6675                repeat: false,
6676            }),
6677            text: None,
6678            selection: None,
6679            modifiers: KeyModifiers::default(),
6680            click_count: 0,
6681            path: None,
6682            pointer_kind: None,
6683            wheel_delta: None,
6684            kind: UiEventKind::KeyDown,
6685        };
6686
6687        // 1. App's on_event would call into this path:
6688        let mutated = text_input::apply_event(&mut value, &mut sel, &arrow_right, "ti");
6689        assert!(mutated, "ArrowRight should mutate selection");
6690        assert_eq!(
6691            sel.within("ti").unwrap().head,
6692            3,
6693            "head moved one char right (h-e-l-l-o, byte 2 → 3)"
6694        );
6695
6696        // 2. Next frame's set_selection sees the new value → bump.
6697        std::thread::sleep(std::time::Duration::from_millis(2));
6698        core.set_selection(sel);
6699        let after = core.ui_state.caret.activity_at.unwrap();
6700        // If a baseline existed, the new bump must be later. Either
6701        // way the activity is now Some, which the .unwrap() above
6702        // already enforced.
6703        if let Some(b) = baseline {
6704            assert!(after > b, "arrow-key flow should bump activity");
6705        }
6706    }
6707
6708    #[test]
6709    fn set_selection_bumps_caret_activity_only_when_value_changes() {
6710        let mut core = lay_out_paragraph_tree();
6711        // First call with the default selection — no bump (mirror is
6712        // already default-empty).
6713        core.set_selection(crate::selection::Selection::default());
6714        assert!(
6715            core.ui_state.caret.activity_at.is_none(),
6716            "no-op set_selection should not bump activity"
6717        );
6718
6719        // Move the selection to a real range — bump.
6720        let sel_a = crate::selection::Selection::caret("p1", 3);
6721        core.set_selection(sel_a.clone());
6722        let bumped_at = core
6723            .ui_state
6724            .caret
6725            .activity_at
6726            .expect("first real selection bumps");
6727
6728        // Same selection again — must NOT bump (else every frame
6729        // re-bumps and the caret never blinks).
6730        core.set_selection(sel_a.clone());
6731        assert_eq!(
6732            core.ui_state.caret.activity_at,
6733            Some(bumped_at),
6734            "set_selection with same value is a no-op"
6735        );
6736
6737        // Caret at a different byte (simulating arrow-key motion) →
6738        // bump again.
6739        std::thread::sleep(std::time::Duration::from_millis(2));
6740        let sel_b = crate::selection::Selection::caret("p1", 7);
6741        core.set_selection(sel_b);
6742        let new_bump = core.ui_state.caret.activity_at.expect("second bump");
6743        assert!(
6744            new_bump > bumped_at,
6745            "moving the caret bumps activity again",
6746        );
6747    }
6748
6749    #[test]
6750    fn escape_clears_active_selection_and_emits_selection_changed() {
6751        let mut core = lay_out_paragraph_tree();
6752        let p1 = core.rect_of_key("p1").expect("p1 rect");
6753        let cy = p1.y + p1.h * 0.5;
6754        // Drag-select inside p1 to establish a non-empty selection.
6755        core.pointer_down(Pointer::mouse(p1.x + 4.0, cy, PointerButton::Primary));
6756        core.pointer_moved(Pointer::moving(p1.x + p1.w - 10.0, cy));
6757        core.pointer_up(Pointer::mouse(
6758            p1.x + p1.w - 10.0,
6759            cy,
6760            PointerButton::Primary,
6761        ));
6762        assert!(!core.ui_state.current_selection.is_empty());
6763
6764        let events = core.key_down(
6765            LogicalKey::Named(NamedKey::Escape),
6766            PhysicalKey::Unidentified,
6767            KeyModifiers::default(),
6768            false,
6769        );
6770        let kinds: Vec<UiEventKind> = events.iter().map(|e| e.kind).collect();
6771        assert_eq!(
6772            kinds,
6773            vec![UiEventKind::Escape, UiEventKind::SelectionChanged],
6774            "Esc emits Escape (for popover dismiss) AND SelectionChanged"
6775        );
6776        let cleared = events
6777            .iter()
6778            .find(|e| e.kind == UiEventKind::SelectionChanged)
6779            .unwrap();
6780        assert!(cleared.selection.as_ref().unwrap().is_empty());
6781        assert!(core.ui_state.current_selection.is_empty());
6782    }
6783
6784    #[test]
6785    fn consecutive_clicks_on_same_target_extend_count() {
6786        let mut core = lay_out_input_tree(false);
6787        let btn = core.rect_of_key("btn").expect("btn rect");
6788        let cx = btn.x + btn.w * 0.5;
6789        let cy = btn.y + btn.h * 0.5;
6790
6791        // First press: count = 1.
6792        let down1 = core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
6793        let pd1 = down1
6794            .iter()
6795            .find(|e| e.kind == UiEventKind::PointerDown)
6796            .expect("PointerDown emitted");
6797        assert_eq!(pd1.click_count, 1, "first press starts the sequence");
6798        let up1 = core.pointer_up(Pointer::mouse(cx, cy, PointerButton::Primary));
6799        let click1 = up1
6800            .iter()
6801            .find(|e| e.kind == UiEventKind::Click)
6802            .expect("Click emitted");
6803        assert_eq!(
6804            click1.click_count, 1,
6805            "Click carries the same count as its PointerDown"
6806        );
6807
6808        // Second press immediately after, same target: count = 2.
6809        let down2 = core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
6810        let pd2 = down2
6811            .iter()
6812            .find(|e| e.kind == UiEventKind::PointerDown)
6813            .unwrap();
6814        assert_eq!(pd2.click_count, 2, "second press extends the sequence");
6815        let up2 = core.pointer_up(Pointer::mouse(cx, cy, PointerButton::Primary));
6816        assert_eq!(
6817            up2.iter()
6818                .find(|e| e.kind == UiEventKind::Click)
6819                .unwrap()
6820                .click_count,
6821            2
6822        );
6823
6824        // Third: count = 3.
6825        let down3 = core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
6826        let pd3 = down3
6827            .iter()
6828            .find(|e| e.kind == UiEventKind::PointerDown)
6829            .unwrap();
6830        assert_eq!(pd3.click_count, 3, "third press → triple-click");
6831        core.pointer_up(Pointer::mouse(cx, cy, PointerButton::Primary));
6832    }
6833
6834    #[test]
6835    fn click_count_resets_when_target_changes() {
6836        let mut core = lay_out_input_tree(false);
6837        let btn = core.rect_of_key("btn").expect("btn rect");
6838        let ti = core.rect_of_key("ti").expect("ti rect");
6839
6840        // Press on btn → count=1.
6841        let down1 = core.pointer_down(Pointer::mouse(
6842            btn.x + btn.w * 0.5,
6843            btn.y + btn.h * 0.5,
6844            PointerButton::Primary,
6845        ));
6846        assert_eq!(
6847            down1
6848                .iter()
6849                .find(|e| e.kind == UiEventKind::PointerDown)
6850                .unwrap()
6851                .click_count,
6852            1
6853        );
6854        let _ = core.pointer_up(Pointer::mouse(
6855            btn.x + btn.w * 0.5,
6856            btn.y + btn.h * 0.5,
6857            PointerButton::Primary,
6858        ));
6859
6860        // Press on ti (different target) → count resets to 1.
6861        let down2 = core.pointer_down(Pointer::mouse(
6862            ti.x + ti.w * 0.5,
6863            ti.y + ti.h * 0.5,
6864            PointerButton::Primary,
6865        ));
6866        let pd2 = down2
6867            .iter()
6868            .find(|e| e.kind == UiEventKind::PointerDown)
6869            .unwrap();
6870        assert_eq!(
6871            pd2.click_count, 1,
6872            "press on a new target resets the multi-click sequence"
6873        );
6874    }
6875
6876    #[test]
6877    fn double_click_on_selectable_text_selects_word_at_hit() {
6878        let mut core = lay_out_paragraph_tree();
6879        let p1 = core.rect_of_key("p1").expect("p1 rect");
6880        let cy = p1.y + p1.h * 0.5;
6881        // Click near the start of "First paragraph of text." — twice
6882        // within the multi-click window.
6883        let cx = p1.x + 4.0;
6884        core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
6885        core.pointer_up(Pointer::mouse(cx, cy, PointerButton::Primary));
6886        core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
6887        // The current selection should now span the first word.
6888        let sel = &core.ui_state.current_selection;
6889        let r = sel.range.as_ref().expect("selection set");
6890        assert_eq!(r.anchor.key, "p1");
6891        assert_eq!(r.head.key, "p1");
6892        // "First" is 5 bytes.
6893        assert_eq!(r.anchor.byte.min(r.head.byte), 0);
6894        assert_eq!(r.anchor.byte.max(r.head.byte), 5);
6895    }
6896
6897    #[test]
6898    fn triple_click_on_selectable_text_selects_whole_leaf() {
6899        let mut core = lay_out_paragraph_tree();
6900        let p1 = core.rect_of_key("p1").expect("p1 rect");
6901        let cy = p1.y + p1.h * 0.5;
6902        let cx = p1.x + 4.0;
6903        core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
6904        core.pointer_up(Pointer::mouse(cx, cy, PointerButton::Primary));
6905        core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
6906        core.pointer_up(Pointer::mouse(cx, cy, PointerButton::Primary));
6907        core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
6908        let sel = &core.ui_state.current_selection;
6909        let r = sel.range.as_ref().expect("selection set");
6910        assert_eq!(r.anchor.byte, 0);
6911        // "First paragraph of text." is 24 bytes.
6912        assert_eq!(r.head.byte, 24);
6913    }
6914
6915    #[test]
6916    fn click_count_resets_when_press_drifts_outside_distance_window() {
6917        let mut core = lay_out_input_tree(false);
6918        let btn = core.rect_of_key("btn").expect("btn rect");
6919        let cx = btn.x + btn.w * 0.5;
6920        let cy = btn.y + btn.h * 0.5;
6921
6922        let _ = core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
6923        let _ = core.pointer_up(Pointer::mouse(cx, cy, PointerButton::Primary));
6924
6925        // Move 10 px (well outside MULTI_CLICK_DIST=4.0). Even if same
6926        // target, the second press starts a fresh sequence.
6927        let down2 = core.pointer_down(Pointer::mouse(cx + 10.0, cy, PointerButton::Primary));
6928        let pd2 = down2
6929            .iter()
6930            .find(|e| e.kind == UiEventKind::PointerDown)
6931            .unwrap();
6932        assert_eq!(pd2.click_count, 1);
6933    }
6934
6935    #[test]
6936    fn escape_with_no_selection_emits_only_escape() {
6937        let mut core = lay_out_paragraph_tree();
6938        assert!(core.ui_state.current_selection.is_empty());
6939        let events = core.key_down(
6940            LogicalKey::Named(NamedKey::Escape),
6941            PhysicalKey::Unidentified,
6942            KeyModifiers::default(),
6943            false,
6944        );
6945        let kinds: Vec<UiEventKind> = events.iter().map(|e| e.kind).collect();
6946        assert_eq!(
6947            kinds,
6948            vec![UiEventKind::Escape],
6949            "no selection → no SelectionChanged side-effect"
6950        );
6951    }
6952
6953    /// Build a 200x200 viewport hosting a `scroll([rows...])` whose
6954    /// content overflows so the thumb is present.
6955    fn lay_out_scroll_tree() -> (RunnerCore, String) {
6956        use crate::tree::*;
6957        let mut tree = crate::scroll(
6958            (0..6)
6959                .map(|i| crate::widgets::text::text(format!("row {i}")).height(Size::Fixed(50.0))),
6960        )
6961        .gap(12.0)
6962        .height(Size::Fixed(200.0));
6963        let mut core = RunnerCore::new();
6964        crate::layout::layout(
6965            &mut tree,
6966            &mut core.ui_state,
6967            Rect::new(0.0, 0.0, 300.0, 200.0),
6968        );
6969        let scroll_id = tree.computed_id.clone();
6970        let mut t = PrepareTimings::default();
6971        core.snapshot(&tree, &mut t);
6972        (core, scroll_id.to_string())
6973    }
6974
6975    #[test]
6976    fn thumb_pointer_down_captures_drag_and_suppresses_events() {
6977        let (mut core, scroll_id) = lay_out_scroll_tree();
6978        let thumb = core
6979            .ui_state
6980            .scroll
6981            .thumb_rects
6982            .get(&scroll_id)
6983            .copied()
6984            .expect("scrollable should have a thumb");
6985        let event = core.pointer_down(Pointer::mouse(
6986            thumb.x + thumb.w * 0.5,
6987            thumb.y + thumb.h * 0.5,
6988            PointerButton::Primary,
6989        ));
6990        assert!(
6991            event.is_empty(),
6992            "thumb press should not emit PointerDown to the app"
6993        );
6994        let drag = core
6995            .ui_state
6996            .scroll
6997            .thumb_drag
6998            .as_ref()
6999            .expect("scroll.thumb_drag should be set after pointer_down on thumb");
7000        assert_eq!(drag.scroll_id, scroll_id);
7001    }
7002
7003    #[test]
7004    fn track_click_above_thumb_pages_up_below_pages_down() {
7005        let (mut core, scroll_id) = lay_out_scroll_tree();
7006        let track = core
7007            .ui_state
7008            .scroll
7009            .thumb_tracks
7010            .get(&scroll_id)
7011            .copied()
7012            .expect("scrollable should have a track");
7013        let thumb = core
7014            .ui_state
7015            .scroll
7016            .thumb_rects
7017            .get(&scroll_id)
7018            .copied()
7019            .unwrap();
7020        let metrics = core
7021            .ui_state
7022            .scroll
7023            .metrics
7024            .get(&scroll_id)
7025            .copied()
7026            .unwrap();
7027
7028        // Press in the track below the thumb at offset 0 → page down.
7029        let evt = core.pointer_down(Pointer::mouse(
7030            track.x + track.w * 0.5,
7031            thumb.y + thumb.h + 10.0,
7032            PointerButton::Primary,
7033        ));
7034        assert!(evt.is_empty(), "track press should not surface PointerDown");
7035        assert!(
7036            core.ui_state.scroll.thumb_drag.is_none(),
7037            "track click outside the thumb should not start a drag",
7038        );
7039        let after_down = core.ui_state.scroll_offset(&scroll_id);
7040        let expected_page = (metrics.viewport_h - SCROLL_PAGE_OVERLAP).max(0.0);
7041        assert!(
7042            (after_down - expected_page.min(metrics.max_offset)).abs() < 0.5,
7043            "page-down offset = {after_down} (expected ~{expected_page})",
7044        );
7045        // pointer_up after a track-page is a no-op (no drag to clear).
7046        let _ = core.pointer_up(Pointer::mouse(0.0, 0.0, PointerButton::Primary));
7047
7048        // Re-layout to refresh the thumb position at the new offset,
7049        // then click-to-page up.
7050        let mut tree = lay_out_scroll_tree_only();
7051        crate::layout::layout(
7052            &mut tree,
7053            &mut core.ui_state,
7054            Rect::new(0.0, 0.0, 300.0, 200.0),
7055        );
7056        let mut t = PrepareTimings::default();
7057        core.snapshot(&tree, &mut t);
7058        let track = core
7059            .ui_state
7060            .scroll
7061            .thumb_tracks
7062            .get(&*tree.computed_id)
7063            .copied()
7064            .unwrap();
7065        let thumb = core
7066            .ui_state
7067            .scroll
7068            .thumb_rects
7069            .get(&*tree.computed_id)
7070            .copied()
7071            .unwrap();
7072
7073        core.pointer_down(Pointer::mouse(
7074            track.x + track.w * 0.5,
7075            thumb.y - 4.0,
7076            PointerButton::Primary,
7077        ));
7078        let after_up = core.ui_state.scroll_offset(&tree.computed_id);
7079        assert!(
7080            after_up < after_down,
7081            "page-up should reduce offset: before={after_down} after={after_up}",
7082        );
7083    }
7084
7085    #[test]
7086    fn scrollbar_press_does_not_bypass_covering_scrim() {
7087        let (mut core, scroll_id) =
7088            lay_out_scroll_tree_with_layer(crate::widgets::overlay::scrim("modal:dismiss"));
7089        let thumb = core
7090            .ui_state
7091            .scroll
7092            .thumb_rects
7093            .get(&scroll_id)
7094            .copied()
7095            .expect("scrollable should have a thumb");
7096
7097        let events = core.pointer_down(Pointer::mouse(
7098            thumb.x + thumb.w * 0.5,
7099            thumb.y + thumb.h * 0.5,
7100            PointerButton::Primary,
7101        ));
7102
7103        assert_eq!(events.len(), 1);
7104        assert_eq!(events[0].kind, UiEventKind::PointerDown);
7105        assert_eq!(events[0].route(), Some("modal:dismiss"));
7106        assert!(
7107            core.ui_state.scroll.thumb_drag.is_none(),
7108            "covered scrollbar thumb must not capture drag",
7109        );
7110    }
7111
7112    #[test]
7113    fn scrollbar_press_does_not_bypass_block_pointer_layer() {
7114        use crate::tree::*;
7115
7116        let (mut core, scroll_id) =
7117            lay_out_scroll_tree_with_layer(El::new(Kind::Group).fill_size().block_pointer());
7118        let thumb = core
7119            .ui_state
7120            .scroll
7121            .thumb_rects
7122            .get(&scroll_id)
7123            .copied()
7124            .expect("scrollable should have a thumb");
7125
7126        let events = core.pointer_down(Pointer::mouse(
7127            thumb.x + thumb.w * 0.5,
7128            thumb.y + thumb.h * 0.5,
7129            PointerButton::Primary,
7130        ));
7131
7132        assert!(
7133            events.is_empty(),
7134            "block_pointer layer should swallow the press without scrolling",
7135        );
7136        assert!(
7137            core.ui_state.scroll.thumb_drag.is_none(),
7138            "covered scrollbar thumb must not capture drag",
7139        );
7140    }
7141
7142    /// Same fixture as `lay_out_scroll_tree` but doesn't build a
7143    /// fresh `RunnerCore` — useful when tests want to re-layout
7144    /// against an existing core to refresh thumb rects after a
7145    /// scroll offset change.
7146    fn lay_out_scroll_tree_only() -> El {
7147        use crate::tree::*;
7148        crate::scroll(
7149            (0..6)
7150                .map(|i| crate::widgets::text::text(format!("row {i}")).height(Size::Fixed(50.0))),
7151        )
7152        .gap(12.0)
7153        .height(Size::Fixed(200.0))
7154    }
7155
7156    fn lay_out_scroll_tree_with_layer(layer: El) -> (RunnerCore, String) {
7157        use crate::tree::*;
7158
7159        let scroll = lay_out_scroll_tree_only();
7160        let mut tree = crate::stack([scroll, layer]).fill_size();
7161        let mut core = RunnerCore::new();
7162        crate::layout::layout(
7163            &mut tree,
7164            &mut core.ui_state,
7165            Rect::new(0.0, 0.0, 300.0, 200.0),
7166        );
7167        let scroll_id = tree.children[0].computed_id.clone();
7168        let mut t = PrepareTimings::default();
7169        core.snapshot(&tree, &mut t);
7170        (core, scroll_id.to_string())
7171    }
7172
7173    /// `row([nav (resizable, 200px, clamp 120..420), main (Fill)])` in
7174    /// an 800x400 viewport — the canonical `.user_resizable()` sidebar
7175    /// (issue #106). Optionally wrapped in a stack under `layer`.
7176    fn lay_out_resizable_tree(layer: Option<El>) -> RunnerCore {
7177        use crate::tree::*;
7178        let row = crate::tree::row([
7179            column(Vec::<El>::new())
7180                .key("nav")
7181                .user_resizable()
7182                .width(Size::Fixed(200.0))
7183                .min_width(120.0)
7184                .max_width(420.0),
7185            column(Vec::<El>::new()).width(Size::Fill(1.0)),
7186        ])
7187        .height(Size::Fill(1.0));
7188        let mut tree = match layer {
7189            Some(layer) => crate::stack([row, layer]).fill_size(),
7190            None => row,
7191        };
7192        let mut core = RunnerCore::new();
7193        crate::layout::layout(
7194            &mut tree,
7195            &mut core.ui_state,
7196            Rect::new(0.0, 0.0, 800.0, 400.0),
7197        );
7198        let mut t = PrepareTimings::default();
7199        core.snapshot(&tree, &mut t);
7200        core
7201    }
7202
7203    #[test]
7204    fn resize_band_drag_updates_user_size_and_emits_resized_on_release() {
7205        let mut core = lay_out_resizable_tree(None);
7206
7207        // Press on the seam (x=200) captures the drag silently.
7208        let events = core.pointer_down(Pointer::mouse(200.0, 150.0, PointerButton::Primary));
7209        assert!(events.is_empty(), "band press must not reach the app");
7210        assert!(core.ui_state.resize.drag.is_some());
7211
7212        // Drag right 80px → the override tracks absolutely.
7213        let mv = core.pointer_moved(Pointer::mouse(280.0, 150.0, PointerButton::Primary));
7214        assert!(mv.events.is_empty(), "drag is captured, no app events");
7215        assert!(mv.needs_redraw, "size change must redraw");
7216        assert_eq!(core.ui_state.user_size("nav"), Some(280.0));
7217
7218        // Way past max → clamps to max_width.
7219        let _ = core.pointer_moved(Pointer::mouse(700.0, 150.0, PointerButton::Primary));
7220        assert_eq!(core.ui_state.user_size("nav"), Some(420.0));
7221
7222        // Release emits exactly one `Resized` routed to the pane key.
7223        let up = core.pointer_up(Pointer::mouse(700.0, 150.0, PointerButton::Primary));
7224        assert_eq!(up.len(), 1);
7225        assert_eq!(up[0].kind, UiEventKind::Resized);
7226        assert_eq!(up[0].route(), Some("nav"));
7227        assert!(core.ui_state.resize.drag.is_none());
7228    }
7229
7230    #[test]
7231    fn resize_band_hover_flips_cursor_and_requests_redraw() {
7232        let mut core = lay_out_resizable_tree(None);
7233        let tree = core.last_tree.clone().expect("snapshot stored a tree");
7234
7235        // Far from the seam: default cursor.
7236        let _ = core.pointer_moved(Pointer::mouse(400.0, 150.0, PointerButton::Primary));
7237        assert_eq!(core.ui_state.cursor(&tree), crate::cursor::Cursor::Default);
7238
7239        // Crossing onto the band must request a redraw (the host only
7240        // re-resolves the cursor after one) and resolve the resize
7241        // arrows even though no keyed hover changed.
7242        let mv = core.pointer_moved(Pointer::mouse(201.0, 150.0, PointerButton::Primary));
7243        assert!(mv.needs_redraw, "band entry must redraw for the cursor");
7244        assert_eq!(core.ui_state.cursor(&tree), crate::cursor::Cursor::EwResize);
7245
7246        // During a captured drag the cursor stays put even when the
7247        // pointer wanders off the band.
7248        let _ = core.pointer_down(Pointer::mouse(201.0, 150.0, PointerButton::Primary));
7249        let _ = core.pointer_moved(Pointer::mouse(390.0, 150.0, PointerButton::Primary));
7250        assert_eq!(core.ui_state.cursor(&tree), crate::cursor::Cursor::EwResize);
7251    }
7252
7253    #[test]
7254    fn resize_band_press_does_not_bypass_covering_scrim() {
7255        let mut core =
7256            lay_out_resizable_tree(Some(crate::widgets::overlay::scrim("modal:dismiss")));
7257
7258        let events = core.pointer_down(Pointer::mouse(200.0, 150.0, PointerButton::Primary));
7259        assert!(
7260            core.ui_state.resize.drag.is_none(),
7261            "covered band must not capture the press",
7262        );
7263        assert_eq!(events.len(), 1);
7264        assert_eq!(events[0].kind, UiEventKind::PointerDown);
7265        assert_eq!(events[0].route(), Some("modal:dismiss"));
7266    }
7267
7268    #[test]
7269    fn thumb_drag_translates_pointer_delta_into_scroll_offset() {
7270        let (mut core, scroll_id) = lay_out_scroll_tree();
7271        let thumb = core
7272            .ui_state
7273            .scroll
7274            .thumb_rects
7275            .get(&scroll_id)
7276            .copied()
7277            .unwrap();
7278        let metrics = core
7279            .ui_state
7280            .scroll
7281            .metrics
7282            .get(&scroll_id)
7283            .copied()
7284            .unwrap();
7285        let track_remaining = (metrics.viewport_h - thumb.h).max(0.0);
7286
7287        let press_y = thumb.y + thumb.h * 0.5;
7288        core.pointer_down(Pointer::mouse(
7289            thumb.x + thumb.w * 0.5,
7290            press_y,
7291            PointerButton::Primary,
7292        ));
7293        // Drag 20 px down — offset should advance by `20 * max_offset / track_remaining`.
7294        let evt = core.pointer_moved(Pointer::moving(thumb.x + thumb.w * 0.5, press_y + 20.0));
7295        assert!(
7296            evt.events.is_empty(),
7297            "thumb-drag move should suppress Drag event",
7298        );
7299        let offset = core.ui_state.scroll_offset(&scroll_id);
7300        let expected = 20.0 * (metrics.max_offset / track_remaining);
7301        assert!(
7302            (offset - expected).abs() < 0.5,
7303            "offset {offset} (expected {expected})",
7304        );
7305        // Overshooting clamps to max_offset.
7306        core.pointer_moved(Pointer::moving(thumb.x + thumb.w * 0.5, press_y + 9999.0));
7307        let offset = core.ui_state.scroll_offset(&scroll_id);
7308        assert!(
7309            (offset - metrics.max_offset).abs() < 0.5,
7310            "overshoot offset {offset} (expected {})",
7311            metrics.max_offset
7312        );
7313        // Release clears the drag without emitting events.
7314        let events = core.pointer_up(Pointer::mouse(thumb.x, press_y, PointerButton::Primary));
7315        assert!(events.is_empty(), "thumb release shouldn't emit events");
7316        assert!(core.ui_state.scroll.thumb_drag.is_none());
7317    }
7318
7319    #[test]
7320    fn secondary_click_does_not_steal_focus_or_press() {
7321        let mut core = lay_out_input_tree(false);
7322        let btn_rect = core.rect_of_key("btn").expect("btn rect");
7323        let cx = btn_rect.x + btn_rect.w * 0.5;
7324        let cy = btn_rect.y + btn_rect.h * 0.5;
7325        // Focus elsewhere first via primary click on the input.
7326        let ti_rect = core.rect_of_key("ti").expect("ti rect");
7327        let tx = ti_rect.x + ti_rect.w * 0.5;
7328        let ty = ti_rect.y + ti_rect.h * 0.5;
7329        core.pointer_down(Pointer::mouse(tx, ty, PointerButton::Primary));
7330        let _ = core.pointer_up(Pointer::mouse(tx, ty, PointerButton::Primary));
7331        let focused_before = core.ui_state.focused.as_ref().map(|t| t.key.clone());
7332        // Right-click on the button.
7333        core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Secondary));
7334        let events = core.pointer_up(Pointer::mouse(cx, cy, PointerButton::Secondary));
7335        let kinds: Vec<UiEventKind> = events.iter().map(|e| e.kind).collect();
7336        assert_eq!(kinds, vec![UiEventKind::SecondaryClick]);
7337        let focused_after = core.ui_state.focused.as_ref().map(|t| t.key.clone());
7338        assert_eq!(
7339            focused_before, focused_after,
7340            "right-click must not steal focus"
7341        );
7342        assert!(
7343            core.ui_state.pressed.is_none(),
7344            "right-click must not set primary press"
7345        );
7346    }
7347
7348    #[test]
7349    fn text_input_routes_to_focused_only() {
7350        let mut core = lay_out_input_tree(false);
7351        // No focus yet → no event.
7352        assert!(core.text_input("a".into()).is_none());
7353        // Focus the button via primary click.
7354        let btn_rect = core.rect_of_key("btn").expect("btn rect");
7355        let cx = btn_rect.x + btn_rect.w * 0.5;
7356        let cy = btn_rect.y + btn_rect.h * 0.5;
7357        core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
7358        let _ = core.pointer_up(Pointer::mouse(cx, cy, PointerButton::Primary));
7359        let event = core.text_input("hi".into()).expect("focused → event");
7360        assert_eq!(event.kind, UiEventKind::TextInput);
7361        assert_eq!(event.text.as_deref(), Some("hi"));
7362        assert_eq!(event.target.as_ref().map(|t| t.key.as_str()), Some("btn"));
7363        // Empty text → no event (some IME paths emit empty composition).
7364        assert!(core.text_input(String::new()).is_none());
7365    }
7366
7367    #[test]
7368    fn capture_keys_bypasses_tab_traversal_for_focused_node() {
7369        // Focus the capture_keys input. Tab should NOT move focus —
7370        // it should be delivered as a raw KeyDown to the input.
7371        let mut core = lay_out_input_tree(true);
7372        let ti_rect = core.rect_of_key("ti").expect("ti rect");
7373        let tx = ti_rect.x + ti_rect.w * 0.5;
7374        let ty = ti_rect.y + ti_rect.h * 0.5;
7375        core.pointer_down(Pointer::mouse(tx, ty, PointerButton::Primary));
7376        let _ = core.pointer_up(Pointer::mouse(tx, ty, PointerButton::Primary));
7377        assert_eq!(
7378            core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
7379            Some("ti"),
7380            "primary click on capture_keys node still focuses it"
7381        );
7382
7383        let events = core.key_down(
7384            LogicalKey::Named(NamedKey::Tab),
7385            PhysicalKey::Unidentified,
7386            KeyModifiers::default(),
7387            false,
7388        );
7389        assert_eq!(events.len(), 1, "Tab → exactly one KeyDown");
7390        let event = &events[0];
7391        assert_eq!(event.kind, UiEventKind::KeyDown);
7392        assert_eq!(event.target.as_ref().map(|t| t.key.as_str()), Some("ti"));
7393        assert_eq!(
7394            core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
7395            Some("ti"),
7396            "Tab inside capture_keys must NOT move focus"
7397        );
7398    }
7399
7400    #[test]
7401    fn escape_blurs_capture_keys_after_delivering_raw_keydown() {
7402        let mut core = lay_out_input_tree(true);
7403        let ti_rect = core.rect_of_key("ti").expect("ti rect");
7404        let tx = ti_rect.x + ti_rect.w * 0.5;
7405        let ty = ti_rect.y + ti_rect.h * 0.5;
7406        core.pointer_down(Pointer::mouse(tx, ty, PointerButton::Primary));
7407        let _ = core.pointer_up(Pointer::mouse(tx, ty, PointerButton::Primary));
7408        assert_eq!(
7409            core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
7410            Some("ti")
7411        );
7412
7413        let events = core.key_down(
7414            LogicalKey::Named(NamedKey::Escape),
7415            PhysicalKey::Unidentified,
7416            KeyModifiers::default(),
7417            false,
7418        );
7419
7420        assert_eq!(events.len(), 1);
7421        let event = &events[0];
7422        assert_eq!(event.kind, UiEventKind::KeyDown);
7423        assert_eq!(event.target.as_ref().map(|t| t.key.as_str()), Some("ti"));
7424        assert!(matches!(
7425            event.key_press.as_ref().map(|p| &p.logical),
7426            Some(LogicalKey::Named(NamedKey::Escape))
7427        ));
7428        assert_eq!(core.ui_state.focused.as_ref().map(|t| t.key.as_str()), None);
7429    }
7430
7431    #[test]
7432    fn pointer_down_focus_does_not_raise_focus_visible() {
7433        // `:focus-visible` semantics: clicking a widget focuses it but
7434        // does NOT light up the focus ring. Verify the runtime flag.
7435        let mut core = lay_out_input_tree(false);
7436        let btn_rect = core.rect_of_key("btn").expect("btn rect");
7437        let cx = btn_rect.x + btn_rect.w * 0.5;
7438        let cy = btn_rect.y + btn_rect.h * 0.5;
7439        core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
7440        assert_eq!(
7441            core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
7442            Some("btn"),
7443            "primary click focuses the button",
7444        );
7445        assert!(
7446            !core.ui_state.focus_visible,
7447            "click focus must not raise focus_visible — ring stays off",
7448        );
7449    }
7450
7451    #[test]
7452    fn tab_key_raises_focus_visible_so_ring_appears() {
7453        let mut core = lay_out_input_tree(false);
7454        // Pre-focus via click so focus_visible starts low.
7455        let btn_rect = core.rect_of_key("btn").expect("btn rect");
7456        let cx = btn_rect.x + btn_rect.w * 0.5;
7457        let cy = btn_rect.y + btn_rect.h * 0.5;
7458        core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
7459        assert!(!core.ui_state.focus_visible);
7460        // Tab moves focus and should raise the ring.
7461        let _ = core.key_down(
7462            LogicalKey::Named(NamedKey::Tab),
7463            PhysicalKey::Unidentified,
7464            KeyModifiers::default(),
7465            false,
7466        );
7467        assert!(
7468            core.ui_state.focus_visible,
7469            "Tab must raise focus_visible so the ring paints on the new target",
7470        );
7471    }
7472
7473    #[test]
7474    fn click_after_tab_clears_focus_visible_again() {
7475        // Tab raises the ring; a subsequent click on a focusable widget
7476        // suppresses it again — the user is back on the pointer.
7477        let mut core = lay_out_input_tree(false);
7478        let _ = core.key_down(
7479            LogicalKey::Named(NamedKey::Tab),
7480            PhysicalKey::Unidentified,
7481            KeyModifiers::default(),
7482            false,
7483        );
7484        assert!(core.ui_state.focus_visible, "Tab raises ring");
7485        let btn_rect = core.rect_of_key("btn").expect("btn rect");
7486        let cx = btn_rect.x + btn_rect.w * 0.5;
7487        let cy = btn_rect.y + btn_rect.h * 0.5;
7488        core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
7489        assert!(
7490            !core.ui_state.focus_visible,
7491            "pointer-down clears focus_visible — ring fades back out",
7492        );
7493    }
7494
7495    #[test]
7496    fn keypress_on_focused_widget_raises_focus_visible_after_click() {
7497        // Click a focused-but-non-text widget, then nudge with a key
7498        // (e.g. arrow on a slider). The keypress is keyboard
7499        // interaction → ring lights up even though focus didn't move.
7500        let mut core = lay_out_input_tree(false);
7501        let btn_rect = core.rect_of_key("btn").expect("btn rect");
7502        let cx = btn_rect.x + btn_rect.w * 0.5;
7503        let cy = btn_rect.y + btn_rect.h * 0.5;
7504        core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
7505        assert!(!core.ui_state.focus_visible);
7506        let _ = core.key_down(
7507            LogicalKey::Named(NamedKey::ArrowRight),
7508            PhysicalKey::Unidentified,
7509            KeyModifiers::default(),
7510            false,
7511        );
7512        assert!(
7513            core.ui_state.focus_visible,
7514            "non-Tab key on focused widget raises focus_visible",
7515        );
7516    }
7517
7518    #[test]
7519    fn selected_text_resolves_a_selection_inside_a_virtual_list() {
7520        // Regression: a build-the-tree-then-walk-it path would miss
7521        // virtual_list children, because rows are realized in layout
7522        // (not build) — copy/cut from a visible row in a chat-style
7523        // virtualized pane silently produced an empty clipboard. The
7524        // runtime helper reads `last_tree`, which already has the
7525        // visible rows realized at the live scroll offset.
7526        use crate::selection::{Selection, SelectionPoint, SelectionRange};
7527        use crate::tree::*;
7528
7529        // 20 rows; each row is a keyed selectable leaf so the
7530        // selection can point at it directly. 50px high so a 200px
7531        // viewport realizes the first few rows on the initial pass.
7532        let mut tree = virtual_list_dyn(
7533            20,
7534            50.0,
7535            |i| format!("row-{i}"),
7536            |i| {
7537                crate::widgets::text::text(format!("row {i} text"))
7538                    .key(format!("row-{i}"))
7539                    .selectable()
7540                    .height(Size::Fixed(50.0))
7541            },
7542        );
7543        let mut core = RunnerCore::new();
7544        crate::layout::layout(
7545            &mut tree,
7546            &mut core.ui_state,
7547            Rect::new(0.0, 0.0, 200.0, 200.0),
7548        );
7549        let mut t = PrepareTimings::default();
7550        core.snapshot(&tree, &mut t);
7551
7552        // Select the middle of "row 1 text" — bytes 0..9 = "row 1 tex".
7553        let selection = Selection {
7554            range: Some(SelectionRange {
7555                anchor: SelectionPoint::new("row-1", 0),
7556                head: SelectionPoint::new("row-1", 9),
7557            }),
7558        };
7559        core.set_selection(selection);
7560
7561        assert_eq!(
7562            core.selected_text().as_deref(),
7563            Some("row 1 tex"),
7564            "runtime.selected_text() must walk last_tree (realized rows) — \
7565             a build-only path would miss virtual_list children entirely",
7566        );
7567    }
7568
7569    #[test]
7570    fn shortcut_chord_does_not_raise_focus_visible() {
7571        // Pointer-click focuses the button and suppresses the ring.
7572        // Tapping or holding a bare modifier (Ctrl, Shift, …) before
7573        // the second half of a chord must NOT light the ring, and
7574        // completing the chord (e.g. Ctrl+C) must NOT light it
7575        // either — the focused widget is incidental to a global
7576        // shortcut, matching browser `:focus-visible` heuristics.
7577        let mut core = lay_out_input_tree(false);
7578        let btn_rect = core.rect_of_key("btn").expect("btn rect");
7579        let cx = btn_rect.x + btn_rect.w * 0.5;
7580        let cy = btn_rect.y + btn_rect.h * 0.5;
7581        core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
7582        assert!(!core.ui_state.focus_visible);
7583
7584        let ctrl = KeyModifiers {
7585            ctrl: true,
7586            ..Default::default()
7587        };
7588        let _ = core.key_down(
7589            LogicalKey::Named(NamedKey::Control),
7590            PhysicalKey::Unidentified,
7591            ctrl,
7592            false,
7593        );
7594        assert!(
7595            !core.ui_state.focus_visible,
7596            "bare Ctrl press must not raise focus_visible on a pointer-focused widget",
7597        );
7598        let _ = core.key_down(
7599            LogicalKey::Character("c".into()),
7600            PhysicalKey::Unidentified,
7601            ctrl,
7602            false,
7603        );
7604        assert!(
7605            !core.ui_state.focus_visible,
7606            "Ctrl+C is a shortcut, not interaction with the focused widget",
7607        );
7608
7609        let _ = core.key_down(
7610            LogicalKey::Named(NamedKey::Shift),
7611            PhysicalKey::Unidentified,
7612            KeyModifiers::default(),
7613            false,
7614        );
7615        assert!(
7616            !core.ui_state.focus_visible,
7617            "bare Shift press must not raise focus_visible",
7618        );
7619        let _ = core.key_down(
7620            LogicalKey::Character("a".into()),
7621            PhysicalKey::Unidentified,
7622            KeyModifiers::default(),
7623            false,
7624        );
7625        assert!(
7626            !core.ui_state.focus_visible,
7627            "bare character keys are typing/activation guesses, not navigation",
7628        );
7629        let _ = core.key_down(
7630            LogicalKey::Named(NamedKey::Escape),
7631            PhysicalKey::Unidentified,
7632            KeyModifiers::default(),
7633            false,
7634        );
7635        assert!(
7636            !core.ui_state.focus_visible,
7637            "Escape is dismissal, not navigation — no ring",
7638        );
7639    }
7640
7641    #[test]
7642    fn arrow_nav_in_sibling_group_raises_focus_visible() {
7643        let mut core = lay_out_arrow_nav_tree();
7644        // The fixture pre-sets focus directly without going through
7645        // the runtime; ensure the flag starts low.
7646        core.ui_state.set_focus_visible(false);
7647        let _ = core.key_down(
7648            LogicalKey::Named(NamedKey::ArrowDown),
7649            PhysicalKey::Unidentified,
7650            KeyModifiers::default(),
7651            false,
7652        );
7653        assert!(
7654            core.ui_state.focus_visible,
7655            "arrow-nav within an arrow_nav_siblings group is keyboard navigation",
7656        );
7657    }
7658
7659    #[test]
7660    fn capture_keys_falls_back_to_default_when_focus_off_capturing_node() {
7661        // Tree has both a normal-focusable button and a capture_keys
7662        // input. Focus the button (normal focusable). Tab should then
7663        // do library-default focus traversal.
7664        let mut core = lay_out_input_tree(true);
7665        let btn_rect = core.rect_of_key("btn").expect("btn rect");
7666        let cx = btn_rect.x + btn_rect.w * 0.5;
7667        let cy = btn_rect.y + btn_rect.h * 0.5;
7668        core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
7669        let _ = core.pointer_up(Pointer::mouse(cx, cy, PointerButton::Primary));
7670        assert_eq!(
7671            core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
7672            Some("btn"),
7673            "primary click focuses button"
7674        );
7675        // Tab should move focus to the next focusable (the input).
7676        let _ = core.key_down(
7677            LogicalKey::Named(NamedKey::Tab),
7678            PhysicalKey::Unidentified,
7679            KeyModifiers::default(),
7680            false,
7681        );
7682        assert_eq!(
7683            core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
7684            Some("ti"),
7685            "Tab from non-capturing focused does library-default traversal"
7686        );
7687    }
7688
7689    /// A column whose three buttons sit inside an `arrow_nav_siblings`
7690    /// parent (the shape `popover_panel` produces). Layout runs against
7691    /// a 200x300 viewport with 10px padding; each button is 80px wide
7692    /// and 36px tall stacked vertically, plenty inside the clip.
7693    fn lay_out_arrow_nav_tree() -> RunnerCore {
7694        use crate::tree::*;
7695        let mut tree = crate::column([
7696            crate::widgets::button::button("Red").key("opt-red"),
7697            crate::widgets::button::button("Green").key("opt-green"),
7698            crate::widgets::button::button("Blue").key("opt-blue"),
7699        ])
7700        .arrow_nav_siblings()
7701        .padding(10.0);
7702        let mut core = RunnerCore::new();
7703        crate::layout::layout(
7704            &mut tree,
7705            &mut core.ui_state,
7706            Rect::new(0.0, 0.0, 200.0, 300.0),
7707        );
7708        core.ui_state.sync_focus_order(&tree);
7709        let mut t = PrepareTimings::default();
7710        core.snapshot(&tree, &mut t);
7711        // Pre-focus the middle option (the typical state right after a
7712        // popover opens — we'll exercise transitions from there).
7713        let target = core
7714            .ui_state
7715            .focus
7716            .order
7717            .iter()
7718            .find(|t| t.key == "opt-green")
7719            .cloned();
7720        core.ui_state.set_focus(target);
7721        core
7722    }
7723
7724    #[test]
7725    fn arrow_nav_moves_focus_among_siblings() {
7726        let mut core = lay_out_arrow_nav_tree();
7727
7728        // ArrowDown moves to next sibling, no event emitted (it was
7729        // consumed by the navigation path).
7730        let down = core.key_down(
7731            LogicalKey::Named(NamedKey::ArrowDown),
7732            PhysicalKey::Unidentified,
7733            KeyModifiers::default(),
7734            false,
7735        );
7736        assert!(down.is_empty(), "arrow-nav consumes the key event");
7737        assert_eq!(
7738            core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
7739            Some("opt-blue"),
7740        );
7741
7742        // ArrowUp moves back.
7743        core.key_down(
7744            LogicalKey::Named(NamedKey::ArrowUp),
7745            PhysicalKey::Unidentified,
7746            KeyModifiers::default(),
7747            false,
7748        );
7749        assert_eq!(
7750            core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
7751            Some("opt-green"),
7752        );
7753
7754        // Home jumps to first.
7755        core.key_down(
7756            LogicalKey::Named(NamedKey::Home),
7757            PhysicalKey::Unidentified,
7758            KeyModifiers::default(),
7759            false,
7760        );
7761        assert_eq!(
7762            core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
7763            Some("opt-red"),
7764        );
7765
7766        // End jumps to last.
7767        core.key_down(
7768            LogicalKey::Named(NamedKey::End),
7769            PhysicalKey::Unidentified,
7770            KeyModifiers::default(),
7771            false,
7772        );
7773        assert_eq!(
7774            core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
7775            Some("opt-blue"),
7776        );
7777    }
7778
7779    #[test]
7780    fn arrow_nav_saturates_at_ends() {
7781        let mut core = lay_out_arrow_nav_tree();
7782        // Walk to the first option and try to go before it.
7783        core.key_down(
7784            LogicalKey::Named(NamedKey::Home),
7785            PhysicalKey::Unidentified,
7786            KeyModifiers::default(),
7787            false,
7788        );
7789        core.key_down(
7790            LogicalKey::Named(NamedKey::ArrowUp),
7791            PhysicalKey::Unidentified,
7792            KeyModifiers::default(),
7793            false,
7794        );
7795        assert_eq!(
7796            core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
7797            Some("opt-red"),
7798            "ArrowUp at top stays at top — no wrap",
7799        );
7800        // Same at the bottom.
7801        core.key_down(
7802            LogicalKey::Named(NamedKey::End),
7803            PhysicalKey::Unidentified,
7804            KeyModifiers::default(),
7805            false,
7806        );
7807        core.key_down(
7808            LogicalKey::Named(NamedKey::ArrowDown),
7809            PhysicalKey::Unidentified,
7810            KeyModifiers::default(),
7811            false,
7812        );
7813        assert_eq!(
7814            core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
7815            Some("opt-blue"),
7816            "ArrowDown at bottom stays at bottom — no wrap",
7817        );
7818    }
7819
7820    #[test]
7821    fn arrow_nav_vertical_lets_horizontal_arrows_fall_through() {
7822        // Regression guard for menubars: a vertical group must not
7823        // consume Left/Right, so the app can still route them (e.g.
7824        // switching between menubar menus).
7825        let mut core = lay_out_arrow_nav_tree();
7826        let out = core.key_down(
7827            LogicalKey::Named(NamedKey::ArrowRight),
7828            PhysicalKey::Unidentified,
7829            KeyModifiers::default(),
7830            false,
7831        );
7832        assert_eq!(
7833            core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
7834            Some("opt-green"),
7835            "ArrowRight must not move focus in a Vertical group",
7836        );
7837        assert!(
7838            !out.is_empty(),
7839            "ArrowRight falls through to normal KeyDown routing",
7840        );
7841    }
7842
7843    /// Lay out a real `toggle_group` (a Horizontal arrow-nav row) and
7844    /// pre-focus the first item.
7845    fn lay_out_horizontal_group() -> RunnerCore {
7846        let mut tree = crate::widgets::toggle::toggle_group(
7847            "view",
7848            &"list",
7849            [("list", "List"), ("grid", "Grid"), ("map", "Map")],
7850        )
7851        .padding(10.0);
7852        let mut core = RunnerCore::new();
7853        crate::layout::layout(
7854            &mut tree,
7855            &mut core.ui_state,
7856            Rect::new(0.0, 0.0, 400.0, 100.0),
7857        );
7858        core.ui_state.sync_focus_order(&tree);
7859        let mut t = PrepareTimings::default();
7860        core.snapshot(&tree, &mut t);
7861        let target = core
7862            .ui_state
7863            .focus
7864            .order
7865            .iter()
7866            .find(|t| t.key == "view:toggle:list")
7867            .cloned();
7868        core.ui_state.set_focus(target);
7869        core
7870    }
7871
7872    #[test]
7873    fn arrow_nav_horizontal_group_steps_on_left_right() {
7874        // Issue #63: toggle_group / tabs_list are Horizontal groups —
7875        // Left/Right step among the items, Up/Down fall through.
7876        let mut core = lay_out_horizontal_group();
7877
7878        let right = core.key_down(
7879            LogicalKey::Named(NamedKey::ArrowRight),
7880            PhysicalKey::Unidentified,
7881            KeyModifiers::default(),
7882            false,
7883        );
7884        assert!(right.is_empty(), "ArrowRight is consumed by the group");
7885        assert_eq!(
7886            core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
7887            Some("view:toggle:grid"),
7888        );
7889
7890        core.key_down(
7891            LogicalKey::Named(NamedKey::ArrowLeft),
7892            PhysicalKey::Unidentified,
7893            KeyModifiers::default(),
7894            false,
7895        );
7896        assert_eq!(
7897            core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
7898            Some("view:toggle:list"),
7899        );
7900
7901        core.key_down(
7902            LogicalKey::Named(NamedKey::End),
7903            PhysicalKey::Unidentified,
7904            KeyModifiers::default(),
7905            false,
7906        );
7907        assert_eq!(
7908            core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
7909            Some("view:toggle:map"),
7910        );
7911
7912        let down = core.key_down(
7913            LogicalKey::Named(NamedKey::ArrowDown),
7914            PhysicalKey::Unidentified,
7915            KeyModifiers::default(),
7916            false,
7917        );
7918        assert_eq!(
7919            core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
7920            Some("view:toggle:map"),
7921            "ArrowDown must not move focus in a Horizontal group",
7922        );
7923        assert!(
7924            !down.is_empty(),
7925            "ArrowDown falls through to normal KeyDown routing",
7926        );
7927    }
7928
7929    #[test]
7930    fn arrow_nav_radio_group_steps_on_both_axes() {
7931        // ARIA radio-group: Up/Left previous, Down/Right next,
7932        // regardless of the group's visual axis (issue #63).
7933        let mut tree = crate::widgets::radio::radio_group(
7934            "theme",
7935            &"light",
7936            [("light", "Light"), ("dark", "Dark"), ("auto", "Auto")],
7937        )
7938        .padding(10.0);
7939        let mut core = RunnerCore::new();
7940        crate::layout::layout(
7941            &mut tree,
7942            &mut core.ui_state,
7943            Rect::new(0.0, 0.0, 300.0, 300.0),
7944        );
7945        core.ui_state.sync_focus_order(&tree);
7946        let mut t = PrepareTimings::default();
7947        core.snapshot(&tree, &mut t);
7948        let target = core
7949            .ui_state
7950            .focus
7951            .order
7952            .iter()
7953            .find(|t| t.key == "theme:radio:light")
7954            .cloned();
7955        core.ui_state.set_focus(target);
7956
7957        core.key_down(
7958            LogicalKey::Named(NamedKey::ArrowDown),
7959            PhysicalKey::Unidentified,
7960            KeyModifiers::default(),
7961            false,
7962        );
7963        assert_eq!(
7964            core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
7965            Some("theme:radio:dark"),
7966            "ArrowDown steps to the next radio",
7967        );
7968        core.key_down(
7969            LogicalKey::Named(NamedKey::ArrowRight),
7970            PhysicalKey::Unidentified,
7971            KeyModifiers::default(),
7972            false,
7973        );
7974        assert_eq!(
7975            core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
7976            Some("theme:radio:auto"),
7977            "ArrowRight also steps in a Both group",
7978        );
7979        core.key_down(
7980            LogicalKey::Named(NamedKey::ArrowLeft),
7981            PhysicalKey::Unidentified,
7982            KeyModifiers::default(),
7983            false,
7984        );
7985        assert_eq!(
7986            core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
7987            Some("theme:radio:dark"),
7988            "ArrowLeft steps back in a Both group",
7989        );
7990    }
7991
7992    /// Lay out a two-week `calendar_month` (days 1–14, day `disabled`
7993    /// optionally grayed out) and pre-focus the given day.
7994    fn lay_out_calendar(focus_day: u32, disabled_day: Option<u32>) -> RunnerCore {
7995        use crate::widgets::calendar::{CalendarDay, calendar_month};
7996        let days = (1..=14).map(|d| {
7997            let day = CalendarDay::new(format!("2026-06-{d:02}"), d.to_string());
7998            if Some(d) == disabled_day {
7999                day.disabled()
8000            } else {
8001                day
8002            }
8003        });
8004        let mut tree = calendar_month("cal", "June 2026", days);
8005        let mut core = RunnerCore::new();
8006        crate::layout::layout(
8007            &mut tree,
8008            &mut core.ui_state,
8009            Rect::new(0.0, 0.0, 600.0, 400.0),
8010        );
8011        core.ui_state.sync_focus_order(&tree);
8012        let mut t = PrepareTimings::default();
8013        core.snapshot(&tree, &mut t);
8014        let key = format!("cal:day:2026-06-{focus_day:02}");
8015        let target = core
8016            .ui_state
8017            .focus
8018            .order
8019            .iter()
8020            .find(|t| t.key == key)
8021            .cloned();
8022        assert!(target.is_some(), "day {focus_day} should be focusable");
8023        core.ui_state.set_focus(target);
8024        core
8025    }
8026
8027    #[test]
8028    fn arrow_nav_calendar_grid_moves_two_dimensionally() {
8029        // Issue #63: the day grid is an ArrowNav::Grid group —
8030        // Left/Right step in tree order, Up/Down move between weeks.
8031        let mut core = lay_out_calendar(3, None);
8032
8033        let down = core.key_down(
8034            LogicalKey::Named(NamedKey::ArrowDown),
8035            PhysicalKey::Unidentified,
8036            KeyModifiers::default(),
8037            false,
8038        );
8039        assert!(down.is_empty(), "ArrowDown is consumed by the grid");
8040        assert_eq!(
8041            core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
8042            Some("cal:day:2026-06-10"),
8043            "ArrowDown lands on the same weekday one week later",
8044        );
8045
8046        core.key_down(
8047            LogicalKey::Named(NamedKey::ArrowRight),
8048            PhysicalKey::Unidentified,
8049            KeyModifiers::default(),
8050            false,
8051        );
8052        assert_eq!(
8053            core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
8054            Some("cal:day:2026-06-11"),
8055        );
8056
8057        core.key_down(
8058            LogicalKey::Named(NamedKey::ArrowUp),
8059            PhysicalKey::Unidentified,
8060            KeyModifiers::default(),
8061            false,
8062        );
8063        assert_eq!(
8064            core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
8065            Some("cal:day:2026-06-04"),
8066            "ArrowUp lands on the same weekday one week earlier",
8067        );
8068
8069        // Up from the first week has no row above — focus stays put.
8070        core.key_down(
8071            LogicalKey::Named(NamedKey::ArrowUp),
8072            PhysicalKey::Unidentified,
8073            KeyModifiers::default(),
8074            false,
8075        );
8076        assert_eq!(
8077            core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
8078            Some("cal:day:2026-06-04"),
8079            "ArrowUp at the top edge saturates",
8080        );
8081    }
8082
8083    #[test]
8084    fn arrow_nav_calendar_grid_skips_disabled_days() {
8085        // Disabled cells aren't focusable, so they aren't group
8086        // members; Up/Down land on the geometrically nearest enabled
8087        // day in the target week instead of dead-ending.
8088        let mut core = lay_out_calendar(3, Some(10));
8089        core.key_down(
8090            LogicalKey::Named(NamedKey::ArrowDown),
8091            PhysicalKey::Unidentified,
8092            KeyModifiers::default(),
8093            false,
8094        );
8095        let focused = core
8096            .ui_state
8097            .focused
8098            .as_ref()
8099            .map(|t| t.key.clone())
8100            .expect("focus survives the move");
8101        assert!(
8102            focused == "cal:day:2026-06-09" || focused == "cal:day:2026-06-11",
8103            "ArrowDown over a disabled day lands on a neighbor in the \
8104             same week, got {focused}",
8105        );
8106    }
8107
8108    /// Build a tree shaped like a real app's `build()` output: a
8109    /// background row with a "Trigger" button, optionally with a
8110    /// dropdown popover layered on top.
8111    fn build_popover_tree(open: bool) -> El {
8112        use crate::widgets::button::button;
8113        use crate::widgets::overlay::overlay;
8114        use crate::widgets::popover::{dropdown, menu_item};
8115        let mut layers: Vec<El> = vec![button("Trigger").key("trigger")];
8116        if open {
8117            layers.push(dropdown(
8118                "menu",
8119                "trigger",
8120                [
8121                    menu_item("A").key("item-a"),
8122                    menu_item("B").key("item-b"),
8123                    menu_item("C").key("item-c"),
8124                ],
8125            ));
8126        }
8127        overlay(layers).padding(20.0)
8128    }
8129
8130    /// Run a full per-frame layout pass against `tree` so all the
8131    /// post-layout hooks (focus order sync, popover focus stack, etc.)
8132    /// fire just like a real frame.
8133    fn run_frame(core: &mut RunnerCore, tree: &mut El) {
8134        let mut t = PrepareTimings::default();
8135        core.prepare_layout(
8136            tree,
8137            Rect::new(0.0, 0.0, 400.0, 300.0),
8138            1.0,
8139            &mut t,
8140            RunnerCore::no_time_shaders,
8141        );
8142        core.snapshot(tree, &mut t);
8143    }
8144
8145    #[test]
8146    fn popover_open_pushes_focus_and_auto_focuses_first_item() {
8147        let mut core = RunnerCore::new();
8148        let mut closed = build_popover_tree(false);
8149        run_frame(&mut core, &mut closed);
8150        // Pre-focus the trigger as if the user tabbed to it before
8151        // opening the menu.
8152        let trigger = core
8153            .ui_state
8154            .focus
8155            .order
8156            .iter()
8157            .find(|t| t.key == "trigger")
8158            .cloned();
8159        core.ui_state.set_focus(trigger);
8160        assert_eq!(
8161            core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
8162            Some("trigger"),
8163        );
8164
8165        // Open the popover. The runtime should snapshot the trigger
8166        // onto the focus stack and auto-focus the first menu item.
8167        let mut open = build_popover_tree(true);
8168        run_frame(&mut core, &mut open);
8169        assert_eq!(
8170            core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
8171            Some("item-a"),
8172            "popover open should auto-focus the first menu item",
8173        );
8174        assert_eq!(
8175            core.ui_state.popover_focus.focus_stack.len(),
8176            1,
8177            "trigger should be saved on the focus stack",
8178        );
8179        assert_eq!(
8180            core.ui_state.popover_focus.focus_stack[0].key.as_str(),
8181            "trigger",
8182            "saved focus should be the pre-open target",
8183        );
8184    }
8185
8186    #[test]
8187    fn popover_close_restores_focus_to_trigger() {
8188        let mut core = RunnerCore::new();
8189        let mut closed = build_popover_tree(false);
8190        run_frame(&mut core, &mut closed);
8191        let trigger = core
8192            .ui_state
8193            .focus
8194            .order
8195            .iter()
8196            .find(|t| t.key == "trigger")
8197            .cloned();
8198        core.ui_state.set_focus(trigger);
8199
8200        // Open → focus walks to the menu.
8201        let mut open = build_popover_tree(true);
8202        run_frame(&mut core, &mut open);
8203        assert_eq!(
8204            core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
8205            Some("item-a"),
8206        );
8207
8208        // Close → focus restored to trigger, stack drained.
8209        let mut closed_again = build_popover_tree(false);
8210        run_frame(&mut core, &mut closed_again);
8211        assert_eq!(
8212            core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
8213            Some("trigger"),
8214            "closing the popover should pop the saved focus",
8215        );
8216        assert!(
8217            core.ui_state.popover_focus.focus_stack.is_empty(),
8218            "focus stack should be drained after restore",
8219        );
8220    }
8221
8222    #[test]
8223    fn popover_close_does_not_override_intentional_focus_move() {
8224        let mut core = RunnerCore::new();
8225        // Tree with a second focusable button outside the popover so
8226        // the user can "click somewhere else" while the menu is open.
8227        let build = |open: bool| -> El {
8228            use crate::widgets::button::button;
8229            use crate::widgets::overlay::overlay;
8230            use crate::widgets::popover::{dropdown, menu_item};
8231            let main = crate::row([
8232                button("Trigger").key("trigger"),
8233                button("Other").key("other"),
8234            ]);
8235            let mut layers: Vec<El> = vec![main];
8236            if open {
8237                layers.push(dropdown("menu", "trigger", [menu_item("A").key("item-a")]));
8238            }
8239            overlay(layers).padding(20.0)
8240        };
8241
8242        let mut closed = build(false);
8243        run_frame(&mut core, &mut closed);
8244        let trigger = core
8245            .ui_state
8246            .focus
8247            .order
8248            .iter()
8249            .find(|t| t.key == "trigger")
8250            .cloned();
8251        core.ui_state.set_focus(trigger);
8252
8253        let mut open = build(true);
8254        run_frame(&mut core, &mut open);
8255        assert_eq!(core.ui_state.popover_focus.focus_stack.len(), 1);
8256
8257        // Simulate an intentional focus move to a sibling that is
8258        // outside the popover (e.g. the user re-tabbed somewhere). Do
8259        // this by setting focus directly while the popover is still in
8260        // the tree — the existing focus-order contains "other".
8261        let other = core
8262            .ui_state
8263            .focus
8264            .order
8265            .iter()
8266            .find(|t| t.key == "other")
8267            .cloned();
8268        core.ui_state.set_focus(other);
8269
8270        let mut closed_again = build(false);
8271        run_frame(&mut core, &mut closed_again);
8272        assert_eq!(
8273            core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
8274            Some("other"),
8275            "focus moved before close should not be overridden by restore",
8276        );
8277        assert!(core.ui_state.popover_focus.focus_stack.is_empty());
8278    }
8279
8280    #[test]
8281    fn nested_popovers_stack_and_unwind_focus_correctly() {
8282        let mut core = RunnerCore::new();
8283        // Two siblings layered at El root: an outer popover anchored to
8284        // the trigger, and an inner popover anchored to a button inside
8285        // the outer panel. Both are real popovers — separate
8286        // popover_layer ids — so the runtime sees them stack.
8287        let build = |outer: bool, inner: bool| -> El {
8288            use crate::widgets::button::button;
8289            use crate::widgets::overlay::overlay;
8290            use crate::widgets::popover::{Anchor, popover, popover_panel};
8291            let main = button("Trigger").key("trigger");
8292            let mut layers: Vec<El> = vec![main];
8293            if outer {
8294                layers.push(popover(
8295                    "outer",
8296                    Anchor::below_key("trigger"),
8297                    popover_panel([button("Open inner").key("inner-trigger")]),
8298                ));
8299            }
8300            if inner {
8301                layers.push(popover(
8302                    "inner",
8303                    Anchor::below_key("inner-trigger"),
8304                    popover_panel([button("X").key("inner-a"), button("Y").key("inner-b")]),
8305                ));
8306            }
8307            overlay(layers).padding(20.0)
8308        };
8309
8310        // Frame 1: nothing open, focus on the trigger.
8311        let mut closed = build(false, false);
8312        run_frame(&mut core, &mut closed);
8313        let trigger = core
8314            .ui_state
8315            .focus
8316            .order
8317            .iter()
8318            .find(|t| t.key == "trigger")
8319            .cloned();
8320        core.ui_state.set_focus(trigger);
8321
8322        // Frame 2: outer opens. Save trigger, focus inner-trigger.
8323        let mut outer = build(true, false);
8324        run_frame(&mut core, &mut outer);
8325        assert_eq!(
8326            core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
8327            Some("inner-trigger"),
8328        );
8329        assert_eq!(core.ui_state.popover_focus.focus_stack.len(), 1);
8330
8331        // Frame 3: inner also opens. Save inner-trigger, focus inner-a.
8332        let mut both = build(true, true);
8333        run_frame(&mut core, &mut both);
8334        assert_eq!(
8335            core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
8336            Some("inner-a"),
8337        );
8338        assert_eq!(core.ui_state.popover_focus.focus_stack.len(), 2);
8339
8340        // Frame 4: inner closes. Pop → restore inner-trigger.
8341        let mut outer_only = build(true, false);
8342        run_frame(&mut core, &mut outer_only);
8343        assert_eq!(
8344            core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
8345            Some("inner-trigger"),
8346        );
8347        assert_eq!(core.ui_state.popover_focus.focus_stack.len(), 1);
8348
8349        // Frame 5: outer closes. Pop → restore trigger.
8350        let mut none = build(false, false);
8351        run_frame(&mut core, &mut none);
8352        assert_eq!(
8353            core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
8354            Some("trigger"),
8355        );
8356        assert!(core.ui_state.popover_focus.focus_stack.is_empty());
8357    }
8358
8359    #[test]
8360    fn arrow_nav_does_not_intercept_outside_navigable_groups() {
8361        // Reuse the input tree (no arrow_nav_siblings parent). Arrow
8362        // keys must produce a regular `KeyDown` event so a
8363        // capture_keys widget can interpret them as caret motion.
8364        let mut core = lay_out_input_tree(false);
8365        let target = core
8366            .ui_state
8367            .focus
8368            .order
8369            .iter()
8370            .find(|t| t.key == "btn")
8371            .cloned();
8372        core.ui_state.set_focus(target);
8373        let events = core.key_down(
8374            LogicalKey::Named(NamedKey::ArrowDown),
8375            PhysicalKey::Unidentified,
8376            KeyModifiers::default(),
8377            false,
8378        );
8379        assert_eq!(
8380            events.len(),
8381            1,
8382            "ArrowDown without navigable parent → event"
8383        );
8384        assert_eq!(events[0].kind, UiEventKind::KeyDown);
8385    }
8386
8387    fn quad(shader: ShaderHandle) -> DrawOp {
8388        DrawOp::Quad {
8389            id: "q".into(),
8390            rect: Rect::new(0.0, 0.0, 10.0, 10.0),
8391            scissor: None,
8392            shader,
8393            uniforms: UniformBlock::new(),
8394        }
8395    }
8396
8397    #[test]
8398    fn prepare_paint_skips_ops_outside_viewport() {
8399        let mut core = RunnerCore::new();
8400        core.set_surface_size(100, 100);
8401        core.viewport_px = (100, 100);
8402        let ops = vec![
8403            DrawOp::Quad {
8404                id: "offscreen".into(),
8405                rect: Rect::new(0.0, 150.0, 10.0, 10.0),
8406                scissor: None,
8407                shader: ShaderHandle::Stock(StockShader::RoundedRect),
8408                uniforms: UniformBlock::new(),
8409            },
8410            quad(ShaderHandle::Stock(StockShader::RoundedRect)),
8411        ];
8412        let mut timings = PrepareTimings::default();
8413        core.prepare_paint(&ops, |_| true, |_| false, &mut NoText, 1.0, &mut timings);
8414
8415        assert_eq!(timings.paint_culled_ops, 1);
8416        assert_eq!(
8417            core.runs.len(),
8418            1,
8419            "only the visible quad should become a paint run"
8420        );
8421    }
8422
8423    #[test]
8424    fn prepare_paint_does_not_shape_text_outside_clip() {
8425        let mut core = RunnerCore::new();
8426        core.set_surface_size(100, 100);
8427        core.viewport_px = (100, 100);
8428        let ops = vec![
8429            DrawOp::GlyphRun {
8430                id: "offscreen-text".into(),
8431                rect: Rect::new(0.0, 150.0, 80.0, 20.0),
8432                scissor: Some(Rect::new(0.0, 0.0, 100.0, 100.0)),
8433                shader: ShaderHandle::Stock(StockShader::Text),
8434                color: Color::srgb_u8a(255, 255, 255, 255),
8435                text: "offscreen".into(),
8436                size: 14.0,
8437                line_height: 20.0,
8438                family: Default::default(),
8439                mono_family: Default::default(),
8440                weight: FontWeight::Regular,
8441                mono: false,
8442                wrap: TextWrap::NoWrap,
8443                anchor: TextAnchor::Start,
8444                layout: empty_text_layout(20.0),
8445                underline: false,
8446                strikethrough: false,
8447                link: None,
8448                tabular_numerals: false,
8449                letter_spacing: 0.0,
8450            },
8451            DrawOp::GlyphRun {
8452                id: "visible-text".into(),
8453                rect: Rect::new(0.0, 10.0, 80.0, 20.0),
8454                scissor: Some(Rect::new(0.0, 0.0, 100.0, 100.0)),
8455                shader: ShaderHandle::Stock(StockShader::Text),
8456                color: Color::srgb_u8a(255, 255, 255, 255),
8457                text: "visible".into(),
8458                size: 14.0,
8459                line_height: 20.0,
8460                family: Default::default(),
8461                mono_family: Default::default(),
8462                weight: FontWeight::Regular,
8463                mono: false,
8464                wrap: TextWrap::NoWrap,
8465                anchor: TextAnchor::Start,
8466                layout: empty_text_layout(20.0),
8467                underline: false,
8468                strikethrough: false,
8469                link: None,
8470                tabular_numerals: false,
8471                letter_spacing: 0.0,
8472            },
8473        ];
8474        let mut text = CountingText::default();
8475        let mut timings = PrepareTimings::default();
8476        core.prepare_paint(&ops, |_| true, |_| false, &mut text, 1.0, &mut timings);
8477
8478        assert_eq!(timings.paint_culled_ops, 1);
8479        assert_eq!(text.records, 1, "offscreen text must not be shaped");
8480    }
8481
8482    #[test]
8483    fn samples_backdrop_inserts_snapshot_before_first_glass_quad() {
8484        let mut core = RunnerCore::new();
8485        core.set_surface_size(100, 100);
8486        let ops = vec![
8487            quad(ShaderHandle::Stock(StockShader::RoundedRect)),
8488            quad(ShaderHandle::Stock(StockShader::RoundedRect)),
8489            quad(ShaderHandle::Custom("liquid_glass")),
8490            quad(ShaderHandle::Custom("liquid_glass")),
8491            quad(ShaderHandle::Stock(StockShader::RoundedRect)),
8492        ];
8493        let mut timings = PrepareTimings::default();
8494        core.prepare_paint(
8495            &ops,
8496            |_| true,
8497            |s| matches!(s, ShaderHandle::Custom(name) if *name == "liquid_glass"),
8498            &mut NoText,
8499            1.0,
8500            &mut timings,
8501        );
8502
8503        let kinds: Vec<&'static str> = core
8504            .paint_items
8505            .iter()
8506            .map(|p| match p {
8507                PaintItem::QuadRun(_) => "Q",
8508                PaintItem::IconRun(_) => "I",
8509                PaintItem::Text(_) => "T",
8510                PaintItem::Image(_) => "M",
8511                PaintItem::AppTexture(_) => "A",
8512                PaintItem::Vector(_) => "V",
8513                PaintItem::Scene3D(_) => "3",
8514                PaintItem::BackdropSnapshot => "S",
8515            })
8516            .collect();
8517        assert_eq!(
8518            kinds,
8519            vec!["Q", "S", "Q", "Q"],
8520            "expected one stock run, snapshot, then a glass run, then a foreground stock run"
8521        );
8522    }
8523
8524    #[test]
8525    fn no_snapshot_when_no_glass_drawn() {
8526        let mut core = RunnerCore::new();
8527        core.set_surface_size(100, 100);
8528        let ops = vec![
8529            quad(ShaderHandle::Stock(StockShader::RoundedRect)),
8530            quad(ShaderHandle::Stock(StockShader::RoundedRect)),
8531        ];
8532        let mut timings = PrepareTimings::default();
8533        core.prepare_paint(&ops, |_| true, |_| false, &mut NoText, 1.0, &mut timings);
8534        assert!(
8535            !core
8536                .paint_items
8537                .iter()
8538                .any(|p| matches!(p, PaintItem::BackdropSnapshot)),
8539            "no glass shader registered → no snapshot"
8540        );
8541    }
8542
8543    /// A `DrawOp::Scene3D` whose backend overrides `record_scene3d`
8544    /// produces a `PaintItem::Scene3D`; one that leaves the default no-op
8545    /// recorder produces nothing. This locks the prepare_paint wiring
8546    /// independent of any GPU backend.
8547    #[test]
8548    fn scene3d_op_emits_paint_item_only_when_recorder_records() {
8549        use crate::scene::{Aabb, CameraState, LightRig, Scene3DData, SceneStyle};
8550
8551        fn scene_op() -> DrawOp {
8552            let scene = Scene3DData {
8553                meshes: Vec::new(),
8554                points: Vec::new(),
8555                lines: Vec::new(),
8556                camera: CameraState::default().resolve(Aabb::EMPTY),
8557                lights: LightRig::default(),
8558                style: SceneStyle::default(),
8559                capture_depth: false,
8560            };
8561            DrawOp::Scene3D {
8562                id: "scene".into(),
8563                rect: Rect::new(0.0, 0.0, 40.0, 40.0),
8564                scissor: None,
8565                scene: std::sync::Arc::new(scene),
8566            }
8567        }
8568
8569        // Default recorder (NoText) never overrides record_scene3d → no item.
8570        let mut core = RunnerCore::new();
8571        core.set_surface_size(100, 100);
8572        let mut timings = PrepareTimings::default();
8573        core.prepare_paint(
8574            &[scene_op()],
8575            |_| true,
8576            |_| false,
8577            &mut NoText,
8578            1.0,
8579            &mut timings,
8580        );
8581        assert!(
8582            !core
8583                .paint_items
8584                .iter()
8585                .any(|p| matches!(p, PaintItem::Scene3D(_))),
8586            "default no-op recorder must not emit a Scene3D paint item",
8587        );
8588
8589        // A recorder that records one scene → exactly one Scene3D item.
8590        struct SceneRecorder {
8591            calls: usize,
8592        }
8593        impl TextRecorder for SceneRecorder {
8594            fn record(
8595                &mut self,
8596                _: Rect,
8597                _: Option<PhysicalScissor>,
8598                _: &RunStyle,
8599                _: &str,
8600                _: f32,
8601                _: f32,
8602                _: TextWrap,
8603                _: TextAnchor,
8604                _: f32,
8605            ) -> Range<usize> {
8606                0..0
8607            }
8608            fn record_runs(
8609                &mut self,
8610                _: Rect,
8611                _: Option<PhysicalScissor>,
8612                _: &[(String, RunStyle)],
8613                _: f32,
8614                _: f32,
8615                _: TextWrap,
8616                _: TextAnchor,
8617                _: f32,
8618            ) -> Range<usize> {
8619                0..0
8620            }
8621            fn record_scene3d(
8622                &mut self,
8623                _: Rect,
8624                _: Option<PhysicalScissor>,
8625                id: &str,
8626                _: &std::sync::Arc<Scene3DData>,
8627                _: f32,
8628            ) -> Range<usize> {
8629                assert_eq!(id, "scene", "node id threads through to the recorder");
8630                let start = self.calls;
8631                self.calls += 1;
8632                start..self.calls
8633            }
8634        }
8635
8636        let mut core = RunnerCore::new();
8637        core.set_surface_size(100, 100);
8638        let mut rec = SceneRecorder { calls: 0 };
8639        let mut timings = PrepareTimings::default();
8640        core.prepare_paint(
8641            &[scene_op()],
8642            |_| true,
8643            |_| false,
8644            &mut rec,
8645            1.0,
8646            &mut timings,
8647        );
8648        let scenes = core
8649            .paint_items
8650            .iter()
8651            .filter(|p| matches!(p, PaintItem::Scene3D(_)))
8652            .count();
8653        assert_eq!(
8654            scenes, 1,
8655            "recorded scene must emit exactly one Scene3D item"
8656        );
8657    }
8658
8659    /// A `chart3d` that is given a `.key(...)` must still orbit. Keying a
8660    /// node makes it a hit-test target, so a press over the scene now
8661    /// *hits* the scene's own node; the camera-drag gate must treat that
8662    /// hit as "nothing in the way" and still begin the drag. Regression for
8663    /// the bug where a keyed scene silently lost orbit/pan (only wheel-zoom,
8664    /// which bypasses the gate, kept working).
8665    #[test]
8666    fn keyed_scene_still_begins_camera_drag() {
8667        use crate::scene::glam::Vec3;
8668        use crate::scene::{PointData, PointsHandle, ScenePoint, SceneSpec};
8669        use crate::tree::chart3d;
8670
8671        let spec = || {
8672            SceneSpec::new().points(PointsHandle::new(PointData {
8673                points: vec![
8674                    ScenePoint {
8675                        position: Vec3::splat(-1.0),
8676                        color: [1.0; 4],
8677                    },
8678                    ScenePoint {
8679                        position: Vec3::splat(1.0),
8680                        color: [1.0; 4],
8681                    },
8682                ],
8683            }))
8684        };
8685
8686        // Lay out, tick the camera (so the scene registers a viewport rect
8687        // for hit-routing), snapshot for hit-testing, then press at centre.
8688        let drag_active_after_press = |mut tree: crate::tree::El| {
8689            let mut core = RunnerCore::new();
8690            crate::layout::layout(
8691                &mut tree,
8692                &mut core.ui_state,
8693                Rect::new(0.0, 0.0, 200.0, 200.0),
8694            );
8695            core.ui_state.tick_scene_cameras(&tree, Instant::now());
8696            let mut t = PrepareTimings::default();
8697            core.snapshot(&tree, &mut t);
8698            core.pointer_down(Pointer::mouse(100.0, 100.0, PointerButton::Primary));
8699            core.ui_state.camera_drag_active()
8700        };
8701
8702        // Unkeyed: works today (baseline).
8703        assert!(
8704            drag_active_after_press(chart3d(spec())),
8705            "unkeyed scene should begin a camera drag"
8706        );
8707        // Keyed: the regression — must also begin a drag.
8708        assert!(
8709            drag_active_after_press(chart3d(spec()).key("scene")),
8710            "keyed scene must still begin a camera drag (its own node hit must not suppress it)"
8711        );
8712    }
8713
8714    /// The plot's control scheme selects what the unmodified primary drag
8715    /// does: `ZoomDrag` (default) box-zooms and Shift pans; `PanDrag` is the
8716    /// inverse. Double-click reset and wheel are scheme-independent.
8717    #[test]
8718    fn plot_control_scheme_routes_primary_drag() {
8719        use crate::event::KeyModifiers;
8720        use crate::plot::{PlotControls, PlotSpec, Sample, Scale, SeriesHandle, line};
8721        use crate::tree::plot;
8722
8723        let make = |controls| {
8724            let h = SeriesHandle::new(vec![Sample::new(0.0, 0.0), Sample::new(10.0, 10.0)]);
8725            plot(
8726                PlotSpec::new()
8727                    .x(Scale::linear())
8728                    .y(Scale::linear())
8729                    .add_mark(line(&h))
8730                    .controls(controls),
8731            )
8732            .key("p")
8733        };
8734
8735        // (zoom_active, pan_active) after a centre press, with/without Shift.
8736        let press = |mut tree: crate::tree::El, shift: bool| {
8737            let mut core = RunnerCore::new();
8738            crate::layout::layout(
8739                &mut tree,
8740                &mut core.ui_state,
8741                Rect::new(0.0, 0.0, 200.0, 200.0),
8742            );
8743            core.ui_state.prepare_plots(&tree);
8744            let mut t = PrepareTimings::default();
8745            core.snapshot(&tree, &mut t);
8746            core.ui_state.set_modifiers(KeyModifiers {
8747                shift,
8748                ..Default::default()
8749            });
8750            core.pointer_down(Pointer::mouse(100.0, 100.0, PointerButton::Primary));
8751            (
8752                core.ui_state.plot_zoom_active(),
8753                core.ui_state.plot_pan_active(),
8754            )
8755        };
8756
8757        // ZoomDrag (default): plain drag box-zooms, Shift pans.
8758        assert_eq!(press(make(PlotControls::ZoomDrag), false), (true, false));
8759        assert_eq!(press(make(PlotControls::ZoomDrag), true), (false, true));
8760        // PanDrag: plain drag pans, Shift box-zooms.
8761        assert_eq!(press(make(PlotControls::PanDrag), false), (false, true));
8762        assert_eq!(press(make(PlotControls::PanDrag), true), (true, false));
8763    }
8764
8765    #[test]
8766    fn at_most_one_snapshot_per_frame() {
8767        let mut core = RunnerCore::new();
8768        core.set_surface_size(100, 100);
8769        let ops = vec![
8770            quad(ShaderHandle::Stock(StockShader::RoundedRect)),
8771            quad(ShaderHandle::Custom("g")),
8772            quad(ShaderHandle::Stock(StockShader::RoundedRect)),
8773            quad(ShaderHandle::Custom("g")),
8774        ];
8775        let mut timings = PrepareTimings::default();
8776        core.prepare_paint(
8777            &ops,
8778            |_| true,
8779            |s| matches!(s, ShaderHandle::Custom("g")),
8780            &mut NoText,
8781            1.0,
8782            &mut timings,
8783        );
8784        let snapshots = core
8785            .paint_items
8786            .iter()
8787            .filter(|p| matches!(p, PaintItem::BackdropSnapshot))
8788            .count();
8789        assert_eq!(snapshots, 1, "backdrop depth is capped at 1");
8790    }
8791}