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