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