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