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