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