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