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