Skip to main content

cranpose_app_shell/
shell_input.rs

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