Skip to main content

cranpose_app_shell/
shell_input.rs

1use super::*;
2
3impl<R> AppShell<R>
4where
5    R: Renderer,
6    R::Error: Debug,
7{
8    fn resolve_gesture_targets(
9        &self,
10        pointer: PointerId,
11    ) -> Vec<<<R as Renderer>::Scene as RenderScene>::HitTarget> {
12        self.resolve_hit_path(pointer)
13    }
14
15    /// Resolves cached NodeIds to fresh HitTargets from the current scene.
16    ///
17    /// This is the key to avoiding stale geometry during scroll/layout changes:
18    /// - We cache NodeIds on PointerDown (stable identity)
19    /// - On Move/Up/Cancel, we call find_target() to get fresh geometry
20    /// - Handler closures are preserved (same Rc), so gesture state survives
21    fn resolve_hit_path(
22        &self,
23        pointer: PointerId,
24    ) -> Vec<<<R as Renderer>::Scene as RenderScene>::HitTarget> {
25        let Some(node_ids) = self.hit_path_tracker.dispatch_order(pointer) else {
26            return Vec::new();
27        };
28
29        let scene = self.renderer.scene();
30        let targets: Vec<_> = node_ids
31            .iter()
32            .filter_map(|&id| scene.find_target(id))
33            .collect();
34        log::trace!(
35            target: "cranpose::input",
36            "resolve_hit_path pointer={pointer:?} cached={node_ids:?} resolved_count={}",
37            targets.len()
38        );
39        targets
40    }
41
42    fn dispatch_targets<I>(&mut self, targets: I, event: PointerEvent, stop_on_consume: bool)
43    where
44        I: IntoIterator<Item = <<R as Renderer>::Scene as RenderScene>::HitTarget>,
45    {
46        let mut applier = self.composition.applier_mut();
47        for target in targets {
48            let node_id = target.node_id();
49            target.dispatch_with_applier(&mut applier, event.clone());
50            log::trace!(
51                target: "cranpose::input",
52                "dispatch {:?} node={} consumed={} stop_on_consume={}",
53                event.kind,
54                node_id,
55                event.is_consumed(),
56                stop_on_consume,
57            );
58            if stop_on_consume && event.is_consumed() {
59                break;
60            }
61        }
62    }
63
64    pub fn set_cursor(&mut self, x: f32, y: f32) -> bool {
65        self.set_cursor_at_time(x, y, None)
66    }
67
68    /// Like [`set_cursor`](Self::set_cursor), but carries the platform input
69    /// timestamp (milliseconds, platform-specific time base) of the sample.
70    ///
71    /// Platforms that deliver input batched/frame-aligned (Android) must use
72    /// this so gesture velocity is computed from real event times instead of
73    /// delivery times.
74    pub fn set_cursor_at_time(&mut self, x: f32, y: f32, time_ms: Option<i64>) -> bool {
75        let _event_handler = enter_event_handler_scope();
76        let app_context = Rc::clone(&self.app_context);
77        let result = app_context.enter(|| {
78            run_in_mutable_snapshot(|| self.set_cursor_inner(x, y, time_ms)).unwrap_or(false)
79        });
80        if result {
81            self.mark_dirty();
82        }
83        log::trace!(
84            target: "cranpose::input",
85            "set_cursor ({x:.2},{y:.2}) time_ms={time_ms:?} -> {result}"
86        );
87        result
88    }
89
90    fn set_cursor_inner(&mut self, x: f32, y: f32, time_ms: Option<i64>) -> bool {
91        self.cursor = (x, y);
92
93        // During a gesture (button pressed), ONLY dispatch to the tracked hit path.
94        // Never fall back to hover hit-testing while buttons are down.
95        // This maintains the invariant: the path that receives Down must receive Move and Up/Cancel.
96        if self.buttons_pressed != PointerButtons::NONE {
97            if self.hit_path_tracker.has_path(PointerId::PRIMARY) {
98                let targets = self.resolve_gesture_targets(PointerId::PRIMARY);
99                if !targets.is_empty() {
100                    let event =
101                        PointerEvent::new(PointerEventKind::Move, Point { x, y }, Point { x, y })
102                            .with_buttons(self.buttons_pressed)
103                            .with_time_ms(time_ms);
104                    self.dispatch_targets(targets, event, false);
105                    return true;
106                }
107
108                return false;
109            }
110
111            // Button is down but we have no recorded path inside this app
112            // (e.g. drag started outside). Do not dispatch anything.
113            return false;
114        }
115
116        // No gesture in progress: regular hover move using hit-test.
117        // Diff against previous hover set to synthesize Enter/Exit events.
118        let hits = self.renderer.scene().hit_test(x, y);
119        let new_ids: Vec<NodeId> = hits.iter().map(|h| h.node_id()).collect();
120
121        // Dispatch Exit to nodes that are no longer hovered
122        let pos = Point { x, y };
123        let previously_hovered = self.hovered_nodes.clone();
124        for old_id in previously_hovered {
125            if !new_ids.contains(&old_id) {
126                if let Some(target) = self.renderer.scene().find_target(old_id) {
127                    let exit_event = PointerEvent::new(PointerEventKind::Exit, pos, pos)
128                        .with_buttons(self.buttons_pressed);
129                    self.dispatch_targets(std::iter::once(target), exit_event, false);
130                }
131            }
132        }
133
134        // Dispatch Enter to newly hovered nodes
135        for hit in &hits {
136            if !self.hovered_nodes.contains(&hit.node_id()) {
137                let enter_event = PointerEvent::new(PointerEventKind::Enter, pos, pos)
138                    .with_buttons(self.buttons_pressed);
139                self.dispatch_targets(std::iter::once(hit.clone()), enter_event, false);
140            }
141        }
142
143        self.hovered_nodes = new_ids;
144
145        if !hits.is_empty() {
146            let event = PointerEvent::new(PointerEventKind::Move, pos, pos)
147                .with_buttons(self.buttons_pressed)
148                .with_time_ms(time_ms);
149            self.dispatch_targets(hits, event, true);
150            true
151        } else {
152            false
153        }
154    }
155
156    pub fn pointer_pressed(&mut self) -> bool {
157        self.pointer_pressed_at_time(None)
158    }
159
160    /// Like [`pointer_pressed`](Self::pointer_pressed), but carries the
161    /// platform input timestamp (milliseconds) of the press sample.
162    pub fn pointer_pressed_at_time(&mut self, time_ms: Option<i64>) -> bool {
163        let _event_handler = enter_event_handler_scope();
164        let app_context = Rc::clone(&self.app_context);
165        let result = app_context.enter(|| {
166            run_in_mutable_snapshot(|| self.pointer_pressed_inner(time_ms)).unwrap_or(false)
167        });
168        if result {
169            self.mark_dirty();
170        }
171        log::trace!(target: "cranpose::input", "pointer_pressed time_ms={time_ms:?} -> {result}");
172        result
173    }
174
175    fn pointer_pressed_inner(&mut self, time_ms: Option<i64>) -> bool {
176        // Track button state
177        self.buttons_pressed.insert(PointerButton::Primary);
178
179        // Hit-test against the current (last rendered) scene.
180        // Even if the app is dirty, this scene is what the user actually saw and clicked.
181        // Frame N is rendered → user sees frame N and taps → we hit-test frame N's geometry.
182        // The pointer event may mark dirty → next frame runs update() → renders N+1.
183
184        // Perform hit test and cache the NodeIds (not geometry!)
185        // The key insight from Jetpack Compose: cache identity, resolve fresh geometry per dispatch
186        let hits = self.renderer.scene().hit_test(self.cursor.0, self.cursor.1);
187        if hits.is_empty() {
188            self.hit_path_tracker.remove_path(PointerId::PRIMARY);
189            false
190        } else {
191            let event = PointerEvent::new(
192                PointerEventKind::Down,
193                Point {
194                    x: self.cursor.0,
195                    y: self.cursor.1,
196                },
197                Point {
198                    x: self.cursor.0,
199                    y: self.cursor.1,
200                },
201            )
202            .with_buttons(self.buttons_pressed)
203            .with_time_ms(time_ms);
204
205            let mut delivered_capture_paths = Vec::new();
206            let mut applier = self.composition.applier_mut();
207            for hit in hits {
208                let node_id = hit.node_id();
209                delivered_capture_paths.push(hit.capture_path());
210                hit.dispatch_with_applier(&mut applier, event.clone());
211                log::trace!(
212                    target: "cranpose::input",
213                    "dispatch {:?} node={} consumed={} stop_on_consume=true",
214                    event.kind,
215                    node_id,
216                    event.is_consumed(),
217                );
218                if event.is_consumed() {
219                    break;
220                }
221            }
222
223            self.hit_path_tracker
224                .add_hit_path(PointerId::PRIMARY, delivered_capture_paths);
225            log::trace!(
226                target: "cranpose::input",
227                "pointer_pressed_inner cached_hit_path={:?}",
228                self.hit_path_tracker.get_path(PointerId::PRIMARY),
229            );
230
231            true
232        }
233    }
234
235    pub fn pointer_released(&mut self) -> bool {
236        self.pointer_released_at_time(None)
237    }
238
239    /// Releases the pointer at the position carried by the platform's release
240    /// sample (Android `ACTION_UP`, web `pointerup`/`touchend`).
241    ///
242    /// The cursor is moved to `(x, y)` WITHOUT dispatching a Move event, then
243    /// the Up event is dispatched at that position. Platforms whose release
244    /// events carry their own coordinates must use this instead of
245    /// `set_cursor* + pointer_released*`: lift-off samples routinely roll back
246    /// a few dp against the travel direction as the finger peels off, and
247    /// feeding that jitter into gesture velocity trackers as a final Move
248    /// sample can flip the sign of the computed fling velocity (flings that
249    /// suddenly go the opposite way). Jetpack Compose likewise never feeds the
250    /// up sample into velocity tracking.
251    pub fn pointer_released_at_position(&mut self, x: f32, y: f32) -> bool {
252        self.pointer_released_at_position_time(x, y, None)
253    }
254
255    /// Like [`pointer_released_at_position`](Self::pointer_released_at_position),
256    /// but carries the platform input timestamp (milliseconds) of the release
257    /// sample.
258    pub fn pointer_released_at_position_time(
259        &mut self,
260        x: f32,
261        y: f32,
262        time_ms: Option<i64>,
263    ) -> bool {
264        let _event_handler = enter_event_handler_scope();
265        let app_context = Rc::clone(&self.app_context);
266        let result = app_context.enter(|| {
267            run_in_mutable_snapshot(|| {
268                self.cursor = (x, y);
269                self.pointer_released_inner(time_ms)
270            })
271            .unwrap_or(false)
272        });
273        if result {
274            self.mark_dirty();
275        }
276        log::trace!(
277            target: "cranpose::input",
278            "pointer_released_at_position ({x:.2},{y:.2}) time_ms={time_ms:?} -> {result}"
279        );
280        result
281    }
282
283    /// Like [`pointer_released`](Self::pointer_released), but carries the
284    /// platform input timestamp (milliseconds) of the release sample.
285    pub fn pointer_released_at_time(&mut self, time_ms: Option<i64>) -> bool {
286        let _event_handler = enter_event_handler_scope();
287        let app_context = Rc::clone(&self.app_context);
288        let result = app_context.enter(|| {
289            run_in_mutable_snapshot(|| self.pointer_released_inner(time_ms)).unwrap_or(false)
290        });
291        if result {
292            self.mark_dirty();
293        }
294        log::trace!(target: "cranpose::input", "pointer_released time_ms={time_ms:?} -> {result}");
295        result
296    }
297
298    fn pointer_released_inner(&mut self, time_ms: Option<i64>) -> bool {
299        // UP events report buttons as "currently pressed" (after release),
300        // matching typical platform semantics where primary is already gone.
301        self.buttons_pressed.remove(PointerButton::Primary);
302        let corrected_buttons = self.buttons_pressed;
303        let targets = self.resolve_gesture_targets(PointerId::PRIMARY);
304
305        // Always remove the path, even if targets is empty (node may have been removed)
306        self.hit_path_tracker.remove_path(PointerId::PRIMARY);
307
308        if !targets.is_empty() {
309            let event = PointerEvent::new(
310                PointerEventKind::Up,
311                Point {
312                    x: self.cursor.0,
313                    y: self.cursor.1,
314                },
315                Point {
316                    x: self.cursor.0,
317                    y: self.cursor.1,
318                },
319            )
320            .with_buttons(corrected_buttons)
321            .with_time_ms(time_ms);
322
323            self.dispatch_targets(targets, event, false);
324            true
325        } else {
326            false
327        }
328    }
329
330    /// Dispatches an event for a secondary pointer (`pointer_id != 0`).
331    ///
332    /// Multi-touch gestures act on the element the first finger grabbed, so
333    /// secondary pointers are routed to the hit path captured by the primary
334    /// pointer's Down. They carry no hover/click semantics and are ignored
335    /// when no primary gesture is in progress.
336    ///
337    /// Returns `true` when the event was dispatched to at least one target.
338    pub fn secondary_pointer_pressed(
339        &mut self,
340        pointer_id: u64,
341        x: f32,
342        y: f32,
343        time_ms: Option<i64>,
344    ) -> bool {
345        self.dispatch_secondary_pointer(PointerEventKind::Down, pointer_id, x, y, time_ms)
346    }
347
348    /// Move counterpart of [`secondary_pointer_pressed`](Self::secondary_pointer_pressed).
349    pub fn secondary_pointer_moved(
350        &mut self,
351        pointer_id: u64,
352        x: f32,
353        y: f32,
354        time_ms: Option<i64>,
355    ) -> bool {
356        self.dispatch_secondary_pointer(PointerEventKind::Move, pointer_id, x, y, time_ms)
357    }
358
359    /// Release counterpart of [`secondary_pointer_pressed`](Self::secondary_pointer_pressed).
360    pub fn secondary_pointer_released(
361        &mut self,
362        pointer_id: u64,
363        x: f32,
364        y: f32,
365        time_ms: Option<i64>,
366    ) -> bool {
367        self.dispatch_secondary_pointer(PointerEventKind::Up, pointer_id, x, y, time_ms)
368    }
369
370    fn dispatch_secondary_pointer(
371        &mut self,
372        kind: PointerEventKind,
373        pointer_id: u64,
374        x: f32,
375        y: f32,
376        time_ms: Option<i64>,
377    ) -> bool {
378        if pointer_id == 0 {
379            log::warn!(
380                target: "cranpose::input",
381                "secondary pointer dispatch called with the primary pointer id"
382            );
383            return false;
384        }
385
386        let _event_handler = enter_event_handler_scope();
387        let app_context = Rc::clone(&self.app_context);
388        let result = app_context.enter(|| {
389            run_in_mutable_snapshot(|| {
390                if !self.hit_path_tracker.has_path(PointerId::PRIMARY) {
391                    return false;
392                }
393                let targets = self.resolve_gesture_targets(PointerId::PRIMARY);
394                if targets.is_empty() {
395                    return false;
396                }
397                let pos = Point { x, y };
398                let event = PointerEvent::new(kind, pos, pos)
399                    .with_buttons(self.buttons_pressed)
400                    .with_time_ms(time_ms)
401                    .with_id(pointer_id);
402                self.dispatch_targets(targets, event, false);
403                true
404            })
405            .unwrap_or(false)
406        });
407        if result {
408            self.mark_dirty();
409        }
410        log::trace!(
411            target: "cranpose::input",
412            "secondary_pointer {kind:?} id={pointer_id} ({x:.2},{y:.2}) time_ms={time_ms:?} -> {result}"
413        );
414        result
415    }
416
417    /// Dispatches a discrete zoom step (desktop ctrl+wheel, browser pinch)
418    /// to the pointer handlers under the cursor.
419    ///
420    /// `zoom_factor` is multiplicative: `> 1.0` zooms in, `< 1.0` zooms out.
421    /// Returns `true` if a handler consumed the event.
422    pub fn pointer_zoomed(&mut self, zoom_factor: f32) -> bool {
423        let _event_handler = enter_event_handler_scope();
424        let app_context = Rc::clone(&self.app_context);
425        let result = app_context.enter(|| {
426            run_in_mutable_snapshot(|| self.pointer_zoomed_inner(zoom_factor)).unwrap_or(false)
427        });
428        if result {
429            self.mark_dirty();
430        }
431        log::trace!(
432            target: "cranpose::input",
433            "pointer_zoomed factor={zoom_factor:.4} -> {result}"
434        );
435        result
436    }
437
438    fn pointer_zoomed_inner(&mut self, zoom_factor: f32) -> bool {
439        if !zoom_factor.is_finite() || zoom_factor <= 0.0 || zoom_factor == 1.0 {
440            return false;
441        }
442
443        let hits = self.renderer.scene().hit_test(self.cursor.0, self.cursor.1);
444        if hits.is_empty() {
445            return false;
446        }
447
448        let pos = Point {
449            x: self.cursor.0,
450            y: self.cursor.1,
451        };
452        let event = PointerEvent::new(PointerEventKind::Zoom, pos, pos)
453            .with_buttons(self.buttons_pressed)
454            .with_zoom_delta(zoom_factor);
455
456        let capture_paths = hits
457            .iter()
458            .map(|hit| hit.capture_path())
459            .collect::<Vec<_>>();
460        let targets = crate::hit_path_tracker::dispatch_order_for_paths(&capture_paths)
461            .into_iter()
462            .filter_map(|node_id| self.renderer.scene().find_target(node_id))
463            .collect::<Vec<_>>();
464
465        self.dispatch_targets(targets, event.clone(), true);
466
467        event.is_consumed()
468    }
469
470    /// Dispatches a mouse wheel / trackpad scroll event to hovered pointer handlers.
471    ///
472    /// Returns `true` if a handler consumed the event.
473    pub fn pointer_scrolled(&mut self, delta_x: f32, delta_y: f32) -> bool {
474        let _event_handler = enter_event_handler_scope();
475        let app_context = Rc::clone(&self.app_context);
476        let result = app_context.enter(|| {
477            run_in_mutable_snapshot(|| self.pointer_scrolled_inner(delta_x, delta_y))
478                .unwrap_or(false)
479        });
480        if result {
481            self.mark_dirty();
482        }
483        log::trace!(
484            target: "cranpose::input",
485            "pointer_scrolled ({delta_x:.2},{delta_y:.2}) -> {result}"
486        );
487        result
488    }
489
490    fn pointer_scrolled_inner(&mut self, delta_x: f32, delta_y: f32) -> bool {
491        if delta_x.abs() <= f32::EPSILON && delta_y.abs() <= f32::EPSILON {
492            return false;
493        }
494
495        let hits = self.renderer.scene().hit_test(self.cursor.0, self.cursor.1);
496        if hits.is_empty() {
497            return false;
498        }
499
500        let event = PointerEvent::new(
501            PointerEventKind::Scroll,
502            Point {
503                x: self.cursor.0,
504                y: self.cursor.1,
505            },
506            Point {
507                x: self.cursor.0,
508                y: self.cursor.1,
509            },
510        )
511        .with_buttons(self.buttons_pressed)
512        .with_scroll_delta(Point {
513            x: delta_x,
514            y: delta_y,
515        });
516
517        let capture_paths = hits
518            .iter()
519            .map(|hit| hit.capture_path())
520            .collect::<Vec<_>>();
521        let targets = crate::hit_path_tracker::dispatch_order_for_paths(&capture_paths)
522            .into_iter()
523            .filter_map(|node_id| self.renderer.scene().find_target(node_id))
524            .collect::<Vec<_>>();
525
526        self.dispatch_targets(targets, event.clone(), true);
527
528        event.is_consumed()
529    }
530
531    /// Cancels any active gesture, dispatching Cancel events to cached targets.
532    /// Call this when:
533    /// - Window loses focus
534    /// - Mouse leaves window while button pressed
535    /// - Any other gesture abort scenario
536    pub fn cancel_gesture(&mut self) {
537        let _event_handler = enter_event_handler_scope();
538        let app_context = Rc::clone(&self.app_context);
539        let _ = app_context.enter(|| {
540            run_in_mutable_snapshot(|| {
541                self.cancel_gesture_inner();
542            })
543        });
544    }
545
546    fn cancel_gesture_inner(&mut self) {
547        let targets = self.resolve_gesture_targets(PointerId::PRIMARY);
548
549        // Clear tracker and button state
550        self.hit_path_tracker.clear();
551        self.buttons_pressed = PointerButtons::NONE;
552
553        if !targets.is_empty() {
554            let event = PointerEvent::new(
555                PointerEventKind::Cancel,
556                Point {
557                    x: self.cursor.0,
558                    y: self.cursor.1,
559                },
560                Point {
561                    x: self.cursor.0,
562                    y: self.cursor.1,
563                },
564            );
565
566            self.dispatch_targets(targets, event, false);
567        }
568
569        // Dispatch Exit to all previously hovered nodes
570        let pos = Point {
571            x: self.cursor.0,
572            y: self.cursor.1,
573        };
574        let hovered_nodes = self.hovered_nodes.clone();
575        for node_id in hovered_nodes {
576            if let Some(target) = self.renderer.scene().find_target(node_id) {
577                let exit_event = PointerEvent::new(PointerEventKind::Exit, pos, pos);
578                self.dispatch_targets(std::iter::once(target), exit_event, false);
579            }
580        }
581        self.hovered_nodes.clear();
582    }
583
584    /// Installs the platform soft-keyboard handler for this shell's app context.
585    ///
586    /// The handler is invoked when a text field gains focus (`show_keyboard`)
587    /// or when text-field focus is cleared or goes stale (`hide_keyboard`).
588    /// Platform runtimes with an on-screen keyboard (Android, iOS) call this
589    /// once after creating the shell.
590    pub fn set_platform_text_input(
591        &mut self,
592        handler: Rc<dyn cranpose_ui::PlatformTextInputHandler>,
593    ) {
594        let app_context = Rc::clone(&self.app_context);
595        app_context
596            .enter(|| cranpose_ui::text_input_session::set_platform_text_input_handler(handler));
597    }
598
599    /// Removes the platform soft-keyboard handler, if one is installed.
600    pub fn clear_platform_text_input(&mut self) {
601        let app_context = Rc::clone(&self.app_context);
602        app_context.enter(cranpose_ui::text_input_session::clear_platform_text_input_handler);
603    }
604
605    /// Routes a keyboard event to the focused text field, if any.
606    ///
607    /// Returns `true` if the event was consumed by a text field.
608    ///
609    /// On desktop, Ctrl+C/X/V are handled here when native clipboard support is enabled.
610    /// On web, these keys are NOT handled here - they bubble to browser for native copy/paste events.
611    pub fn on_key_event(&mut self, event: &KeyEvent) -> bool {
612        let _event_handler = enter_event_handler_scope();
613        let app_context = Rc::clone(&self.app_context);
614        app_context.enter(|| self.on_key_event_inner(event))
615    }
616
617    /// Internal keyboard event handler wrapped by on_key_event.
618    fn on_key_event_inner(&mut self, event: &KeyEvent) -> bool {
619        use KeyEventType::KeyDown;
620
621        // Only process KeyDown events for clipboard shortcuts
622        if event.event_type == KeyDown && event.modifiers.command_or_ctrl() {
623            // Use persistent self.clipboard to keep content alive on Linux X11.
624            #[cfg(all(
625                feature = "clipboard-native",
626                not(target_arch = "wasm32"),
627                not(target_os = "android"),
628                not(target_os = "ios")
629            ))]
630            {
631                match event.key_code {
632                    // Ctrl+C - Copy
633                    KeyCode::C => {
634                        // Get text first, then access clipboard to avoid borrow conflict
635                        let text = self.on_copy_inner();
636                        if let (Some(text), Some(clipboard)) = (text, self.clipboard.as_mut()) {
637                            let _ = clipboard.set_text(&text);
638                            return true;
639                        }
640                    }
641                    // Ctrl+X - Cut
642                    KeyCode::X => {
643                        // Get text first (this also deletes it), then access clipboard
644                        let text = self.on_cut_inner();
645                        if let (Some(text), Some(clipboard)) = (text, self.clipboard.as_mut()) {
646                            let _ = clipboard.set_text(&text);
647                            self.mark_dirty();
648                            self.request_layout_pass();
649                            return true;
650                        }
651                    }
652                    // Ctrl+V - Paste
653                    KeyCode::V => {
654                        // Get text from clipboard first, then paste
655                        let text = self.clipboard.as_mut().and_then(|cb| cb.get_text().ok());
656                        if let Some(text) = text {
657                            if self.on_paste_inner(&text) {
658                                return true;
659                            }
660                        }
661                    }
662                    _ => {}
663                }
664            }
665        }
666
667        // Pure O(1) dispatch - no tree walking needed
668        if !cranpose_ui::text_field_focus::has_focused_field() {
669            return false;
670        }
671
672        // Wrap key event handling in a mutable snapshot so changes are atomically applied.
673        // This ensures keyboard input modifications are visible to subsequent snapshot contexts
674        // (like button click handlers that run in their own mutable snapshots).
675        let handled = run_in_mutable_snapshot(|| {
676            // O(1) dispatch via stored handler - handles ALL text input key events
677            // No fallback needed since handler now handles arrows, Home/End, word nav
678            cranpose_ui::text_field_focus::dispatch_key_event(event)
679        })
680        .unwrap_or(false);
681
682        if handled {
683            // Mark both dirty (for redraw) and request a layout pass to rebuild semantics.
684            self.mark_dirty();
685            self.request_layout_pass();
686        }
687
688        handled
689    }
690
691    /// Handles paste event from platform clipboard.
692    /// Returns `true` if the paste was consumed by a focused text field.
693    /// O(1) operation using stored handler.
694    pub fn on_paste(&mut self, text: &str) -> bool {
695        let _event_handler = enter_event_handler_scope();
696        let app_context = Rc::clone(&self.app_context);
697        app_context.enter(|| self.on_paste_inner(text))
698    }
699
700    fn on_paste_inner(&mut self, text: &str) -> bool {
701        // Wrap paste in a mutable snapshot so changes are atomically applied.
702        // This ensures paste modifications are visible to subsequent snapshot contexts
703        // (like button click handlers that run in their own mutable snapshots).
704        let handled =
705            run_in_mutable_snapshot(|| cranpose_ui::text_field_focus::dispatch_paste(text))
706                .unwrap_or(false);
707
708        if handled {
709            self.mark_dirty();
710            self.request_layout_pass();
711        }
712
713        handled
714    }
715
716    /// Handles copy request from platform.
717    /// Returns the selected text from focused text field, or None.
718    /// O(1) operation using stored handler.
719    pub fn on_copy(&mut self) -> Option<String> {
720        let app_context = Rc::clone(&self.app_context);
721        app_context.enter(|| self.on_copy_inner())
722    }
723
724    fn on_copy_inner(&mut self) -> Option<String> {
725        // Use O(1) dispatch instead of tree scan
726        cranpose_ui::text_field_focus::dispatch_copy()
727    }
728
729    /// Handles cut request from platform.
730    /// Returns the cut text from focused text field, or None.
731    /// O(1) operation using stored handler.
732    pub fn on_cut(&mut self) -> Option<String> {
733        let _event_handler = enter_event_handler_scope();
734        let app_context = Rc::clone(&self.app_context);
735        app_context.enter(|| self.on_cut_inner())
736    }
737
738    fn on_cut_inner(&mut self) -> Option<String> {
739        let text =
740            run_in_mutable_snapshot(cranpose_ui::text_field_focus::dispatch_cut).unwrap_or(None);
741
742        if text.is_some() {
743            self.mark_dirty();
744            self.request_layout_pass();
745        }
746
747        text
748    }
749
750    /// Sets the Linux primary selection (for middle-click paste).
751    /// This is called when text is selected in a text field.
752    /// On non-Linux platforms, this is a no-op.
753    #[cfg(all(
754        feature = "clipboard-native",
755        target_os = "linux",
756        not(target_arch = "wasm32")
757    ))]
758    pub fn set_primary_selection(&mut self, text: &str) {
759        use arboard::{LinuxClipboardKind, SetExtLinux};
760        if let Some(ref mut clipboard) = self.clipboard {
761            let result = clipboard
762                .set()
763                .clipboard(LinuxClipboardKind::Primary)
764                .text(text.to_string());
765            if let Err(e) = result {
766                // Primary selection may not be available on all systems
767                log::debug!("Primary selection set failed: {:?}", e);
768            }
769        }
770    }
771
772    #[cfg(not(all(
773        feature = "clipboard-native",
774        target_os = "linux",
775        not(target_arch = "wasm32")
776    )))]
777    pub fn set_primary_selection(&mut self, _text: &str) {}
778
779    /// Gets text from the Linux primary selection (for middle-click paste).
780    /// On non-Linux platforms, returns None.
781    #[cfg(all(
782        feature = "clipboard-native",
783        target_os = "linux",
784        not(target_arch = "wasm32")
785    ))]
786    pub fn get_primary_selection(&mut self) -> Option<String> {
787        use arboard::{GetExtLinux, LinuxClipboardKind};
788        if let Some(ref mut clipboard) = self.clipboard {
789            clipboard
790                .get()
791                .clipboard(LinuxClipboardKind::Primary)
792                .text()
793                .ok()
794        } else {
795            None
796        }
797    }
798
799    #[cfg(not(all(
800        feature = "clipboard-native",
801        target_os = "linux",
802        not(target_arch = "wasm32")
803    )))]
804    pub fn get_primary_selection(&mut self) -> Option<String> {
805        None
806    }
807
808    /// Syncs the current text field selection to PRIMARY (Linux X11).
809    /// Call this when selection changes in a text field.
810    pub fn sync_selection_to_primary(&mut self) {
811        #[cfg(all(target_os = "linux", not(target_arch = "wasm32")))]
812        {
813            if let Some(text) = self.on_copy() {
814                self.set_primary_selection(&text);
815            }
816        }
817    }
818
819    /// Handles IME preedit (composition) events.
820    /// Called when the input method is composing text (e.g., typing CJK characters).
821    ///
822    /// - `text`: The current preedit text (empty to clear composition state)
823    /// - `cursor`: Optional cursor position within the preedit text (start, end)
824    ///
825    /// Returns `true` if a text field consumed the event.
826    pub fn on_ime_preedit(&mut self, text: &str, cursor: Option<(usize, usize)>) -> bool {
827        let _event_handler = enter_event_handler_scope();
828        let app_context = Rc::clone(&self.app_context);
829        app_context.enter(|| self.on_ime_preedit_inner(text, cursor))
830    }
831
832    fn on_ime_preedit_inner(&mut self, text: &str, cursor: Option<(usize, usize)>) -> bool {
833        // Wrap in mutable snapshot for atomic changes
834        let handled = run_in_mutable_snapshot(|| {
835            cranpose_ui::text_field_focus::dispatch_ime_preedit(text, cursor)
836        })
837        .unwrap_or(false);
838
839        if handled {
840            self.mark_dirty();
841            // IME composition changes the visible text, needs layout update
842            self.request_layout_pass();
843        }
844
845        handled
846    }
847
848    /// Handles IME delete-surrounding events.
849    /// Returns `true` if a text field consumed the event.
850    pub fn on_ime_delete_surrounding(&mut self, before_bytes: usize, after_bytes: usize) -> bool {
851        let _event_handler = enter_event_handler_scope();
852        let app_context = Rc::clone(&self.app_context);
853        app_context.enter(|| self.on_ime_delete_surrounding_inner(before_bytes, after_bytes))
854    }
855
856    fn on_ime_delete_surrounding_inner(&mut self, before_bytes: usize, after_bytes: usize) -> bool {
857        let handled = run_in_mutable_snapshot(|| {
858            cranpose_ui::text_field_focus::dispatch_delete_surrounding(before_bytes, after_bytes)
859        })
860        .unwrap_or(false);
861
862        if handled {
863            self.mark_dirty();
864            self.request_layout_pass();
865        }
866
867        handled
868    }
869}