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