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 so consumers can preserve device-specific gesture
68    /// details without changing shared pointer UI.
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    /// Notifies the framework that the host app was paused/backgrounded.
631    ///
632    /// Withdraws any outstanding soft-keyboard request (and hides the keyboard)
633    /// so the "keyboard shown" state does not survive across the pause and get
634    /// restored on resume with no focused field. Platform runtimes call this
635    /// from their pause lifecycle event.
636    pub fn notify_app_paused(&mut self) {
637        let app_context = Rc::clone(&self.app_context);
638        app_context.enter(cranpose_ui::text_input_session::notify_app_paused);
639    }
640
641    /// Notifies the framework that the host app resumed/foregrounded.
642    ///
643    /// Never auto-shows the soft keyboard, even for a still-focused field: a
644    /// warm resume keeps the caret but must not resurrect the keyboard (the user
645    /// taps the field to bring it back). Always returns `false` so the platform
646    /// runtime force-hides the OS-restored keyboard. Platform runtimes call this
647    /// from their resume lifecycle event.
648    pub fn notify_app_resumed(&mut self) -> bool {
649        let app_context = Rc::clone(&self.app_context);
650        app_context.enter(cranpose_ui::text_input_session::notify_app_resumed)
651    }
652
653    /// Routes a keyboard event to the focused text field, if any.
654    ///
655    /// Returns `true` if the event was consumed by a text field.
656    ///
657    /// On desktop, Ctrl+C/X/V are handled here when native clipboard support is enabled.
658    /// On web, these keys are NOT handled here - they bubble to browser for native copy/paste events.
659    pub fn on_key_event(&mut self, event: &KeyEvent) -> bool {
660        let _event_handler = enter_event_handler_scope();
661        let app_context = Rc::clone(&self.app_context);
662        app_context.enter(|| self.on_key_event_inner(event))
663    }
664
665    /// Internal keyboard event handler wrapped by on_key_event.
666    fn on_key_event_inner(&mut self, event: &KeyEvent) -> bool {
667        use KeyEventType::KeyDown;
668
669        // Only process KeyDown events for clipboard shortcuts
670        if event.event_type == KeyDown && event.modifiers.command_or_ctrl() {
671            // Use persistent self.clipboard to keep content alive on Linux X11.
672            #[cfg(all(
673                feature = "clipboard-native",
674                not(target_arch = "wasm32"),
675                not(target_os = "android"),
676                not(target_os = "ios")
677            ))]
678            {
679                match event.key_code {
680                    // Ctrl+C - Copy
681                    KeyCode::C => {
682                        // Get text first, then access clipboard to avoid borrow conflict
683                        let text = self.on_copy_inner();
684                        if let (Some(text), Some(clipboard)) = (text, self.clipboard.as_mut()) {
685                            let _ = clipboard.set_text(&text);
686                            return true;
687                        }
688                    }
689                    // Ctrl+X - Cut
690                    KeyCode::X => {
691                        // Get text first (this also deletes it), then access clipboard
692                        let text = self.on_cut_inner();
693                        if let (Some(text), Some(clipboard)) = (text, self.clipboard.as_mut()) {
694                            let _ = clipboard.set_text(&text);
695                            self.mark_dirty();
696                            self.request_layout_pass();
697                            return true;
698                        }
699                    }
700                    // Ctrl+V - Paste
701                    KeyCode::V => {
702                        // Get text from clipboard first, then paste
703                        let text = self.clipboard.as_mut().and_then(|cb| cb.get_text().ok());
704                        if let Some(text) = text {
705                            if self.on_paste_inner(&text) {
706                                return true;
707                            }
708                        }
709                    }
710                    _ => {}
711                }
712            }
713        }
714
715        // Pure O(1) dispatch - no tree walking needed
716        if !cranpose_ui::text_field_focus::has_focused_field() {
717            return false;
718        }
719
720        // Wrap key event handling in a mutable snapshot so changes are atomically applied.
721        // This ensures keyboard input modifications are visible to subsequent snapshot contexts
722        // (like button click handlers that run in their own mutable snapshots).
723        let handled = run_in_mutable_snapshot(|| {
724            // O(1) dispatch via stored handler - handles ALL text input key events
725            // No fallback needed since handler now handles arrows, Home/End, word nav
726            cranpose_ui::text_field_focus::dispatch_key_event(event)
727        })
728        .unwrap_or(false);
729
730        if handled {
731            // Mark both dirty (for redraw) and request a layout pass to rebuild semantics.
732            self.mark_dirty();
733            self.request_layout_pass();
734        }
735
736        handled
737    }
738
739    /// Handles paste event from platform clipboard.
740    /// Returns `true` if the paste was consumed by a focused text field.
741    /// O(1) operation using stored handler.
742    pub fn on_paste(&mut self, text: &str) -> bool {
743        let _event_handler = enter_event_handler_scope();
744        let app_context = Rc::clone(&self.app_context);
745        app_context.enter(|| self.on_paste_inner(text))
746    }
747
748    fn on_paste_inner(&mut self, text: &str) -> bool {
749        // Wrap paste in a mutable snapshot so changes are atomically applied.
750        // This ensures paste modifications are visible to subsequent snapshot contexts
751        // (like button click handlers that run in their own mutable snapshots).
752        let handled =
753            run_in_mutable_snapshot(|| cranpose_ui::text_field_focus::dispatch_paste(text))
754                .unwrap_or(false);
755
756        if handled {
757            self.mark_dirty();
758            self.request_layout_pass();
759        }
760
761        handled
762    }
763
764    /// Handles copy request from platform.
765    /// Returns the selected text from focused text field, or None.
766    /// O(1) operation using stored handler.
767    pub fn on_copy(&mut self) -> Option<String> {
768        let app_context = Rc::clone(&self.app_context);
769        app_context.enter(|| self.on_copy_inner())
770    }
771
772    fn on_copy_inner(&mut self) -> Option<String> {
773        // Use O(1) dispatch instead of tree scan
774        cranpose_ui::text_field_focus::dispatch_copy()
775    }
776
777    /// Handles cut request from platform.
778    /// Returns the cut text from focused text field, or None.
779    /// O(1) operation using stored handler.
780    pub fn on_cut(&mut self) -> Option<String> {
781        let _event_handler = enter_event_handler_scope();
782        let app_context = Rc::clone(&self.app_context);
783        app_context.enter(|| self.on_cut_inner())
784    }
785
786    fn on_cut_inner(&mut self) -> Option<String> {
787        let text =
788            run_in_mutable_snapshot(cranpose_ui::text_field_focus::dispatch_cut).unwrap_or(None);
789
790        if text.is_some() {
791            self.mark_dirty();
792            self.request_layout_pass();
793        }
794
795        text
796    }
797
798    /// Sets the Linux primary selection (for middle-click paste).
799    /// This is called when text is selected in a text field.
800    /// On non-Linux platforms, this is a no-op.
801    #[cfg(all(
802        feature = "clipboard-native",
803        target_os = "linux",
804        not(target_arch = "wasm32")
805    ))]
806    pub fn set_primary_selection(&mut self, text: &str) {
807        use arboard::{LinuxClipboardKind, SetExtLinux};
808        if let Some(ref mut clipboard) = self.clipboard {
809            let result = clipboard
810                .set()
811                .clipboard(LinuxClipboardKind::Primary)
812                .text(text.to_string());
813            if let Err(e) = result {
814                // Primary selection may not be available on all systems
815                log::debug!("Primary selection set failed: {:?}", e);
816            }
817        }
818    }
819
820    #[cfg(not(all(
821        feature = "clipboard-native",
822        target_os = "linux",
823        not(target_arch = "wasm32")
824    )))]
825    pub fn set_primary_selection(&mut self, _text: &str) {}
826
827    /// Gets text from the Linux primary selection (for middle-click paste).
828    /// On non-Linux platforms, returns None.
829    #[cfg(all(
830        feature = "clipboard-native",
831        target_os = "linux",
832        not(target_arch = "wasm32")
833    ))]
834    pub fn get_primary_selection(&mut self) -> Option<String> {
835        use arboard::{GetExtLinux, LinuxClipboardKind};
836        if let Some(ref mut clipboard) = self.clipboard {
837            clipboard
838                .get()
839                .clipboard(LinuxClipboardKind::Primary)
840                .text()
841                .ok()
842        } else {
843            None
844        }
845    }
846
847    #[cfg(not(all(
848        feature = "clipboard-native",
849        target_os = "linux",
850        not(target_arch = "wasm32")
851    )))]
852    pub fn get_primary_selection(&mut self) -> Option<String> {
853        None
854    }
855
856    /// Syncs the current text field selection to PRIMARY (Linux X11).
857    /// Call this when selection changes in a text field.
858    pub fn sync_selection_to_primary(&mut self) {
859        #[cfg(all(target_os = "linux", not(target_arch = "wasm32")))]
860        {
861            if let Some(text) = self.on_copy() {
862                self.set_primary_selection(&text);
863            }
864        }
865    }
866
867    /// Handles IME preedit (composition) events.
868    /// Called when the input method is composing text (e.g., typing CJK characters).
869    ///
870    /// - `text`: The current preedit text (empty to clear composition state)
871    /// - `cursor`: Optional cursor position within the preedit text (start, end)
872    ///
873    /// Returns `true` if a text field consumed the event.
874    pub fn on_ime_preedit(&mut self, text: &str, cursor: Option<(usize, usize)>) -> bool {
875        let _event_handler = enter_event_handler_scope();
876        let app_context = Rc::clone(&self.app_context);
877        app_context.enter(|| self.on_ime_preedit_inner(text, cursor))
878    }
879
880    fn on_ime_preedit_inner(&mut self, text: &str, cursor: Option<(usize, usize)>) -> bool {
881        // Wrap in mutable snapshot for atomic changes
882        let handled = run_in_mutable_snapshot(|| {
883            cranpose_ui::text_field_focus::dispatch_ime_preedit(text, cursor)
884        })
885        .unwrap_or(false);
886
887        if handled {
888            self.mark_dirty();
889            // IME composition changes the visible text, needs layout update
890            self.request_layout_pass();
891        }
892
893        handled
894    }
895
896    /// Finishes the active IME composition, keeping the composed text as
897    /// committed text (Android `finishComposingText` semantics).
898    /// Returns `true` if a text field consumed the event.
899    pub fn on_ime_finish_composing(&mut self) -> bool {
900        let _event_handler = enter_event_handler_scope();
901        let app_context = Rc::clone(&self.app_context);
902        app_context.enter(|| self.on_ime_finish_composing_inner())
903    }
904
905    fn on_ime_finish_composing_inner(&mut self) -> bool {
906        let handled =
907            run_in_mutable_snapshot(cranpose_ui::text_field_focus::dispatch_ime_finish_composing)
908                .unwrap_or(false);
909
910        if handled {
911            self.mark_dirty();
912            self.request_layout_pass();
913        }
914
915        handled
916    }
917
918    /// Marks existing text in the focused field as the composing region
919    /// without changing it (Android `setComposingRegion` semantics). Offsets
920    /// are UTF-8 bytes. Returns `true` if a text field consumed the event.
921    pub fn on_ime_set_composing_region(&mut self, start_bytes: usize, end_bytes: usize) -> bool {
922        let _event_handler = enter_event_handler_scope();
923        let app_context = Rc::clone(&self.app_context);
924        app_context.enter(|| {
925            let handled = run_in_mutable_snapshot(|| {
926                cranpose_ui::text_field_focus::dispatch_ime_set_composing_region(
927                    start_bytes,
928                    end_bytes,
929                )
930            })
931            .unwrap_or(false);
932
933            if handled {
934                self.mark_dirty();
935                self.request_layout_pass();
936            }
937
938            handled
939        })
940    }
941
942    /// Moves the focused field's selection/caret to `[start_bytes, end_bytes)`
943    /// without editing text (Android `InputConnection.setSelection`; the path
944    /// Gboard's spacebar-swipe uses to scrub the cursor). Offsets are UTF-8
945    /// bytes. Returns `true` if a text field consumed the event.
946    pub fn on_ime_set_selection(&mut self, start_bytes: usize, end_bytes: usize) -> bool {
947        let _event_handler = enter_event_handler_scope();
948        let app_context = Rc::clone(&self.app_context);
949        app_context.enter(|| {
950            let handled = run_in_mutable_snapshot(|| {
951                cranpose_ui::text_field_focus::dispatch_ime_set_selection(start_bytes, end_bytes)
952            })
953            .unwrap_or(false);
954
955            // A selection-only change never reflows text, so it needs a redraw
956            // but not a layout pass.
957            if handled {
958                self.mark_dirty();
959            }
960
961            handled
962        })
963    }
964
965    /// Returns a snapshot of the focused text field's editable state for
966    /// platform IMEs (text, selection and composition in UTF-8 bytes), or
967    /// `None` when no text field is focused.
968    pub fn ime_editor_state(&mut self) -> Option<cranpose_ui::text_field_focus::ImeEditorState> {
969        let app_context = Rc::clone(&self.app_context);
970        app_context.enter(cranpose_ui::text_field_focus::focused_editor_state)
971    }
972
973    /// Window-space caret geometry of the focused field for coordinate-based
974    /// platform text input (iOS trackpad cursor + tap-to-position), or `None`
975    /// when no text field is focused.
976    pub fn ime_caret_geometry(
977        &mut self,
978    ) -> Option<cranpose_ui::text_field_focus::ImeCaretGeometry> {
979        let app_context = Rc::clone(&self.app_context);
980        app_context.enter(cranpose_ui::text_field_focus::focused_caret_geometry)
981    }
982
983    /// Clears text-field focus (used by platform IME actions such as
984    /// Android's Done). The focus-loss notification hides the soft keyboard.
985    pub fn clear_text_field_focus(&mut self) {
986        let _event_handler = enter_event_handler_scope();
987        let app_context = Rc::clone(&self.app_context);
988        app_context.enter(cranpose_ui::text_field_focus::clear_focus);
989        self.mark_dirty();
990        self.request_layout_pass();
991    }
992
993    /// Handles IME delete-surrounding events.
994    /// Returns `true` if a text field consumed the event.
995    pub fn on_ime_delete_surrounding(&mut self, before_bytes: usize, after_bytes: usize) -> bool {
996        let _event_handler = enter_event_handler_scope();
997        let app_context = Rc::clone(&self.app_context);
998        app_context.enter(|| self.on_ime_delete_surrounding_inner(before_bytes, after_bytes))
999    }
1000
1001    fn on_ime_delete_surrounding_inner(&mut self, before_bytes: usize, after_bytes: usize) -> bool {
1002        let handled = run_in_mutable_snapshot(|| {
1003            cranpose_ui::text_field_focus::dispatch_delete_surrounding(before_bytes, after_bytes)
1004        })
1005        .unwrap_or(false);
1006
1007        if handled {
1008            self.mark_dirty();
1009            self.request_layout_pass();
1010        }
1011
1012        handled
1013    }
1014}