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::shortcuts::DragAction;
7use repose_core::input::{
8    ImeEvent, Key, KeyEvent, KeyEventType, Modifiers, PointerButton, PointerEvent,
9    PointerEventKind, PointerId, PointerKind,
10};
11use repose_core::locals::{dp_to_px, set_density_default, with_density, Density};
12use repose_core::runtime::{Frame, Scheduler};
13use repose_core::{
14    take_focus_request, CursorIcon, HitRegion, RenderContext, Scene, Vec2, View,
15    request_frame,
16};
17use repose_ui::textfield::{
18    caret_xy_for_byte, measure_text, TextFieldState, TF_FONT_DP, TextMeasureConfig,
19};
20use repose_ui::{layout_and_paint, Interactions};
21
22/// Platform-directed side effects requested by the UI.
23#[derive(Clone, Default)]
24pub struct PlatformOutput {
25    /// Cursor to display (None = default/system cursor).
26    pub cursor: Option<CursorIcon>,
27    /// Whether IME input is allowed for the currently focused widget.
28    pub ime_allowed: bool,
29    /// IME cursor area in logical (DPI-scaled) coordinates: (x, y, width, height).
30    pub ime_cursor_area: Option<(f64, f64, f64, f64)>,
31    /// Text to write to the clipboard (transient - set once per frame, cleared after read).
32    pub clipboard_text: Option<String>,
33}
34
35/// Output of a single frame: the rendered scene plus metadata for the host.
36pub struct FrameOutput {
37    /// The scene graph for rendering.
38    pub scene: Scene,
39    /// Hit regions for pointer dispatch between frames.
40    pub hit_regions: Vec<HitRegion>,
41    /// Semantics nodes for a11y.
42    pub semantics_nodes: Vec<repose_core::runtime::SemNode>,
43    /// Focus chain for tab navigation.
44    pub focus_chain: Vec<u64>,
45    /// Platform-side effects (cursor, IME, clipboard).
46    pub platform: PlatformOutput,
47    /// Whether the UI wants pointer events (if false, host can pass events through).
48    pub wants_pointer: bool,
49    /// Whether the UI wants keyboard events (if false, host can pass events through).
50    pub wants_keyboard: bool,
51}
52
53/// Result of a pointer-move event processed by the runtime.
54pub struct PointerMoveResult {
55    /// Updated cursor suggestion for the host.
56    pub cursor: Option<CursorIcon>,
57    /// The id of the element under the pointer, if any.
58    pub hover_id: Option<u64>,
59}
60
61/// Result of a pointer-button event processed by the runtime.
62pub struct PointerButtonResult {
63    /// Id of the element that received focus (if any).
64    pub focused: Option<u64>,
65    /// Id of the captured element.
66    pub capture_id: Option<u64>,
67    /// Whether the event was consumed by the UI.
68    pub consumed: bool,
69    /// Whether an accessibility announcement was triggered.
70    pub needs_a11y_announce: bool,
71}
72
73/// Embeddable Repose runtime.
74///
75/// Manages composition scheduling, input routing, text-field state, and
76/// pointer/key dispatch.  The host owns the event loop and GPU device; this
77/// is purely the UI logic layer.
78pub struct ReposeRuntime {
79    pub sched: Scheduler,
80    pub scale: f32,
81
82    // Input state
83    pub modifiers: Modifiers,
84    pub mouse_pos_px: (f32, f32),
85    /// Whether the pointer is currently inside the window.
86    pub pointer_inside: bool,
87    pub hover_id: Option<u64>,
88    pub capture_id: Option<u64>,
89    pub pressed_ids: HashSet<u64>,
90    pub ime_preedit: bool,
91    pub key_pressed_active: Option<u64>,
92    pub last_focus: Option<u64>,
93
94    // Per-frame cache for hit testing
95    pub frame_cache: Option<Frame>,
96
97    // Platform output accumulator (cursor changes, etc.)
98    cursor: Option<CursorIcon>,
99
100    // Text field state
101    pub textfield_states: HashMap<u64, Rc<RefCell<TextFieldState>>>,
102}
103
104impl ReposeRuntime {
105    pub fn new() -> Self {
106        Self {
107            sched: Scheduler::new(),
108            scale: 1.0,
109            modifiers: Modifiers::default(),
110            mouse_pos_px: (0.0, 0.0),
111            pointer_inside: false,
112            hover_id: None,
113            capture_id: None,
114            pressed_ids: HashSet::new(),
115            ime_preedit: false,
116            key_pressed_active: None,
117            last_focus: None,
118            frame_cache: None,
119            cursor: None,
120            textfield_states: HashMap::new(),
121        }
122    }
123
124
125    /// Set the logical viewport size (in device pixels).
126    pub fn set_viewport(&mut self, width_px: u32, height_px: u32) {
127        self.sched.size = (width_px, height_px);
128    }
129
130    /// Set viewport size and DPI scale factor.
131    pub fn set_viewport_and_scale(&mut self, width_px: u32, height_px: u32, scale: f32) {
132        self.scale = scale;
133        self.sched.size = (width_px, height_px);
134    }
135
136    /// Advance animations. Call before `compose` each frame.
137    pub fn tick_animations(&self) {
138        repose_core::animation_driver::tick();
139    }
140
141
142    /// Compose and layout a frame, returning the output for rendering.
143    ///
144    /// Call `tick_animations` before this and `cache_frame` after (once you
145    /// have applied any host-specific overlays like the devtools inspector).
146    pub fn compose<F>(
147        &mut self,
148        root_fn: &mut F,
149        render_ctx: &RenderContext,
150    ) -> Frame
151    where
152        F: FnMut(&mut Scheduler, &RenderContext) -> View,
153    {
154        let size = self.sched.size;
155        let rc = render_ctx.clone();
156        let mut inner = |s: &mut Scheduler| (root_fn)(s, &rc);
157        // Root-level panic guard: a stray panic during compose must not kill the
158        // event loop / freeze the hosted demo.
159        match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
160            compose_frame_inner(
161                &mut self.sched,
162                &mut inner,
163                self.scale,
164                size,
165                self.hover_id,
166                &self.pressed_ids,
167                &self.textfield_states,
168            )
169        })) {
170            Ok(frame) => frame,
171            Err(_) => {
172                log::error!("compose panicked; presenting last good frame");
173                self.frame_cache.clone().unwrap_or_else(|| Frame {
174                    scene: Default::default(),
175                    hit_regions: Vec::new(),
176                    semantics_nodes: Vec::new(),
177                    focus_chain: Vec::new(),
178                })
179            }
180        }
181    }
182
183    /// Compose a frame and return structured output for the host.
184    pub fn frame(
185        &mut self,
186        mut root_fn: impl FnMut(&mut Scheduler, &RenderContext) -> View,
187        render_ctx: &RenderContext,
188    ) -> FrameOutput {
189        let captured = Rc::new(RefCell::new(None::<String>));
190        let hook = captured.clone();
191        repose_core::clipboard::set_clipboard_observer(Box::new(move |text| {
192            *hook.borrow_mut() = Some(text.to_string());
193        }));
194
195        let f = self.compose(&mut root_fn, render_ctx);
196
197        repose_core::clipboard::clear_clipboard_observer();
198        let clipboard_text = captured.borrow_mut().take();
199
200        let wants_pointer = !f.hit_regions.is_empty() || self.hover_id.is_some() || self.capture_id.is_some();
201        let wants_keyboard = !self.textfield_states.is_empty() || self.ime_preedit;
202
203        let ime_allowed = self.sched.focused.map_or(false, |fid| {
204            f.semantics_nodes
205                .iter()
206                .any(|n| n.id == fid && n.role == repose_core::semantics::Role::TextField)
207        });
208
209        let ime_cursor_area = if ime_allowed {
210            self.sched.focused.and_then(|fid| {
211                f.hit_regions.iter().find(|h| h.id == fid).map(|hit| {
212                    let sf = self.scale as f64;
213                    (
214                        hit.rect.x as f64 / sf,
215                        hit.rect.y as f64 / sf,
216                        hit.rect.w as f64 / sf,
217                        hit.rect.h as f64 / sf,
218                    )
219                })
220            })
221        } else {
222            None
223        };
224
225        let platform = PlatformOutput {
226            cursor: self.take_cursor_suggestion(),
227            ime_allowed,
228            ime_cursor_area,
229            clipboard_text,
230        };
231        FrameOutput {
232            scene: f.scene,
233            hit_regions: f.hit_regions,
234            semantics_nodes: f.semantics_nodes,
235            focus_chain: f.focus_chain,
236            platform,
237            wants_pointer,
238            wants_keyboard,
239        }
240    }
241
242    /// Store the composed frame for event hit testing.
243    pub fn cache_frame(&mut self, frame: Frame) {
244        self.frame_cache = Some(frame);
245    }
246
247
248    /// Process a pointer-move event. Returns cursor suggestion.
249    pub fn handle_pointer_move(&mut self, pos: Vec2) -> PointerMoveResult {
250        self.mouse_pos_px = (pos.x, pos.y);
251
252        // DnD move
253        if dnd::handle_drag_action(&DragAction::Move {
254            position: pos,
255            modifiers: self.modifiers,
256        }) {
257            request_frame();
258            return PointerMoveResult {
259                cursor: self.cursor,
260                hover_id: self.hover_id,
261            };
262        }
263
264        let Some(f) = &self.frame_cache else {
265            return PointerMoveResult {
266                cursor: None,
267                hover_id: None,
268            };
269        };
270
271        // TextField/TextArea drag selection (if captured)
272        if let Some(cid) = self.capture_id {
273            if is_textfield_in_frame(f, cid) {
274                if let Some(hit) = f.hit_regions.iter().find(|h| h.id == cid) {
275                    let key = tf_key_of(f, cid);
276                    if let Some(st_rc) = self.textfield_states.get(&key) {
277                        let mut st = st_rc.borrow_mut();
278                        let (ox, oy) = hit.tf_content_origin.unwrap_or((hit.rect.x, hit.rect.y));
279                        let content_x = (pos.x - ox + st.scroll_offset).max(0.0);
280                        let content_y = (pos.y - oy + st.scroll_offset_y).max(0.0);
281                        let font_px = dp_to_px(TF_FONT_DP) * repose_core::locals::text_scale().0;
282                        let wrap_w = st.inner_width.max(1.0);
283                        let idx = if hit.tf_multiline {
284                            index_for_xy_bytes_vt(&st, font_px, wrap_w, content_x, content_y)
285                        } else {
286                            index_for_x_bytes_vt(&st, font_px, content_x)
287                        };
288                        st.drag_to(idx);
289                    }
290                }
291            }
292        }
293
294        // Determine topmost hit
295        let top = f.hit_regions.iter().rev().find(|h| h.rect.contains(pos));
296
297        // Update cursor
298        self.cursor = top
299            .and_then(|h| h.cursor)
300            .or(Some(CursorIcon::Default));
301
302        let new_hover = top.map(|h| h.id);
303
304        // Enter / Leave
305        if new_hover != self.hover_id {
306            dispatch_hover_change(
307                Some(f),
308                &mut self.hover_id,
309                new_hover,
310                pos,
311                self.modifiers,
312            );
313            request_frame();
314        }
315
316        // Move delivery (captured first)
317        let pe = PointerEvent::new(
318            PointerId(0),
319            PointerKind::Mouse,
320            PointerEventKind::Move,
321            pos,
322            1.0,
323            self.modifiers,
324        );
325
326        if let Some(cid) = self.capture_id {
327            if let Some(h) = f.hit_regions.iter().find(|h| h.id == cid) {
328                if let Some(cb) = &h.on_pointer_move {
329                    cb(pe);
330                }
331            }
332        } else if let Some(h) = top {
333            if let Some(cb) = &h.on_pointer_move {
334                cb(pe);
335            }
336        }
337
338        PointerMoveResult {
339            cursor: self.cursor,
340            hover_id: self.hover_id,
341        }
342    }
343
344    /// Process a pointer button press. Returns focus/capture info.
345    pub fn handle_pointer_press(
346        &mut self,
347        pos: Vec2,
348        button: PointerButton,
349    ) -> PointerButtonResult {
350        self.mouse_pos_px = (pos.x, pos.y);
351
352        let Some(f) = &self.frame_cache else {
353            return PointerButtonResult {
354                focused: None,
355                capture_id: None,
356                consumed: false,
357                needs_a11y_announce: false,
358            };
359        };
360
361        let mut result = PointerButtonResult {
362            focused: None,
363            capture_id: None,
364            consumed: false,
365            needs_a11y_announce: false,
366        };
367
368        if let Some(hit) = f.hit_regions.iter().rev().find(|h| h.rect.contains(pos)) {
369            // DnD press
370            dnd::handle_drag_action(&DragAction::Press {
371                position: pos,
372                capture_id: hit.id,
373                kind: PointerKind::Mouse,
374                modifiers: self.modifiers,
375            });
376
377            // Capture
378            self.capture_id = Some(hit.id);
379            result.capture_id = Some(hit.id);
380            result.consumed = true;
381
382            // TextField caret placement
383            if is_textfield_in_frame(f, hit.id) {
384                let key = tf_key_of(f, hit.id);
385                let st_rc = self
386                    .textfield_states
387                    .entry(key)
388                    .or_insert_with(|| Rc::new(RefCell::new(TextFieldState::new())));
389                let mut st = st_rc.borrow_mut();
390                let (ox, oy) = hit.tf_content_origin.unwrap_or((hit.rect.x, hit.rect.y));
391                let content_x = (pos.x - ox + st.scroll_offset).max(0.0);
392                let content_y = (pos.y - oy + st.scroll_offset_y).max(0.0);
393                let font_px = dp_to_px(TF_FONT_DP) * repose_core::locals::text_scale().0;
394                let wrap_w = st.inner_width.max(1.0);
395
396                let idx = if hit.tf_multiline {
397                    index_for_xy_bytes_vt(&st, font_px, wrap_w, content_x, content_y)
398                } else {
399                    index_for_x_bytes_vt(&st, font_px, content_x)
400                };
401                st.handle_pointer_down(idx, (pos.x, pos.y), self.modifiers.shift);
402            }
403
404            // Pressed visual
405            self.pressed_ids.insert(hit.id);
406
407            // Focus + IME
408            if hit.focusable {
409                self.sched.focused = Some(hit.id);
410                result.focused = Some(hit.id);
411                let key = tf_key_of(f, hit.id);
412                self.textfield_states.entry(key).or_insert_with(|| {
413                    Rc::new(RefCell::new(TextFieldState::new()))
414                });
415            }
416
417            // PointerDown callback
418            if let Some(cb) = &hit.on_pointer_down {
419                let pe = PointerEvent::new(
420                    PointerId(0),
421                    PointerKind::Mouse,
422                    PointerEventKind::Down(button),
423                    pos,
424                    1.0,
425                    self.modifiers,
426                );
427                cb(pe);
428            }
429
430            request_frame();
431        } else {
432            // Click outside: drop focus
433            if self.ime_preedit {
434                self.ime_preedit = false;
435            }
436            self.sched.focused = None;
437            request_frame();
438        }
439
440        result
441    }
442
443    /// Process a pointer button release.
444    pub fn handle_pointer_release(&mut self, pos: Vec2, _button: PointerButton) {
445        self.mouse_pos_px = (pos.x, pos.y);
446
447        if dnd::handle_drag_action(&DragAction::Release {
448            position: pos,
449            modifiers: self.modifiers,
450        }) {
451            self.capture_id = None;
452            self.pressed_ids.clear();
453            request_frame();
454            return;
455        }
456
457        if let Some(cid) = self.capture_id {
458            self.pressed_ids.remove(&cid);
459        }
460
461        let Some(f) = &self.frame_cache else {
462            self.capture_id = None;
463            return;
464        };
465
466        // PointerUp callback
467        if let Some(cid) = self.capture_id {
468            if let Some(hit) = f.hit_regions.iter().find(|h| h.id == cid) {
469                if let Some(cb) = &hit.on_pointer_up {
470                    let pe = PointerEvent::new(
471                        PointerId(0),
472                        PointerKind::Mouse,
473                        PointerEventKind::Up(_button),
474                        pos,
475                        1.0,
476                        self.modifiers,
477                    );
478                    cb(pe);
479                }
480            }
481        }
482
483        // Click detection
484        if let Some(cid) = self.capture_id {
485            if let Some(hit) = f.hit_regions.iter().find(|h| h.id == cid) {
486                if hit.rect.contains(pos) {
487                    if let Some(cb) = &hit.on_click {
488                        cb();
489                    }
490                }
491            }
492        }
493
494        // TextField drag end
495        if let Some(cid) = self.capture_id {
496            if is_semantics_textfield(f, cid) {
497                let key = tf_key_of(f, cid);
498                if let Some(state_rc) = self.textfield_states.get(&key) {
499                    state_rc.borrow_mut().end_drag();
500                }
501            }
502        }
503
504        self.capture_id = None;
505        request_frame();
506    }
507
508    /// Cancel pointer state (focus lost, cursor left window, etc.).
509    pub fn handle_pointer_cancel(&mut self) {
510        dnd::handle_drag_action(&DragAction::Cancel);
511        let pos = Vec2 {
512            x: self.mouse_pos_px.0,
513            y: self.mouse_pos_px.1,
514        };
515        dispatch_hover_change(
516            self.frame_cache.as_ref(),
517            &mut self.hover_id,
518            None,
519            pos,
520            self.modifiers,
521        );
522        // Emit Cancel for captured region
523        if let (Some(f), Some(cid)) = (&self.frame_cache, self.capture_id) {
524            if let Some(hit) = f.hit_regions.iter().find(|h| h.id == cid) {
525                if let Some(cb) = &hit.on_pointer_cancel {
526                    let pe = PointerEvent::new(
527                        PointerId(0),
528                        PointerKind::Mouse,
529                        PointerEventKind::Cancel,
530                        pos,
531                        1.0,
532                        self.modifiers,
533                    );
534                    cb(pe);
535                }
536            }
537        }
538        self.reset_pointer_state();
539    }
540
541    /// Clear hover state, emitting HoverLeave for the currently hovered region.
542    pub fn clear_hover(&mut self) {
543        if self.hover_id.is_none() {
544            return;
545        }
546        let pos = Vec2 {
547            x: self.mouse_pos_px.0,
548            y: self.mouse_pos_px.1,
549        };
550        dispatch_hover_change(
551            self.frame_cache.as_ref(),
552            &mut self.hover_id,
553            None,
554            pos,
555            self.modifiers,
556        );
557    }
558
559    /// Reconcile hover state when the composed frame changes.
560    pub fn reconcile_hover_from_mouse_pos(&mut self, new_frame: &Frame) {
561        let mut changed = false;
562
563        if let Some(prev_id) = self.hover_id {
564            if !new_frame.hit_regions.iter().any(|h| h.id == prev_id) {
565                if let Some(old_f) = &self.frame_cache
566                    && let Some(prev) = old_f.hit_regions.iter().find(|h| h.id == prev_id)
567                    && let Some(cb) = &prev.on_pointer_leave
568                {
569                    let pos = Vec2 {
570                        x: self.mouse_pos_px.0,
571                        y: self.mouse_pos_px.1,
572                    };
573                    let pe = PointerEvent::new(
574                        PointerId(0),
575                        PointerKind::Mouse,
576                        PointerEventKind::Leave,
577                        pos,
578                        1.0,
579                        self.modifiers,
580                    );
581                    cb(pe);
582                    changed = true;
583                }
584                self.hover_id = None;
585            }
586        }
587
588        if !self.pointer_inside {
589            return;
590        }
591
592        let pos = Vec2 {
593            x: self.mouse_pos_px.0,
594            y: self.mouse_pos_px.1,
595        };
596        let new_hover = new_frame
597            .hit_regions
598            .iter()
599            .rev()
600            .find(|h| h.rect.contains(pos))
601            .map(|h| h.id);
602
603        if new_hover == self.hover_id {
604            if changed {
605                request_frame();
606            }
607            return;
608        }
609
610        dispatch_hover_change(
611            Some(new_frame),
612            &mut self.hover_id,
613            new_hover,
614            pos,
615            self.modifiers,
616        );
617        request_frame();
618    }
619
620    fn reset_pointer_state(&mut self) {
621        self.capture_id = None;
622        self.pressed_ids.clear();
623        self.hover_id = None;
624    }
625
626
627    /// Process a scroll event. Returns true if consumed.
628    pub fn handle_scroll(&mut self, delta: Vec2) -> bool {
629        let Some(f) = &self.frame_cache else {
630            return false;
631        };
632        let pos = Vec2 {
633            x: self.mouse_pos_px.0,
634            y: self.mouse_pos_px.1,
635        };
636        let (consumed, _) = dispatch_scroll(f, pos, delta, None);
637        if consumed {
638            request_frame();
639        }
640        consumed
641    }
642
643
644    /// Process a keyboard key event. Returns true if consumed.
645    pub fn handle_key(&mut self, event: &KeyEvent) -> bool {
646        let Some(f) = &self.frame_cache else {
647            return false;
648        };
649
650        // Escape / BrowserBack: cancel DnD first, then try focus key dispatch
651        if event.event_type == KeyEventType::Down && !event.is_repeat {
652            if event.key == Key::Escape {
653                if dnd::handle_drag_action(&DragAction::Cancel) {
654                    request_frame();
655                    return true;
656                }
657                // Try dispatch through focus chain
658                if self.dispatch_focus_key_event(f, event) {
659                    request_frame();
660                    return true;
661                }
662                return true;
663            }
664        }
665
666        // Dispatch through focus ancestor chain
667        let consumed = self.dispatch_focus_key_event(f, event);
668        if consumed {
669            request_frame();
670            return true;
671        }
672
673        // Action dispatch (shortcuts like Ctrl+C, Tab, etc.)
674        if event.event_type == KeyEventType::Down && !event.is_repeat {
675            if let Some(action) = repose_core::shortcuts::resolve_action(
676                repose_core::shortcuts::KeyChord::new(event.key.clone(), self.modifiers),
677            ) {
678                if self.dispatch_action(f, action.clone()) {
679                    request_frame();
680                    return true;
681                }
682                // Focus navigation
683                if let Some(new_id) = repose_core::focus::handle_action(&action, &mut self.sched, f)
684                {
685                    // Lazy-init textfield state for newly focused element
686                    if let Some(hit) = f.hit_regions.iter().find(|h| h.id == new_id) {
687                        if let Some(key) = hit.tf_state_key {
688                            self.textfield_states.entry(key).or_insert_with(|| {
689                                Rc::new(RefCell::new(TextFieldState::new()))
690                            });
691                        }
692                    }
693                    request_frame();
694                    return true;
695                }
696            }
697        }
698
699        // Keyboard activation (Space/Enter on focused non-textfield)
700        if let Some(fid) = self.sched.focused {
701            let is_tf = f.semantics_nodes.iter().any(|n| n.id == fid && n.role == repose_core::semantics::Role::TextField);
702            if !is_tf {
703                if event.event_type == KeyEventType::Down && !event.is_repeat {
704                    if event.key == Key::Space || event.key == Key::Enter {
705                        self.pressed_ids.insert(fid);
706                        self.key_pressed_active = Some(fid);
707                        request_frame();
708                        return true;
709                    }
710                } else if event.event_type == KeyEventType::Up {
711                    if let Some(active_id) = self.key_pressed_active {
712                        if event.key == Key::Space || event.key == Key::Enter {
713                            self.pressed_ids.remove(&active_id);
714                            self.key_pressed_active = None;
715                            if let Some(hit) = f.hit_regions.iter().find(|h| h.id == active_id) {
716                                if let Some(cb) = &hit.on_click {
717                                    cb();
718                                } else if let Some(cb) = &hit.on_pointer_down {
719                                    let pe = PointerEvent::new(
720                                        PointerId(0),
721                                        PointerKind::Mouse,
722                                        PointerEventKind::Down(PointerButton::Primary),
723                                        Vec2 { x: 0.0, y: 0.0 },
724                                        1.0,
725                                        self.modifiers,
726                                    );
727                                    cb(pe);
728                                }
729                            }
730                            request_frame();
731                            return true;
732                        }
733                    }
734                }
735            }
736        }
737
738        // Enter submission for focused TextField
739        if event.event_type == KeyEventType::Down && !event.is_repeat && event.key == Key::Enter {
740            if let Some(fid) = self.sched.focused {
741                if let Some(hit) = f.hit_regions.iter().find(|h| h.id == fid) {
742                    let is_multiline = hit.tf_multiline;
743                    let should_submit = if is_multiline {
744                        self.modifiers.ctrl || self.modifiers.meta
745                    } else {
746                        true
747                    };
748                    if should_submit {
749                        if let Some(on_submit) = &hit.on_text_submit {
750                            let key = tf_key_of(f, fid);
751                            if let Some(state_rc) = self.textfield_states.get(&key) {
752                                let text = state_rc.borrow().text.clone();
753                                on_submit(text);
754                                request_frame();
755                                return true;
756                            }
757                        }
758                    } else {
759                        // Multiline plain Enter: insert newline
760                        let key = tf_key_of(f, fid);
761                        if let Some(state_rc) = self.textfield_states.get(&key) {
762                            let mut st = state_rc.borrow_mut();
763                            st.insert_text("\n");
764                            let new_text = st.text.clone();
765                            notify_text_change(f, fid, new_text);
766                            tf_ensure_caret_visible(&mut st, hit.tf_multiline);
767                            request_frame();
768                            return true;
769                        }
770                    }
771                }
772            }
773        }
774
775        // TextField navigation / edit keys
776        if event.event_type == KeyEventType::Down {
777            if let Some(fid) = self.sched.focused {
778                let key = tf_key_of(f, fid);
779                if let Some(state_rc) = self.textfield_states.get(&key) {
780                    let mut state = state_rc.borrow_mut();
781                    match event.key {
782                        Key::Backspace => {
783                            state.delete_backward();
784                            let new_text = state.text.clone();
785                            notify_text_change(f, fid, new_text);
786                            tf_ensure_caret_visible(&mut state, is_multiline_id(f, fid));
787                            request_frame();
788                            return true;
789                        }
790                        Key::Delete => {
791                            state.delete_forward();
792                            let new_text = state.text.clone();
793                            notify_text_change(f, fid, new_text);
794                            tf_ensure_caret_visible(&mut state, is_multiline_id(f, fid));
795                            request_frame();
796                            return true;
797                        }
798                        Key::ArrowLeft => {
799                            state.move_cursor(-1, self.modifiers.shift);
800                            state.preferred_x_px = None;
801                            tf_ensure_caret_visible(&mut state, is_multiline_id(f, fid));
802                            request_frame();
803                            return true;
804                        }
805                        Key::ArrowRight => {
806                            state.move_cursor(1, self.modifiers.shift);
807                            state.preferred_x_px = None;
808                            tf_ensure_caret_visible(&mut state, is_multiline_id(f, fid));
809                            request_frame();
810                            return true;
811                        }
812                        Key::ArrowUp => {
813                            if is_multiline_id(f, fid) {
814                                if let Some(hit) = f.hit_regions.iter().find(|h| h.id == fid) {
815                                    let font_px = dp_to_px(TF_FONT_DP);
816                                    let cur = state.caret_index();
817                                    let (new_pos, px) =
818                                        repose_ui::textfield::move_caret_vertical(
819                                            &state.text,
820                                            font_px,
821                                            hit.rect.w,
822                                            cur,
823                                            -1,
824                                            state.preferred_x_px,
825                                        );
826                                    if self.modifiers.shift {
827                                        state.selection.end = new_pos;
828                                    } else {
829                                        state.selection = new_pos..new_pos;
830                                    }
831                                    state.preferred_x_px = Some(px);
832                                    let (cx, cy, _) = caret_xy_for_byte(
833                                        &state.text,
834                                        font_px,
835                                        hit.rect.w,
836                                        state.caret_index(),
837                                    );
838                                    let iw = state.inner_width;
839                                    let ih = state.inner_height;
840                                    state.ensure_caret_visible_xy(
841                                        cx, cy,
842                                        iw,
843                                        ih,
844                                        dp_to_px(2.0),
845                                    );
846                                    request_frame();
847                                    return true;
848                                }
849                            }
850                        }
851                        Key::ArrowDown => {
852                            if is_multiline_id(f, fid) {
853                                if let Some(hit) = f.hit_regions.iter().find(|h| h.id == fid) {
854                                    let font_px = dp_to_px(TF_FONT_DP);
855                                    let cur = state.caret_index();
856                                    let (new_pos, px) =
857                                        repose_ui::textfield::move_caret_vertical(
858                                            &state.text,
859                                            font_px,
860                                            hit.rect.w,
861                                            cur,
862                                            1,
863                                            state.preferred_x_px,
864                                        );
865                                    if self.modifiers.shift {
866                                        state.selection.end = new_pos;
867                                    } else {
868                                        state.selection = new_pos..new_pos;
869                                    }
870                                    state.preferred_x_px = Some(px);
871                                    let (cx, cy, _) = caret_xy_for_byte(
872                                        &state.text,
873                                        font_px,
874                                        hit.rect.w,
875                                        state.caret_index(),
876                                    );
877                                    let iw = state.inner_width;
878                                    let ih = state.inner_height;
879                                    state.ensure_caret_visible_xy(
880                                        cx, cy,
881                                        iw,
882                                        ih,
883                                        dp_to_px(2.0),
884                                    );
885                                    request_frame();
886                                    return true;
887                                }
888                            }
889                        }
890                        Key::Home => {
891                            state.selection = 0..0;
892                            tf_ensure_caret_visible(&mut state, is_multiline_id(f, fid));
893                            request_frame();
894                            return true;
895                        }
896                        Key::End => {
897                            let end = state.text.len();
898                            state.selection = end..end;
899                            tf_ensure_caret_visible(&mut state, is_multiline_id(f, fid));
900                            request_frame();
901                            return true;
902                        }
903                        _ => {}
904                    }
905                }
906            }
907
908            // Plain text input (non-IME)
909            if !self.ime_preedit
910                && !self.modifiers.ctrl
911                && !self.modifiers.alt
912                && !self.modifiers.meta
913            {
914                if let Key::Character(c) = event.key {
915                    if !c.is_control() && c != '\n' && c != '\r' {
916                        if let Some(fid) = self.sched.focused {
917                            let key = tf_key_of(f, fid);
918                            if let Some(state_rc) = self.textfield_states.get(&key) {
919                                let mut st = state_rc.borrow_mut();
920                                let text = c.to_string();
921                                st.insert_text(&text);
922                                notify_text_change(f, fid, st.text.clone());
923                                if let Some(hit) = f.hit_regions.iter().find(|h| h.id == fid) {
924                                    tf_ensure_caret_visible(&mut st, hit.tf_multiline);
925                                }
926                                request_frame();
927                                return true;
928                            }
929                        }
930                    }
931                }
932            }
933        }
934
935        // Key release: finish keyboard activation
936        if event.event_type == KeyEventType::Up {
937            if let Some(active_id) = self.key_pressed_active {
938                if event.key == Key::Space || event.key == Key::Enter {
939                    self.pressed_ids.remove(&active_id);
940                    self.key_pressed_active = None;
941                    if let Some(hit) = f.hit_regions.iter().find(|h| h.id == active_id) {
942                        if let Some(cb) = &hit.on_click {
943                            cb();
944                        }
945                    }
946                    request_frame();
947                    return true;
948                }
949            }
950        }
951
952        false
953    }
954
955    /// Dispatch a key event through the focus ancestor chain.
956    fn dispatch_focus_key_event(&self, f: &Frame, event: &KeyEvent) -> bool {
957        let Some(focused) = self.sched.focused else {
958            return false;
959        };
960
961        let hit_by_id: HashMap<u64, &HitRegion> =
962            f.hit_regions.iter().map(|h| (h.id, h)).collect();
963        let sem_parent_of: HashMap<u64, u64> = f
964            .semantics_nodes
965            .iter()
966            .filter_map(|n| n.parent.map(|p| (n.id, p)))
967            .collect();
968
969        // Build ancestor chain
970        let mut ancestors = Vec::new();
971        let mut cur = focused;
972        loop {
973            ancestors.push(cur);
974            if let Some(&p) = sem_parent_of.get(&cur) {
975                cur = p;
976            } else {
977                break;
978            }
979        }
980
981        // Top-down preview: root → focused
982        for &id in ancestors.iter().rev() {
983            if let Some(hit) = hit_by_id.get(&id) {
984                if let Some(cb) = &hit.on_preview_key_event {
985                    if cb(event.clone()) {
986                        return true;
987                    }
988                }
989            }
990        }
991
992        // Bottom-up normal: focused → root
993        for &id in ancestors.iter() {
994            if let Some(hit) = hit_by_id.get(&id) {
995                if let Some(cb) = &hit.on_key_event {
996                    if cb(event.clone()) {
997                        return true;
998                    }
999                }
1000            }
1001        }
1002
1003        false
1004    }
1005
1006    /// Dispatch a shortcut action to the focused element.
1007    fn dispatch_action(&self, f: &Frame, action: repose_core::shortcuts::Action) -> bool {
1008        if let Some(fid) = self.sched.focused {
1009            if let Some(hit) = f.hit_regions.iter().find(|h| h.id == fid) {
1010                if let Some(cb) = &hit.on_action {
1011                    if cb(action.clone()) {
1012                        return true;
1013                    }
1014                }
1015            }
1016        }
1017
1018        if repose_core::shortcuts::handle(action.clone()) {
1019            return true;
1020        }
1021
1022        false
1023    }
1024
1025
1026    /// Process an IME event.
1027    pub fn handle_ime(&mut self, event: &ImeEvent) {
1028        let Some(fid) = self.sched.focused else {
1029            return;
1030        };
1031        let Some(f) = &self.frame_cache else {
1032            return;
1033        };
1034        let key = tf_key_of(f, fid);
1035        let Some(state_rc) = self.textfield_states.get(&key) else {
1036            return;
1037        };
1038
1039        let mut state = state_rc.borrow_mut();
1040
1041        match event {
1042            ImeEvent::Start => {
1043                self.ime_preedit = false;
1044            }
1045            ImeEvent::Update { text, cursor } => {
1046                state.set_composition(text.clone(), *cursor);
1047                self.ime_preedit = !text.is_empty();
1048                repose_ui::textfield::ensure_caret_visible(&mut state, true);
1049                notify_text_change(f, fid, state.text.clone());
1050            }
1051            ImeEvent::Commit(text) => {
1052                state.commit_composition(text.clone());
1053                self.ime_preedit = false;
1054                repose_ui::textfield::ensure_caret_visible(&mut state, true);
1055                notify_text_change(f, fid, state.text.clone());
1056            }
1057            ImeEvent::Cancel => {
1058                self.ime_preedit = false;
1059                if state.composition.is_some() {
1060                    state.cancel_composition();
1061                    repose_ui::textfield::ensure_caret_visible(&mut state, true);
1062                    notify_text_change(f, fid, state.text.clone());
1063                }
1064            }
1065        }
1066
1067        request_frame();
1068    }
1069
1070
1071    /// Handle focus lost (window unfocused, etc.).
1072    pub fn handle_focus_lost(&mut self) {
1073        dnd::handle_drag_action(&DragAction::Cancel);
1074        self.handle_pointer_cancel();
1075        self.ime_preedit = false;
1076    }
1077
1078
1079    /// Get or create a text field state by its key.
1080    pub fn ensure_textfield_state(&mut self, key: u64) -> Rc<RefCell<TextFieldState>> {
1081        self.textfield_states
1082            .entry(key)
1083            .or_insert_with(|| Rc::new(RefCell::new(TextFieldState::new())))
1084            .clone()
1085    }
1086
1087    /// Look up the persistent state key for a visual hit-region id.
1088    pub fn tf_key_of(&self, visual_id: u64) -> u64 {
1089        self.frame_cache
1090            .as_ref()
1091            .map(|f| tf_key_of(f, visual_id))
1092            .unwrap_or(visual_id)
1093    }
1094
1095    /// True if the given id belongs to a TextField.
1096    pub fn is_textfield(&self, id: u64) -> bool {
1097        self.frame_cache
1098            .as_ref()
1099            .map(|f| is_textfield_in_frame(f, id))
1100            .unwrap_or(false)
1101    }
1102
1103    /// True if the given textfield id is multiline.
1104    pub fn is_multiline(&self, id: u64) -> bool {
1105        self.frame_cache
1106            .as_ref()
1107            .map(|f| is_multiline_id(f, id))
1108            .unwrap_or(false)
1109    }
1110
1111
1112    /// Insert text into a focused text field (used for paste).
1113    pub fn paste_into_focused(&mut self, text: &str) {
1114        let Some(fid) = self.sched.focused else {
1115            return;
1116        };
1117        let Some(f) = &self.frame_cache.clone() else {
1118            return;
1119        };
1120        let key = tf_key_of(f, fid);
1121        if let Some(state_rc) = self.textfield_states.get(&key) {
1122            let mut st = state_rc.borrow_mut();
1123            st.insert_text_atomic(text);
1124            let new_text = st.text.clone();
1125            notify_text_change(f, fid, new_text);
1126            if let Some(hit) = f.hit_regions.iter().find(|h| h.id == fid) {
1127                tf_ensure_caret_visible(&mut st, hit.tf_multiline);
1128            }
1129        }
1130    }
1131
1132    /// Get the cursor suggestion (set during pointer-move handling).
1133    pub fn cursor_suggestion(&self) -> Option<CursorIcon> {
1134        self.cursor
1135    }
1136
1137    /// Take the cursor suggestion (clears it).
1138    pub fn take_cursor_suggestion(&mut self) -> Option<CursorIcon> {
1139        self.cursor.take()
1140    }
1141}
1142
1143impl Default for ReposeRuntime {
1144    fn default() -> Self {
1145        Self::new()
1146    }
1147}
1148
1149
1150/// Inner compose frame logic (no dependency on repose-platform).
1151pub fn compose_frame_inner<F>(
1152    sched: &mut Scheduler,
1153    root_fn: &mut F,
1154    scale: f32,
1155    size_px_u32: (u32, u32),
1156    hover_id: Option<u64>,
1157    pressed_ids: &HashSet<u64>,
1158    tf_states: &HashMap<u64, Rc<RefCell<TextFieldState>>>,
1159) -> Frame
1160where
1161    F: FnMut(&mut Scheduler) -> View,
1162{
1163    if let Some(requested_id) = take_focus_request() {
1164        if requested_id == repose_core::runtime::CLEAR_FOCUS_MARKER {
1165            sched.focused = None;
1166        } else {
1167            sched.focused = Some(requested_id);
1168        }
1169    }
1170
1171    set_density_default(Density { scale });
1172
1173    let current_focused = sched.focused;
1174
1175    let frame = sched.repose(
1176        {
1177            let scale = scale;
1178            move |s: &mut Scheduler| with_density(Density { scale }, || (root_fn)(s))
1179        },
1180        {
1181            let hover_id = hover_id;
1182            let pressed_ids = pressed_ids.clone();
1183            move |view, _size| {
1184                let interactions = Interactions {
1185                    hover: hover_id,
1186                    pressed: pressed_ids.clone(),
1187                };
1188                with_density(Density { scale }, || {
1189                    layout_and_paint(view, size_px_u32, tf_states, &interactions, current_focused)
1190                })
1191            }
1192        },
1193    );
1194
1195    if let Some(fid) = sched.focused {
1196        if !frame.focus_chain.contains(&fid) {
1197            sched.focused = None;
1198        }
1199    }
1200
1201    frame
1202}
1203
1204/// Fire enter/leave callbacks when the hovered region changes, updating
1205/// `hover_id`. If the previous region is gone from the frame, no leave is
1206/// fired (its callbacks were dropped with it); `hover_id` is still cleared.
1207fn dispatch_hover_change(
1208    frame: Option<&Frame>,
1209    hover_id: &mut Option<u64>,
1210    new_hover: Option<u64>,
1211    pos: Vec2,
1212    modifiers: Modifiers,
1213) {
1214    let Some(f) = frame else {
1215        *hover_id = None;
1216        return;
1217    };
1218    if new_hover == *hover_id {
1219        return;
1220    }
1221    if let Some(prev_id) = *hover_id {
1222        if let Some(prev) = f.hit_regions.iter().find(|h| h.id == prev_id) {
1223            if let Some(cb) = &prev.on_pointer_leave {
1224                let pe = PointerEvent::new(
1225                    PointerId(0),
1226                    PointerKind::Mouse,
1227                    PointerEventKind::Leave,
1228                    pos,
1229                    1.0,
1230                    modifiers,
1231                );
1232                cb(pe);
1233            }
1234        }
1235    }
1236    if let Some(hid) = new_hover {
1237        if let Some(h) = f.hit_regions.iter().find(|h| h.id == hid) {
1238            if let Some(cb) = &h.on_pointer_enter {
1239                let pe = PointerEvent::new(
1240                    PointerId(0),
1241                    PointerKind::Mouse,
1242                    PointerEventKind::Enter,
1243                    pos,
1244                    1.0,
1245                    modifiers,
1246                );
1247                cb(pe);
1248            }
1249        }
1250    }
1251    *hover_id = new_hover;
1252}
1253
1254fn is_textfield_in_frame(f: &Frame, id: u64) -> bool {
1255    f.semantics_nodes
1256        .iter()
1257        .any(|n| n.id == id && n.role == repose_core::semantics::Role::TextField)
1258}
1259
1260fn is_semantics_textfield(f: &Frame, id: u64) -> bool {
1261    f.semantics_nodes
1262        .iter()
1263        .any(|n| n.id == id && n.role == repose_core::semantics::Role::TextField)
1264}
1265
1266fn is_multiline_id(f: &Frame, id: u64) -> bool {
1267    f.hit_regions
1268        .iter()
1269        .find(|h| h.id == id)
1270        .map(|h| h.tf_multiline)
1271        .unwrap_or(false)
1272}
1273
1274fn tf_key_of(frame: &Frame, visual_id: u64) -> u64 {
1275    if let Some(i) = frame.hit_regions.iter().position(|h| h.id == visual_id) {
1276        let hr = &frame.hit_regions[i];
1277        return hr.tf_state_key.unwrap_or(hr.id);
1278    }
1279    visual_id
1280}
1281
1282fn notify_text_change(f: &Frame, id: u64, text: String) {
1283    if let Some(h) = f.hit_regions.iter().find(|h| h.id == id) {
1284        if let Some(cb) = &h.on_text_change {
1285            cb(text);
1286        }
1287    }
1288}
1289
1290fn tf_ensure_caret_visible(state: &mut TextFieldState, is_multiline: bool) {
1291    let font_px = dp_to_px(TF_FONT_DP) * repose_core::locals::text_scale().0;
1292    let wrap_width = state.inner_width;
1293
1294    if is_multiline {
1295        let (cx, cy, _) = caret_xy_for_byte(&state.text, font_px, wrap_width, state.caret_index());
1296        state.ensure_caret_visible_xy(cx, cy, state.inner_width, state.inner_height, dp_to_px(2.0));
1297    } else {
1298        let caret_idx = state.caret_index();
1299        let (display, caret_display_off) = if let Some(vt) = &state.visual_transformation {
1300            let annotated = repose_core::AnnotatedString::new(state.text.clone(), vec![]);
1301            let tfmd = vt.filter(&annotated);
1302            let off = repose_core::original_offset_to_display(&state.text, tfmd.text.as_str(), caret_idx);
1303            (tfmd.text.text, off)
1304        } else {
1305            (state.text.clone(), caret_idx)
1306        };
1307        let m = measure_text(&display, font_px, TextMeasureConfig::default());
1308        let caret_x_px = m.positions.get(caret_display_off).copied().unwrap_or(0.0);
1309        state.ensure_caret_visible(caret_x_px, wrap_width, dp_to_px(2.0));
1310    }
1311}
1312
1313fn index_for_x_bytes_vt(state: &TextFieldState, font_px: f32, x_px: f32) -> usize {
1314    if let Some(vt) = &state.visual_transformation {
1315        let annotated = repose_core::AnnotatedString::new(state.text.clone(), vec![]);
1316        let tfmd = vt.filter(&annotated);
1317        let display_idx = repose_ui::textfield::index_for_x_bytes(tfmd.text.as_str(), font_px, x_px, 400, 0);
1318        tfmd.offset_mapping.transformed_to_original(display_idx)
1319    } else {
1320        repose_ui::textfield::index_for_x_bytes(&state.text, font_px, x_px, 400, 0)
1321    }
1322}
1323
1324fn index_for_xy_bytes_vt(
1325    state: &TextFieldState,
1326    font_px: f32,
1327    wrap_w: f32,
1328    x_px: f32,
1329    y_px: f32,
1330) -> usize {
1331    if let Some(vt) = &state.visual_transformation {
1332        let annotated = repose_core::AnnotatedString::new(state.text.clone(), vec![]);
1333        let tfmd = vt.filter(&annotated);
1334        let display_idx = repose_ui::textfield::index_for_xy_bytes(tfmd.text.as_str(), font_px, wrap_w, x_px, y_px);
1335        tfmd.offset_mapping.transformed_to_original(display_idx)
1336    } else {
1337        repose_ui::textfield::index_for_xy_bytes(&state.text, font_px, wrap_w, x_px, y_px)
1338    }
1339}
1340
1341/// Dispatch scroll to scroll consumers. Returns (consumed, optional capture id).
1342fn dispatch_scroll(
1343    frame: &Frame,
1344    pos: Vec2,
1345    mut delta: Vec2,
1346    scroll_capture: Option<u64>,
1347) -> (bool, Option<u64>) {
1348    let mut new_capture = scroll_capture;
1349
1350    if let Some(cid) = scroll_capture {
1351        if let Some(cb) = frame
1352            .hit_regions
1353            .iter()
1354            .find(|h| h.id == cid)
1355            .and_then(|h| h.on_scroll.as_ref())
1356        {
1357            let before = delta;
1358            let leftover = cb(before);
1359            let consumed_x = (before.x - leftover.x).abs() > 0.001;
1360            let consumed_y = (before.y - leftover.y).abs() > 0.001;
1361            if consumed_x || consumed_y {
1362                delta = leftover;
1363                if delta.x.abs() <= 0.001 && delta.y.abs() <= 0.001 {
1364                    return (true, Some(cid));
1365                }
1366            } else {
1367                new_capture = None;
1368            }
1369        }
1370    }
1371
1372    let mut any_consumed = false;
1373    for hit in frame
1374        .hit_regions
1375        .iter()
1376        .rev()
1377        .filter(|h| h.rect.contains(pos))
1378    {
1379        if let Some(cb) = &hit.on_scroll {
1380            let before = delta;
1381            let leftover = cb(before);
1382            let consumed_x = (before.x - leftover.x).abs() > 0.001;
1383            let consumed_y = (before.y - leftover.y).abs() > 0.001;
1384            if consumed_x || consumed_y {
1385                any_consumed = true;
1386                if new_capture.is_none() {
1387                    new_capture = Some(hit.id);
1388                }
1389            }
1390            delta = leftover;
1391            if delta.x.abs() <= 0.001 && delta.y.abs() <= 0.001 {
1392                break;
1393            }
1394        }
1395    }
1396    (any_consumed, new_capture)
1397}