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