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