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