Skip to main content

repose_app/
runtime.rs

1use std::cell::RefCell;
2use std::collections::{HashMap, HashSet};
3use std::rc::Rc;
4
5use repose_core::dnd;
6use repose_core::input::{
7    GamepadAxis, GamepadButton, GamepadEvent, GamepadId, ImeEvent, Key, KeyEvent, KeyEventType,
8    Modifiers, PointerButton, PointerEvent, PointerEventKind, PointerId, PointerKind, SensorKind,
9    SensorSample,
10};
11use repose_core::locals::{Density, set_density_default, with_density};
12use repose_core::runtime::{Frame, Scheduler};
13use repose_core::shortcuts::DragAction;
14use repose_core::{
15    CursorIcon, HitRegion, Interaction, Modifier, RenderContext, Scene, Vec2, View, request_frame,
16};
17use repose_ui::textfield::TextFieldState;
18use repose_ui::{Interactions, layout_and_paint};
19
20fn ensure_tf_state(
21    map: &mut HashMap<u64, Rc<RefCell<TextFieldState>>>,
22    key: u64,
23    seed: &str,
24) -> Rc<RefCell<TextFieldState>> {
25    map.entry(key)
26        .or_insert_with(|| {
27            Rc::new(RefCell::new(if seed.is_empty() {
28                TextFieldState::new()
29            } else {
30                TextFieldState::with_text(seed.to_string())
31            }))
32        })
33        .clone()
34}
35
36fn ensure_all_tf_states_from_frame(
37    map: &mut HashMap<u64, Rc<RefCell<TextFieldState>>>,
38    frame: &Frame,
39) {
40    for hit in &frame.hit_regions {
41        if let Some(key) = hit.tf_state_key {
42            let st = ensure_tf_state(map, key, hit.tf_value.as_str());
43            // Only sync text; do not move caret.
44            st.borrow_mut()
45                .apply_controlled_value(hit.tf_value.as_str());
46        }
47    }
48}
49
50fn is_tf_hit(f: &Frame, id: u64) -> bool {
51    f.hit_regions
52        .iter()
53        .any(|h| h.id == id && h.tf_state_key.is_some())
54        || is_textfield_in_frame(f, id)
55}
56
57/// Platform-directed side effects requested by the UI.
58#[derive(Clone, Default)]
59pub struct PlatformOutput {
60    /// Cursor to display (None = default/system cursor).
61    pub cursor: Option<CursorIcon>,
62    /// Whether IME input is allowed for the currently focused widget.
63    pub ime_allowed: bool,
64    /// IME cursor area in logical (DPI-scaled) coordinates: (x, y, width, height).
65    pub ime_cursor_area: Option<(f64, f64, f64, f64)>,
66    /// Text to write to the clipboard (transient - set once per frame, cleared after read).
67    pub clipboard_text: Option<String>,
68
69    /// IME / soft-keyboard hints for the focused text field. The host should
70    /// apply these to the OS keyboard and to `set_ime_purpose` / web attrs.
71    pub ime_purpose: repose_core::ImePurposeHint,
72    pub ime_auto_correct: bool,
73    pub ime_capitalization: repose_core::KeyboardCapitalization,
74    pub keyboard_type: repose_core::KeyboardType,
75
76    /// Whether the app theme is dark, so the host can sync OS window chrome
77    /// (titlebar, caption buttons). `None` = don't touch the OS chrome.
78    pub window_theme_dark: Option<bool>,
79}
80
81/// Output of a single frame: the rendered scene plus metadata for the host.
82pub struct FrameOutput {
83    /// The scene graph for rendering.
84    pub scene: Scene,
85    /// Hit regions for pointer dispatch between frames.
86    pub hit_regions: Vec<HitRegion>,
87    /// Semantics nodes for a11y.
88    pub semantics_nodes: Vec<repose_core::runtime::SemNode>,
89    /// Focus chain for tab navigation.
90    pub focus_chain: Vec<u64>,
91    /// Platform-side effects (cursor, IME, clipboard).
92    pub platform: PlatformOutput,
93    /// Whether the UI wants pointer events (if false, host can pass events through).
94    pub wants_pointer: bool,
95    /// Whether the UI wants keyboard events (if false, host can pass events through).
96    pub wants_keyboard: bool,
97}
98
99impl FrameOutput {
100    /// Consume the frame into a `repose_core::Frame` for hit-testing/caching
101    /// by the host. Drops the platform-output and pointer metadata.
102    pub fn into_frame(self) -> Frame {
103        Frame {
104            scene: self.scene,
105            hit_regions: self.hit_regions,
106            semantics_nodes: self.semantics_nodes,
107            focus_chain: self.focus_chain,
108        }
109    }
110
111    pub fn into_shared_frame(self) -> Rc<Frame> {
112        Rc::new(self.into_frame())
113    }
114}
115
116/// Result of a pointer-move event processed by the runtime.
117pub struct PointerMoveResult {
118    /// Updated cursor suggestion for the host.
119    pub cursor: Option<CursorIcon>,
120    /// The id of the element under the pointer, if any.
121    pub hover_id: Option<u64>,
122}
123
124/// Result of a pointer-button event processed by the runtime.
125#[derive(Debug)]
126pub struct PointerButtonResult {
127    /// Id of the element that received focus (if any).
128    pub focused: Option<u64>,
129    /// Id of the captured element.
130    pub capture_id: Option<u64>,
131    /// Whether the event was consumed by the UI.
132    pub consumed: bool,
133    /// Whether an accessibility announcement was triggered.
134    pub needs_a11y_announce: bool,
135    /// Set on release when a click fired, so hosts can announce activation
136    /// without re-reading runtime state that has already been cleared.
137    pub clicked_id: Option<u64>,
138}
139
140// ViewConfiguration defaults
141const LONG_PRESS_MS: u128 = 500;
142const DOUBLE_CLICK_MS: u128 = 300;
143const DOUBLE_TAP_MIN_MS: u128 = 40;
144const LONG_PRESS_SLOP_DP: f32 = 18.0;
145/// Pointer travel that cancels a pending tap, in dp. Compose's
146/// `detectTapGestures` treats a press that moves past the platform touch slop
147/// as a drag, so the tap never fires. This is the same value Compose's
148/// `ViewConfiguration.touchSlop` resolves to on Android.
149pub(crate) const TAP_SLOP_DP: f32 = 18.0;
150
151/// Did the pointer travel from `(x0, y0)` past `slop` dp? Compared squared to
152/// keep the per-move path allocation-free.
153fn moved_past_slop(x0: f32, y0: f32, pos: Vec2, slop_dp: f32, scale: f32) -> bool {
154    let slop = slop_dp * scale;
155    let dx = pos.x - x0;
156    let dy = pos.y - y0;
157    dx * dx + dy * dy > slop * slop
158}
159
160struct TouchPressState {
161    capture_id: u64,
162    pressed_ids: HashSet<u64>,
163}
164
165struct DndPointerCapture {
166    touch: Option<u64>,
167    source_id: u64,
168}
169
170/// Embeddable Repose runtime.
171///
172/// Manages composition scheduling, input routing, text-field state, and
173/// pointer/key dispatch.  The host owns the event loop and GPU device. This
174/// is purely the UI logic layer.
175pub struct ReposeRuntime {
176    pub sched: Scheduler,
177    pub scale: f32,
178    /// Ambient host layer for floating surfaces. Installed around
179    /// composition each frame; entries render at the root.
180    pub overlay: repose_ui::overlay::OverlayHandle,
181    pub dnd_context: repose_core::dnd::DndContext,
182    lifecycle_events: std::sync::Arc<crate::lifecycle::LifecycleDispatcher>,
183    deeplink_events: std::sync::Arc<crate::lifecycle::DeeplinkDispatcher>,
184
185    pub modifiers: Modifiers,
186    pub mouse_pos_px: (f32, f32),
187    /// Whether the pointer is currently inside the window.
188    pub pointer_inside: bool,
189    pub hover_id: Option<u64>,
190    pub hover_ancestors: std::collections::HashSet<u64>,
191    /// Needed so `Leave` still fires
192    /// even when the hovered hit region is removed from the tree between frames.
193    /// Rebuilt on every `cache_frame`.
194    hover_leave: HashMap<u64, repose_ui::HitRegionSnapshot>,
195    pub capture_id: Option<u64>,
196    /// Hit path captured at pointer-down: every region under the pointer,
197    /// ordered bottom-up (deepest child first, ancestors last).
198    pub hit_path: Option<Vec<u64>>,
199    /// Per-finger press table: every concurrent touch owns its own
200    /// hit path (unlike the single mouse `hit_path`/`capture_id`,
201    /// which is the single-primary tap emulation's). Game viewports
202    /// stage per-finger contacts off the dispatched events, so the
203    /// second finger's joystick/button press stages alongside the
204    /// first instead of stealing its path. UI press arbitration
205    /// (tap/click/scroll) stays single-primary in `touch_gesture`;
206    /// this only routes the per-finger event stream.
207    pub touch_paths: HashMap<u64, Vec<u64>>,
208    mouse_targets: Option<Vec<repose_ui::HitRegionSnapshot>>,
209    touch_targets: HashMap<u64, Vec<repose_ui::HitRegionSnapshot>>,
210    touch_positions: HashMap<u64, Vec2>,
211    touch_presses: HashMap<u64, TouchPressState>,
212    dnd_capture: Option<DndPointerCapture>,
213    touch_primary: Option<u64>,
214    suppressed_touch_clicks: HashSet<u64>,
215    /// Which scroll consumer currently owns the wheel gesture.
216    pub scroll_capture_id: Option<u64>,
217    last_scroll_at: Option<web_time::Instant>,
218    pub pressed_ids: HashSet<u64>,
219    pub ime_preedit: bool,
220    pub key_pressed_active: Option<u64>,
221    key_pressed_key: Option<Key>,
222    pub last_focus: Option<u64>,
223    /// Polled physical-key state, keyed by debug name (`KeyCode::KeyW`,
224    /// `Digit1`, ...). Platform runners report every `KeyboardInput`
225    /// press/release here so games can poll held keys GML-style
226    /// (`keyboard_check`) instead of reconstructing them from
227    /// focus-routed key events, which miss keys when focus moves or
228    /// a key-up is swallowed (alt-tab, overlay, layout).
229    /// Cleared on window focus loss.
230    pub held_keys: HashSet<String>,
231    /// Polled mouse-button state. `handle_pointer_press/release`
232    /// maintain this for Primary/Secondary/Tertiary so games can read
233    /// held buttons (`mouse_check_button`) without tracking
234    /// press/release edges themselves.
235    pub held_mouse: HashSet<PointerButton>,
236
237    last_up: Option<(u64, web_time::Instant, f32, f32)>,
238    /// Position/time of the most recent pointer-down, used to time the second
239    /// tap of a double click (Compose: window + min time measured to the
240    /// second DOWN, not its up).
241    last_down: Option<(u64, web_time::Instant)>,
242    /// Origin of the press that produced the current capture, so release can
243    /// tell a tap from a drag (Compose `detectTapGestures` cancels the tap once
244    /// the pointer moves past touch slop).
245    press_origin: Option<(u64, f32, f32)>,
246    /// Set when the second tap of a double-click qualifies (within
247    /// [DOUBLE_TAP_MIN_MS, DOUBLE_CLICK_MS] of the first tap's up, and within
248    /// tap slop of it). Its up confirms the double click. A canceled second tap
249    /// falls back to the first tap's onClick.
250    double_candidate: Option<u64>,
251    long_press: Option<(u64, web_time::Instant, f32, f32)>,
252    long_press_touch: Option<u64>,
253    /// Keyboard long-press (Compose combinedClickable: holding Space/Enter
254    /// past LONG_PRESS_MS fires on_long_click). `bool` = already fired.
255    key_long_press: Option<(u64, web_time::Instant, bool)>,
256    suppress_next_click: bool,
257    pending_click: Option<(u64, web_time::Instant, Rc<dyn Fn()>)>,
258
259    pub frame_cache: Option<Rc<Frame>>,
260
261    cursor: Option<CursorIcon>,
262    frame_cursor_override: Option<CursorIcon>,
263    fullscreen_request: Option<bool>,
264
265    pub shortcuts: repose_core::shortcuts::ShortcutState,
266
267    pub textfield_states: HashMap<u64, Rc<RefCell<TextFieldState>>>,
268    /// Connected gamepads by backend id: display name plus live button/axis
269    /// state. Fed by [`ReposeRuntime::handle_gamepad`].
270    pub gamepads: HashMap<u32, GamepadPad>,
271    /// Queued dual-motor rumble requests.
272    pub pending_rumble: Vec<(u32, f32, f32, u32)>,
273    /// Latest motion-sensor reading per pad and sensor, fed by
274    /// [`ReposeRuntime::handle_sensor_sample`].
275    pub sensors: HashMap<(u32, SensorKind), [f32; 3]>,
276}
277
278/// Live state of one connected gamepad, mirrored from [`GamepadEvent`]s.
279#[derive(Clone, Debug, Default)]
280pub struct GamepadPad {
281    pub name: String,
282    /// USB vendor and product ids, `0` where the platform reports none. Enough
283    /// to tell a model apart (a GameCube adapter, a Switch pad) without
284    /// carrying a driver identity.
285    pub vendor_id: u16,
286    pub product_id: u16,
287    pub pressed: HashSet<GamepadButton>,
288    pub axes: HashMap<GamepadAxis, f32>,
289}
290
291impl GamepadPad {
292    pub fn button(&self, button: GamepadButton) -> bool {
293        self.pressed.contains(&button)
294    }
295
296    pub fn axis(&self, axis: GamepadAxis) -> f32 {
297        self.axes.get(&axis).copied().unwrap_or(0.0)
298    }
299}
300
301impl ReposeRuntime {
302    pub fn new() -> Self {
303        Self::with_overlay(repose_ui::overlay::OverlayHandle::new())
304    }
305
306    /// Create a runtime sharing `overlay` as the ambient host layer.
307    /// Entries posted through this handle render at the root; per-frame
308    /// `show_guard` state can still target other handles as an escape hatch.
309    pub fn with_overlay(overlay: repose_ui::overlay::OverlayHandle) -> Self {
310        Self {
311            overlay,
312            dnd_context: repose_core::dnd::DndContext::default(),
313            lifecycle_events: std::sync::Arc::new(crate::lifecycle::LifecycleDispatcher::default()),
314            deeplink_events: std::sync::Arc::new(crate::lifecycle::DeeplinkDispatcher::default()),
315            sched: Scheduler::new(),
316            scale: 1.0,
317            modifiers: Modifiers::default(),
318            mouse_pos_px: (0.0, 0.0),
319            pointer_inside: false,
320            hover_id: None,
321            hover_ancestors: std::collections::HashSet::new(),
322            hover_leave: HashMap::new(),
323            capture_id: None,
324            hit_path: None,
325            touch_paths: HashMap::new(),
326            mouse_targets: None,
327            touch_targets: HashMap::new(),
328            touch_positions: HashMap::new(),
329            touch_presses: HashMap::new(),
330            dnd_capture: None,
331            touch_primary: None,
332            suppressed_touch_clicks: HashSet::new(),
333            scroll_capture_id: None,
334            last_scroll_at: None,
335            pressed_ids: HashSet::new(),
336            ime_preedit: false,
337            key_pressed_active: None,
338            key_pressed_key: None,
339            last_focus: None,
340            held_keys: HashSet::new(),
341            held_mouse: HashSet::new(),
342            last_up: None,
343            last_down: None,
344            press_origin: None,
345            double_candidate: None,
346            long_press: None,
347            long_press_touch: None,
348            key_long_press: None,
349            suppress_next_click: false,
350            pending_click: None,
351            frame_cache: None,
352            cursor: None,
353            frame_cursor_override: None,
354            fullscreen_request: None,
355            shortcuts: repose_core::shortcuts::ShortcutState::new().without_global_fallback(),
356            textfield_states: HashMap::new(),
357            gamepads: HashMap::new(),
358            pending_rumble: Vec::new(),
359            sensors: HashMap::new(),
360        }
361    }
362
363    pub fn event_dispatchers(&self) -> crate::lifecycle::RuntimeDispatchers {
364        crate::lifecycle::RuntimeDispatchers {
365            lifecycle: self.lifecycle_events.clone(),
366            deeplink: self.deeplink_events.clone(),
367        }
368    }
369
370    pub fn set_lifecycle_callback(
371        &mut self,
372        callback: Box<dyn Fn(crate::lifecycle::AppLifecycle) + Send>,
373    ) {
374        self.lifecycle_events.set_callback(callback);
375    }
376
377    pub fn add_lifecycle_listener(
378        &mut self,
379        callback: Box<dyn Fn(crate::lifecycle::AppLifecycle) + Send>,
380    ) -> u64 {
381        self.lifecycle_events.add_listener(callback)
382    }
383
384    pub fn remove_lifecycle_listener(&mut self, id: u64) -> bool {
385        self.lifecycle_events.remove_listener(id)
386    }
387
388    pub fn current_lifecycle(&self) -> Option<crate::lifecycle::AppLifecycle> {
389        self.lifecycle_events.current()
390    }
391
392    pub fn push_lifecycle(&mut self, state: crate::lifecycle::AppLifecycle) {
393        self.lifecycle_events.push(state);
394    }
395
396    pub fn process_lifecycle(&mut self) {
397        self.lifecycle_events.process();
398    }
399
400    pub fn set_deeplink_callback(&mut self, callback: Box<dyn Fn(Vec<u8>) + Send>) {
401        self.deeplink_events.set_callback(callback);
402    }
403
404    pub fn add_deeplink_listener(&mut self, callback: Box<dyn Fn(Vec<u8>) + Send>) -> u64 {
405        self.deeplink_events.add_listener(callback)
406    }
407
408    pub fn remove_deeplink_listener(&mut self, id: u64) -> bool {
409        self.deeplink_events.remove_listener(id)
410    }
411
412    pub fn push_deeplink(&mut self, data: Vec<u8>) {
413        self.deeplink_events.push(data);
414    }
415
416    pub fn process_deeplinks(&mut self) {
417        self.deeplink_events.process();
418    }
419
420    /// Set the logical viewport size (in device pixels).
421    pub fn set_viewport(&mut self, width_px: u32, height_px: u32) {
422        self.sched.size = (width_px, height_px);
423    }
424
425    /// Set viewport size and DPI scale factor.
426    pub fn set_viewport_and_scale(&mut self, width_px: u32, height_px: u32, scale: f32) {
427        self.scale = scale;
428        self.sched.size = (width_px, height_px);
429    }
430
431    /// Advance animations. Call before `compose` each frame.
432    pub fn tick_animations(&self) {
433        repose_core::animation_driver::tick();
434    }
435
436    pub fn poll_gesture_timers(&mut self) {
437        self.poll_long_press();
438        self.flush_pending_click();
439        self.poll_key_long_press();
440        repose_ui::scrollbar_repeat::poll_track_repeat();
441    }
442
443    /// Compose and layout a frame, returning the output for rendering.
444    ///
445    /// Call `tick_animations` before this and `cache_frame` after (once you
446    /// have applied any host-specific overlays like the devtools inspector).
447    pub fn compose<F>(&mut self, root_fn: &mut F, render_ctx: &RenderContext) -> Frame
448    where
449        F: FnMut(&mut Scheduler, &RenderContext) -> View,
450    {
451        let _dnd_guard = self.dnd_context.enter();
452        let _event_scope = crate::lifecycle::enter_dispatchers(self.event_dispatchers());
453        self.poll_long_press();
454        self.flush_pending_click();
455        self.poll_key_long_press();
456        repose_ui::scrollbar_repeat::poll_track_repeat();
457
458        let size = self.sched.size;
459        let rc = render_ctx.clone();
460        let overlay = self.overlay.clone();
461        let mut compose_once = |this: &mut Self| {
462            let overlay = overlay.clone();
463            let ambient = this.overlay.clone();
464            let mut inner = |s: &mut Scheduler| {
465                repose_ui::overlay::with_ambient_overlay(ambient.clone(), || {
466                    let content = (root_fn)(s, &rc);
467                    overlay.host(Modifier::new().fill_max_size(), content)
468                })
469            };
470            match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
471                compose_frame_inner_with_ancestors(
472                    &mut this.sched,
473                    &mut inner,
474                    this.scale,
475                    size,
476                    this.hover_id,
477                    &this.hover_ancestors,
478                    &this.pressed_ids,
479                    &this.textfield_states,
480                )
481            })) {
482                Ok(frame) => frame,
483                Err(_) => {
484                    log::error!("compose panicked; presenting last good frame");
485                    this.frame_cache
486                        .as_deref()
487                        .cloned()
488                        .unwrap_or_else(|| Frame {
489                            scene: Default::default(),
490                            hit_regions: Vec::new(),
491                            semantics_nodes: Vec::new(),
492                            focus_chain: Vec::new(),
493                        })
494                }
495            }
496        };
497
498        let shortcut_state = self.shortcuts.clone();
499        let frame =
500            repose_core::shortcuts::with_runtime_state(&shortcut_state, || compose_once(self));
501
502        // Reconcile hover against the *new* hit list before presenting. If the
503        // hover target changed, recompose once so paint uses the correct
504        // Interactions.hover (eliminates 1-frame sticky/wrong hover).
505        let hover_before = self.hover_id;
506        self.reconcile_hover_from_mouse_pos(&frame);
507        if self.hover_id != hover_before {
508            // Refresh the retained leave map from the first frame so Leave on
509            // further changes still works (cache_frame does this fully).
510            self.hover_leave.clear();
511            for h in &frame.hit_regions {
512                if h.on_pointer_leave.is_some() {
513                    self.hover_leave
514                        .insert(h.id, repose_ui::HitRegionSnapshot::new(h));
515                }
516            }
517            // Hover should be stable: same geometry + same pointer. Do not loop.
518            return repose_core::shortcuts::with_runtime_state(&shortcut_state, || {
519                compose_once(self)
520            });
521        }
522        frame
523    }
524
525    /// Compose a frame and return structured output for the host.
526    pub fn frame(
527        &mut self,
528        mut root_fn: impl FnMut(&mut Scheduler, &RenderContext) -> View,
529        render_ctx: &RenderContext,
530    ) -> FrameOutput {
531        let (f, clipboard_text) = repose_core::clipboard::with_captured_clipboard(|| {
532            self.compose(&mut root_fn, render_ctx)
533        });
534
535        let wants_pointer = self.hover_id.is_some() || self.capture_id.is_some();
536
537        let focused_hit = self
538            .sched
539            .focused
540            .and_then(|fid| f.hit_regions.iter().find(|h| h.id == fid));
541        let ime_allowed = focused_hit.is_some_and(|hit| {
542            hit.on_ime.is_some()
543                || (hit.tf_enabled
544                    && !hit.tf_read_only
545                    && f.semantics_nodes.iter().any(|node| {
546                        node.id == hit.id && node.role == repose_core::semantics::Role::TextField
547                    }))
548        });
549
550        let ime_cursor_area = if ime_allowed {
551            focused_hit.map(|hit| {
552                let sf = self.scale as f64;
553                let origin = repose_ui::hit_region_transformed_origin(hit);
554                let local = repose_ui::hit_region_local_rect(hit);
555                (
556                    origin.x as f64 / sf,
557                    origin.y as f64 / sf,
558                    local.w as f64 / sf,
559                    local.h as f64 / sf,
560                )
561            })
562        } else {
563            None
564        };
565
566        let focused_is_textfield = ime_allowed
567            || self.sched.focused.is_some_and(|fid| {
568                f.hit_regions
569                    .iter()
570                    .any(|h| h.id == fid && h.tf_state_key.is_some())
571            });
572        let wants_keyboard = focused_is_textfield || self.ime_preedit;
573
574        let (ime_purpose, ime_auto_correct, ime_capitalization, keyboard_type) =
575            match (ime_allowed, focused_hit) {
576                (true, Some(hit)) => (
577                    hit.keyboard_type.ime_purpose_hint(),
578                    hit.auto_correct.unwrap_or(true),
579                    hit.capitalization,
580                    hit.keyboard_type,
581                ),
582                _ => (
583                    repose_core::ImePurposeHint::Normal,
584                    true,
585                    repose_core::KeyboardCapitalization::Unspecified,
586                    repose_core::KeyboardType::Unspecified,
587                ),
588            };
589
590        let declared = self.sched.cursor_override.take();
591        self.frame_cursor_override = declared.clone();
592        let platform = PlatformOutput {
593            cursor: declared.or_else(|| self.take_cursor_suggestion()),
594            ime_allowed,
595            ime_cursor_area,
596            clipboard_text,
597            ime_purpose,
598            ime_auto_correct,
599            ime_capitalization,
600            keyboard_type,
601            window_theme_dark: Some(repose_core::locals::theme().is_dark()),
602        };
603        FrameOutput {
604            scene: f.scene,
605            hit_regions: f.hit_regions,
606            semantics_nodes: f.semantics_nodes,
607            focus_chain: f.focus_chain,
608            platform,
609            wants_pointer,
610            wants_keyboard,
611        }
612    }
613
614    /// Store the composed frame for event hit testing.
615    pub fn cache_frame(&mut self, frame: impl Into<Rc<Frame>>) {
616        let frame = frame.into();
617        let _dnd_guard = self.dnd_context.enter();
618        if self.key_pressed_active.is_some_and(|id| {
619            self.sched.focused != Some(id)
620                || !frame
621                    .hit_regions
622                    .iter()
623                    .any(|hit| hit.id == id && !hit.disabled)
624        }) {
625            self.cancel_keyboard_press();
626        }
627        self.hover_leave.clear();
628        for hit in &frame.hit_regions {
629            if hit.on_pointer_leave.is_some() {
630                self.hover_leave
631                    .insert(hit.id, repose_ui::HitRegionSnapshot::new(hit));
632            }
633        }
634        self.frame_cache = Some(frame);
635        self.reconcile_cached_targets();
636    }
637
638    /// Post-compose host-agnostic bookkeeping.
639    /// this lazy-initializes text-field state for focus-requester paths, reconciles
640    /// hover against the new hit list, and publishes the frame to the DnD
641    /// registry. Replaces platform-local copies of this logic.
642    pub fn after_compose(&mut self, frame: &Frame, scale: f32) {
643        self.after_compose_shared(Rc::new(frame.clone()), scale);
644    }
645
646    pub fn after_compose_shared(&mut self, frame: Rc<Frame>, scale: f32) {
647        let _dnd_guard = self.dnd_context.enter();
648        ensure_all_tf_states_from_frame(&mut self.textfield_states, &frame);
649        self.prune_textfield_states(&frame);
650        self.ensure_focused_state_in_frame(&frame);
651        self.reconcile_hover_from_mouse_pos(&frame);
652        repose_core::dnd::set_shared_dnd_frame(Some(frame));
653        repose_core::dnd::set_dnd_scale(scale);
654    }
655
656    fn prune_textfield_states(&mut self, frame: &Frame) {
657        if self.textfield_states.is_empty() {
658            return;
659        }
660        let live: std::collections::HashSet<u64> = frame
661            .hit_regions
662            .iter()
663            .filter_map(|h| h.tf_state_key)
664            .collect();
665        self.textfield_states.retain(|k, _| live.contains(k));
666    }
667
668    fn end_textfield_drag(&mut self, id: u64) {
669        let key = self
670            .frame_cache
671            .as_ref()
672            .and_then(|frame| is_tf_hit(frame, id).then(|| tf_key_of(frame, id)));
673        if let Some(key) = key {
674            self.end_textfield_drag_key(key);
675        }
676    }
677
678    fn end_textfield_drag_key(&mut self, key: u64) {
679        if let Some(state) = self.textfield_states.get(&key) {
680            state.borrow_mut().end_drag();
681        }
682    }
683
684    /// Lazy-init the focused textfield's persistent state (FocusRequester
685    /// paths don't create it until first click). Resets the caret blink.
686    pub fn ensure_focused_textfield_state(&mut self) {
687        let Some(f) = self.frame_cache.clone() else {
688            return;
689        };
690        self.ensure_focused_state_in_frame(&f);
691    }
692
693    /// Shared helper: create a persistent `TextFieldState` for the focused
694    /// widget (if it is a textfield with a state key) and reset its caret
695    /// blink. No-op when the focused widget already has state.
696    fn ensure_focused_state_in_frame(&mut self, frame: &Frame) {
697        let Some(fid) = self.sched.focused else {
698            return;
699        };
700        if let Some(hit) = frame.hit_regions.iter().find(|h| h.id == fid)
701            && let Some(key) = hit.tf_state_key
702        {
703            let st = ensure_tf_state(&mut self.textfield_states, key, hit.tf_value.as_str());
704            st.borrow_mut().apply_controlled_value(&hit.tf_value);
705        }
706    }
707
708    /// Cache a composed [`FrameOutput`] for hit-testing: rebuilds the retained
709    /// hover-leave map, reconciles hover, lazy-initializes focused textfield
710    /// state, and publishes the DnD frame/scale to the input registry.
711    pub fn cache_from_output(&mut self, out: &FrameOutput) {
712        let frame = Rc::new(Frame {
713            scene: out.scene.clone(),
714            hit_regions: out.hit_regions.clone(),
715            semantics_nodes: out.semantics_nodes.clone(),
716            focus_chain: out.focus_chain.clone(),
717        });
718        self.cache_frame(frame.clone());
719        self.after_compose_shared(frame, self.scale);
720    }
721
722    /// One-shot host tick: advance animations, compose a frame, and publish
723    /// the result (hover reconciliation, focused textfield lazy-init, DnD
724    /// frame/scale) in a single call.
725    pub fn compose_frame_output<F>(
726        &mut self,
727        root: &mut F,
728        render_ctx: &RenderContext,
729    ) -> FrameOutput
730    where
731        F: FnMut(&mut Scheduler, &RenderContext) -> View,
732    {
733        self.tick_animations();
734        let out = self.frame(root, render_ctx);
735        self.cache_from_output(&out);
736        out
737    }
738
739    fn dispatch_pointer_to_targets(
740        &self,
741        kind: PointerEventKind,
742        pos: Vec2,
743        targets: &[repose_ui::HitRegionSnapshot],
744        touch: Option<u64>,
745    ) {
746        let (id, pkind) = match touch {
747            Some(tid) => (PointerId(tid), PointerKind::Touch),
748            None => (PointerId(0), PointerKind::Mouse),
749        };
750        let base = PointerEvent::new(id, pkind, kind, pos, 1.0, self.modifiers);
751        for target in targets {
752            let hit = target.hit();
753            let callback = match kind {
754                PointerEventKind::Down(_) => &hit.on_pointer_down,
755                PointerEventKind::Up(_) => &hit.on_pointer_up,
756                PointerEventKind::Move => &hit.on_pointer_move,
757                PointerEventKind::Cancel => &hit.on_pointer_cancel,
758                PointerEventKind::Enter | PointerEventKind::Leave => continue,
759            };
760            let Some(callback) = callback else {
761                continue;
762            };
763            let mut event = base.clone();
764            let (origin, local) = target.pointer_coordinates(pos);
765            event.origin = origin;
766            event.position = local;
767            callback(event);
768            if !matches!(kind, PointerEventKind::Cancel) && base.is_consumed() {
769                break;
770            }
771        }
772    }
773
774    fn dispatch_pointer_to_path(
775        &self,
776        kind: PointerEventKind,
777        pos: Vec2,
778        path: &[u64],
779        touch: Option<u64>,
780    ) {
781        let Some(frame) = &self.frame_cache else {
782            return;
783        };
784        let targets: Vec<_> = path
785            .iter()
786            .filter_map(|id| frame.hit_regions.iter().find(|hit| hit.id == *id))
787            .map(repose_ui::HitRegionSnapshot::new)
788            .collect();
789        self.dispatch_pointer_to_targets(kind, pos, &targets, touch);
790    }
791
792    /// Splits captured targets into live/stale against the current frame.
793    /// Live targets keep the snapshot captured at press time so the whole
794    /// gesture dispatches to the original closures (pointer-capture
795    /// semantics); stale targets (gone or disabled) keep their snapshot so
796    /// the cancel reaches the handlers that received the press.
797    fn refreshed_capture_targets(
798        &self,
799        targets: &[repose_ui::HitRegionSnapshot],
800    ) -> (
801        Vec<repose_ui::HitRegionSnapshot>,
802        Vec<repose_ui::HitRegionSnapshot>,
803    ) {
804        let Some(frame) = &self.frame_cache else {
805            return (targets.to_vec(), Vec::new());
806        };
807        let mut live = Vec::with_capacity(targets.len());
808        let mut stale = Vec::new();
809        for target in targets {
810            if frame
811                .hit_regions
812                .iter()
813                .any(|hit| hit.id == target.id() && !hit.disabled)
814            {
815                live.push(target.clone());
816            } else {
817                stale.push(target.clone());
818            }
819        }
820        (live, stale)
821    }
822
823    fn cancel_dnd_capture(&mut self, touch: Option<u64>, source_id: u64) -> bool {
824        if !self
825            .dnd_capture
826            .as_ref()
827            .is_some_and(|capture| capture.touch == touch && capture.source_id == source_id)
828        {
829            return false;
830        }
831        self.dnd_capture = None;
832        dnd::handle_drag_action(&DragAction::Cancel)
833    }
834
835    fn cancel_active_dnd(&mut self) -> bool {
836        if self.dnd_capture.take().is_none() {
837            return false;
838        }
839        dnd::handle_drag_action(&DragAction::Cancel)
840    }
841
842    fn reconcile_cached_targets(&mut self) {
843        let mouse_pos = Vec2 {
844            x: self.mouse_pos_px.0,
845            y: self.mouse_pos_px.1,
846        };
847        if let Some(targets) = self.mouse_targets.take() {
848            let capture_id = targets.first().map(|target| target.id());
849            let capture_key = targets.first().and_then(|target| target.hit().tf_state_key);
850            let (live, stale) = self.refreshed_capture_targets(&targets);
851            if !stale.is_empty() {
852                self.dispatch_pointer_to_targets(PointerEventKind::Cancel, mouse_pos, &stale, None);
853            }
854            let capture_stale =
855                capture_id.is_some_and(|id| stale.iter().any(|target| target.id() == id));
856            if capture_stale {
857                self.pressed_ids.remove(&capture_id.unwrap_or_default());
858                self.capture_id = None;
859                self.hit_path = None;
860                if let Some(key) = capture_key {
861                    self.end_textfield_drag_key(key);
862                }
863                if self
864                    .long_press
865                    .is_some_and(|(id, _, _, _)| Some(id) == capture_id)
866                {
867                    self.long_press = None;
868                    self.long_press_touch = None;
869                }
870                self.cancel_dnd_capture(None, capture_id.unwrap_or_default());
871            } else {
872                self.mouse_targets = (!live.is_empty()).then_some(live);
873                self.hit_path = self
874                    .mouse_targets
875                    .as_ref()
876                    .map(|targets| targets.iter().map(|target| target.id()).collect());
877            }
878            if !stale.is_empty() {
879                request_frame();
880            }
881        }
882
883        let mut touch_ids: Vec<u64> = self.touch_targets.keys().copied().collect();
884        touch_ids.sort_unstable();
885        let mut changed = false;
886        for tid in touch_ids {
887            let Some(targets) = self.touch_targets.remove(&tid) else {
888                continue;
889            };
890            let pos = self.touch_positions.get(&tid).copied().unwrap_or(mouse_pos);
891            let old_capture = self.touch_presses.get(&tid).map(|state| state.capture_id);
892            let old_capture_key = targets.first().and_then(|target| target.hit().tf_state_key);
893            let (live, stale) = self.refreshed_capture_targets(&targets);
894            if !stale.is_empty() {
895                self.dispatch_pointer_to_targets(PointerEventKind::Cancel, pos, &stale, Some(tid));
896                changed = true;
897            }
898            let capture_stale =
899                old_capture.is_some_and(|id| stale.iter().any(|target| target.id() == id));
900            if capture_stale {
901                self.touch_targets.remove(&tid);
902                self.touch_paths.remove(&tid);
903                self.touch_presses.remove(&tid);
904                self.suppressed_touch_clicks.remove(&tid);
905                if let Some(key) = old_capture_key {
906                    self.end_textfield_drag_key(key);
907                }
908                if self.long_press_touch == Some(tid)
909                    && old_capture.is_some_and(|id| {
910                        self.long_press
911                            .is_some_and(|(long_id, _, _, _)| long_id == id)
912                    })
913                {
914                    self.long_press = None;
915                    self.long_press_touch = None;
916                }
917                if self.touch_primary == Some(tid) {
918                    self.touch_primary = None;
919                    self.double_candidate = None;
920                    self.pending_click = None;
921                    self.last_up = None;
922                    self.last_down = None;
923                    self.press_origin = None;
924                    self.suppress_next_click = false;
925                    self.scroll_capture_id = None;
926                    if let Some(source_id) = old_capture {
927                        self.cancel_dnd_capture(Some(tid), source_id);
928                    }
929                }
930            } else if live.is_empty() {
931                self.touch_paths.remove(&tid);
932                self.touch_presses.remove(&tid);
933                self.suppressed_touch_clicks.remove(&tid);
934            } else {
935                self.touch_targets.insert(tid, live.clone());
936                self.touch_paths
937                    .insert(tid, live.iter().map(|target| target.id()).collect());
938            }
939        }
940        if changed {
941            self.rebuild_pressed_ids();
942            request_frame();
943        }
944    }
945
946    /// Process a pointer-move event. Returns cursor suggestion.
947    pub fn handle_pointer_move(&mut self, pos: Vec2) -> PointerMoveResult {
948        self.handle_touch_move(None, pos)
949    }
950
951    /// Touch move with a stable finger id (same pairing as
952    /// [`Self::handle_touch_press`]).
953    ///
954    /// Touch never hovers: Compose feeds `collectIsHovered` from pointer
955    /// *hover* events only, so finger motion must not raise hover state
956    /// layers or the ripple's hover circle. `pointer_inside` and
957    /// `hover_id` stay mouse-owned (driven by `CursorMoved`/`CursorEnter`).
958    pub fn handle_touch_move(&mut self, touch: Option<u64>, pos: Vec2) -> PointerMoveResult {
959        let _event_scope = crate::lifecycle::enter_dispatchers(self.event_dispatchers());
960        let _dnd_guard = self.dnd_context.enter();
961        self.mouse_pos_px = (pos.x, pos.y);
962        if let Some(tid) = touch {
963            self.touch_positions.insert(tid, pos);
964        }
965        let from_mouse = touch.is_none();
966        if from_mouse {
967            self.pointer_inside = true;
968            self.sched.pointer_pos_px = Some((pos.x, pos.y));
969        }
970
971        let owns_dnd_move = self
972            .dnd_capture
973            .as_ref()
974            .is_some_and(|capture| capture.touch == touch);
975        let dnd_move_consumed = owns_dnd_move
976            && dnd::handle_drag_action(&DragAction::Move {
977                position: pos,
978                modifiers: self.modifiers,
979            });
980        if dnd_move_consumed {
981            self.cancel_keyboard_press();
982            request_frame();
983        }
984
985        let Some(f) = &self.frame_cache else {
986            return PointerMoveResult {
987                cursor: self.move_cursor_suggestion(),
988                hover_id: None,
989            };
990        };
991
992        let active_long_press_matches = match (touch, self.long_press_touch) {
993            (Some(tid), Some(active)) => tid == active,
994            (None, None) => true,
995            _ => false,
996        };
997        if active_long_press_matches && let Some((_, _, x0, y0)) = self.long_press {
998            let slop = LONG_PRESS_SLOP_DP * self.scale;
999            let dx = pos.x - x0;
1000            let dy = pos.y - y0;
1001            if dx * dx + dy * dy > slop * slop {
1002                self.long_press = None;
1003                self.long_press_touch = None;
1004            }
1005        }
1006
1007        // Cancel the long press once the pointer leaves the element's bounds
1008        if active_long_press_matches
1009            && self.long_press.is_some()
1010            && let Some(lid) = self.long_press.map(|(id, _, _, _)| id)
1011            && f.hit_regions
1012                .iter()
1013                .find(|h| h.id == lid)
1014                .is_none_or(|h| !repose_ui::hit_region_contains(h, pos))
1015        {
1016            self.long_press = None;
1017            self.long_press_touch = None;
1018        }
1019
1020        let text_capture = match touch {
1021            Some(tid) => self.touch_presses.get(&tid).map(|state| state.capture_id),
1022            None => self.capture_id,
1023        };
1024        let owns_text_drag = touch.is_none() || self.touch_primary == touch;
1025        if owns_text_drag
1026            && let Some(cid) = text_capture
1027            && is_tf_hit(f, cid)
1028            && let Some(hit) = f.hit_regions.iter().find(|h| h.id == cid)
1029        {
1030            let key = tf_key_of(f, cid);
1031            if let Some(st_rc) = self.textfield_states.get(&key) {
1032                let mut st = st_rc.borrow_mut();
1033                let local = repose_ui::hit_region_to_local(hit, pos);
1034                let local_rect = repose_ui::hit_region_local_rect(hit);
1035                let content_origin = hit
1036                    .tf_content_origin
1037                    .map(|(x, y)| repose_ui::hit_region_to_local(hit, Vec2 { x, y }))
1038                    .unwrap_or(Vec2 {
1039                        x: local_rect.x,
1040                        y: local_rect.y,
1041                    });
1042                let content_x = (local.x - content_origin.x + st.scroll_offset).max(0.0);
1043                let content_y = (local.y - content_origin.y + st.scroll_offset_y).max(0.0);
1044                let metrics = repose_ui::textfield::textfield_metrics(hit);
1045                let wrap_w = st.inner_width.max(1.0);
1046                let idx = if hit.tf_multiline {
1047                    index_for_xy_bytes_vt(&st, &metrics, wrap_w, content_x, content_y)
1048                } else {
1049                    index_for_x_bytes_vt(&st, &metrics, content_x)
1050                };
1051                st.drag_to(idx);
1052            }
1053        }
1054
1055        let top = repose_ui::hit_test_enabled_frame(f, pos);
1056
1057        if from_mouse {
1058            self.cursor = top
1059                .and_then(|h| h.cursor.clone())
1060                .or(Some(CursorIcon::Default));
1061            if dnd_move_consumed && dnd::is_dragging() {
1062                self.cursor = Some(CursorIcon::Grabbing);
1063            }
1064
1065            let new_hover = top.map(|h| h.id);
1066
1067            let old_chain = hover_chain_for(Some(f), self.hover_id);
1068            let new_chain = hover_chain_for(Some(f), new_hover);
1069            if new_chain != old_chain {
1070                dispatch_hover_change_bubbled(
1071                    Some(f),
1072                    &self.hover_leave,
1073                    &mut self.hover_id,
1074                    &mut self.hover_ancestors,
1075                    new_hover,
1076                    pos,
1077                    self.modifiers,
1078                );
1079                request_frame();
1080            }
1081        }
1082
1083        if let Some(tid) = touch {
1084            if let Some(targets) = self.touch_targets.get(&tid) {
1085                self.dispatch_pointer_to_targets(PointerEventKind::Move, pos, targets, Some(tid));
1086            } else if let Some(path) = self.touch_paths.get(&tid).cloned() {
1087                self.dispatch_pointer_to_path(PointerEventKind::Move, pos, &path, Some(tid));
1088            }
1089            return PointerMoveResult {
1090                cursor: self.move_cursor_suggestion(),
1091                hover_id: self.hover_id,
1092            };
1093        }
1094
1095        if let Some(targets) = &self.mouse_targets {
1096            self.dispatch_pointer_to_targets(PointerEventKind::Move, pos, targets, None);
1097        } else if let Some(path) = self.hit_path.clone() {
1098            self.dispatch_pointer_to_path(PointerEventKind::Move, pos, &path, None);
1099        }
1100        if self.hit_path.is_none()
1101            && let Some(h) = top
1102            && let Some(cb) = &h.on_pointer_move
1103        {
1104            let (id, kind) = match touch {
1105                Some(tid) => (PointerId(tid), PointerKind::Touch),
1106                None => (PointerId(0), PointerKind::Mouse),
1107            };
1108            let mut pe =
1109                PointerEvent::new(id, kind, PointerEventKind::Move, pos, 1.0, self.modifiers);
1110            let (origin, local) = repose_ui::hit_region_pointer_coordinates(h, pos);
1111            pe.origin = origin;
1112            pe.position = local;
1113            cb(pe);
1114        }
1115
1116        PointerMoveResult {
1117            cursor: self.move_cursor_suggestion(),
1118            hover_id: self.hover_id,
1119        }
1120    }
1121
1122    /// Process a pointer button press. Returns focus/capture info.
1123    pub fn handle_pointer_press(
1124        &mut self,
1125        pos: Vec2,
1126        button: PointerButton,
1127    ) -> PointerButtonResult {
1128        self.handle_touch_press(None, pos, button)
1129    }
1130
1131    /// Touch press with a stable finger id: same hit-path dispatch as
1132    /// the mouse press, but the event carries `PointerKind::Touch` +
1133    /// the finger id, so game viewports stage per-finger contacts
1134    /// instead of one shared mouse point (GML `device_mouse_*` parity).
1135    pub fn handle_touch_press(
1136        &mut self,
1137        touch: Option<u64>,
1138        pos: Vec2,
1139        button: PointerButton,
1140    ) -> PointerButtonResult {
1141        let _event_scope = crate::lifecycle::enter_dispatchers(self.event_dispatchers());
1142        let _dnd_guard = self.dnd_context.enter();
1143        self.mouse_pos_px = (pos.x, pos.y);
1144        if let Some(tid) = touch {
1145            self.touch_positions.insert(tid, pos);
1146        }
1147        if touch.is_none() {
1148            self.sched.pointer_pos_px = Some((pos.x, pos.y));
1149        }
1150        if touch.is_none() {
1151            self.held_mouse.insert(button);
1152        }
1153        let _ = repose_core::request_input_mode(repose_core::InputMode::Touch);
1154
1155        if touch.is_none() && (self.capture_id.is_some() || self.hit_path.is_some()) {
1156            self.handle_pointer_cancel();
1157        }
1158        // A new press supersedes any held track-press repeat for this pointer;
1159        // the dispatch below may begin a fresh one.
1160        repose_ui::scrollbar_repeat::cancel_track_repeat_for_touch(touch);
1161        if let Some(tid) = touch
1162            && self.touch_presses.contains_key(&tid)
1163        {
1164            self.handle_touch_cancel(Some(tid));
1165        }
1166        if let Some(tid) = touch {
1167            self.touch_positions.insert(tid, pos);
1168        }
1169
1170        let Some(frame) = self.frame_cache.clone() else {
1171            return PointerButtonResult {
1172                focused: None,
1173                capture_id: None,
1174                consumed: false,
1175                needs_a11y_announce: false,
1176                clicked_id: None,
1177            };
1178        };
1179        let f = &frame;
1180
1181        let mut result = PointerButtonResult {
1182            focused: None,
1183            capture_id: None,
1184            consumed: false,
1185            needs_a11y_announce: false,
1186            clicked_id: None,
1187        };
1188        let primary_pointer = match touch {
1189            Some(tid) => {
1190                if self.touch_primary.is_none() {
1191                    self.touch_primary = Some(tid);
1192                }
1193                self.touch_primary == Some(tid)
1194            }
1195            None => true,
1196        };
1197
1198        if primary_pointer {
1199            self.cancel_keyboard_press();
1200        }
1201
1202        let path = repose_ui::hit_test_frame_path(f, pos);
1203        if let Some(&capture_id) = path.first()
1204            && let Some(hit) = f.hit_regions.iter().find(|hit| hit.id == capture_id)
1205        {
1206            if touch.is_none() {
1207                self.hit_path = Some(path.clone());
1208            }
1209
1210            if touch.is_none() || primary_pointer {
1211                dnd::handle_drag_action(&DragAction::Press {
1212                    position: pos,
1213                    capture_id: hit.id,
1214                    kind: match touch {
1215                        Some(_) => PointerKind::Touch,
1216                        None => PointerKind::Mouse,
1217                    },
1218                    modifiers: self.modifiers,
1219                });
1220                self.dnd_capture = Some(DndPointerCapture {
1221                    touch,
1222                    source_id: hit.id,
1223                });
1224            }
1225
1226            if touch.is_none() {
1227                self.capture_id = Some(hit.id);
1228            }
1229            result.capture_id = Some(hit.id);
1230            result.consumed = true;
1231            let targets: Vec<_> = path
1232                .iter()
1233                .filter_map(|id| f.hit_regions.iter().find(|hit| hit.id == *id))
1234                .map(repose_ui::HitRegionSnapshot::new)
1235                .collect();
1236            if let Some(tid) = touch {
1237                self.touch_paths.insert(tid, path.clone());
1238                self.touch_targets.insert(tid, targets);
1239                self.touch_presses.insert(
1240                    tid,
1241                    TouchPressState {
1242                        capture_id: hit.id,
1243                        pressed_ids: HashSet::from([hit.id]),
1244                    },
1245                );
1246            } else {
1247                self.mouse_targets = Some(targets);
1248            }
1249
1250            // A new press cancels a still-pending delayed single click only
1251            // when it qualifies as the second tap of a double click on the
1252            // same element (Compose detectTapGestures).
1253            if primary_pointer {
1254                self.last_down = Some((hit.id, web_time::Instant::now()));
1255                self.press_origin = Some((hit.id, pos.x, pos.y));
1256                // The second DOWN must land within
1257                // [doubleTapMinTimeMillis, doubleTapTimeoutMillis] after the first
1258                // tap's UP, and within tap slop of it. Compose requires both: two
1259                // taps far apart are two taps, not a double tap.
1260                self.double_candidate = if hit.on_double_click.is_some()
1261                    && self.last_up.is_some_and(|(pid, t0, ux, uy)| {
1262                        pid == hit.id
1263                            && !moved_past_slop(ux, uy, pos, TAP_SLOP_DP, self.scale)
1264                            && self.last_down.is_some_and(|(did, dt)| {
1265                                did == hit.id
1266                                    && dt.duration_since(t0).as_millis() >= DOUBLE_TAP_MIN_MS
1267                                    && dt.duration_since(t0).as_millis() <= DOUBLE_CLICK_MS
1268                            })
1269                    }) {
1270                    Some(hit.id)
1271                } else {
1272                    None
1273                };
1274                if self.double_candidate.is_some() {
1275                    // The pair is a double tap: drop the deferred first click.
1276                    self.pending_click = None;
1277                } else if self
1278                    .pending_click
1279                    .as_ref()
1280                    .is_some_and(|(pid, ..)| *pid == hit.id)
1281                {
1282                    // A new press on the same element that cannot be the second
1283                    // half of a double tap settles the deferred click. It has to
1284                    // fire now, or this press would overwrite it in the single
1285                    // pending slot and the tap would be lost.
1286                    if let Some((_, _, cb)) = self.pending_click.take() {
1287                        cb();
1288                    }
1289                }
1290
1291                if let PointerButton::Primary = button {
1292                    self.long_press = if hit.on_long_click.is_some() {
1293                        Some((hit.id, web_time::Instant::now(), pos.x, pos.y))
1294                    } else {
1295                        None
1296                    };
1297                    self.long_press_touch = touch;
1298                    self.suppress_next_click = false;
1299                }
1300            }
1301
1302            if primary_pointer
1303                && hit.tf_enabled
1304                && (hit.tf_state_key.is_some() || is_textfield_in_frame(f, hit.id))
1305            {
1306                let key = tf_key_of(f, hit.id);
1307                let seed = hit.tf_value.as_str();
1308                let st_rc = ensure_tf_state(&mut self.textfield_states, key, seed);
1309                {
1310                    let mut st = st_rc.borrow_mut();
1311                    // Sync text only; never place-at-end here.
1312                    st.apply_controlled_value(seed);
1313
1314                    if st.inner_width <= 0.0 {
1315                        let local_rect = repose_ui::hit_region_local_rect(hit);
1316                        st.set_inner_width(local_rect.w.max(1.0));
1317                        st.set_inner_height(local_rect.h.max(1.0));
1318                    }
1319
1320                    let local = repose_ui::hit_region_to_local(hit, pos);
1321                    let local_rect = repose_ui::hit_region_local_rect(hit);
1322                    let content_origin = hit
1323                        .tf_content_origin
1324                        .map(|(x, y)| repose_ui::hit_region_to_local(hit, Vec2 { x, y }))
1325                        .unwrap_or(Vec2 {
1326                            x: local_rect.x,
1327                            y: local_rect.y,
1328                        });
1329                    let content_x = (local.x - content_origin.x + st.scroll_offset).max(0.0);
1330                    let content_y = (local.y - content_origin.y + st.scroll_offset_y).max(0.0);
1331                    let metrics = repose_ui::textfield::textfield_metrics(hit);
1332                    let wrap_w = st.inner_width.max(1.0);
1333
1334                    let idx = if hit.tf_multiline {
1335                        index_for_xy_bytes_vt(&st, &metrics, wrap_w, content_x, content_y)
1336                    } else {
1337                        index_for_x_bytes_vt(&st, &metrics, content_x)
1338                    };
1339                    st.handle_pointer_down(idx, (pos.x, pos.y), self.modifiers.shift);
1340                    // caret was placed by pointer this gesture
1341                }
1342            }
1343
1344            self.pressed_ids.insert(hit.id);
1345
1346            if primary_pointer && hit.focusable {
1347                if self.sched.focused != Some(hit.id) {
1348                    self.finish_compositions();
1349                }
1350                self.sched.focused = Some(hit.id);
1351                result.focused = Some(hit.id);
1352                if hit.tf_state_key.is_some() {
1353                    let key = tf_key_of(f, hit.id);
1354                    let st =
1355                        ensure_tf_state(&mut self.textfield_states, key, hit.tf_value.as_str());
1356                    let mut s = st.borrow_mut();
1357                    s.apply_controlled_value(&hit.tf_value);
1358                    s.reset_caret_blink();
1359                }
1360            }
1361
1362            let targets = if touch.is_some() {
1363                self.touch_targets.get(&touch.unwrap()).cloned()
1364            } else {
1365                self.mouse_targets.clone()
1366            };
1367            if let Some(targets) = targets {
1368                self.dispatch_pointer_to_targets(
1369                    PointerEventKind::Down(button),
1370                    pos,
1371                    &targets,
1372                    touch,
1373                );
1374            }
1375
1376            request_frame();
1377        } else if primary_pointer {
1378            if touch.is_none() {
1379                self.hit_path = None;
1380                self.mouse_targets = None;
1381                self.capture_id = None;
1382                self.pressed_ids.clear();
1383            } else if let Some(tid) = touch {
1384                self.touch_paths.remove(&tid);
1385                self.touch_targets.remove(&tid);
1386                self.touch_positions.remove(&tid);
1387            }
1388            self.finish_compositions();
1389            self.sched.focused = None;
1390            request_frame();
1391        }
1392
1393        result
1394    }
1395
1396    /// Process a pointer button release.
1397    pub fn handle_pointer_release(
1398        &mut self,
1399        pos: Vec2,
1400        button: PointerButton,
1401    ) -> PointerButtonResult {
1402        self.handle_touch_release(None, pos, button)
1403    }
1404
1405    /// Touch release with a stable finger id (same pairing as
1406    /// [`Self::handle_touch_press`]).
1407    pub fn handle_touch_release(
1408        &mut self,
1409        touch: Option<u64>,
1410        pos: Vec2,
1411        button: PointerButton,
1412    ) -> PointerButtonResult {
1413        let _event_scope = crate::lifecycle::enter_dispatchers(self.event_dispatchers());
1414        let _dnd_guard = self.dnd_context.enter();
1415        self.mouse_pos_px = (pos.x, pos.y);
1416        if let Some(tid) = touch {
1417            self.touch_positions.insert(tid, pos);
1418        } else {
1419            self.sched.pointer_pos_px = Some((pos.x, pos.y));
1420            self.held_mouse.remove(&button);
1421        }
1422        // The pointer is up: any track-press repeat it drove ends here even if
1423        // the track hit itself is gone (recomposed away) and never sees Up.
1424        repose_ui::scrollbar_repeat::cancel_track_repeat_for_touch(touch);
1425        let mut result = PointerButtonResult {
1426            focused: self.sched.focused,
1427            capture_id: self.capture_id,
1428            consumed: false,
1429            needs_a11y_announce: false,
1430            clicked_id: None,
1431        };
1432        if let Some(tid) = touch {
1433            return self.finish_touch_release(tid, pos, button, result);
1434        }
1435
1436        let owns_dnd_release = self.dnd_capture.as_ref().is_some_and(|capture| {
1437            capture.touch.is_none() && Some(capture.source_id) == self.capture_id
1438        });
1439        let drag_consumed = if owns_dnd_release {
1440            self.dnd_capture = None;
1441            dnd::handle_drag_action(&DragAction::Release {
1442                position: pos,
1443                modifiers: self.modifiers,
1444            })
1445        } else {
1446            false
1447        };
1448        if let Some(targets) = &self.mouse_targets {
1449            self.dispatch_pointer_to_targets(PointerEventKind::Up(button), pos, targets, None);
1450            result.consumed = true;
1451        } else if let Some(path) = &self.hit_path {
1452            self.dispatch_pointer_to_path(PointerEventKind::Up(button), pos, path, None);
1453            result.consumed = true;
1454        }
1455        if let Some(cid) = self.capture_id {
1456            self.pressed_ids.remove(&cid);
1457            self.end_textfield_drag(cid);
1458        }
1459        if drag_consumed {
1460            self.capture_id = None;
1461            self.hit_path = None;
1462            self.mouse_targets = None;
1463            request_frame();
1464            result.consumed = true;
1465            return result;
1466        }
1467
1468        let f = match &self.frame_cache {
1469            Some(f) => f.clone(),
1470            None => {
1471                self.capture_id = None;
1472                self.hit_path = None;
1473                self.mouse_targets = None;
1474                self.pressed_ids.clear();
1475                return result;
1476            }
1477        };
1478
1479        // Long-press resolution: `poll_long_press` normally fires on timeout when held.
1480        if self.long_press_touch.is_none()
1481            && let Some((lid, t0, _, _)) = self.long_press.take()
1482            && Some(lid) == self.capture_id
1483            && t0.elapsed().as_millis() >= LONG_PRESS_MS
1484            && let Some(hit) = f.hit_regions.iter().find(|h| h.id == lid && !h.disabled)
1485            && let Some(cb) = &hit.on_long_click
1486        {
1487            cb();
1488            self.suppress_next_click = true;
1489            self.pending_click = None;
1490            result.clicked_id = Some(lid);
1491            result.needs_a11y_announce = true;
1492            result.consumed = true;
1493        }
1494
1495        // A press that travelled past tap slop is a drag, not a tap, so nothing
1496        // below may activate the element. Compose `detectTapGestures` drops the
1497        // tap on the same condition; without this, releasing after a drag fires
1498        // onClick anyway.
1499        let dragged = self.drag_cancelled_tap(self.capture_id, pos);
1500        if dragged {
1501            self.pending_click = None;
1502        }
1503
1504        if self.double_candidate.is_none()
1505            && !dragged
1506            && !self.suppress_next_click
1507            && let Some(cid) = self.capture_id
1508            && let Some(hit) = f.hit_regions.iter().find(|h| h.id == cid && !h.disabled)
1509            && repose_ui::hit_region_contains(hit, pos)
1510        {
1511            let now = web_time::Instant::now();
1512            // With onDoubleTap present, single
1513            // clicks are delayed until the double-tap window elapses.
1514            if hit.on_double_click.is_some() {
1515                if let Some(cb) = hit.on_click.clone() {
1516                    self.pending_click = Some((cid, now, cb));
1517                }
1518                self.last_up = Some((cid, now, pos.x, pos.y));
1519                result.consumed = true;
1520                request_frame(); // need another frame to flush pending
1521            } else {
1522                if let Some(cb) = &hit.on_click {
1523                    cb();
1524                }
1525                self.last_up = Some((cid, now, pos.x, pos.y));
1526                result.clicked_id = Some(cid);
1527                result.needs_a11y_announce = true;
1528                result.consumed = true;
1529            }
1530        }
1531        self.suppress_next_click = false;
1532
1533        // Double-click resolution. The second DOWN (handle_pointer_down)
1534        // qualifies the pair; the second UP confirms it. A canceled second tap
1535        // (moved out of bounds, or dragged) falls back to the first tap's
1536        // onClick, unless it dragged in which case nothing activates.
1537        if let Some(dc) = self.double_candidate.take() {
1538            self.pending_click = None;
1539            self.last_up = None;
1540            self.last_down = None;
1541            if dragged {
1542                // The second press turned into a drag: the pair is not a double
1543                // tap and the deferred first click is dropped with it.
1544            } else if self.capture_id == Some(dc)
1545                && let Some(hit) = f.hit_regions.iter().find(|h| h.id == dc && !h.disabled)
1546                && repose_ui::hit_region_contains(hit, pos)
1547            {
1548                if let Some(cb) = &hit.on_double_click {
1549                    cb();
1550                }
1551                result.clicked_id = Some(dc);
1552                result.needs_a11y_announce = true;
1553                result.consumed = true;
1554            } else if self.capture_id == Some(dc)
1555                && let Some(hit) = f.hit_regions.iter().find(|h| h.id == dc && !h.disabled)
1556            {
1557                // Second tap canceled -> the first tap counts as a click.
1558                if let Some(cb) = &hit.on_click {
1559                    cb();
1560                }
1561                result.clicked_id = Some(dc);
1562                result.needs_a11y_announce = true;
1563                result.consumed = true;
1564            }
1565        }
1566
1567        self.capture_id = None;
1568        self.hit_path = None;
1569        self.mouse_targets = None;
1570        self.press_origin = None;
1571        request_frame();
1572        result
1573    }
1574
1575    fn finish_touch_release(
1576        &mut self,
1577        tid: u64,
1578        pos: Vec2,
1579        button: PointerButton,
1580        mut result: PointerButtonResult,
1581    ) -> PointerButtonResult {
1582        let capture_id = self
1583            .touch_presses
1584            .remove(&tid)
1585            .map(|state| state.capture_id);
1586        result.capture_id = capture_id;
1587        let targets = self.touch_targets.remove(&tid);
1588        if let Some(targets) = &targets {
1589            self.dispatch_pointer_to_targets(PointerEventKind::Up(button), pos, targets, Some(tid));
1590            result.consumed = true;
1591        } else if let Some(path) = self.touch_paths.remove(&tid) {
1592            self.dispatch_pointer_to_path(PointerEventKind::Up(button), pos, &path, Some(tid));
1593            result.consumed = true;
1594        }
1595        self.touch_paths.remove(&tid);
1596        self.touch_positions.remove(&tid);
1597        let primary = self.touch_primary == Some(tid);
1598        if let Some(cid) = capture_id {
1599            self.end_textfield_drag(cid);
1600        }
1601        let owns_dnd_release = self.dnd_capture.as_ref().is_some_and(|capture| {
1602            capture.touch == Some(tid) && Some(capture.source_id) == capture_id
1603        });
1604        let drag_consumed = if owns_dnd_release {
1605            self.dnd_capture = None;
1606            dnd::handle_drag_action(&DragAction::Release {
1607                position: pos,
1608                modifiers: self.modifiers,
1609            })
1610        } else {
1611            false
1612        };
1613        let suppressed = self.suppressed_touch_clicks.remove(&tid);
1614        if primary
1615            && !drag_consumed
1616            && !suppressed
1617            && let Some(frame) = self.frame_cache.clone()
1618            && let Some(cid) = capture_id
1619        {
1620            if self.long_press_touch == Some(tid)
1621                && let Some((lid, t0, _, _)) = self.long_press.take()
1622                && lid == cid
1623                && t0.elapsed().as_millis() >= LONG_PRESS_MS
1624                && let Some(hit) = frame
1625                    .hit_regions
1626                    .iter()
1627                    .find(|hit| hit.id == lid && !hit.disabled)
1628                && let Some(callback) = &hit.on_long_click
1629            {
1630                callback();
1631                self.suppress_next_click = true;
1632                self.pending_click = None;
1633                result.clicked_id = Some(lid);
1634                result.needs_a11y_announce = true;
1635                result.consumed = true;
1636            }
1637
1638            // Same drag-cancels-tap rule as the mouse path.
1639            let dragged = self.drag_cancelled_tap(capture_id, pos);
1640            if dragged {
1641                self.pending_click = None;
1642            }
1643
1644            if self.double_candidate.is_none()
1645                && !dragged
1646                && !self.suppress_next_click
1647                && let Some(hit) = frame
1648                    .hit_regions
1649                    .iter()
1650                    .find(|hit| hit.id == cid && !hit.disabled)
1651                && repose_ui::hit_region_contains(hit, pos)
1652            {
1653                let now = web_time::Instant::now();
1654                if hit.on_double_click.is_some() {
1655                    if let Some(callback) = hit.on_click.clone() {
1656                        self.pending_click = Some((cid, now, callback));
1657                    }
1658                    self.last_up = Some((cid, now, pos.x, pos.y));
1659                    result.consumed = true;
1660                    request_frame();
1661                } else {
1662                    if let Some(callback) = &hit.on_click {
1663                        callback();
1664                    }
1665                    self.last_up = Some((cid, now, pos.x, pos.y));
1666                    result.clicked_id = Some(cid);
1667                    result.needs_a11y_announce = true;
1668                    result.consumed = true;
1669                }
1670            }
1671            self.suppress_next_click = false;
1672
1673            if let Some(double_id) = self.double_candidate.take() {
1674                self.pending_click = None;
1675                self.last_up = None;
1676                self.last_down = None;
1677                if let Some(hit) = frame
1678                    .hit_regions
1679                    .iter()
1680                    .find(|hit| hit.id == double_id && !hit.disabled)
1681                    && !dragged
1682                {
1683                    if repose_ui::hit_region_contains(hit, pos) {
1684                        if let Some(callback) = &hit.on_double_click {
1685                            callback();
1686                        }
1687                    } else if let Some(callback) = &hit.on_click {
1688                        callback();
1689                    }
1690                    result.clicked_id = Some(double_id);
1691                    result.needs_a11y_announce = true;
1692                    result.consumed = true;
1693                }
1694            }
1695        }
1696        if self.long_press_touch == Some(tid) {
1697            self.long_press = None;
1698            self.long_press_touch = None;
1699        }
1700        if primary {
1701            self.press_origin = None;
1702        }
1703        self.rebuild_pressed_ids();
1704        if primary {
1705            self.touch_primary = None;
1706        }
1707        request_frame();
1708        result
1709    }
1710
1711    /// Did the press behind `capture_id` travel past tap slop? When it did, the
1712    /// release is a drag and must not activate the element (Compose
1713    /// `detectTapGestures`).
1714    fn drag_cancelled_tap(&self, capture_id: Option<u64>, pos: Vec2) -> bool {
1715        capture_id.is_some_and(|cid| {
1716            self.press_origin.is_some_and(|(oid, x0, y0)| {
1717                oid == cid && moved_past_slop(x0, y0, pos, TAP_SLOP_DP, self.scale)
1718            })
1719        })
1720    }
1721
1722    fn cancel_keyboard_press(&mut self) {
1723        let active = self.key_pressed_active.take();
1724        self.key_pressed_key = None;
1725        self.key_long_press = None;
1726        let Some(active) = active else {
1727            return;
1728        };
1729        self.pressed_ids.remove(&active);
1730        let hit = self
1731            .frame_cache
1732            .as_ref()
1733            .and_then(|frame| frame.hit_regions.iter().find(|hit| hit.id == active))
1734            .cloned();
1735        if let Some(hit) = hit
1736            && let Some(source) = &hit.interaction_source
1737        {
1738            let press_id = source.collect_last_press_id().unwrap_or(0);
1739            source.to_mutable().emit(Interaction::Cancel(press_id));
1740        }
1741    }
1742
1743    fn rebuild_pressed_ids(&mut self) {
1744        self.pressed_ids = self
1745            .touch_presses
1746            .values()
1747            .flat_map(|state| state.pressed_ids.iter().copied())
1748            .collect();
1749        if let Some(active) = self.key_pressed_active {
1750            self.pressed_ids.insert(active);
1751        }
1752    }
1753
1754    pub fn suppress_touch_click(&mut self, tid: u64) {
1755        self.suppressed_touch_clicks.insert(tid);
1756        if self.touch_primary == Some(tid) {
1757            self.suppress_next_click = true;
1758            self.long_press = None;
1759            self.pending_click = None;
1760            self.double_candidate = None;
1761        }
1762    }
1763
1764    /// Does the touch captured by `tid` drive the gesture through pointer
1765    /// moves, so an ancestor scroll container must not also scroll?
1766    ///
1767    /// True while a drag session is live (long-press drag and drop) or when
1768    /// the pressed region handles `on_pointer_move` (splitters, sliders,
1769    /// swipes, `draggable`). Compose's rule: a child that consumes the drag
1770    /// wins over the parent `scrollable`.
1771    pub fn touch_owns_drag(&self, tid: u64) -> bool {
1772        if dnd::is_dragging() {
1773            return true;
1774        }
1775        let Some(capture_id) = self.touch_presses.get(&tid).map(|state| state.capture_id) else {
1776            return false;
1777        };
1778        self.frame_cache.as_ref().is_some_and(|frame| {
1779            frame
1780                .hit_regions
1781                .iter()
1782                .find(|hit| hit.id == capture_id)
1783                .is_some_and(|hit| hit.on_pointer_move.is_some())
1784        })
1785    }
1786
1787    /// Cancel mouse pointer state (focus lost). Touch fingers never feed
1788    /// the polled mouse position, so only a mouse cancel clears it.
1789    /// A touch cancel drops just that finger's path (the viewport
1790    /// must drop the contact); a mouse cancel (`None`) keeps the old
1791    /// full-reset behavior for the single mouse path.
1792    pub fn handle_touch_cancel(&mut self, touch: Option<u64>) {
1793        if let Some(tid) = touch {
1794            let pos = self.touch_positions.get(&tid).copied().unwrap_or(Vec2 {
1795                x: self.mouse_pos_px.0,
1796                y: self.mouse_pos_px.1,
1797            });
1798            self.handle_touch_cancel_at(tid, pos);
1799        } else {
1800            self.handle_pointer_cancel();
1801        }
1802    }
1803
1804    pub fn handle_touch_cancel_at(&mut self, tid: u64, pos: Vec2) {
1805        let _event_scope = crate::lifecycle::enter_dispatchers(self.event_dispatchers());
1806        let _dnd_guard = self.dnd_context.enter();
1807        repose_ui::scrollbar_repeat::cancel_track_repeat_for_touch(Some(tid));
1808        let was_primary = self.touch_primary == Some(tid);
1809        let capture_id = self
1810            .touch_presses
1811            .remove(&tid)
1812            .map(|state| state.capture_id);
1813        let targets = self.touch_targets.remove(&tid);
1814        if let Some(targets) = &targets {
1815            self.dispatch_pointer_to_targets(PointerEventKind::Cancel, pos, targets, Some(tid));
1816        } else if let Some(path) = self.touch_paths.remove(&tid) {
1817            self.dispatch_pointer_to_path(PointerEventKind::Cancel, pos, &path, Some(tid));
1818        }
1819        self.touch_paths.remove(&tid);
1820        self.touch_positions.remove(&tid);
1821        if let Some(cid) = capture_id {
1822            self.end_textfield_drag(cid);
1823        }
1824        self.suppressed_touch_clicks.remove(&tid);
1825        if was_primary {
1826            self.touch_primary = None;
1827            self.double_candidate = None;
1828            self.pending_click = None;
1829            self.last_up = None;
1830            self.last_down = None;
1831            self.suppress_next_click = false;
1832            self.scroll_capture_id = None;
1833        }
1834        if self.long_press_touch == Some(tid) {
1835            self.long_press = None;
1836            self.long_press_touch = None;
1837        }
1838        self.rebuild_pressed_ids();
1839        if let Some(source_id) = capture_id {
1840            self.cancel_dnd_capture(Some(tid), source_id);
1841        }
1842        request_frame();
1843    }
1844
1845    pub fn handle_pointer_cancel(&mut self) {
1846        let _event_scope = crate::lifecycle::enter_dispatchers(self.event_dispatchers());
1847        let _dnd_guard = self.dnd_context.enter();
1848        repose_ui::scrollbar_repeat::cancel_all_track_repeats();
1849        self.sched.pointer_pos_px = None;
1850        self.held_mouse.clear();
1851        self.sched.mouse_primary = false;
1852        self.sched.mouse_secondary = false;
1853        self.sched.mouse_middle = false;
1854        self.scroll_capture_id = None;
1855        self.last_scroll_at = None;
1856        if let Some(cid) = self.capture_id {
1857            self.end_textfield_drag(cid);
1858        }
1859        let touch_targets = std::mem::take(&mut self.touch_targets);
1860        let touch_paths = std::mem::take(&mut self.touch_paths);
1861        let mut touch_ids: Vec<u64> = touch_targets
1862            .keys()
1863            .chain(touch_paths.keys())
1864            .copied()
1865            .collect();
1866        touch_ids.sort_unstable();
1867        touch_ids.dedup();
1868        for tid in touch_ids {
1869            let pos = self.touch_positions.remove(&tid).unwrap_or(Vec2 {
1870                x: self.mouse_pos_px.0,
1871                y: self.mouse_pos_px.1,
1872            });
1873            if let Some(targets) = touch_targets.get(&tid) {
1874                self.dispatch_pointer_to_targets(PointerEventKind::Cancel, pos, targets, Some(tid));
1875            } else if let Some(path) = touch_paths.get(&tid) {
1876                self.dispatch_pointer_to_path(PointerEventKind::Cancel, pos, path, Some(tid));
1877            }
1878        }
1879        let touch_presses = std::mem::take(&mut self.touch_presses);
1880        for (tid, state) in &touch_presses {
1881            let source_id = state.capture_id;
1882            self.cancel_dnd_capture(Some(*tid), source_id);
1883            self.end_textfield_drag(source_id);
1884        }
1885        self.touch_primary = None;
1886        self.suppressed_touch_clicks.clear();
1887        self.cancel_keyboard_press();
1888        self.long_press = None;
1889        self.long_press_touch = None;
1890        self.last_up = None;
1891        self.last_down = None;
1892        self.press_origin = None;
1893        self.double_candidate = None;
1894        self.suppress_next_click = false;
1895        if self.dnd_capture.is_some() {
1896            self.cancel_active_dnd();
1897        }
1898        let pos = Vec2 {
1899            x: self.mouse_pos_px.0,
1900            y: self.mouse_pos_px.1,
1901        };
1902        dispatch_hover_change_bubbled(
1903            self.frame_cache.as_deref(),
1904            &self.hover_leave,
1905            &mut self.hover_id,
1906            &mut self.hover_ancestors,
1907            None,
1908            pos,
1909            self.modifiers,
1910        );
1911        if let Some(targets) = &self.mouse_targets {
1912            self.dispatch_pointer_to_targets(PointerEventKind::Cancel, pos, targets, None);
1913        } else if let Some(path) = &self.hit_path {
1914            self.dispatch_pointer_to_path(PointerEventKind::Cancel, pos, path, None);
1915        }
1916        self.reset_pointer_state();
1917        request_frame();
1918    }
1919
1920    /// Clear hover state, emitting HoverLeave for the currently hovered region.
1921    pub fn clear_hover(&mut self) {
1922        if self.hover_id.is_none() && self.hover_ancestors.is_empty() {
1923            return;
1924        }
1925        let pos = Vec2 {
1926            x: self.mouse_pos_px.0,
1927            y: self.mouse_pos_px.1,
1928        };
1929        dispatch_hover_change_bubbled(
1930            self.frame_cache.as_deref(),
1931            &self.hover_leave,
1932            &mut self.hover_id,
1933            &mut self.hover_ancestors,
1934            None,
1935            pos,
1936            self.modifiers,
1937        );
1938    }
1939
1940    /// Reconcile hover state when the composed frame changes.
1941    pub fn reconcile_hover_from_mouse_pos(&mut self, new_frame: &Frame) {
1942        let pos = Vec2 {
1943            x: self.mouse_pos_px.0,
1944            y: self.mouse_pos_px.1,
1945        };
1946
1947        // If the previous hover target vanished from the new frame, deliver
1948        // Leave via the retained map (which survives tree removal), then clear.
1949        if let Some(prev_id) = self.hover_id
1950            && !new_frame.hit_regions.iter().any(|h| h.id == prev_id)
1951        {
1952            dispatch_hover_change_bubbled(
1953                Some(new_frame),
1954                &self.hover_leave,
1955                &mut self.hover_id,
1956                &mut self.hover_ancestors,
1957                None,
1958                pos,
1959                self.modifiers,
1960            );
1961        }
1962
1963        if !self.pointer_inside {
1964            if self.hover_id.is_some() || !self.hover_ancestors.is_empty() {
1965                dispatch_hover_change_bubbled(
1966                    Some(new_frame),
1967                    &self.hover_leave,
1968                    &mut self.hover_id,
1969                    &mut self.hover_ancestors,
1970                    None,
1971                    pos,
1972                    self.modifiers,
1973                );
1974                request_frame();
1975            }
1976            return;
1977        }
1978
1979        let new_hover = repose_ui::hit_test_enabled_frame(new_frame, pos).map(|hit| hit.id);
1980
1981        self.cursor = if dnd::is_dragging() {
1982            Some(CursorIcon::Grabbing)
1983        } else {
1984            new_hover
1985                .and_then(|id| new_frame.hit_regions.iter().find(|h| h.id == id))
1986                .and_then(|h| h.cursor.clone())
1987                .or(Some(CursorIcon::Default))
1988        };
1989
1990        let new_chain = hover_chain_for(Some(new_frame), new_hover);
1991        let old_chain = hover_chain_for(Some(new_frame), self.hover_id);
1992        if new_chain == old_chain {
1993            return;
1994        }
1995
1996        dispatch_hover_change_bubbled(
1997            Some(new_frame),
1998            &self.hover_leave,
1999            &mut self.hover_id,
2000            &mut self.hover_ancestors,
2001            new_hover,
2002            pos,
2003            self.modifiers,
2004        );
2005        request_frame();
2006    }
2007
2008    fn reset_pointer_state(&mut self) {
2009        self.capture_id = None;
2010        self.hit_path = None;
2011        self.mouse_targets = None;
2012        self.touch_paths.clear();
2013        self.touch_targets.clear();
2014        self.touch_positions.clear();
2015        self.touch_presses.clear();
2016        self.dnd_capture = None;
2017        self.touch_primary = None;
2018        self.suppressed_touch_clicks.clear();
2019        self.pressed_ids.clear();
2020        self.pending_click = None;
2021        self.last_down = None;
2022        self.press_origin = None;
2023        self.double_candidate = None;
2024        self.key_pressed_active = None;
2025        self.key_pressed_key = None;
2026        self.key_long_press = None;
2027        self.last_up = None;
2028        self.last_down = None;
2029        self.double_candidate = None;
2030        self.suppress_next_click = false;
2031    }
2032
2033    fn flush_pending_click(&mut self) {
2034        let Some((id, t0, cb)) = self.pending_click.take() else {
2035            return;
2036        };
2037        if t0.elapsed().as_millis() >= DOUBLE_CLICK_MS {
2038            cb();
2039            request_frame();
2040        } else {
2041            self.pending_click = Some((id, t0, cb));
2042        }
2043    }
2044
2045    fn poll_long_press(&mut self) {
2046        let Some((lid, t0, _, _)) = self.long_press else {
2047            return;
2048        };
2049        if t0.elapsed().as_millis() < LONG_PRESS_MS {
2050            return;
2051        }
2052        let Some(f) = self.frame_cache.clone() else {
2053            return;
2054        };
2055        // Still captured and within the element bounds? (Compose cancels the
2056        // long press when the pointer leaves the element.)
2057        let captured = self
2058            .long_press_touch
2059            .and_then(|tid| self.touch_presses.get(&tid).map(|state| state.capture_id))
2060            .or(self.capture_id);
2061        if captured != Some(lid) {
2062            self.long_press = None;
2063            self.long_press_touch = None;
2064            return;
2065        }
2066        let (mx, my) = self.mouse_pos_px;
2067        let in_bounds = f
2068            .hit_regions
2069            .iter()
2070            .find(|h| h.id == lid)
2071            .is_some_and(|h| repose_ui::hit_region_contains(h, Vec2 { x: mx, y: my }));
2072        if !in_bounds {
2073            self.long_press = None;
2074            return;
2075        }
2076        if let Some(hit) = f.hit_regions.iter().find(|h| h.id == lid && !h.disabled)
2077            && let Some(cb) = &hit.on_long_click
2078        {
2079            self.long_press = None;
2080            self.suppress_next_click = true;
2081            self.pending_click = None;
2082            self.last_up = None;
2083            cb();
2084            request_frame();
2085        } else {
2086            self.long_press = None;
2087        }
2088    }
2089
2090    /// Holding Space/Enter past LONG_PRESS_MS fires long-click. The following KeyUp must not fire onClick.
2091    fn poll_key_long_press(&mut self) {
2092        let Some((kid, t0, fired)) = self.key_long_press else {
2093            return;
2094        };
2095        if t0.elapsed().as_millis() < LONG_PRESS_MS {
2096            return;
2097        }
2098        let Some(f) = self.frame_cache.clone() else {
2099            return;
2100        };
2101        if !fired {
2102            if let Some(hit) = f.hit_regions.iter().find(|h| h.id == kid && !h.disabled)
2103                && let Some(cb) = &hit.on_long_click
2104            {
2105                cb();
2106            }
2107            self.key_long_press = Some((kid, t0, true));
2108            request_frame();
2109        }
2110    }
2111
2112    /// Process a scroll event. Returns true if consumed.
2113    pub fn handle_scroll(&mut self, delta: Vec2) -> bool {
2114        let Some(f) = &self.frame_cache else {
2115            return false;
2116        };
2117
2118        let now = web_time::Instant::now();
2119        if let Some(last) = self.last_scroll_at
2120            && now.duration_since(last).as_millis() > 250
2121        {
2122            self.scroll_capture_id = None;
2123        }
2124        self.last_scroll_at = Some(now);
2125
2126        let pos = Vec2 {
2127            x: self.mouse_pos_px.0,
2128            y: self.mouse_pos_px.1,
2129        };
2130        let (consumed, cap) = dispatch_scroll(
2131            f,
2132            pos,
2133            delta,
2134            self.scroll_capture_id,
2135            repose_core::input::ScrollSource::Wheel,
2136        );
2137        self.scroll_capture_id = cap;
2138        if consumed {
2139            request_frame();
2140        }
2141        consumed
2142    }
2143
2144    fn resolve_shortcut_action(
2145        &self,
2146        chord: &repose_core::shortcuts::KeyChord,
2147    ) -> Option<repose_core::shortcuts::Action> {
2148        self.shortcuts.resolve_action(chord)
2149    }
2150
2151    fn handle_shortcut_action(&self, action: repose_core::shortcuts::Action) -> bool {
2152        self.shortcuts.handle(action)
2153    }
2154
2155    /// Process a keyboard key event. Returns true if consumed.
2156    pub fn handle_key(&mut self, event: &KeyEvent) -> bool {
2157        let _event_scope = crate::lifecycle::enter_dispatchers(self.event_dispatchers());
2158        let shortcut_state = self.shortcuts.clone();
2159        repose_core::shortcuts::with_runtime_state(&shortcut_state, || self.handle_key_inner(event))
2160    }
2161
2162    fn handle_key_inner(&mut self, event: &KeyEvent) -> bool {
2163        let _dnd_guard = self.dnd_context.enter();
2164        if event.event_type == KeyEventType::Down {
2165            let _ = repose_core::request_input_mode(repose_core::InputMode::Keyboard);
2166        }
2167
2168        let Some(frame) = self.frame_cache.clone() else {
2169            if event.event_type == KeyEventType::Down
2170                && !event.is_repeat
2171                && let Some(action) = self.resolve_shortcut_action(
2172                    &repose_core::shortcuts::KeyChord::new(event.key.clone(), event.modifiers),
2173                )
2174            {
2175                return self.dispatch_action(action);
2176            }
2177            return false;
2178        };
2179        let f = &frame;
2180
2181        if event.event_type == KeyEventType::Down && !event.is_repeat && event.key == Key::Escape {
2182            self.cancel_keyboard_press();
2183            if self.cancel_active_dnd() {
2184                request_frame();
2185                return true;
2186            }
2187        }
2188
2189        if event.event_type == KeyEventType::Down && !event.is_repeat {
2190            let is_back = event.key == Key::Escape
2191                || self
2192                    .resolve_shortcut_action(&repose_core::shortcuts::KeyChord::new(
2193                        event.key.clone(),
2194                        event.modifiers,
2195                    ))
2196                    .is_some_and(|action| matches!(action, repose_core::shortcuts::Action::Back));
2197            if is_back && self.overlay.handle_back() {
2198                request_frame();
2199                return true;
2200            }
2201        }
2202
2203        if self.dispatch_key_event(f, event) {
2204            request_frame();
2205            return true;
2206        }
2207
2208        // Action dispatch (shortcuts like Ctrl+C, Tab, etc.)
2209        if event.event_type == KeyEventType::Down
2210            && !event.is_repeat
2211            && let Some(action) = self.resolve_shortcut_action(
2212                &repose_core::shortcuts::KeyChord::new(event.key.clone(), event.modifiers),
2213            )
2214        {
2215            // `dispatch_action` covers focus navigation internally.
2216            if self.dispatch_action(action.clone()) {
2217                return true;
2218            }
2219        }
2220
2221        // Keyboard activation (Space/Enter on focused non-textfield)
2222        if let Some(fid) = self.sched.focused {
2223            let is_tf = f
2224                .semantics_nodes
2225                .iter()
2226                .any(|n| n.id == fid && n.role == repose_core::semantics::Role::TextField);
2227            if !is_tf {
2228                if event.event_type == KeyEventType::Down && !event.is_repeat {
2229                    if event.key == Key::Space || event.key == Key::Enter {
2230                        let Some(hit) = f.hit_regions.iter().find(|h| h.id == fid) else {
2231                            return false;
2232                        };
2233                        if hit.disabled {
2234                            return false;
2235                        }
2236                        if hit.on_click.is_none()
2237                            && hit.on_long_click.is_none()
2238                            && hit.on_double_click.is_none()
2239                        {
2240                            return false; // don't steal keys from non-clickable focusables
2241                        }
2242                        self.cancel_keyboard_press();
2243                        self.pressed_ids.insert(fid);
2244                        self.key_pressed_active = Some(fid);
2245                        self.key_pressed_key = Some(event.key.clone());
2246                        self.key_long_press = if hit.on_long_click.is_some() {
2247                            Some((fid, web_time::Instant::now(), false))
2248                        } else {
2249                            None
2250                        };
2251
2252                        if let Some(hit) = f.hit_regions.iter().find(|h| h.id == fid)
2253                            && let Some(src) = &hit.interaction_source
2254                        {
2255                            let local = Vec2 {
2256                                x: hit.rect.w * 0.5,
2257                                y: hit.rect.h * 0.5,
2258                            };
2259                            src.to_mutable().emit(Interaction::new_press(local));
2260                        }
2261
2262                        request_frame();
2263                        return true;
2264                    }
2265                } else if event.event_type == KeyEventType::Up
2266                    && let Some(active_id) = self.key_pressed_active
2267                    && self
2268                        .key_pressed_key
2269                        .as_ref()
2270                        .is_some_and(|key| key == &event.key)
2271                {
2272                    self.pressed_ids.remove(&active_id);
2273                    self.key_pressed_active = None;
2274                    self.key_pressed_key = None;
2275
2276                    let long_fired = self
2277                        .key_long_press
2278                        .take()
2279                        .map(|(_, _, fired)| fired)
2280                        .unwrap_or(false);
2281
2282                    if let Some(hit) = f
2283                        .hit_regions
2284                        .iter()
2285                        .find(|h| h.id == active_id && !h.disabled)
2286                    {
2287                        if let Some(src) = &hit.interaction_source {
2288                            let pid = src.collect_last_press_id().unwrap_or(0);
2289                            src.to_mutable().emit(Interaction::Release(pid));
2290                        }
2291                        if !long_fired && let Some(cb) = &hit.on_click {
2292                            cb();
2293                        }
2294                    }
2295                    request_frame();
2296                    return true;
2297                }
2298            }
2299        }
2300
2301        // Enter submission for focused TextField
2302        if event.event_type == KeyEventType::Down
2303            && !event.is_repeat
2304            && event.key == Key::Enter
2305            && let Some(fid) = self.sched.focused
2306            && let Some(hit) = f.hit_regions.iter().find(|h| h.id == fid)
2307        {
2308            let is_multiline = hit.tf_multiline;
2309            let should_submit = if is_multiline {
2310                self.modifiers.ctrl || self.modifiers.meta
2311            } else {
2312                true
2313            };
2314            if should_submit {
2315                let key = tf_key_of(f, fid);
2316                if let Some(state_rc) = self.textfield_states.get(&key) {
2317                    let text = state_rc.borrow().text.clone();
2318                    let action = match hit.ime_action {
2319                        repose_core::text::ImeAction::Unspecified => {
2320                            repose_core::text::ImeAction::Done
2321                        }
2322                        action => action,
2323                    };
2324                    repose_ui::textfield::dispatch_textfield_keyboard_action(hit, action, &|| {
2325                        if let Some(on_submit) = &hit.on_text_submit {
2326                            on_submit(text.clone());
2327                        }
2328                    });
2329                    request_frame();
2330                    return true;
2331                }
2332            } else {
2333                // Multiline plain Enter: insert newline
2334                let key = tf_key_of(f, fid);
2335                if !is_tf_editable(f, fid) {
2336                    return true;
2337                }
2338                if let Some(state_rc) = self.textfield_states.get(&key) {
2339                    let mut edited = state_rc.borrow().clone();
2340                    repose_ui::textfield::insert_text_with_input_transformation(
2341                        hit,
2342                        &mut edited,
2343                        "\n",
2344                        false,
2345                    );
2346                    tf_ensure_caret_visible_for_hit(hit, &mut edited);
2347                    let new_text = edited.text.clone();
2348                    *state_rc.borrow_mut() = edited;
2349                    notify_text_change(f, fid, new_text);
2350                    request_frame();
2351                    return true;
2352                }
2353            }
2354        }
2355
2356        // TextField navigation / edit keys
2357        if event.event_type == KeyEventType::Down
2358            && let Some(fid) = self.sched.focused
2359            && self.handle_text_navigation_key(f, fid, &event.key)
2360        {
2361            return true;
2362        }
2363
2364        if event.event_type == KeyEventType::Down {
2365            // Plain text input (non-IME)
2366            if !self.ime_preedit
2367                && !self.modifiers.ctrl
2368                && !self.modifiers.alt
2369                && !self.modifiers.meta
2370                && let Key::Character(c) = event.key
2371                && !c.is_control()
2372                && c != '\n'
2373                && c != '\r'
2374                && let Some(fid) = self.sched.focused
2375            {
2376                let key = tf_key_of(f, fid);
2377                if !is_tf_editable(f, fid) {
2378                    return true;
2379                }
2380                if let Some(state_rc) = self.textfield_states.get(&key) {
2381                    let new_text = if let Some(hit) = f.hit_regions.iter().find(|h| h.id == fid) {
2382                        let mut edited = state_rc.borrow().clone();
2383                        repose_ui::textfield::insert_text_with_input_transformation(
2384                            hit,
2385                            &mut edited,
2386                            &c.to_string(),
2387                            false,
2388                        );
2389                        tf_ensure_caret_visible_for_hit(hit, &mut edited);
2390                        let text = edited.text.clone();
2391                        *state_rc.borrow_mut() = edited;
2392                        text
2393                    } else {
2394                        return false;
2395                    };
2396                    notify_text_change(f, fid, new_text);
2397                    request_frame();
2398                    return true;
2399                }
2400            }
2401        }
2402
2403        false
2404    }
2405
2406    fn display_text_for_state(
2407        state: &TextFieldState,
2408    ) -> (String, usize, Option<Box<dyn repose_core::OffsetMapping>>) {
2409        let caret = state.caret_index();
2410        let Some(transformation) = state.visual_transformation.clone() else {
2411            return (state.text.clone(), caret, None);
2412        };
2413        let annotated = repose_core::AnnotatedString::new(state.text.clone(), vec![]);
2414        let transformed = transformation.filter(&annotated);
2415        let display_caret = transformed.offset_mapping.original_to_transformed(caret);
2416        (
2417            transformed.text.text,
2418            display_caret,
2419            Some(transformed.offset_mapping),
2420        )
2421    }
2422
2423    fn handle_text_navigation_key(&mut self, frame: &Frame, id: u64, key: &Key) -> bool {
2424        let Some(hit) = frame.hit_regions.iter().find(|hit| hit.id == id) else {
2425            return false;
2426        };
2427        let state_key = tf_key_of(frame, id);
2428        let Some(state) = self.textfield_states.get(&state_key) else {
2429            return false;
2430        };
2431        let editable = is_tf_editable(frame, id);
2432        let metrics = repose_ui::textfield::textfield_metrics(hit);
2433        let shift = self.modifiers.shift;
2434        let word = self.modifiers.command;
2435        let mut edited = state.borrow().clone();
2436        let (display, display_caret, mapping) = Self::display_text_for_state(&edited);
2437        let mut changed = false;
2438        let consumed = match key {
2439            Key::Backspace => {
2440                changed = editable
2441                    && repose_ui::textfield::delete_backward_with_input_transformation(
2442                        hit,
2443                        &mut edited,
2444                    );
2445                true
2446            }
2447            Key::Delete => {
2448                changed = editable
2449                    && repose_ui::textfield::delete_forward_with_input_transformation(
2450                        hit,
2451                        &mut edited,
2452                    );
2453                true
2454            }
2455            Key::ArrowLeft => {
2456                edited.move_cursor(-1, shift, word);
2457                edited.preferred_x_px = None;
2458                true
2459            }
2460            Key::ArrowRight => {
2461                edited.move_cursor(1, shift, word);
2462                edited.preferred_x_px = None;
2463                true
2464            }
2465            Key::ArrowUp if hit.tf_multiline => {
2466                let (next_display, preferred) =
2467                    repose_ui::textfield::move_caret_vertical_with_metrics(
2468                        &display,
2469                        edited.inner_width.max(1.0),
2470                        display_caret,
2471                        -1,
2472                        edited.preferred_x_px,
2473                        &metrics,
2474                    );
2475                let next = mapping.as_ref().map_or(next_display, |mapping| {
2476                    mapping.transformed_to_original(next_display)
2477                });
2478                set_caret(&mut edited, next, shift);
2479                edited.preferred_x_px = Some(preferred);
2480                true
2481            }
2482            Key::ArrowDown if hit.tf_multiline => {
2483                let (next_display, preferred) =
2484                    repose_ui::textfield::move_caret_vertical_with_metrics(
2485                        &display,
2486                        edited.inner_width.max(1.0),
2487                        display_caret,
2488                        1,
2489                        edited.preferred_x_px,
2490                        &metrics,
2491                    );
2492                let next = mapping.as_ref().map_or(next_display, |mapping| {
2493                    mapping.transformed_to_original(next_display)
2494                });
2495                set_caret(&mut edited, next, shift);
2496                edited.preferred_x_px = Some(preferred);
2497                true
2498            }
2499            Key::Home => {
2500                let next_display = if hit.tf_multiline {
2501                    repose_ui::textfield::line_home_end_with_metrics(
2502                        &display,
2503                        edited.inner_width.max(1.0),
2504                        display_caret,
2505                        false,
2506                        &metrics,
2507                    )
2508                } else {
2509                    0
2510                };
2511                let next = mapping.as_ref().map_or(next_display, |mapping| {
2512                    mapping.transformed_to_original(next_display)
2513                });
2514                set_caret(&mut edited, next, shift);
2515                edited.preferred_x_px = None;
2516                true
2517            }
2518            Key::End => {
2519                let next_display = if hit.tf_multiline {
2520                    repose_ui::textfield::line_home_end_with_metrics(
2521                        &display,
2522                        edited.inner_width.max(1.0),
2523                        display_caret,
2524                        true,
2525                        &metrics,
2526                    )
2527                } else {
2528                    display.len()
2529                };
2530                let next = mapping.as_ref().map_or(next_display, |mapping| {
2531                    mapping.transformed_to_original(next_display)
2532                });
2533                set_caret(&mut edited, next, shift);
2534                edited.preferred_x_px = None;
2535                true
2536            }
2537            _ => false,
2538        };
2539        if !consumed {
2540            return false;
2541        }
2542        tf_ensure_caret_visible_for_hit(hit, &mut edited);
2543        let text = changed.then(|| edited.text.clone());
2544        *state.borrow_mut() = edited;
2545        if let Some(text) = text {
2546            notify_text_change(frame, id, text);
2547        }
2548        request_frame();
2549        true
2550    }
2551
2552    /// Dispatch a key event through the compose hierarchy, mirroring
2553    /// Compose `FocusOwner.dispatchKeyEvent`: resolve the focused key-input
2554    /// node (falling back to the root when unfocused), then run the preview
2555    /// tunnel root -> focused and the bubble focused -> root.
2556    fn dispatch_key_event(&self, f: &Frame, event: &KeyEvent) -> bool {
2557        let chain = key_ancestor_chain(f, self.sched.focused);
2558        let hit_by_id: HashMap<u64, &HitRegion> = f.hit_regions.iter().map(|h| (h.id, h)).collect();
2559
2560        for &id in &chain {
2561            let Some(hit) = hit_by_id.get(&id) else {
2562                continue;
2563            };
2564            if hit.disabled {
2565                continue;
2566            }
2567            if let Some(cb) = &hit.on_preview_key_event
2568                && cb(event.clone())
2569            {
2570                return true;
2571            }
2572        }
2573
2574        for &id in chain.iter().rev() {
2575            let Some(hit) = hit_by_id.get(&id) else {
2576                continue;
2577            };
2578            if hit.disabled {
2579                continue;
2580            }
2581            if let Some(cb) = &hit.on_key_event
2582                && cb(event.clone())
2583            {
2584                return true;
2585            }
2586        }
2587
2588        false
2589    }
2590
2591    /// Dispatch a shortcut action: widget handler first, then built-in
2592    /// textfield editing, then the global shortcut map, then focus navigation.
2593    /// Returns true if the action was consumed.
2594    pub fn dispatch_action(&mut self, action: repose_core::shortcuts::Action) -> bool {
2595        // 1) Widget-level handler
2596        if let Some(f) = &self.frame_cache
2597            && let Some(fid) = self.sched.focused
2598            && let Some(hit) = f.hit_regions.iter().find(|h| h.id == fid)
2599            && let Some(cb) = &hit.on_action
2600            && cb(action.clone())
2601        {
2602            request_frame();
2603            return true;
2604        }
2605
2606        // 2) Built-in textfield editing (undo/redo/copy/cut/paste/select-all)
2607        if self.apply_text_editing_action(&action) {
2608            return true;
2609        }
2610
2611        // 3) Global shortcut handler
2612        if self.handle_shortcut_action(action.clone()) {
2613            request_frame();
2614            return true;
2615        }
2616
2617        // 4) Focus navigation (Tab / arrows)
2618        if let Some(f) = self.frame_cache.clone()
2619            && let Some(new_id) = repose_core::focus::handle_action(&action, &mut self.sched, &f)
2620        {
2621            // End any in-flight keyboard press (e.g. Space held on the old focus).
2622            self.cancel_keyboard_press();
2623            // Lazy-init + reset the caret blink for the newly focused text field.
2624            if let Some(hit) = f.hit_regions.iter().find(|h| h.id == new_id)
2625                && let Some(key) = hit.tf_state_key
2626            {
2627                let st = ensure_tf_state(&mut self.textfield_states, key, hit.tf_value.as_str());
2628                {
2629                    let mut s = st.borrow_mut();
2630                    s.apply_controlled_value(&hit.tf_value);
2631                    s.reset_caret_blink();
2632                }
2633            }
2634            request_frame();
2635            return true;
2636        }
2637
2638        false
2639    }
2640
2641    /// Apply built-in textfield editing actions (Undo/Redo/SelectAll/Copy/
2642    /// Cut/Paste) to the focused text field. Returns true if consumed.
2643    fn apply_text_editing_action(&mut self, action: &repose_core::shortcuts::Action) -> bool {
2644        use repose_core::shortcuts::Action;
2645        let Some(fid) = self.sched.focused else {
2646            return false;
2647        };
2648        let Some(f) = self.frame_cache.clone() else {
2649            return false;
2650        };
2651        if !is_tf_hit(&f, fid) {
2652            return false;
2653        }
2654        let key = tf_key_of(&f, fid);
2655        let Some(state_rc) = self.textfield_states.get(&key).cloned() else {
2656            return false;
2657        };
2658        let hit = f.hit_regions.iter().find(|hit| hit.id == fid);
2659
2660        match action {
2661            Action::Undo => {
2662                if !is_tf_editable(&f, fid) {
2663                    return true;
2664                }
2665                let Some(hit) = hit else {
2666                    return false;
2667                };
2668                let mut edited = state_rc.borrow().clone();
2669                if !repose_ui::textfield::undo_with_input_transformation(hit, &mut edited) {
2670                    return false;
2671                }
2672                tf_ensure_caret_visible_for_hit(hit, &mut edited);
2673                let new_text = edited.text.clone();
2674                *state_rc.borrow_mut() = edited;
2675                notify_text_change(&f, fid, new_text);
2676                request_frame();
2677                true
2678            }
2679            Action::Redo => {
2680                if !is_tf_editable(&f, fid) {
2681                    return true;
2682                }
2683                let Some(hit) = hit else {
2684                    return false;
2685                };
2686                let mut edited = state_rc.borrow().clone();
2687                if !repose_ui::textfield::redo_with_input_transformation(hit, &mut edited) {
2688                    return false;
2689                }
2690                tf_ensure_caret_visible_for_hit(hit, &mut edited);
2691                let new_text = edited.text.clone();
2692                *state_rc.borrow_mut() = edited;
2693                notify_text_change(&f, fid, new_text);
2694                request_frame();
2695                true
2696            }
2697            Action::SelectAll => {
2698                let mut st = state_rc.borrow_mut();
2699                let len = st.text.len();
2700                st.selection = 0..len;
2701                if let Some(hit) = hit {
2702                    tf_ensure_caret_visible_for_hit(hit, &mut st);
2703                }
2704                request_frame();
2705                true
2706            }
2707            Action::Copy => {
2708                if hit.is_some_and(tf_is_sensitive) {
2709                    return true;
2710                }
2711                let st = state_rc.borrow();
2712                let (a, b) = (
2713                    st.selection.start.min(st.selection.end),
2714                    st.selection.start.max(st.selection.end),
2715                );
2716                if a == b {
2717                    return false;
2718                }
2719                let slice = st.text.get(a..b).unwrap_or("").to_string();
2720                drop(st);
2721                if !slice.is_empty() {
2722                    repose_core::clipboard::copy_to_clipboard(&slice);
2723                }
2724                true
2725            }
2726            Action::Cut => {
2727                if hit.is_some_and(tf_is_sensitive) {
2728                    return true;
2729                }
2730                if !is_tf_editable(&f, fid) {
2731                    return true;
2732                }
2733                let Some(hit) = hit else {
2734                    return false;
2735                };
2736                let mut edited = state_rc.borrow().clone();
2737                let Some(slice) =
2738                    repose_ui::textfield::cut_with_input_transformation(hit, &mut edited)
2739                else {
2740                    return false;
2741                };
2742                tf_ensure_caret_visible_for_hit(hit, &mut edited);
2743                let new_text = edited.text.clone();
2744                *state_rc.borrow_mut() = edited;
2745                repose_core::clipboard::copy_to_clipboard(&slice);
2746                notify_text_change(&f, fid, new_text);
2747                request_frame();
2748                true
2749            }
2750            Action::Paste => {
2751                if let Some(txt) = repose_core::clipboard::paste_text() {
2752                    self.paste_into_focused(&txt);
2753                    return true;
2754                }
2755                false
2756            }
2757            _ => false,
2758        }
2759    }
2760
2761    fn cancel_ime_compositions(&mut self) {
2762        let mut changed = None;
2763        for (key, state) in &self.textfield_states {
2764            let mut edited = state.borrow().clone();
2765            if edited.composition.is_none() {
2766                continue;
2767            }
2768            edited.cancel_composition();
2769            *state.borrow_mut() = edited;
2770            changed = Some((*key, self.textfield_states[key].borrow().text.clone()));
2771        }
2772        self.ime_preedit = false;
2773        if let (Some((key, text)), Some(frame)) = (changed, &self.frame_cache)
2774            && let Some(id) = frame
2775                .hit_regions
2776                .iter()
2777                .find(|hit| hit.tf_state_key.unwrap_or(hit.id) == key)
2778                .map(|hit| hit.id)
2779        {
2780            notify_text_change(frame, id, text);
2781        }
2782        request_frame();
2783    }
2784
2785    /// Process an IME event.
2786    pub fn handle_ime(&mut self, event: &ImeEvent) {
2787        if matches!(event, ImeEvent::Cancel) {
2788            self.cancel_ime_compositions();
2789        }
2790        let Some(fid) = self.sched.focused else {
2791            return;
2792        };
2793        let Some(frame) = self.frame_cache.clone() else {
2794            return;
2795        };
2796        if !(is_tf_editable(&frame, fid) && is_textfield_in_frame(&frame, fid)) {
2797            if let Some(hit) = frame.hit_regions.iter().find(|hit| hit.id == fid)
2798                && !hit.disabled
2799                && let Some(cb) = &hit.on_ime
2800                && cb(event.clone())
2801            {
2802                request_frame();
2803            }
2804            return;
2805        }
2806        if matches!(event, ImeEvent::Cancel) {
2807            return;
2808        }
2809        let key = tf_key_of(&frame, fid);
2810        let Some(state) = self.textfield_states.get(&key).cloned() else {
2811            return;
2812        };
2813        let hit = frame.hit_regions.iter().find(|hit| hit.id == fid);
2814        let mut edited = state.borrow().clone();
2815        let changed = match event {
2816            ImeEvent::Start => {
2817                self.ime_preedit = false;
2818                None
2819            }
2820            ImeEvent::Update { text, cursor } => {
2821                edited.set_composition(text.clone(), *cursor);
2822                if let Some(hit) = hit {
2823                    repose_ui::textfield::apply_textfield_input_transformation(hit, &mut edited);
2824                    tf_ensure_caret_visible_for_hit(hit, &mut edited);
2825                }
2826                self.ime_preedit = !text.is_empty();
2827                Some(edited.text.clone())
2828            }
2829            ImeEvent::Commit(text) => {
2830                if let Some(hit) = hit {
2831                    repose_ui::textfield::commit_composition_with_input_transformation(
2832                        hit,
2833                        &mut edited,
2834                        text.clone(),
2835                    );
2836                    tf_ensure_caret_visible_for_hit(hit, &mut edited);
2837                } else {
2838                    edited.commit_composition(text.clone());
2839                }
2840                self.ime_preedit = false;
2841                Some(edited.text.clone())
2842            }
2843            ImeEvent::Cancel => unreachable!(),
2844        };
2845        *state.borrow_mut() = edited;
2846        if let Some(text) = changed {
2847            notify_text_change(&frame, fid, text);
2848        }
2849        request_frame();
2850    }
2851
2852    /// Finish the active composition in every field without deleting text.
2853    /// Used for focus changes and IME disconnects where the confirmed text
2854    /// must be kept; distinct from cancelling, which discards the preedit.
2855    pub fn finish_compositions(&mut self) {
2856        for state_rc in self.textfield_states.values() {
2857            let mut st = state_rc.borrow_mut();
2858            if st.composition.is_some() {
2859                st.finish_composition();
2860            }
2861        }
2862        self.ime_preedit = false;
2863    }
2864
2865    /// Handle focus lost (window unfocused, etc.). Physical-key and
2866    /// mouse-button levels clear too: winit does not deliver key-ups
2867    /// for keys still down across an alt-tab, so without this the
2868    /// polled `held_keys` set sticks until the next press of that key.
2869    pub fn handle_focus_lost(&mut self) {
2870        self.pointer_inside = false;
2871        self.sched.window_focused = false;
2872        self.handle_pointer_cancel();
2873        self.held_keys.clear();
2874        self.held_mouse.clear();
2875        self.sched.held_keys.clear();
2876        self.sched.mouse_primary = false;
2877        self.sched.mouse_secondary = false;
2878        self.sched.mouse_middle = false;
2879        self.sched.touch_points.clear();
2880        self.key_pressed_active = None;
2881        self.key_pressed_key = None;
2882        self.key_long_press = None;
2883        self.pressed_ids.clear();
2884        self.pending_click = None;
2885        self.last_down = None;
2886        self.press_origin = None;
2887        self.last_up = None;
2888        self.double_candidate = None;
2889        self.scroll_capture_id = None;
2890        self.last_scroll_at = None;
2891        self.finish_compositions();
2892    }
2893
2894    /// Report one physical key transition (`true` = down). Platform
2895    /// runners call this from the raw `KeyboardInput` event alongside
2896    /// the focus-routed `handle_key_with_text`, so `held` reflects
2897    /// hardware even when no widget has focus.
2898    pub fn set_physical_key(&mut self, name: &str, down: bool) {
2899        if down {
2900            self.held_keys.insert(name.to_string());
2901        } else {
2902            self.held_keys.remove(name);
2903        }
2904    }
2905
2906    /// Snapshot of currently held physical keys (debug names).
2907    pub fn held_physical_keys(&self) -> Vec<String> {
2908        let mut out: Vec<String> = self.held_keys.iter().cloned().collect();
2909        out.sort();
2910        out
2911    }
2912
2913    /// True while the named physical key is down (`KeyCode::KeyW`, ...).
2914    pub fn physical_key_held(&self, name: &str) -> bool {
2915        self.held_keys.contains(name)
2916    }
2917
2918    /// True while the mouse button is down.
2919    pub fn mouse_button_held(&self, button: PointerButton) -> bool {
2920        self.held_mouse.contains(&button)
2921    }
2922
2923    /// Get or create a text field state by its key.
2924    pub fn ensure_textfield_state(&mut self, key: u64) -> Rc<RefCell<TextFieldState>> {
2925        self.textfield_states
2926            .entry(key)
2927            .or_insert_with(|| Rc::new(RefCell::new(TextFieldState::new())))
2928            .clone()
2929    }
2930
2931    pub fn ensure_textfield_state_seeded(
2932        &mut self,
2933        key: u64,
2934        seed: &str,
2935    ) -> Rc<RefCell<TextFieldState>> {
2936        ensure_tf_state(&mut self.textfield_states, key, seed)
2937    }
2938
2939    /// Look up the persistent state key for a visual hit-region id.
2940    pub fn tf_key_of(&self, visual_id: u64) -> u64 {
2941        self.frame_cache
2942            .as_ref()
2943            .map(|f| tf_key_of(f, visual_id))
2944            .unwrap_or(visual_id)
2945    }
2946
2947    /// True if the given id belongs to a TextField.
2948    pub fn is_textfield(&self, id: u64) -> bool {
2949        self.frame_cache
2950            .as_ref()
2951            .map(|f| is_textfield_in_frame(f, id))
2952            .unwrap_or(false)
2953    }
2954
2955    /// True if the given id is an enabled, editable text field.
2956    pub fn is_editable_textfield(&self, id: u64) -> bool {
2957        self.frame_cache
2958            .as_ref()
2959            .map(|f| is_tf_editable(f, id))
2960            .unwrap_or(false)
2961    }
2962
2963    /// True if the given textfield id is multiline.
2964    pub fn is_multiline(&self, id: u64) -> bool {
2965        self.frame_cache
2966            .as_ref()
2967            .map(|f| is_multiline_id(f, id))
2968            .unwrap_or(false)
2969    }
2970
2971    /// Keyboard hints of the currently focused text field, or defaults if none.
2972    /// Returns `(purpose, auto_correct, capitalization)` for the platform runner.
2973    pub fn focused_keyboard_hints(
2974        &self,
2975    ) -> (
2976        repose_core::ImePurposeHint,
2977        bool,
2978        repose_core::KeyboardCapitalization,
2979    ) {
2980        let defaults = || {
2981            (
2982                repose_core::ImePurposeHint::Normal,
2983                true,
2984                repose_core::KeyboardCapitalization::Unspecified,
2985            )
2986        };
2987        let Some(fid) = self.sched.focused else {
2988            return defaults();
2989        };
2990        let Some(f) = &self.frame_cache else {
2991            return defaults();
2992        };
2993        match f.hit_regions.iter().find(|h| h.id == fid) {
2994            Some(hit) => (
2995                hit.keyboard_type.ime_purpose_hint(),
2996                hit.auto_correct.unwrap_or(true),
2997                hit.capitalization,
2998            ),
2999            None => defaults(),
3000        }
3001    }
3002
3003    pub fn focused_ime_action(&self) -> repose_core::text::ImeAction {
3004        self.frame_cache
3005            .as_ref()
3006            .and_then(|frame| {
3007                self.sched
3008                    .focused
3009                    .and_then(|id| frame.hit_regions.iter().find(|hit| hit.id == id))
3010            })
3011            .map(|hit| hit.ime_action)
3012            .unwrap_or_default()
3013    }
3014
3015    /// Delete text the soft keyboard removed around the caret, reported as byte
3016    /// counts before and after the caret. Counts rather than key chords, because
3017    /// deleting a selection is one operation however long the selection is.
3018    pub fn delete_surrounding_into_focused(&mut self, before: usize, after: usize) -> bool {
3019        if self.ime_preedit {
3020            return false;
3021        }
3022        let Some(fid) = self.sched.focused else {
3023            return false;
3024        };
3025        let Some(f) = self.frame_cache.clone() else {
3026            return false;
3027        };
3028        if !(is_textfield_in_frame(&f, fid) && is_tf_editable(&f, fid)) {
3029            return false;
3030        }
3031        let key = tf_key_of(&f, fid);
3032        let Some(state_rc) = self.textfield_states.get(&key).cloned() else {
3033            return false;
3034        };
3035        let Some(hit) = f.hit_regions.iter().find(|hit| hit.id == fid) else {
3036            return false;
3037        };
3038        let mut edited = state_rc.borrow().clone();
3039        let changed = repose_ui::textfield::delete_surrounding_with_input_transformation(
3040            hit,
3041            &mut edited,
3042            before,
3043            after,
3044        );
3045        if changed {
3046            tf_ensure_caret_visible_for_hit(hit, &mut edited);
3047        }
3048        let text = changed.then(|| edited.text.clone());
3049        *state_rc.borrow_mut() = edited;
3050        if let Some(text) = text {
3051            notify_text_change(&f, fid, text);
3052        }
3053        if changed {
3054            request_frame();
3055        }
3056        changed
3057    }
3058
3059    /// Insert arbitrary text into the focused text field (composed keyboard
3060    /// text, clipboard paste, hardware-keyboard fallback, ...).
3061    /// Returns true if text was inserted.
3062    ///
3063    /// Control chars are filtered. Newlines are dropped for single-line fields. Skipped during IME preedit.
3064    pub fn insert_text_into_focused(&mut self, text: &str) -> bool {
3065        if text.is_empty()
3066            || self.ime_preedit
3067            || self.modifiers.ctrl
3068            || self.modifiers.alt
3069            || self.modifiers.meta
3070        {
3071            return false;
3072        }
3073        let Some(fid) = self.sched.focused else {
3074            return false;
3075        };
3076        let Some(f) = self.frame_cache.clone() else {
3077            return false;
3078        };
3079        if !is_textfield_in_frame(&f, fid) {
3080            return false;
3081        }
3082        if !is_tf_editable(&f, fid) {
3083            return false;
3084        }
3085        let key = tf_key_of(&f, fid);
3086        let Some(state_rc) = self.textfield_states.get(&key).cloned() else {
3087            return false;
3088        };
3089        let multiline = is_multiline_id(&f, fid);
3090        let filtered: String = text
3091            .chars()
3092            .filter(|c| {
3093                // Keep newlines for multiline fields; otherwise drop control
3094                // chars and CR (\n is a control char, so it needs an explicit
3095                // exception or it never survives for multiline fields).
3096                (*c == '\n' && multiline) || (!c.is_control() && *c != '\r')
3097            })
3098            .collect();
3099        if filtered.is_empty() {
3100            return false;
3101        }
3102        let new_text = if let Some(hit) = f.hit_regions.iter().find(|hit| hit.id == fid) {
3103            let mut edited = state_rc.borrow().clone();
3104            repose_ui::textfield::insert_text_with_input_transformation(
3105                hit,
3106                &mut edited,
3107                &filtered,
3108                false,
3109            );
3110            tf_ensure_caret_visible_for_hit(hit, &mut edited);
3111            let text = edited.text.clone();
3112            *state_rc.borrow_mut() = edited;
3113            text
3114        } else {
3115            return false;
3116        };
3117        notify_text_change(&f, fid, new_text);
3118        request_frame();
3119        true
3120    }
3121
3122    /// Insert plain text into the focused textfield (winit `key_event.text`,
3123    /// Android soft-keyboard text, web paste). Alias for
3124    /// [`Self::insert_text_into_focused`].
3125    pub fn insert_text(&mut self, text: &str) -> bool {
3126        self.insert_text_into_focused(text)
3127    }
3128
3129    /// Insert text into a focused text field (used for paste). Uses an atomic
3130    /// (non-mergeable) edit so Ctrl+V doesn't merge with adjacent typing.
3131    pub fn paste_into_focused(&mut self, text: &str) {
3132        let Some(fid) = self.sched.focused else {
3133            return;
3134        };
3135        let Some(f) = &self.frame_cache.clone() else {
3136            return;
3137        };
3138        if !is_tf_editable(f, fid) {
3139            return;
3140        }
3141        let key = tf_key_of(f, fid);
3142        let Some(state_rc) = self.textfield_states.get(&key) else {
3143            return;
3144        };
3145        let Some(hit) = f.hit_regions.iter().find(|hit| hit.id == fid) else {
3146            return;
3147        };
3148        let filtered: String = text
3149            .chars()
3150            .filter(|c| (*c == '\n' && hit.tf_multiline) || (!c.is_control() && *c != '\r'))
3151            .collect();
3152        if filtered.is_empty() {
3153            return;
3154        }
3155        let new_text = {
3156            let mut edited = state_rc.borrow().clone();
3157            repose_ui::textfield::insert_text_with_input_transformation(
3158                hit,
3159                &mut edited,
3160                &filtered,
3161                true,
3162            );
3163            tf_ensure_caret_visible_for_hit(hit, &mut edited);
3164            let text = edited.text.clone();
3165            *state_rc.borrow_mut() = edited;
3166            text
3167        };
3168        notify_text_change(f, fid, new_text);
3169        request_frame();
3170    }
3171
3172    /// Process a gamepad event: mirror connection/button/axis state into
3173    /// [`ReposeRuntime::gamepads`]. Sticks, shoulders and face extras stay raw
3174    /// gameplay input for the game to read from [`ReposeRuntime::gamepads`].
3175    ///
3176    /// Returns `true` when the event drove UI navigation.
3177    pub fn handle_gamepad(&mut self, event: &GamepadEvent) -> bool {
3178        match event {
3179            GamepadEvent::Connected {
3180                id,
3181                name,
3182                vendor_id,
3183                product_id,
3184            } => {
3185                self.gamepads.insert(
3186                    id.0,
3187                    GamepadPad {
3188                        name: name.clone(),
3189                        vendor_id: *vendor_id,
3190                        product_id: *product_id,
3191                        ..GamepadPad::default()
3192                    },
3193                );
3194                request_frame();
3195                false
3196            }
3197            GamepadEvent::Disconnected { id } => {
3198                self.gamepads.remove(&id.0);
3199                self.sensors.retain(|(pad, _), _| *pad != id.0);
3200                request_frame();
3201                false
3202            }
3203            GamepadEvent::Button {
3204                id,
3205                button,
3206                pressed,
3207            } => {
3208                if let Some(pad) = self.gamepads.get_mut(&id.0) {
3209                    if *pressed {
3210                        pad.pressed.insert(*button);
3211                    } else {
3212                        pad.pressed.remove(button);
3213                    }
3214                }
3215                if *pressed {
3216                    let _ = repose_core::request_input_mode(repose_core::InputMode::Keyboard);
3217                }
3218                let key = match button {
3219                    GamepadButton::South => Key::Space,
3220                    GamepadButton::East => Key::Escape,
3221                    GamepadButton::Start => Key::Enter,
3222                    GamepadButton::DPadUp => Key::ArrowUp,
3223                    GamepadButton::DPadDown => Key::ArrowDown,
3224                    GamepadButton::DPadLeft => Key::ArrowLeft,
3225                    GamepadButton::DPadRight => Key::ArrowRight,
3226                    _ => return false,
3227                };
3228                let synthetic = KeyEvent {
3229                    key,
3230                    modifiers: self.modifiers,
3231                    is_repeat: false,
3232                    event_type: if *pressed {
3233                        KeyEventType::Down
3234                    } else {
3235                        KeyEventType::Up
3236                    },
3237                    utf16_code_point: 0,
3238                    physical: None,
3239                };
3240                self.handle_key(&synthetic)
3241            }
3242            GamepadEvent::Axis { id, axis, value } => {
3243                if let Some(pad) = self.gamepads.get_mut(&id.0) {
3244                    pad.axes.insert(*axis, *value);
3245                }
3246                false
3247            }
3248        }
3249    }
3250
3251    /// Queue a dual-motor rumble for `id` (SDL-style: low = strong motor,
3252    /// high = weak motor, 0.0..=1.0, `duration_ms`). The platform runner
3253    /// drains the queue each frame into `GamepadBackend::set_rumble`.
3254    /// A `duration_ms` of 0 stops. No-op when the pad is unknown.
3255    pub fn request_rumble(
3256        &mut self,
3257        id: repose_core::input::GamepadId,
3258        low_freq: f32,
3259        high_freq: f32,
3260        duration_ms: u32,
3261    ) {
3262        self.pending_rumble.push((
3263            id.0,
3264            low_freq.clamp(0.0, 1.0),
3265            high_freq.clamp(0.0, 1.0),
3266            duration_ms,
3267        ));
3268    }
3269
3270    /// Queue a rumble stop for `id`.
3271    pub fn stop_rumble(&mut self, id: repose_core::input::GamepadId) {
3272        self.pending_rumble.push((id.0, 0.0, 0.0, 0));
3273    }
3274
3275    /// Drain queued rumble requests (platform runners call this after `poll`).
3276    /// [`repose_core::rumble`] entries broadcast to every connected pad.
3277    pub fn take_rumble_requests(&mut self) -> Vec<(u32, f32, f32, u32)> {
3278        let mut requests = std::mem::take(&mut self.pending_rumble);
3279        let global = repose_core::rumble::take();
3280        if !global.is_empty() {
3281            let pads: Vec<u32> = self.gamepads.keys().copied().collect();
3282            for (low, high, duration_ms) in global {
3283                for &id in &pads {
3284                    requests.push((id, low, high, duration_ms));
3285                }
3286            }
3287        }
3288        requests
3289    }
3290
3291    /// Record a motion-sensor reading for `id` (platform runners call this
3292    /// once per polled sample). A frame is requested only once the reading
3293    /// moves past its deadband, compared against the reading that last asked
3294    /// for one: resting noise then cannot pin the app at display rate, while
3295    /// slow drift keeps accumulating until it counts as motion.
3296    pub fn handle_sensor_sample(&mut self, id: GamepadId, sample: SensorSample) {
3297        let key = (id.0, sample.kind);
3298        let deadband = match sample.kind {
3299            SensorKind::Gyroscope => SENSOR_GYRO_DEADBAND,
3300            SensorKind::Accelerometer => SENSOR_ACCEL_DEADBAND,
3301        };
3302        let moved = self.sensors.get(&key).is_none_or(|stored| {
3303            sample
3304                .data
3305                .iter()
3306                .zip(stored)
3307                .any(|(now, was)| (now - was).abs() > deadband)
3308        });
3309        if moved {
3310            self.sensors.insert(key, sample.data);
3311            request_frame();
3312        }
3313    }
3314
3315    /// Reading for `id`'s `kind`, or `None` while the pad has never reported
3316    /// it. Gyroscope values are degrees per second, accelerometer values g,
3317    /// both in the device frame. A reading holds until the sensor moves past
3318    /// its deadband, so a resting pad reports a stable value instead of noise.
3319    pub fn gamepad_sensor(&self, id: GamepadId, kind: SensorKind) -> Option<[f32; 3]> {
3320        self.sensors.get(&(id.0, kind)).copied()
3321    }
3322
3323    /// Which sensors `id` has reported, in gyroscope-then-accelerometer order.
3324    pub fn gamepad_sensor_kinds(&self, id: GamepadId) -> Vec<SensorKind> {
3325        [SensorKind::Gyroscope, SensorKind::Accelerometer]
3326            .into_iter()
3327            .filter(|kind| self.sensors.contains_key(&(id.0, *kind)))
3328            .collect()
3329    }
3330
3331    /// Vendor and product ids of a connected pad, `None` while it is not
3332    /// connected. Both `0` where the platform reports no ids.
3333    pub fn gamepad_vendor_product(&self, id: GamepadId) -> Option<(u16, u16)> {
3334        self.gamepads
3335            .get(&id.0)
3336            .map(|pad| (pad.vendor_id, pad.product_id))
3337    }
3338
3339    /// Resolve a sensor driver's device name to a connected pad. Sensor nodes
3340    /// are separate from the pad node, so they are matched by name: exact
3341    /// first, then case- and punctuation-insensitively, since drivers append
3342    /// interface suffixes ("Wireless Controller (Vendor: 054c)"). Ambiguous
3343    /// names resolve to nothing rather than picking a pad at random.
3344    pub fn gamepad_id_by_sensor_device(&self, device: &str) -> Option<GamepadId> {
3345        let exact: Vec<u32> = self
3346            .gamepads
3347            .iter()
3348            .filter(|(_, pad)| pad.name == device)
3349            .map(|(id, _)| *id)
3350            .collect();
3351        if exact.len() == 1 {
3352            return Some(GamepadId(exact[0]));
3353        }
3354        if !exact.is_empty() {
3355            return None;
3356        }
3357        let needle = repose_core::input::normalize_device_name(device);
3358        let matches: Vec<u32> = self
3359            .gamepads
3360            .iter()
3361            .filter(|(_, pad)| repose_core::input::normalize_device_name(&pad.name) == needle)
3362            .map(|(id, _)| *id)
3363            .collect();
3364        if matches.len() == 1 {
3365            Some(GamepadId(matches[0]))
3366        } else {
3367            None
3368        }
3369    }
3370
3371    /// Process a key event with an optional host-composed `text` payload
3372    /// (winit `key_event.text`, Android soft-keyboard text, ...).
3373    ///
3374    /// When the modifiers are free of Ctrl/Alt/Meta and the payload is
3375    /// Printable text goes to the focused field first. Otherwise falls through to handle_key.
3376    pub fn handle_key_with_text(&mut self, event: &KeyEvent, composed_text: Option<&str>) -> bool {
3377        if event.event_type == KeyEventType::Down {
3378            let _ = repose_core::request_input_mode(repose_core::InputMode::Keyboard);
3379        }
3380        if event.event_type == KeyEventType::Down
3381            && !event.is_repeat
3382            && !self.ime_preedit
3383            && !event.modifiers.ctrl
3384            && !event.modifiers.alt
3385            && !event.modifiers.meta
3386            && let Some(text) = composed_text
3387            && !text.chars().all(|c| c.is_control())
3388            && self.insert_text_into_focused(text)
3389        {
3390            return true;
3391        }
3392        self.handle_key(event)
3393    }
3394
3395    /// Process a scroll event at an explicit position, honoring a caller-owned
3396    /// scroll capture id (touch gestures initialize the capture themselves).
3397    /// Returns `(consumed, updated_capture_id)`.
3398    pub fn handle_scroll_at(
3399        &mut self,
3400        pos: Vec2,
3401        delta: Vec2,
3402        scroll_capture: Option<u64>,
3403    ) -> (bool, Option<u64>) {
3404        let Some(f) = &self.frame_cache else {
3405            return (false, scroll_capture);
3406        };
3407        // Only the one-finger drag path calls this; the wheel goes through
3408        // `handle_scroll`. Two-finger pan/pinch never becomes a scroll at all.
3409        let (consumed, cap) = dispatch_scroll(
3410            f,
3411            pos,
3412            delta,
3413            scroll_capture,
3414            repose_core::input::ScrollSource::Touch,
3415        );
3416        if consumed {
3417            request_frame();
3418        }
3419        (consumed, cap)
3420    }
3421
3422    /// Next caret blink edge (`Instant`) for the focused text field, if any.
3423    /// Internal - platform should use `next_wakeup_deadline()` instead.
3424    fn next_caret_blink_deadline(&self) -> Option<web_time::Instant> {
3425        let fid = self.sched.focused?;
3426        let frame = self.frame_cache.as_ref()?;
3427        let hit = frame.hit_regions.iter().find(|h| h.id == fid)?;
3428        let key = hit.tf_state_key?;
3429        self.textfield_states
3430            .get(&key)?
3431            .borrow()
3432            .next_blink_deadline()
3433    }
3434
3435    fn next_gesture_deadline(&self) -> Option<web_time::Instant> {
3436        [
3437            self.pending_click
3438                .as_ref()
3439                .map(|(_, at, _)| *at + web_time::Duration::from_millis(DOUBLE_CLICK_MS as u64)),
3440            self.long_press
3441                .as_ref()
3442                .map(|(_, at, _, _)| *at + web_time::Duration::from_millis(LONG_PRESS_MS as u64)),
3443            self.key_long_press
3444                .as_ref()
3445                .filter(|(_, _, fired)| !*fired)
3446                .map(|(_, at, _)| *at + web_time::Duration::from_millis(LONG_PRESS_MS as u64)),
3447            repose_ui::scrollbar_repeat::track_repeat_deadline(),
3448            repose_ui::scrollbar_fade::scrollbar_fade_deadline(),
3449        ]
3450        .into_iter()
3451        .flatten()
3452        .min()
3453    }
3454
3455    /// Centralized wakeup helper for platform runners (caret, snackbar, timers, etc.).
3456    /// Debounced entries live on the shared timer queue, so `timer` covers them.
3457    pub fn next_wakeup_deadline(&self) -> Option<web_time::Instant> {
3458        [
3459            self.next_caret_blink_deadline(),
3460            self.next_gesture_deadline(),
3461            repose_ui::overlay::SnackbarController::next_deadline(),
3462            repose_core::timer::next_deadline(),
3463        ]
3464        .into_iter()
3465        .flatten()
3466        .min()
3467    }
3468
3469    /// Whether a scheduled wakeup is due at `now` (deadline <= now).
3470    pub fn is_wakeup_due(&self, now: web_time::Instant) -> bool {
3471        self.next_wakeup_deadline().is_some_and(|d| d <= now)
3472    }
3473
3474    /// Whether a caret blink edge is due at `now` (deadline <= now).
3475    /// Deprecated: use `is_wakeup_due`.
3476    pub fn is_caret_blink_due(&self, now: web_time::Instant) -> bool {
3477        self.is_wakeup_due(now)
3478    }
3479
3480    /// Desktop-style deadline with idle keep-alive fallback.
3481    /// `idle_cap` is the maximum time the host should sleep without a
3482    /// scheduled wakeup (e.g. 1s on desktop to handle tray Deeplinks).
3483    pub fn next_frame_deadline(
3484        &self,
3485        now: web_time::Instant,
3486        idle_cap: web_time::Duration,
3487    ) -> web_time::Instant {
3488        self.next_wakeup_deadline().unwrap_or(now + idle_cap)
3489    }
3490
3491    /// Tick host-facing overlays (snackbar timeouts) and timers (including
3492    /// debounced entries, which live on the shared timer queue).
3493    /// Call once per redraw.
3494    pub fn tick_overlays(&self) {
3495        repose_ui::overlay::SnackbarController::tick_all();
3496        repose_core::timer::poll();
3497    }
3498
3499    /// Get the cursor suggestion (set during pointer-move handling).
3500    pub fn cursor_suggestion(&self) -> Option<CursorIcon> {
3501        self.cursor.clone()
3502    }
3503
3504    /// Take the cursor suggestion (clears it).
3505    pub fn take_cursor_suggestion(&mut self) -> Option<CursorIcon> {
3506        self.cursor.take()
3507    }
3508
3509    /// Ask the runner for borderless fullscreen on or off, as dock and
3510    /// handheld mode switches need. Read back with
3511    /// [`take_fullscreen_request`](Self::take_fullscreen_request).
3512    pub fn request_fullscreen(&mut self, fullscreen: bool) {
3513        self.fullscreen_request = Some(fullscreen);
3514    }
3515
3516    /// Whether the window is currently fullscreen, as far as requests go.
3517    pub fn fullscreen(&self) -> Option<bool> {
3518        self.fullscreen_request
3519    }
3520
3521    /// Take the pending fullscreen request (clears it).
3522    pub fn take_fullscreen_request(&mut self) -> Option<bool> {
3523        self.fullscreen_request.take()
3524    }
3525
3526    fn move_cursor_suggestion(&self) -> Option<CursorIcon> {
3527        self.frame_cursor_override
3528            .clone()
3529            .or_else(|| self.cursor.clone())
3530    }
3531}
3532
3533impl Default for ReposeRuntime {
3534    fn default() -> Self {
3535        Self::new()
3536    }
3537}
3538
3539/// Inner compose frame logic (no dependency on repose-platform).
3540pub fn compose_frame_inner<F>(
3541    sched: &mut Scheduler,
3542    root_fn: &mut F,
3543    scale: f32,
3544    size_px_u32: (u32, u32),
3545    hover_id: Option<u64>,
3546    pressed_ids: &HashSet<u64>,
3547    tf_states: &HashMap<u64, Rc<RefCell<TextFieldState>>>,
3548) -> Frame
3549where
3550    F: FnMut(&mut Scheduler) -> View,
3551{
3552    compose_frame_inner_with_ancestors(
3553        sched,
3554        root_fn,
3555        scale,
3556        size_px_u32,
3557        hover_id,
3558        &std::collections::HashSet::new(),
3559        pressed_ids,
3560        tf_states,
3561    )
3562}
3563
3564pub fn compose_frame_inner_with_ancestors<F>(
3565    sched: &mut Scheduler,
3566    root_fn: &mut F,
3567    scale: f32,
3568    size_px_u32: (u32, u32),
3569    hover_id: Option<u64>,
3570    hover_ancestors: &std::collections::HashSet<u64>,
3571    pressed_ids: &HashSet<u64>,
3572    tf_states: &HashMap<u64, Rc<RefCell<TextFieldState>>>,
3573) -> Frame
3574where
3575    F: FnMut(&mut Scheduler) -> View,
3576{
3577    for requested_id in repose_core::runtime::drain_focus_requests() {
3578        if requested_id == repose_core::runtime::CLEAR_FOCUS_MARKER {
3579            sched.focused = None;
3580            continue;
3581        }
3582        sched.focused = Some(requested_id);
3583    }
3584
3585    set_density_default(Density { scale });
3586
3587    let current_focused = sched.focused;
3588
3589    let frame = sched.repose(
3590        move |s: &mut Scheduler| with_density(Density { scale }, || (root_fn)(s)),
3591        {
3592            let hover_ancestors = hover_ancestors.clone();
3593            let pressed_ids = pressed_ids.clone();
3594            move |view, _size| {
3595                let interactions = Interactions {
3596                    hover: hover_id,
3597                    hover_ancestors: hover_ancestors.clone(),
3598                    pressed: pressed_ids.clone(),
3599                };
3600                with_density(Density { scale }, || {
3601                    layout_and_paint(view, size_px_u32, tf_states, &interactions, current_focused)
3602                })
3603            }
3604        },
3605    );
3606
3607    for requested_id in repose_core::runtime::drain_focus_requests() {
3608        if requested_id == repose_core::runtime::CLEAR_FOCUS_MARKER {
3609            sched.focused = None;
3610        } else if frame.focus_chain.contains(&requested_id) {
3611            sched.focused = Some(requested_id);
3612        }
3613    }
3614
3615    if let Some(fid) = sched.focused
3616        && !frame.focus_chain.contains(&fid)
3617    {
3618        sched.focused = None;
3619    }
3620
3621    frame
3622}
3623
3624/// Compose `FocusOwner.dispatchKeyEvent` chain: focused node up through its
3625/// hit-region ancestors, root first.
3626/// Falls back to the root alone when unfocused, unknown, or orphaned.
3627fn key_ancestor_chain(f: &Frame, focused: Option<u64>) -> Vec<u64> {
3628    let parent_of: HashMap<u64, Option<u64>> =
3629        f.hit_regions.iter().map(|h| (h.id, h.parent)).collect();
3630    let mut leaf = focused.filter(|id| parent_of.contains_key(id));
3631    if leaf.is_none() {
3632        let ids: HashSet<u64> = parent_of.keys().copied().collect();
3633        leaf = f
3634            .hit_regions
3635            .iter()
3636            .filter(|h| !h.disabled)
3637            .find(|h| h.parent.is_none_or(|p| !ids.contains(&p)))
3638            .map(|h| h.id);
3639    }
3640    let Some(mut cur) = leaf else {
3641        return Vec::new();
3642    };
3643    let mut chain = vec![cur];
3644    while let Some(parent) = parent_of.get(&cur).copied().flatten() {
3645        if chain.contains(&parent) {
3646            break;
3647        }
3648        chain.push(parent);
3649        cur = parent;
3650    }
3651    chain.reverse();
3652    chain
3653}
3654
3655fn hover_chain_for(frame: Option<&Frame>, hover: Option<u64>) -> std::collections::HashSet<u64> {
3656    let Some(f) = frame else {
3657        return std::collections::HashSet::new();
3658    };
3659    let Some(mut cur) = hover else {
3660        return std::collections::HashSet::new();
3661    };
3662    let map: std::collections::HashMap<u64, Option<u64>> =
3663        f.hit_regions.iter().map(|h| (h.id, h.parent)).collect();
3664    let mut set = std::collections::HashSet::new();
3665    loop {
3666        set.insert(cur);
3667        if let Some(Some(parent)) = map.get(&cur).copied() {
3668            cur = parent;
3669        } else {
3670            break;
3671        }
3672    }
3673    set
3674}
3675
3676fn dispatch_hover_change_bubbled(
3677    frame: Option<&Frame>,
3678    leave_map: &HashMap<u64, repose_ui::HitRegionSnapshot>,
3679    hover_id: &mut Option<u64>,
3680    hover_ancestors: &mut std::collections::HashSet<u64>,
3681    new_hover: Option<u64>,
3682    pos: Vec2,
3683    modifiers: Modifiers,
3684) {
3685    let old_hover = *hover_id;
3686    let mut old_chain = hover_ancestors.clone();
3687    if let Some(id) = old_hover {
3688        old_chain.insert(id);
3689    }
3690    let new_chain = hover_chain_for(frame, new_hover);
3691    if old_chain == new_chain {
3692        return;
3693    }
3694    for leave_id in old_chain.difference(&new_chain) {
3695        let current = frame.and_then(|f| f.hit_regions.iter().find(|h| h.id == *leave_id));
3696        if let Some(hit) = current
3697            && let Some(cb) = &hit.on_pointer_leave
3698        {
3699            let mut pe = PointerEvent::new(
3700                PointerId(0),
3701                PointerKind::Mouse,
3702                PointerEventKind::Leave,
3703                pos,
3704                1.0,
3705                modifiers,
3706            );
3707            let (origin, local) = repose_ui::hit_region_pointer_coordinates(hit, pos);
3708            pe.origin = origin;
3709            pe.position = local;
3710            cb(pe);
3711            continue;
3712        }
3713        if let Some(snapshot) = leave_map.get(leave_id)
3714            && let Some(callback) = &snapshot.hit().on_pointer_leave
3715        {
3716            let mut pe = PointerEvent::new(
3717                PointerId(0),
3718                PointerKind::Mouse,
3719                PointerEventKind::Leave,
3720                pos,
3721                1.0,
3722                modifiers,
3723            );
3724            let (origin, local) = snapshot.pointer_coordinates(pos);
3725            pe.origin = origin;
3726            pe.position = local;
3727            callback(pe);
3728        }
3729    }
3730    for enter_id in new_chain.difference(&old_chain) {
3731        if let Some(f) = frame
3732            && let Some(h) = f.hit_regions.iter().find(|h| h.id == *enter_id)
3733            && let Some(cb) = &h.on_pointer_enter
3734        {
3735            let mut pe = PointerEvent::new(
3736                PointerId(0),
3737                PointerKind::Mouse,
3738                PointerEventKind::Enter,
3739                pos,
3740                1.0,
3741                modifiers,
3742            );
3743            let (origin, local) = repose_ui::hit_region_pointer_coordinates(h, pos);
3744            pe.origin = origin;
3745            pe.position = local;
3746            cb(pe);
3747        }
3748    }
3749    *hover_id = new_hover;
3750    hover_ancestors.clear();
3751    for id in &new_chain {
3752        if Some(*id) != new_hover {
3753            hover_ancestors.insert(*id);
3754        }
3755    }
3756}
3757
3758pub fn is_textfield_in_frame(f: &Frame, id: u64) -> bool {
3759    f.semantics_nodes
3760        .iter()
3761        .any(|n| n.id == id && n.role == repose_core::semantics::Role::TextField)
3762}
3763
3764pub fn is_textfield_in_frame_cache(frame_cache: &Option<Rc<Frame>>, id: u64) -> bool {
3765    if let Some(f) = frame_cache {
3766        is_textfield_in_frame(f, id)
3767    } else {
3768        false
3769    }
3770}
3771
3772pub fn hit_index_by_id(frame: &Frame, id: u64) -> Option<usize> {
3773    frame.hit_regions.iter().position(|h| h.id == id)
3774}
3775
3776fn is_multiline_id(f: &Frame, id: u64) -> bool {
3777    f.hit_regions
3778        .iter()
3779        .find(|h| h.id == id)
3780        .map(|h| h.tf_multiline)
3781        .unwrap_or(false)
3782}
3783
3784/// `enabled=false` rejects edits. `readOnly` also rejects
3785/// edits but keeps selection/focus/copy working.
3786fn tf_can_edit(hit: &HitRegion) -> bool {
3787    hit.tf_enabled && !hit.tf_read_only
3788}
3789
3790/// Movement a sensor must show before the app is asked to redraw, in the
3791/// units [`ReposeRuntime::gamepad_sensor`] reports. Each sits above its
3792/// drivers' resting noise and per-count quantization (gyro 0.5 °/s against
3793/// hid-steam's 0.0625 °/s per count, accel 0.02 g against hid-sony's 0.0088
3794/// g), so a connected but untouched pad lets the app idle.
3795const SENSOR_GYRO_DEADBAND: f32 = 0.5;
3796const SENSOR_ACCEL_DEADBAND: f32 = 0.02;
3797
3798fn tf_is_sensitive(hit: &HitRegion) -> bool {
3799    hit.tf_sensitive
3800        || matches!(
3801            hit.keyboard_type,
3802            repose_core::KeyboardType::Password
3803                | repose_core::KeyboardType::NumberPassword
3804                | repose_core::KeyboardType::DecimalPassword
3805                | repose_core::KeyboardType::NumberPasswordSigned
3806                | repose_core::KeyboardType::DecimalPasswordSigned
3807        )
3808}
3809
3810fn is_tf_editable(f: &Frame, id: u64) -> bool {
3811    f.hit_regions
3812        .iter()
3813        .find(|h| h.id == id)
3814        .is_some_and(tf_can_edit)
3815}
3816
3817fn tf_key_of(frame: &Frame, visual_id: u64) -> u64 {
3818    if let Some(i) = frame.hit_regions.iter().position(|h| h.id == visual_id) {
3819        let hr = &frame.hit_regions[i];
3820        return hr.tf_state_key.unwrap_or(hr.id);
3821    }
3822    visual_id
3823}
3824
3825fn set_caret(state: &mut TextFieldState, position: usize, extend: bool) {
3826    if extend {
3827        state.selection.end = position;
3828    } else {
3829        state.selection = position..position;
3830    }
3831}
3832
3833fn notify_text_change(f: &Frame, id: u64, text: String) {
3834    if let Some(h) = f.hit_regions.iter().find(|h| h.id == id)
3835        && let Some(cb) = &h.on_text_change
3836    {
3837        cb(text);
3838    }
3839}
3840
3841fn tf_ensure_caret_visible_for_hit(hit: &HitRegion, state: &mut TextFieldState) {
3842    let metrics = repose_ui::textfield::textfield_metrics(hit);
3843    tf_ensure_caret_visible_with_metrics(state, hit.tf_multiline, &metrics);
3844}
3845
3846fn tf_ensure_caret_visible_with_metrics(
3847    state: &mut TextFieldState,
3848    is_multiline: bool,
3849    metrics: &repose_ui::textfield::TextFieldMetrics,
3850) {
3851    repose_ui::textfield::ensure_caret_visible_with_metrics(state, is_multiline, metrics);
3852}
3853
3854fn index_for_x_bytes_vt(
3855    state: &TextFieldState,
3856    metrics: &repose_ui::textfield::TextFieldMetrics,
3857    x_px: f32,
3858) -> usize {
3859    if let Some(transformation) = &state.visual_transformation {
3860        let annotated = repose_core::AnnotatedString::new(state.text.clone(), vec![]);
3861        let transformed = transformation.filter(&annotated);
3862        let display = repose_ui::textfield::index_for_x_bytes_with_config(
3863            transformed.text.as_str(),
3864            metrics.font_px,
3865            x_px,
3866            metrics.measure_config(),
3867        );
3868        transformed.offset_mapping.transformed_to_original(display)
3869    } else {
3870        repose_ui::textfield::index_for_x_bytes_with_config(
3871            &state.text,
3872            metrics.font_px,
3873            x_px,
3874            metrics.measure_config(),
3875        )
3876    }
3877}
3878
3879fn index_for_xy_bytes_vt(
3880    state: &TextFieldState,
3881    metrics: &repose_ui::textfield::TextFieldMetrics,
3882    wrap_width: f32,
3883    x_px: f32,
3884    y_px: f32,
3885) -> usize {
3886    if let Some(transformation) = &state.visual_transformation {
3887        let annotated = repose_core::AnnotatedString::new(state.text.clone(), vec![]);
3888        let transformed = transformation.filter(&annotated);
3889        let display = repose_ui::textfield::index_for_xy_bytes_with_metrics(
3890            transformed.text.as_str(),
3891            wrap_width,
3892            x_px,
3893            y_px,
3894            metrics,
3895        );
3896        transformed.offset_mapping.transformed_to_original(display)
3897    } else {
3898        repose_ui::textfield::index_for_xy_bytes_with_metrics(
3899            &state.text,
3900            wrap_width,
3901            x_px,
3902            y_px,
3903            metrics,
3904        )
3905    }
3906}
3907
3908/// Dispatch scroll to scroll consumers. Returns (consumed, optional capture id).
3909fn dispatch_scroll(
3910    frame: &Frame,
3911    pos: Vec2,
3912    delta: Vec2,
3913    scroll_capture: Option<u64>,
3914    source: repose_core::input::ScrollSource,
3915) -> (bool, Option<u64>) {
3916    let mut remaining = delta;
3917    let mut first_consumer: Option<u64> = None;
3918    if let Some(cid) = scroll_capture
3919        && let Some(hit) = frame.hit_regions.iter().find(|h| h.id == cid)
3920        && let Some(leftover) = run_scroll_handler(hit, delta, source)
3921    {
3922        if (delta.x - leftover.x).abs() > 0.001 || (delta.y - leftover.y).abs() > 0.001 {
3923            first_consumer = Some(cid);
3924        }
3925        remaining = leftover;
3926        if remaining.x.abs() <= 0.001 && remaining.y.abs() <= 0.001 {
3927            return (true, Some(cid));
3928        }
3929    }
3930
3931    let mut consumed_any = first_consumer.is_some();
3932    for id in repose_ui::hit_test_frame_regions(frame, pos) {
3933        if Some(id) == scroll_capture {
3934            continue;
3935        }
3936        let Some(hit) = frame.hit_regions.iter().find(|hit| hit.id == id) else {
3937            continue;
3938        };
3939        if remaining.x.abs() <= 0.001 && remaining.y.abs() <= 0.001 {
3940            break;
3941        }
3942        if let Some(leftover) = run_scroll_handler(hit, remaining, source) {
3943            let before = remaining;
3944            if (before.x - leftover.x).abs() > 0.001 || (before.y - leftover.y).abs() > 0.001 {
3945                consumed_any = true;
3946                if first_consumer.is_none() {
3947                    first_consumer = Some(hit.id);
3948                }
3949            }
3950            remaining = leftover;
3951        }
3952    }
3953    if consumed_any {
3954        (true, first_consumer)
3955    } else {
3956        (false, None)
3957    }
3958}
3959
3960/// Invoke whichever scroll handler a hit region installed, returning the
3961/// unconsumed remainder. `on_scroll_with_source` wins when both are present.
3962fn run_scroll_handler(
3963    hit: &HitRegion,
3964    delta: Vec2,
3965    source: repose_core::input::ScrollSource,
3966) -> Option<Vec2> {
3967    if let Some(cb) = &hit.on_scroll_with_source {
3968        return Some(cb(delta, source));
3969    }
3970    hit.on_scroll.as_ref().map(|cb| cb(delta))
3971}
3972
3973#[cfg(test)]
3974mod scroll_source_tests {
3975    use super::*;
3976    use repose_core::input::ScrollSource;
3977    use std::cell::RefCell;
3978    use std::rc::Rc;
3979
3980    /// A frame with one source-aware region that records every source it sees
3981    /// and swallows the whole delta.
3982    fn recording_frame(id: u64) -> (Frame, Rc<RefCell<Vec<ScrollSource>>>) {
3983        let seen: Rc<RefCell<Vec<ScrollSource>>> = Rc::new(RefCell::new(Vec::new()));
3984        let recorder = seen.clone();
3985        let frame = Frame {
3986            scene: Default::default(),
3987            hit_regions: vec![HitRegion {
3988                id,
3989                rect: repose_core::Rect {
3990                    x: 0.0,
3991                    y: 0.0,
3992                    w: 200.0,
3993                    h: 200.0,
3994                },
3995                on_scroll_with_source: Some(Rc::new(move |_d: Vec2, s: ScrollSource| {
3996                    recorder.borrow_mut().push(s);
3997                    // Consume everything, so dispatch reports the region as hit.
3998                    Vec2 { x: 0.0, y: 0.0 }
3999                })),
4000                ..Default::default()
4001            }],
4002            semantics_nodes: vec![],
4003            focus_chain: vec![id],
4004        };
4005        (frame, seen)
4006    }
4007
4008    #[test]
4009    fn dispatch_passes_the_source_through_to_the_handler() {
4010        let (frame, seen) = recording_frame(1);
4011        let pos = Vec2 { x: 50.0, y: 50.0 };
4012        let delta = Vec2 { x: 0.0, y: 4.0 };
4013
4014        let (consumed, _) = dispatch_scroll(&frame, pos, delta, None, ScrollSource::Wheel);
4015        assert!(consumed, "handler consumed the delta");
4016        let (consumed, _) = dispatch_scroll(&frame, pos, delta, None, ScrollSource::Touch);
4017        assert!(consumed);
4018
4019        assert_eq!(
4020            *seen.borrow(),
4021            vec![ScrollSource::Wheel, ScrollSource::Touch]
4022        );
4023    }
4024
4025    #[test]
4026    fn legacy_on_scroll_still_runs_and_reports_its_leftover() {
4027        let hit = HitRegion {
4028            id: 3,
4029            on_scroll: Some(Rc::new(|d: Vec2| Vec2 { x: 0.0, y: d.y })),
4030            ..Default::default()
4031        };
4032        let leftover = run_scroll_handler(&hit, Vec2 { x: 7.0, y: 5.0 }, ScrollSource::Touch);
4033        assert_eq!(leftover, Some(Vec2 { x: 0.0, y: 5.0 }));
4034    }
4035
4036    #[test]
4037    fn source_aware_handler_takes_precedence_over_legacy() {
4038        let legacy = Rc::new(RefCell::new(0u32));
4039        let aware = Rc::new(RefCell::new(0u32));
4040        let (legacy_cb, aware_cb) = (legacy.clone(), aware.clone());
4041        let hit = HitRegion {
4042            id: 4,
4043            on_scroll: Some(Rc::new(move |d: Vec2| {
4044                *legacy_cb.borrow_mut() += 1;
4045                d
4046            })),
4047            on_scroll_with_source: Some(Rc::new(move |d: Vec2, _s: ScrollSource| {
4048                *aware_cb.borrow_mut() += 1;
4049                d
4050            })),
4051            ..Default::default()
4052        };
4053        run_scroll_handler(&hit, Vec2 { x: 1.0, y: 1.0 }, ScrollSource::Wheel);
4054        assert_eq!(*aware.borrow(), 1, "source-aware handler runs");
4055        assert_eq!(*legacy.borrow(), 0, "legacy handler is not also run");
4056    }
4057
4058    #[test]
4059    fn region_without_any_scroll_handler_is_skipped() {
4060        let hit = HitRegion {
4061            id: 5,
4062            ..Default::default()
4063        };
4064        assert_eq!(
4065            run_scroll_handler(&hit, Vec2 { x: 1.0, y: 1.0 }, ScrollSource::Wheel),
4066            None
4067        );
4068    }
4069}
4070
4071#[cfg(test)]
4072mod ime_tests {
4073    use super::*;
4074    use repose_core::input::ImeEvent;
4075
4076    const TF_ID: u64 = 100;
4077
4078    fn editable_frame(id: u64) -> Frame {
4079        let rect = repose_core::Rect {
4080            x: 0.0,
4081            y: 0.0,
4082            w: 200.0,
4083            h: 30.0,
4084        };
4085        Frame {
4086            scene: Default::default(),
4087            hit_regions: vec![HitRegion {
4088                id,
4089                rect,
4090                focusable: true,
4091                tf_state_key: Some(id),
4092                tf_multiline: false,
4093                tf_enabled: true,
4094                tf_read_only: false,
4095                ..Default::default()
4096            }],
4097            semantics_nodes: vec![repose_core::runtime::SemNode {
4098                id,
4099                role: repose_core::semantics::Role::TextField,
4100                label: Some("Field".into()),
4101                rect,
4102                focused: true,
4103                ..Default::default()
4104            }],
4105            focus_chain: vec![id],
4106        }
4107    }
4108
4109    fn focused_rt() -> ReposeRuntime {
4110        let mut rt = ReposeRuntime::new();
4111        rt.sched.focused = Some(TF_ID);
4112        rt.cache_frame(editable_frame(TF_ID));
4113        let _ = rt.ensure_textfield_state(TF_ID);
4114        rt
4115    }
4116
4117    #[test]
4118    fn preedit_cursor_uses_byte_offsets_for_non_ascii() {
4119        let mut rt = focused_rt();
4120        rt.handle_ime(&ImeEvent::Update {
4121            text: "あb".to_string(),
4122            cursor: Some((3, 3)),
4123        });
4124        let st = rt.textfield_states[&TF_ID].borrow();
4125        assert_eq!(st.text, "あb");
4126        assert_eq!(st.selection, 3..3);
4127    }
4128
4129    #[test]
4130    fn cancel_discards_preedit() {
4131        let mut rt = focused_rt();
4132        rt.handle_ime(&ImeEvent::Update {
4133            text: "あ".to_string(),
4134            cursor: None,
4135        });
4136        assert!(rt.ime_preedit);
4137        rt.handle_ime(&ImeEvent::Cancel);
4138        let st = rt.textfield_states[&TF_ID].borrow();
4139        assert_eq!(st.text, "");
4140        assert!(st.composition.is_none());
4141        assert!(!rt.ime_preedit);
4142    }
4143
4144    #[test]
4145    fn defocus_finish_keeps_text() {
4146        let mut rt = focused_rt();
4147        rt.handle_ime(&ImeEvent::Update {
4148            text: "x".to_string(),
4149            cursor: None,
4150        });
4151        rt.sched.focused = None;
4152        rt.handle_focus_lost();
4153        assert_eq!(rt.textfield_states[&TF_ID].borrow().text, "x");
4154        assert!(!rt.ime_preedit);
4155    }
4156
4157    #[test]
4158    fn focused_game_node_receives_events() {
4159        let mut rt = ReposeRuntime::new();
4160        rt.sched.focused = Some(TF_ID);
4161        let mut frame = editable_frame(TF_ID);
4162        frame.hit_regions[0].tf_state_key = None;
4163        frame.hit_regions[0].tf_enabled = false;
4164        frame.semantics_nodes.clear();
4165        let seen: Rc<RefCell<Vec<String>>> = Rc::new(RefCell::new(Vec::new()));
4166        let sink = seen.clone();
4167        frame.hit_regions[0].on_ime = Some(Rc::new(move |event| {
4168            if let ImeEvent::Commit(text) = event {
4169                sink.borrow_mut().push(text);
4170            }
4171            true
4172        }));
4173        rt.cache_frame(frame);
4174        rt.handle_ime(&ImeEvent::Commit("あ".to_string()));
4175        rt.handle_ime(&ImeEvent::Cancel);
4176        assert_eq!(&*seen.borrow(), &["あ".to_string()]);
4177        assert!(rt.textfield_states.is_empty());
4178    }
4179
4180    #[test]
4181    fn unconsumed_events_leave_textfields_untouched() {
4182        let mut rt = ReposeRuntime::new();
4183        rt.sched.focused = Some(TF_ID);
4184        let mut frame = editable_frame(TF_ID);
4185        frame.hit_regions[0].tf_state_key = None;
4186        frame.hit_regions[0].tf_enabled = false;
4187        frame.semantics_nodes.clear();
4188        frame.hit_regions[0].on_ime = Some(Rc::new(|_| false));
4189        rt.cache_frame(frame);
4190        rt.handle_ime(&ImeEvent::Commit("x".to_string()));
4191        assert!(rt.textfield_states.is_empty());
4192    }
4193}
4194
4195#[cfg(test)]
4196mod gamepad_tests {
4197    use super::*;
4198    use repose_core::input::{GamepadAxis, GamepadButton, GamepadEvent, GamepadId};
4199
4200    #[test]
4201    fn gamepad_state_mirrors_connection_and_inputs() {
4202        let mut rt = ReposeRuntime::new();
4203        rt.handle_gamepad(&GamepadEvent::Connected {
4204            id: GamepadId(0),
4205            name: "Pad".to_string(),
4206            vendor_id: 0,
4207            product_id: 0,
4208        });
4209        assert_eq!(rt.gamepads.len(), 1);
4210
4211        rt.handle_gamepad(&GamepadEvent::Button {
4212            id: GamepadId(0),
4213            button: GamepadButton::West,
4214            pressed: true,
4215        });
4216        assert!(rt.gamepads[&0].button(GamepadButton::West));
4217
4218        rt.handle_gamepad(&GamepadEvent::Axis {
4219            id: GamepadId(0),
4220            axis: GamepadAxis::LeftStickX,
4221            value: 0.5,
4222        });
4223        assert_eq!(rt.gamepads[&0].axis(GamepadAxis::LeftStickX), 0.5);
4224
4225        rt.handle_gamepad(&GamepadEvent::Button {
4226            id: GamepadId(0),
4227            button: GamepadButton::West,
4228            pressed: false,
4229        });
4230        assert!(!rt.gamepads[&0].button(GamepadButton::West));
4231
4232        rt.handle_gamepad(&GamepadEvent::Disconnected { id: GamepadId(0) });
4233        assert!(rt.gamepads.is_empty());
4234    }
4235
4236    #[test]
4237    fn rumble_requests_queue_and_drain() {
4238        let mut rt = ReposeRuntime::new();
4239        assert!(rt.take_rumble_requests().is_empty());
4240        rt.request_rumble(GamepadId(1), 2.0, -1.0, 150);
4241        rt.stop_rumble(GamepadId(1));
4242        let reqs = rt.take_rumble_requests();
4243        assert_eq!(reqs.len(), 2);
4244        assert_eq!(reqs[0], (1, 1.0, 0.0, 150));
4245        assert_eq!(reqs[1], (1, 0.0, 0.0, 0));
4246        assert!(rt.take_rumble_requests().is_empty());
4247    }
4248
4249    #[test]
4250    fn global_rumble_broadcasts_to_connected_pads() {
4251        let mut rt = ReposeRuntime::new();
4252        rt.handle_gamepad(&GamepadEvent::Connected {
4253            id: GamepadId(1),
4254            name: "Pad".to_string(),
4255            vendor_id: 0,
4256            product_id: 0,
4257        });
4258        repose_core::rumble::push(0.9, 0.5, 200);
4259        repose_core::rumble::push(0.0, 0.0, 0);
4260        let reqs = rt.take_rumble_requests();
4261        assert_eq!(reqs, vec![(1, 0.9, 0.5, 200), (1, 0.0, 0.0, 0)]);
4262        assert!(rt.take_rumble_requests().is_empty());
4263    }
4264
4265    #[test]
4266    fn global_rumble_without_pads_is_dropped() {
4267        let mut rt = ReposeRuntime::new();
4268        repose_core::rumble::push(0.5, 0.5, 100);
4269        assert!(rt.take_rumble_requests().is_empty());
4270        assert!(repose_core::rumble::take().is_empty());
4271    }
4272
4273    #[test]
4274    fn sensor_samples_are_latest_wins_per_pad_and_kind() {
4275        let mut rt = ReposeRuntime::new();
4276        rt.handle_sensor_sample(GamepadId(0), SensorSample::gyroscope(1.0, 2.0, 3.0));
4277        rt.handle_sensor_sample(GamepadId(0), SensorSample::gyroscope(4.0, 5.0, 6.0));
4278        rt.handle_sensor_sample(GamepadId(0), SensorSample::accelerometer(0.0, 0.0, 1.0));
4279        rt.handle_sensor_sample(GamepadId(2), SensorSample::gyroscope(-1.0, 0.0, 0.0));
4280
4281        assert_eq!(
4282            rt.gamepad_sensor(GamepadId(0), SensorKind::Gyroscope),
4283            Some([4.0, 5.0, 6.0])
4284        );
4285        assert_eq!(
4286            rt.gamepad_sensor(GamepadId(0), SensorKind::Accelerometer),
4287            Some([0.0, 0.0, 1.0])
4288        );
4289        assert_eq!(
4290            rt.gamepad_sensor(GamepadId(2), SensorKind::Gyroscope),
4291            Some([-1.0, 0.0, 0.0])
4292        );
4293        assert_eq!(rt.gamepad_sensor(GamepadId(1), SensorKind::Gyroscope), None);
4294        assert_eq!(
4295            rt.gamepad_sensor_kinds(GamepadId(0)),
4296            vec![SensorKind::Gyroscope, SensorKind::Accelerometer]
4297        );
4298        assert_eq!(
4299            rt.gamepad_sensor_kinds(GamepadId(2)),
4300            vec![SensorKind::Gyroscope]
4301        );
4302    }
4303
4304    #[test]
4305    fn resting_sensor_noise_holds_still_until_motion_accumulates() {
4306        let mut rt = ReposeRuntime::new();
4307        rt.handle_sensor_sample(GamepadId(0), SensorSample::gyroscope(0.0, 0.0, 0.0));
4308        rt.handle_sensor_sample(GamepadId(0), SensorSample::gyroscope(0.1, -0.1, 0.05));
4309        rt.handle_sensor_sample(GamepadId(0), SensorSample::accelerometer(1.0, 0.004, 0.0));
4310        rt.handle_sensor_sample(GamepadId(0), SensorSample::accelerometer(1.0, -0.004, 0.0));
4311
4312        assert_eq!(
4313            rt.gamepad_sensor(GamepadId(0), SensorKind::Gyroscope),
4314            Some([0.0, 0.0, 0.0])
4315        );
4316        assert_eq!(
4317            rt.gamepad_sensor(GamepadId(0), SensorKind::Accelerometer),
4318            Some([1.0, 0.004, 0.0])
4319        );
4320
4321        rt.handle_sensor_sample(GamepadId(0), SensorSample::gyroscope(40.0, 0.0, 0.0));
4322        rt.handle_sensor_sample(GamepadId(0), SensorSample::accelerometer(1.03, 0.0, 0.0));
4323
4324        assert_eq!(
4325            rt.gamepad_sensor(GamepadId(0), SensorKind::Gyroscope),
4326            Some([40.0, 0.0, 0.0])
4327        );
4328        assert_eq!(
4329            rt.gamepad_sensor(GamepadId(0), SensorKind::Accelerometer),
4330            Some([1.03, 0.0, 0.0])
4331        );
4332    }
4333
4334    #[test]
4335    fn sensor_state_drops_with_the_pad() {
4336        let mut rt = ReposeRuntime::new();
4337        rt.handle_gamepad(&GamepadEvent::Connected {
4338            id: GamepadId(0),
4339            name: "Pad".to_string(),
4340            vendor_id: 0,
4341            product_id: 0,
4342        });
4343        rt.handle_sensor_sample(GamepadId(0), SensorSample::gyroscope(1.0, 1.0, 1.0));
4344        assert!(
4345            rt.gamepad_sensor(GamepadId(0), SensorKind::Gyroscope)
4346                .is_some()
4347        );
4348
4349        rt.handle_gamepad(&GamepadEvent::Disconnected { id: GamepadId(0) });
4350        assert_eq!(rt.gamepad_sensor(GamepadId(0), SensorKind::Gyroscope), None);
4351    }
4352
4353    #[test]
4354    fn sensor_devices_resolve_to_connected_pads_by_name() {
4355        let mut rt = ReposeRuntime::new();
4356        rt.handle_gamepad(&GamepadEvent::Connected {
4357            id: GamepadId(3),
4358            name: "Wireless Controller (Vendor: 054c)".to_string(),
4359            vendor_id: 0,
4360            product_id: 0,
4361        });
4362
4363        assert_eq!(
4364            rt.gamepad_id_by_sensor_device("Wireless Controller (Vendor: 054c)"),
4365            Some(GamepadId(3))
4366        );
4367        assert_eq!(
4368            rt.gamepad_id_by_sensor_device("wireless controller vendor 054c"),
4369            Some(GamepadId(3))
4370        );
4371        assert_eq!(rt.gamepad_id_by_sensor_device("Some Other Pad"), None);
4372    }
4373
4374    #[test]
4375    fn ambiguous_sensor_device_names_resolve_to_nothing() {
4376        let mut rt = ReposeRuntime::new();
4377        for (id, name) in [(0, "Wireless Controller"), (1, "wireless controller!")] {
4378            rt.handle_gamepad(&GamepadEvent::Connected {
4379                id: GamepadId(id),
4380                name: name.to_string(),
4381                vendor_id: 0,
4382                product_id: 0,
4383            });
4384        }
4385        assert_eq!(rt.gamepad_id_by_sensor_device("WIRELESS CONTROLLER"), None);
4386        assert_eq!(
4387            rt.gamepad_id_by_sensor_device("Wireless Controller"),
4388            Some(GamepadId(0))
4389        );
4390    }
4391
4392    #[test]
4393    fn two_pads_sharing_one_name_never_win_by_hash_order() {
4394        let mut rt = ReposeRuntime::new();
4395        for id in 0..2 {
4396            rt.handle_gamepad(&GamepadEvent::Connected {
4397                id: GamepadId(id),
4398                name: "Nintendo Switch Pro Controller".to_string(),
4399                vendor_id: 0,
4400                product_id: 0,
4401            });
4402        }
4403        assert_eq!(
4404            rt.gamepad_id_by_sensor_device("Nintendo Switch Pro Controller"),
4405            None,
4406            "an identical name cannot pick a pad by map order"
4407        );
4408    }
4409}