Skip to main content

cranpose_app_shell/
shell_frame.rs

1use super::*;
2
3const DEV_OVERLAY_PADDING: f32 = 8.0;
4const DEV_OVERLAY_FONT_SIZE: f32 = 14.0;
5const DEV_OVERLAY_CHAR_WIDTH: f32 = 7.0;
6const DEV_OVERLAY_REFRESH_INTERVAL: std::time::Duration = std::time::Duration::from_millis(250);
7const DEFAULT_FRAME_STAGE_TELEMETRY_THRESHOLD_MS: f64 = 4.0;
8
9#[derive(Copy, Clone)]
10enum DispatchInvalidationKind {
11    Pointer,
12    Focus,
13}
14
15fn frame_stage_telemetry_threshold_ms() -> Option<f64> {
16    static THRESHOLD_MS: std::sync::OnceLock<Option<f64>> = std::sync::OnceLock::new();
17    *THRESHOLD_MS.get_or_init(|| {
18        let explicit = std::env::var("CRANPOSE_FRAME_STAGE_TELEMETRY_MS")
19            .ok()
20            .and_then(|value| value.parse::<f64>().ok())
21            .filter(|value| value.is_finite() && *value >= 0.0);
22        explicit.or_else(|| {
23            std::env::var_os("CRANPOSE_FRAME_STAGE_TELEMETRY")
24                .is_some()
25                .then_some(DEFAULT_FRAME_STAGE_TELEMETRY_THRESHOLD_MS)
26        })
27    })
28}
29
30fn log_frame_stage_telemetry(
31    frame_start: Instant,
32    after_initial_layout: Instant,
33    after_layout: Instant,
34    after_dispatch: Instant,
35    after_render: Instant,
36) {
37    let Some(threshold_ms) = frame_stage_telemetry_threshold_ms() else {
38        return;
39    };
40
41    let total_ms = after_render.duration_since(frame_start).as_secs_f64() * 1000.0;
42    if total_ms < threshold_ms {
43        return;
44    }
45
46    let layout_ms = after_layout.duration_since(frame_start).as_secs_f64() * 1000.0;
47    let initial_layout_ms = after_initial_layout
48        .duration_since(frame_start)
49        .as_secs_f64()
50        * 1000.0;
51    let post_layout_ms = after_layout
52        .duration_since(after_initial_layout)
53        .as_secs_f64()
54        * 1000.0;
55    let dispatch_ms = after_dispatch.duration_since(after_layout).as_secs_f64() * 1000.0;
56    let scene_ms = after_render.duration_since(after_dispatch).as_secs_f64() * 1000.0;
57    log::warn!(
58        "[frame-stage-telemetry] total_ms={total_ms:.2} layout_ms={layout_ms:.2} initial_layout_ms={initial_layout_ms:.2} post_layout_ms={post_layout_ms:.2} dispatch_ms={dispatch_ms:.2} scene_ms={scene_ms:.2}",
59    );
60}
61
62fn render_phase_dirty_diagnostics_enabled() -> bool {
63    cranpose_core::env_flag!("CRANPOSE_RENDER_PHASE_DIRTY_DIAG")
64}
65
66struct RenderPhaseDirtyDiagnostics<'a> {
67    render_dirty: bool,
68    pointer_dirty: bool,
69    scene_dirty: bool,
70    draw_repass_pending: bool,
71    draw_dirty_nodes: usize,
72    layout_dirty_nodes: usize,
73    structural_dirty_nodes: usize,
74    partial_dirty_nodes: usize,
75    dirty_node_ids: Option<String>,
76    render_only_dirty: bool,
77    recomposed_this_frame: bool,
78    path: &'a str,
79}
80
81fn log_render_phase_dirty_diagnostics(diagnostics: RenderPhaseDirtyDiagnostics<'_>) {
82    if !render_phase_dirty_diagnostics_enabled() {
83        return;
84    }
85
86    let RenderPhaseDirtyDiagnostics {
87        render_dirty,
88        pointer_dirty,
89        scene_dirty,
90        draw_repass_pending,
91        draw_dirty_nodes,
92        layout_dirty_nodes,
93        structural_dirty_nodes,
94        partial_dirty_nodes,
95        dirty_node_ids,
96        render_only_dirty,
97        recomposed_this_frame,
98        path,
99    } = diagnostics;
100    if let Some(dirty_node_ids) = dirty_node_ids {
101        log::warn!(
102            "[render-phase-dirty] path={path} render_dirty={render_dirty} pointer_dirty={pointer_dirty} scene_dirty={scene_dirty} draw_repass_pending={draw_repass_pending} draw_dirty_nodes={draw_dirty_nodes} layout_dirty_nodes={layout_dirty_nodes} structural_dirty_nodes={structural_dirty_nodes} partial_dirty_nodes={partial_dirty_nodes} render_only_dirty={render_only_dirty} recomposed_this_frame={recomposed_this_frame} ids={dirty_node_ids}",
103        );
104    } else {
105        log::warn!(
106            "[render-phase-dirty] path={path} render_dirty={render_dirty} pointer_dirty={pointer_dirty} scene_dirty={scene_dirty} draw_repass_pending={draw_repass_pending} draw_dirty_nodes={draw_dirty_nodes} layout_dirty_nodes={layout_dirty_nodes} structural_dirty_nodes={structural_dirty_nodes} partial_dirty_nodes={partial_dirty_nodes} render_only_dirty={render_only_dirty} recomposed_this_frame={recomposed_this_frame}",
107        );
108    }
109}
110
111impl<R> AppShell<R>
112where
113    R: Renderer,
114    R::Error: Debug,
115{
116    pub fn set_semantics_enabled(&mut self, enabled: bool) {
117        if self.semantics_enabled == enabled {
118            return;
119        }
120        self.semantics_enabled = enabled;
121        self.semantics_snapshot_revision = self.semantics_snapshot_revision.wrapping_add(1);
122        if enabled {
123            self.request_forced_layout_pass();
124            self.mark_dirty();
125        } else {
126            self.semantics_tree = None;
127        }
128    }
129
130    /// Sets how the platform should vote the display's frame rate for this
131    /// app. The default, [`FrameRatePreference::Auto`], mirrors Compose: the
132    /// panel's fastest rate while frames are being produced, no preference
133    /// when the scene is still.
134    pub fn set_frame_rate_preference(&mut self, preference: crate::FrameRatePreference) {
135        self.frame_rate_preference = preference;
136    }
137
138    /// The app's current display frame-rate preference. Read by platform
139    /// backends each frame; the vote itself is applied by the backend that
140    /// owns the native window.
141    pub fn frame_rate_preference(&self) -> crate::FrameRatePreference {
142        self.frame_rate_preference
143    }
144
145    pub(crate) fn process_frame(&mut self) -> FrameUpdateResult {
146        let app_context = Rc::clone(&self.app_context);
147        app_context.enter(|| self.process_frame_in_context(false))
148    }
149
150    pub(crate) fn process_frame_in_context(
151        &mut self,
152        recomposed_before_frame: bool,
153    ) -> FrameUpdateResult {
154        let frame_start = Instant::now();
155
156        self.run_layout_phase();
157        let after_initial_layout = Instant::now();
158
159        let recomposed_this_frame = recomposed_before_frame || self.run_post_layout_recomposition();
160
161        let after_layout = Instant::now();
162
163        self.run_dispatch_queues();
164
165        let after_dispatch = Instant::now();
166
167        clear_transient_scroll_motion_contexts();
168
169        let result = self.run_render_phase_with_recomposition_state(recomposed_this_frame);
170        let after_render = Instant::now();
171        log_frame_stage_telemetry(
172            frame_start,
173            after_initial_layout,
174            after_layout,
175            after_dispatch,
176            after_render,
177        );
178        result
179    }
180
181    pub(crate) fn run_layout_phase(&mut self) {
182        let app_context = Rc::clone(&self.app_context);
183        app_context.enter(|| self.run_layout_phase_in_context());
184    }
185
186    fn run_layout_phase_in_context(&mut self) {
187        // Both scoped layout repasses (re-placement) and scoped measure repasses
188        // (re-sizing, e.g. a collapsing swipe-dismissed row) drive a layout pass.
189        let has_scoped_repasses = cranpose_ui::has_pending_layout_repasses()
190            || cranpose_ui::has_pending_measure_repasses();
191        let scoped_layout_nodes = if cranpose_ui::has_pending_layout_repasses() {
192            cranpose_ui::pending_layout_repass_nodes_snapshot()
193        } else {
194            Vec::new()
195        };
196
197        // Global layout invalidation is reserved for app-wide inputs such as
198        // viewport, density, font-scale, or debug layout changes. Normal node
199        // updates should arrive through scoped repasses.
200        let invalidation_requested = take_layout_invalidation();
201        let global_layout_invalidation = invalidation_requested && !has_scoped_repasses;
202        let force_layout_pass = self.force_layout_pass;
203
204        if invalidation_requested && !has_scoped_repasses {
205            cranpose_ui::layout::invalidate_all_layout_caches();
206
207            // Mark root as needing layout AND measure so tree_needs_layout() returns true
208            // and intrinsic sizes are recalculated (e.g., text field resizing on content change)
209            if let Some(root) = self.composition.root() {
210                let mut applier = self.composition.applier_mut();
211                match applier.with_node::<LayoutNode, _>(root, |node| {
212                    node.mark_needs_measure();
213                    node.mark_needs_layout();
214                }) {
215                    Ok(()) | Err(NodeError::Missing { .. }) => {}
216                    Err(NodeError::TypeMismatch { .. }) => {
217                        let _ = applier.with_node::<SubcomposeLayoutNode, _>(root, |node| {
218                            node.mark_needs_measure();
219                            node.mark_needs_layout_flag();
220                        });
221                    }
222                    Err(_) => {}
223                }
224            }
225            self.request_forced_layout_pass();
226        } else if invalidation_requested || has_scoped_repasses {
227            self.request_layout_pass();
228        }
229
230        if !self.layout_requested {
231            return;
232        }
233
234        let viewport_size = Size {
235            width: self.viewport.0,
236            height: self.viewport.1,
237        };
238        if let Some(root) = self.composition.root() {
239            let handle = self.composition.runtime_handle();
240            let mut applier = self.composition.applier_mut();
241            applier.set_runtime_handle(handle);
242
243            let tree_needs_layout_check = cranpose_ui::tree_needs_layout(&mut *applier, root)
244                .unwrap_or_else(|err| {
245                    log::warn!(
246                        "Cannot check layout dirty status for root #{}: {}",
247                        root,
248                        err
249                    );
250                    true // Assume dirty on error
251                });
252
253            let needs_layout =
254                self.force_layout_pass || has_scoped_repasses || tree_needs_layout_check;
255
256            if !needs_layout {
257                log::trace!("Skipping layout: tree is clean");
258                self.layout_requested = false;
259                self.force_layout_pass = false;
260                applier.clear_runtime_handle();
261                return;
262            }
263
264            self.layout_requested = false;
265            self.force_layout_pass = false;
266
267            // Ensure slots exist and borrow mutably (handled inside measure_layout via MemoryApplier)
268            match cranpose_ui::measure_layout_with_options(
269                &mut applier,
270                root,
271                viewport_size,
272                MeasureLayoutOptions {
273                    collect_semantics: false,
274                    build_layout_tree: false,
275                },
276            ) {
277                Ok(_measurements) => {
278                    self.layout_tree = None;
279                    self.semantics_snapshot_revision =
280                        self.semantics_snapshot_revision.wrapping_add(1);
281                    if self.semantics_enabled {
282                        self.semantics_tree = None;
283                    }
284                    if has_scoped_repasses
285                        && !global_layout_invalidation
286                        && !force_layout_pass
287                        && !scoped_layout_nodes.is_empty()
288                    {
289                        self.scoped_layout_scene_nodes = scoped_layout_nodes;
290                    } else {
291                        self.scoped_layout_scene_nodes.clear();
292                    }
293                    self.scene_dirty = true;
294                }
295                Err(err) => {
296                    log::error!("failed to compute layout: {err}");
297                    self.layout_tree = None;
298                    self.semantics_tree = None;
299                    self.semantics_snapshot_revision =
300                        self.semantics_snapshot_revision.wrapping_add(1);
301                    self.scoped_layout_scene_nodes.clear();
302                    self.scene_dirty = true;
303                }
304            }
305            applier.clear_runtime_handle();
306        } else {
307            self.layout_tree = None;
308            self.semantics_tree = None;
309            self.semantics_snapshot_revision = self.semantics_snapshot_revision.wrapping_add(1);
310            self.scoped_layout_scene_nodes.clear();
311            self.scene_dirty = true;
312            self.layout_requested = false;
313            self.force_layout_pass = false;
314        }
315    }
316
317    fn run_post_layout_recomposition(&mut self) -> bool {
318        if !self.composition.should_recompose() {
319            return false;
320        }
321
322        let Some(root_key) = self.composition.root_key() else {
323            return false;
324        };
325
326        match self.composition.reconcile(root_key, &mut *self.content) {
327            Ok(changed) => {
328                if !changed {
329                    return false;
330                }
331                self.fps_monitor.record_recomposition();
332                if self.composition_tree_needs_layout() {
333                    self.request_layout_pass();
334                    self.run_layout_phase_in_context();
335                }
336                request_render_invalidation();
337                true
338            }
339            Err(NodeError::Missing { id }) => {
340                log::debug!(
341                    "Post-layout recomposition skipped: node {} no longer exists",
342                    id
343                );
344                self.request_layout_pass();
345                request_render_invalidation();
346                true
347            }
348            Err(err) => {
349                log::error!("post-layout recomposition failed: {err}");
350                self.request_layout_pass();
351                request_render_invalidation();
352                true
353            }
354        }
355    }
356
357    fn run_dispatch_queues(&mut self) {
358        // Process pointer input repasses
359        // Similar to Jetpack Compose's pointer input invalidation processing,
360        // we service nodes that need pointer input state updates without forcing layout/draw
361        if has_pending_pointer_repasses() {
362            let mut applier = self.composition.applier_mut();
363            process_pointer_repasses(|node_id| {
364                match clear_dispatch_invalidation(
365                    &mut applier,
366                    node_id,
367                    DispatchInvalidationKind::Pointer,
368                ) {
369                    Ok(true) => {
370                        log::trace!("Cleared pointer repass flag for node #{}", node_id);
371                    }
372                    Ok(false) => {}
373                    Err(err) => {
374                        log::debug!(
375                            "Could not process pointer repass for node #{}: {}",
376                            node_id,
377                            err
378                        );
379                    }
380                }
381            });
382        }
383
384        // Process focus invalidations
385        // Mirrors Jetpack Compose's FocusInvalidationManager.invalidateNodes(),
386        // processing nodes that need focus state synchronization
387        if has_pending_focus_invalidations() {
388            let mut applier = self.composition.applier_mut();
389            process_focus_invalidations(|node_id| {
390                match clear_dispatch_invalidation(
391                    &mut applier,
392                    node_id,
393                    DispatchInvalidationKind::Focus,
394                ) {
395                    Ok(true) => {
396                        log::trace!("Cleared focus sync flag for node #{}", node_id);
397                    }
398                    Ok(false) => {}
399                    Err(err) => {
400                        log::debug!(
401                            "Could not process focus invalidation for node #{}: {}",
402                            node_id,
403                            err
404                        );
405                    }
406                }
407            });
408        }
409    }
410
411    fn refresh_draw_repasses(&mut self) -> Vec<NodeId> {
412        let dirty_nodes = take_draw_repass_nodes();
413        if dirty_nodes.is_empty() {
414            return dirty_nodes;
415        }
416        self.refresh_draw_nodes(dirty_nodes)
417    }
418
419    fn refresh_retained_redraw_nodes(&mut self) -> Vec<NodeId> {
420        let Some(root) = self.composition.root() else {
421            return Vec::new();
422        };
423        let mut dirty_nodes = Vec::new();
424        {
425            let mut applier = self.composition.applier_mut();
426            collect_retained_redraw_nodes(&mut applier, root, &mut dirty_nodes);
427        }
428        if dirty_nodes.is_empty() {
429            return dirty_nodes;
430        }
431        self.refresh_draw_nodes(dirty_nodes)
432    }
433
434    fn refresh_draw_nodes(&mut self, dirty_nodes: Vec<NodeId>) -> Vec<NodeId> {
435        let Some(layout_tree) = self.layout_tree.as_mut() else {
436            return dirty_nodes;
437        };
438
439        let dirty_set: HashSet<NodeId> = dirty_nodes.into_iter().collect();
440        let mut applier = self.composition.applier_mut();
441        let refresh_scope = build_draw_refresh_scope(&mut applier, &dirty_set);
442        refresh_layout_box_data(
443            &mut applier,
444            layout_tree.root_mut(),
445            &refresh_scope,
446            &dirty_set,
447        );
448        dirty_set.into_iter().collect()
449    }
450
451    #[cfg(test)]
452    pub(crate) fn run_render_phase(&mut self) -> FrameUpdateResult {
453        let app_context = Rc::clone(&self.app_context);
454        app_context.enter(|| self.run_render_phase_in_context(false))
455    }
456
457    fn run_render_phase_with_recomposition_state(
458        &mut self,
459        recomposed_this_frame: bool,
460    ) -> FrameUpdateResult {
461        let app_context = Rc::clone(&self.app_context);
462        app_context.enter(|| self.run_render_phase_in_context(recomposed_this_frame))
463    }
464
465    fn run_render_phase_in_context(&mut self, recomposed_this_frame: bool) -> FrameUpdateResult {
466        let render_dirty = take_render_invalidation();
467        let pointer_dirty = take_pointer_invalidation();
468        take_focus_invalidation();
469        let draw_repass_pending = cranpose_ui::has_pending_draw_repasses();
470        let mut draw_dirty_nodes = self.refresh_draw_repasses();
471        if render_dirty && !draw_repass_pending && draw_dirty_nodes.is_empty() {
472            draw_dirty_nodes = self.refresh_retained_redraw_nodes();
473        }
474        let layout_dirty_nodes = std::mem::take(&mut self.scoped_layout_scene_nodes);
475        // Parents whose child lists changed structurally this frame. A scoped
476        // scene update patches only the dirty subtrees, so every structural
477        // parent must be in scope or a removed subtree's layers stay in the
478        // persistent render graph and keep compositing (the cross-tab ghost).
479        let structural_parents = if cranpose_core::env_flag!("CRANPOSE_DISABLE_STRUCTURAL_DIRT") {
480            Vec::new()
481        } else if let Some(root) = self.composition.root() {
482            self.composition
483                .applier_mut()
484                .take_structural_change_parents_attached_to(root)
485        } else {
486            Vec::new()
487        };
488        let structural_dirty = !structural_parents.is_empty();
489        let mut partial_dirty_nodes = draw_dirty_nodes.clone();
490        partial_dirty_nodes.extend(layout_dirty_nodes.iter().copied());
491        partial_dirty_nodes.extend(structural_parents.iter().copied());
492        partial_dirty_nodes.sort_unstable();
493        partial_dirty_nodes.dedup();
494        let draw_dirty_node_count = draw_dirty_nodes.len();
495        let layout_dirty_node_count = layout_dirty_nodes.len();
496        let structural_dirty_node_count = structural_parents.len();
497        let partial_dirty_node_count = partial_dirty_nodes.len();
498        // Detect a focused text field that left the composition this frame:
499        // the check clears the stale focus entry and asks the platform to
500        // hide its soft keyboard (no-op while focus is live or when no
501        // keyboard was requested).
502        let _ = cranpose_ui::has_focused_field();
503        // Tick cursor blink timer - only marks dirty when visibility state changes
504        let cursor_blink_dirty = cranpose_ui::tick_cursor_blink();
505
506        let render_only_dirty = (render_dirty
507            && partial_dirty_nodes.is_empty()
508            && !draw_repass_pending
509            && !structural_dirty)
510            || cursor_blink_dirty;
511        let scene_dirty = self.scene_dirty;
512        let draw_only_partial_dirty = !draw_dirty_nodes.is_empty()
513            && layout_dirty_nodes.is_empty()
514            && !pointer_dirty
515            && !recomposed_this_frame
516            && !structural_dirty;
517        let scoped_scene_dirty = scene_dirty && !layout_dirty_nodes.is_empty();
518        let full_scene_dirty = scene_dirty && !scoped_scene_dirty && !draw_only_partial_dirty;
519        let partial_scene_dirty = !partial_dirty_nodes.is_empty();
520        let needs_scene_rebuild = full_scene_dirty
521            || scoped_scene_dirty
522            || partial_scene_dirty
523            || draw_repass_pending
524            || render_only_dirty
525            || structural_dirty;
526
527        if !needs_scene_rebuild {
528            log_render_phase_dirty_diagnostics(RenderPhaseDirtyDiagnostics {
529                render_dirty,
530                pointer_dirty,
531                scene_dirty,
532                draw_repass_pending,
533                draw_dirty_nodes: draw_dirty_node_count,
534                layout_dirty_nodes: layout_dirty_node_count,
535                structural_dirty_nodes: structural_dirty_node_count,
536                partial_dirty_nodes: partial_dirty_node_count,
537                dirty_node_ids: render_phase_dirty_diagnostics_enabled().then(|| {
538                    format!(
539                        "draw={:?} layout={:?} structural={:?} partial={:?}",
540                        draw_dirty_nodes,
541                        layout_dirty_nodes,
542                        structural_parents,
543                        partial_dirty_nodes
544                    )
545                }),
546                render_only_dirty,
547                recomposed_this_frame,
548                path: "skip",
549            });
550            self.scoped_layout_scene_nodes = layout_dirty_nodes;
551            return FrameUpdateResult::default();
552        }
553        self.scene_dirty = false;
554        let viewport_size = Size {
555            width: self.viewport.0,
556            height: self.viewport.1,
557        };
558        let visual_update_only =
559            draw_only_partial_dirty && !partial_dirty_nodes.is_empty() && !full_scene_dirty;
560        let structure_changed = !render_only_dirty && !visual_update_only;
561
562        // Use new direct traversal rendering
563        if let Some(root) = self.composition.root() {
564            let mut applier = self.composition.applier_mut();
565            let use_partial_update =
566                !partial_dirty_nodes.is_empty() && !render_only_dirty && !full_scene_dirty;
567            let use_visual_update = use_partial_update && draw_only_partial_dirty;
568            log_render_phase_dirty_diagnostics(RenderPhaseDirtyDiagnostics {
569                render_dirty,
570                pointer_dirty,
571                scene_dirty,
572                draw_repass_pending,
573                draw_dirty_nodes: draw_dirty_node_count,
574                layout_dirty_nodes: layout_dirty_node_count,
575                structural_dirty_nodes: structural_dirty_node_count,
576                partial_dirty_nodes: partial_dirty_node_count,
577                dirty_node_ids: render_phase_dirty_diagnostics_enabled().then(|| {
578                    format!(
579                        "draw={:?} layout={:?} structural={:?} partial={:?}",
580                        draw_dirty_nodes,
581                        layout_dirty_nodes,
582                        structural_parents,
583                        partial_dirty_nodes
584                    )
585                }),
586                render_only_dirty,
587                recomposed_this_frame,
588                path: if use_visual_update {
589                    "visual-update"
590                } else if use_partial_update {
591                    "update"
592                } else {
593                    "rebuild"
594                },
595            });
596            let rebuild_result = if use_visual_update {
597                self.renderer.update_visual_scene_from_applier(
598                    &mut applier,
599                    root,
600                    viewport_size,
601                    &partial_dirty_nodes,
602                )
603            } else if use_partial_update {
604                self.renderer.update_scene_from_applier(
605                    &mut applier,
606                    root,
607                    viewport_size,
608                    &partial_dirty_nodes,
609                )
610            } else {
611                self.renderer
612                    .rebuild_scene_from_applier(&mut applier, root, viewport_size)
613            };
614            if let Err(err) = rebuild_result {
615                // Fallback to clearing scene on error
616                log::error!("renderer rebuild failed: {err:?}");
617                self.renderer.scene_mut().clear();
618            }
619        } else {
620            self.renderer.scene_mut().clear();
621        }
622
623        // Draw FPS overlay if enabled (directly by renderer, no composition)
624        if self.dev_options.fps_counter {
625            self.refresh_dev_overlay_text_for_frame_at(viewport_size, Instant::now());
626            let renderer = &mut self.renderer;
627            let text = self.dev_overlay_text.as_str();
628            renderer.draw_dev_overlay(text, viewport_size);
629        }
630
631        FrameUpdateResult {
632            visual_changed: true,
633            structure_changed,
634        }
635    }
636
637    fn refresh_dev_overlay_text_for_frame_at(&mut self, viewport_size: Size, now: Instant) {
638        if !self.dev_overlay_text_needs_refresh(viewport_size, now) {
639            return;
640        }
641        self.dev_overlay_text = self.build_dev_overlay_text(viewport_size);
642        self.dev_overlay_last_refresh = Some(now);
643        self.dev_overlay_viewport = Some(viewport_size);
644    }
645
646    fn dev_overlay_text_needs_refresh(&self, viewport_size: Size, now: Instant) -> bool {
647        if self.dev_overlay_text.is_empty() || self.dev_overlay_viewport != Some(viewport_size) {
648            return true;
649        }
650
651        self.dev_overlay_last_refresh
652            .map(|last| {
653                now.checked_duration_since(last).unwrap_or_default() >= DEV_OVERLAY_REFRESH_INTERVAL
654            })
655            .unwrap_or(true)
656    }
657
658    fn build_dev_overlay_text(&mut self, viewport_size: Size) -> String {
659        self.dev_overlay_controls.clear();
660
661        let stats = self.fps_monitor.stats();
662        let mut text = format!(
663            "{:.0} FPS | avg {:.1}ms | p95 {:.1}ms | max {:.1}ms | work {:.1}ms | {} recomp/s",
664            stats.fps,
665            stats.avg_ms,
666            stats.p95_ms,
667            stats.max_ms,
668            stats.work_avg_ms,
669            stats.recomps_per_second
670        );
671
672        if !self.dev_options.frame_pacing_controls {
673            return text;
674        }
675
676        text.push_str(" | ");
677        let mut controls = Vec::with_capacity(FramePacingMode::ALL.len());
678        for (index, mode) in FramePacingMode::ALL.into_iter().enumerate() {
679            if index > 0 {
680                text.push(' ');
681            }
682            let start = text.len();
683            if mode == self.dev_options.frame_pacing_mode {
684                text.push('[');
685                text.push_str(mode.label());
686                text.push(']');
687            } else {
688                text.push_str(mode.label());
689            }
690            controls.push((start, text.len(), mode));
691        }
692
693        let overlay_width = text.len() as f32 * DEV_OVERLAY_CHAR_WIDTH;
694        let overlay_x = (viewport_size.width - overlay_width - DEV_OVERLAY_PADDING * 2.0)
695            .max(DEV_OVERLAY_PADDING);
696        let overlay_y = DEV_OVERLAY_PADDING;
697        let text_x = overlay_x + DEV_OVERLAY_PADDING / 2.0;
698        let text_y = overlay_y + DEV_OVERLAY_PADDING / 4.0;
699        let text_height = DEV_OVERLAY_FONT_SIZE * 1.4;
700
701        self.dev_overlay_controls = controls
702            .into_iter()
703            .map(|(start, end, mode)| DevOverlayControl {
704                bounds: Rect {
705                    x: text_x + start as f32 * DEV_OVERLAY_CHAR_WIDTH - 3.0,
706                    y: text_y - 3.0,
707                    width: (end - start) as f32 * DEV_OVERLAY_CHAR_WIDTH + 6.0,
708                    height: text_height + 6.0,
709                },
710                mode,
711            })
712            .collect();
713
714        text
715    }
716}
717
718fn clear_dispatch_invalidation(
719    applier: &mut MemoryApplier,
720    node_id: NodeId,
721    invalidation: DispatchInvalidationKind,
722) -> Result<bool, NodeError> {
723    match invalidation {
724        DispatchInvalidationKind::Pointer => {
725            match applier.with_node::<LayoutNode, _>(node_id, |node| {
726                let needs_pointer_pass = node.needs_pointer_pass();
727                if needs_pointer_pass {
728                    node.clear_needs_pointer_pass();
729                }
730                needs_pointer_pass
731            }) {
732                Ok(cleared) => Ok(cleared),
733                Err(NodeError::TypeMismatch { .. }) => applier
734                    .with_node::<SubcomposeLayoutNode, _>(node_id, |node| {
735                        let needs_pointer_pass = node.needs_pointer_pass();
736                        if needs_pointer_pass {
737                            node.clear_needs_pointer_pass();
738                        }
739                        needs_pointer_pass
740                    }),
741                Err(err) => Err(err),
742            }
743        }
744        DispatchInvalidationKind::Focus => {
745            match applier.with_node::<LayoutNode, _>(node_id, |node| {
746                let needs_focus_sync = node.needs_focus_sync();
747                if needs_focus_sync {
748                    node.clear_needs_focus_sync();
749                }
750                needs_focus_sync
751            }) {
752                Ok(cleared) => Ok(cleared),
753                Err(NodeError::TypeMismatch { .. }) => applier
754                    .with_node::<SubcomposeLayoutNode, _>(node_id, |node| {
755                        let needs_focus_sync = node.needs_focus_sync();
756                        if needs_focus_sync {
757                            node.clear_needs_focus_sync();
758                        }
759                        needs_focus_sync
760                    }),
761                Err(err) => Err(err),
762            }
763        }
764    }
765}
766
767pub(crate) fn build_draw_refresh_scope(
768    applier: &mut MemoryApplier,
769    dirty_nodes: &HashSet<NodeId>,
770) -> HashSet<NodeId> {
771    let mut refresh_scope = HashSet::with_capacity(dirty_nodes.len());
772    for &dirty_node in dirty_nodes {
773        let mut current = Some(dirty_node);
774        while let Some(node_id) = current {
775            if !refresh_scope.insert(node_id) {
776                break;
777            }
778            current = applier.get_mut(node_id).ok().and_then(|node| node.parent());
779        }
780    }
781    refresh_scope
782}
783
784fn collect_retained_redraw_nodes(
785    applier: &mut MemoryApplier,
786    node_id: NodeId,
787    dirty_nodes: &mut Vec<NodeId>,
788) {
789    let children = match applier.get_mut(node_id) {
790        Ok(node) => node.children(),
791        Err(_) => return,
792    };
793
794    let redraw = match applier.with_node::<LayoutNode, _>(node_id, |node| {
795        let needs_redraw = node.needs_redraw();
796        if needs_redraw {
797            node.clear_needs_redraw();
798        }
799        needs_redraw
800    }) {
801        Ok(needs_redraw) => needs_redraw,
802        Err(NodeError::TypeMismatch { .. }) => applier
803            .with_node::<SubcomposeLayoutNode, _>(node_id, |node| {
804                let needs_redraw = node.needs_redraw();
805                if needs_redraw {
806                    node.clear_needs_redraw();
807                }
808                needs_redraw
809            })
810            .unwrap_or(false),
811        Err(_) => false,
812    };
813    if redraw {
814        dirty_nodes.push(node_id);
815    }
816
817    for child in children {
818        collect_retained_redraw_nodes(applier, child, dirty_nodes);
819    }
820}
821
822fn refresh_layout_box_data(
823    applier: &mut MemoryApplier,
824    layout: &mut cranpose_ui::layout::LayoutBox,
825    refresh_scope: &HashSet<NodeId>,
826    dirty_nodes: &HashSet<NodeId>,
827) {
828    if !refresh_scope.contains(&layout.node_id) {
829        return;
830    }
831
832    if dirty_nodes.contains(&layout.node_id) {
833        if let Ok((modifier, resolved_modifiers, slices)) =
834            applier.with_node::<LayoutNode, _>(layout.node_id, |node| {
835                node.clear_needs_redraw();
836                (
837                    node.modifier.clone(),
838                    node.resolved_modifiers(),
839                    node.modifier_slices_snapshot(),
840                )
841            })
842        {
843            layout.node_data.modifier = modifier;
844            layout.node_data.resolved_modifiers = resolved_modifiers;
845            layout.node_data.modifier_slices = slices;
846        } else if let Ok((modifier, resolved_modifiers)) = applier
847            .with_node::<SubcomposeLayoutNode, _>(layout.node_id, |node| {
848                node.clear_needs_redraw();
849                (node.modifier(), node.resolved_modifiers())
850            })
851        {
852            layout.node_data.modifier = modifier.clone();
853            layout.node_data.resolved_modifiers = resolved_modifiers;
854            layout.node_data.modifier_slices =
855                std::rc::Rc::new(cranpose_ui::collect_slices_from_modifier(&modifier));
856        }
857    }
858
859    for child in &mut layout.children {
860        refresh_layout_box_data(applier, child, refresh_scope, dirty_nodes);
861    }
862}