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