Skip to main content

cranpose_app_shell/
lib.rs

1#![allow(clippy::type_complexity)]
2
3mod fps_monitor;
4mod hit_path_tracker;
5
6// Re-export FPS monitoring API
7pub use fps_monitor::{
8    current_fps, fps_display, fps_display_detailed, fps_stats, record_recomposition, FpsStats,
9};
10
11use std::fmt::Debug;
12use std::sync::OnceLock;
13// Use web_time for cross-platform time support (native + WASM) - compatible with winit
14use web_time::Instant;
15
16use cranpose_core::{
17    enter_event_handler, exit_event_handler, location_key, run_in_mutable_snapshot, Applier,
18    Composition, Key, MemoryApplier, NodeError, NodeId,
19};
20use cranpose_foundation::{PointerButton, PointerButtons, PointerEvent, PointerEventKind};
21use cranpose_render_common::{HitTestTarget, RenderScene, Renderer};
22use cranpose_runtime_std::StdRuntime;
23use cranpose_ui::{
24    has_pending_focus_invalidations, has_pending_pointer_repasses, log_layout_tree,
25    log_render_scene, log_screen_summary, peek_focus_invalidation, peek_layout_invalidation,
26    peek_pointer_invalidation, peek_render_invalidation, process_focus_invalidations,
27    process_pointer_repasses, request_render_invalidation, take_draw_repass_nodes,
28    take_focus_invalidation, take_layout_invalidation, take_pointer_invalidation,
29    take_render_invalidation, HeadlessRenderer, LayoutNode, LayoutTree, MeasureLayoutOptions,
30    SemanticsTree, SubcomposeLayoutNode,
31};
32use cranpose_ui_graphics::{Point, Size};
33use hit_path_tracker::{HitPathTracker, PointerId};
34use std::collections::HashSet;
35
36// Re-export key event types for use by cranpose
37pub use cranpose_ui::{KeyCode, KeyEvent, KeyEventType, Modifiers};
38
39#[derive(Copy, Clone)]
40enum DispatchInvalidationKind {
41    Pointer,
42    Focus,
43}
44
45pub struct AppShell<R>
46where
47    R: Renderer,
48{
49    runtime: StdRuntime,
50    composition: Composition<MemoryApplier>,
51    renderer: R,
52    cursor: (f32, f32),
53    viewport: (f32, f32),
54    buffer_size: (u32, u32),
55    start_time: Instant,
56    layout_tree: Option<LayoutTree>,
57    semantics_tree: Option<SemanticsTree>,
58    semantics_enabled: bool,
59    layout_dirty: bool,
60    scene_dirty: bool,
61    is_dirty: bool,
62    /// Tracks which mouse buttons are currently pressed
63    buttons_pressed: PointerButtons,
64    /// Tracks which nodes were hit on PointerDown (by stable NodeId).
65    ///
66    /// This follows Jetpack Compose's HitPathTracker pattern:
67    /// - On Down: cache NodeIds, not geometry
68    /// - On Move/Up/Cancel: resolve fresh HitTargets from current scene
69    /// - Handler closures are preserved (same Rc), so internal state survives
70    hit_path_tracker: HitPathTracker,
71    /// Tracks which nodes the pointer is currently hovering over.
72    /// Used to synthesize Enter/Exit events when the hover set changes.
73    hovered_nodes: Vec<NodeId>,
74    /// Persistent clipboard for desktop (Linux X11 requires clipboard to stay alive)
75    #[cfg(all(
76        not(target_arch = "wasm32"),
77        not(target_os = "android"),
78        not(target_os = "ios")
79    ))]
80    clipboard: Option<arboard::Clipboard>,
81    /// Dev options for debugging and performance monitoring
82    dev_options: DevOptions,
83}
84
85/// Development options for debugging and performance monitoring.
86///
87/// These are rendered directly by the renderer (not via composition)
88/// to avoid affecting performance measurements.
89#[derive(Clone, Debug, Default)]
90pub struct DevOptions {
91    /// Show FPS counter overlay
92    pub fps_counter: bool,
93    /// Show recomposition count
94    pub recomposition_counter: bool,
95    /// Show layout timing breakdown
96    pub layout_timing: bool,
97}
98
99fn input_pipeline_debug_enabled() -> bool {
100    static ENABLED: OnceLock<bool> = OnceLock::new();
101    *ENABLED.get_or_init(|| std::env::var_os("CRANPOSE_INPUT_DEBUG").is_some())
102}
103
104impl<R> AppShell<R>
105where
106    R: Renderer,
107    R::Error: Debug,
108{
109    pub fn new(mut renderer: R, root_key: Key, content: impl FnMut() + 'static) -> Self {
110        // Initialize FPS tracking
111        fps_monitor::init_fps_tracker();
112
113        let runtime = StdRuntime::new();
114        let mut composition = Composition::with_runtime(MemoryApplier::new(), runtime.runtime());
115        let build = content;
116        if let Err(err) = composition.render(root_key, build) {
117            log::error!("initial render failed: {err}");
118        }
119        renderer.scene_mut().clear();
120        let mut shell = Self {
121            runtime,
122            composition,
123            renderer,
124            cursor: (0.0, 0.0),
125            viewport: (800.0, 600.0),
126            buffer_size: (800, 600),
127            start_time: Instant::now(),
128            layout_tree: None,
129            semantics_tree: None,
130            semantics_enabled: false,
131            layout_dirty: true,
132            scene_dirty: true,
133            is_dirty: true,
134            buttons_pressed: PointerButtons::NONE,
135            hit_path_tracker: HitPathTracker::new(),
136            hovered_nodes: Vec::new(),
137            #[cfg(all(
138                not(target_arch = "wasm32"),
139                not(target_os = "android"),
140                not(target_os = "ios")
141            ))]
142            clipboard: arboard::Clipboard::new().ok(),
143            dev_options: DevOptions::default(),
144        };
145        shell.process_frame();
146        shell
147    }
148
149    /// Set development options for debugging and performance monitoring.
150    ///
151    /// The FPS counter and other overlays are rendered directly by the renderer
152    /// (not via composition) to avoid affecting performance measurements.
153    pub fn set_dev_options(&mut self, options: DevOptions) {
154        self.dev_options = options;
155    }
156
157    /// Get a reference to the current dev options.
158    pub fn dev_options(&self) -> &DevOptions {
159        &self.dev_options
160    }
161
162    pub fn set_viewport(&mut self, width: f32, height: f32) {
163        self.viewport = (width, height);
164        self.layout_dirty = true;
165        self.mark_dirty();
166        self.process_frame();
167    }
168
169    pub fn set_buffer_size(&mut self, width: u32, height: u32) {
170        self.buffer_size = (width, height);
171    }
172
173    pub fn buffer_size(&self) -> (u32, u32) {
174        self.buffer_size
175    }
176
177    pub fn scene(&self) -> &R::Scene {
178        self.renderer.scene()
179    }
180
181    pub fn renderer(&mut self) -> &mut R {
182        &mut self.renderer
183    }
184
185    #[cfg(not(target_arch = "wasm32"))]
186    pub fn set_frame_waker(&mut self, waker: impl Fn() + Send + Sync + 'static) {
187        self.runtime.set_frame_waker(waker);
188    }
189
190    #[cfg(target_arch = "wasm32")]
191    pub fn set_frame_waker(&mut self, waker: impl Fn() + Send + 'static) {
192        self.runtime.set_frame_waker(waker);
193    }
194
195    pub fn clear_frame_waker(&mut self) {
196        self.runtime.clear_frame_waker();
197    }
198
199    pub fn should_render(&self) -> bool {
200        if self.layout_dirty
201            || self.scene_dirty
202            || peek_render_invalidation()
203            || peek_pointer_invalidation()
204            || peek_focus_invalidation()
205            || peek_layout_invalidation()
206        {
207            return true;
208        }
209        self.runtime.take_frame_request() || self.composition.should_render()
210    }
211
212    /// Returns true if the shell needs to redraw (dirty flag, layout dirty, active animations).
213    /// Note: Cursor blink is now timer-based and uses WaitUntil scheduling, not continuous redraw.
214    pub fn needs_redraw(&self) -> bool {
215        if self.is_dirty
216            || self.layout_dirty
217            || self.scene_dirty
218            || peek_render_invalidation()
219            || peek_pointer_invalidation()
220            || peek_focus_invalidation()
221            || peek_layout_invalidation()
222            || cranpose_ui::has_pending_layout_repasses()
223            || cranpose_ui::has_pending_draw_repasses()
224            || has_pending_pointer_repasses()
225            || has_pending_focus_invalidations()
226        {
227            return true;
228        }
229
230        self.composition.should_render()
231    }
232
233    /// Marks the shell as dirty, indicating a redraw is needed.
234    pub fn mark_dirty(&mut self) {
235        self.is_dirty = true;
236    }
237
238    /// Returns true if there are active animations or pending recompositions.
239    pub fn has_active_animations(&self) -> bool {
240        self.runtime.take_frame_request() || self.composition.should_render()
241    }
242
243    /// Returns the next scheduled event time for cursor blink.
244    /// Use this for `ControlFlow::WaitUntil` scheduling.
245    pub fn next_event_time(&self) -> Option<web_time::Instant> {
246        cranpose_ui::next_cursor_blink_time()
247    }
248
249    /// Resolves cached NodeIds to fresh HitTargets from the current scene.
250    ///
251    /// This is the key to avoiding stale geometry during scroll/layout changes:
252    /// - We cache NodeIds on PointerDown (stable identity)
253    /// - On Move/Up/Cancel, we call find_target() to get fresh geometry
254    /// - Handler closures are preserved (same Rc), so gesture state survives
255    fn resolve_hit_path(
256        &self,
257        pointer: PointerId,
258    ) -> Vec<<<R as Renderer>::Scene as RenderScene>::HitTarget> {
259        let Some(node_ids) = self.hit_path_tracker.get_path(pointer) else {
260            return Vec::new();
261        };
262
263        let scene = self.renderer.scene();
264        node_ids
265            .iter()
266            .filter_map(|&id| scene.find_target(id))
267            .collect()
268    }
269
270    pub fn update(&mut self) {
271        let now = Instant::now();
272        let frame_time = now
273            .checked_duration_since(self.start_time)
274            .unwrap_or_default()
275            .as_nanos() as u64;
276        self.runtime.drain_frame_callbacks(frame_time);
277        self.runtime.runtime_handle().drain_ui();
278        let should_render = self.composition.should_render();
279        if input_pipeline_debug_enabled() && should_render {
280            eprintln!(
281                "[CRANPOSE_INPUT_DEBUG] update begin: should_render=true layout_dirty={} scene_dirty={} is_dirty={}",
282                self.layout_dirty, self.scene_dirty, self.is_dirty
283            );
284        }
285        if should_render {
286            match self.composition.process_invalid_scopes() {
287                Ok(changed) => {
288                    if input_pipeline_debug_enabled() {
289                        eprintln!(
290                            "[CRANPOSE_INPUT_DEBUG] process_invalid_scopes changed={}",
291                            changed
292                        );
293                    }
294                    if changed {
295                        fps_monitor::record_recomposition();
296                        self.layout_dirty = true;
297                        // Force root needs_measure since bubbling may fail for
298                        // subcomposition nodes with broken parent chains (node 226 issue)
299                        if let Some(root_id) = self.composition.root() {
300                            let _ = self.composition.applier_mut().with_node::<LayoutNode, _>(
301                                root_id,
302                                |node| {
303                                    node.mark_needs_measure();
304                                },
305                            );
306                        }
307                        request_render_invalidation();
308                    }
309                }
310                Err(NodeError::Missing { id }) => {
311                    // Node was removed (likely due to conditional render or tab switch)
312                    // This is expected when scopes try to recompose after their nodes are gone
313                    log::debug!("Recomposition skipped: node {} no longer exists", id);
314                    self.layout_dirty = true;
315                    request_render_invalidation();
316                }
317                Err(err) => {
318                    log::error!("recomposition failed: {err}");
319                    self.layout_dirty = true;
320                    request_render_invalidation();
321                }
322            }
323        }
324        self.process_frame();
325        // Clear dirty flag after update (frame has been processed)
326        self.is_dirty = false;
327    }
328
329    pub fn set_cursor(&mut self, x: f32, y: f32) -> bool {
330        enter_event_handler();
331        let result = run_in_mutable_snapshot(|| self.set_cursor_inner(x, y)).unwrap_or(false);
332        exit_event_handler();
333        if input_pipeline_debug_enabled() {
334            eprintln!(
335                "[CRANPOSE_INPUT_DEBUG] set_cursor ({:.2},{:.2}) -> {}",
336                x, y, result
337            );
338        }
339        result
340    }
341
342    fn set_cursor_inner(&mut self, x: f32, y: f32) -> bool {
343        self.cursor = (x, y);
344
345        // During a gesture (button pressed), ONLY dispatch to the tracked hit path.
346        // Never fall back to hover hit-testing while buttons are down.
347        // This maintains the invariant: the path that receives Down must receive Move and Up/Cancel.
348        if self.buttons_pressed != PointerButtons::NONE {
349            if self.hit_path_tracker.has_path(PointerId::PRIMARY) {
350                // Resolve fresh targets from current scene (not cached geometry!)
351                let targets = self.resolve_hit_path(PointerId::PRIMARY);
352
353                if !targets.is_empty() {
354                    let event =
355                        PointerEvent::new(PointerEventKind::Move, Point { x, y }, Point { x, y })
356                            .with_buttons(self.buttons_pressed);
357
358                    for hit in targets {
359                        hit.dispatch(event.clone());
360                        if event.is_consumed() {
361                            break;
362                        }
363                    }
364                    return true;
365                }
366
367                // Gesture exists but we can't resolve any nodes (removed / no hit region).
368                // Fall back to a fresh hit test so gestures can continue after node disposal.
369                let hits = self.renderer.scene().hit_test(x, y);
370                if !hits.is_empty() {
371                    let node_ids: Vec<_> = hits.iter().map(|h| h.node_id()).collect();
372                    self.hit_path_tracker
373                        .add_hit_path(PointerId::PRIMARY, node_ids);
374                    let event =
375                        PointerEvent::new(PointerEventKind::Move, Point { x, y }, Point { x, y })
376                            .with_buttons(self.buttons_pressed);
377                    for hit in hits {
378                        hit.dispatch(event.clone());
379                        if event.is_consumed() {
380                            break;
381                        }
382                    }
383                    return true;
384                }
385                return false;
386            }
387
388            // Button is down but we have no recorded path inside this app
389            // (e.g. drag started outside). Do not dispatch anything.
390            return false;
391        }
392
393        // No gesture in progress: regular hover move using hit-test.
394        // Diff against previous hover set to synthesize Enter/Exit events.
395        let hits = self.renderer.scene().hit_test(x, y);
396        let new_ids: Vec<NodeId> = hits.iter().map(|h| h.node_id()).collect();
397
398        // Dispatch Exit to nodes that are no longer hovered
399        let pos = Point { x, y };
400        for &old_id in &self.hovered_nodes {
401            if !new_ids.contains(&old_id) {
402                if let Some(target) = self.renderer.scene().find_target(old_id) {
403                    let exit_event = PointerEvent::new(PointerEventKind::Exit, pos, pos)
404                        .with_buttons(self.buttons_pressed);
405                    target.dispatch(exit_event);
406                }
407            }
408        }
409
410        // Dispatch Enter to newly hovered nodes
411        for hit in &hits {
412            if !self.hovered_nodes.contains(&hit.node_id()) {
413                let enter_event = PointerEvent::new(PointerEventKind::Enter, pos, pos)
414                    .with_buttons(self.buttons_pressed);
415                hit.dispatch(enter_event);
416            }
417        }
418
419        self.hovered_nodes = new_ids;
420
421        if !hits.is_empty() {
422            let event = PointerEvent::new(PointerEventKind::Move, pos, pos)
423                .with_buttons(self.buttons_pressed);
424            for hit in hits {
425                hit.dispatch(event.clone());
426                if event.is_consumed() {
427                    break;
428                }
429            }
430            true
431        } else {
432            false
433        }
434    }
435
436    pub fn pointer_pressed(&mut self) -> bool {
437        enter_event_handler();
438        let result = run_in_mutable_snapshot(|| self.pointer_pressed_inner()).unwrap_or(false);
439        exit_event_handler();
440        if input_pipeline_debug_enabled() {
441            eprintln!("[CRANPOSE_INPUT_DEBUG] pointer_pressed -> {}", result);
442        }
443        result
444    }
445
446    fn pointer_pressed_inner(&mut self) -> bool {
447        // Track button state
448        self.buttons_pressed.insert(PointerButton::Primary);
449
450        // Hit-test against the current (last rendered) scene.
451        // Even if the app is dirty, this scene is what the user actually saw and clicked.
452        // Frame N is rendered → user sees frame N and taps → we hit-test frame N's geometry.
453        // The pointer event may mark dirty → next frame runs update() → renders N+1.
454
455        // Perform hit test and cache the NodeIds (not geometry!)
456        // The key insight from Jetpack Compose: cache identity, resolve fresh geometry per dispatch
457        let hits = self.renderer.scene().hit_test(self.cursor.0, self.cursor.1);
458
459        // Cache NodeIds for this pointer
460        let node_ids: Vec<_> = hits.iter().map(|h| h.node_id()).collect();
461        self.hit_path_tracker
462            .add_hit_path(PointerId::PRIMARY, node_ids);
463
464        if !hits.is_empty() {
465            let event = PointerEvent::new(
466                PointerEventKind::Down,
467                Point {
468                    x: self.cursor.0,
469                    y: self.cursor.1,
470                },
471                Point {
472                    x: self.cursor.0,
473                    y: self.cursor.1,
474                },
475            )
476            .with_buttons(self.buttons_pressed);
477
478            // Dispatch to fresh hits (geometry is already current for Down event)
479            for hit in hits {
480                hit.dispatch(event.clone());
481                if event.is_consumed() {
482                    break;
483                }
484            }
485            true
486        } else {
487            false
488        }
489    }
490
491    pub fn pointer_released(&mut self) -> bool {
492        enter_event_handler();
493        let result = run_in_mutable_snapshot(|| self.pointer_released_inner()).unwrap_or(false);
494        exit_event_handler();
495        if input_pipeline_debug_enabled() {
496            eprintln!("[CRANPOSE_INPUT_DEBUG] pointer_released -> {}", result);
497        }
498        result
499    }
500
501    fn pointer_released_inner(&mut self) -> bool {
502        // UP events report buttons as "currently pressed" (after release),
503        // matching typical platform semantics where primary is already gone.
504        self.buttons_pressed.remove(PointerButton::Primary);
505        let corrected_buttons = self.buttons_pressed;
506
507        // Resolve FRESH targets from cached NodeIds
508        let targets = self.resolve_hit_path(PointerId::PRIMARY);
509
510        // Always remove the path, even if targets is empty (node may have been removed)
511        self.hit_path_tracker.remove_path(PointerId::PRIMARY);
512
513        if !targets.is_empty() {
514            let event = PointerEvent::new(
515                PointerEventKind::Up,
516                Point {
517                    x: self.cursor.0,
518                    y: self.cursor.1,
519                },
520                Point {
521                    x: self.cursor.0,
522                    y: self.cursor.1,
523                },
524            )
525            .with_buttons(corrected_buttons);
526
527            for hit in targets {
528                hit.dispatch(event.clone());
529                if event.is_consumed() {
530                    break;
531                }
532            }
533            true
534        } else {
535            false
536        }
537    }
538
539    /// Dispatches a mouse wheel / trackpad scroll event to hovered pointer handlers.
540    ///
541    /// Returns `true` if a handler consumed the event.
542    pub fn pointer_scrolled(&mut self, delta_x: f32, delta_y: f32) -> bool {
543        enter_event_handler();
544        let result = run_in_mutable_snapshot(|| self.pointer_scrolled_inner(delta_x, delta_y))
545            .unwrap_or(false);
546        exit_event_handler();
547        if input_pipeline_debug_enabled() {
548            eprintln!(
549                "[CRANPOSE_INPUT_DEBUG] pointer_scrolled ({:.2},{:.2}) -> {}",
550                delta_x, delta_y, result
551            );
552        }
553        result
554    }
555
556    fn pointer_scrolled_inner(&mut self, delta_x: f32, delta_y: f32) -> bool {
557        if delta_x.abs() <= f32::EPSILON && delta_y.abs() <= f32::EPSILON {
558            return false;
559        }
560
561        let hits = self.renderer.scene().hit_test(self.cursor.0, self.cursor.1);
562        if hits.is_empty() {
563            return false;
564        }
565
566        let event = PointerEvent::new(
567            PointerEventKind::Scroll,
568            Point {
569                x: self.cursor.0,
570                y: self.cursor.1,
571            },
572            Point {
573                x: self.cursor.0,
574                y: self.cursor.1,
575            },
576        )
577        .with_buttons(self.buttons_pressed)
578        .with_scroll_delta(Point {
579            x: delta_x,
580            y: delta_y,
581        });
582
583        for hit in hits {
584            hit.dispatch(event.clone());
585            if event.is_consumed() {
586                break;
587            }
588        }
589
590        event.is_consumed()
591    }
592
593    /// Cancels any active gesture, dispatching Cancel events to cached targets.
594    /// Call this when:
595    /// - Window loses focus
596    /// - Mouse leaves window while button pressed
597    /// - Any other gesture abort scenario
598    pub fn cancel_gesture(&mut self) {
599        enter_event_handler();
600        let _ = run_in_mutable_snapshot(|| {
601            self.cancel_gesture_inner();
602        });
603        exit_event_handler();
604    }
605
606    fn cancel_gesture_inner(&mut self) {
607        // Resolve FRESH targets from cached NodeIds
608        let targets = self.resolve_hit_path(PointerId::PRIMARY);
609
610        // Clear tracker and button state
611        self.hit_path_tracker.clear();
612        self.buttons_pressed = PointerButtons::NONE;
613
614        if !targets.is_empty() {
615            let event = PointerEvent::new(
616                PointerEventKind::Cancel,
617                Point {
618                    x: self.cursor.0,
619                    y: self.cursor.1,
620                },
621                Point {
622                    x: self.cursor.0,
623                    y: self.cursor.1,
624                },
625            );
626
627            for hit in targets {
628                hit.dispatch(event.clone());
629            }
630        }
631
632        // Dispatch Exit to all previously hovered nodes
633        let pos = Point {
634            x: self.cursor.0,
635            y: self.cursor.1,
636        };
637        for &node_id in &self.hovered_nodes {
638            if let Some(target) = self.renderer.scene().find_target(node_id) {
639                let exit_event = PointerEvent::new(PointerEventKind::Exit, pos, pos);
640                target.dispatch(exit_event);
641            }
642        }
643        self.hovered_nodes.clear();
644    }
645    /// Routes a keyboard event to the focused text field, if any.
646    ///
647    /// Returns `true` if the event was consumed by a text field.
648    ///
649    /// On desktop, Ctrl+C/X/V are handled here with system clipboard (arboard).
650    /// On web, these keys are NOT handled here - they bubble to browser for native copy/paste events.
651    pub fn on_key_event(&mut self, event: &KeyEvent) -> bool {
652        enter_event_handler();
653        let result = self.on_key_event_inner(event);
654        exit_event_handler();
655        result
656    }
657
658    /// Internal keyboard event handler wrapped by on_key_event.
659    fn on_key_event_inner(&mut self, event: &KeyEvent) -> bool {
660        use KeyEventType::KeyDown;
661
662        // Only process KeyDown events for clipboard shortcuts
663        if event.event_type == KeyDown && event.modifiers.command_or_ctrl() {
664            // Desktop-only clipboard handling via arboard
665            // Use persistent self.clipboard to keep content alive on Linux X11
666            #[cfg(all(
667                not(target_arch = "wasm32"),
668                not(target_os = "android"),
669                not(target_os = "ios")
670            ))]
671            {
672                match event.key_code {
673                    // Ctrl+C - Copy
674                    KeyCode::C => {
675                        // Get text first, then access clipboard to avoid borrow conflict
676                        let text = self.on_copy();
677                        if let (Some(text), Some(clipboard)) = (text, self.clipboard.as_mut()) {
678                            let _ = clipboard.set_text(&text);
679                            return true;
680                        }
681                    }
682                    // Ctrl+X - Cut
683                    KeyCode::X => {
684                        // Get text first (this also deletes it), then access clipboard
685                        let text = self.on_cut();
686                        if let (Some(text), Some(clipboard)) = (text, self.clipboard.as_mut()) {
687                            let _ = clipboard.set_text(&text);
688                            self.mark_dirty();
689                            self.layout_dirty = true;
690                            return true;
691                        }
692                    }
693                    // Ctrl+V - Paste
694                    KeyCode::V => {
695                        // Get text from clipboard first, then paste
696                        let text = self.clipboard.as_mut().and_then(|cb| cb.get_text().ok());
697                        if let Some(text) = text {
698                            if self.on_paste(&text) {
699                                return true;
700                            }
701                        }
702                    }
703                    _ => {}
704                }
705            }
706        }
707
708        // Pure O(1) dispatch - no tree walking needed
709        if !cranpose_ui::text_field_focus::has_focused_field() {
710            return false;
711        }
712
713        // Wrap key event handling in a mutable snapshot so changes are atomically applied.
714        // This ensures keyboard input modifications are visible to subsequent snapshot contexts
715        // (like button click handlers that run in their own mutable snapshots).
716        let handled = run_in_mutable_snapshot(|| {
717            // O(1) dispatch via stored handler - handles ALL text input key events
718            // No fallback needed since handler now handles arrows, Home/End, word nav
719            cranpose_ui::text_field_focus::dispatch_key_event(event)
720        })
721        .unwrap_or(false);
722
723        if handled {
724            // Mark both dirty (for redraw) and layout_dirty (to rebuild semantics tree)
725            self.mark_dirty();
726            self.layout_dirty = true;
727        }
728
729        handled
730    }
731
732    /// Handles paste event from platform clipboard.
733    /// Returns `true` if the paste was consumed by a focused text field.
734    /// O(1) operation using stored handler.
735    pub fn on_paste(&mut self, text: &str) -> bool {
736        // Wrap paste in a mutable snapshot so changes are atomically applied.
737        // This ensures paste modifications are visible to subsequent snapshot contexts
738        // (like button click handlers that run in their own mutable snapshots).
739        let handled =
740            run_in_mutable_snapshot(|| cranpose_ui::text_field_focus::dispatch_paste(text))
741                .unwrap_or(false);
742
743        if handled {
744            self.mark_dirty();
745            self.layout_dirty = true;
746        }
747
748        handled
749    }
750
751    /// Handles copy request from platform.
752    /// Returns the selected text from focused text field, or None.
753    /// O(1) operation using stored handler.
754    pub fn on_copy(&mut self) -> Option<String> {
755        // Use O(1) dispatch instead of tree scan
756        cranpose_ui::text_field_focus::dispatch_copy()
757    }
758
759    /// Handles cut request from platform.
760    /// Returns the cut text from focused text field, or None.
761    /// O(1) operation using stored handler.
762    pub fn on_cut(&mut self) -> Option<String> {
763        // Use O(1) dispatch instead of tree scan
764        let text = cranpose_ui::text_field_focus::dispatch_cut();
765
766        if text.is_some() {
767            self.mark_dirty();
768            self.layout_dirty = true;
769        }
770
771        text
772    }
773
774    /// Sets the Linux primary selection (for middle-click paste).
775    /// This is called when text is selected in a text field.
776    /// On non-Linux platforms, this is a no-op.
777    #[cfg(all(target_os = "linux", not(target_arch = "wasm32")))]
778    pub fn set_primary_selection(&mut self, text: &str) {
779        use arboard::{LinuxClipboardKind, SetExtLinux};
780        if let Some(ref mut clipboard) = self.clipboard {
781            let result = clipboard
782                .set()
783                .clipboard(LinuxClipboardKind::Primary)
784                .text(text.to_string());
785            if let Err(e) = result {
786                // Primary selection may not be available on all systems
787                log::debug!("Primary selection set failed: {:?}", e);
788            }
789        }
790    }
791
792    /// Gets text from the Linux primary selection (for middle-click paste).
793    /// On non-Linux platforms, returns None.
794    #[cfg(all(target_os = "linux", not(target_arch = "wasm32")))]
795    pub fn get_primary_selection(&mut self) -> Option<String> {
796        use arboard::{GetExtLinux, LinuxClipboardKind};
797        if let Some(ref mut clipboard) = self.clipboard {
798            clipboard
799                .get()
800                .clipboard(LinuxClipboardKind::Primary)
801                .text()
802                .ok()
803        } else {
804            None
805        }
806    }
807
808    #[cfg(all(
809        not(target_os = "linux"),
810        not(target_arch = "wasm32"),
811        not(target_os = "ios")
812    ))]
813    pub fn get_primary_selection(&mut self) -> Option<String> {
814        None
815    }
816
817    /// Syncs the current text field selection to PRIMARY (Linux X11).
818    /// Call this when selection changes in a text field.
819    pub fn sync_selection_to_primary(&mut self) {
820        #[cfg(all(target_os = "linux", not(target_arch = "wasm32")))]
821        {
822            if let Some(text) = self.on_copy() {
823                self.set_primary_selection(&text);
824            }
825        }
826    }
827
828    /// Handles IME preedit (composition) events.
829    /// Called when the input method is composing text (e.g., typing CJK characters).
830    ///
831    /// - `text`: The current preedit text (empty to clear composition state)
832    /// - `cursor`: Optional cursor position within the preedit text (start, end)
833    ///
834    /// Returns `true` if a text field consumed the event.
835    pub fn on_ime_preedit(&mut self, text: &str, cursor: Option<(usize, usize)>) -> bool {
836        // Wrap in mutable snapshot for atomic changes
837        let handled = run_in_mutable_snapshot(|| {
838            cranpose_ui::text_field_focus::dispatch_ime_preedit(text, cursor)
839        })
840        .unwrap_or(false);
841
842        if handled {
843            self.mark_dirty();
844            // IME composition changes the visible text, needs layout update
845            self.layout_dirty = true;
846        }
847
848        handled
849    }
850
851    /// Handles IME delete-surrounding events.
852    /// Returns `true` if a text field consumed the event.
853    pub fn on_ime_delete_surrounding(&mut self, before_bytes: usize, after_bytes: usize) -> bool {
854        let handled = run_in_mutable_snapshot(|| {
855            cranpose_ui::text_field_focus::dispatch_delete_surrounding(before_bytes, after_bytes)
856        })
857        .unwrap_or(false);
858
859        if handled {
860            self.mark_dirty();
861            self.layout_dirty = true;
862        }
863
864        handled
865    }
866
867    pub fn log_debug_info(&mut self) {
868        println!("\n\n");
869        println!("════════════════════════════════════════════════════════");
870        println!("           DEBUG: CURRENT SCREEN STATE");
871        println!("════════════════════════════════════════════════════════");
872
873        if let Some(ref layout_tree) = self.layout_tree {
874            log_layout_tree(layout_tree);
875            let renderer = HeadlessRenderer::new();
876            let render_scene = renderer.render(layout_tree);
877            log_render_scene(&render_scene);
878            log_screen_summary(layout_tree, &render_scene);
879        } else {
880            println!("No layout available");
881        }
882
883        println!("════════════════════════════════════════════════════════");
884        println!("\n\n");
885    }
886
887    /// Get the current layout tree (for robot/testing)
888    pub fn layout_tree(&self) -> Option<&LayoutTree> {
889        self.layout_tree.as_ref()
890    }
891
892    /// Get the current semantics tree (for robot/testing)
893    pub fn semantics_tree(&self) -> Option<&SemanticsTree> {
894        self.semantics_tree.as_ref()
895    }
896
897    pub fn set_semantics_enabled(&mut self, enabled: bool) {
898        if self.semantics_enabled == enabled {
899            return;
900        }
901        self.semantics_enabled = enabled;
902        if enabled {
903            self.layout_dirty = true;
904            self.mark_dirty();
905        } else {
906            self.semantics_tree = None;
907        }
908    }
909
910    fn process_frame(&mut self) {
911        // Record frame for FPS tracking
912        fps_monitor::record_frame();
913
914        #[cfg(debug_assertions)]
915        let _frame_start = Instant::now();
916
917        self.run_layout_phase();
918
919        #[cfg(debug_assertions)]
920        let _after_layout = Instant::now();
921
922        self.run_dispatch_queues();
923
924        #[cfg(debug_assertions)]
925        let _after_dispatch = Instant::now();
926
927        self.run_render_phase();
928    }
929
930    fn run_layout_phase(&mut self) {
931        // ═══════════════════════════════════════════════════════════════════════════════
932        // SCOPED LAYOUT REPASSES (preferred path for local changes)
933        // ═══════════════════════════════════════════════════════════════════════════════
934        // Process node-specific layout invalidations (e.g., from scroll).
935        // This bubbles dirty flags up from specific nodes WITHOUT invalidating all caches.
936        // Result: O(subtree) remeasurement, not O(app).
937        let repass_nodes = cranpose_ui::take_layout_repass_nodes();
938        let had_repass_nodes = !repass_nodes.is_empty();
939        if had_repass_nodes {
940            let root = self.composition.root();
941            let mut applier = self.composition.applier_mut();
942            for node_id in repass_nodes {
943                // Bubble measure dirty flags up to root so cache epoch increments.
944                // This uses the centralized function in cranpose-core.
945                cranpose_core::bubble_measure_dirty(
946                    &mut *applier as &mut dyn cranpose_core::Applier,
947                    node_id,
948                );
949                cranpose_core::bubble_layout_dirty(
950                    &mut *applier as &mut dyn cranpose_core::Applier,
951                    node_id,
952                );
953            }
954
955            // IMPORTANT: Also mark the actual root as needing measure.
956            // The bubble may not reach root if intermediate nodes (e.g., subcomposed slot roots
957            // from SubcomposeLayout) have broken parent chains. This ensures the epoch
958            // is incremented so SubcomposeLayout re-measures its items.
959            if let Some(root) = root {
960                if let Ok(node) = applier.get_mut(root) {
961                    node.mark_needs_measure();
962                }
963            }
964
965            drop(applier);
966            self.layout_dirty = true;
967        }
968
969        // ═══════════════════════════════════════════════════════════════════════════════
970        // GLOBAL LAYOUT INVALIDATION (rare fallback for true global events)
971        // ═══════════════════════════════════════════════════════════════════════════════
972        // This is the "nuclear option" - invalidates ALL layout caches across the entire app.
973        //
974        // WHEN THIS SHOULD FIRE:
975        //   ✓ Window/viewport resize
976        //   ✓ Global font scale or density changes
977        //   ✓ Debug toggles that affect layout globally
978        //
979        // WHEN THIS SHOULD *NOT* FIRE:
980        //   ✗ Scroll (use schedule_layout_repass instead)
981        //   ✗ Single widget updates (use schedule_layout_repass instead)
982        //   ✗ Any local layout change (use schedule_layout_repass instead)
983        //
984        // If you see this firing frequently during normal interactions,
985        // someone is abusing request_layout_invalidation() - investigate!
986        let invalidation_requested = take_layout_invalidation();
987
988        // Only do global cache invalidation if:
989        // 1. Invalidation was requested (flag was set)
990        // 2. AND there were no scoped repass nodes (which handle layout more efficiently)
991        //
992        // If scoped repasses were handled above, they've already marked the tree dirty
993        // and bubbled up the hierarchy. We don't need to also invalidate all caches.
994        if invalidation_requested && !had_repass_nodes {
995            // Invalidate all caches (O(app size) - expensive!)
996            // This is internal-only API, only accessible via the internal path
997            cranpose_ui::layout::invalidate_all_layout_caches();
998
999            // Mark root as needing layout AND measure so tree_needs_layout() returns true
1000            // and intrinsic sizes are recalculated (e.g., text field resizing on content change)
1001            if let Some(root) = self.composition.root() {
1002                let mut applier = self.composition.applier_mut();
1003                if let Ok(node) = applier.get_mut(root) {
1004                    if let Some(layout_node) =
1005                        node.as_any_mut().downcast_mut::<cranpose_ui::LayoutNode>()
1006                    {
1007                        layout_node.mark_needs_measure();
1008                        layout_node.mark_needs_layout();
1009                    }
1010                }
1011            }
1012            self.layout_dirty = true;
1013        } else if invalidation_requested {
1014            // Invalidation was requested but scoped repasses already handled it.
1015            // Just make sure layout_dirty is set.
1016            self.layout_dirty = true;
1017        }
1018
1019        // Early exit if layout is not needed (viewport didn't change, etc.)
1020        if !self.layout_dirty {
1021            return;
1022        }
1023
1024        let viewport_size = Size {
1025            width: self.viewport.0,
1026            height: self.viewport.1,
1027        };
1028        if let Some(root) = self.composition.root() {
1029            let handle = self.composition.runtime_handle();
1030            let mut applier = self.composition.applier_mut();
1031            applier.set_runtime_handle(handle);
1032
1033            // Selective measure optimization: skip layout if tree is clean (O(1) check)
1034            // UNLESS layout_dirty was explicitly set (e.g., from keyboard input)
1035            let tree_needs_layout_check = cranpose_ui::tree_needs_layout(&mut *applier, root)
1036                .unwrap_or_else(|err| {
1037                    log::warn!(
1038                        "Cannot check layout dirty status for root #{}: {}",
1039                        root,
1040                        err
1041                    );
1042                    true // Assume dirty on error
1043                });
1044
1045            // Force layout if either:
1046            // 1. Tree nodes are marked dirty (tree_needs_layout_check = true)
1047            // 2. layout_dirty was explicitly set (e.g., from keyboard/external events)
1048            let needs_layout = tree_needs_layout_check || self.layout_dirty;
1049
1050            if !needs_layout {
1051                // Tree is clean and no external dirtying - skip layout computation
1052                log::trace!("Skipping layout: tree is clean");
1053                self.layout_dirty = false;
1054                applier.clear_runtime_handle();
1055                return;
1056            }
1057
1058            // Tree needs layout - compute it
1059            self.layout_dirty = false;
1060
1061            // Ensure slots exist and borrow mutably (handled inside measure_layout via MemoryApplier)
1062            match cranpose_ui::measure_layout_with_options(
1063                &mut applier,
1064                root,
1065                viewport_size,
1066                MeasureLayoutOptions {
1067                    collect_semantics: self.semantics_enabled,
1068                },
1069            ) {
1070                Ok(measurements) => {
1071                    self.semantics_tree = measurements.semantics_tree().cloned();
1072                    self.layout_tree = Some(measurements.into_layout_tree());
1073                    self.scene_dirty = true;
1074                }
1075                Err(err) => {
1076                    log::error!("failed to compute layout: {err}");
1077                    self.layout_tree = None;
1078                    self.semantics_tree = None;
1079                    self.scene_dirty = true;
1080                }
1081            }
1082            applier.clear_runtime_handle();
1083        } else {
1084            self.layout_tree = None;
1085            self.semantics_tree = None;
1086            self.scene_dirty = true;
1087            self.layout_dirty = false;
1088        }
1089    }
1090
1091    fn run_dispatch_queues(&mut self) {
1092        // Process pointer input repasses
1093        // Similar to Jetpack Compose's pointer input invalidation processing,
1094        // we service nodes that need pointer input state updates without forcing layout/draw
1095        if has_pending_pointer_repasses() {
1096            let mut applier = self.composition.applier_mut();
1097            process_pointer_repasses(|node_id| {
1098                match clear_dispatch_invalidation(
1099                    &mut applier,
1100                    node_id,
1101                    DispatchInvalidationKind::Pointer,
1102                ) {
1103                    Ok(true) => {
1104                        log::trace!("Cleared pointer repass flag for node #{}", node_id);
1105                    }
1106                    Ok(false) => {}
1107                    Err(err) => {
1108                        log::debug!(
1109                            "Could not process pointer repass for node #{}: {}",
1110                            node_id,
1111                            err
1112                        );
1113                    }
1114                }
1115            });
1116        }
1117
1118        // Process focus invalidations
1119        // Mirrors Jetpack Compose's FocusInvalidationManager.invalidateNodes(),
1120        // processing nodes that need focus state synchronization
1121        if has_pending_focus_invalidations() {
1122            let mut applier = self.composition.applier_mut();
1123            process_focus_invalidations(|node_id| {
1124                match clear_dispatch_invalidation(
1125                    &mut applier,
1126                    node_id,
1127                    DispatchInvalidationKind::Focus,
1128                ) {
1129                    Ok(true) => {
1130                        log::trace!("Cleared focus sync flag for node #{}", node_id);
1131                    }
1132                    Ok(false) => {}
1133                    Err(err) => {
1134                        log::debug!(
1135                            "Could not process focus invalidation for node #{}: {}",
1136                            node_id,
1137                            err
1138                        );
1139                    }
1140                }
1141            });
1142        }
1143    }
1144
1145    fn refresh_draw_repasses(&mut self) {
1146        let dirty_nodes = take_draw_repass_nodes();
1147        if dirty_nodes.is_empty() {
1148            return;
1149        }
1150
1151        let Some(layout_tree) = self.layout_tree.as_mut() else {
1152            return;
1153        };
1154
1155        let dirty_set: HashSet<NodeId> = dirty_nodes.into_iter().collect();
1156        let mut applier = self.composition.applier_mut();
1157        let refresh_scope = build_draw_refresh_scope(&mut applier, &dirty_set);
1158        refresh_layout_box_data(
1159            &mut applier,
1160            layout_tree.root_mut(),
1161            &refresh_scope,
1162            &dirty_set,
1163        );
1164    }
1165
1166    fn run_render_phase(&mut self) {
1167        let render_dirty = take_render_invalidation();
1168        take_pointer_invalidation();
1169        take_focus_invalidation();
1170        let draw_repass_pending = cranpose_ui::has_pending_draw_repasses();
1171        // Tick cursor blink timer - only marks dirty when visibility state changes
1172        let cursor_blink_dirty = cranpose_ui::tick_cursor_blink();
1173
1174        let render_only_dirty = render_dirty || cursor_blink_dirty;
1175        // Pointer/focus queues mutate live node state during dispatch. Direct applier rendering
1176        // reads that state on demand, so only real scene dirties require a rebuild here.
1177        let needs_scene_rebuild = self.scene_dirty || draw_repass_pending || render_only_dirty;
1178
1179        if !needs_scene_rebuild {
1180            return;
1181        }
1182        self.scene_dirty = false;
1183        self.refresh_draw_repasses();
1184        let viewport_size = Size {
1185            width: self.viewport.0,
1186            height: self.viewport.1,
1187        };
1188
1189        // Use new direct traversal rendering
1190        if let Some(root) = self.composition.root() {
1191            let mut applier = self.composition.applier_mut();
1192            if let Err(err) =
1193                self.renderer
1194                    .rebuild_scene_from_applier(&mut applier, root, viewport_size)
1195            {
1196                // Fallback to clearing scene on error
1197                log::error!("renderer rebuild failed: {err:?}");
1198                self.renderer.scene_mut().clear();
1199            }
1200        } else {
1201            self.renderer.scene_mut().clear();
1202        }
1203
1204        // Draw FPS overlay if enabled (directly by renderer, no composition)
1205        if self.dev_options.fps_counter {
1206            let stats = fps_monitor::fps_stats();
1207            let text = format!(
1208                "{:.0} FPS | {:.1}ms | {} recomp/s",
1209                stats.fps, stats.avg_ms, stats.recomps_per_second
1210            );
1211            self.renderer.draw_dev_overlay(&text, viewport_size);
1212        }
1213    }
1214}
1215
1216fn clear_dispatch_invalidation(
1217    applier: &mut MemoryApplier,
1218    node_id: NodeId,
1219    invalidation: DispatchInvalidationKind,
1220) -> Result<bool, NodeError> {
1221    match invalidation {
1222        DispatchInvalidationKind::Pointer => {
1223            match applier.with_node::<LayoutNode, _>(node_id, |node| {
1224                let needs_pointer_pass = node.needs_pointer_pass();
1225                if needs_pointer_pass {
1226                    node.clear_needs_pointer_pass();
1227                }
1228                needs_pointer_pass
1229            }) {
1230                Ok(cleared) => Ok(cleared),
1231                Err(NodeError::TypeMismatch { .. }) => applier
1232                    .with_node::<SubcomposeLayoutNode, _>(node_id, |node| {
1233                        let needs_pointer_pass = node.needs_pointer_pass();
1234                        if needs_pointer_pass {
1235                            node.clear_needs_pointer_pass();
1236                        }
1237                        needs_pointer_pass
1238                    }),
1239                Err(err) => Err(err),
1240            }
1241        }
1242        DispatchInvalidationKind::Focus => {
1243            match applier.with_node::<LayoutNode, _>(node_id, |node| {
1244                let needs_focus_sync = node.needs_focus_sync();
1245                if needs_focus_sync {
1246                    node.clear_needs_focus_sync();
1247                }
1248                needs_focus_sync
1249            }) {
1250                Ok(cleared) => Ok(cleared),
1251                Err(NodeError::TypeMismatch { .. }) => applier
1252                    .with_node::<SubcomposeLayoutNode, _>(node_id, |node| {
1253                        let needs_focus_sync = node.needs_focus_sync();
1254                        if needs_focus_sync {
1255                            node.clear_needs_focus_sync();
1256                        }
1257                        needs_focus_sync
1258                    }),
1259                Err(err) => Err(err),
1260            }
1261        }
1262    }
1263}
1264
1265fn build_draw_refresh_scope(
1266    applier: &mut MemoryApplier,
1267    dirty_nodes: &HashSet<NodeId>,
1268) -> HashSet<NodeId> {
1269    let mut refresh_scope = HashSet::with_capacity(dirty_nodes.len());
1270    for &dirty_node in dirty_nodes {
1271        let mut current = Some(dirty_node);
1272        while let Some(node_id) = current {
1273            if !refresh_scope.insert(node_id) {
1274                break;
1275            }
1276            current = applier.get_mut(node_id).ok().and_then(|node| node.parent());
1277        }
1278    }
1279    refresh_scope
1280}
1281
1282fn refresh_layout_box_data(
1283    applier: &mut MemoryApplier,
1284    layout: &mut cranpose_ui::layout::LayoutBox,
1285    refresh_scope: &HashSet<NodeId>,
1286    dirty_nodes: &HashSet<NodeId>,
1287) {
1288    if !refresh_scope.contains(&layout.node_id) {
1289        return;
1290    }
1291
1292    if dirty_nodes.contains(&layout.node_id) {
1293        if let Ok((modifier, resolved_modifiers, slices)) =
1294            applier.with_node::<LayoutNode, _>(layout.node_id, |node| {
1295                node.clear_needs_redraw();
1296                (
1297                    node.modifier.clone(),
1298                    node.resolved_modifiers(),
1299                    node.modifier_slices_snapshot(),
1300                )
1301            })
1302        {
1303            layout.node_data.modifier = modifier;
1304            layout.node_data.resolved_modifiers = resolved_modifiers;
1305            layout.node_data.modifier_slices = slices;
1306        } else if let Ok((modifier, resolved_modifiers)) = applier
1307            .with_node::<SubcomposeLayoutNode, _>(layout.node_id, |node| {
1308                node.clear_needs_redraw();
1309                (node.modifier(), node.resolved_modifiers())
1310            })
1311        {
1312            layout.node_data.modifier = modifier.clone();
1313            layout.node_data.resolved_modifiers = resolved_modifiers;
1314            layout.node_data.modifier_slices =
1315                std::rc::Rc::new(cranpose_ui::collect_slices_from_modifier(&modifier));
1316        }
1317    }
1318
1319    for child in &mut layout.children {
1320        refresh_layout_box_data(applier, child, refresh_scope, dirty_nodes);
1321    }
1322}
1323
1324impl<R> Drop for AppShell<R>
1325where
1326    R: Renderer,
1327{
1328    fn drop(&mut self) {
1329        self.runtime.clear_frame_waker();
1330    }
1331}
1332
1333pub fn default_root_key() -> Key {
1334    location_key(file!(), line!(), column!())
1335}
1336
1337#[cfg(test)]
1338#[path = "tests/app_shell_tests.rs"]
1339mod tests;