Skip to main content

cranpose_app_shell/
shell_input.rs

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