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        enter_event_handler();
277        let result = run_in_mutable_snapshot(|| self.set_cursor_inner(x, y)).unwrap_or(false);
278        exit_event_handler();
279        result
280    }
281
282    fn set_cursor_inner(&mut self, x: f32, y: f32) -> bool {
283        self.cursor = (x, y);
284
285        // During a gesture (button pressed), ONLY dispatch to the tracked hit path.
286        // Never fall back to hover hit-testing while buttons are down.
287        // This maintains the invariant: the path that receives Down must receive Move and Up/Cancel.
288        if self.buttons_pressed != PointerButtons::NONE {
289            if self.hit_path_tracker.has_path(PointerId::PRIMARY) {
290                // Resolve fresh targets from current scene (not cached geometry!)
291                let targets = self.resolve_hit_path(PointerId::PRIMARY);
292
293                if !targets.is_empty() {
294                    let event =
295                        PointerEvent::new(PointerEventKind::Move, Point { x, y }, Point { x, y })
296                            .with_buttons(self.buttons_pressed);
297
298                    for hit in targets {
299                        hit.dispatch(event.clone());
300                        if event.is_consumed() {
301                            break;
302                        }
303                    }
304                    self.mark_dirty();
305                    return true;
306                }
307
308                // Gesture exists but we can't resolve any nodes (removed / no hit region).
309                // Fall back to a fresh hit test so gestures can continue after node disposal.
310                let hits = self.renderer.scene().hit_test(x, y);
311                if !hits.is_empty() {
312                    let node_ids: Vec<_> = hits.iter().map(|h| h.node_id()).collect();
313                    self.hit_path_tracker
314                        .add_hit_path(PointerId::PRIMARY, node_ids);
315                    let event =
316                        PointerEvent::new(PointerEventKind::Move, Point { x, y }, Point { x, y })
317                            .with_buttons(self.buttons_pressed);
318                    for hit in hits {
319                        hit.dispatch(event.clone());
320                        if event.is_consumed() {
321                            break;
322                        }
323                    }
324                    self.mark_dirty();
325                    return true;
326                }
327                return false;
328            }
329
330            // Button is down but we have no recorded path inside this app
331            // (e.g. drag started outside). Do not dispatch anything.
332            return false;
333        }
334
335        // No gesture in progress: regular hover move using hit-test.
336        let hits = self.renderer.scene().hit_test(x, y);
337        if !hits.is_empty() {
338            let event = PointerEvent::new(PointerEventKind::Move, Point { x, y }, Point { x, y })
339                .with_buttons(self.buttons_pressed); // usually NONE here
340            for hit in hits {
341                hit.dispatch(event.clone());
342                if event.is_consumed() {
343                    break;
344                }
345            }
346            self.mark_dirty();
347            true
348        } else {
349            false
350        }
351    }
352
353    pub fn pointer_pressed(&mut self) -> bool {
354        enter_event_handler();
355        let result = run_in_mutable_snapshot(|| self.pointer_pressed_inner()).unwrap_or(false);
356        exit_event_handler();
357        result
358    }
359
360    fn pointer_pressed_inner(&mut self) -> bool {
361        // Track button state
362        self.buttons_pressed.insert(PointerButton::Primary);
363
364        // Hit-test against the current (last rendered) scene.
365        // Even if the app is dirty, this scene is what the user actually saw and clicked.
366        // Frame N is rendered → user sees frame N and taps → we hit-test frame N's geometry.
367        // The pointer event may mark dirty → next frame runs update() → renders N+1.
368
369        // Perform hit test and cache the NodeIds (not geometry!)
370        // The key insight from Jetpack Compose: cache identity, resolve fresh geometry per dispatch
371        let hits = self.renderer.scene().hit_test(self.cursor.0, self.cursor.1);
372
373        // Cache NodeIds for this pointer
374        let node_ids: Vec<_> = hits.iter().map(|h| h.node_id()).collect();
375        self.hit_path_tracker
376            .add_hit_path(PointerId::PRIMARY, node_ids);
377
378        if !hits.is_empty() {
379            let event = PointerEvent::new(
380                PointerEventKind::Down,
381                Point {
382                    x: self.cursor.0,
383                    y: self.cursor.1,
384                },
385                Point {
386                    x: self.cursor.0,
387                    y: self.cursor.1,
388                },
389            )
390            .with_buttons(self.buttons_pressed);
391
392            // Dispatch to fresh hits (geometry is already current for Down event)
393            for hit in hits {
394                hit.dispatch(event.clone());
395                if event.is_consumed() {
396                    break;
397                }
398            }
399            self.mark_dirty();
400            true
401        } else {
402            false
403        }
404    }
405
406    pub fn pointer_released(&mut self) -> bool {
407        enter_event_handler();
408        let result = run_in_mutable_snapshot(|| self.pointer_released_inner()).unwrap_or(false);
409        exit_event_handler();
410        result
411    }
412
413    fn pointer_released_inner(&mut self) -> bool {
414        // UP events report buttons as "currently pressed" (after release),
415        // matching typical platform semantics where primary is already gone.
416        self.buttons_pressed.remove(PointerButton::Primary);
417        let corrected_buttons = self.buttons_pressed;
418
419        // Resolve FRESH targets from cached NodeIds
420        let targets = self.resolve_hit_path(PointerId::PRIMARY);
421
422        // Always remove the path, even if targets is empty (node may have been removed)
423        self.hit_path_tracker.remove_path(PointerId::PRIMARY);
424
425        if !targets.is_empty() {
426            let event = PointerEvent::new(
427                PointerEventKind::Up,
428                Point {
429                    x: self.cursor.0,
430                    y: self.cursor.1,
431                },
432                Point {
433                    x: self.cursor.0,
434                    y: self.cursor.1,
435                },
436            )
437            .with_buttons(corrected_buttons);
438
439            for hit in targets {
440                hit.dispatch(event.clone());
441                if event.is_consumed() {
442                    break;
443                }
444            }
445            self.mark_dirty();
446            true
447        } else {
448            false
449        }
450    }
451
452    /// Cancels any active gesture, dispatching Cancel events to cached targets.
453    /// Call this when:
454    /// - Window loses focus
455    /// - Mouse leaves window while button pressed
456    /// - Any other gesture abort scenario
457    pub fn cancel_gesture(&mut self) {
458        enter_event_handler();
459        let _ = run_in_mutable_snapshot(|| {
460            self.cancel_gesture_inner();
461        });
462        exit_event_handler();
463    }
464
465    fn cancel_gesture_inner(&mut self) {
466        // Resolve FRESH targets from cached NodeIds
467        let targets = self.resolve_hit_path(PointerId::PRIMARY);
468
469        // Clear tracker and button state
470        self.hit_path_tracker.clear();
471        self.buttons_pressed = PointerButtons::NONE;
472
473        if !targets.is_empty() {
474            let event = PointerEvent::new(
475                PointerEventKind::Cancel,
476                Point {
477                    x: self.cursor.0,
478                    y: self.cursor.1,
479                },
480                Point {
481                    x: self.cursor.0,
482                    y: self.cursor.1,
483                },
484            );
485
486            for hit in targets {
487                hit.dispatch(event.clone());
488            }
489            self.mark_dirty();
490        }
491    }
492    /// Routes a keyboard event to the focused text field, if any.
493    ///
494    /// Returns `true` if the event was consumed by a text field.
495    ///
496    /// On desktop, Ctrl+C/X/V are handled here with system clipboard (arboard).
497    /// On web, these keys are NOT handled here - they bubble to browser for native copy/paste events.
498    pub fn on_key_event(&mut self, event: &KeyEvent) -> bool {
499        enter_event_handler();
500        let result = self.on_key_event_inner(event);
501        exit_event_handler();
502        result
503    }
504
505    /// Internal keyboard event handler wrapped by on_key_event.
506    fn on_key_event_inner(&mut self, event: &KeyEvent) -> bool {
507        use KeyEventType::KeyDown;
508
509        // Only process KeyDown events for clipboard shortcuts
510        if event.event_type == KeyDown && event.modifiers.command_or_ctrl() {
511            // Desktop-only clipboard handling via arboard
512            // Use persistent self.clipboard to keep content alive on Linux X11
513            #[cfg(all(not(target_arch = "wasm32"), not(target_os = "android")))]
514            {
515                match event.key_code {
516                    // Ctrl+C - Copy
517                    KeyCode::C => {
518                        // Get text first, then access clipboard to avoid borrow conflict
519                        let text = self.on_copy();
520                        if let (Some(text), Some(clipboard)) = (text, self.clipboard.as_mut()) {
521                            let _ = clipboard.set_text(&text);
522                            return true;
523                        }
524                    }
525                    // Ctrl+X - Cut
526                    KeyCode::X => {
527                        // Get text first (this also deletes it), then access clipboard
528                        let text = self.on_cut();
529                        if let (Some(text), Some(clipboard)) = (text, self.clipboard.as_mut()) {
530                            let _ = clipboard.set_text(&text);
531                            self.mark_dirty();
532                            self.layout_dirty = true;
533                            return true;
534                        }
535                    }
536                    // Ctrl+V - Paste
537                    KeyCode::V => {
538                        // Get text from clipboard first, then paste
539                        let text = self.clipboard.as_mut().and_then(|cb| cb.get_text().ok());
540                        if let Some(text) = text {
541                            if self.on_paste(&text) {
542                                return true;
543                            }
544                        }
545                    }
546                    _ => {}
547                }
548            }
549        }
550
551        // Pure O(1) dispatch - no tree walking needed
552        if !cranpose_ui::text_field_focus::has_focused_field() {
553            return false;
554        }
555
556        // Wrap key event handling in a mutable snapshot so changes are atomically applied.
557        // This ensures keyboard input modifications are visible to subsequent snapshot contexts
558        // (like button click handlers that run in their own mutable snapshots).
559        let handled = run_in_mutable_snapshot(|| {
560            // O(1) dispatch via stored handler - handles ALL text input key events
561            // No fallback needed since handler now handles arrows, Home/End, word nav
562            cranpose_ui::text_field_focus::dispatch_key_event(event)
563        })
564        .unwrap_or(false);
565
566        if handled {
567            // Mark both dirty (for redraw) and layout_dirty (to rebuild semantics tree)
568            self.mark_dirty();
569            self.layout_dirty = true;
570        }
571
572        handled
573    }
574
575    /// Handles paste event from platform clipboard.
576    /// Returns `true` if the paste was consumed by a focused text field.
577    /// O(1) operation using stored handler.
578    pub fn on_paste(&mut self, text: &str) -> bool {
579        // Wrap paste in a mutable snapshot so changes are atomically applied.
580        // This ensures paste modifications are visible to subsequent snapshot contexts
581        // (like button click handlers that run in their own mutable snapshots).
582        let handled =
583            run_in_mutable_snapshot(|| cranpose_ui::text_field_focus::dispatch_paste(text))
584                .unwrap_or(false);
585
586        if handled {
587            self.mark_dirty();
588            self.layout_dirty = true;
589        }
590
591        handled
592    }
593
594    /// Handles copy request from platform.
595    /// Returns the selected text from focused text field, or None.
596    /// O(1) operation using stored handler.
597    pub fn on_copy(&mut self) -> Option<String> {
598        // Use O(1) dispatch instead of tree scan
599        cranpose_ui::text_field_focus::dispatch_copy()
600    }
601
602    /// Handles cut request from platform.
603    /// Returns the cut text from focused text field, or None.
604    /// O(1) operation using stored handler.
605    pub fn on_cut(&mut self) -> Option<String> {
606        // Use O(1) dispatch instead of tree scan
607        let text = cranpose_ui::text_field_focus::dispatch_cut();
608
609        if text.is_some() {
610            self.mark_dirty();
611            self.layout_dirty = true;
612        }
613
614        text
615    }
616
617    /// Sets the Linux primary selection (for middle-click paste).
618    /// This is called when text is selected in a text field.
619    /// On non-Linux platforms, this is a no-op.
620    #[cfg(all(target_os = "linux", not(target_arch = "wasm32")))]
621    pub fn set_primary_selection(&mut self, text: &str) {
622        use arboard::{LinuxClipboardKind, SetExtLinux};
623        if let Some(ref mut clipboard) = self.clipboard {
624            let result = clipboard
625                .set()
626                .clipboard(LinuxClipboardKind::Primary)
627                .text(text.to_string());
628            if let Err(e) = result {
629                // Primary selection may not be available on all systems
630                log::debug!("Primary selection set failed: {:?}", e);
631            }
632        }
633    }
634
635    /// Gets text from the Linux primary selection (for middle-click paste).
636    /// On non-Linux platforms, returns None.
637    #[cfg(all(target_os = "linux", not(target_arch = "wasm32")))]
638    pub fn get_primary_selection(&mut self) -> Option<String> {
639        use arboard::{GetExtLinux, LinuxClipboardKind};
640        if let Some(ref mut clipboard) = self.clipboard {
641            clipboard
642                .get()
643                .clipboard(LinuxClipboardKind::Primary)
644                .text()
645                .ok()
646        } else {
647            None
648        }
649    }
650
651    #[cfg(all(not(target_os = "linux"), not(target_arch = "wasm32")))]
652    pub fn get_primary_selection(&mut self) -> Option<String> {
653        None
654    }
655
656    /// Syncs the current text field selection to PRIMARY (Linux X11).
657    /// Call this when selection changes in a text field.
658    pub fn sync_selection_to_primary(&mut self) {
659        #[cfg(all(target_os = "linux", not(target_arch = "wasm32")))]
660        {
661            if let Some(text) = self.on_copy() {
662                self.set_primary_selection(&text);
663            }
664        }
665    }
666
667    /// Handles IME preedit (composition) events.
668    /// Called when the input method is composing text (e.g., typing CJK characters).
669    ///
670    /// - `text`: The current preedit text (empty to clear composition state)
671    /// - `cursor`: Optional cursor position within the preedit text (start, end)
672    ///
673    /// Returns `true` if a text field consumed the event.
674    pub fn on_ime_preedit(&mut self, text: &str, cursor: Option<(usize, usize)>) -> bool {
675        // Wrap in mutable snapshot for atomic changes
676        let handled = run_in_mutable_snapshot(|| {
677            cranpose_ui::text_field_focus::dispatch_ime_preedit(text, cursor)
678        })
679        .unwrap_or(false);
680
681        if handled {
682            self.mark_dirty();
683            // IME composition changes the visible text, needs layout update
684            self.layout_dirty = true;
685        }
686
687        handled
688    }
689
690    /// Handles IME delete-surrounding events.
691    /// Returns `true` if a text field consumed the event.
692    pub fn on_ime_delete_surrounding(&mut self, before_bytes: usize, after_bytes: usize) -> bool {
693        let handled = run_in_mutable_snapshot(|| {
694            cranpose_ui::text_field_focus::dispatch_delete_surrounding(before_bytes, after_bytes)
695        })
696        .unwrap_or(false);
697
698        if handled {
699            self.mark_dirty();
700            self.layout_dirty = true;
701        }
702
703        handled
704    }
705
706    pub fn log_debug_info(&mut self) {
707        println!("\n\n");
708        println!("════════════════════════════════════════════════════════");
709        println!("           DEBUG: CURRENT SCREEN STATE");
710        println!("════════════════════════════════════════════════════════");
711
712        if let Some(ref layout_tree) = self.layout_tree {
713            log_layout_tree(layout_tree);
714            let renderer = HeadlessRenderer::new();
715            let render_scene = renderer.render(layout_tree);
716            log_render_scene(&render_scene);
717            log_screen_summary(layout_tree, &render_scene);
718        } else {
719            println!("No layout available");
720        }
721
722        println!("════════════════════════════════════════════════════════");
723        println!("\n\n");
724    }
725
726    /// Get the current layout tree (for robot/testing)
727    pub fn layout_tree(&self) -> Option<&LayoutTree> {
728        self.layout_tree.as_ref()
729    }
730
731    /// Get the current semantics tree (for robot/testing)
732    pub fn semantics_tree(&self) -> Option<&SemanticsTree> {
733        self.semantics_tree.as_ref()
734    }
735
736    fn process_frame(&mut self) {
737        // Record frame for FPS tracking
738        fps_monitor::record_frame();
739
740        #[cfg(debug_assertions)]
741        let _frame_start = Instant::now();
742
743        self.run_layout_phase();
744
745        #[cfg(debug_assertions)]
746        let _after_layout = Instant::now();
747
748        self.run_dispatch_queues();
749
750        #[cfg(debug_assertions)]
751        let _after_dispatch = Instant::now();
752
753        self.run_render_phase();
754    }
755
756    fn run_layout_phase(&mut self) {
757        // ═══════════════════════════════════════════════════════════════════════════════
758        // SCOPED LAYOUT REPASSES (preferred path for local changes)
759        // ═══════════════════════════════════════════════════════════════════════════════
760        // Process node-specific layout invalidations (e.g., from scroll).
761        // This bubbles dirty flags up from specific nodes WITHOUT invalidating all caches.
762        // Result: O(subtree) remeasurement, not O(app).
763        let repass_nodes = cranpose_ui::take_layout_repass_nodes();
764        let had_repass_nodes = !repass_nodes.is_empty();
765        if had_repass_nodes {
766            let root = self.composition.root();
767            let mut applier = self.composition.applier_mut();
768            for node_id in repass_nodes {
769                // Bubble measure dirty flags up to root so cache epoch increments.
770                // This uses the centralized function in cranpose-core.
771                cranpose_core::bubble_measure_dirty(
772                    &mut *applier as &mut dyn cranpose_core::Applier,
773                    node_id,
774                );
775                cranpose_core::bubble_layout_dirty(
776                    &mut *applier as &mut dyn cranpose_core::Applier,
777                    node_id,
778                );
779            }
780
781            // IMPORTANT: Also mark the actual root as needing measure.
782            // The bubble may not reach root if intermediate nodes (e.g., subcomposed slot roots
783            // from SubcomposeLayout) have broken parent chains. This ensures the epoch
784            // is incremented so SubcomposeLayout re-measures its items.
785            if let Some(root) = root {
786                if let Ok(node) = applier.get_mut(root) {
787                    node.mark_needs_measure();
788                }
789            }
790
791            drop(applier);
792            self.layout_dirty = true;
793        }
794
795        // ═══════════════════════════════════════════════════════════════════════════════
796        // GLOBAL LAYOUT INVALIDATION (rare fallback for true global events)
797        // ═══════════════════════════════════════════════════════════════════════════════
798        // This is the "nuclear option" - invalidates ALL layout caches across the entire app.
799        //
800        // WHEN THIS SHOULD FIRE:
801        //   ✓ Window/viewport resize
802        //   ✓ Global font scale or density changes
803        //   ✓ Debug toggles that affect layout globally
804        //
805        // WHEN THIS SHOULD *NOT* FIRE:
806        //   ✗ Scroll (use schedule_layout_repass instead)
807        //   ✗ Single widget updates (use schedule_layout_repass instead)
808        //   ✗ Any local layout change (use schedule_layout_repass instead)
809        //
810        // If you see this firing frequently during normal interactions,
811        // someone is abusing request_layout_invalidation() - investigate!
812        let invalidation_requested = take_layout_invalidation();
813
814        // Only do global cache invalidation if:
815        // 1. Invalidation was requested (flag was set)
816        // 2. AND there were no scoped repass nodes (which handle layout more efficiently)
817        //
818        // If scoped repasses were handled above, they've already marked the tree dirty
819        // and bubbled up the hierarchy. We don't need to also invalidate all caches.
820        if invalidation_requested && !had_repass_nodes {
821            // Invalidate all caches (O(app size) - expensive!)
822            // This is internal-only API, only accessible via the internal path
823            cranpose_ui::layout::invalidate_all_layout_caches();
824
825            // Mark root as needing layout AND measure so tree_needs_layout() returns true
826            // and intrinsic sizes are recalculated (e.g., text field resizing on content change)
827            if let Some(root) = self.composition.root() {
828                let mut applier = self.composition.applier_mut();
829                if let Ok(node) = applier.get_mut(root) {
830                    if let Some(layout_node) =
831                        node.as_any_mut().downcast_mut::<cranpose_ui::LayoutNode>()
832                    {
833                        layout_node.mark_needs_measure();
834                        layout_node.mark_needs_layout();
835                    }
836                }
837            }
838            self.layout_dirty = true;
839        } else if invalidation_requested {
840            // Invalidation was requested but scoped repasses already handled it.
841            // Just make sure layout_dirty is set.
842            self.layout_dirty = true;
843        }
844
845        // Early exit if layout is not needed (viewport didn't change, etc.)
846        if !self.layout_dirty {
847            return;
848        }
849
850        let viewport_size = Size {
851            width: self.viewport.0,
852            height: self.viewport.1,
853        };
854        if let Some(root) = self.composition.root() {
855            let handle = self.composition.runtime_handle();
856            let mut applier = self.composition.applier_mut();
857            applier.set_runtime_handle(handle);
858
859            // Selective measure optimization: skip layout if tree is clean (O(1) check)
860            // UNLESS layout_dirty was explicitly set (e.g., from keyboard input)
861            let tree_needs_layout_check = cranpose_ui::tree_needs_layout(&mut *applier, root)
862                .unwrap_or_else(|err| {
863                    log::warn!(
864                        "Cannot check layout dirty status for root #{}: {}",
865                        root,
866                        err
867                    );
868                    true // Assume dirty on error
869                });
870
871            // Force layout if either:
872            // 1. Tree nodes are marked dirty (tree_needs_layout_check = true)
873            // 2. layout_dirty was explicitly set (e.g., from keyboard/external events)
874            let needs_layout = tree_needs_layout_check || self.layout_dirty;
875
876            if !needs_layout {
877                // Tree is clean and no external dirtying - skip layout computation
878                log::trace!("Skipping layout: tree is clean");
879                self.layout_dirty = false;
880                applier.clear_runtime_handle();
881                return;
882            }
883
884            // Tree needs layout - compute it
885            self.layout_dirty = false;
886
887            // Ensure slots exist and borrow mutably (handled inside measure_layout via MemoryApplier)
888            match cranpose_ui::measure_layout(&mut applier, root, viewport_size) {
889                Ok(measurements) => {
890                    self.semantics_tree = Some(measurements.semantics_tree().clone());
891                    self.layout_tree = Some(measurements.into_layout_tree());
892                    self.scene_dirty = true;
893                }
894                Err(err) => {
895                    log::error!("failed to compute layout: {err}");
896                    self.layout_tree = None;
897                    self.semantics_tree = None;
898                    self.scene_dirty = true;
899                }
900            }
901            applier.clear_runtime_handle();
902        } else {
903            self.layout_tree = None;
904            self.semantics_tree = None;
905            self.scene_dirty = true;
906            self.layout_dirty = false;
907        }
908    }
909
910    fn run_dispatch_queues(&mut self) {
911        // Process pointer input repasses
912        // Similar to Jetpack Compose's pointer input invalidation processing,
913        // we service nodes that need pointer input state updates without forcing layout/draw
914        if has_pending_pointer_repasses() {
915            let mut applier = self.composition.applier_mut();
916            process_pointer_repasses(|node_id| {
917                // Access the LayoutNode and clear its dirty flag
918                let result = applier.with_node::<LayoutNode, _>(node_id, |layout_node| {
919                    if layout_node.needs_pointer_pass() {
920                        layout_node.clear_needs_pointer_pass();
921                        log::trace!("Cleared pointer repass flag for node #{}", node_id);
922                    }
923                });
924                if let Err(err) = result {
925                    log::debug!(
926                        "Could not process pointer repass for node #{}: {}",
927                        node_id,
928                        err
929                    );
930                }
931            });
932        }
933
934        // Process focus invalidations
935        // Mirrors Jetpack Compose's FocusInvalidationManager.invalidateNodes(),
936        // processing nodes that need focus state synchronization
937        if has_pending_focus_invalidations() {
938            let mut applier = self.composition.applier_mut();
939            process_focus_invalidations(|node_id| {
940                // Access the LayoutNode and clear its dirty flag
941                let result = applier.with_node::<LayoutNode, _>(node_id, |layout_node| {
942                    if layout_node.needs_focus_sync() {
943                        layout_node.clear_needs_focus_sync();
944                        log::trace!("Cleared focus sync flag for node #{}", node_id);
945                    }
946                });
947                if let Err(err) = result {
948                    log::debug!(
949                        "Could not process focus invalidation for node #{}: {}",
950                        node_id,
951                        err
952                    );
953                }
954            });
955        }
956    }
957
958    fn refresh_draw_repasses(&mut self) {
959        let dirty_nodes = take_draw_repass_nodes();
960        if dirty_nodes.is_empty() {
961            return;
962        }
963
964        let Some(layout_tree) = self.layout_tree.as_mut() else {
965            return;
966        };
967
968        let dirty_set: HashSet<NodeId> = dirty_nodes.into_iter().collect();
969        let mut applier = self.composition.applier_mut();
970        refresh_layout_box_data(&mut applier, layout_tree.root_mut(), &dirty_set);
971    }
972
973    fn run_render_phase(&mut self) {
974        let render_dirty = take_render_invalidation();
975        let pointer_dirty = take_pointer_invalidation();
976        let focus_dirty = take_focus_invalidation();
977        let draw_repass_pending = cranpose_ui::has_pending_draw_repasses();
978        // Tick cursor blink timer - only marks dirty when visibility state changes
979        let cursor_blink_dirty = cranpose_ui::tick_cursor_blink();
980        if render_dirty || pointer_dirty || focus_dirty || cursor_blink_dirty || draw_repass_pending
981        {
982            self.scene_dirty = true;
983        }
984        if !self.scene_dirty {
985            return;
986        }
987        self.scene_dirty = false;
988        self.refresh_draw_repasses();
989        let viewport_size = Size {
990            width: self.viewport.0,
991            height: self.viewport.1,
992        };
993
994        // Use new direct traversal rendering
995        if let Some(root) = self.composition.root() {
996            let mut applier = self.composition.applier_mut();
997            if let Err(err) =
998                self.renderer
999                    .rebuild_scene_from_applier(&mut applier, root, viewport_size)
1000            {
1001                // Fallback to clearing scene on error
1002                log::error!("renderer rebuild failed: {err:?}");
1003                self.renderer.scene_mut().clear();
1004            }
1005        } else {
1006            self.renderer.scene_mut().clear();
1007        }
1008
1009        // Draw FPS overlay if enabled (directly by renderer, no composition)
1010        if self.dev_options.fps_counter {
1011            let stats = fps_monitor::fps_stats();
1012            let text = format!(
1013                "{:.0} FPS | {:.1}ms | {} recomp/s",
1014                stats.fps, stats.avg_ms, stats.recomps_per_second
1015            );
1016            self.renderer.draw_dev_overlay(&text, viewport_size);
1017        }
1018    }
1019}
1020
1021fn refresh_layout_box_data(
1022    applier: &mut MemoryApplier,
1023    layout: &mut cranpose_ui::layout::LayoutBox,
1024    dirty_nodes: &HashSet<NodeId>,
1025) {
1026    if dirty_nodes.contains(&layout.node_id) {
1027        if let Ok((modifier, resolved_modifiers, slices)) =
1028            applier.with_node::<LayoutNode, _>(layout.node_id, |node| {
1029                node.clear_needs_redraw();
1030                (
1031                    node.modifier.clone(),
1032                    node.resolved_modifiers(),
1033                    node.modifier_slices_snapshot(),
1034                )
1035            })
1036        {
1037            layout.node_data.modifier = modifier;
1038            layout.node_data.resolved_modifiers = resolved_modifiers;
1039            layout.node_data.modifier_slices = slices;
1040        } else if let Ok((modifier, resolved_modifiers)) = applier
1041            .with_node::<SubcomposeLayoutNode, _>(layout.node_id, |node| {
1042                node.clear_needs_redraw();
1043                (node.modifier(), node.resolved_modifiers())
1044            })
1045        {
1046            layout.node_data.modifier = modifier.clone();
1047            layout.node_data.resolved_modifiers = resolved_modifiers;
1048            layout.node_data.modifier_slices =
1049                std::rc::Rc::new(cranpose_ui::collect_slices_from_modifier(&modifier));
1050        }
1051    }
1052
1053    for child in &mut layout.children {
1054        refresh_layout_box_data(applier, child, dirty_nodes);
1055    }
1056}
1057
1058impl<R> Drop for AppShell<R>
1059where
1060    R: Renderer,
1061{
1062    fn drop(&mut self) {
1063        self.runtime.clear_frame_waker();
1064    }
1065}
1066
1067pub fn default_root_key() -> Key {
1068    location_key(file!(), line!(), column!())
1069}
1070
1071#[cfg(test)]
1072#[path = "tests/app_shell_tests.rs"]
1073mod tests;