Skip to main content

damascene_core/
runtime.rs

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