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 pointer_event(
9        &self,
10        kind: PointerEventKind,
11        position: Point,
12        global_position: Point,
13        event_time: PointerEventTime,
14    ) -> PointerEvent {
15        PointerEvent::new(kind, position, global_position)
16            .with_time_ms(event_time.platform_time_ms)
17            .with_animation_time_nanos(event_time.animation_time_nanos)
18    }
19
20    fn resolve_gesture_targets(
21        &self,
22        pointer: PointerId,
23    ) -> Vec<<<R as Renderer>::Scene as RenderScene>::HitTarget> {
24        self.resolve_hit_path(pointer)
25    }
26
27    /// Resolves cached NodeIds to fresh HitTargets from the current scene.
28    ///
29    /// This is the key to avoiding stale geometry during scroll/layout changes:
30    /// - We cache NodeIds on PointerDown (stable identity)
31    /// - On Move/Up/Cancel, we call find_target() to get fresh geometry
32    /// - Handler closures are preserved (same Rc), so gesture state survives
33    fn resolve_hit_path(
34        &self,
35        pointer: PointerId,
36    ) -> Vec<<<R as Renderer>::Scene as RenderScene>::HitTarget> {
37        let Some(node_ids) = self.hit_path_tracker.dispatch_order(pointer) else {
38            return Vec::new();
39        };
40
41        let scene = self.renderer.scene();
42        let targets: Vec<_> = node_ids
43            .iter()
44            .filter_map(|&id| scene.find_target(id))
45            .collect();
46        log::trace!(
47            target: "cranpose::input",
48            "resolve_hit_path pointer={pointer:?} cached={node_ids:?} resolved_count={}",
49            targets.len()
50        );
51        targets
52    }
53
54    fn dispatch_targets<I>(&mut self, targets: I, event: PointerEvent, stop_on_consume: bool)
55    where
56        I: IntoIterator<Item = <<R as Renderer>::Scene as RenderScene>::HitTarget>,
57    {
58        let mut applier = self.composition.applier_mut();
59        for target in targets {
60            let node_id = target.node_id();
61            target.dispatch_with_applier(&mut applier, event.clone());
62            log::trace!(
63                target: "cranpose::input",
64                "dispatch {:?} node={} consumed={} stop_on_consume={}",
65                event.kind,
66                node_id,
67                event.is_consumed(),
68                stop_on_consume,
69            );
70            if stop_on_consume && event.is_consumed() {
71                break;
72            }
73        }
74        event.finish_post_dispatch();
75    }
76
77    /// Sets the device source (touch/mouse/stylus) of the pointer sample that
78    /// the platform is about to dispatch. Call this before `set_cursor` /
79    /// `pointer_pressed` / `pointer_released` so the resulting `PointerEvent`s
80    /// carry the source so consumers can preserve device-specific gesture
81    /// details without changing shared pointer UI.
82    pub fn set_pointer_source(&mut self, source: PointerSource) {
83        self.pointer_source = source;
84    }
85
86    /// The device source of the most recent pointer sample.
87    pub fn pointer_source(&self) -> PointerSource {
88        self.pointer_source
89    }
90
91    pub fn set_cursor(&mut self, x: f32, y: f32) -> bool {
92        self.set_cursor_at_time(x, y, None)
93    }
94
95    /// Like [`set_cursor`](Self::set_cursor), but carries the platform input
96    /// timestamp (milliseconds, platform-specific time base) of the sample.
97    ///
98    /// Platforms that deliver input batched/frame-aligned (Android) must use
99    /// this so gesture velocity is computed from real event times instead of
100    /// delivery times.
101    pub fn set_cursor_at_time(&mut self, x: f32, y: f32, time_ms: Option<i64>) -> bool {
102        let event_time = self.realtime_pointer_event_time(time_ms);
103        self.set_cursor_at_event_time(x, y, event_time)
104    }
105
106    /// Set the cursor using a timestamp already resolved into both clock domains.
107    pub fn set_cursor_at_event_time(
108        &mut self,
109        x: f32,
110        y: f32,
111        event_time: PointerEventTime,
112    ) -> bool {
113        let _event_handler = enter_event_handler_scope();
114        let app_context = Rc::clone(&self.app_context);
115        let result = app_context.enter(|| {
116            run_in_mutable_snapshot(|| self.set_cursor_inner(x, y, event_time)).unwrap_or(false)
117        });
118        if result {
119            self.mark_dirty();
120        }
121        log::trace!(
122            target: "cranpose::input",
123            "set_cursor ({x:.2},{y:.2}) time_ms={:?} animation_time_nanos={} -> {result}",
124            event_time.platform_time_ms,
125            event_time.animation_time_nanos,
126        );
127        result
128    }
129
130    fn set_cursor_inner(&mut self, x: f32, y: f32, event_time: PointerEventTime) -> bool {
131        self.cursor = (x, y);
132
133        // During a gesture (button pressed), ONLY dispatch to the tracked hit path.
134        // Never fall back to hover hit-testing while buttons are down.
135        // This maintains the invariant: the path that receives Down must receive Move and Up/Cancel.
136        if self.buttons_pressed != PointerButtons::NONE {
137            if self.hit_path_tracker.has_path(PointerId::PRIMARY) {
138                let targets = self.resolve_gesture_targets(PointerId::PRIMARY);
139                if !targets.is_empty() {
140                    let event = self
141                        .pointer_event(
142                            PointerEventKind::Move,
143                            Point { x, y },
144                            Point { x, y },
145                            event_time,
146                        )
147                        .with_buttons(self.buttons_pressed)
148                        .with_source(self.pointer_source);
149                    self.dispatch_targets(targets, event, false);
150                    return true;
151                }
152
153                return false;
154            }
155
156            // Button is down but we have no recorded path inside this app
157            // (e.g. drag started outside). Do not dispatch anything.
158            return false;
159        }
160
161        // No gesture in progress: regular hover move using hit-test.
162        // Diff against previous hover set to synthesize Enter/Exit events.
163        let hits = self.renderer.scene().hit_test(x, y);
164        let new_ids: Vec<NodeId> = hits.iter().map(|h| h.node_id()).collect();
165
166        // Dispatch Exit to nodes that are no longer hovered
167        let pos = Point { x, y };
168        let previously_hovered = self.hovered_nodes.clone();
169        for old_id in previously_hovered {
170            if !new_ids.contains(&old_id) {
171                if let Some(target) = self.renderer.scene().find_target(old_id) {
172                    let exit_event = self
173                        .pointer_event(PointerEventKind::Exit, pos, pos, event_time)
174                        .with_buttons(self.buttons_pressed)
175                        .with_source(self.pointer_source);
176                    self.dispatch_targets(std::iter::once(target), exit_event, false);
177                }
178            }
179        }
180
181        // Dispatch Enter to newly hovered nodes
182        for hit in &hits {
183            if !self.hovered_nodes.contains(&hit.node_id()) {
184                let enter_event = self
185                    .pointer_event(PointerEventKind::Enter, pos, pos, event_time)
186                    .with_buttons(self.buttons_pressed)
187                    .with_source(self.pointer_source);
188                self.dispatch_targets(std::iter::once(hit.clone()), enter_event, false);
189            }
190        }
191
192        self.hovered_nodes = new_ids;
193
194        if !hits.is_empty() {
195            let event = self
196                .pointer_event(PointerEventKind::Move, pos, pos, event_time)
197                .with_buttons(self.buttons_pressed)
198                .with_source(self.pointer_source);
199            self.dispatch_targets(hits, event, true);
200            true
201        } else {
202            false
203        }
204    }
205
206    pub fn pointer_pressed(&mut self) -> bool {
207        self.pointer_pressed_at_time(None)
208    }
209
210    /// Like [`pointer_pressed`](Self::pointer_pressed), but carries the
211    /// platform input timestamp (milliseconds) of the press sample.
212    pub fn pointer_pressed_at_time(&mut self, time_ms: Option<i64>) -> bool {
213        let event_time = self.realtime_pointer_event_time(time_ms);
214        self.pointer_pressed_at_event_time(event_time)
215    }
216
217    /// Dispatch primary-button down with an already resolved event timestamp.
218    pub fn pointer_pressed_at_event_time(&mut self, event_time: PointerEventTime) -> bool {
219        // The dev overlay is drawn over the composition and is not part of it,
220        // so it gets the press first and keeps it. Nothing below it is armed:
221        // no button state, no hit path, so the matching release is inert.
222        if self.dev_overlay_press(self.cursor.0, self.cursor.1) {
223            return true;
224        }
225        let _event_handler = enter_event_handler_scope();
226        let app_context = Rc::clone(&self.app_context);
227        let result = app_context.enter(|| {
228            run_in_mutable_snapshot(|| self.pointer_pressed_inner(event_time)).unwrap_or(false)
229        });
230        if result {
231            self.mark_dirty();
232        }
233        log::trace!(
234            target: "cranpose::input",
235            "pointer_pressed time_ms={:?} animation_time_nanos={} -> {result}",
236            event_time.platform_time_ms,
237            event_time.animation_time_nanos,
238        );
239        result
240    }
241
242    fn pointer_pressed_inner(&mut self, event_time: PointerEventTime) -> bool {
243        // Track button state
244        self.buttons_pressed.insert(PointerButton::Primary);
245
246        // Hit-test against the current (last rendered) scene.
247        // Even if the app is dirty, this scene is what the user actually saw and clicked.
248        // Frame N is rendered → user sees frame N and taps → we hit-test frame N's geometry.
249        // The pointer event may mark dirty → next frame runs update() → renders N+1.
250
251        // Perform hit test and cache the NodeIds (not geometry!)
252        // The key insight from Jetpack Compose: cache identity, resolve fresh geometry per dispatch
253        let hits = self.renderer.scene().hit_test(self.cursor.0, self.cursor.1);
254        if hits.is_empty() {
255            self.hit_path_tracker.remove_path(PointerId::PRIMARY);
256            false
257        } else {
258            let event = self
259                .pointer_event(
260                    PointerEventKind::Down,
261                    Point {
262                        x: self.cursor.0,
263                        y: self.cursor.1,
264                    },
265                    Point {
266                        x: self.cursor.0,
267                        y: self.cursor.1,
268                    },
269                    event_time,
270                )
271                .with_buttons(self.buttons_pressed)
272                .with_source(self.pointer_source);
273
274            let mut delivered_capture_paths = Vec::new();
275            let mut applier = self.composition.applier_mut();
276            for hit in hits {
277                let node_id = hit.node_id();
278                delivered_capture_paths.push(hit.capture_path());
279                hit.dispatch_with_applier(&mut applier, event.clone());
280                log::trace!(
281                    target: "cranpose::input",
282                    "dispatch {:?} node={} consumed={} stop_on_consume=true",
283                    event.kind,
284                    node_id,
285                    event.is_consumed(),
286                );
287                if event.is_consumed() {
288                    break;
289                }
290            }
291
292            self.hit_path_tracker
293                .add_hit_path(PointerId::PRIMARY, delivered_capture_paths);
294            log::trace!(
295                target: "cranpose::input",
296                "pointer_pressed_inner cached_hit_path={:?}",
297                self.hit_path_tracker.get_path(PointerId::PRIMARY),
298            );
299
300            true
301        }
302    }
303
304    pub fn pointer_released(&mut self) -> bool {
305        self.pointer_released_at_time(None)
306    }
307
308    /// Releases the pointer at the position carried by the platform's release
309    /// sample (Android `ACTION_UP`, web `pointerup`/`touchend`).
310    ///
311    /// The cursor is moved to `(x, y)` WITHOUT dispatching a Move event, then
312    /// the Up event is dispatched at that position. Platforms whose release
313    /// events carry their own coordinates must use this instead of
314    /// `set_cursor* + pointer_released*`: lift-off samples routinely roll back
315    /// a few dp against the travel direction as the finger peels off, and
316    /// feeding that jitter into gesture velocity trackers as a final Move
317    /// sample can flip the sign of the computed fling velocity (flings that
318    /// suddenly go the opposite way). Jetpack Compose likewise never feeds the
319    /// up sample into velocity tracking.
320    pub fn pointer_released_at_position(&mut self, x: f32, y: f32) -> bool {
321        self.pointer_released_at_position_time(x, y, None)
322    }
323
324    /// Like [`pointer_released_at_position`](Self::pointer_released_at_position),
325    /// but carries the platform input timestamp (milliseconds) of the release
326    /// sample.
327    pub fn pointer_released_at_position_time(
328        &mut self,
329        x: f32,
330        y: f32,
331        time_ms: Option<i64>,
332    ) -> bool {
333        let event_time = self.realtime_pointer_event_time(time_ms);
334        self.pointer_released_at_position_event_time(x, y, event_time)
335    }
336
337    /// Release at a position with an already resolved event timestamp.
338    pub fn pointer_released_at_position_event_time(
339        &mut self,
340        x: f32,
341        y: f32,
342        event_time: PointerEventTime,
343    ) -> bool {
344        let _event_handler = enter_event_handler_scope();
345        let app_context = Rc::clone(&self.app_context);
346        let result = app_context.enter(|| {
347            run_in_mutable_snapshot(|| {
348                self.cursor = (x, y);
349                self.pointer_released_inner(event_time)
350            })
351            .unwrap_or(false)
352        });
353        if result {
354            self.mark_dirty();
355        }
356        log::trace!(
357            target: "cranpose::input",
358            "pointer_released_at_position ({x:.2},{y:.2}) time_ms={:?} animation_time_nanos={} -> {result}",
359            event_time.platform_time_ms,
360            event_time.animation_time_nanos,
361        );
362        result
363    }
364
365    /// Like [`pointer_released`](Self::pointer_released), but carries the
366    /// platform input timestamp (milliseconds) of the release sample.
367    pub fn pointer_released_at_time(&mut self, time_ms: Option<i64>) -> bool {
368        let event_time = self.realtime_pointer_event_time(time_ms);
369        self.pointer_released_at_event_time(event_time)
370    }
371
372    /// Dispatch primary-button up with an already resolved event timestamp.
373    pub fn pointer_released_at_event_time(&mut self, event_time: PointerEventTime) -> bool {
374        let _event_handler = enter_event_handler_scope();
375        let app_context = Rc::clone(&self.app_context);
376        let result = app_context.enter(|| {
377            run_in_mutable_snapshot(|| self.pointer_released_inner(event_time)).unwrap_or(false)
378        });
379        if result {
380            self.mark_dirty();
381        }
382        log::trace!(
383            target: "cranpose::input",
384            "pointer_released time_ms={:?} animation_time_nanos={} -> {result}",
385            event_time.platform_time_ms,
386            event_time.animation_time_nanos,
387        );
388        result
389    }
390
391    fn pointer_released_inner(&mut self, event_time: PointerEventTime) -> bool {
392        // UP events report buttons as "currently pressed" (after release),
393        // matching typical platform semantics where primary is already gone.
394        self.buttons_pressed.remove(PointerButton::Primary);
395        let corrected_buttons = self.buttons_pressed;
396        let targets = self.resolve_gesture_targets(PointerId::PRIMARY);
397
398        // Always remove the path, even if targets is empty (node may have been removed)
399        self.hit_path_tracker.remove_path(PointerId::PRIMARY);
400
401        if !targets.is_empty() {
402            let event = self
403                .pointer_event(
404                    PointerEventKind::Up,
405                    Point {
406                        x: self.cursor.0,
407                        y: self.cursor.1,
408                    },
409                    Point {
410                        x: self.cursor.0,
411                        y: self.cursor.1,
412                    },
413                    event_time,
414                )
415                .with_buttons(corrected_buttons)
416                .with_source(self.pointer_source);
417
418            self.dispatch_targets(targets, event, false);
419            true
420        } else {
421            false
422        }
423    }
424
425    /// Dispatches an event for a secondary pointer (`pointer_id != 0`).
426    ///
427    /// Multi-touch gestures act on the element the first finger grabbed, so
428    /// secondary pointers are routed to the hit path captured by the primary
429    /// pointer's Down. They carry no hover/click semantics and are ignored
430    /// when no primary gesture is in progress.
431    ///
432    /// Returns `true` when the event was dispatched to at least one target.
433    pub fn secondary_pointer_pressed(
434        &mut self,
435        pointer_id: u64,
436        x: f32,
437        y: f32,
438        time_ms: Option<i64>,
439    ) -> bool {
440        let event_time = self.realtime_pointer_event_time(time_ms);
441        self.dispatch_secondary_pointer(PointerEventKind::Down, pointer_id, x, y, event_time)
442    }
443
444    /// Move counterpart of [`secondary_pointer_pressed`](Self::secondary_pointer_pressed).
445    pub fn secondary_pointer_moved(
446        &mut self,
447        pointer_id: u64,
448        x: f32,
449        y: f32,
450        time_ms: Option<i64>,
451    ) -> bool {
452        let event_time = self.realtime_pointer_event_time(time_ms);
453        self.dispatch_secondary_pointer(PointerEventKind::Move, pointer_id, x, y, event_time)
454    }
455
456    /// Release counterpart of [`secondary_pointer_pressed`](Self::secondary_pointer_pressed).
457    pub fn secondary_pointer_released(
458        &mut self,
459        pointer_id: u64,
460        x: f32,
461        y: f32,
462        time_ms: Option<i64>,
463    ) -> bool {
464        let event_time = self.realtime_pointer_event_time(time_ms);
465        self.dispatch_secondary_pointer(PointerEventKind::Up, pointer_id, x, y, event_time)
466    }
467
468    fn dispatch_secondary_pointer(
469        &mut self,
470        kind: PointerEventKind,
471        pointer_id: u64,
472        x: f32,
473        y: f32,
474        event_time: PointerEventTime,
475    ) -> bool {
476        if pointer_id == 0 {
477            log::warn!(
478                target: "cranpose::input",
479                "secondary pointer dispatch called with the primary pointer id"
480            );
481            return false;
482        }
483
484        let _event_handler = enter_event_handler_scope();
485        let app_context = Rc::clone(&self.app_context);
486        let result = app_context.enter(|| {
487            run_in_mutable_snapshot(|| {
488                if !self.hit_path_tracker.has_path(PointerId::PRIMARY) {
489                    return false;
490                }
491                let targets = self.resolve_gesture_targets(PointerId::PRIMARY);
492                if targets.is_empty() {
493                    return false;
494                }
495                let pos = Point { x, y };
496                let event = self
497                    .pointer_event(kind, pos, pos, event_time)
498                    .with_buttons(self.buttons_pressed)
499                    .with_id(pointer_id)
500                    .with_source(self.pointer_source);
501                self.dispatch_targets(targets, event, false);
502                true
503            })
504            .unwrap_or(false)
505        });
506        if result {
507            self.mark_dirty();
508        }
509        log::trace!(
510            target: "cranpose::input",
511            "secondary_pointer {kind:?} id={pointer_id} ({x:.2},{y:.2}) time_ms={:?} animation_time_nanos={} -> {result}",
512            event_time.platform_time_ms,
513            event_time.animation_time_nanos,
514        );
515        result
516    }
517
518    /// Dispatches a discrete zoom step (desktop ctrl+wheel, browser pinch)
519    /// to the pointer handlers under the cursor.
520    ///
521    /// `zoom_factor` is multiplicative: `> 1.0` zooms in, `< 1.0` zooms out.
522    /// Returns `true` if a handler consumed the event.
523    pub fn pointer_zoomed(&mut self, zoom_factor: f32) -> bool {
524        let event_time = self.realtime_pointer_event_time(None);
525        let _event_handler = enter_event_handler_scope();
526        let app_context = Rc::clone(&self.app_context);
527        let result = app_context.enter(|| {
528            run_in_mutable_snapshot(|| self.pointer_zoomed_inner(zoom_factor, event_time))
529                .unwrap_or(false)
530        });
531        if result {
532            self.mark_dirty();
533        }
534        log::trace!(
535            target: "cranpose::input",
536            "pointer_zoomed factor={zoom_factor:.4} -> {result}"
537        );
538        result
539    }
540
541    fn pointer_zoomed_inner(&mut self, zoom_factor: f32, event_time: PointerEventTime) -> bool {
542        if !zoom_factor.is_finite() || zoom_factor <= 0.0 || zoom_factor == 1.0 {
543            return false;
544        }
545
546        let hits = self.renderer.scene().hit_test(self.cursor.0, self.cursor.1);
547        if hits.is_empty() {
548            return false;
549        }
550
551        let pos = Point {
552            x: self.cursor.0,
553            y: self.cursor.1,
554        };
555        let event = self
556            .pointer_event(PointerEventKind::Zoom, pos, pos, event_time)
557            .with_buttons(self.buttons_pressed)
558            .with_zoom_delta(zoom_factor)
559            .with_source(self.pointer_source);
560
561        let capture_paths = hits
562            .iter()
563            .map(|hit| hit.capture_path())
564            .collect::<Vec<_>>();
565        let targets = crate::hit_path_tracker::dispatch_order_for_paths(&capture_paths)
566            .into_iter()
567            .filter_map(|node_id| self.renderer.scene().find_target(node_id))
568            .collect::<Vec<_>>();
569
570        self.dispatch_targets(targets, event.clone(), true);
571
572        event.is_consumed()
573    }
574
575    /// Dispatches one mouse-wheel / trackpad sample through the whole wheel
576    /// policy, and returns `true` when something consumed it.
577    ///
578    /// This is the single entry point every host with a wheel calls, after
579    /// placing the cursor. A wheel sample is not just a scroll — it is whichever
580    /// of four things the modifiers and the tree make it, in this order:
581    ///
582    /// 1. **Zoom** when ctrl is held. That is the desktop convention and the
583    ///    way browsers deliver a trackpad pinch, so both arrive here as the
584    ///    same gesture.
585    /// 2. **Rotary**, offered to [`rotary_scrolled`](Self::rotary_scrolled)
586    ///    before anything else can take it, so the Wear OS crown stack is
587    ///    developable on a machine with a wheel. Nothing consumes rotary unless
588    ///    the app opts in via `Modifier::on_rotary_scroll_event` or
589    ///    [`set_on_rotary_scroll`](Self::set_on_rotary_scroll), so ordinary
590    ///    scrolling is unaffected.
591    /// 3. **Horizontal scroll** when alt is held on a wheel that only reports a
592    ///    vertical axis.
593    /// 4. **Scroll**, to the hovered scrollable.
594    ///
595    /// Hosts must not re-implement this order. Doing so is how the browser
596    /// ended up scrolling backwards and never delivering rotary at all: the
597    /// policy lived in the desktop event loop, and the second host that grew a
598    /// wheel reimplemented the parts of it that were obvious from the outside.
599    pub fn wheel_scrolled(&mut self, wheel: crate::WheelScroll) -> bool {
600        if wheel.is_zoom() {
601            let zoom_factor = wheel.zoom_factor();
602            log::trace!(
603                target: "cranpose::input",
604                "wheel zoom factor={zoom_factor:.4}"
605            );
606            return self.pointer_zoomed(zoom_factor);
607        }
608
609        let rotary =
610            RotaryScrollEvent::from_wheel_pixels(wheel.delta.y, wheel.delta.x, wheel.uptime_millis);
611        if self.rotary_scrolled(rotary) {
612            return true;
613        }
614
615        let delta = wheel.scroll_delta();
616        log::trace!(
617            target: "cranpose::input",
618            "wheel delta ({:.2},{:.2}) alt={}",
619            delta.x,
620            delta.y,
621            wheel.modifiers.alt
622        );
623        self.pointer_scrolled(delta.x, delta.y)
624    }
625
626    /// Dispatches a mouse wheel / trackpad scroll event to hovered pointer handlers.
627    ///
628    /// Returns `true` if a handler consumed the event.
629    ///
630    /// This is the last step of the wheel policy, not its entry point: hosts
631    /// call [`wheel_scrolled`](Self::wheel_scrolled), which reaches here once
632    /// zoom and rotary have declined the sample.
633    pub fn pointer_scrolled(&mut self, delta_x: f32, delta_y: f32) -> bool {
634        let event_time = self.realtime_pointer_event_time(None);
635        let _event_handler = enter_event_handler_scope();
636        let app_context = Rc::clone(&self.app_context);
637        let result = app_context.enter(|| {
638            run_in_mutable_snapshot(|| self.pointer_scrolled_inner(delta_x, delta_y, event_time))
639                .unwrap_or(false)
640        });
641        if result {
642            self.mark_dirty();
643        }
644        log::trace!(
645            target: "cranpose::input",
646            "pointer_scrolled ({delta_x:.2},{delta_y:.2}) -> {result}"
647        );
648        result
649    }
650
651    fn pointer_scrolled_inner(
652        &mut self,
653        delta_x: f32,
654        delta_y: f32,
655        event_time: PointerEventTime,
656    ) -> bool {
657        if delta_x.abs() <= f32::EPSILON && delta_y.abs() <= f32::EPSILON {
658            return false;
659        }
660
661        let hits = self.renderer.scene().hit_test(self.cursor.0, self.cursor.1);
662        if hits.is_empty() {
663            return false;
664        }
665
666        let event = self
667            .pointer_event(
668                PointerEventKind::Scroll,
669                Point {
670                    x: self.cursor.0,
671                    y: self.cursor.1,
672                },
673                Point {
674                    x: self.cursor.0,
675                    y: self.cursor.1,
676                },
677                event_time,
678            )
679            .with_buttons(self.buttons_pressed)
680            .with_scroll_delta(Point {
681                x: delta_x,
682                y: delta_y,
683            })
684            .with_source(self.pointer_source);
685
686        let capture_paths = hits
687            .iter()
688            .map(|hit| hit.capture_path())
689            .collect::<Vec<_>>();
690        let targets = crate::hit_path_tracker::dispatch_order_for_paths(&capture_paths)
691            .into_iter()
692            .filter_map(|node_id| self.renderer.scene().find_target(node_id))
693            .collect::<Vec<_>>();
694
695        self.dispatch_targets(targets, event.clone(), true);
696
697        event.is_consumed()
698    }
699
700    /// Installs the window-level rotary (Wear OS crown / rotating bezel)
701    /// handler — the low-level escape hatch.
702    ///
703    /// The handler runs only after the routed modifier chain has declined the
704    /// event (see [`rotary_scrolled`](Self::rotary_scrolled)), so an app that
705    /// draws everything into a single canvas receives every rotary delta
706    /// without registering a focus target or a modifier. Returning `true`
707    /// reports the event as consumed to the platform.
708    ///
709    /// Passing a new handler replaces the previous one.
710    pub fn set_on_rotary_scroll<F>(&mut self, handler: F)
711    where
712        F: Fn(RotaryScrollEvent) -> bool + 'static,
713    {
714        self.on_rotary_scroll = Some(Rc::new(handler));
715    }
716
717    /// Removes the window-level rotary handler, if one is installed.
718    pub fn clear_on_rotary_scroll(&mut self) {
719        self.on_rotary_scroll = None;
720    }
721
722    /// Pixels per rotary detent used by
723    /// [`rotary_scrolled_by_detents`](Self::rotary_scrolled_by_detents).
724    pub fn rotary_scroll_factor(&self) -> f32 {
725        self.rotary_scroll_factor
726    }
727
728    /// Sets the pixels-per-detent factor for rotary input.
729    ///
730    /// On Wear OS this must be `ViewConfiguration.getScaledVerticalScrollFactor()`
731    /// for pixel-exact parity with Compose. The host activity can read it over
732    /// JNI once at startup and push it here; when it does not, the shell falls
733    /// back to [`DEFAULT_ROTARY_SCROLL_FACTOR_DP`] scaled by display density.
734    ///
735    /// Non-finite or non-positive values are ignored.
736    pub fn set_rotary_scroll_factor(&mut self, factor: f32) {
737        if factor.is_finite() && factor > 0.0 {
738            self.rotary_scroll_factor = factor;
739        }
740    }
741
742    /// Dispatches a rotary scroll expressed in raw detents (Android
743    /// `AXIS_SCROLL`), converting to pixels with the configured scroll factor.
744    ///
745    /// Applies Compose's sign convention: a positive detent value (crown turned
746    /// up/away) produces a negative `vertical_scroll_pixels`.
747    pub fn rotary_scrolled_by_detents(&mut self, detents: f32, uptime_millis: u64) -> bool {
748        let factor = self.rotary_scroll_factor;
749        self.rotary_scrolled(RotaryScrollEvent::from_detents(
750            detents,
751            factor,
752            factor,
753            uptime_millis,
754        ))
755    }
756
757    /// Dispatches a rotary scroll event (Wear OS crown, Galaxy Watch bezel, or
758    /// a desktop mouse wheel standing in for one during development).
759    ///
760    /// Routing mirrors Compose's `RotaryInputModifierNode` contract:
761    ///
762    /// 1. Resolve the target chain. When a focus target is registered
763    ///    ([`cranpose_ui::focus_dispatch::active_focus_target`]) and still
764    ///    exists in the current scene, its capture path is used, so rotary goes
765    ///    to the focused node exactly as on Wear OS. Cranpose does not yet wire
766    ///    focus automatically, so in practice this falls back to the chain
767    ///    under the current cursor position.
768    /// 2. **Capture pass**, root to leaf, invoking `on_pre_rotary_scroll_event`
769    ///    handlers.
770    /// 3. **Bubble pass**, leaf to root, invoking `on_rotary_scroll_event`
771    ///    handlers.
772    /// 4. If still unconsumed, the window-level handler installed by
773    ///    [`set_on_rotary_scroll`](Self::set_on_rotary_scroll).
774    ///
775    /// The first handler returning `true` consumes the event and stops every
776    /// remaining step. Returns `true` when the event was consumed.
777    pub fn rotary_scrolled(&mut self, event: RotaryScrollEvent) -> bool {
778        let _event_handler = enter_event_handler_scope();
779        let app_context = Rc::clone(&self.app_context);
780        let result = app_context.enter(|| {
781            run_in_mutable_snapshot(|| self.rotary_scrolled_inner(event)).unwrap_or(false)
782        });
783        if result {
784            self.mark_dirty();
785        }
786        log::trace!(
787            target: "cranpose::input",
788            "rotary_scrolled v={:.2} h={:.2} uptime={} -> {result}",
789            event.vertical_scroll_pixels,
790            event.horizontal_scroll_pixels,
791            event.uptime_millis,
792        );
793        result
794    }
795
796    fn rotary_scrolled_inner(&mut self, rotary: RotaryScrollEvent) -> bool {
797        if rotary.is_empty() {
798            return false;
799        }
800
801        // Leaf-first dispatch order (children before their ancestors), the
802        // same ordering pointer events use.
803        let bubble_order = self.rotary_dispatch_order();
804        let position = Point {
805            x: self.cursor.0,
806            y: self.cursor.1,
807        };
808
809        if !bubble_order.is_empty() {
810            // Capture pass: root -> leaf, so ancestors can intercept first.
811            let capture_targets = bubble_order
812                .iter()
813                .rev()
814                .filter_map(|&node_id| self.renderer.scene().find_target(node_id))
815                .collect::<Vec<_>>();
816            let capture_event =
817                PointerEvent::rotary(PointerEventKind::RotaryScrollPre, rotary, position);
818            self.dispatch_targets(capture_targets, capture_event.clone(), true);
819            if capture_event.is_consumed() {
820                return true;
821            }
822
823            // Bubble pass: leaf -> root.
824            let bubble_targets = bubble_order
825                .iter()
826                .filter_map(|&node_id| self.renderer.scene().find_target(node_id))
827                .collect::<Vec<_>>();
828            let bubble_event =
829                PointerEvent::rotary(PointerEventKind::RotaryScroll, rotary, position);
830            self.dispatch_targets(bubble_targets, bubble_event.clone(), true);
831            if bubble_event.is_consumed() {
832                return true;
833            }
834        }
835
836        // Window-level escape hatch for single-canvas apps.
837        if let Some(handler) = self.on_rotary_scroll.clone() {
838            return handler(rotary);
839        }
840
841        false
842    }
843
844    /// Resolves the leaf-to-root node order rotary events are dispatched over.
845    ///
846    /// Prefers the focused node's capture path; falls back to the chain under
847    /// the current cursor so rotary remains usable on a build where nothing has
848    /// claimed focus (the common case today).
849    fn rotary_dispatch_order(&self) -> Vec<NodeId> {
850        if let Some(focused) = cranpose_ui::active_focus_target() {
851            if let Some(target) = self.renderer.scene().find_target(focused) {
852                let path = target.capture_path();
853                if !path.is_empty() {
854                    return crate::hit_path_tracker::dispatch_order_for_paths(&[path]);
855                }
856            }
857        }
858
859        let hits = self.renderer.scene().hit_test(self.cursor.0, self.cursor.1);
860        if hits.is_empty() {
861            return Vec::new();
862        }
863        let capture_paths = hits
864            .iter()
865            .map(|hit| hit.capture_path())
866            .collect::<Vec<_>>();
867        crate::hit_path_tracker::dispatch_order_for_paths(&capture_paths)
868    }
869
870    /// Cancels any active gesture, dispatching Cancel events to cached targets.
871    /// Call this when:
872    /// - Window loses focus
873    /// - Mouse leaves window while button pressed
874    /// - Any other gesture abort scenario
875    pub fn cancel_gesture(&mut self) {
876        let event_time = self.realtime_pointer_event_time(None);
877        let _event_handler = enter_event_handler_scope();
878        let app_context = Rc::clone(&self.app_context);
879        let _ = app_context.enter(|| {
880            run_in_mutable_snapshot(|| {
881                self.cancel_gesture_inner(event_time);
882            })
883        });
884    }
885
886    fn cancel_gesture_inner(&mut self, event_time: PointerEventTime) {
887        let targets = self.resolve_gesture_targets(PointerId::PRIMARY);
888
889        // Clear tracker and button state
890        self.hit_path_tracker.clear();
891        self.buttons_pressed = PointerButtons::NONE;
892
893        if !targets.is_empty() {
894            let event = self
895                .pointer_event(
896                    PointerEventKind::Cancel,
897                    Point {
898                        x: self.cursor.0,
899                        y: self.cursor.1,
900                    },
901                    Point {
902                        x: self.cursor.0,
903                        y: self.cursor.1,
904                    },
905                    event_time,
906                )
907                .with_source(self.pointer_source);
908
909            self.dispatch_targets(targets, event, false);
910        }
911
912        // Dispatch Exit to all previously hovered nodes
913        let pos = Point {
914            x: self.cursor.0,
915            y: self.cursor.1,
916        };
917        let hovered_nodes = self.hovered_nodes.clone();
918        for node_id in hovered_nodes {
919            if let Some(target) = self.renderer.scene().find_target(node_id) {
920                let exit_event = self
921                    .pointer_event(PointerEventKind::Exit, pos, pos, event_time)
922                    .with_source(self.pointer_source);
923                self.dispatch_targets(std::iter::once(target), exit_event, false);
924            }
925        }
926        self.hovered_nodes.clear();
927    }
928
929    /// Installs the platform soft-keyboard handler for this shell's app context.
930    ///
931    /// The handler is invoked when a text field gains focus (`show_keyboard`)
932    /// or when text-field focus is cleared or goes stale (`hide_keyboard`).
933    /// Platform runtimes with an on-screen keyboard (Android, iOS) call this
934    /// once after creating the shell.
935    pub fn set_platform_text_input(
936        &mut self,
937        handler: Rc<dyn cranpose_ui::PlatformTextInputHandler>,
938    ) {
939        let app_context = Rc::clone(&self.app_context);
940        app_context
941            .enter(|| cranpose_ui::text_input_session::set_platform_text_input_handler(handler));
942    }
943
944    /// Removes the platform soft-keyboard handler, if one is installed.
945    pub fn clear_platform_text_input(&mut self) {
946        let app_context = Rc::clone(&self.app_context);
947        app_context.enter(cranpose_ui::text_input_session::clear_platform_text_input_handler);
948    }
949
950    /// Notifies the framework that the host app was paused/backgrounded.
951    ///
952    /// Withdraws any outstanding soft-keyboard request (and hides the keyboard)
953    /// so the "keyboard shown" state does not survive across the pause and get
954    /// restored on resume with no focused field. Platform runtimes call this
955    /// from their pause lifecycle event.
956    pub fn notify_app_paused(&mut self) {
957        let app_context = Rc::clone(&self.app_context);
958        app_context.enter(cranpose_ui::text_input_session::notify_app_paused);
959    }
960
961    /// Notifies the framework that the host app resumed/foregrounded.
962    ///
963    /// Never auto-shows the soft keyboard, even for a still-focused field: a
964    /// warm resume keeps the caret but must not resurrect the keyboard (the user
965    /// taps the field to bring it back). Always returns `false` so the platform
966    /// runtime force-hides the OS-restored keyboard. Platform runtimes call this
967    /// from their resume lifecycle event.
968    pub fn notify_app_resumed(&mut self) -> bool {
969        let app_context = Rc::clone(&self.app_context);
970        app_context.enter(cranpose_ui::text_input_session::notify_app_resumed)
971    }
972
973    /// Routes a keyboard event to the focused text field, if any.
974    ///
975    /// Returns `true` if the event was consumed by a text field.
976    ///
977    /// On desktop, Ctrl+C/X/V are handled here when native clipboard support is enabled.
978    /// On web, these keys are NOT handled here - they bubble to browser for native copy/paste events.
979    pub fn on_key_event(&mut self, event: &KeyEvent) -> bool {
980        let _event_handler = enter_event_handler_scope();
981        let app_context = Rc::clone(&self.app_context);
982        app_context.enter(|| self.on_key_event_inner(event))
983    }
984
985    /// Internal keyboard event handler wrapped by on_key_event.
986    fn on_key_event_inner(&mut self, event: &KeyEvent) -> bool {
987        use KeyEventType::KeyDown;
988
989        // Only process KeyDown events for clipboard shortcuts
990        if event.event_type == KeyDown && event.modifiers.command_or_ctrl() {
991            #[cfg(all(
992                feature = "clipboard-native",
993                not(target_arch = "wasm32"),
994                not(target_os = "android"),
995                not(target_os = "ios")
996            ))]
997            {
998                match event.key_code {
999                    // Ctrl+C - Copy
1000                    KeyCode::C => {
1001                        if let Some(text) = self.on_copy_inner() {
1002                            cranpose_ui::clipboard_session::clipboard_write_text(&text);
1003                            return true;
1004                        }
1005                    }
1006                    // Ctrl+X - Cut
1007                    KeyCode::X => {
1008                        if let Some(text) = self.on_cut_inner() {
1009                            cranpose_ui::clipboard_session::clipboard_write_text(&text);
1010                            self.mark_dirty();
1011                            self.request_layout_pass();
1012                            return true;
1013                        }
1014                    }
1015                    // Ctrl+V - Paste
1016                    KeyCode::V => {
1017                        if let Some(text) = cranpose_ui::clipboard_session::clipboard_read_text() {
1018                            if self.on_paste_inner(&text) {
1019                                return true;
1020                            }
1021                        }
1022                    }
1023                    _ => {}
1024                }
1025            }
1026        }
1027
1028        // Pure O(1) dispatch - no tree walking needed
1029        if !cranpose_ui::text_field_focus::has_focused_field() {
1030            return false;
1031        }
1032
1033        // Wrap key event handling in a mutable snapshot so changes are atomically applied.
1034        // This ensures keyboard input modifications are visible to subsequent snapshot contexts
1035        // (like button click handlers that run in their own mutable snapshots).
1036        let handled = run_in_mutable_snapshot(|| {
1037            // O(1) dispatch via stored handler - handles ALL text input key events
1038            // No fallback needed since handler now handles arrows, Home/End, word nav
1039            cranpose_ui::text_field_focus::dispatch_key_event(event)
1040        })
1041        .unwrap_or(false);
1042
1043        if handled {
1044            // Mark both dirty (for redraw) and request a layout pass to rebuild semantics.
1045            self.mark_dirty();
1046            self.request_layout_pass();
1047        }
1048
1049        handled
1050    }
1051
1052    /// Handles paste event from platform clipboard.
1053    /// Returns `true` if the paste was consumed by a focused text field.
1054    /// O(1) operation using stored handler.
1055    pub fn on_paste(&mut self, text: &str) -> bool {
1056        let _event_handler = enter_event_handler_scope();
1057        let app_context = Rc::clone(&self.app_context);
1058        app_context.enter(|| self.on_paste_inner(text))
1059    }
1060
1061    fn on_paste_inner(&mut self, text: &str) -> bool {
1062        // Wrap paste in a mutable snapshot so changes are atomically applied.
1063        // This ensures paste modifications are visible to subsequent snapshot contexts
1064        // (like button click handlers that run in their own mutable snapshots).
1065        let handled =
1066            run_in_mutable_snapshot(|| cranpose_ui::text_field_focus::dispatch_paste(text))
1067                .unwrap_or(false);
1068
1069        if handled {
1070            self.mark_dirty();
1071            self.request_layout_pass();
1072        }
1073
1074        handled
1075    }
1076
1077    /// Handles copy request from platform.
1078    /// Returns the selected text from focused text field, or None.
1079    /// O(1) operation using stored handler.
1080    pub fn on_copy(&mut self) -> Option<String> {
1081        let app_context = Rc::clone(&self.app_context);
1082        app_context.enter(|| self.on_copy_inner())
1083    }
1084
1085    fn on_copy_inner(&mut self) -> Option<String> {
1086        // Use O(1) dispatch instead of tree scan
1087        cranpose_ui::text_field_focus::dispatch_copy()
1088    }
1089
1090    /// Handles cut request from platform.
1091    /// Returns the cut text from focused text field, or None.
1092    /// O(1) operation using stored handler.
1093    pub fn on_cut(&mut self) -> Option<String> {
1094        let _event_handler = enter_event_handler_scope();
1095        let app_context = Rc::clone(&self.app_context);
1096        app_context.enter(|| self.on_cut_inner())
1097    }
1098
1099    fn on_cut_inner(&mut self) -> Option<String> {
1100        let text =
1101            run_in_mutable_snapshot(cranpose_ui::text_field_focus::dispatch_cut).unwrap_or(None);
1102
1103        if text.is_some() {
1104            self.mark_dirty();
1105            self.request_layout_pass();
1106        }
1107
1108        text
1109    }
1110
1111    /// Sets the Linux primary selection (for middle-click paste).
1112    /// This is called when text is selected in a text field.
1113    /// On non-Linux platforms, this is a no-op.
1114    #[cfg(all(
1115        feature = "clipboard-native",
1116        target_os = "linux",
1117        not(target_arch = "wasm32")
1118    ))]
1119    pub fn set_primary_selection(&mut self, text: &str) {
1120        use arboard::{LinuxClipboardKind, SetExtLinux};
1121        if let Some(ref mut clipboard) = self.clipboard {
1122            let result = clipboard
1123                .set()
1124                .clipboard(LinuxClipboardKind::Primary)
1125                .text(text.to_string());
1126            if let Err(e) = result {
1127                // Primary selection may not be available on all systems
1128                log::debug!("Primary selection set failed: {:?}", e);
1129            }
1130        }
1131    }
1132
1133    #[cfg(not(all(
1134        feature = "clipboard-native",
1135        target_os = "linux",
1136        not(target_arch = "wasm32")
1137    )))]
1138    pub fn set_primary_selection(&mut self, _text: &str) {}
1139
1140    /// Gets text from the Linux primary selection (for middle-click paste).
1141    /// On non-Linux platforms, returns None.
1142    #[cfg(all(
1143        feature = "clipboard-native",
1144        target_os = "linux",
1145        not(target_arch = "wasm32")
1146    ))]
1147    pub fn get_primary_selection(&mut self) -> Option<String> {
1148        use arboard::{GetExtLinux, LinuxClipboardKind};
1149        if let Some(ref mut clipboard) = self.clipboard {
1150            clipboard
1151                .get()
1152                .clipboard(LinuxClipboardKind::Primary)
1153                .text()
1154                .ok()
1155        } else {
1156            None
1157        }
1158    }
1159
1160    #[cfg(not(all(
1161        feature = "clipboard-native",
1162        target_os = "linux",
1163        not(target_arch = "wasm32")
1164    )))]
1165    pub fn get_primary_selection(&mut self) -> Option<String> {
1166        None
1167    }
1168
1169    /// Syncs the current text field selection to PRIMARY (Linux X11).
1170    /// Call this when selection changes in a text field.
1171    pub fn sync_selection_to_primary(&mut self) {
1172        #[cfg(all(target_os = "linux", not(target_arch = "wasm32")))]
1173        {
1174            if let Some(text) = self.on_copy() {
1175                self.set_primary_selection(&text);
1176            }
1177        }
1178    }
1179
1180    /// Handles IME preedit (composition) events.
1181    /// Called when the input method is composing text (e.g., typing CJK characters).
1182    ///
1183    /// - `text`: The current preedit text (empty to clear composition state)
1184    /// - `cursor`: Optional cursor position within the preedit text (start, end)
1185    ///
1186    /// Returns `true` if a text field consumed the event.
1187    pub fn on_ime_preedit(&mut self, text: &str, cursor: Option<(usize, usize)>) -> bool {
1188        let _event_handler = enter_event_handler_scope();
1189        let app_context = Rc::clone(&self.app_context);
1190        app_context.enter(|| self.on_ime_preedit_inner(text, cursor))
1191    }
1192
1193    fn on_ime_preedit_inner(&mut self, text: &str, cursor: Option<(usize, usize)>) -> bool {
1194        // Wrap in mutable snapshot for atomic changes
1195        let handled = run_in_mutable_snapshot(|| {
1196            cranpose_ui::text_field_focus::dispatch_ime_preedit(text, cursor)
1197        })
1198        .unwrap_or(false);
1199
1200        if handled {
1201            self.mark_dirty();
1202            // IME composition changes the visible text, needs layout update
1203            self.request_layout_pass();
1204        }
1205
1206        handled
1207    }
1208
1209    /// Finishes the active IME composition, keeping the composed text as
1210    /// committed text (Android `finishComposingText` semantics).
1211    /// Returns `true` if a text field consumed the event.
1212    pub fn on_ime_finish_composing(&mut self) -> bool {
1213        let _event_handler = enter_event_handler_scope();
1214        let app_context = Rc::clone(&self.app_context);
1215        app_context.enter(|| self.on_ime_finish_composing_inner())
1216    }
1217
1218    fn on_ime_finish_composing_inner(&mut self) -> bool {
1219        let handled =
1220            run_in_mutable_snapshot(cranpose_ui::text_field_focus::dispatch_ime_finish_composing)
1221                .unwrap_or(false);
1222
1223        if handled {
1224            self.mark_dirty();
1225            self.request_layout_pass();
1226        }
1227
1228        handled
1229    }
1230
1231    /// Marks existing text in the focused field as the composing region
1232    /// without changing it (Android `setComposingRegion` semantics). Offsets
1233    /// are UTF-8 bytes. Returns `true` if a text field consumed the event.
1234    pub fn on_ime_set_composing_region(&mut self, start_bytes: usize, end_bytes: usize) -> bool {
1235        let _event_handler = enter_event_handler_scope();
1236        let app_context = Rc::clone(&self.app_context);
1237        app_context.enter(|| {
1238            let handled = run_in_mutable_snapshot(|| {
1239                cranpose_ui::text_field_focus::dispatch_ime_set_composing_region(
1240                    start_bytes,
1241                    end_bytes,
1242                )
1243            })
1244            .unwrap_or(false);
1245
1246            if handled {
1247                self.mark_dirty();
1248                self.request_layout_pass();
1249            }
1250
1251            handled
1252        })
1253    }
1254
1255    /// Moves the focused field's selection/caret to `[start_bytes, end_bytes)`
1256    /// without editing text (Android `InputConnection.setSelection`; the path
1257    /// Gboard's spacebar-swipe uses to scrub the cursor). Offsets are UTF-8
1258    /// bytes. Returns `true` if a text field consumed the event.
1259    pub fn on_ime_set_selection(&mut self, start_bytes: usize, end_bytes: usize) -> bool {
1260        let _event_handler = enter_event_handler_scope();
1261        let app_context = Rc::clone(&self.app_context);
1262        app_context.enter(|| {
1263            let handled = run_in_mutable_snapshot(|| {
1264                cranpose_ui::text_field_focus::dispatch_ime_set_selection(start_bytes, end_bytes)
1265            })
1266            .unwrap_or(false);
1267
1268            // A selection-only change never reflows text, so it needs a redraw
1269            // but not a layout pass.
1270            if handled {
1271                self.mark_dirty();
1272            }
1273
1274            handled
1275        })
1276    }
1277
1278    /// Returns a snapshot of the focused text field's editable state for
1279    /// platform IMEs (text, selection and composition in UTF-8 bytes), or
1280    /// `None` when no text field is focused.
1281    pub fn ime_editor_state(&mut self) -> Option<cranpose_ui::text_field_focus::ImeEditorState> {
1282        let app_context = Rc::clone(&self.app_context);
1283        app_context.enter(cranpose_ui::text_field_focus::focused_editor_state)
1284    }
1285
1286    /// Window-space caret geometry of the focused field for coordinate-based
1287    /// platform text input (iOS trackpad cursor + tap-to-position), or `None`
1288    /// when no text field is focused.
1289    pub fn ime_caret_geometry(
1290        &mut self,
1291    ) -> Option<cranpose_ui::text_field_focus::ImeCaretGeometry> {
1292        let app_context = Rc::clone(&self.app_context);
1293        app_context.enter(cranpose_ui::text_field_focus::focused_caret_geometry)
1294    }
1295
1296    /// Clears text-field focus (used by platform IME actions such as
1297    /// Android's Done). The focus-loss notification hides the soft keyboard.
1298    pub fn clear_text_field_focus(&mut self) {
1299        let _event_handler = enter_event_handler_scope();
1300        let app_context = Rc::clone(&self.app_context);
1301        app_context.enter(cranpose_ui::text_field_focus::clear_focus);
1302        self.mark_dirty();
1303        self.request_layout_pass();
1304    }
1305
1306    /// Handles IME delete-surrounding events.
1307    /// Returns `true` if a text field consumed the event.
1308    pub fn on_ime_delete_surrounding(&mut self, before_bytes: usize, after_bytes: usize) -> bool {
1309        let _event_handler = enter_event_handler_scope();
1310        let app_context = Rc::clone(&self.app_context);
1311        app_context.enter(|| self.on_ime_delete_surrounding_inner(before_bytes, after_bytes))
1312    }
1313
1314    fn on_ime_delete_surrounding_inner(&mut self, before_bytes: usize, after_bytes: usize) -> bool {
1315        let handled = run_in_mutable_snapshot(|| {
1316            cranpose_ui::text_field_focus::dispatch_delete_surrounding(before_bytes, after_bytes)
1317        })
1318        .unwrap_or(false);
1319
1320        if handled {
1321            self.mark_dirty();
1322            self.request_layout_pass();
1323        }
1324
1325        handled
1326    }
1327}