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;
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    #[cfg(all(not(target_os = "linux"), not(target_arch = "wasm32")))]
637    pub fn get_primary_selection(&mut self) -> Option<String> {
638        None
639    }
640
641    /// Syncs the current text field selection to PRIMARY (Linux X11).
642    /// Call this when selection changes in a text field.
643    pub fn sync_selection_to_primary(&mut self) {
644        #[cfg(all(target_os = "linux", not(target_arch = "wasm32")))]
645        {
646            if let Some(text) = self.on_copy() {
647                self.set_primary_selection(&text);
648            }
649        }
650    }
651
652    /// Handles IME preedit (composition) events.
653    /// Called when the input method is composing text (e.g., typing CJK characters).
654    ///
655    /// - `text`: The current preedit text (empty to clear composition state)
656    /// - `cursor`: Optional cursor position within the preedit text (start, end)
657    ///
658    /// Returns `true` if a text field consumed the event.
659    pub fn on_ime_preedit(&mut self, text: &str, cursor: Option<(usize, usize)>) -> bool {
660        // Wrap in mutable snapshot for atomic changes
661        let handled = run_in_mutable_snapshot(|| {
662            cranpose_ui::text_field_focus::dispatch_ime_preedit(text, cursor)
663        })
664        .unwrap_or(false);
665
666        if handled {
667            self.mark_dirty();
668            // IME composition changes the visible text, needs layout update
669            self.layout_dirty = true;
670        }
671
672        handled
673    }
674
675    /// Handles IME delete-surrounding events.
676    /// Returns `true` if a text field consumed the event.
677    pub fn on_ime_delete_surrounding(&mut self, before_bytes: usize, after_bytes: usize) -> bool {
678        let handled = run_in_mutable_snapshot(|| {
679            cranpose_ui::text_field_focus::dispatch_delete_surrounding(before_bytes, after_bytes)
680        })
681        .unwrap_or(false);
682
683        if handled {
684            self.mark_dirty();
685            self.layout_dirty = true;
686        }
687
688        handled
689    }
690
691    pub fn log_debug_info(&mut self) {
692        println!("\n\n");
693        println!("════════════════════════════════════════════════════════");
694        println!("           DEBUG: CURRENT SCREEN STATE");
695        println!("════════════════════════════════════════════════════════");
696
697        if let Some(ref layout_tree) = self.layout_tree {
698            log_layout_tree(layout_tree);
699            let renderer = HeadlessRenderer::new();
700            let render_scene = renderer.render(layout_tree);
701            log_render_scene(&render_scene);
702            log_screen_summary(layout_tree, &render_scene);
703        } else {
704            println!("No layout available");
705        }
706
707        println!("════════════════════════════════════════════════════════");
708        println!("\n\n");
709    }
710
711    /// Get the current layout tree (for robot/testing)
712    pub fn layout_tree(&self) -> Option<&LayoutTree> {
713        self.layout_tree.as_ref()
714    }
715
716    /// Get the current semantics tree (for robot/testing)
717    pub fn semantics_tree(&self) -> Option<&SemanticsTree> {
718        self.semantics_tree.as_ref()
719    }
720
721    fn process_frame(&mut self) {
722        // Record frame for FPS tracking
723        fps_monitor::record_frame();
724
725        #[cfg(debug_assertions)]
726        let _frame_start = Instant::now();
727
728        self.run_layout_phase();
729
730        #[cfg(debug_assertions)]
731        let _after_layout = Instant::now();
732
733        self.run_dispatch_queues();
734
735        #[cfg(debug_assertions)]
736        let _after_dispatch = Instant::now();
737
738        self.run_render_phase();
739    }
740
741    fn run_layout_phase(&mut self) {
742        // ═══════════════════════════════════════════════════════════════════════════════
743        // SCOPED LAYOUT REPASSES (preferred path for local changes)
744        // ═══════════════════════════════════════════════════════════════════════════════
745        // Process node-specific layout invalidations (e.g., from scroll).
746        // This bubbles dirty flags up from specific nodes WITHOUT invalidating all caches.
747        // Result: O(subtree) remeasurement, not O(app).
748        let repass_nodes = cranpose_ui::take_layout_repass_nodes();
749        let had_repass_nodes = !repass_nodes.is_empty();
750        if had_repass_nodes {
751            let root = self.composition.root();
752            let mut applier = self.composition.applier_mut();
753            for node_id in repass_nodes {
754                // Bubble measure dirty flags up to root so cache epoch increments.
755                // This uses the centralized function in cranpose-core.
756                cranpose_core::bubble_measure_dirty(
757                    &mut *applier as &mut dyn cranpose_core::Applier,
758                    node_id,
759                );
760                cranpose_core::bubble_layout_dirty(
761                    &mut *applier as &mut dyn cranpose_core::Applier,
762                    node_id,
763                );
764            }
765
766            // IMPORTANT: Also mark the actual root as needing measure.
767            // The bubble may not reach root if intermediate nodes (e.g., subcomposed slot roots
768            // from SubcomposeLayout) have broken parent chains. This ensures the epoch
769            // is incremented so SubcomposeLayout re-measures its items.
770            if let Some(root) = root {
771                if let Ok(node) = applier.get_mut(root) {
772                    node.mark_needs_measure();
773                }
774            }
775
776            drop(applier);
777            self.layout_dirty = true;
778        }
779
780        // ═══════════════════════════════════════════════════════════════════════════════
781        // GLOBAL LAYOUT INVALIDATION (rare fallback for true global events)
782        // ═══════════════════════════════════════════════════════════════════════════════
783        // This is the "nuclear option" - invalidates ALL layout caches across the entire app.
784        //
785        // WHEN THIS SHOULD FIRE:
786        //   ✓ Window/viewport resize
787        //   ✓ Global font scale or density changes
788        //   ✓ Debug toggles that affect layout globally
789        //
790        // WHEN THIS SHOULD *NOT* FIRE:
791        //   ✗ Scroll (use schedule_layout_repass instead)
792        //   ✗ Single widget updates (use schedule_layout_repass instead)
793        //   ✗ Any local layout change (use schedule_layout_repass instead)
794        //
795        // If you see this firing frequently during normal interactions,
796        // someone is abusing request_layout_invalidation() - investigate!
797        let invalidation_requested = take_layout_invalidation();
798
799        // Only do global cache invalidation if:
800        // 1. Invalidation was requested (flag was set)
801        // 2. AND there were no scoped repass nodes (which handle layout more efficiently)
802        //
803        // If scoped repasses were handled above, they've already marked the tree dirty
804        // and bubbled up the hierarchy. We don't need to also invalidate all caches.
805        if invalidation_requested && !had_repass_nodes {
806            // Invalidate all caches (O(app size) - expensive!)
807            // This is internal-only API, only accessible via the internal path
808            cranpose_ui::layout::invalidate_all_layout_caches();
809
810            // Mark root as needing layout AND measure so tree_needs_layout() returns true
811            // and intrinsic sizes are recalculated (e.g., text field resizing on content change)
812            if let Some(root) = self.composition.root() {
813                let mut applier = self.composition.applier_mut();
814                if let Ok(node) = applier.get_mut(root) {
815                    if let Some(layout_node) =
816                        node.as_any_mut().downcast_mut::<cranpose_ui::LayoutNode>()
817                    {
818                        layout_node.mark_needs_measure();
819                        layout_node.mark_needs_layout();
820                    }
821                }
822            }
823            self.layout_dirty = true;
824        } else if invalidation_requested {
825            // Invalidation was requested but scoped repasses already handled it.
826            // Just make sure layout_dirty is set.
827            self.layout_dirty = true;
828        }
829
830        // Early exit if layout is not needed (viewport didn't change, etc.)
831        if !self.layout_dirty {
832            return;
833        }
834
835        let viewport_size = Size {
836            width: self.viewport.0,
837            height: self.viewport.1,
838        };
839        if let Some(root) = self.composition.root() {
840            let handle = self.composition.runtime_handle();
841            let mut applier = self.composition.applier_mut();
842            applier.set_runtime_handle(handle);
843
844            // Selective measure optimization: skip layout if tree is clean (O(1) check)
845            // UNLESS layout_dirty was explicitly set (e.g., from keyboard input)
846            let tree_needs_layout_check = cranpose_ui::tree_needs_layout(&mut *applier, root)
847                .unwrap_or_else(|err| {
848                    log::warn!(
849                        "Cannot check layout dirty status for root #{}: {}",
850                        root,
851                        err
852                    );
853                    true // Assume dirty on error
854                });
855
856            // Force layout if either:
857            // 1. Tree nodes are marked dirty (tree_needs_layout_check = true)
858            // 2. layout_dirty was explicitly set (e.g., from keyboard/external events)
859            let needs_layout = tree_needs_layout_check || self.layout_dirty;
860
861            if !needs_layout {
862                // Tree is clean and no external dirtying - skip layout computation
863                log::trace!("Skipping layout: tree is clean");
864                self.layout_dirty = false;
865                applier.clear_runtime_handle();
866                return;
867            }
868
869            // Tree needs layout - compute it
870            self.layout_dirty = false;
871
872            // Ensure slots exist and borrow mutably (handled inside measure_layout via MemoryApplier)
873            match cranpose_ui::measure_layout(&mut applier, root, viewport_size) {
874                Ok(measurements) => {
875                    self.semantics_tree = Some(measurements.semantics_tree().clone());
876                    self.layout_tree = Some(measurements.into_layout_tree());
877                    self.scene_dirty = true;
878                }
879                Err(err) => {
880                    log::error!("failed to compute layout: {err}");
881                    self.layout_tree = None;
882                    self.semantics_tree = None;
883                    self.scene_dirty = true;
884                }
885            }
886            applier.clear_runtime_handle();
887        } else {
888            self.layout_tree = None;
889            self.semantics_tree = None;
890            self.scene_dirty = true;
891            self.layout_dirty = false;
892        }
893    }
894
895    fn run_dispatch_queues(&mut self) {
896        // Process pointer input repasses
897        // Similar to Jetpack Compose's pointer input invalidation processing,
898        // we service nodes that need pointer input state updates without forcing layout/draw
899        if has_pending_pointer_repasses() {
900            let mut applier = self.composition.applier_mut();
901            process_pointer_repasses(|node_id| {
902                // Access the LayoutNode and clear its dirty flag
903                let result = applier.with_node::<LayoutNode, _>(node_id, |layout_node| {
904                    if layout_node.needs_pointer_pass() {
905                        layout_node.clear_needs_pointer_pass();
906                        log::trace!("Cleared pointer repass flag for node #{}", node_id);
907                    }
908                });
909                if let Err(err) = result {
910                    log::debug!(
911                        "Could not process pointer repass for node #{}: {}",
912                        node_id,
913                        err
914                    );
915                }
916            });
917        }
918
919        // Process focus invalidations
920        // Mirrors Jetpack Compose's FocusInvalidationManager.invalidateNodes(),
921        // processing nodes that need focus state synchronization
922        if has_pending_focus_invalidations() {
923            let mut applier = self.composition.applier_mut();
924            process_focus_invalidations(|node_id| {
925                // Access the LayoutNode and clear its dirty flag
926                let result = applier.with_node::<LayoutNode, _>(node_id, |layout_node| {
927                    if layout_node.needs_focus_sync() {
928                        layout_node.clear_needs_focus_sync();
929                        log::trace!("Cleared focus sync flag for node #{}", node_id);
930                    }
931                });
932                if let Err(err) = result {
933                    log::debug!(
934                        "Could not process focus invalidation for node #{}: {}",
935                        node_id,
936                        err
937                    );
938                }
939            });
940        }
941    }
942
943    fn refresh_draw_repasses(&mut self) {
944        let dirty_nodes = take_draw_repass_nodes();
945        if dirty_nodes.is_empty() {
946            return;
947        }
948
949        let Some(layout_tree) = self.layout_tree.as_mut() else {
950            return;
951        };
952
953        let dirty_set: HashSet<NodeId> = dirty_nodes.into_iter().collect();
954        let mut applier = self.composition.applier_mut();
955        refresh_layout_box_data(&mut applier, layout_tree.root_mut(), &dirty_set);
956    }
957
958    fn run_render_phase(&mut self) {
959        let render_dirty = take_render_invalidation();
960        let pointer_dirty = take_pointer_invalidation();
961        let focus_dirty = take_focus_invalidation();
962        // Tick cursor blink timer - only marks dirty when visibility state changes
963        let cursor_blink_dirty = cranpose_ui::tick_cursor_blink();
964        if render_dirty || pointer_dirty || focus_dirty || cursor_blink_dirty {
965            self.scene_dirty = true;
966        }
967        if !self.scene_dirty {
968            return;
969        }
970        self.scene_dirty = false;
971        self.refresh_draw_repasses();
972        let viewport_size = Size {
973            width: self.viewport.0,
974            height: self.viewport.1,
975        };
976
977        // Use new direct traversal rendering
978        if let Some(root) = self.composition.root() {
979            let mut applier = self.composition.applier_mut();
980            if let Err(err) =
981                self.renderer
982                    .rebuild_scene_from_applier(&mut applier, root, viewport_size)
983            {
984                // Fallback to clearing scene on error
985                log::error!("renderer rebuild failed: {err:?}");
986                self.renderer.scene_mut().clear();
987            }
988        } else {
989            self.renderer.scene_mut().clear();
990        }
991
992        // Draw FPS overlay if enabled (directly by renderer, no composition)
993        if self.dev_options.fps_counter {
994            let stats = fps_monitor::fps_stats();
995            let text = format!(
996                "{:.0} FPS | {:.1}ms | {} recomp/s",
997                stats.fps, stats.avg_ms, stats.recomps_per_second
998            );
999            self.renderer.draw_dev_overlay(&text, viewport_size);
1000        }
1001    }
1002}
1003
1004fn refresh_layout_box_data(
1005    applier: &mut MemoryApplier,
1006    layout: &mut cranpose_ui::layout::LayoutBox,
1007    dirty_nodes: &HashSet<NodeId>,
1008) {
1009    if dirty_nodes.contains(&layout.node_id) {
1010        if let Ok((modifier, resolved_modifiers, slices)) =
1011            applier.with_node::<LayoutNode, _>(layout.node_id, |node| {
1012                node.clear_needs_redraw();
1013                (
1014                    node.modifier.clone(),
1015                    node.resolved_modifiers(),
1016                    node.modifier_slices_snapshot(),
1017                )
1018            })
1019        {
1020            layout.node_data.modifier = modifier;
1021            layout.node_data.resolved_modifiers = resolved_modifiers;
1022            layout.node_data.modifier_slices = slices;
1023        } else if let Ok((modifier, resolved_modifiers)) = applier
1024            .with_node::<SubcomposeLayoutNode, _>(layout.node_id, |node| {
1025                node.clear_needs_redraw();
1026                (node.modifier(), node.resolved_modifiers())
1027            })
1028        {
1029            layout.node_data.modifier = modifier.clone();
1030            layout.node_data.resolved_modifiers = resolved_modifiers;
1031            layout.node_data.modifier_slices =
1032                std::rc::Rc::new(cranpose_ui::collect_slices_from_modifier(&modifier));
1033        }
1034    }
1035
1036    for child in &mut layout.children {
1037        refresh_layout_box_data(applier, child, dirty_nodes);
1038    }
1039}
1040
1041impl<R> Drop for AppShell<R>
1042where
1043    R: Renderer,
1044{
1045    fn drop(&mut self) {
1046        self.runtime.clear_frame_waker();
1047    }
1048}
1049
1050pub fn default_root_key() -> Key {
1051    location_key(file!(), line!(), column!())
1052}
1053
1054#[cfg(test)]
1055#[path = "tests/app_shell_tests.rs"]
1056mod tests;