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
111#[allow(clippy::too_many_arguments)]
112fn log_dirty_diagnostics_for_path(
113    render_dirty: bool,
114    pointer_dirty: bool,
115    scene_dirty: bool,
116    draw_repass_pending: bool,
117    draw_dirty_nodes: &[NodeId],
118    layout_dirty_nodes: &[NodeId],
119    structural_parents: &[NodeId],
120    partial_dirty_nodes: &[NodeId],
121    render_only_dirty: bool,
122    recomposed_this_frame: bool,
123    path: &str,
124) {
125    log_render_phase_dirty_diagnostics(RenderPhaseDirtyDiagnostics {
126        render_dirty,
127        pointer_dirty,
128        scene_dirty,
129        draw_repass_pending,
130        draw_dirty_nodes: draw_dirty_nodes.len(),
131        layout_dirty_nodes: layout_dirty_nodes.len(),
132        structural_dirty_nodes: structural_parents.len(),
133        partial_dirty_nodes: partial_dirty_nodes.len(),
134        dirty_node_ids: render_phase_dirty_diagnostics_enabled().then(|| {
135            format!(
136                "draw={draw_dirty_nodes:?} layout={layout_dirty_nodes:?} structural={structural_parents:?} partial={partial_dirty_nodes:?}",
137            )
138        }),
139        render_only_dirty,
140        recomposed_this_frame,
141        path,
142    });
143}
144
145impl<R> AppShell<R>
146where
147    R: Renderer,
148    R::Error: Debug,
149{
150    pub fn set_semantics_enabled(&mut self, enabled: bool) {
151        if self.semantics_enabled == enabled {
152            return;
153        }
154        self.semantics_enabled = enabled;
155        self.semantics_snapshot_revision = self.semantics_snapshot_revision.wrapping_add(1);
156        if enabled {
157            self.request_forced_layout_pass();
158            self.mark_dirty();
159        } else {
160            self.semantics_tree = None;
161        }
162    }
163
164    /// Sets how the platform should vote the display's frame rate for this
165    /// app. The default, [`FrameRatePreference::Auto`], mirrors Compose: the
166    /// panel's fastest rate while frames are being produced, no preference
167    /// when the scene is still.
168    pub fn set_frame_rate_preference(&mut self, preference: crate::FrameRatePreference) {
169        self.frame_rate_preference = preference;
170    }
171
172    /// The app's current display frame-rate preference. Read by platform
173    /// backends each frame; the vote itself is applied by the backend that
174    /// owns the native window.
175    pub fn frame_rate_preference(&self) -> crate::FrameRatePreference {
176        self.frame_rate_preference
177    }
178
179    pub(crate) fn process_frame(&mut self) -> FrameUpdateResult {
180        let app_context = Rc::clone(&self.app_context);
181        app_context.enter(|| self.process_frame_in_context(false))
182    }
183
184    pub(crate) fn process_frame_in_context(
185        &mut self,
186        recomposed_before_frame: bool,
187    ) -> FrameUpdateResult {
188        let frame_start = Instant::now();
189
190        self.run_layout_phase();
191        let after_initial_layout = Instant::now();
192
193        let recomposed_this_frame = recomposed_before_frame || self.run_post_layout_recomposition();
194
195        let after_layout = Instant::now();
196
197        self.run_dispatch_queues();
198
199        let after_dispatch = Instant::now();
200
201        clear_transient_scroll_motion_contexts();
202
203        let result = self.run_render_phase_with_recomposition_state(recomposed_this_frame);
204        let after_render = Instant::now();
205        log_frame_stage_telemetry(
206            frame_start,
207            after_initial_layout,
208            after_layout,
209            after_dispatch,
210            after_render,
211        );
212        result
213    }
214
215    pub(crate) fn run_layout_phase(&mut self) {
216        let app_context = Rc::clone(&self.app_context);
217        app_context.enter(|| self.run_layout_phase_in_context());
218    }
219
220    fn run_layout_phase_in_context(&mut self) {
221        let has_scoped_repasses = cranpose_ui::has_pending_layout_repasses()
222            || cranpose_ui::has_pending_measure_repasses();
223        let mut scoped_layout_nodes = if cranpose_ui::has_pending_layout_repasses() {
224            cranpose_ui::pending_layout_repass_nodes_snapshot()
225        } else {
226            Vec::new()
227        };
228        if cranpose_ui::has_pending_measure_repasses() {
229            for node in cranpose_ui::pending_measure_repass_nodes_snapshot() {
230                if !scoped_layout_nodes.contains(&node) {
231                    scoped_layout_nodes.push(node);
232                }
233            }
234        }
235
236        let invalidation_requested = take_layout_invalidation();
237        let global_layout_invalidation = invalidation_requested && !has_scoped_repasses;
238        let force_layout_pass = self.force_layout_pass;
239
240        if invalidation_requested && !has_scoped_repasses {
241            cranpose_ui::layout::invalidate_all_layout_caches();
242
243            if let Some(root) = self.composition.root() {
244                let mut applier = self.composition.applier_mut();
245                match applier.with_node::<LayoutNode, _>(root, |node| {
246                    node.mark_needs_measure();
247                    node.mark_needs_layout();
248                }) {
249                    Ok(()) | Err(NodeError::Missing { .. }) => {}
250                    Err(NodeError::TypeMismatch { .. }) => {
251                        let _ = applier.with_node::<SubcomposeLayoutNode, _>(root, |node| {
252                            node.mark_needs_measure();
253                            node.mark_needs_layout_flag();
254                        });
255                    }
256                    Err(_) => {}
257                }
258            }
259            self.request_forced_layout_pass();
260        } else if invalidation_requested || has_scoped_repasses {
261            self.request_layout_pass();
262        }
263
264        if !self.layout_requested {
265            return;
266        }
267
268        let viewport_size = Size {
269            width: self.viewport.0,
270            height: self.viewport.1,
271        };
272        if let Some(root) = self.composition.root() {
273            let handle = self.composition.runtime_handle();
274            let mut applier = self.composition.applier_mut();
275            applier.set_runtime_handle(handle);
276
277            let tree_needs_layout_check = cranpose_ui::tree_needs_layout(&mut *applier, root)
278                .unwrap_or_else(|err| {
279                    log::warn!(
280                        "Cannot check layout dirty status for root #{}: {}",
281                        root,
282                        err
283                    );
284                    true
285                });
286
287            let needs_layout =
288                self.force_layout_pass || has_scoped_repasses || tree_needs_layout_check;
289
290            if !needs_layout {
291                log::trace!("Skipping layout: tree is clean");
292                self.layout_requested = false;
293                self.force_layout_pass = false;
294                applier.clear_runtime_handle();
295                return;
296            }
297
298            self.layout_requested = false;
299            self.force_layout_pass = false;
300
301            match cranpose_ui::measure_layout_with_options(
302                &mut applier,
303                root,
304                viewport_size,
305                MeasureLayoutOptions {
306                    collect_semantics: false,
307                    build_layout_tree: false,
308                },
309            ) {
310                Ok(_measurements) => {
311                    self.layout_tree = None;
312                    self.semantics_snapshot_revision =
313                        self.semantics_snapshot_revision.wrapping_add(1);
314                    if self.semantics_enabled {
315                        self.semantics_tree = None;
316                    }
317                    if global_layout_invalidation || force_layout_pass {
318                        self.scoped_layout_scene_nodes.clear();
319                        let _ = cranpose_ui::take_geometry_scene_nodes();
320                    } else {
321                        let mut seen: HashSet<NodeId> =
322                            self.scoped_layout_scene_nodes.iter().copied().collect();
323                        if has_scoped_repasses {
324                            for node in scoped_layout_nodes {
325                                if seen.insert(node) {
326                                    self.scoped_layout_scene_nodes.push(node);
327                                }
328                            }
329                        }
330                        for node in cranpose_ui::take_geometry_scene_nodes() {
331                            let Some(node) = applier.scene_node_attached_to(node, root) else {
332                                continue;
333                            };
334                            if seen.insert(node) {
335                                self.scoped_layout_scene_nodes.push(node);
336                            }
337                        }
338                    }
339                    self.scene_dirty = true;
340                }
341                Err(err) => {
342                    log::error!("failed to compute layout: {err}");
343                    self.layout_tree = None;
344                    self.semantics_tree = None;
345                    self.semantics_snapshot_revision =
346                        self.semantics_snapshot_revision.wrapping_add(1);
347                    self.scoped_layout_scene_nodes.clear();
348                    let _ = cranpose_ui::take_geometry_scene_nodes();
349                    self.scene_dirty = true;
350                }
351            }
352            applier.clear_runtime_handle();
353        } else {
354            self.layout_tree = None;
355            self.semantics_tree = None;
356            self.semantics_snapshot_revision = self.semantics_snapshot_revision.wrapping_add(1);
357            self.scoped_layout_scene_nodes.clear();
358            let _ = cranpose_ui::take_geometry_scene_nodes();
359            self.scene_dirty = true;
360            self.layout_requested = false;
361            self.force_layout_pass = false;
362        }
363    }
364
365    fn run_post_layout_recomposition(&mut self) -> bool {
366        if !self.composition.should_recompose() {
367            return false;
368        }
369
370        let Some(root_key) = self.composition.root_key() else {
371            return false;
372        };
373
374        match self.composition.reconcile(root_key, &mut *self.content) {
375            Ok(changed) => {
376                if !changed {
377                    return false;
378                }
379                self.fps_monitor.record_recomposition();
380                if self.composition_tree_needs_layout() {
381                    self.request_layout_pass();
382                    self.run_layout_phase_in_context();
383                }
384                request_render_invalidation();
385                true
386            }
387            Err(NodeError::Missing { id }) => {
388                log::debug!(
389                    "Post-layout recomposition skipped: node {} no longer exists",
390                    id
391                );
392                self.request_layout_pass();
393                request_render_invalidation();
394                true
395            }
396            Err(err) => {
397                log::error!("post-layout recomposition failed: {err}");
398                self.request_layout_pass();
399                request_render_invalidation();
400                true
401            }
402        }
403    }
404
405    fn run_dispatch_queues(&mut self) {
406        if has_pending_pointer_repasses() {
407            let mut applier = self.composition.applier_mut();
408            process_pointer_repasses(|node_id| {
409                match clear_dispatch_invalidation(
410                    &mut applier,
411                    node_id,
412                    DispatchInvalidationKind::Pointer,
413                ) {
414                    Ok(true) => {
415                        log::trace!("Cleared pointer repass flag for node #{}", node_id);
416                    }
417                    Ok(false) => {}
418                    Err(err) => {
419                        log::debug!(
420                            "Could not process pointer repass for node #{}: {}",
421                            node_id,
422                            err
423                        );
424                    }
425                }
426            });
427        }
428
429        if has_pending_focus_invalidations() {
430            let mut applier = self.composition.applier_mut();
431            process_focus_invalidations(|node_id| {
432                match clear_dispatch_invalidation(
433                    &mut applier,
434                    node_id,
435                    DispatchInvalidationKind::Focus,
436                ) {
437                    Ok(true) => {
438                        log::trace!("Cleared focus sync flag for node #{}", node_id);
439                    }
440                    Ok(false) => {}
441                    Err(err) => {
442                        log::debug!(
443                            "Could not process focus invalidation for node #{}: {}",
444                            node_id,
445                            err
446                        );
447                    }
448                }
449            });
450        }
451
452        if has_pending_semantics_invalidations() {
453            let mut applier = self.composition.applier_mut();
454            process_semantics_invalidations(|node_id| {
455                cranpose_core::bubble_semantics_dirty(&mut *applier, node_id);
456            });
457        }
458    }
459
460    fn refresh_draw_repasses(&mut self) -> Vec<NodeId> {
461        let dirty_nodes = take_draw_repass_nodes();
462        if dirty_nodes.is_empty() {
463            return dirty_nodes;
464        }
465        self.refresh_draw_nodes(dirty_nodes)
466    }
467
468    fn refresh_retained_redraw_nodes(&mut self) -> Vec<NodeId> {
469        let Some(root) = self.composition.root() else {
470            return Vec::new();
471        };
472        let mut dirty_nodes = Vec::new();
473        {
474            let mut applier = self.composition.applier_mut();
475            collect_retained_redraw_nodes(&mut applier, root, &mut dirty_nodes);
476        }
477        if dirty_nodes.is_empty() {
478            return dirty_nodes;
479        }
480        self.refresh_draw_nodes(dirty_nodes)
481    }
482
483    fn refresh_draw_nodes(&mut self, dirty_nodes: Vec<NodeId>) -> Vec<NodeId> {
484        let Some(layout_tree) = self.layout_tree.as_mut() else {
485            return dirty_nodes;
486        };
487
488        let dirty_set: HashSet<NodeId> = dirty_nodes.into_iter().collect();
489        let mut applier = self.composition.applier_mut();
490        let refresh_scope = build_draw_refresh_scope(&mut applier, &dirty_set);
491        refresh_layout_box_data(
492            &mut applier,
493            layout_tree.root_mut(),
494            &refresh_scope,
495            &dirty_set,
496        );
497        dirty_set.into_iter().collect()
498    }
499
500    #[cfg(test)]
501    pub(crate) fn run_render_phase(&mut self) -> FrameUpdateResult {
502        let app_context = Rc::clone(&self.app_context);
503        app_context.enter(|| self.run_render_phase_in_context(false))
504    }
505
506    fn run_render_phase_with_recomposition_state(
507        &mut self,
508        recomposed_this_frame: bool,
509    ) -> FrameUpdateResult {
510        let app_context = Rc::clone(&self.app_context);
511        app_context.enter(|| self.run_render_phase_in_context(recomposed_this_frame))
512    }
513
514    fn run_render_phase_in_context(&mut self, recomposed_this_frame: bool) -> FrameUpdateResult {
515        cranpose_ui::tick_cursor_blink();
516        let render_dirty = take_render_invalidation();
517        let pointer_dirty = take_pointer_invalidation();
518        take_focus_invalidation();
519        let draw_repass_pending = cranpose_ui::has_pending_draw_repasses();
520        let mut draw_dirty_nodes = self.refresh_draw_repasses();
521        if render_dirty && !draw_repass_pending && draw_dirty_nodes.is_empty() {
522            draw_dirty_nodes = self.refresh_retained_redraw_nodes();
523        }
524        let layout_dirty_nodes = std::mem::take(&mut self.scoped_layout_scene_nodes);
525        let structural_parents = if cranpose_core::env_flag!("CRANPOSE_DISABLE_STRUCTURAL_DIRT") {
526            Vec::new()
527        } else if let Some(root) = self.composition.root() {
528            self.composition
529                .applier_mut()
530                .take_structural_change_parents_attached_to(root)
531        } else {
532            Vec::new()
533        };
534        let structural_dirty = !structural_parents.is_empty();
535        let mut partial_dirty_nodes = draw_dirty_nodes.clone();
536        partial_dirty_nodes.extend(layout_dirty_nodes.iter().copied());
537        partial_dirty_nodes.extend(structural_parents.iter().copied());
538        partial_dirty_nodes.sort_unstable();
539        partial_dirty_nodes.dedup();
540        let _ = cranpose_ui::has_focused_field();
541
542        let render_only_dirty = render_dirty
543            && partial_dirty_nodes.is_empty()
544            && !draw_repass_pending
545            && !structural_dirty;
546        let scene_dirty = self.scene_dirty;
547        let draw_only_partial_dirty = !draw_dirty_nodes.is_empty()
548            && layout_dirty_nodes.is_empty()
549            && !pointer_dirty
550            && !recomposed_this_frame
551            && !structural_dirty;
552        let scoped_scene_dirty = scene_dirty && !layout_dirty_nodes.is_empty();
553        let full_scene_dirty = scene_dirty && !scoped_scene_dirty && !draw_only_partial_dirty;
554        let partial_scene_dirty = !partial_dirty_nodes.is_empty();
555        let needs_scene_rebuild = full_scene_dirty
556            || scoped_scene_dirty
557            || partial_scene_dirty
558            || draw_repass_pending
559            || structural_dirty;
560
561        if !needs_scene_rebuild {
562            log_dirty_diagnostics_for_path(
563                render_dirty,
564                pointer_dirty,
565                scene_dirty,
566                draw_repass_pending,
567                &draw_dirty_nodes,
568                &layout_dirty_nodes,
569                &structural_parents,
570                &partial_dirty_nodes,
571                render_only_dirty,
572                recomposed_this_frame,
573                if render_only_dirty {
574                    "present-retained"
575                } else {
576                    "skip"
577                },
578            );
579            self.scoped_layout_scene_nodes = layout_dirty_nodes;
580            if render_only_dirty && self.dev_options.fps_counter {
581                let viewport_size = Size {
582                    width: self.viewport.0,
583                    height: self.viewport.1,
584                };
585                self.refresh_dev_overlay_text_for_frame_at(viewport_size, Instant::now());
586                let text = self.dev_overlay_text.as_str();
587                self.renderer.draw_dev_overlay(text, viewport_size);
588            }
589            return FrameUpdateResult {
590                visual_changed: render_only_dirty,
591                structure_changed: false,
592            };
593        }
594        self.scene_dirty = false;
595        let viewport_size = Size {
596            width: self.viewport.0,
597            height: self.viewport.1,
598        };
599        let visual_update_only =
600            draw_only_partial_dirty && !partial_dirty_nodes.is_empty() && !full_scene_dirty;
601        let structure_changed = !render_only_dirty && !visual_update_only;
602
603        if let Some(root) = self.composition.root() {
604            let mut applier = self.composition.applier_mut();
605            let use_partial_update =
606                !partial_dirty_nodes.is_empty() && !render_only_dirty && !full_scene_dirty;
607            let use_visual_update = use_partial_update && draw_only_partial_dirty;
608            log_dirty_diagnostics_for_path(
609                render_dirty,
610                pointer_dirty,
611                scene_dirty,
612                draw_repass_pending,
613                &draw_dirty_nodes,
614                &layout_dirty_nodes,
615                &structural_parents,
616                &partial_dirty_nodes,
617                render_only_dirty,
618                recomposed_this_frame,
619                if use_visual_update {
620                    "visual-update"
621                } else if use_partial_update {
622                    "update"
623                } else {
624                    "rebuild"
625                },
626            );
627            let rebuild_result = if use_visual_update {
628                self.renderer.update_visual_scene_from_applier(
629                    &mut applier,
630                    root,
631                    viewport_size,
632                    &partial_dirty_nodes,
633                )
634            } else if use_partial_update {
635                self.renderer.update_scene_from_applier(
636                    &mut applier,
637                    root,
638                    viewport_size,
639                    &partial_dirty_nodes,
640                )
641            } else {
642                self.renderer
643                    .rebuild_scene_from_applier(&mut applier, root, viewport_size)
644            };
645            if let Err(err) = rebuild_result {
646                log::error!("renderer rebuild failed: {err:?}");
647                self.renderer.scene_mut().clear();
648            }
649        } else {
650            self.renderer.scene_mut().clear();
651        }
652        if self
653            .renderer
654            .scene()
655            .collect_retained_visual_observation_nodes(&mut self.retained_visual_nodes)
656        {
657            cranpose_ui::prune_draw_observations_to_nodes(&self.retained_visual_nodes);
658        }
659
660        if self.dev_options.fps_counter {
661            self.refresh_dev_overlay_text_for_frame_at(viewport_size, Instant::now());
662            let renderer = &mut self.renderer;
663            let text = self.dev_overlay_text.as_str();
664            renderer.draw_dev_overlay(text, viewport_size);
665        }
666
667        FrameUpdateResult {
668            visual_changed: true,
669            structure_changed,
670        }
671    }
672
673    fn refresh_dev_overlay_text_for_frame_at(&mut self, viewport_size: Size, now: Instant) {
674        if !self.dev_overlay_text_needs_refresh(viewport_size, now) {
675            return;
676        }
677        self.dev_overlay_text = self.build_dev_overlay_text(viewport_size);
678        self.dev_overlay_last_refresh = Some(now);
679        self.dev_overlay_viewport = Some(viewport_size);
680    }
681
682    fn dev_overlay_text_needs_refresh(&self, viewport_size: Size, now: Instant) -> bool {
683        if self.dev_overlay_text.is_empty() || self.dev_overlay_viewport != Some(viewport_size) {
684            return true;
685        }
686
687        self.dev_overlay_last_refresh
688            .map(|last| {
689                now.checked_duration_since(last).unwrap_or_default() >= DEV_OVERLAY_REFRESH_INTERVAL
690            })
691            .unwrap_or(true)
692    }
693
694    fn build_dev_overlay_text(&mut self, viewport_size: Size) -> String {
695        self.dev_overlay_controls.clear();
696
697        let stats = self.fps_monitor.stats();
698        let mut text = format!(
699            "{:.0} FPS | avg {:.1}ms | p95 {:.1}ms | max {:.1}ms | work {:.1}ms | {} recomp/s",
700            stats.fps,
701            stats.avg_ms,
702            stats.p95_ms,
703            stats.max_ms,
704            stats.work_avg_ms,
705            stats.recomps_per_second
706        );
707
708        if !self.dev_options.frame_pacing_controls {
709            return text;
710        }
711
712        text.push_str(" | ");
713        let mut controls = Vec::with_capacity(FramePacingMode::ALL.len());
714        for (index, mode) in FramePacingMode::ALL.into_iter().enumerate() {
715            if index > 0 {
716                text.push(' ');
717            }
718            let start = text.len();
719            if mode == self.dev_options.frame_pacing_mode {
720                text.push('[');
721                text.push_str(mode.label());
722                text.push(']');
723            } else {
724                text.push_str(mode.label());
725            }
726            controls.push((start, text.len(), mode));
727        }
728
729        let overlay_width = text.len() as f32 * DEV_OVERLAY_CHAR_WIDTH;
730        let overlay_x = (viewport_size.width - overlay_width - DEV_OVERLAY_PADDING * 2.0)
731            .max(DEV_OVERLAY_PADDING);
732        let overlay_y = DEV_OVERLAY_PADDING;
733        let text_x = overlay_x + DEV_OVERLAY_PADDING / 2.0;
734        let text_y = overlay_y + DEV_OVERLAY_PADDING / 4.0;
735        let text_height = DEV_OVERLAY_FONT_SIZE * 1.4;
736
737        self.dev_overlay_controls = controls
738            .into_iter()
739            .map(|(start, end, mode)| DevOverlayControl {
740                bounds: Rect {
741                    x: text_x + start as f32 * DEV_OVERLAY_CHAR_WIDTH - 3.0,
742                    y: text_y - 3.0,
743                    width: (end - start) as f32 * DEV_OVERLAY_CHAR_WIDTH + 6.0,
744                    height: text_height + 6.0,
745                },
746                mode,
747            })
748            .collect();
749
750        text
751    }
752}
753
754fn clear_dispatch_invalidation(
755    applier: &mut MemoryApplier,
756    node_id: NodeId,
757    invalidation: DispatchInvalidationKind,
758) -> Result<bool, NodeError> {
759    match invalidation {
760        DispatchInvalidationKind::Pointer => {
761            match applier.with_node::<LayoutNode, _>(node_id, |node| {
762                let needs_pointer_pass = node.needs_pointer_pass();
763                if needs_pointer_pass {
764                    node.clear_needs_pointer_pass();
765                }
766                needs_pointer_pass
767            }) {
768                Ok(cleared) => Ok(cleared),
769                Err(NodeError::TypeMismatch { .. }) => applier
770                    .with_node::<SubcomposeLayoutNode, _>(node_id, |node| {
771                        let needs_pointer_pass = node.needs_pointer_pass();
772                        if needs_pointer_pass {
773                            node.clear_needs_pointer_pass();
774                        }
775                        needs_pointer_pass
776                    }),
777                Err(err) => Err(err),
778            }
779        }
780        DispatchInvalidationKind::Focus => {
781            match applier.with_node::<LayoutNode, _>(node_id, |node| {
782                let needs_focus_sync = node.needs_focus_sync();
783                if needs_focus_sync {
784                    node.clear_needs_focus_sync();
785                }
786                needs_focus_sync
787            }) {
788                Ok(cleared) => Ok(cleared),
789                Err(NodeError::TypeMismatch { .. }) => applier
790                    .with_node::<SubcomposeLayoutNode, _>(node_id, |node| {
791                        let needs_focus_sync = node.needs_focus_sync();
792                        if needs_focus_sync {
793                            node.clear_needs_focus_sync();
794                        }
795                        needs_focus_sync
796                    }),
797                Err(err) => Err(err),
798            }
799        }
800    }
801}
802
803pub(crate) fn build_draw_refresh_scope(
804    applier: &mut MemoryApplier,
805    dirty_nodes: &HashSet<NodeId>,
806) -> HashSet<NodeId> {
807    let mut refresh_scope = HashSet::with_capacity(dirty_nodes.len());
808    for &dirty_node in dirty_nodes {
809        let mut current = Some(dirty_node);
810        while let Some(node_id) = current {
811            if !refresh_scope.insert(node_id) {
812                break;
813            }
814            current = applier.get_mut(node_id).ok().and_then(|node| node.parent());
815        }
816    }
817    refresh_scope
818}
819
820fn collect_retained_redraw_nodes(
821    applier: &mut MemoryApplier,
822    node_id: NodeId,
823    dirty_nodes: &mut Vec<NodeId>,
824) {
825    let mut children = Default::default();
826    match applier.get_mut(node_id) {
827        Ok(node) => node.collect_children_into(&mut children),
828        Err(_) => return,
829    }
830
831    let redraw = match applier.with_node::<LayoutNode, _>(node_id, |node| {
832        let needs_redraw = node.needs_redraw();
833        if needs_redraw {
834            node.clear_needs_redraw();
835        }
836        needs_redraw
837    }) {
838        Ok(needs_redraw) => needs_redraw,
839        Err(NodeError::TypeMismatch { .. }) => applier
840            .with_node::<SubcomposeLayoutNode, _>(node_id, |node| {
841                let needs_redraw = node.needs_redraw();
842                if needs_redraw {
843                    node.clear_needs_redraw();
844                }
845                needs_redraw
846            })
847            .unwrap_or(false),
848        Err(_) => false,
849    };
850    if redraw {
851        dirty_nodes.push(node_id);
852    }
853
854    for child in children {
855        collect_retained_redraw_nodes(applier, child, dirty_nodes);
856    }
857}
858
859fn refresh_layout_box_data(
860    applier: &mut MemoryApplier,
861    layout: &mut cranpose_ui::layout::LayoutBox,
862    refresh_scope: &HashSet<NodeId>,
863    dirty_nodes: &HashSet<NodeId>,
864) {
865    if !refresh_scope.contains(&layout.node_id) {
866        return;
867    }
868
869    if dirty_nodes.contains(&layout.node_id) {
870        if let Ok((modifier, resolved_modifiers, slices)) =
871            applier.with_node::<LayoutNode, _>(layout.node_id, |node| {
872                node.clear_needs_redraw();
873                (
874                    node.modifier.clone(),
875                    node.resolved_modifiers(),
876                    node.modifier_slices_snapshot(),
877                )
878            })
879        {
880            layout.node_data.modifier = modifier;
881            layout.node_data.resolved_modifiers = resolved_modifiers;
882            layout.node_data.modifier_slices = slices;
883        } else if let Ok((modifier, resolved_modifiers)) = applier
884            .with_node::<SubcomposeLayoutNode, _>(layout.node_id, |node| {
885                node.clear_needs_redraw();
886                (node.modifier(), node.resolved_modifiers())
887            })
888        {
889            layout.node_data.modifier = modifier.clone();
890            layout.node_data.resolved_modifiers = resolved_modifiers;
891            layout.node_data.modifier_slices =
892                std::rc::Rc::new(cranpose_ui::collect_slices_from_modifier(&modifier));
893        }
894    }
895
896    for child in &mut layout.children {
897        refresh_layout_box_data(applier, child, refresh_scope, dirty_nodes);
898    }
899}
900
901#[cfg(test)]
902mod tests {
903    use super::*;
904
905    #[test]
906    fn retained_redraw_traversal_keeps_wide_child_lists_and_clears_only_attached_nodes() {
907        cranpose_ui::AppContext::new().enter(|| {
908            let mut applier = MemoryApplier::new();
909            let children: Vec<_> = (0..16)
910                .map(|_| {
911                    let node = LayoutNode::new_virtual();
912                    node.clear_needs_redraw();
913                    applier.create(Box::new(node))
914                })
915                .collect();
916            let detached = applier.create(Box::new(LayoutNode::new_virtual()));
917            let mut parent = LayoutNode::new_virtual();
918            parent.clear_needs_redraw();
919            parent.children.clone_from(&children);
920            let root = applier.create(Box::new(parent));
921            for id in [children[0], children[15], detached] {
922                applier
923                    .with_node::<LayoutNode, _>(id, |node| node.mark_needs_redraw())
924                    .expect("node to redraw");
925            }
926
927            let mut dirty = Vec::new();
928            collect_retained_redraw_nodes(&mut applier, root, &mut dirty);
929            assert_eq!(dirty, [children[0], children[15]]);
930            dirty.clear();
931            collect_retained_redraw_nodes(&mut applier, root, &mut dirty);
932            assert!(dirty.is_empty());
933            assert!(
934                applier
935                    .with_node::<LayoutNode, _>(detached, |node| node.needs_redraw())
936                    .expect("detached node")
937            );
938        });
939    }
940}