Skip to main content

cranpose_app_shell/
lib.rs

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