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