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