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    /// Dispatches a mouse wheel / trackpad scroll event to hovered pointer handlers.
510    ///
511    /// Returns `true` if a handler consumed the event.
512    pub fn pointer_scrolled(&mut self, delta_x: f32, delta_y: f32) -> bool {
513        enter_event_handler();
514        let result = run_in_mutable_snapshot(|| self.pointer_scrolled_inner(delta_x, delta_y))
515            .unwrap_or(false);
516        exit_event_handler();
517        if input_pipeline_debug_enabled() {
518            eprintln!(
519                "[CRANPOSE_INPUT_DEBUG] pointer_scrolled ({:.2},{:.2}) -> {}",
520                delta_x, delta_y, result
521            );
522        }
523        result
524    }
525
526    fn pointer_scrolled_inner(&mut self, delta_x: f32, delta_y: f32) -> bool {
527        if delta_x.abs() <= f32::EPSILON && delta_y.abs() <= f32::EPSILON {
528            return false;
529        }
530
531        let hits = self.renderer.scene().hit_test(self.cursor.0, self.cursor.1);
532        if hits.is_empty() {
533            return false;
534        }
535
536        let event = PointerEvent::new(
537            PointerEventKind::Scroll,
538            Point {
539                x: self.cursor.0,
540                y: self.cursor.1,
541            },
542            Point {
543                x: self.cursor.0,
544                y: self.cursor.1,
545            },
546        )
547        .with_buttons(self.buttons_pressed)
548        .with_scroll_delta(Point {
549            x: delta_x,
550            y: delta_y,
551        });
552
553        for hit in hits {
554            hit.dispatch(event.clone());
555            if event.is_consumed() {
556                break;
557            }
558        }
559
560        event.is_consumed()
561    }
562
563    /// Cancels any active gesture, dispatching Cancel events to cached targets.
564    /// Call this when:
565    /// - Window loses focus
566    /// - Mouse leaves window while button pressed
567    /// - Any other gesture abort scenario
568    pub fn cancel_gesture(&mut self) {
569        enter_event_handler();
570        let _ = run_in_mutable_snapshot(|| {
571            self.cancel_gesture_inner();
572        });
573        exit_event_handler();
574    }
575
576    fn cancel_gesture_inner(&mut self) {
577        // Resolve FRESH targets from cached NodeIds
578        let targets = self.resolve_hit_path(PointerId::PRIMARY);
579
580        // Clear tracker and button state
581        self.hit_path_tracker.clear();
582        self.buttons_pressed = PointerButtons::NONE;
583
584        if !targets.is_empty() {
585            let event = PointerEvent::new(
586                PointerEventKind::Cancel,
587                Point {
588                    x: self.cursor.0,
589                    y: self.cursor.1,
590                },
591                Point {
592                    x: self.cursor.0,
593                    y: self.cursor.1,
594                },
595            );
596
597            for hit in targets {
598                hit.dispatch(event.clone());
599            }
600        }
601    }
602    /// Routes a keyboard event to the focused text field, if any.
603    ///
604    /// Returns `true` if the event was consumed by a text field.
605    ///
606    /// On desktop, Ctrl+C/X/V are handled here with system clipboard (arboard).
607    /// On web, these keys are NOT handled here - they bubble to browser for native copy/paste events.
608    pub fn on_key_event(&mut self, event: &KeyEvent) -> bool {
609        enter_event_handler();
610        let result = self.on_key_event_inner(event);
611        exit_event_handler();
612        result
613    }
614
615    /// Internal keyboard event handler wrapped by on_key_event.
616    fn on_key_event_inner(&mut self, event: &KeyEvent) -> bool {
617        use KeyEventType::KeyDown;
618
619        // Only process KeyDown events for clipboard shortcuts
620        if event.event_type == KeyDown && event.modifiers.command_or_ctrl() {
621            // Desktop-only clipboard handling via arboard
622            // Use persistent self.clipboard to keep content alive on Linux X11
623            #[cfg(all(
624                not(target_arch = "wasm32"),
625                not(target_os = "android"),
626                not(target_os = "ios")
627            ))]
628            {
629                match event.key_code {
630                    // Ctrl+C - Copy
631                    KeyCode::C => {
632                        // Get text first, then access clipboard to avoid borrow conflict
633                        let text = self.on_copy();
634                        if let (Some(text), Some(clipboard)) = (text, self.clipboard.as_mut()) {
635                            let _ = clipboard.set_text(&text);
636                            return true;
637                        }
638                    }
639                    // Ctrl+X - Cut
640                    KeyCode::X => {
641                        // Get text first (this also deletes it), then access clipboard
642                        let text = self.on_cut();
643                        if let (Some(text), Some(clipboard)) = (text, self.clipboard.as_mut()) {
644                            let _ = clipboard.set_text(&text);
645                            self.mark_dirty();
646                            self.layout_dirty = true;
647                            return true;
648                        }
649                    }
650                    // Ctrl+V - Paste
651                    KeyCode::V => {
652                        // Get text from clipboard first, then paste
653                        let text = self.clipboard.as_mut().and_then(|cb| cb.get_text().ok());
654                        if let Some(text) = text {
655                            if self.on_paste(&text) {
656                                return true;
657                            }
658                        }
659                    }
660                    _ => {}
661                }
662            }
663        }
664
665        // Pure O(1) dispatch - no tree walking needed
666        if !cranpose_ui::text_field_focus::has_focused_field() {
667            return false;
668        }
669
670        // Wrap key event handling in a mutable snapshot so changes are atomically applied.
671        // This ensures keyboard input modifications are visible to subsequent snapshot contexts
672        // (like button click handlers that run in their own mutable snapshots).
673        let handled = run_in_mutable_snapshot(|| {
674            // O(1) dispatch via stored handler - handles ALL text input key events
675            // No fallback needed since handler now handles arrows, Home/End, word nav
676            cranpose_ui::text_field_focus::dispatch_key_event(event)
677        })
678        .unwrap_or(false);
679
680        if handled {
681            // Mark both dirty (for redraw) and layout_dirty (to rebuild semantics tree)
682            self.mark_dirty();
683            self.layout_dirty = true;
684        }
685
686        handled
687    }
688
689    /// Handles paste event from platform clipboard.
690    /// Returns `true` if the paste was consumed by a focused text field.
691    /// O(1) operation using stored handler.
692    pub fn on_paste(&mut self, text: &str) -> bool {
693        // Wrap paste in a mutable snapshot so changes are atomically applied.
694        // This ensures paste modifications are visible to subsequent snapshot contexts
695        // (like button click handlers that run in their own mutable snapshots).
696        let handled =
697            run_in_mutable_snapshot(|| cranpose_ui::text_field_focus::dispatch_paste(text))
698                .unwrap_or(false);
699
700        if handled {
701            self.mark_dirty();
702            self.layout_dirty = true;
703        }
704
705        handled
706    }
707
708    /// Handles copy request from platform.
709    /// Returns the selected text from focused text field, or None.
710    /// O(1) operation using stored handler.
711    pub fn on_copy(&mut self) -> Option<String> {
712        // Use O(1) dispatch instead of tree scan
713        cranpose_ui::text_field_focus::dispatch_copy()
714    }
715
716    /// Handles cut request from platform.
717    /// Returns the cut text from focused text field, or None.
718    /// O(1) operation using stored handler.
719    pub fn on_cut(&mut self) -> Option<String> {
720        // Use O(1) dispatch instead of tree scan
721        let text = cranpose_ui::text_field_focus::dispatch_cut();
722
723        if text.is_some() {
724            self.mark_dirty();
725            self.layout_dirty = true;
726        }
727
728        text
729    }
730
731    /// Sets the Linux primary selection (for middle-click paste).
732    /// This is called when text is selected in a text field.
733    /// On non-Linux platforms, this is a no-op.
734    #[cfg(all(target_os = "linux", not(target_arch = "wasm32")))]
735    pub fn set_primary_selection(&mut self, text: &str) {
736        use arboard::{LinuxClipboardKind, SetExtLinux};
737        if let Some(ref mut clipboard) = self.clipboard {
738            let result = clipboard
739                .set()
740                .clipboard(LinuxClipboardKind::Primary)
741                .text(text.to_string());
742            if let Err(e) = result {
743                // Primary selection may not be available on all systems
744                log::debug!("Primary selection set failed: {:?}", e);
745            }
746        }
747    }
748
749    /// Gets text from the Linux primary selection (for middle-click paste).
750    /// On non-Linux platforms, returns None.
751    #[cfg(all(target_os = "linux", not(target_arch = "wasm32")))]
752    pub fn get_primary_selection(&mut self) -> Option<String> {
753        use arboard::{GetExtLinux, LinuxClipboardKind};
754        if let Some(ref mut clipboard) = self.clipboard {
755            clipboard
756                .get()
757                .clipboard(LinuxClipboardKind::Primary)
758                .text()
759                .ok()
760        } else {
761            None
762        }
763    }
764
765    #[cfg(all(
766        not(target_os = "linux"),
767        not(target_arch = "wasm32"),
768        not(target_os = "ios")
769    ))]
770    pub fn get_primary_selection(&mut self) -> Option<String> {
771        None
772    }
773
774    /// Syncs the current text field selection to PRIMARY (Linux X11).
775    /// Call this when selection changes in a text field.
776    pub fn sync_selection_to_primary(&mut self) {
777        #[cfg(all(target_os = "linux", not(target_arch = "wasm32")))]
778        {
779            if let Some(text) = self.on_copy() {
780                self.set_primary_selection(&text);
781            }
782        }
783    }
784
785    /// Handles IME preedit (composition) events.
786    /// Called when the input method is composing text (e.g., typing CJK characters).
787    ///
788    /// - `text`: The current preedit text (empty to clear composition state)
789    /// - `cursor`: Optional cursor position within the preedit text (start, end)
790    ///
791    /// Returns `true` if a text field consumed the event.
792    pub fn on_ime_preedit(&mut self, text: &str, cursor: Option<(usize, usize)>) -> bool {
793        // Wrap in mutable snapshot for atomic changes
794        let handled = run_in_mutable_snapshot(|| {
795            cranpose_ui::text_field_focus::dispatch_ime_preedit(text, cursor)
796        })
797        .unwrap_or(false);
798
799        if handled {
800            self.mark_dirty();
801            // IME composition changes the visible text, needs layout update
802            self.layout_dirty = true;
803        }
804
805        handled
806    }
807
808    /// Handles IME delete-surrounding events.
809    /// Returns `true` if a text field consumed the event.
810    pub fn on_ime_delete_surrounding(&mut self, before_bytes: usize, after_bytes: usize) -> bool {
811        let handled = run_in_mutable_snapshot(|| {
812            cranpose_ui::text_field_focus::dispatch_delete_surrounding(before_bytes, after_bytes)
813        })
814        .unwrap_or(false);
815
816        if handled {
817            self.mark_dirty();
818            self.layout_dirty = true;
819        }
820
821        handled
822    }
823
824    pub fn log_debug_info(&mut self) {
825        println!("\n\n");
826        println!("════════════════════════════════════════════════════════");
827        println!("           DEBUG: CURRENT SCREEN STATE");
828        println!("════════════════════════════════════════════════════════");
829
830        if let Some(ref layout_tree) = self.layout_tree {
831            log_layout_tree(layout_tree);
832            let renderer = HeadlessRenderer::new();
833            let render_scene = renderer.render(layout_tree);
834            log_render_scene(&render_scene);
835            log_screen_summary(layout_tree, &render_scene);
836        } else {
837            println!("No layout available");
838        }
839
840        println!("════════════════════════════════════════════════════════");
841        println!("\n\n");
842    }
843
844    /// Get the current layout tree (for robot/testing)
845    pub fn layout_tree(&self) -> Option<&LayoutTree> {
846        self.layout_tree.as_ref()
847    }
848
849    /// Get the current semantics tree (for robot/testing)
850    pub fn semantics_tree(&self) -> Option<&SemanticsTree> {
851        self.semantics_tree.as_ref()
852    }
853
854    pub fn set_semantics_enabled(&mut self, enabled: bool) {
855        if self.semantics_enabled == enabled {
856            return;
857        }
858        self.semantics_enabled = enabled;
859        if enabled {
860            self.layout_dirty = true;
861            self.mark_dirty();
862        } else {
863            self.semantics_tree = None;
864        }
865    }
866
867    fn process_frame(&mut self) {
868        // Record frame for FPS tracking
869        fps_monitor::record_frame();
870
871        #[cfg(debug_assertions)]
872        let _frame_start = Instant::now();
873
874        self.run_layout_phase();
875
876        #[cfg(debug_assertions)]
877        let _after_layout = Instant::now();
878
879        self.run_dispatch_queues();
880
881        #[cfg(debug_assertions)]
882        let _after_dispatch = Instant::now();
883
884        self.run_render_phase();
885    }
886
887    fn run_layout_phase(&mut self) {
888        // ═══════════════════════════════════════════════════════════════════════════════
889        // SCOPED LAYOUT REPASSES (preferred path for local changes)
890        // ═══════════════════════════════════════════════════════════════════════════════
891        // Process node-specific layout invalidations (e.g., from scroll).
892        // This bubbles dirty flags up from specific nodes WITHOUT invalidating all caches.
893        // Result: O(subtree) remeasurement, not O(app).
894        let repass_nodes = cranpose_ui::take_layout_repass_nodes();
895        let had_repass_nodes = !repass_nodes.is_empty();
896        if had_repass_nodes {
897            let root = self.composition.root();
898            let mut applier = self.composition.applier_mut();
899            for node_id in repass_nodes {
900                // Bubble measure dirty flags up to root so cache epoch increments.
901                // This uses the centralized function in cranpose-core.
902                cranpose_core::bubble_measure_dirty(
903                    &mut *applier as &mut dyn cranpose_core::Applier,
904                    node_id,
905                );
906                cranpose_core::bubble_layout_dirty(
907                    &mut *applier as &mut dyn cranpose_core::Applier,
908                    node_id,
909                );
910            }
911
912            // IMPORTANT: Also mark the actual root as needing measure.
913            // The bubble may not reach root if intermediate nodes (e.g., subcomposed slot roots
914            // from SubcomposeLayout) have broken parent chains. This ensures the epoch
915            // is incremented so SubcomposeLayout re-measures its items.
916            if let Some(root) = root {
917                if let Ok(node) = applier.get_mut(root) {
918                    node.mark_needs_measure();
919                }
920            }
921
922            drop(applier);
923            self.layout_dirty = true;
924        }
925
926        // ═══════════════════════════════════════════════════════════════════════════════
927        // GLOBAL LAYOUT INVALIDATION (rare fallback for true global events)
928        // ═══════════════════════════════════════════════════════════════════════════════
929        // This is the "nuclear option" - invalidates ALL layout caches across the entire app.
930        //
931        // WHEN THIS SHOULD FIRE:
932        //   ✓ Window/viewport resize
933        //   ✓ Global font scale or density changes
934        //   ✓ Debug toggles that affect layout globally
935        //
936        // WHEN THIS SHOULD *NOT* FIRE:
937        //   ✗ Scroll (use schedule_layout_repass instead)
938        //   ✗ Single widget updates (use schedule_layout_repass instead)
939        //   ✗ Any local layout change (use schedule_layout_repass instead)
940        //
941        // If you see this firing frequently during normal interactions,
942        // someone is abusing request_layout_invalidation() - investigate!
943        let invalidation_requested = take_layout_invalidation();
944
945        // Only do global cache invalidation if:
946        // 1. Invalidation was requested (flag was set)
947        // 2. AND there were no scoped repass nodes (which handle layout more efficiently)
948        //
949        // If scoped repasses were handled above, they've already marked the tree dirty
950        // and bubbled up the hierarchy. We don't need to also invalidate all caches.
951        if invalidation_requested && !had_repass_nodes {
952            // Invalidate all caches (O(app size) - expensive!)
953            // This is internal-only API, only accessible via the internal path
954            cranpose_ui::layout::invalidate_all_layout_caches();
955
956            // Mark root as needing layout AND measure so tree_needs_layout() returns true
957            // and intrinsic sizes are recalculated (e.g., text field resizing on content change)
958            if let Some(root) = self.composition.root() {
959                let mut applier = self.composition.applier_mut();
960                if let Ok(node) = applier.get_mut(root) {
961                    if let Some(layout_node) =
962                        node.as_any_mut().downcast_mut::<cranpose_ui::LayoutNode>()
963                    {
964                        layout_node.mark_needs_measure();
965                        layout_node.mark_needs_layout();
966                    }
967                }
968            }
969            self.layout_dirty = true;
970        } else if invalidation_requested {
971            // Invalidation was requested but scoped repasses already handled it.
972            // Just make sure layout_dirty is set.
973            self.layout_dirty = true;
974        }
975
976        // Early exit if layout is not needed (viewport didn't change, etc.)
977        if !self.layout_dirty {
978            return;
979        }
980
981        let viewport_size = Size {
982            width: self.viewport.0,
983            height: self.viewport.1,
984        };
985        if let Some(root) = self.composition.root() {
986            let handle = self.composition.runtime_handle();
987            let mut applier = self.composition.applier_mut();
988            applier.set_runtime_handle(handle);
989
990            // Selective measure optimization: skip layout if tree is clean (O(1) check)
991            // UNLESS layout_dirty was explicitly set (e.g., from keyboard input)
992            let tree_needs_layout_check = cranpose_ui::tree_needs_layout(&mut *applier, root)
993                .unwrap_or_else(|err| {
994                    log::warn!(
995                        "Cannot check layout dirty status for root #{}: {}",
996                        root,
997                        err
998                    );
999                    true // Assume dirty on error
1000                });
1001
1002            // Force layout if either:
1003            // 1. Tree nodes are marked dirty (tree_needs_layout_check = true)
1004            // 2. layout_dirty was explicitly set (e.g., from keyboard/external events)
1005            let needs_layout = tree_needs_layout_check || self.layout_dirty;
1006
1007            if !needs_layout {
1008                // Tree is clean and no external dirtying - skip layout computation
1009                log::trace!("Skipping layout: tree is clean");
1010                self.layout_dirty = false;
1011                applier.clear_runtime_handle();
1012                return;
1013            }
1014
1015            // Tree needs layout - compute it
1016            self.layout_dirty = false;
1017
1018            // Ensure slots exist and borrow mutably (handled inside measure_layout via MemoryApplier)
1019            match cranpose_ui::measure_layout_with_options(
1020                &mut applier,
1021                root,
1022                viewport_size,
1023                MeasureLayoutOptions {
1024                    collect_semantics: self.semantics_enabled,
1025                },
1026            ) {
1027                Ok(measurements) => {
1028                    self.semantics_tree = measurements.semantics_tree().cloned();
1029                    self.layout_tree = Some(measurements.into_layout_tree());
1030                    self.scene_dirty = true;
1031                }
1032                Err(err) => {
1033                    log::error!("failed to compute layout: {err}");
1034                    self.layout_tree = None;
1035                    self.semantics_tree = None;
1036                    self.scene_dirty = true;
1037                }
1038            }
1039            applier.clear_runtime_handle();
1040        } else {
1041            self.layout_tree = None;
1042            self.semantics_tree = None;
1043            self.scene_dirty = true;
1044            self.layout_dirty = false;
1045        }
1046    }
1047
1048    fn run_dispatch_queues(&mut self) {
1049        // Process pointer input repasses
1050        // Similar to Jetpack Compose's pointer input invalidation processing,
1051        // we service nodes that need pointer input state updates without forcing layout/draw
1052        if has_pending_pointer_repasses() {
1053            let mut applier = self.composition.applier_mut();
1054            process_pointer_repasses(|node_id| {
1055                match clear_dispatch_invalidation(
1056                    &mut applier,
1057                    node_id,
1058                    DispatchInvalidationKind::Pointer,
1059                ) {
1060                    Ok(true) => {
1061                        log::trace!("Cleared pointer repass flag for node #{}", node_id);
1062                    }
1063                    Ok(false) => {}
1064                    Err(err) => {
1065                        log::debug!(
1066                            "Could not process pointer repass for node #{}: {}",
1067                            node_id,
1068                            err
1069                        );
1070                    }
1071                }
1072            });
1073        }
1074
1075        // Process focus invalidations
1076        // Mirrors Jetpack Compose's FocusInvalidationManager.invalidateNodes(),
1077        // processing nodes that need focus state synchronization
1078        if has_pending_focus_invalidations() {
1079            let mut applier = self.composition.applier_mut();
1080            process_focus_invalidations(|node_id| {
1081                match clear_dispatch_invalidation(
1082                    &mut applier,
1083                    node_id,
1084                    DispatchInvalidationKind::Focus,
1085                ) {
1086                    Ok(true) => {
1087                        log::trace!("Cleared focus sync flag for node #{}", node_id);
1088                    }
1089                    Ok(false) => {}
1090                    Err(err) => {
1091                        log::debug!(
1092                            "Could not process focus invalidation for node #{}: {}",
1093                            node_id,
1094                            err
1095                        );
1096                    }
1097                }
1098            });
1099        }
1100    }
1101
1102    fn refresh_draw_repasses(&mut self) {
1103        let dirty_nodes = take_draw_repass_nodes();
1104        if dirty_nodes.is_empty() {
1105            return;
1106        }
1107
1108        let Some(layout_tree) = self.layout_tree.as_mut() else {
1109            return;
1110        };
1111
1112        let dirty_set: HashSet<NodeId> = dirty_nodes.into_iter().collect();
1113        let mut applier = self.composition.applier_mut();
1114        let refresh_scope = build_draw_refresh_scope(&mut applier, &dirty_set);
1115        refresh_layout_box_data(
1116            &mut applier,
1117            layout_tree.root_mut(),
1118            &refresh_scope,
1119            &dirty_set,
1120        );
1121    }
1122
1123    fn run_render_phase(&mut self) {
1124        let render_dirty = take_render_invalidation();
1125        take_pointer_invalidation();
1126        take_focus_invalidation();
1127        let draw_repass_pending = cranpose_ui::has_pending_draw_repasses();
1128        // Tick cursor blink timer - only marks dirty when visibility state changes
1129        let cursor_blink_dirty = cranpose_ui::tick_cursor_blink();
1130
1131        let render_only_dirty = render_dirty || cursor_blink_dirty;
1132        // Pointer/focus queues mutate live node state during dispatch. Direct applier rendering
1133        // reads that state on demand, so only real scene dirties require a rebuild here.
1134        let needs_scene_rebuild = self.scene_dirty || draw_repass_pending || render_only_dirty;
1135
1136        if !needs_scene_rebuild {
1137            return;
1138        }
1139        self.scene_dirty = false;
1140        self.refresh_draw_repasses();
1141        let viewport_size = Size {
1142            width: self.viewport.0,
1143            height: self.viewport.1,
1144        };
1145
1146        // Use new direct traversal rendering
1147        if let Some(root) = self.composition.root() {
1148            let mut applier = self.composition.applier_mut();
1149            if let Err(err) =
1150                self.renderer
1151                    .rebuild_scene_from_applier(&mut applier, root, viewport_size)
1152            {
1153                // Fallback to clearing scene on error
1154                log::error!("renderer rebuild failed: {err:?}");
1155                self.renderer.scene_mut().clear();
1156            }
1157        } else {
1158            self.renderer.scene_mut().clear();
1159        }
1160
1161        // Draw FPS overlay if enabled (directly by renderer, no composition)
1162        if self.dev_options.fps_counter {
1163            let stats = fps_monitor::fps_stats();
1164            let text = format!(
1165                "{:.0} FPS | {:.1}ms | {} recomp/s",
1166                stats.fps, stats.avg_ms, stats.recomps_per_second
1167            );
1168            self.renderer.draw_dev_overlay(&text, viewport_size);
1169        }
1170    }
1171}
1172
1173fn clear_dispatch_invalidation(
1174    applier: &mut MemoryApplier,
1175    node_id: NodeId,
1176    invalidation: DispatchInvalidationKind,
1177) -> Result<bool, NodeError> {
1178    match invalidation {
1179        DispatchInvalidationKind::Pointer => {
1180            match applier.with_node::<LayoutNode, _>(node_id, |node| {
1181                let needs_pointer_pass = node.needs_pointer_pass();
1182                if needs_pointer_pass {
1183                    node.clear_needs_pointer_pass();
1184                }
1185                needs_pointer_pass
1186            }) {
1187                Ok(cleared) => Ok(cleared),
1188                Err(NodeError::TypeMismatch { .. }) => applier
1189                    .with_node::<SubcomposeLayoutNode, _>(node_id, |node| {
1190                        let needs_pointer_pass = node.needs_pointer_pass();
1191                        if needs_pointer_pass {
1192                            node.clear_needs_pointer_pass();
1193                        }
1194                        needs_pointer_pass
1195                    }),
1196                Err(err) => Err(err),
1197            }
1198        }
1199        DispatchInvalidationKind::Focus => {
1200            match applier.with_node::<LayoutNode, _>(node_id, |node| {
1201                let needs_focus_sync = node.needs_focus_sync();
1202                if needs_focus_sync {
1203                    node.clear_needs_focus_sync();
1204                }
1205                needs_focus_sync
1206            }) {
1207                Ok(cleared) => Ok(cleared),
1208                Err(NodeError::TypeMismatch { .. }) => applier
1209                    .with_node::<SubcomposeLayoutNode, _>(node_id, |node| {
1210                        let needs_focus_sync = node.needs_focus_sync();
1211                        if needs_focus_sync {
1212                            node.clear_needs_focus_sync();
1213                        }
1214                        needs_focus_sync
1215                    }),
1216                Err(err) => Err(err),
1217            }
1218        }
1219    }
1220}
1221
1222fn build_draw_refresh_scope(
1223    applier: &mut MemoryApplier,
1224    dirty_nodes: &HashSet<NodeId>,
1225) -> HashSet<NodeId> {
1226    let mut refresh_scope = HashSet::with_capacity(dirty_nodes.len());
1227    for &dirty_node in dirty_nodes {
1228        let mut current = Some(dirty_node);
1229        while let Some(node_id) = current {
1230            if !refresh_scope.insert(node_id) {
1231                break;
1232            }
1233            current = applier.get_mut(node_id).ok().and_then(|node| node.parent());
1234        }
1235    }
1236    refresh_scope
1237}
1238
1239fn refresh_layout_box_data(
1240    applier: &mut MemoryApplier,
1241    layout: &mut cranpose_ui::layout::LayoutBox,
1242    refresh_scope: &HashSet<NodeId>,
1243    dirty_nodes: &HashSet<NodeId>,
1244) {
1245    if !refresh_scope.contains(&layout.node_id) {
1246        return;
1247    }
1248
1249    if dirty_nodes.contains(&layout.node_id) {
1250        if let Ok((modifier, resolved_modifiers, slices)) =
1251            applier.with_node::<LayoutNode, _>(layout.node_id, |node| {
1252                node.clear_needs_redraw();
1253                (
1254                    node.modifier.clone(),
1255                    node.resolved_modifiers(),
1256                    node.modifier_slices_snapshot(),
1257                )
1258            })
1259        {
1260            layout.node_data.modifier = modifier;
1261            layout.node_data.resolved_modifiers = resolved_modifiers;
1262            layout.node_data.modifier_slices = slices;
1263        } else if let Ok((modifier, resolved_modifiers)) = applier
1264            .with_node::<SubcomposeLayoutNode, _>(layout.node_id, |node| {
1265                node.clear_needs_redraw();
1266                (node.modifier(), node.resolved_modifiers())
1267            })
1268        {
1269            layout.node_data.modifier = modifier.clone();
1270            layout.node_data.resolved_modifiers = resolved_modifiers;
1271            layout.node_data.modifier_slices =
1272                std::rc::Rc::new(cranpose_ui::collect_slices_from_modifier(&modifier));
1273        }
1274    }
1275
1276    for child in &mut layout.children {
1277        refresh_layout_box_data(applier, child, refresh_scope, dirty_nodes);
1278    }
1279}
1280
1281impl<R> Drop for AppShell<R>
1282where
1283    R: Renderer,
1284{
1285    fn drop(&mut self) {
1286        self.runtime.clear_frame_waker();
1287    }
1288}
1289
1290pub fn default_root_key() -> Key {
1291    location_key(file!(), line!(), column!())
1292}
1293
1294#[cfg(test)]
1295#[path = "tests/app_shell_tests.rs"]
1296mod tests;