Skip to main content

cranpose_app_shell/
lib.rs

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