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, Dp, HitRegion, Interaction, RenderContext, Scene, Sp, Vec2, View, request_frame,
15};
16use repose_ui::textfield::{
17    TF_FONT_SP, TextFieldState, TextMeasureConfig, caret_xy_for_byte, measure_text,
18};
19use repose_ui::{Interactions, layout_and_paint};
20
21fn ensure_tf_state(
22    map: &mut HashMap<u64, Rc<RefCell<TextFieldState>>>,
23    key: u64,
24    seed: &str,
25) -> Rc<RefCell<TextFieldState>> {
26    map.entry(key)
27        .or_insert_with(|| {
28            Rc::new(RefCell::new(if seed.is_empty() {
29                TextFieldState::new()
30            } else {
31                TextFieldState::with_text(seed.to_string())
32            }))
33        })
34        .clone()
35}
36
37fn ensure_all_tf_states_from_frame(
38    map: &mut HashMap<u64, Rc<RefCell<TextFieldState>>>,
39    frame: &Frame,
40) {
41    for hit in &frame.hit_regions {
42        if let Some(key) = hit.tf_state_key {
43            let st = ensure_tf_state(map, key, hit.tf_value.as_str());
44            // Only sync text; do not move caret.
45            st.borrow_mut()
46                .apply_controlled_value(hit.tf_value.as_str());
47        }
48    }
49}
50
51fn is_tf_hit(f: &Frame, id: u64) -> bool {
52    f.hit_regions
53        .iter()
54        .any(|h| h.id == id && h.tf_state_key.is_some())
55        || is_textfield_in_frame(f, id)
56}
57
58/// Platform-directed side effects requested by the UI.
59#[derive(Clone, Default)]
60pub struct PlatformOutput {
61    /// Cursor to display (None = default/system cursor).
62    pub cursor: Option<CursorIcon>,
63    /// Whether IME input is allowed for the currently focused widget.
64    pub ime_allowed: bool,
65    /// IME cursor area in logical (DPI-scaled) coordinates: (x, y, width, height).
66    pub ime_cursor_area: Option<(f64, f64, f64, f64)>,
67    /// Text to write to the clipboard (transient - set once per frame, cleared after read).
68    pub clipboard_text: Option<String>,
69
70    /// IME / soft-keyboard hints for the focused text field. The host should
71    /// apply these to the OS keyboard and to `set_ime_purpose` / web attrs.
72    pub ime_purpose: repose_core::ImePurposeHint,
73    pub ime_auto_correct: bool,
74    pub ime_capitalization: repose_core::KeyboardCapitalization,
75    pub keyboard_type: repose_core::KeyboardType,
76
77    /// Whether the app theme is dark, so the host can sync OS window chrome
78    /// (titlebar, caption buttons). `None` = don't touch the OS chrome.
79    pub window_theme_dark: Option<bool>,
80}
81
82/// Output of a single frame: the rendered scene plus metadata for the host.
83pub struct FrameOutput {
84    /// The scene graph for rendering.
85    pub scene: Scene,
86    /// Hit regions for pointer dispatch between frames.
87    pub hit_regions: Vec<HitRegion>,
88    /// Semantics nodes for a11y.
89    pub semantics_nodes: Vec<repose_core::runtime::SemNode>,
90    /// Focus chain for tab navigation.
91    pub focus_chain: Vec<u64>,
92    /// Platform-side effects (cursor, IME, clipboard).
93    pub platform: PlatformOutput,
94    /// Whether the UI wants pointer events (if false, host can pass events through).
95    pub wants_pointer: bool,
96    /// Whether the UI wants keyboard events (if false, host can pass events through).
97    pub wants_keyboard: bool,
98}
99
100impl FrameOutput {
101    /// Consume the frame into a `repose_core::Frame` for hit-testing/caching
102    /// by the host. Drops the platform-output and pointer metadata.
103    pub fn into_frame(self) -> Frame {
104        Frame {
105            scene: self.scene,
106            hit_regions: self.hit_regions,
107            semantics_nodes: self.semantics_nodes,
108            focus_chain: self.focus_chain,
109        }
110    }
111}
112
113/// Result of a pointer-move event processed by the runtime.
114pub struct PointerMoveResult {
115    /// Updated cursor suggestion for the host.
116    pub cursor: Option<CursorIcon>,
117    /// The id of the element under the pointer, if any.
118    pub hover_id: Option<u64>,
119}
120
121/// Result of a pointer-button event processed by the runtime.
122#[derive(Debug)]
123pub struct PointerButtonResult {
124    /// Id of the element that received focus (if any).
125    pub focused: Option<u64>,
126    /// Id of the captured element.
127    pub capture_id: Option<u64>,
128    /// Whether the event was consumed by the UI.
129    pub consumed: bool,
130    /// Whether an accessibility announcement was triggered.
131    pub needs_a11y_announce: bool,
132    /// Set on release when a click fired, so hosts can announce activation
133    /// without re-reading runtime state that has already been cleared.
134    pub clicked_id: Option<u64>,
135}
136
137// ViewConfiguration defaults
138const LONG_PRESS_MS: u128 = 500;
139const DOUBLE_CLICK_MS: u128 = 300;
140const DOUBLE_TAP_MIN_MS: u128 = 40;
141const LONG_PRESS_SLOP_DP: f32 = 18.0;
142
143/// Embeddable Repose runtime.
144///
145/// Manages composition scheduling, input routing, text-field state, and
146/// pointer/key dispatch.  The host owns the event loop and GPU device. This
147/// is purely the UI logic layer.
148pub struct ReposeRuntime {
149    pub sched: Scheduler,
150    pub scale: f32,
151
152    pub modifiers: Modifiers,
153    pub mouse_pos_px: (f32, f32),
154    /// Whether the pointer is currently inside the window.
155    pub pointer_inside: bool,
156    pub hover_id: Option<u64>,
157    pub hover_ancestors: std::collections::HashSet<u64>,
158    /// Needed so `Leave` still fires
159    /// even when the hovered hit region is removed from the tree between frames.
160    /// Rebuilt on every `cache_frame`.
161    hover_leave: HashMap<u64, (f32, f32, f32, f32, Rc<dyn Fn(PointerEvent)>)>,
162    pub capture_id: Option<u64>,
163    /// Hit path captured at pointer-down: every region under the pointer,
164    /// ordered bottom-up (deepest child first, ancestors last).
165    pub hit_path: Option<Vec<u64>>,
166    /// Which scroll consumer currently owns the wheel gesture.
167    pub scroll_capture_id: Option<u64>,
168    last_scroll_at: Option<web_time::Instant>,
169    pub pressed_ids: HashSet<u64>,
170    pub ime_preedit: bool,
171    pub key_pressed_active: Option<u64>,
172    pub last_focus: Option<u64>,
173
174    last_up: Option<(u64, web_time::Instant, f32, f32)>,
175    /// Position/time of the most recent pointer-down, used to time the second
176    /// tap of a double click (Compose: window + min time measured to the
177    /// second DOWN, not its up).
178    last_down: Option<(u64, web_time::Instant)>,
179    /// Set when the second tap of a double-click qualifies (within
180    /// [DOUBLE_TAP_MIN_MS, DOUBLE_CLICK_MS] of the first tap's up). Its up
181    /// Confirms the double click. A canceled second tap falls back to the first tap's onClick.
182    double_candidate: Option<u64>,
183    long_press: Option<(u64, web_time::Instant, f32, f32)>,
184    /// Keyboard long-press (Compose combinedClickable: holding Space/Enter
185    /// past LONG_PRESS_MS fires on_long_click). `bool` = already fired.
186    key_long_press: Option<(u64, web_time::Instant, bool)>,
187    suppress_next_click: bool,
188    pending_click: Option<(u64, web_time::Instant, Rc<dyn Fn()>)>,
189
190    pub frame_cache: Option<Frame>,
191
192    cursor: Option<CursorIcon>,
193
194    pub textfield_states: HashMap<u64, Rc<RefCell<TextFieldState>>>,
195    /// Connected gamepads by backend id: display name plus live button/axis
196    /// state. Fed by [`ReposeRuntime::handle_gamepad`].
197    pub gamepads: HashMap<u32, GamepadPad>,
198    /// Queued dual-motor rumble requests.
199    pub pending_rumble: Vec<(u32, f32, f32, u32)>,
200}
201
202/// Live state of one connected gamepad, mirrored from [`GamepadEvent`]s.
203#[derive(Clone, Debug, Default)]
204pub struct GamepadPad {
205    pub name: String,
206    pub pressed: HashSet<GamepadButton>,
207    pub axes: HashMap<GamepadAxis, f32>,
208}
209
210impl GamepadPad {
211    pub fn button(&self, button: GamepadButton) -> bool {
212        self.pressed.contains(&button)
213    }
214
215    pub fn axis(&self, axis: GamepadAxis) -> f32 {
216        self.axes.get(&axis).copied().unwrap_or(0.0)
217    }
218}
219
220impl ReposeRuntime {
221    pub fn new() -> Self {
222        Self {
223            sched: Scheduler::new(),
224            scale: 1.0,
225            modifiers: Modifiers::default(),
226            mouse_pos_px: (0.0, 0.0),
227            pointer_inside: false,
228            hover_id: None,
229            hover_ancestors: std::collections::HashSet::new(),
230            hover_leave: HashMap::new(),
231            capture_id: None,
232            hit_path: None,
233            scroll_capture_id: None,
234            last_scroll_at: None,
235            pressed_ids: HashSet::new(),
236            ime_preedit: false,
237            key_pressed_active: None,
238            last_focus: None,
239            last_up: None,
240            last_down: None,
241            double_candidate: None,
242            long_press: None,
243            key_long_press: None,
244            suppress_next_click: false,
245            pending_click: None,
246            frame_cache: None,
247            cursor: None,
248            textfield_states: HashMap::new(),
249            gamepads: HashMap::new(),
250            pending_rumble: Vec::new(),
251        }
252    }
253
254    /// Set the logical viewport size (in device pixels).
255    pub fn set_viewport(&mut self, width_px: u32, height_px: u32) {
256        self.sched.size = (width_px, height_px);
257    }
258
259    /// Set viewport size and DPI scale factor.
260    pub fn set_viewport_and_scale(&mut self, width_px: u32, height_px: u32, scale: f32) {
261        self.scale = scale;
262        self.sched.size = (width_px, height_px);
263    }
264
265    /// Advance animations. Call before `compose` each frame.
266    pub fn tick_animations(&self) {
267        repose_core::animation_driver::tick();
268    }
269
270    pub fn poll_gesture_timers(&mut self) {
271        self.poll_long_press();
272        self.flush_pending_click();
273        self.poll_key_long_press();
274    }
275
276    /// Compose and layout a frame, returning the output for rendering.
277    ///
278    /// Call `tick_animations` before this and `cache_frame` after (once you
279    /// have applied any host-specific overlays like the devtools inspector).
280    pub fn compose<F>(&mut self, root_fn: &mut F, render_ctx: &RenderContext) -> Frame
281    where
282        F: FnMut(&mut Scheduler, &RenderContext) -> View,
283    {
284        self.poll_long_press();
285        self.flush_pending_click();
286        self.poll_key_long_press();
287
288        let size = self.sched.size;
289        let rc = render_ctx.clone();
290        let mut compose_once = |this: &mut Self| {
291            let mut inner = |s: &mut Scheduler| (root_fn)(s, &rc);
292            match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
293                compose_frame_inner_with_ancestors(
294                    &mut this.sched,
295                    &mut inner,
296                    this.scale,
297                    size,
298                    this.hover_id,
299                    &this.hover_ancestors,
300                    &this.pressed_ids,
301                    &this.textfield_states,
302                )
303            })) {
304                Ok(frame) => frame,
305                Err(_) => {
306                    log::error!("compose panicked; presenting last good frame");
307                    this.frame_cache.clone().unwrap_or_else(|| Frame {
308                        scene: Default::default(),
309                        hit_regions: Vec::new(),
310                        semantics_nodes: Vec::new(),
311                        focus_chain: Vec::new(),
312                    })
313                }
314            }
315        };
316
317        let frame = compose_once(self);
318
319        // Reconcile hover against the *new* hit list before presenting. If the
320        // hover target changed, recompose once so paint uses the correct
321        // Interactions.hover (eliminates 1-frame sticky/wrong hover).
322        let hover_before = self.hover_id;
323        self.reconcile_hover_from_mouse_pos(&frame);
324        if self.hover_id != hover_before {
325            // Refresh the retained leave map from the first frame so Leave on
326            // further changes still works (cache_frame does this fully).
327            self.hover_leave.clear();
328            for h in &frame.hit_regions {
329                if let Some(cb) = &h.on_pointer_leave {
330                    self.hover_leave
331                        .insert(h.id, (h.rect.x, h.rect.y, h.rect.w, h.rect.h, cb.clone()));
332                }
333            }
334            // Hover should be stable: same geometry + same pointer. Do not loop.
335            return compose_once(self);
336        }
337        frame
338    }
339
340    /// Compose a frame and return structured output for the host.
341    pub fn frame(
342        &mut self,
343        mut root_fn: impl FnMut(&mut Scheduler, &RenderContext) -> View,
344        render_ctx: &RenderContext,
345    ) -> FrameOutput {
346        let captured = Rc::new(RefCell::new(None::<String>));
347        let hook = captured.clone();
348        repose_core::clipboard::set_clipboard_observer(Box::new(move |text| {
349            *hook.borrow_mut() = Some(text.to_string());
350        }));
351
352        let f = self.compose(&mut root_fn, render_ctx);
353
354        repose_core::clipboard::clear_clipboard_observer();
355        let clipboard_text = captured.borrow_mut().take();
356
357        let wants_pointer = self.hover_id.is_some() || self.capture_id.is_some();
358
359        let ime_allowed = self.sched.focused.is_some_and(|fid| {
360            f.semantics_nodes
361                .iter()
362                .any(|n| n.id == fid && n.role == repose_core::semantics::Role::TextField)
363        });
364
365        let focused_hit = self
366            .sched
367            .focused
368            .and_then(|fid| f.hit_regions.iter().find(|h| h.id == fid));
369
370        let ime_cursor_area = if ime_allowed {
371            focused_hit.map(|hit| {
372                let sf = self.scale as f64;
373                (
374                    hit.rect.x as f64 / sf,
375                    hit.rect.y as f64 / sf,
376                    hit.rect.w as f64 / sf,
377                    hit.rect.h as f64 / sf,
378                )
379            })
380        } else {
381            None
382        };
383
384        let focused_is_textfield = ime_allowed
385            || self.sched.focused.is_some_and(|fid| {
386                f.hit_regions
387                    .iter()
388                    .any(|h| h.id == fid && h.tf_state_key.is_some())
389            });
390        let wants_keyboard = focused_is_textfield || self.ime_preedit;
391
392        let (ime_purpose, ime_auto_correct, ime_capitalization, keyboard_type) =
393            match (ime_allowed, focused_hit) {
394                (true, Some(hit)) => (
395                    hit.keyboard_type.ime_purpose_hint(),
396                    hit.auto_correct.unwrap_or(true),
397                    hit.capitalization,
398                    hit.keyboard_type,
399                ),
400                _ => (
401                    repose_core::ImePurposeHint::Normal,
402                    true,
403                    repose_core::KeyboardCapitalization::Unspecified,
404                    repose_core::KeyboardType::Unspecified,
405                ),
406            };
407
408        let platform = PlatformOutput {
409            cursor: self.take_cursor_suggestion(),
410            ime_allowed,
411            ime_cursor_area,
412            clipboard_text,
413            ime_purpose,
414            ime_auto_correct,
415            ime_capitalization,
416            keyboard_type,
417            window_theme_dark: Some(repose_core::locals::theme().is_dark()),
418        };
419        FrameOutput {
420            scene: f.scene,
421            hit_regions: f.hit_regions,
422            semantics_nodes: f.semantics_nodes,
423            focus_chain: f.focus_chain,
424            platform,
425            wants_pointer,
426            wants_keyboard,
427        }
428    }
429
430    /// Store the composed frame for event hit testing.
431    pub fn cache_frame(&mut self, frame: Frame) {
432        self.hover_leave.clear();
433        for h in &frame.hit_regions {
434            if let Some(cb) = &h.on_pointer_leave {
435                self.hover_leave
436                    .insert(h.id, (h.rect.x, h.rect.y, h.rect.w, h.rect.h, cb.clone()));
437            }
438        }
439        self.frame_cache = Some(frame);
440    }
441
442    /// Post-compose host-agnostic bookkeeping.
443    /// this lazy-initializes text-field state for focus-requester paths, reconciles
444    /// hover against the new hit list, and publishes the frame to the DnD
445    /// registry. Replaces platform-local copies of this logic.
446    pub fn after_compose(&mut self, frame: &Frame, scale: f32) {
447        ensure_all_tf_states_from_frame(&mut self.textfield_states, frame);
448        self.ensure_focused_state_in_frame(frame);
449        self.reconcile_hover_from_mouse_pos(frame);
450        repose_core::dnd::set_dnd_frame(Some(frame.clone()));
451        repose_core::dnd::set_dnd_scale(scale);
452    }
453
454    /// Lazy-init the focused textfield's persistent state (FocusRequester
455    /// paths don't create it until first click). Resets the caret blink.
456    pub fn ensure_focused_textfield_state(&mut self) {
457        let Some(f) = self.frame_cache.clone() else {
458            return;
459        };
460        self.ensure_focused_state_in_frame(&f);
461    }
462
463    /// Shared helper: create a persistent `TextFieldState` for the focused
464    /// widget (if it is a textfield with a state key) and reset its caret
465    /// blink. No-op when the focused widget already has state.
466    fn ensure_focused_state_in_frame(&mut self, frame: &Frame) {
467        let Some(fid) = self.sched.focused else {
468            return;
469        };
470        if let Some(hit) = frame.hit_regions.iter().find(|h| h.id == fid)
471            && let Some(key) = hit.tf_state_key
472        {
473            let st = ensure_tf_state(&mut self.textfield_states, key, hit.tf_value.as_str());
474            st.borrow_mut().apply_controlled_value(&hit.tf_value);
475        }
476    }
477
478    /// Cache a composed [`FrameOutput`] for hit-testing: rebuilds the retained
479    /// hover-leave map, reconciles hover, lazy-initializes focused textfield
480    /// state, and publishes the DnD frame/scale to the input registry.
481    pub fn cache_from_output(&mut self, out: &FrameOutput) {
482        let frame = Frame {
483            scene: out.scene.clone(),
484            hit_regions: out.hit_regions.clone(),
485            semantics_nodes: out.semantics_nodes.clone(),
486            focus_chain: out.focus_chain.clone(),
487        };
488        self.after_compose(&frame, self.scale);
489        self.cache_frame(frame);
490    }
491
492    /// One-shot host tick: advance animations, compose a frame, and publish
493    /// the result (hover reconciliation, focused textfield lazy-init, DnD
494    /// frame/scale) in a single call.
495    pub fn compose_frame_output<F>(
496        &mut self,
497        root: &mut F,
498        render_ctx: &RenderContext,
499    ) -> FrameOutput
500    where
501        F: FnMut(&mut Scheduler, &RenderContext) -> View,
502    {
503        self.tick_animations();
504        let out = self.frame(root, render_ctx);
505        self.cache_from_output(&out);
506        out
507    }
508
509    fn dispatch_pointer_to_path(&self, kind: PointerEventKind, pos: Vec2, path: &[u64]) {
510        let Some(f) = &self.frame_cache else {
511            return;
512        };
513        let base = PointerEvent::new(
514            PointerId(0),
515            PointerKind::Mouse,
516            kind,
517            pos,
518            1.0,
519            self.modifiers,
520        );
521        for &id in path {
522            let Some(h) = f.hit_regions.iter().find(|h| h.id == id) else {
523                continue;
524            };
525            let cb = match kind {
526                PointerEventKind::Down(_) => &h.on_pointer_down,
527                PointerEventKind::Up(_) => &h.on_pointer_up,
528                PointerEventKind::Move => &h.on_pointer_move,
529                PointerEventKind::Cancel => &h.on_pointer_cancel,
530                PointerEventKind::Enter | PointerEventKind::Leave => continue,
531            };
532            let Some(cb) = cb else {
533                continue;
534            };
535            let mut ev = base.clone();
536            ev.origin = Vec2 {
537                x: h.rect.x,
538                y: h.rect.y,
539            };
540            ev.position = pos - ev.origin;
541            cb(ev);
542            if base.is_consumed() {
543                break;
544            }
545        }
546    }
547
548    /// Process a pointer-move event. Returns cursor suggestion.
549    pub fn handle_pointer_move(&mut self, pos: Vec2) -> PointerMoveResult {
550        self.mouse_pos_px = (pos.x, pos.y);
551        self.pointer_inside = true;
552
553        if dnd::handle_drag_action(&DragAction::Move {
554            position: pos,
555            modifiers: self.modifiers,
556        }) {
557            request_frame();
558            return PointerMoveResult {
559                cursor: if dnd::is_dragging() {
560                    Some(CursorIcon::Grabbing)
561                } else {
562                    self.cursor
563                },
564                hover_id: self.hover_id,
565            };
566        }
567
568        let Some(f) = &self.frame_cache else {
569            return PointerMoveResult {
570                cursor: None,
571                hover_id: None,
572            };
573        };
574
575        if let Some((_, _, x0, y0)) = self.long_press {
576            let slop = LONG_PRESS_SLOP_DP * self.scale;
577            let dx = pos.x - x0;
578            let dy = pos.y - y0;
579            if dx * dx + dy * dy > slop * slop {
580                self.long_press = None;
581            }
582        }
583
584        // Cancel the long press once the pointer leaves the element's bounds
585        if self.long_press.is_some()
586            && let Some(lid) = self.long_press.map(|(id, _, _, _)| id)
587            && f.hit_regions
588                .iter()
589                .find(|h| h.id == lid)
590                .is_none_or(|h| !h.rect.contains(pos))
591        {
592            self.long_press = None;
593        }
594
595        // TextField/TextArea drag selection (if captured)
596        if let Some(cid) = self.capture_id
597            && is_tf_hit(f, cid)
598            && let Some(hit) = f.hit_regions.iter().find(|h| h.id == cid)
599        {
600            let key = tf_key_of(f, cid);
601            if let Some(st_rc) = self.textfield_states.get(&key) {
602                let mut st = st_rc.borrow_mut();
603                let (ox, oy) = hit.tf_content_origin.unwrap_or((hit.rect.x, hit.rect.y));
604                let content_x = (pos.x - ox + st.scroll_offset).max(0.0);
605                let content_y = (pos.y - oy + st.scroll_offset_y).max(0.0);
606                let font_size_sp = if hit.tf_font_size != Sp::ZERO {
607                    hit.tf_font_size
608                } else {
609                    TF_FONT_SP
610                };
611                let font_px = font_size_sp.to_px().0;
612                let wrap_w = st.inner_width.max(1.0);
613                let idx = if hit.tf_multiline {
614                    index_for_xy_bytes_vt(&st, font_px, wrap_w, content_x, content_y)
615                } else {
616                    index_for_x_bytes_vt(&st, font_px, content_x)
617                };
618                st.drag_to(idx);
619            }
620        }
621
622        let top = f
623            .hit_regions
624            .iter()
625            .rev()
626            .find(|h| !h.disabled && h.rect.contains(pos));
627
628        self.cursor = top.and_then(|h| h.cursor).or(Some(CursorIcon::Default));
629
630        let new_hover = top.map(|h| h.id);
631
632        let old_chain = hover_chain_for(Some(f), self.hover_id);
633        let new_chain = hover_chain_for(Some(f), new_hover);
634        if new_chain != old_chain {
635            dispatch_hover_change_bubbled(
636                Some(f),
637                &self.hover_leave,
638                &mut self.hover_id,
639                &mut self.hover_ancestors,
640                new_hover,
641                pos,
642                self.modifiers,
643            );
644            request_frame();
645        }
646
647        if let Some(path) = &self.hit_path {
648            self.dispatch_pointer_to_path(PointerEventKind::Move, pos, path);
649        } else if let Some(h) = top
650            && let Some(cb) = &h.on_pointer_move
651        {
652            let mut pe = PointerEvent::new(
653                PointerId(0),
654                PointerKind::Mouse,
655                PointerEventKind::Move,
656                pos,
657                1.0,
658                self.modifiers,
659            );
660            pe.origin = Vec2 {
661                x: h.rect.x,
662                y: h.rect.y,
663            };
664            pe.position = pe.position - pe.origin;
665            cb(pe);
666        }
667
668        PointerMoveResult {
669            cursor: self.cursor,
670            hover_id: self.hover_id,
671        }
672    }
673
674    /// Process a pointer button press. Returns focus/capture info.
675    pub fn handle_pointer_press(
676        &mut self,
677        pos: Vec2,
678        button: PointerButton,
679    ) -> PointerButtonResult {
680        self.mouse_pos_px = (pos.x, pos.y);
681        let _ = repose_core::request_input_mode(repose_core::InputMode::Touch);
682
683        let Some(f) = &self.frame_cache else {
684            return PointerButtonResult {
685                focused: None,
686                capture_id: None,
687                consumed: false,
688                needs_a11y_announce: false,
689                clicked_id: None,
690            };
691        };
692
693        let mut result = PointerButtonResult {
694            focused: None,
695            capture_id: None,
696            consumed: false,
697            needs_a11y_announce: false,
698            clicked_id: None,
699        };
700
701        if let Some(hit) = f
702            .hit_regions
703            .iter()
704            .rev()
705            .find(|h| !h.disabled && h.rect.contains(pos))
706        {
707            let mut path: Vec<u64> = vec![hit.id];
708            let mut cur = hit.parent;
709            while let Some(pid) = cur {
710                path.push(pid);
711                cur = f
712                    .hit_regions
713                    .iter()
714                    .find(|h| h.id == pid)
715                    .and_then(|h| h.parent);
716            }
717            self.hit_path = Some(path.clone());
718
719            dnd::handle_drag_action(&DragAction::Press {
720                position: pos,
721                capture_id: hit.id,
722                kind: PointerKind::Mouse,
723                modifiers: self.modifiers,
724            });
725
726            self.capture_id = Some(hit.id);
727            result.capture_id = Some(hit.id);
728            result.consumed = true;
729
730            // A new press cancels a still-pending delayed single click only
731            // when it qualifies as the second tap of a double click on the
732            // same element (Compose detectTapGestures).
733            self.last_down = Some((hit.id, web_time::Instant::now()));
734            // The second DOWN must land within
735            // [doubleTapMinTimeMillis, doubleTapTimeoutMillis] after the first
736            // tap's UP. No distance/slop requirement between the taps.
737            self.double_candidate = if hit.on_double_click.is_some()
738                && self.last_up.is_some_and(|(pid, t0, _, _)| {
739                    pid == hit.id
740                        && self.last_down.is_some_and(|(did, dt)| {
741                            did == hit.id
742                                && dt.duration_since(t0).as_millis() >= DOUBLE_TAP_MIN_MS
743                                && dt.duration_since(t0).as_millis() <= DOUBLE_CLICK_MS
744                        })
745                }) {
746                Some(hit.id)
747            } else {
748                None
749            };
750            if self.double_candidate.is_some() {
751                self.pending_click = None;
752            }
753
754            if let PointerButton::Primary = button {
755                self.long_press = if hit.on_long_click.is_some() {
756                    Some((hit.id, web_time::Instant::now(), pos.x, pos.y))
757                } else {
758                    None
759                };
760                self.suppress_next_click = false;
761            }
762
763            if hit.tf_state_key.is_some() || is_textfield_in_frame(f, hit.id) {
764                let key = tf_key_of(f, hit.id);
765                let seed = hit.tf_value.as_str();
766                let st_rc = ensure_tf_state(&mut self.textfield_states, key, seed);
767                {
768                    let mut st = st_rc.borrow_mut();
769                    // Sync text only; never place-at-end here.
770                    st.apply_controlled_value(seed);
771
772                    if st.inner_width <= 0.0 {
773                        let w = hit
774                            .tf_content_origin
775                            .map(|_| hit.rect.w)
776                            .unwrap_or(hit.rect.w)
777                            .max(1.0);
778                        st.set_inner_width(w);
779                        st.set_inner_height(hit.rect.h.max(1.0));
780                    }
781
782                    let (ox, oy) = hit.tf_content_origin.unwrap_or((hit.rect.x, hit.rect.y));
783                    let content_x = (pos.x - ox + st.scroll_offset).max(0.0);
784                    let content_y = (pos.y - oy + st.scroll_offset_y).max(0.0);
785                    let font_size_sp = if hit.tf_font_size != Sp::ZERO {
786                        hit.tf_font_size
787                    } else {
788                        TF_FONT_SP
789                    };
790                    let font_px = font_size_sp.to_px().0;
791                    let wrap_w = st.inner_width.max(1.0);
792
793                    let idx = if hit.tf_multiline {
794                        index_for_xy_bytes_vt(&st, font_px, wrap_w, content_x, content_y)
795                    } else {
796                        index_for_x_bytes_vt(&st, font_px, content_x)
797                    };
798                    st.handle_pointer_down(idx, (pos.x, pos.y), self.modifiers.shift);
799                    // caret was placed by pointer this gesture
800                }
801            }
802
803            self.pressed_ids.insert(hit.id);
804
805            if hit.focusable {
806                self.sched.focused = Some(hit.id);
807                result.focused = Some(hit.id);
808                if hit.tf_state_key.is_some() {
809                    let key = tf_key_of(f, hit.id);
810                    let st =
811                        ensure_tf_state(&mut self.textfield_states, key, hit.tf_value.as_str());
812                    let mut s = st.borrow_mut();
813                    s.apply_controlled_value(&hit.tf_value);
814                    s.reset_caret_blink();
815                }
816            }
817
818            self.dispatch_pointer_to_path(PointerEventKind::Down(button), pos, &path);
819
820            request_frame();
821        } else {
822            self.hit_path = None;
823            if self.ime_preedit {
824                for state_rc in self.textfield_states.values() {
825                    let mut st = state_rc.borrow_mut();
826                    if st.composition.is_some() {
827                        st.commit_composition(String::new());
828                    }
829                }
830                self.ime_preedit = false;
831            }
832            self.sched.focused = None;
833            request_frame();
834        }
835
836        result
837    }
838
839    /// Process a pointer button release.
840    pub fn handle_pointer_release(
841        &mut self,
842        pos: Vec2,
843        button: PointerButton,
844    ) -> PointerButtonResult {
845        self.mouse_pos_px = (pos.x, pos.y);
846        let mut result = PointerButtonResult {
847            focused: self.sched.focused,
848            capture_id: self.capture_id,
849            consumed: false,
850            needs_a11y_announce: false,
851            clicked_id: None,
852        };
853
854        if dnd::handle_drag_action(&DragAction::Release {
855            position: pos,
856            modifiers: self.modifiers,
857        }) {
858            self.capture_id = None;
859            self.hit_path = None;
860            self.pressed_ids.clear();
861            request_frame();
862            result.consumed = true;
863            return result;
864        }
865
866        self.pressed_ids.clear();
867
868        let Some(f) = &self.frame_cache else {
869            self.capture_id = None;
870            self.hit_path = None;
871            return result;
872        };
873
874        if let Some(path) = &self.hit_path {
875            self.dispatch_pointer_to_path(PointerEventKind::Up(button), pos, path);
876            result.consumed = true;
877        }
878
879        // Long-press resolution: `poll_long_press` normally fires on timeout when held.
880        if let Some((lid, t0, _, _)) = self.long_press.take()
881            && Some(lid) == self.capture_id
882            && t0.elapsed().as_millis() >= LONG_PRESS_MS
883            && let Some(hit) = f.hit_regions.iter().find(|h| h.id == lid && !h.disabled)
884            && let Some(cb) = &hit.on_long_click
885        {
886            cb();
887            self.suppress_next_click = true;
888            self.pending_click = None;
889            result.clicked_id = Some(lid);
890            result.needs_a11y_announce = true;
891            result.consumed = true;
892        }
893
894        if self.double_candidate.is_none()
895            && !self.suppress_next_click
896            && let Some(cid) = self.capture_id
897            && let Some(hit) = f.hit_regions.iter().find(|h| h.id == cid && !h.disabled)
898            && hit.rect.contains(pos)
899        {
900            let now = web_time::Instant::now();
901            // With onDoubleTap present, single
902            // clicks are delayed until the double-tap window elapses.
903            if hit.on_double_click.is_some() {
904                if let Some(cb) = hit.on_click.clone() {
905                    self.pending_click = Some((cid, now, cb));
906                }
907                self.last_up = Some((cid, now, pos.x, pos.y));
908                result.consumed = true;
909                request_frame(); // need another frame to flush pending
910            } else {
911                if let Some(cb) = &hit.on_click {
912                    cb();
913                }
914                self.last_up = Some((cid, now, pos.x, pos.y));
915                result.clicked_id = Some(cid);
916                result.needs_a11y_announce = true;
917                result.consumed = true;
918            }
919        }
920        self.suppress_next_click = false;
921
922        // Double-click resolution. The second DOWN (handle_pointer_down)
923        // qualifies the pair; the second UP confirms it. A canceled second tap
924        // (moved out of bounds) falls back to the first tap's onClick.
925        if let Some(dc) = self.double_candidate.take() {
926            self.pending_click = None;
927            self.last_up = None;
928            self.last_down = None;
929            if self.capture_id == Some(dc)
930                && let Some(hit) = f.hit_regions.iter().find(|h| h.id == dc && !h.disabled)
931                && hit.rect.contains(pos)
932            {
933                if let Some(cb) = &hit.on_double_click {
934                    cb();
935                }
936                result.clicked_id = Some(dc);
937                result.needs_a11y_announce = true;
938                result.consumed = true;
939            } else if self.capture_id == Some(dc)
940                && let Some(hit) = f.hit_regions.iter().find(|h| h.id == dc && !h.disabled)
941            {
942                // Second tap canceled -> the first tap counts as a click.
943                if let Some(cb) = &hit.on_click {
944                    cb();
945                }
946                result.clicked_id = Some(dc);
947                result.needs_a11y_announce = true;
948                result.consumed = true;
949            }
950        }
951
952        // TextField drag end
953        if let Some(cid) = self.capture_id
954            && is_tf_hit(f, cid)
955        {
956            let key = tf_key_of(f, cid);
957            if let Some(state_rc) = self.textfield_states.get(&key) {
958                state_rc.borrow_mut().end_drag();
959            }
960        }
961
962        self.capture_id = None;
963        self.hit_path = None;
964        request_frame();
965        result
966    }
967
968    /// Cancel pointer state (focus lost, cursor left window, etc.).
969    pub fn handle_pointer_cancel(&mut self) {
970        if let Some(f) = &self.frame_cache
971            && let Some(cid) = self.capture_id
972            && is_tf_hit(f, cid)
973        {
974            let key = tf_key_of(f, cid);
975            if let Some(state_rc) = self.textfield_states.get(&key) {
976                state_rc.borrow_mut().end_drag();
977            }
978        }
979        self.long_press = None;
980        self.last_up = None;
981        dnd::handle_drag_action(&DragAction::Cancel);
982        let pos = Vec2 {
983            x: self.mouse_pos_px.0,
984            y: self.mouse_pos_px.1,
985        };
986        dispatch_hover_change_bubbled(
987            self.frame_cache.as_ref(),
988            &self.hover_leave,
989            &mut self.hover_id,
990            &mut self.hover_ancestors,
991            None,
992            pos,
993            self.modifiers,
994        );
995        if let Some(path) = &self.hit_path {
996            self.dispatch_pointer_to_path(PointerEventKind::Cancel, pos, path);
997        }
998        self.reset_pointer_state();
999    }
1000
1001    /// Clear hover state, emitting HoverLeave for the currently hovered region.
1002    pub fn clear_hover(&mut self) {
1003        if self.hover_id.is_none() && self.hover_ancestors.is_empty() {
1004            return;
1005        }
1006        let pos = Vec2 {
1007            x: self.mouse_pos_px.0,
1008            y: self.mouse_pos_px.1,
1009        };
1010        dispatch_hover_change_bubbled(
1011            self.frame_cache.as_ref(),
1012            &self.hover_leave,
1013            &mut self.hover_id,
1014            &mut self.hover_ancestors,
1015            None,
1016            pos,
1017            self.modifiers,
1018        );
1019    }
1020
1021    /// Reconcile hover state when the composed frame changes.
1022    pub fn reconcile_hover_from_mouse_pos(&mut self, new_frame: &Frame) {
1023        let pos = Vec2 {
1024            x: self.mouse_pos_px.0,
1025            y: self.mouse_pos_px.1,
1026        };
1027
1028        // If the previous hover target vanished from the new frame, deliver
1029        // Leave via the retained map (which survives tree removal), then clear.
1030        if let Some(prev_id) = self.hover_id
1031            && !new_frame.hit_regions.iter().any(|h| h.id == prev_id)
1032        {
1033            dispatch_hover_change_bubbled(
1034                Some(new_frame),
1035                &self.hover_leave,
1036                &mut self.hover_id,
1037                &mut self.hover_ancestors,
1038                None,
1039                pos,
1040                self.modifiers,
1041            );
1042        }
1043
1044        if !self.pointer_inside {
1045            if self.hover_id.is_some() || !self.hover_ancestors.is_empty() {
1046                dispatch_hover_change_bubbled(
1047                    Some(new_frame),
1048                    &self.hover_leave,
1049                    &mut self.hover_id,
1050                    &mut self.hover_ancestors,
1051                    None,
1052                    pos,
1053                    self.modifiers,
1054                );
1055                request_frame();
1056            }
1057            return;
1058        }
1059
1060        let new_hover = new_frame
1061            .hit_regions
1062            .iter()
1063            .rev()
1064            .find(|h| !h.disabled && h.rect.contains(pos))
1065            .map(|h| h.id);
1066
1067        self.cursor = if dnd::is_dragging() {
1068            Some(CursorIcon::Grabbing)
1069        } else {
1070            new_hover
1071                .and_then(|id| new_frame.hit_regions.iter().find(|h| h.id == id))
1072                .and_then(|h| h.cursor)
1073                .or(Some(CursorIcon::Default))
1074        };
1075
1076        let new_chain = hover_chain_for(Some(new_frame), new_hover);
1077        let old_chain = hover_chain_for(Some(new_frame), self.hover_id);
1078        if new_chain == old_chain {
1079            return;
1080        }
1081
1082        dispatch_hover_change_bubbled(
1083            Some(new_frame),
1084            &self.hover_leave,
1085            &mut self.hover_id,
1086            &mut self.hover_ancestors,
1087            new_hover,
1088            pos,
1089            self.modifiers,
1090        );
1091        request_frame();
1092    }
1093
1094    fn reset_pointer_state(&mut self) {
1095        self.capture_id = None;
1096        self.hit_path = None;
1097        self.pressed_ids.clear();
1098        self.pending_click = None;
1099        self.last_down = None;
1100        self.double_candidate = None;
1101        self.key_long_press = None;
1102        self.suppress_next_click = false;
1103    }
1104
1105    fn flush_pending_click(&mut self) {
1106        let Some((id, t0, cb)) = self.pending_click.take() else {
1107            return;
1108        };
1109        if t0.elapsed().as_millis() >= DOUBLE_CLICK_MS {
1110            cb();
1111            request_frame();
1112        } else {
1113            self.pending_click = Some((id, t0, cb));
1114            request_frame();
1115        }
1116    }
1117
1118    fn poll_long_press(&mut self) {
1119        let Some(f) = self.frame_cache.clone() else {
1120            return;
1121        };
1122        let Some((lid, t0, _, _)) = self.long_press else {
1123            return;
1124        };
1125        if t0.elapsed().as_millis() < LONG_PRESS_MS {
1126            request_frame();
1127            return;
1128        }
1129        // Still captured and within the element bounds? (Compose cancels the
1130        // long press when the pointer leaves the element.)
1131        if self.capture_id != Some(lid) {
1132            self.long_press = None;
1133            return;
1134        }
1135        let (mx, my) = self.mouse_pos_px;
1136        let in_bounds = f
1137            .hit_regions
1138            .iter()
1139            .find(|h| h.id == lid)
1140            .is_some_and(|h| h.rect.contains(Vec2 { x: mx, y: my }));
1141        if !in_bounds {
1142            self.long_press = None;
1143            return;
1144        }
1145        if let Some(hit) = f.hit_regions.iter().find(|h| h.id == lid && !h.disabled)
1146            && let Some(cb) = &hit.on_long_click
1147        {
1148            self.long_press = None;
1149            self.suppress_next_click = true;
1150            self.pending_click = None;
1151            self.last_up = None;
1152            cb();
1153            request_frame();
1154        } else {
1155            self.long_press = None;
1156        }
1157    }
1158
1159    /// Holding Space/Enter past LONG_PRESS_MS fires long-click. The following KeyUp must not fire onClick.
1160    fn poll_key_long_press(&mut self) {
1161        let Some(f) = self.frame_cache.clone() else {
1162            return;
1163        };
1164        let Some((kid, t0, fired)) = self.key_long_press else {
1165            return;
1166        };
1167        if t0.elapsed().as_millis() < LONG_PRESS_MS {
1168            request_frame();
1169            return;
1170        }
1171        if !fired {
1172            if let Some(hit) = f.hit_regions.iter().find(|h| h.id == kid && !h.disabled)
1173                && let Some(cb) = &hit.on_long_click
1174            {
1175                cb();
1176            }
1177            self.key_long_press = Some((kid, t0, true));
1178            request_frame();
1179        }
1180    }
1181
1182    /// Process a scroll event. Returns true if consumed.
1183    pub fn handle_scroll(&mut self, delta: Vec2) -> bool {
1184        let Some(f) = &self.frame_cache else {
1185            return false;
1186        };
1187
1188        let now = web_time::Instant::now();
1189        if let Some(last) = self.last_scroll_at
1190            && now.duration_since(last).as_millis() > 250
1191        {
1192            self.scroll_capture_id = None;
1193        }
1194        self.last_scroll_at = Some(now);
1195
1196        let pos = Vec2 {
1197            x: self.mouse_pos_px.0,
1198            y: self.mouse_pos_px.1,
1199        };
1200        let (consumed, cap) = dispatch_scroll(f, pos, delta, self.scroll_capture_id);
1201        self.scroll_capture_id = cap;
1202        if consumed {
1203            request_frame();
1204        }
1205        consumed
1206    }
1207
1208    /// Process a keyboard key event. Returns true if consumed.
1209    pub fn handle_key(&mut self, event: &KeyEvent) -> bool {
1210        if event.event_type == KeyEventType::Down {
1211            let _ = repose_core::request_input_mode(repose_core::InputMode::Keyboard);
1212        }
1213
1214        let Some(frame) = self.frame_cache.clone() else {
1215            return false;
1216        };
1217        let f = &frame;
1218
1219        // Escape / BrowserBack: cancel DnD first, then try focus key dispatch.
1220        // If nothing consumed it, do NOT consume: let the host handle back /
1221        // exit / window-chrome actions.
1222        if event.event_type == KeyEventType::Down && !event.is_repeat && event.key == Key::Escape {
1223            if dnd::handle_drag_action(&DragAction::Cancel) {
1224                request_frame();
1225                return true;
1226            }
1227            // Try dispatch through focus chain
1228            if self.dispatch_focus_key_event(f, event) {
1229                request_frame();
1230                return true;
1231            }
1232            return false;
1233        }
1234
1235        // Dispatch through focus ancestor chain
1236        let consumed = self.dispatch_focus_key_event(f, event);
1237        if consumed {
1238            request_frame();
1239            return true;
1240        }
1241
1242        // Action dispatch (shortcuts like Ctrl+C, Tab, etc.)
1243        if event.event_type == KeyEventType::Down
1244            && !event.is_repeat
1245            && let Some(action) = repose_core::shortcuts::resolve_action(
1246                repose_core::shortcuts::KeyChord::new(event.key.clone(), self.modifiers),
1247            )
1248        {
1249            // `dispatch_action` covers focus navigation internally.
1250            if self.dispatch_action(action.clone()) {
1251                return true;
1252            }
1253        }
1254
1255        // Keyboard activation (Space/Enter on focused non-textfield)
1256        if let Some(fid) = self.sched.focused {
1257            let is_tf = f
1258                .semantics_nodes
1259                .iter()
1260                .any(|n| n.id == fid && n.role == repose_core::semantics::Role::TextField);
1261            if !is_tf {
1262                if event.event_type == KeyEventType::Down && !event.is_repeat {
1263                    if event.key == Key::Space || event.key == Key::Enter {
1264                        let Some(hit) = f.hit_regions.iter().find(|h| h.id == fid) else {
1265                            return false;
1266                        };
1267                        if hit.on_click.is_none()
1268                            && hit.on_long_click.is_none()
1269                            && hit.on_double_click.is_none()
1270                        {
1271                            return false; // don't steal keys from non-clickable focusables
1272                        }
1273                        self.pressed_ids.insert(fid);
1274                        self.key_pressed_active = Some(fid);
1275                        self.key_long_press = if hit.on_long_click.is_some() {
1276                            Some((fid, web_time::Instant::now(), false))
1277                        } else {
1278                            None
1279                        };
1280
1281                        if let Some(hit) = f.hit_regions.iter().find(|h| h.id == fid)
1282                            && let Some(src) = &hit.interaction_source
1283                        {
1284                            let local = Vec2 {
1285                                x: hit.rect.w * 0.5,
1286                                y: hit.rect.h * 0.5,
1287                            };
1288                            src.to_mutable().emit(Interaction::new_press(local));
1289                        }
1290
1291                        request_frame();
1292                        return true;
1293                    }
1294                } else if event.event_type == KeyEventType::Up
1295                    && let Some(active_id) = self.key_pressed_active
1296                    && (event.key == Key::Space || event.key == Key::Enter)
1297                {
1298                    self.pressed_ids.remove(&active_id);
1299                    self.key_pressed_active = None;
1300
1301                    let long_fired = self
1302                        .key_long_press
1303                        .take()
1304                        .map(|(_, _, fired)| fired)
1305                        .unwrap_or(false);
1306
1307                    if let Some(hit) = f
1308                        .hit_regions
1309                        .iter()
1310                        .find(|h| h.id == active_id && !h.disabled)
1311                    {
1312                        if let Some(src) = &hit.interaction_source {
1313                            let pid = src.collect_last_press_id().unwrap_or(0);
1314                            src.to_mutable().emit(Interaction::Release(pid));
1315                        }
1316                        if !long_fired && let Some(cb) = &hit.on_click {
1317                            cb();
1318                        }
1319                    }
1320                    request_frame();
1321                    return true;
1322                }
1323            }
1324        }
1325
1326        // Enter submission for focused TextField
1327        if event.event_type == KeyEventType::Down
1328            && !event.is_repeat
1329            && event.key == Key::Enter
1330            && let Some(fid) = self.sched.focused
1331            && let Some(hit) = f.hit_regions.iter().find(|h| h.id == fid)
1332        {
1333            let is_multiline = hit.tf_multiline;
1334            let should_submit = if is_multiline {
1335                self.modifiers.ctrl || self.modifiers.meta
1336            } else {
1337                true
1338            };
1339            if should_submit {
1340                if let Some(on_submit) = &hit.on_text_submit {
1341                    let key = tf_key_of(f, fid);
1342                    if let Some(state_rc) = self.textfield_states.get(&key) {
1343                        let text = state_rc.borrow().text.clone();
1344                        on_submit(text);
1345                        request_frame();
1346                        return true;
1347                    }
1348                }
1349            } else {
1350                // Multiline plain Enter: insert newline
1351                let key = tf_key_of(f, fid);
1352                if !is_tf_editable(f, fid) {
1353                    return true;
1354                }
1355                if let Some(state_rc) = self.textfield_states.get(&key) {
1356                    let mut st = state_rc.borrow_mut();
1357                    st.insert_text("\n");
1358                    let new_text = st.text.clone();
1359                    notify_text_change(f, fid, new_text);
1360                    tf_ensure_caret_visible(&mut st, hit.tf_multiline);
1361                    request_frame();
1362                    return true;
1363                }
1364            }
1365        }
1366
1367        // TextField navigation / edit keys
1368        if event.event_type == KeyEventType::Down {
1369            if let Some(fid) = self.sched.focused {
1370                let key = tf_key_of(f, fid);
1371                if let Some(state_rc) = self.textfield_states.get(&key) {
1372                    let mut state = state_rc.borrow_mut();
1373                    match event.key {
1374                        Key::Backspace => {
1375                            if !is_tf_editable(f, fid) {
1376                                return true;
1377                            }
1378                            state.delete_backward();
1379                            let new_text = state.text.clone();
1380                            notify_text_change(f, fid, new_text);
1381                            tf_ensure_caret_visible(&mut state, is_multiline_id(f, fid));
1382                            request_frame();
1383                            return true;
1384                        }
1385                        Key::Delete => {
1386                            if !is_tf_editable(f, fid) {
1387                                return true;
1388                            }
1389                            state.delete_forward();
1390                            let new_text = state.text.clone();
1391                            notify_text_change(f, fid, new_text);
1392                            tf_ensure_caret_visible(&mut state, is_multiline_id(f, fid));
1393                            request_frame();
1394                            return true;
1395                        }
1396                        Key::ArrowLeft => {
1397                            state.move_cursor(-1, self.modifiers.shift);
1398                            state.preferred_x_px = None;
1399                            tf_ensure_caret_visible(&mut state, is_multiline_id(f, fid));
1400                            request_frame();
1401                            return true;
1402                        }
1403                        Key::ArrowRight => {
1404                            state.move_cursor(1, self.modifiers.shift);
1405                            state.preferred_x_px = None;
1406                            tf_ensure_caret_visible(&mut state, is_multiline_id(f, fid));
1407                            request_frame();
1408                            return true;
1409                        }
1410                        Key::ArrowUp => {
1411                            if is_multiline_id(f, fid)
1412                                && let Some(hit) = f.hit_regions.iter().find(|h| h.id == fid)
1413                            {
1414                                let font_size_sp = if hit.tf_font_size != Sp::ZERO {
1415                                    hit.tf_font_size
1416                                } else {
1417                                    TF_FONT_SP
1418                                };
1419                                let font_px = font_size_sp.to_px().0;
1420                                let cur = state.caret_index();
1421                                let (new_pos, px) = repose_ui::textfield::move_caret_vertical(
1422                                    &state.text,
1423                                    font_px,
1424                                    hit.rect.w,
1425                                    cur,
1426                                    -1,
1427                                    state.preferred_x_px,
1428                                );
1429                                if self.modifiers.shift {
1430                                    state.selection.end = new_pos;
1431                                } else {
1432                                    state.selection = new_pos..new_pos;
1433                                }
1434                                state.preferred_x_px = Some(px);
1435                                let (cx, cy, _) = caret_xy_for_byte(
1436                                    &state.text,
1437                                    font_px,
1438                                    hit.rect.w,
1439                                    state.caret_index(),
1440                                );
1441                                let iw = state.inner_width;
1442                                let ih = state.inner_height;
1443                                state.ensure_caret_visible_xy(cx, cy, iw, ih, Dp(2.0).to_px().0);
1444                                request_frame();
1445                                return true;
1446                            }
1447                        }
1448                        Key::ArrowDown => {
1449                            if is_multiline_id(f, fid)
1450                                && let Some(hit) = f.hit_regions.iter().find(|h| h.id == fid)
1451                            {
1452                                let font_size_sp = if hit.tf_font_size != Sp::ZERO {
1453                                    hit.tf_font_size
1454                                } else {
1455                                    TF_FONT_SP
1456                                };
1457                                let font_px = font_size_sp.to_px().0;
1458                                let cur = state.caret_index();
1459                                let (new_pos, px) = repose_ui::textfield::move_caret_vertical(
1460                                    &state.text,
1461                                    font_px,
1462                                    hit.rect.w,
1463                                    cur,
1464                                    1,
1465                                    state.preferred_x_px,
1466                                );
1467                                if self.modifiers.shift {
1468                                    state.selection.end = new_pos;
1469                                } else {
1470                                    state.selection = new_pos..new_pos;
1471                                }
1472                                state.preferred_x_px = Some(px);
1473                                let (cx, cy, _) = caret_xy_for_byte(
1474                                    &state.text,
1475                                    font_px,
1476                                    hit.rect.w,
1477                                    state.caret_index(),
1478                                );
1479                                let iw = state.inner_width;
1480                                let ih = state.inner_height;
1481                                state.ensure_caret_visible_xy(cx, cy, iw, ih, Dp(2.0).to_px().0);
1482                                request_frame();
1483                                return true;
1484                            }
1485                        }
1486                        Key::Home => {
1487                            state.selection = 0..0;
1488                            tf_ensure_caret_visible(&mut state, is_multiline_id(f, fid));
1489                            request_frame();
1490                            return true;
1491                        }
1492                        Key::End => {
1493                            let end = state.text.len();
1494                            state.selection = end..end;
1495                            tf_ensure_caret_visible(&mut state, is_multiline_id(f, fid));
1496                            request_frame();
1497                            return true;
1498                        }
1499                        _ => {}
1500                    }
1501                }
1502            }
1503
1504            // Plain text input (non-IME)
1505            if !self.ime_preedit
1506                && !self.modifiers.ctrl
1507                && !self.modifiers.alt
1508                && !self.modifiers.meta
1509                && let Key::Character(c) = event.key
1510                && !c.is_control()
1511                && c != '\n'
1512                && c != '\r'
1513                && let Some(fid) = self.sched.focused
1514            {
1515                let key = tf_key_of(f, fid);
1516                if !is_tf_editable(f, fid) {
1517                    return true;
1518                }
1519                if let Some(state_rc) = self.textfield_states.get(&key) {
1520                    let mut st = state_rc.borrow_mut();
1521                    let text = c.to_string();
1522                    st.insert_text(&text);
1523                    notify_text_change(f, fid, st.text.clone());
1524                    if let Some(hit) = f.hit_regions.iter().find(|h| h.id == fid) {
1525                        tf_ensure_caret_visible(&mut st, hit.tf_multiline);
1526                    }
1527                    request_frame();
1528                    return true;
1529                }
1530            }
1531        }
1532
1533        false
1534    }
1535
1536    /// Dispatch a key event through the focus ancestor chain.
1537    fn dispatch_focus_key_event(&self, f: &Frame, event: &KeyEvent) -> bool {
1538        let Some(focused) = self.sched.focused else {
1539            return false;
1540        };
1541
1542        let hit_by_id: HashMap<u64, &HitRegion> = f.hit_regions.iter().map(|h| (h.id, h)).collect();
1543        let sem_parent_of: HashMap<u64, u64> = f
1544            .semantics_nodes
1545            .iter()
1546            .filter_map(|n| n.parent.map(|p| (n.id, p)))
1547            .collect();
1548
1549        let mut ancestors = Vec::new();
1550        let mut cur = focused;
1551        loop {
1552            ancestors.push(cur);
1553            if let Some(&p) = sem_parent_of.get(&cur) {
1554                cur = p;
1555            } else {
1556                break;
1557            }
1558        }
1559
1560        for &id in ancestors.iter().rev() {
1561            if let Some(hit) = hit_by_id.get(&id)
1562                && let Some(cb) = &hit.on_preview_key_event
1563                && cb(event.clone())
1564            {
1565                return true;
1566            }
1567        }
1568
1569        for &id in ancestors.iter() {
1570            if let Some(hit) = hit_by_id.get(&id)
1571                && let Some(cb) = &hit.on_key_event
1572                && cb(event.clone())
1573            {
1574                return true;
1575            }
1576        }
1577
1578        false
1579    }
1580
1581    /// Dispatch a shortcut action: widget handler first, then built-in
1582    /// textfield editing, then the global shortcut map, then focus navigation.
1583    /// Returns true if the action was consumed.
1584    pub fn dispatch_action(&mut self, action: repose_core::shortcuts::Action) -> bool {
1585        // 1) Widget-level handler
1586        if let Some(f) = &self.frame_cache
1587            && let Some(fid) = self.sched.focused
1588            && let Some(hit) = f.hit_regions.iter().find(|h| h.id == fid)
1589            && let Some(cb) = &hit.on_action
1590            && cb(action.clone())
1591        {
1592            request_frame();
1593            return true;
1594        }
1595
1596        // 2) Built-in textfield editing (undo/redo/copy/cut/paste/select-all)
1597        if self.apply_text_editing_action(&action) {
1598            return true;
1599        }
1600
1601        // 3) Global shortcut handler
1602        if repose_core::shortcuts::handle(action.clone()) {
1603            request_frame();
1604            return true;
1605        }
1606
1607        // 4) Focus navigation (Tab / arrows)
1608        if let Some(f) = self.frame_cache.clone()
1609            && let Some(new_id) = repose_core::focus::handle_action(&action, &mut self.sched, &f)
1610        {
1611            // End any in-flight keyboard press (e.g. Space held on the old focus).
1612            if let Some(active) = self.key_pressed_active.take() {
1613                self.pressed_ids.remove(&active);
1614            }
1615            self.key_long_press = None;
1616            // Lazy-init + reset the caret blink for the newly focused text field.
1617            if let Some(hit) = f.hit_regions.iter().find(|h| h.id == new_id)
1618                && let Some(key) = hit.tf_state_key
1619            {
1620                let st = ensure_tf_state(&mut self.textfield_states, key, hit.tf_value.as_str());
1621                {
1622                    let mut s = st.borrow_mut();
1623                    s.apply_controlled_value(&hit.tf_value);
1624                    s.reset_caret_blink();
1625                }
1626            }
1627            request_frame();
1628            return true;
1629        }
1630
1631        false
1632    }
1633
1634    /// Apply built-in textfield editing actions (Undo/Redo/SelectAll/Copy/
1635    /// Cut/Paste) to the focused text field. Returns true if consumed.
1636    fn apply_text_editing_action(&mut self, action: &repose_core::shortcuts::Action) -> bool {
1637        use repose_core::shortcuts::Action;
1638        let Some(fid) = self.sched.focused else {
1639            return false;
1640        };
1641        let Some(f) = self.frame_cache.clone() else {
1642            return false;
1643        };
1644        if !is_tf_hit(&f, fid) {
1645            return false;
1646        }
1647        let key = tf_key_of(&f, fid);
1648        let Some(state_rc) = self.textfield_states.get(&key).cloned() else {
1649            return false;
1650        };
1651        let multiline = is_multiline_id(&f, fid);
1652
1653        match action {
1654            Action::Undo => {
1655                let mut st = state_rc.borrow_mut();
1656                if !st.can_undo() {
1657                    return false;
1658                }
1659                st.undo();
1660                notify_text_change(&f, fid, st.text.clone());
1661                tf_ensure_caret_visible(&mut st, multiline);
1662                request_frame();
1663                true
1664            }
1665            Action::Redo => {
1666                let mut st = state_rc.borrow_mut();
1667                if !st.can_redo() {
1668                    return false;
1669                }
1670                st.redo();
1671                notify_text_change(&f, fid, st.text.clone());
1672                tf_ensure_caret_visible(&mut st, multiline);
1673                request_frame();
1674                true
1675            }
1676            Action::SelectAll => {
1677                let mut st = state_rc.borrow_mut();
1678                let len = st.text.len();
1679                st.selection = 0..len;
1680                request_frame();
1681                true
1682            }
1683            Action::Copy => {
1684                let st = state_rc.borrow();
1685                let (a, b) = (
1686                    st.selection.start.min(st.selection.end),
1687                    st.selection.start.max(st.selection.end),
1688                );
1689                if a == b {
1690                    return false;
1691                }
1692                let slice = st.text.get(a..b).unwrap_or("").to_string();
1693                drop(st);
1694                if !slice.is_empty() {
1695                    repose_core::clipboard::copy_to_clipboard(&slice);
1696                }
1697                true
1698            }
1699            Action::Cut => {
1700                if !is_tf_editable(&f, fid) {
1701                    return false;
1702                }
1703                let mut st = state_rc.borrow_mut();
1704                let (a, b) = (
1705                    st.selection.start.min(st.selection.end),
1706                    st.selection.start.max(st.selection.end),
1707                );
1708                if a == b {
1709                    return false;
1710                }
1711                let slice = st.text.get(a..b).unwrap_or("").to_string();
1712                // Replacing the selection with "" deletes it.
1713                st.insert_text_atomic("");
1714                let new_text = st.text.clone();
1715                drop(st);
1716                if !slice.is_empty() {
1717                    repose_core::clipboard::copy_to_clipboard(&slice);
1718                }
1719                notify_text_change(&f, fid, new_text);
1720                if let Some(mut st) = self.textfield_states.get(&key).map(|s| s.borrow_mut()) {
1721                    tf_ensure_caret_visible(&mut st, multiline);
1722                }
1723                request_frame();
1724                true
1725            }
1726            Action::Paste => {
1727                if let Some(txt) = repose_core::clipboard::paste_text() {
1728                    self.paste_into_focused(&txt);
1729                    return true;
1730                }
1731                false
1732            }
1733            _ => false,
1734        }
1735    }
1736
1737    /// Process an IME event.
1738    pub fn handle_ime(&mut self, event: &ImeEvent) {
1739        let Some(fid) = self.sched.focused else {
1740            return;
1741        };
1742        let Some(f) = &self.frame_cache else {
1743            return;
1744        };
1745        if !is_tf_editable(f, fid) {
1746            return;
1747        }
1748        let key = tf_key_of(f, fid);
1749        let Some(state_rc) = self.textfield_states.get(&key) else {
1750            return;
1751        };
1752
1753        let mut state = state_rc.borrow_mut();
1754
1755        match event {
1756            ImeEvent::Start => {
1757                self.ime_preedit = false;
1758            }
1759            ImeEvent::Update { text, cursor } => {
1760                state.set_composition(text.clone(), *cursor);
1761                self.ime_preedit = !text.is_empty();
1762                repose_ui::textfield::ensure_caret_visible(&mut state, true);
1763                notify_text_change(f, fid, state.text.clone());
1764            }
1765            ImeEvent::Commit(text) => {
1766                state.commit_composition(text.clone());
1767                self.ime_preedit = false;
1768                repose_ui::textfield::ensure_caret_visible(&mut state, true);
1769                notify_text_change(f, fid, state.text.clone());
1770            }
1771            ImeEvent::Cancel => {
1772                self.ime_preedit = false;
1773                if state.composition.is_some() {
1774                    state.cancel_composition();
1775                    repose_ui::textfield::ensure_caret_visible(&mut state, true);
1776                    notify_text_change(f, fid, state.text.clone());
1777                }
1778            }
1779        }
1780
1781        request_frame();
1782    }
1783
1784    /// Handle focus lost (window unfocused, etc.).
1785    pub fn handle_focus_lost(&mut self) {
1786        dnd::handle_drag_action(&DragAction::Cancel);
1787        self.handle_pointer_cancel();
1788        self.ime_preedit = false;
1789    }
1790
1791    /// Get or create a text field state by its key.
1792    pub fn ensure_textfield_state(&mut self, key: u64) -> Rc<RefCell<TextFieldState>> {
1793        self.textfield_states
1794            .entry(key)
1795            .or_insert_with(|| Rc::new(RefCell::new(TextFieldState::new())))
1796            .clone()
1797    }
1798
1799    pub fn ensure_textfield_state_seeded(
1800        &mut self,
1801        key: u64,
1802        seed: &str,
1803    ) -> Rc<RefCell<TextFieldState>> {
1804        ensure_tf_state(&mut self.textfield_states, key, seed)
1805    }
1806
1807    /// Look up the persistent state key for a visual hit-region id.
1808    pub fn tf_key_of(&self, visual_id: u64) -> u64 {
1809        self.frame_cache
1810            .as_ref()
1811            .map(|f| tf_key_of(f, visual_id))
1812            .unwrap_or(visual_id)
1813    }
1814
1815    /// True if the given id belongs to a TextField.
1816    pub fn is_textfield(&self, id: u64) -> bool {
1817        self.frame_cache
1818            .as_ref()
1819            .map(|f| is_textfield_in_frame(f, id))
1820            .unwrap_or(false)
1821    }
1822
1823    /// True if the given textfield id is multiline.
1824    pub fn is_multiline(&self, id: u64) -> bool {
1825        self.frame_cache
1826            .as_ref()
1827            .map(|f| is_multiline_id(f, id))
1828            .unwrap_or(false)
1829    }
1830
1831    /// Keyboard hints of the currently focused text field, or defaults if none.
1832    /// Returns `(purpose, auto_correct, capitalization)` for the platform runner.
1833    pub fn focused_keyboard_hints(
1834        &self,
1835    ) -> (
1836        repose_core::ImePurposeHint,
1837        bool,
1838        repose_core::KeyboardCapitalization,
1839    ) {
1840        let defaults = || {
1841            (
1842                repose_core::ImePurposeHint::Normal,
1843                true,
1844                repose_core::KeyboardCapitalization::Unspecified,
1845            )
1846        };
1847        let Some(fid) = self.sched.focused else {
1848            return defaults();
1849        };
1850        let Some(f) = &self.frame_cache else {
1851            return defaults();
1852        };
1853        match f.hit_regions.iter().find(|h| h.id == fid) {
1854            Some(hit) => (
1855                hit.keyboard_type.ime_purpose_hint(),
1856                hit.auto_correct.unwrap_or(true),
1857                hit.capitalization,
1858            ),
1859            None => defaults(),
1860        }
1861    }
1862
1863    /// Insert arbitrary text into the focused text field (composed keyboard
1864    /// text, clipboard paste, hardware-keyboard fallback, ...).
1865    /// Returns true if text was inserted.
1866    ///
1867    /// Control chars are filtered. Newlines are dropped for single-line fields. Skipped during IME preedit.
1868    pub fn insert_text_into_focused(&mut self, text: &str) -> bool {
1869        if text.is_empty()
1870            || self.ime_preedit
1871            || self.modifiers.ctrl
1872            || self.modifiers.alt
1873            || self.modifiers.meta
1874        {
1875            return false;
1876        }
1877        let Some(fid) = self.sched.focused else {
1878            return false;
1879        };
1880        let Some(f) = self.frame_cache.clone() else {
1881            return false;
1882        };
1883        if !is_textfield_in_frame(&f, fid) {
1884            return false;
1885        }
1886        if !is_tf_editable(&f, fid) {
1887            return false;
1888        }
1889        let key = tf_key_of(&f, fid);
1890        let Some(state_rc) = self.textfield_states.get(&key).cloned() else {
1891            return false;
1892        };
1893        let multiline = is_multiline_id(&f, fid);
1894        let filtered: String = text
1895            .chars()
1896            .filter(|c| {
1897                // Keep newlines for multiline fields; otherwise drop control
1898                // chars and CR (\n is a control char, so it needs an explicit
1899                // exception or it never survives for multiline fields).
1900                (*c == '\n' && multiline) || (!c.is_control() && *c != '\r')
1901            })
1902            .collect();
1903        if filtered.is_empty() {
1904            return false;
1905        }
1906        {
1907            let mut st = state_rc.borrow_mut();
1908            st.insert_text(&filtered);
1909            let new_text = st.text.clone();
1910            notify_text_change(&f, fid, new_text);
1911            if let Some(hit) = f.hit_regions.iter().find(|h| h.id == fid) {
1912                tf_ensure_caret_visible(&mut st, hit.tf_multiline);
1913            }
1914        }
1915        request_frame();
1916        true
1917    }
1918
1919    /// Insert plain text into the focused textfield (winit `key_event.text`,
1920    /// Android soft-keyboard text, web paste). Alias for
1921    /// [`Self::insert_text_into_focused`].
1922    pub fn insert_text(&mut self, text: &str) -> bool {
1923        self.insert_text_into_focused(text)
1924    }
1925
1926    /// Insert text into a focused text field (used for paste). Uses an atomic
1927    /// (non-mergeable) edit so Ctrl+V doesn't merge with adjacent typing.
1928    pub fn paste_into_focused(&mut self, text: &str) {
1929        let Some(fid) = self.sched.focused else {
1930            return;
1931        };
1932        let Some(f) = &self.frame_cache.clone() else {
1933            return;
1934        };
1935        if !is_tf_editable(f, fid) {
1936            return;
1937        }
1938        let key = tf_key_of(f, fid);
1939        if let Some(state_rc) = self.textfield_states.get(&key) {
1940            let mut st = state_rc.borrow_mut();
1941            st.insert_text_atomic(text);
1942            let new_text = st.text.clone();
1943            notify_text_change(f, fid, new_text);
1944            if let Some(hit) = f.hit_regions.iter().find(|h| h.id == fid) {
1945                tf_ensure_caret_visible(&mut st, hit.tf_multiline);
1946            }
1947        }
1948        request_frame();
1949    }
1950
1951    /// Process a gamepad event: mirror connection/button/axis state into
1952    /// [`ReposeRuntime::gamepads`]. Sticks, shoulders and face extras stay raw
1953    /// gameplay input for the game to read from [`ReposeRuntime::gamepads`].
1954    ///
1955    /// Returns `true` when the event drove UI navigation.
1956    pub fn handle_gamepad(&mut self, event: &GamepadEvent) -> bool {
1957        match event {
1958            GamepadEvent::Connected { id, name } => {
1959                self.gamepads.insert(
1960                    id.0,
1961                    GamepadPad {
1962                        name: name.clone(),
1963                        ..GamepadPad::default()
1964                    },
1965                );
1966                request_frame();
1967                return false;
1968            }
1969            GamepadEvent::Disconnected { id } => {
1970                self.gamepads.remove(&id.0);
1971                request_frame();
1972                return false;
1973            }
1974            GamepadEvent::Button {
1975                id,
1976                button,
1977                pressed,
1978            } => {
1979                if let Some(pad) = self.gamepads.get_mut(&id.0) {
1980                    if *pressed {
1981                        pad.pressed.insert(*button);
1982                    } else {
1983                        pad.pressed.remove(button);
1984                    }
1985                }
1986                if *pressed {
1987                    let _ = repose_core::request_input_mode(repose_core::InputMode::Keyboard);
1988                }
1989                let key = match button {
1990                    GamepadButton::South => Key::Space,
1991                    GamepadButton::East => Key::Escape,
1992                    GamepadButton::Start => Key::Enter,
1993                    GamepadButton::DPadUp => Key::ArrowUp,
1994                    GamepadButton::DPadDown => Key::ArrowDown,
1995                    GamepadButton::DPadLeft => Key::ArrowLeft,
1996                    GamepadButton::DPadRight => Key::ArrowRight,
1997                    _ => return false,
1998                };
1999                let synthetic = KeyEvent {
2000                    key,
2001                    modifiers: self.modifiers,
2002                    is_repeat: false,
2003                    event_type: if *pressed {
2004                        KeyEventType::Down
2005                    } else {
2006                        KeyEventType::Up
2007                    },
2008                    utf16_code_point: 0,
2009                };
2010                return self.handle_key(&synthetic);
2011            }
2012            GamepadEvent::Axis { id, axis, value } => {
2013                if let Some(pad) = self.gamepads.get_mut(&id.0) {
2014                    pad.axes.insert(*axis, *value);
2015                }
2016                return false;
2017            }
2018        }
2019    }
2020
2021    /// Queue a dual-motor rumble for `id` (SDL-style: low = strong motor,
2022    /// high = weak motor, 0.0..=1.0, `duration_ms`). The platform runner
2023    /// drains the queue each frame into `GamepadBackend::set_rumble`.
2024    /// A `duration_ms` of 0 stops. No-op when the pad is unknown.
2025    pub fn request_rumble(
2026        &mut self,
2027        id: repose_core::input::GamepadId,
2028        low_freq: f32,
2029        high_freq: f32,
2030        duration_ms: u32,
2031    ) {
2032        self.pending_rumble.push((
2033            id.0,
2034            low_freq.clamp(0.0, 1.0),
2035            high_freq.clamp(0.0, 1.0),
2036            duration_ms,
2037        ));
2038    }
2039
2040    /// Queue a rumble stop for `id`.
2041    pub fn stop_rumble(&mut self, id: repose_core::input::GamepadId) {
2042        self.pending_rumble.push((id.0, 0.0, 0.0, 0));
2043    }
2044
2045    /// Drain queued rumble requests (platform runners call this after `poll`).
2046    pub fn take_rumble_requests(&mut self) -> Vec<(u32, f32, f32, u32)> {
2047        std::mem::take(&mut self.pending_rumble)
2048    }
2049
2050    /// Process a key event with an optional host-composed `text` payload
2051    /// (winit `key_event.text`, Android soft-keyboard text, ...).
2052    ///
2053    /// When the modifiers are free of Ctrl/Alt/Meta and the payload is
2054    /// Printable text goes to the focused field first. Otherwise falls through to handle_key.
2055    pub fn handle_key_with_text(&mut self, event: &KeyEvent, composed_text: Option<&str>) -> bool {
2056        if event.event_type == KeyEventType::Down {
2057            let _ = repose_core::request_input_mode(repose_core::InputMode::Keyboard);
2058        }
2059        if event.event_type == KeyEventType::Down
2060            && !event.is_repeat
2061            && !self.ime_preedit
2062            && !self.modifiers.ctrl
2063            && !self.modifiers.alt
2064            && !self.modifiers.meta
2065            && let Some(text) = composed_text
2066            && !text.chars().all(|c| c.is_control())
2067            && self.insert_text_into_focused(text)
2068        {
2069            return true;
2070        }
2071        self.handle_key(event)
2072    }
2073
2074    /// Process a scroll event at an explicit position, honoring a caller-owned
2075    /// scroll capture id (touch gestures initialize the capture themselves).
2076    /// Returns `(consumed, updated_capture_id)`.
2077    pub fn handle_scroll_at(
2078        &mut self,
2079        pos: Vec2,
2080        delta: Vec2,
2081        scroll_capture: Option<u64>,
2082    ) -> (bool, Option<u64>) {
2083        let Some(f) = &self.frame_cache else {
2084            return (false, scroll_capture);
2085        };
2086        let (consumed, cap) = dispatch_scroll(f, pos, delta, scroll_capture);
2087        if consumed {
2088            request_frame();
2089        }
2090        (consumed, cap)
2091    }
2092
2093    /// Next caret blink edge (`Instant`) for the focused text field, if any.
2094    /// Internal - platform should use `next_wakeup_deadline()` instead.
2095    fn next_caret_blink_deadline(&self) -> Option<web_time::Instant> {
2096        let fid = self.sched.focused?;
2097        let frame = self.frame_cache.as_ref()?;
2098        let hit = frame.hit_regions.iter().find(|h| h.id == fid)?;
2099        let key = hit.tf_state_key?;
2100        self.textfield_states
2101            .get(&key)?
2102            .borrow()
2103            .next_blink_deadline()
2104    }
2105
2106    /// Centralized wakeup helper for platform runners (caret, snackbar, timers, etc.).
2107    /// Debounced entries live on the shared timer queue, so `timer` covers them.
2108    pub fn next_wakeup_deadline(&self) -> Option<web_time::Instant> {
2109        [
2110            self.next_caret_blink_deadline(),
2111            repose_ui::overlay::SnackbarController::next_deadline(),
2112            repose_core::timer::next_deadline(),
2113        ]
2114        .into_iter()
2115        .flatten()
2116        .min()
2117    }
2118
2119    /// Whether a scheduled wakeup is due at `now` (deadline <= now).
2120    pub fn is_wakeup_due(&self, now: web_time::Instant) -> bool {
2121        self.next_wakeup_deadline().is_some_and(|d| d <= now)
2122    }
2123
2124    /// Whether a caret blink edge is due at `now` (deadline <= now).
2125    /// Deprecated: use `is_wakeup_due`.
2126    pub fn is_caret_blink_due(&self, now: web_time::Instant) -> bool {
2127        self.is_wakeup_due(now)
2128    }
2129
2130    /// Desktop-style deadline with idle keep-alive fallback.
2131    /// `idle_cap` is the maximum time the host should sleep without a
2132    /// scheduled wakeup (e.g. 1s on desktop to handle tray Deeplinks).
2133    pub fn next_frame_deadline(
2134        &self,
2135        now: web_time::Instant,
2136        idle_cap: web_time::Duration,
2137    ) -> web_time::Instant {
2138        self.next_wakeup_deadline().unwrap_or(now + idle_cap)
2139    }
2140
2141    /// Tick host-facing overlays (snackbar timeouts) and timers (including
2142    /// debounced entries, which live on the shared timer queue).
2143    /// Call once per redraw.
2144    pub fn tick_overlays(&self) {
2145        repose_ui::overlay::SnackbarController::tick_all();
2146        repose_core::timer::poll();
2147    }
2148
2149    /// Get the cursor suggestion (set during pointer-move handling).
2150    pub fn cursor_suggestion(&self) -> Option<CursorIcon> {
2151        self.cursor
2152    }
2153
2154    /// Take the cursor suggestion (clears it).
2155    pub fn take_cursor_suggestion(&mut self) -> Option<CursorIcon> {
2156        self.cursor.take()
2157    }
2158}
2159
2160impl Default for ReposeRuntime {
2161    fn default() -> Self {
2162        Self::new()
2163    }
2164}
2165
2166/// Inner compose frame logic (no dependency on repose-platform).
2167pub fn compose_frame_inner<F>(
2168    sched: &mut Scheduler,
2169    root_fn: &mut F,
2170    scale: f32,
2171    size_px_u32: (u32, u32),
2172    hover_id: Option<u64>,
2173    pressed_ids: &HashSet<u64>,
2174    tf_states: &HashMap<u64, Rc<RefCell<TextFieldState>>>,
2175) -> Frame
2176where
2177    F: FnMut(&mut Scheduler) -> View,
2178{
2179    compose_frame_inner_with_ancestors(
2180        sched,
2181        root_fn,
2182        scale,
2183        size_px_u32,
2184        hover_id,
2185        &std::collections::HashSet::new(),
2186        pressed_ids,
2187        tf_states,
2188    )
2189}
2190
2191pub fn compose_frame_inner_with_ancestors<F>(
2192    sched: &mut Scheduler,
2193    root_fn: &mut F,
2194    scale: f32,
2195    size_px_u32: (u32, u32),
2196    hover_id: Option<u64>,
2197    hover_ancestors: &std::collections::HashSet<u64>,
2198    pressed_ids: &HashSet<u64>,
2199    tf_states: &HashMap<u64, Rc<RefCell<TextFieldState>>>,
2200) -> Frame
2201where
2202    F: FnMut(&mut Scheduler) -> View,
2203{
2204    for requested_id in repose_core::runtime::drain_focus_requests() {
2205        if requested_id == repose_core::runtime::CLEAR_FOCUS_MARKER {
2206            sched.focused = None;
2207            continue;
2208        }
2209        sched.focused = Some(requested_id);
2210    }
2211
2212    set_density_default(Density { scale });
2213
2214    let current_focused = sched.focused;
2215
2216    let frame = sched.repose(
2217        { move |s: &mut Scheduler| with_density(Density { scale }, || (root_fn)(s)) },
2218        {
2219            let hover_ancestors = hover_ancestors.clone();
2220            let pressed_ids = pressed_ids.clone();
2221            move |view, _size| {
2222                let interactions = Interactions {
2223                    hover: hover_id,
2224                    hover_ancestors: hover_ancestors.clone(),
2225                    pressed: pressed_ids.clone(),
2226                };
2227                with_density(Density { scale }, || {
2228                    layout_and_paint(view, size_px_u32, tf_states, &interactions, current_focused)
2229                })
2230            }
2231        },
2232    );
2233
2234    if let Some(fid) = sched.focused
2235        && !frame.focus_chain.contains(&fid)
2236    {
2237        sched.focused = None;
2238    }
2239
2240    frame
2241}
2242
2243fn hover_chain_for(frame: Option<&Frame>, hover: Option<u64>) -> std::collections::HashSet<u64> {
2244    let Some(f) = frame else {
2245        return std::collections::HashSet::new();
2246    };
2247    let Some(mut cur) = hover else {
2248        return std::collections::HashSet::new();
2249    };
2250    let map: std::collections::HashMap<u64, Option<u64>> =
2251        f.hit_regions.iter().map(|h| (h.id, h.parent)).collect();
2252    let mut set = std::collections::HashSet::new();
2253    loop {
2254        set.insert(cur);
2255        if let Some(Some(parent)) = map.get(&cur).copied() {
2256            cur = parent;
2257        } else {
2258            break;
2259        }
2260    }
2261    set
2262}
2263
2264fn dispatch_hover_change_bubbled(
2265    frame: Option<&Frame>,
2266    leave_map: &HashMap<u64, (f32, f32, f32, f32, Rc<dyn Fn(PointerEvent)>)>,
2267    hover_id: &mut Option<u64>,
2268    hover_ancestors: &mut std::collections::HashSet<u64>,
2269    new_hover: Option<u64>,
2270    pos: Vec2,
2271    modifiers: Modifiers,
2272) {
2273    let old_hover = *hover_id;
2274    let old_chain = hover_chain_for(frame, old_hover);
2275    let new_chain = hover_chain_for(frame, new_hover);
2276    if old_chain == new_chain {
2277        return;
2278    }
2279    for leave_id in old_chain.difference(&new_chain) {
2280        let leave_info = leave_map.get(leave_id).cloned().or_else(|| {
2281            frame.and_then(|f| {
2282                f.hit_regions
2283                    .iter()
2284                    .find(|h| h.id == *leave_id)
2285                    .and_then(|h| {
2286                        h.on_pointer_leave
2287                            .as_ref()
2288                            .map(|cb| (h.rect.x, h.rect.y, h.rect.w, h.rect.h, cb.clone()))
2289                    })
2290            })
2291        });
2292        if let Some((rx, ry, _rw, _rh, cb)) = leave_info {
2293            let mut pe = PointerEvent::new(
2294                PointerId(0),
2295                PointerKind::Mouse,
2296                PointerEventKind::Leave,
2297                pos,
2298                1.0,
2299                modifiers,
2300            );
2301            pe.origin = Vec2 { x: rx, y: ry };
2302            pe.position = pe.position - pe.origin;
2303            cb(pe);
2304        }
2305    }
2306    for enter_id in new_chain.difference(&old_chain) {
2307        if let Some(f) = frame
2308            && let Some(h) = f.hit_regions.iter().find(|h| h.id == *enter_id)
2309            && let Some(cb) = &h.on_pointer_enter
2310        {
2311            let mut pe = PointerEvent::new(
2312                PointerId(0),
2313                PointerKind::Mouse,
2314                PointerEventKind::Enter,
2315                pos,
2316                1.0,
2317                modifiers,
2318            );
2319            pe.origin = Vec2 {
2320                x: h.rect.x,
2321                y: h.rect.y,
2322            };
2323            pe.position = pe.position - pe.origin;
2324            cb(pe);
2325        }
2326    }
2327    *hover_id = new_hover;
2328    hover_ancestors.clear();
2329    for id in &new_chain {
2330        if Some(*id) != new_hover {
2331            hover_ancestors.insert(*id);
2332        }
2333    }
2334}
2335
2336pub fn is_textfield_in_frame(f: &Frame, id: u64) -> bool {
2337    f.semantics_nodes
2338        .iter()
2339        .any(|n| n.id == id && n.role == repose_core::semantics::Role::TextField)
2340}
2341
2342pub fn is_textfield_in_frame_cache(frame_cache: &Option<Frame>, id: u64) -> bool {
2343    if let Some(f) = frame_cache {
2344        is_textfield_in_frame(f, id)
2345    } else {
2346        false
2347    }
2348}
2349
2350pub fn hit_index_by_id(frame: &Frame, id: u64) -> Option<usize> {
2351    frame.hit_regions.iter().position(|h| h.id == id)
2352}
2353
2354fn is_multiline_id(f: &Frame, id: u64) -> bool {
2355    f.hit_regions
2356        .iter()
2357        .find(|h| h.id == id)
2358        .map(|h| h.tf_multiline)
2359        .unwrap_or(false)
2360}
2361
2362/// `enabled=false` rejects edits. `readOnly` also rejects
2363/// edits but keeps selection/focus/copy working.
2364fn tf_can_edit(hit: &HitRegion) -> bool {
2365    hit.tf_enabled && !hit.tf_read_only
2366}
2367
2368fn is_tf_editable(f: &Frame, id: u64) -> bool {
2369    f.hit_regions
2370        .iter()
2371        .find(|h| h.id == id)
2372        .is_some_and(tf_can_edit)
2373}
2374
2375fn tf_key_of(frame: &Frame, visual_id: u64) -> u64 {
2376    if let Some(i) = frame.hit_regions.iter().position(|h| h.id == visual_id) {
2377        let hr = &frame.hit_regions[i];
2378        return hr.tf_state_key.unwrap_or(hr.id);
2379    }
2380    visual_id
2381}
2382
2383fn notify_text_change(f: &Frame, id: u64, text: String) {
2384    if let Some(h) = f.hit_regions.iter().find(|h| h.id == id)
2385        && let Some(cb) = &h.on_text_change
2386    {
2387        cb(text);
2388    }
2389}
2390
2391fn tf_ensure_caret_visible(state: &mut TextFieldState, is_multiline: bool) {
2392    let font_px = TF_FONT_SP.to_px().0;
2393    let wrap_width = state.inner_width;
2394
2395    if is_multiline {
2396        let (cx, cy, _) = caret_xy_for_byte(&state.text, font_px, wrap_width, state.caret_index());
2397        state.ensure_caret_visible_xy(
2398            cx,
2399            cy,
2400            state.inner_width,
2401            state.inner_height,
2402            Dp(2.0).to_px().0,
2403        );
2404    } else {
2405        let caret_idx = state.caret_index();
2406        let (display, caret_display_off) = if let Some(vt) = &state.visual_transformation {
2407            let annotated = repose_core::AnnotatedString::new(state.text.clone(), vec![]);
2408            let tfmd = vt.filter(&annotated);
2409            let off =
2410                repose_core::original_offset_to_display(&state.text, tfmd.text.as_str(), caret_idx);
2411            (tfmd.text.text, off)
2412        } else {
2413            (state.text.clone(), caret_idx)
2414        };
2415        let m = measure_text(&display, font_px, TextMeasureConfig::default());
2416        let caret_x_px = m.positions.get(caret_display_off).copied().unwrap_or(0.0);
2417        state.ensure_caret_visible(caret_x_px, wrap_width, Dp(2.0).to_px().0);
2418    }
2419}
2420
2421fn index_for_x_bytes_vt(state: &TextFieldState, font_px: f32, x_px: f32) -> usize {
2422    if let Some(vt) = &state.visual_transformation {
2423        let annotated = repose_core::AnnotatedString::new(state.text.clone(), vec![]);
2424        let tfmd = vt.filter(&annotated);
2425        let display_idx =
2426            repose_ui::textfield::index_for_x_bytes(tfmd.text.as_str(), font_px, x_px, 400, 0);
2427        tfmd.offset_mapping.transformed_to_original(display_idx)
2428    } else {
2429        repose_ui::textfield::index_for_x_bytes(&state.text, font_px, x_px, 400, 0)
2430    }
2431}
2432
2433fn index_for_xy_bytes_vt(
2434    state: &TextFieldState,
2435    font_px: f32,
2436    wrap_w: f32,
2437    x_px: f32,
2438    y_px: f32,
2439) -> usize {
2440    if let Some(vt) = &state.visual_transformation {
2441        let annotated = repose_core::AnnotatedString::new(state.text.clone(), vec![]);
2442        let tfmd = vt.filter(&annotated);
2443        let display_idx = repose_ui::textfield::index_for_xy_bytes(
2444            tfmd.text.as_str(),
2445            font_px,
2446            wrap_w,
2447            x_px,
2448            y_px,
2449        );
2450        tfmd.offset_mapping.transformed_to_original(display_idx)
2451    } else {
2452        repose_ui::textfield::index_for_xy_bytes(&state.text, font_px, wrap_w, x_px, y_px)
2453    }
2454}
2455
2456/// Dispatch scroll to scroll consumers. Returns (consumed, optional capture id).
2457fn dispatch_scroll(
2458    frame: &Frame,
2459    pos: Vec2,
2460    delta: Vec2,
2461    scroll_capture: Option<u64>,
2462) -> (bool, Option<u64>) {
2463    let mut remaining = delta;
2464    let mut first_consumer: Option<u64> = None;
2465    if let Some(cid) = scroll_capture
2466        && let Some(cb) = frame
2467            .hit_regions
2468            .iter()
2469            .find(|h| h.id == cid)
2470            .and_then(|h| h.on_scroll.as_ref())
2471    {
2472        let leftover = cb(delta);
2473        if (delta.x - leftover.x).abs() > 0.001 || (delta.y - leftover.y).abs() > 0.001 {
2474            first_consumer = Some(cid);
2475        }
2476        remaining = leftover;
2477        if remaining.x.abs() <= 0.001 && remaining.y.abs() <= 0.001 {
2478            return (true, Some(cid));
2479        }
2480    }
2481
2482    let mut consumed_any = first_consumer.is_some();
2483    for hit in frame
2484        .hit_regions
2485        .iter()
2486        .rev()
2487        .filter(|h| h.rect.contains(pos))
2488        .filter(|h| Some(h.id) != scroll_capture)
2489    {
2490        if remaining.x.abs() <= 0.001 && remaining.y.abs() <= 0.001 {
2491            break;
2492        }
2493        if let Some(cb) = &hit.on_scroll {
2494            let before = remaining;
2495            let leftover = cb(before);
2496            if (before.x - leftover.x).abs() > 0.001 || (before.y - leftover.y).abs() > 0.001 {
2497                consumed_any = true;
2498                if first_consumer.is_none() {
2499                    first_consumer = Some(hit.id);
2500                }
2501            }
2502            remaining = leftover;
2503        }
2504    }
2505    if consumed_any {
2506        (true, first_consumer)
2507    } else {
2508        (false, None)
2509    }
2510}
2511
2512#[cfg(test)]
2513mod gamepad_tests {
2514    use super::*;
2515    use repose_core::input::{GamepadAxis, GamepadButton, GamepadEvent, GamepadId};
2516
2517    #[test]
2518    fn gamepad_state_mirrors_connection_and_inputs() {
2519        let mut rt = ReposeRuntime::new();
2520        rt.handle_gamepad(&GamepadEvent::Connected {
2521            id: GamepadId(0),
2522            name: "Pad".to_string(),
2523        });
2524        assert_eq!(rt.gamepads.len(), 1);
2525
2526        rt.handle_gamepad(&GamepadEvent::Button {
2527            id: GamepadId(0),
2528            button: GamepadButton::West,
2529            pressed: true,
2530        });
2531        assert!(rt.gamepads[&0].button(GamepadButton::West));
2532
2533        rt.handle_gamepad(&GamepadEvent::Axis {
2534            id: GamepadId(0),
2535            axis: GamepadAxis::LeftStickX,
2536            value: 0.5,
2537        });
2538        assert_eq!(rt.gamepads[&0].axis(GamepadAxis::LeftStickX), 0.5);
2539
2540        rt.handle_gamepad(&GamepadEvent::Button {
2541            id: GamepadId(0),
2542            button: GamepadButton::West,
2543            pressed: false,
2544        });
2545        assert!(!rt.gamepads[&0].button(GamepadButton::West));
2546
2547        rt.handle_gamepad(&GamepadEvent::Disconnected { id: GamepadId(0) });
2548        assert!(rt.gamepads.is_empty());
2549    }
2550
2551    #[test]
2552    fn rumble_requests_queue_and_drain() {
2553        let mut rt = ReposeRuntime::new();
2554        assert!(rt.take_rumble_requests().is_empty());
2555        rt.request_rumble(GamepadId(1), 2.0, -1.0, 150);
2556        rt.stop_rumble(GamepadId(1));
2557        let reqs = rt.take_rumble_requests();
2558        assert_eq!(reqs.len(), 2);
2559        assert_eq!(reqs[0], (1, 1.0, 0.0, 150));
2560        assert_eq!(reqs[1], (1, 0.0, 0.0, 0));
2561        assert!(rt.take_rumble_requests().is_empty());
2562    }
2563}