Skip to main content

cranpose_app_shell/
shell_input.rs

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