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