Skip to main content

damascene_core/
runtime.rs

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