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 resolve_gesture_targets(
9        &self,
10        pointer: PointerId,
11    ) -> Vec<<<R as Renderer>::Scene as RenderScene>::HitTarget> {
12        self.resolve_hit_path(pointer)
13    }
14
15    /// Resolves cached NodeIds to fresh HitTargets from the current scene.
16    ///
17    /// This is the key to avoiding stale geometry during scroll/layout changes:
18    /// - We cache NodeIds on PointerDown (stable identity)
19    /// - On Move/Up/Cancel, we call find_target() to get fresh geometry
20    /// - Handler closures are preserved (same Rc), so gesture state survives
21    fn resolve_hit_path(
22        &self,
23        pointer: PointerId,
24    ) -> Vec<<<R as Renderer>::Scene as RenderScene>::HitTarget> {
25        let Some(node_ids) = self.hit_path_tracker.dispatch_order(pointer) else {
26            return Vec::new();
27        };
28
29        let scene = self.renderer.scene();
30        let targets: Vec<_> = node_ids
31            .iter()
32            .filter_map(|&id| scene.find_target(id))
33            .collect();
34        log::trace!(
35            target: "cranpose::input",
36            "resolve_hit_path pointer={pointer:?} cached={node_ids:?} resolved_count={}",
37            targets.len()
38        );
39        targets
40    }
41
42    fn dispatch_targets<I>(&mut self, targets: I, event: PointerEvent, stop_on_consume: bool)
43    where
44        I: IntoIterator<Item = <<R as Renderer>::Scene as RenderScene>::HitTarget>,
45    {
46        let mut applier = self.composition.applier_mut();
47        for target in targets {
48            let node_id = target.node_id();
49            target.dispatch_with_applier(&mut applier, event.clone());
50            log::trace!(
51                target: "cranpose::input",
52                "dispatch {:?} node={} consumed={} stop_on_consume={}",
53                event.kind,
54                node_id,
55                event.is_consumed(),
56                stop_on_consume,
57            );
58            if stop_on_consume && event.is_consumed() {
59                break;
60            }
61        }
62    }
63
64    pub fn set_cursor(&mut self, x: f32, y: f32) -> bool {
65        let _event_handler = enter_event_handler_scope();
66        let app_context = Rc::clone(&self.app_context);
67        let result = app_context
68            .enter(|| run_in_mutable_snapshot(|| self.set_cursor_inner(x, y)).unwrap_or(false));
69        if result {
70            self.mark_dirty();
71        }
72        log::trace!(
73            target: "cranpose::input",
74            "set_cursor ({x:.2},{y:.2}) -> {result}"
75        );
76        result
77    }
78
79    fn set_cursor_inner(&mut self, x: f32, y: f32) -> bool {
80        self.cursor = (x, y);
81
82        // During a gesture (button pressed), ONLY dispatch to the tracked hit path.
83        // Never fall back to hover hit-testing while buttons are down.
84        // This maintains the invariant: the path that receives Down must receive Move and Up/Cancel.
85        if self.buttons_pressed != PointerButtons::NONE {
86            if self.hit_path_tracker.has_path(PointerId::PRIMARY) {
87                let targets = self.resolve_gesture_targets(PointerId::PRIMARY);
88                if !targets.is_empty() {
89                    let event =
90                        PointerEvent::new(PointerEventKind::Move, Point { x, y }, Point { x, y })
91                            .with_buttons(self.buttons_pressed);
92                    self.dispatch_targets(targets, event, false);
93                    return true;
94                }
95
96                return false;
97            }
98
99            // Button is down but we have no recorded path inside this app
100            // (e.g. drag started outside). Do not dispatch anything.
101            return false;
102        }
103
104        // No gesture in progress: regular hover move using hit-test.
105        // Diff against previous hover set to synthesize Enter/Exit events.
106        let hits = self.renderer.scene().hit_test(x, y);
107        let new_ids: Vec<NodeId> = hits.iter().map(|h| h.node_id()).collect();
108
109        // Dispatch Exit to nodes that are no longer hovered
110        let pos = Point { x, y };
111        let previously_hovered = self.hovered_nodes.clone();
112        for old_id in previously_hovered {
113            if !new_ids.contains(&old_id) {
114                if let Some(target) = self.renderer.scene().find_target(old_id) {
115                    let exit_event = PointerEvent::new(PointerEventKind::Exit, pos, pos)
116                        .with_buttons(self.buttons_pressed);
117                    self.dispatch_targets(std::iter::once(target), exit_event, false);
118                }
119            }
120        }
121
122        // Dispatch Enter to newly hovered nodes
123        for hit in &hits {
124            if !self.hovered_nodes.contains(&hit.node_id()) {
125                let enter_event = PointerEvent::new(PointerEventKind::Enter, pos, pos)
126                    .with_buttons(self.buttons_pressed);
127                self.dispatch_targets(std::iter::once(hit.clone()), enter_event, false);
128            }
129        }
130
131        self.hovered_nodes = new_ids;
132
133        if !hits.is_empty() {
134            let event = PointerEvent::new(PointerEventKind::Move, pos, pos)
135                .with_buttons(self.buttons_pressed);
136            self.dispatch_targets(hits, event, true);
137            true
138        } else {
139            false
140        }
141    }
142
143    pub fn pointer_pressed(&mut self) -> bool {
144        let _event_handler = enter_event_handler_scope();
145        let app_context = Rc::clone(&self.app_context);
146        let result = app_context
147            .enter(|| run_in_mutable_snapshot(|| self.pointer_pressed_inner()).unwrap_or(false));
148        if result {
149            self.mark_dirty();
150        }
151        log::trace!(target: "cranpose::input", "pointer_pressed -> {result}");
152        result
153    }
154
155    fn pointer_pressed_inner(&mut self) -> bool {
156        // Track button state
157        self.buttons_pressed.insert(PointerButton::Primary);
158
159        // Hit-test against the current (last rendered) scene.
160        // Even if the app is dirty, this scene is what the user actually saw and clicked.
161        // Frame N is rendered → user sees frame N and taps → we hit-test frame N's geometry.
162        // The pointer event may mark dirty → next frame runs update() → renders N+1.
163
164        // Perform hit test and cache the NodeIds (not geometry!)
165        // The key insight from Jetpack Compose: cache identity, resolve fresh geometry per dispatch
166        let hits = self.renderer.scene().hit_test(self.cursor.0, self.cursor.1);
167        if hits.is_empty() {
168            self.hit_path_tracker.remove_path(PointerId::PRIMARY);
169            false
170        } else {
171            let event = PointerEvent::new(
172                PointerEventKind::Down,
173                Point {
174                    x: self.cursor.0,
175                    y: self.cursor.1,
176                },
177                Point {
178                    x: self.cursor.0,
179                    y: self.cursor.1,
180                },
181            )
182            .with_buttons(self.buttons_pressed);
183
184            let mut delivered_capture_paths = Vec::new();
185            let mut applier = self.composition.applier_mut();
186            for hit in hits {
187                let node_id = hit.node_id();
188                delivered_capture_paths.push(hit.capture_path());
189                hit.dispatch_with_applier(&mut applier, event.clone());
190                log::trace!(
191                    target: "cranpose::input",
192                    "dispatch {:?} node={} consumed={} stop_on_consume=true",
193                    event.kind,
194                    node_id,
195                    event.is_consumed(),
196                );
197                if event.is_consumed() {
198                    break;
199                }
200            }
201
202            self.hit_path_tracker
203                .add_hit_path(PointerId::PRIMARY, delivered_capture_paths);
204            log::trace!(
205                target: "cranpose::input",
206                "pointer_pressed_inner cached_hit_path={:?}",
207                self.hit_path_tracker.get_path(PointerId::PRIMARY),
208            );
209
210            true
211        }
212    }
213
214    pub fn pointer_released(&mut self) -> bool {
215        let _event_handler = enter_event_handler_scope();
216        let app_context = Rc::clone(&self.app_context);
217        let result = app_context
218            .enter(|| run_in_mutable_snapshot(|| self.pointer_released_inner()).unwrap_or(false));
219        if result {
220            self.mark_dirty();
221        }
222        log::trace!(target: "cranpose::input", "pointer_released -> {result}");
223        result
224    }
225
226    fn pointer_released_inner(&mut self) -> bool {
227        // UP events report buttons as "currently pressed" (after release),
228        // matching typical platform semantics where primary is already gone.
229        self.buttons_pressed.remove(PointerButton::Primary);
230        let corrected_buttons = self.buttons_pressed;
231        let targets = self.resolve_gesture_targets(PointerId::PRIMARY);
232
233        // Always remove the path, even if targets is empty (node may have been removed)
234        self.hit_path_tracker.remove_path(PointerId::PRIMARY);
235
236        if !targets.is_empty() {
237            let event = PointerEvent::new(
238                PointerEventKind::Up,
239                Point {
240                    x: self.cursor.0,
241                    y: self.cursor.1,
242                },
243                Point {
244                    x: self.cursor.0,
245                    y: self.cursor.1,
246                },
247            )
248            .with_buttons(corrected_buttons);
249
250            self.dispatch_targets(targets, event, false);
251            true
252        } else {
253            false
254        }
255    }
256
257    /// Dispatches a mouse wheel / trackpad scroll event to hovered pointer handlers.
258    ///
259    /// Returns `true` if a handler consumed the event.
260    pub fn pointer_scrolled(&mut self, delta_x: f32, delta_y: f32) -> bool {
261        let _event_handler = enter_event_handler_scope();
262        let app_context = Rc::clone(&self.app_context);
263        let result = app_context.enter(|| {
264            run_in_mutable_snapshot(|| self.pointer_scrolled_inner(delta_x, delta_y))
265                .unwrap_or(false)
266        });
267        if result {
268            self.mark_dirty();
269        }
270        log::trace!(
271            target: "cranpose::input",
272            "pointer_scrolled ({delta_x:.2},{delta_y:.2}) -> {result}"
273        );
274        result
275    }
276
277    fn pointer_scrolled_inner(&mut self, delta_x: f32, delta_y: f32) -> bool {
278        if delta_x.abs() <= f32::EPSILON && delta_y.abs() <= f32::EPSILON {
279            return false;
280        }
281
282        let hits = self.renderer.scene().hit_test(self.cursor.0, self.cursor.1);
283        if hits.is_empty() {
284            return false;
285        }
286
287        let event = PointerEvent::new(
288            PointerEventKind::Scroll,
289            Point {
290                x: self.cursor.0,
291                y: self.cursor.1,
292            },
293            Point {
294                x: self.cursor.0,
295                y: self.cursor.1,
296            },
297        )
298        .with_buttons(self.buttons_pressed)
299        .with_scroll_delta(Point {
300            x: delta_x,
301            y: delta_y,
302        });
303
304        let capture_paths = hits
305            .iter()
306            .map(|hit| hit.capture_path())
307            .collect::<Vec<_>>();
308        let targets = crate::hit_path_tracker::dispatch_order_for_paths(&capture_paths)
309            .into_iter()
310            .filter_map(|node_id| self.renderer.scene().find_target(node_id))
311            .collect::<Vec<_>>();
312
313        self.dispatch_targets(targets, event.clone(), true);
314
315        event.is_consumed()
316    }
317
318    /// Cancels any active gesture, dispatching Cancel events to cached targets.
319    /// Call this when:
320    /// - Window loses focus
321    /// - Mouse leaves window while button pressed
322    /// - Any other gesture abort scenario
323    pub fn cancel_gesture(&mut self) {
324        let _event_handler = enter_event_handler_scope();
325        let app_context = Rc::clone(&self.app_context);
326        let _ = app_context.enter(|| {
327            run_in_mutable_snapshot(|| {
328                self.cancel_gesture_inner();
329            })
330        });
331    }
332
333    fn cancel_gesture_inner(&mut self) {
334        let targets = self.resolve_gesture_targets(PointerId::PRIMARY);
335
336        // Clear tracker and button state
337        self.hit_path_tracker.clear();
338        self.buttons_pressed = PointerButtons::NONE;
339
340        if !targets.is_empty() {
341            let event = PointerEvent::new(
342                PointerEventKind::Cancel,
343                Point {
344                    x: self.cursor.0,
345                    y: self.cursor.1,
346                },
347                Point {
348                    x: self.cursor.0,
349                    y: self.cursor.1,
350                },
351            );
352
353            self.dispatch_targets(targets, event, false);
354        }
355
356        // Dispatch Exit to all previously hovered nodes
357        let pos = Point {
358            x: self.cursor.0,
359            y: self.cursor.1,
360        };
361        let hovered_nodes = self.hovered_nodes.clone();
362        for node_id in hovered_nodes {
363            if let Some(target) = self.renderer.scene().find_target(node_id) {
364                let exit_event = PointerEvent::new(PointerEventKind::Exit, pos, pos);
365                self.dispatch_targets(std::iter::once(target), exit_event, false);
366            }
367        }
368        self.hovered_nodes.clear();
369    }
370
371    /// Routes a keyboard event to the focused text field, if any.
372    ///
373    /// Returns `true` if the event was consumed by a text field.
374    ///
375    /// On desktop, Ctrl+C/X/V are handled here when native clipboard support is enabled.
376    /// On web, these keys are NOT handled here - they bubble to browser for native copy/paste events.
377    pub fn on_key_event(&mut self, event: &KeyEvent) -> bool {
378        let _event_handler = enter_event_handler_scope();
379        let app_context = Rc::clone(&self.app_context);
380        app_context.enter(|| self.on_key_event_inner(event))
381    }
382
383    /// Internal keyboard event handler wrapped by on_key_event.
384    fn on_key_event_inner(&mut self, event: &KeyEvent) -> bool {
385        use KeyEventType::KeyDown;
386
387        // Only process KeyDown events for clipboard shortcuts
388        if event.event_type == KeyDown && event.modifiers.command_or_ctrl() {
389            // Use persistent self.clipboard to keep content alive on Linux X11.
390            #[cfg(all(
391                feature = "clipboard-native",
392                not(target_arch = "wasm32"),
393                not(target_os = "android"),
394                not(target_os = "ios")
395            ))]
396            {
397                match event.key_code {
398                    // Ctrl+C - Copy
399                    KeyCode::C => {
400                        // Get text first, then access clipboard to avoid borrow conflict
401                        let text = self.on_copy_inner();
402                        if let (Some(text), Some(clipboard)) = (text, self.clipboard.as_mut()) {
403                            let _ = clipboard.set_text(&text);
404                            return true;
405                        }
406                    }
407                    // Ctrl+X - Cut
408                    KeyCode::X => {
409                        // Get text first (this also deletes it), then access clipboard
410                        let text = self.on_cut_inner();
411                        if let (Some(text), Some(clipboard)) = (text, self.clipboard.as_mut()) {
412                            let _ = clipboard.set_text(&text);
413                            self.mark_dirty();
414                            self.request_layout_pass();
415                            return true;
416                        }
417                    }
418                    // Ctrl+V - Paste
419                    KeyCode::V => {
420                        // Get text from clipboard first, then paste
421                        let text = self.clipboard.as_mut().and_then(|cb| cb.get_text().ok());
422                        if let Some(text) = text {
423                            if self.on_paste_inner(&text) {
424                                return true;
425                            }
426                        }
427                    }
428                    _ => {}
429                }
430            }
431        }
432
433        // Pure O(1) dispatch - no tree walking needed
434        if !cranpose_ui::text_field_focus::has_focused_field() {
435            return false;
436        }
437
438        // Wrap key event handling in a mutable snapshot so changes are atomically applied.
439        // This ensures keyboard input modifications are visible to subsequent snapshot contexts
440        // (like button click handlers that run in their own mutable snapshots).
441        let handled = run_in_mutable_snapshot(|| {
442            // O(1) dispatch via stored handler - handles ALL text input key events
443            // No fallback needed since handler now handles arrows, Home/End, word nav
444            cranpose_ui::text_field_focus::dispatch_key_event(event)
445        })
446        .unwrap_or(false);
447
448        if handled {
449            // Mark both dirty (for redraw) and request a layout pass to rebuild semantics.
450            self.mark_dirty();
451            self.request_layout_pass();
452        }
453
454        handled
455    }
456
457    /// Handles paste event from platform clipboard.
458    /// Returns `true` if the paste was consumed by a focused text field.
459    /// O(1) operation using stored handler.
460    pub fn on_paste(&mut self, text: &str) -> bool {
461        let _event_handler = enter_event_handler_scope();
462        let app_context = Rc::clone(&self.app_context);
463        app_context.enter(|| self.on_paste_inner(text))
464    }
465
466    fn on_paste_inner(&mut self, text: &str) -> bool {
467        // Wrap paste in a mutable snapshot so changes are atomically applied.
468        // This ensures paste modifications are visible to subsequent snapshot contexts
469        // (like button click handlers that run in their own mutable snapshots).
470        let handled =
471            run_in_mutable_snapshot(|| cranpose_ui::text_field_focus::dispatch_paste(text))
472                .unwrap_or(false);
473
474        if handled {
475            self.mark_dirty();
476            self.request_layout_pass();
477        }
478
479        handled
480    }
481
482    /// Handles copy request from platform.
483    /// Returns the selected text from focused text field, or None.
484    /// O(1) operation using stored handler.
485    pub fn on_copy(&mut self) -> Option<String> {
486        let app_context = Rc::clone(&self.app_context);
487        app_context.enter(|| self.on_copy_inner())
488    }
489
490    fn on_copy_inner(&mut self) -> Option<String> {
491        // Use O(1) dispatch instead of tree scan
492        cranpose_ui::text_field_focus::dispatch_copy()
493    }
494
495    /// Handles cut request from platform.
496    /// Returns the cut text from focused text field, or None.
497    /// O(1) operation using stored handler.
498    pub fn on_cut(&mut self) -> Option<String> {
499        let _event_handler = enter_event_handler_scope();
500        let app_context = Rc::clone(&self.app_context);
501        app_context.enter(|| self.on_cut_inner())
502    }
503
504    fn on_cut_inner(&mut self) -> Option<String> {
505        let text =
506            run_in_mutable_snapshot(cranpose_ui::text_field_focus::dispatch_cut).unwrap_or(None);
507
508        if text.is_some() {
509            self.mark_dirty();
510            self.request_layout_pass();
511        }
512
513        text
514    }
515
516    /// Sets the Linux primary selection (for middle-click paste).
517    /// This is called when text is selected in a text field.
518    /// On non-Linux platforms, this is a no-op.
519    #[cfg(all(
520        feature = "clipboard-native",
521        target_os = "linux",
522        not(target_arch = "wasm32")
523    ))]
524    pub fn set_primary_selection(&mut self, text: &str) {
525        use arboard::{LinuxClipboardKind, SetExtLinux};
526        if let Some(ref mut clipboard) = self.clipboard {
527            let result = clipboard
528                .set()
529                .clipboard(LinuxClipboardKind::Primary)
530                .text(text.to_string());
531            if let Err(e) = result {
532                // Primary selection may not be available on all systems
533                log::debug!("Primary selection set failed: {:?}", e);
534            }
535        }
536    }
537
538    #[cfg(not(all(
539        feature = "clipboard-native",
540        target_os = "linux",
541        not(target_arch = "wasm32")
542    )))]
543    pub fn set_primary_selection(&mut self, _text: &str) {}
544
545    /// Gets text from the Linux primary selection (for middle-click paste).
546    /// On non-Linux platforms, returns None.
547    #[cfg(all(
548        feature = "clipboard-native",
549        target_os = "linux",
550        not(target_arch = "wasm32")
551    ))]
552    pub fn get_primary_selection(&mut self) -> Option<String> {
553        use arboard::{GetExtLinux, LinuxClipboardKind};
554        if let Some(ref mut clipboard) = self.clipboard {
555            clipboard
556                .get()
557                .clipboard(LinuxClipboardKind::Primary)
558                .text()
559                .ok()
560        } else {
561            None
562        }
563    }
564
565    #[cfg(not(all(
566        feature = "clipboard-native",
567        target_os = "linux",
568        not(target_arch = "wasm32")
569    )))]
570    pub fn get_primary_selection(&mut self) -> Option<String> {
571        None
572    }
573
574    /// Syncs the current text field selection to PRIMARY (Linux X11).
575    /// Call this when selection changes in a text field.
576    pub fn sync_selection_to_primary(&mut self) {
577        #[cfg(all(target_os = "linux", not(target_arch = "wasm32")))]
578        {
579            if let Some(text) = self.on_copy() {
580                self.set_primary_selection(&text);
581            }
582        }
583    }
584
585    /// Handles IME preedit (composition) events.
586    /// Called when the input method is composing text (e.g., typing CJK characters).
587    ///
588    /// - `text`: The current preedit text (empty to clear composition state)
589    /// - `cursor`: Optional cursor position within the preedit text (start, end)
590    ///
591    /// Returns `true` if a text field consumed the event.
592    pub fn on_ime_preedit(&mut self, text: &str, cursor: Option<(usize, usize)>) -> bool {
593        let _event_handler = enter_event_handler_scope();
594        let app_context = Rc::clone(&self.app_context);
595        app_context.enter(|| self.on_ime_preedit_inner(text, cursor))
596    }
597
598    fn on_ime_preedit_inner(&mut self, text: &str, cursor: Option<(usize, usize)>) -> bool {
599        // Wrap in mutable snapshot for atomic changes
600        let handled = run_in_mutable_snapshot(|| {
601            cranpose_ui::text_field_focus::dispatch_ime_preedit(text, cursor)
602        })
603        .unwrap_or(false);
604
605        if handled {
606            self.mark_dirty();
607            // IME composition changes the visible text, needs layout update
608            self.request_layout_pass();
609        }
610
611        handled
612    }
613
614    /// Handles IME delete-surrounding events.
615    /// Returns `true` if a text field consumed the event.
616    pub fn on_ime_delete_surrounding(&mut self, before_bytes: usize, after_bytes: usize) -> bool {
617        let _event_handler = enter_event_handler_scope();
618        let app_context = Rc::clone(&self.app_context);
619        app_context.enter(|| self.on_ime_delete_surrounding_inner(before_bytes, after_bytes))
620    }
621
622    fn on_ime_delete_surrounding_inner(&mut self, before_bytes: usize, after_bytes: usize) -> bool {
623        let handled = run_in_mutable_snapshot(|| {
624            cranpose_ui::text_field_focus::dispatch_delete_surrounding(before_bytes, after_bytes)
625        })
626        .unwrap_or(false);
627
628        if handled {
629            self.mark_dirty();
630            self.request_layout_pass();
631        }
632
633        handled
634    }
635}