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        let has_scoped_repasses = cranpose_ui::has_pending_layout_repasses()
188            || cranpose_ui::has_pending_measure_repasses();
189        let mut scoped_layout_nodes = if cranpose_ui::has_pending_layout_repasses() {
190            cranpose_ui::pending_layout_repass_nodes_snapshot()
191        } else {
192            Vec::new()
193        };
194        if cranpose_ui::has_pending_measure_repasses() {
195            for node in cranpose_ui::pending_measure_repass_nodes_snapshot() {
196                if !scoped_layout_nodes.contains(&node) {
197                    scoped_layout_nodes.push(node);
198                }
199            }
200        }
201
202        let invalidation_requested = take_layout_invalidation();
203        let global_layout_invalidation = invalidation_requested && !has_scoped_repasses;
204        let force_layout_pass = self.force_layout_pass;
205
206        if invalidation_requested && !has_scoped_repasses {
207            cranpose_ui::layout::invalidate_all_layout_caches();
208
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
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            match cranpose_ui::measure_layout_with_options(
268                &mut applier,
269                root,
270                viewport_size,
271                MeasureLayoutOptions {
272                    collect_semantics: false,
273                    build_layout_tree: false,
274                },
275            ) {
276                Ok(_measurements) => {
277                    self.layout_tree = None;
278                    self.semantics_snapshot_revision =
279                        self.semantics_snapshot_revision.wrapping_add(1);
280                    if self.semantics_enabled {
281                        self.semantics_tree = None;
282                    }
283                    if global_layout_invalidation || force_layout_pass {
284                        self.scoped_layout_scene_nodes.clear();
285                        let _ = cranpose_ui::take_geometry_scene_nodes();
286                    } else {
287                        let mut seen: HashSet<NodeId> =
288                            self.scoped_layout_scene_nodes.iter().copied().collect();
289                        if has_scoped_repasses {
290                            for node in scoped_layout_nodes {
291                                if seen.insert(node) {
292                                    self.scoped_layout_scene_nodes.push(node);
293                                }
294                            }
295                        }
296                        for node in cranpose_ui::take_geometry_scene_nodes() {
297                            let Some(node) = applier.scene_node_attached_to(node, root) else {
298                                continue;
299                            };
300                            if seen.insert(node) {
301                                self.scoped_layout_scene_nodes.push(node);
302                            }
303                        }
304                    }
305                    self.scene_dirty = true;
306                }
307                Err(err) => {
308                    log::error!("failed to compute layout: {err}");
309                    self.layout_tree = None;
310                    self.semantics_tree = None;
311                    self.semantics_snapshot_revision =
312                        self.semantics_snapshot_revision.wrapping_add(1);
313                    self.scoped_layout_scene_nodes.clear();
314                    let _ = cranpose_ui::take_geometry_scene_nodes();
315                    self.scene_dirty = true;
316                }
317            }
318            applier.clear_runtime_handle();
319        } else {
320            self.layout_tree = None;
321            self.semantics_tree = None;
322            self.semantics_snapshot_revision = self.semantics_snapshot_revision.wrapping_add(1);
323            self.scoped_layout_scene_nodes.clear();
324            let _ = cranpose_ui::take_geometry_scene_nodes();
325            self.scene_dirty = true;
326            self.layout_requested = false;
327            self.force_layout_pass = false;
328        }
329    }
330
331    fn run_post_layout_recomposition(&mut self) -> bool {
332        if !self.composition.should_recompose() {
333            return false;
334        }
335
336        let Some(root_key) = self.composition.root_key() else {
337            return false;
338        };
339
340        match self.composition.reconcile(root_key, &mut *self.content) {
341            Ok(changed) => {
342                if !changed {
343                    return false;
344                }
345                self.fps_monitor.record_recomposition();
346                if self.composition_tree_needs_layout() {
347                    self.request_layout_pass();
348                    self.run_layout_phase_in_context();
349                }
350                request_render_invalidation();
351                true
352            }
353            Err(NodeError::Missing { id }) => {
354                log::debug!(
355                    "Post-layout recomposition skipped: node {} no longer exists",
356                    id
357                );
358                self.request_layout_pass();
359                request_render_invalidation();
360                true
361            }
362            Err(err) => {
363                log::error!("post-layout recomposition failed: {err}");
364                self.request_layout_pass();
365                request_render_invalidation();
366                true
367            }
368        }
369    }
370
371    fn run_dispatch_queues(&mut self) {
372        if has_pending_pointer_repasses() {
373            let mut applier = self.composition.applier_mut();
374            process_pointer_repasses(|node_id| {
375                match clear_dispatch_invalidation(
376                    &mut applier,
377                    node_id,
378                    DispatchInvalidationKind::Pointer,
379                ) {
380                    Ok(true) => {
381                        log::trace!("Cleared pointer repass flag for node #{}", node_id);
382                    }
383                    Ok(false) => {}
384                    Err(err) => {
385                        log::debug!(
386                            "Could not process pointer repass for node #{}: {}",
387                            node_id,
388                            err
389                        );
390                    }
391                }
392            });
393        }
394
395        if has_pending_focus_invalidations() {
396            let mut applier = self.composition.applier_mut();
397            process_focus_invalidations(|node_id| {
398                match clear_dispatch_invalidation(
399                    &mut applier,
400                    node_id,
401                    DispatchInvalidationKind::Focus,
402                ) {
403                    Ok(true) => {
404                        log::trace!("Cleared focus sync flag for node #{}", node_id);
405                    }
406                    Ok(false) => {}
407                    Err(err) => {
408                        log::debug!(
409                            "Could not process focus invalidation for node #{}: {}",
410                            node_id,
411                            err
412                        );
413                    }
414                }
415            });
416        }
417
418        if has_pending_semantics_invalidations() {
419            let mut applier = self.composition.applier_mut();
420            process_semantics_invalidations(|node_id| {
421                cranpose_core::bubble_semantics_dirty(&mut *applier, node_id);
422            });
423        }
424    }
425
426    fn refresh_draw_repasses(&mut self) -> Vec<NodeId> {
427        let dirty_nodes = take_draw_repass_nodes();
428        if dirty_nodes.is_empty() {
429            return dirty_nodes;
430        }
431        self.refresh_draw_nodes(dirty_nodes)
432    }
433
434    fn refresh_retained_redraw_nodes(&mut self) -> Vec<NodeId> {
435        let Some(root) = self.composition.root() else {
436            return Vec::new();
437        };
438        let mut dirty_nodes = Vec::new();
439        {
440            let mut applier = self.composition.applier_mut();
441            collect_retained_redraw_nodes(&mut applier, root, &mut dirty_nodes);
442        }
443        if dirty_nodes.is_empty() {
444            return dirty_nodes;
445        }
446        self.refresh_draw_nodes(dirty_nodes)
447    }
448
449    fn refresh_draw_nodes(&mut self, dirty_nodes: Vec<NodeId>) -> Vec<NodeId> {
450        let Some(layout_tree) = self.layout_tree.as_mut() else {
451            return dirty_nodes;
452        };
453
454        let dirty_set: HashSet<NodeId> = dirty_nodes.into_iter().collect();
455        let mut applier = self.composition.applier_mut();
456        let refresh_scope = build_draw_refresh_scope(&mut applier, &dirty_set);
457        refresh_layout_box_data(
458            &mut applier,
459            layout_tree.root_mut(),
460            &refresh_scope,
461            &dirty_set,
462        );
463        dirty_set.into_iter().collect()
464    }
465
466    #[cfg(test)]
467    pub(crate) fn run_render_phase(&mut self) -> FrameUpdateResult {
468        let app_context = Rc::clone(&self.app_context);
469        app_context.enter(|| self.run_render_phase_in_context(false))
470    }
471
472    fn run_render_phase_with_recomposition_state(
473        &mut self,
474        recomposed_this_frame: bool,
475    ) -> FrameUpdateResult {
476        let app_context = Rc::clone(&self.app_context);
477        app_context.enter(|| self.run_render_phase_in_context(recomposed_this_frame))
478    }
479
480    fn run_render_phase_in_context(&mut self, recomposed_this_frame: bool) -> FrameUpdateResult {
481        cranpose_ui::tick_cursor_blink();
482        let render_dirty = take_render_invalidation();
483        let pointer_dirty = take_pointer_invalidation();
484        take_focus_invalidation();
485        let draw_repass_pending = cranpose_ui::has_pending_draw_repasses();
486        let mut draw_dirty_nodes = self.refresh_draw_repasses();
487        if render_dirty && !draw_repass_pending && draw_dirty_nodes.is_empty() {
488            draw_dirty_nodes = self.refresh_retained_redraw_nodes();
489        }
490        let layout_dirty_nodes = std::mem::take(&mut self.scoped_layout_scene_nodes);
491        let structural_parents = if cranpose_core::env_flag!("CRANPOSE_DISABLE_STRUCTURAL_DIRT") {
492            Vec::new()
493        } else if let Some(root) = self.composition.root() {
494            self.composition
495                .applier_mut()
496                .take_structural_change_parents_attached_to(root)
497        } else {
498            Vec::new()
499        };
500        let structural_dirty = !structural_parents.is_empty();
501        let mut partial_dirty_nodes = draw_dirty_nodes.clone();
502        partial_dirty_nodes.extend(layout_dirty_nodes.iter().copied());
503        partial_dirty_nodes.extend(structural_parents.iter().copied());
504        partial_dirty_nodes.sort_unstable();
505        partial_dirty_nodes.dedup();
506        let draw_dirty_node_count = draw_dirty_nodes.len();
507        let layout_dirty_node_count = layout_dirty_nodes.len();
508        let structural_dirty_node_count = structural_parents.len();
509        let partial_dirty_node_count = partial_dirty_nodes.len();
510        let _ = cranpose_ui::has_focused_field();
511
512        let render_only_dirty = render_dirty
513            && partial_dirty_nodes.is_empty()
514            && !draw_repass_pending
515            && !structural_dirty;
516        let scene_dirty = self.scene_dirty;
517        let draw_only_partial_dirty = !draw_dirty_nodes.is_empty()
518            && layout_dirty_nodes.is_empty()
519            && !pointer_dirty
520            && !recomposed_this_frame
521            && !structural_dirty;
522        let scoped_scene_dirty = scene_dirty && !layout_dirty_nodes.is_empty();
523        let full_scene_dirty = scene_dirty && !scoped_scene_dirty && !draw_only_partial_dirty;
524        let partial_scene_dirty = !partial_dirty_nodes.is_empty();
525        let needs_scene_rebuild = full_scene_dirty
526            || scoped_scene_dirty
527            || partial_scene_dirty
528            || draw_repass_pending
529            || structural_dirty;
530
531        if !needs_scene_rebuild {
532            log_render_phase_dirty_diagnostics(RenderPhaseDirtyDiagnostics {
533                render_dirty,
534                pointer_dirty,
535                scene_dirty,
536                draw_repass_pending,
537                draw_dirty_nodes: draw_dirty_node_count,
538                layout_dirty_nodes: layout_dirty_node_count,
539                structural_dirty_nodes: structural_dirty_node_count,
540                partial_dirty_nodes: partial_dirty_node_count,
541                dirty_node_ids: render_phase_dirty_diagnostics_enabled().then(|| {
542                    format!(
543                        "draw={:?} layout={:?} structural={:?} partial={:?}",
544                        draw_dirty_nodes,
545                        layout_dirty_nodes,
546                        structural_parents,
547                        partial_dirty_nodes
548                    )
549                }),
550                render_only_dirty,
551                recomposed_this_frame,
552                path: if render_only_dirty {
553                    "present-retained"
554                } else {
555                    "skip"
556                },
557            });
558            self.scoped_layout_scene_nodes = layout_dirty_nodes;
559            if render_only_dirty && self.dev_options.fps_counter {
560                let viewport_size = Size {
561                    width: self.viewport.0,
562                    height: self.viewport.1,
563                };
564                self.refresh_dev_overlay_text_for_frame_at(viewport_size, Instant::now());
565                let text = self.dev_overlay_text.as_str();
566                self.renderer.draw_dev_overlay(text, viewport_size);
567            }
568            return FrameUpdateResult {
569                visual_changed: render_only_dirty,
570                structure_changed: false,
571            };
572        }
573        self.scene_dirty = false;
574        let viewport_size = Size {
575            width: self.viewport.0,
576            height: self.viewport.1,
577        };
578        let visual_update_only =
579            draw_only_partial_dirty && !partial_dirty_nodes.is_empty() && !full_scene_dirty;
580        let structure_changed = !render_only_dirty && !visual_update_only;
581
582        if let Some(root) = self.composition.root() {
583            let mut applier = self.composition.applier_mut();
584            let use_partial_update =
585                !partial_dirty_nodes.is_empty() && !render_only_dirty && !full_scene_dirty;
586            let use_visual_update = use_partial_update && draw_only_partial_dirty;
587            log_render_phase_dirty_diagnostics(RenderPhaseDirtyDiagnostics {
588                render_dirty,
589                pointer_dirty,
590                scene_dirty,
591                draw_repass_pending,
592                draw_dirty_nodes: draw_dirty_node_count,
593                layout_dirty_nodes: layout_dirty_node_count,
594                structural_dirty_nodes: structural_dirty_node_count,
595                partial_dirty_nodes: partial_dirty_node_count,
596                dirty_node_ids: render_phase_dirty_diagnostics_enabled().then(|| {
597                    format!(
598                        "draw={:?} layout={:?} structural={:?} partial={:?}",
599                        draw_dirty_nodes,
600                        layout_dirty_nodes,
601                        structural_parents,
602                        partial_dirty_nodes
603                    )
604                }),
605                render_only_dirty,
606                recomposed_this_frame,
607                path: if use_visual_update {
608                    "visual-update"
609                } else if use_partial_update {
610                    "update"
611                } else {
612                    "rebuild"
613                },
614            });
615            let rebuild_result = if use_visual_update {
616                self.renderer.update_visual_scene_from_applier(
617                    &mut applier,
618                    root,
619                    viewport_size,
620                    &partial_dirty_nodes,
621                )
622            } else if use_partial_update {
623                self.renderer.update_scene_from_applier(
624                    &mut applier,
625                    root,
626                    viewport_size,
627                    &partial_dirty_nodes,
628                )
629            } else {
630                self.renderer
631                    .rebuild_scene_from_applier(&mut applier, root, viewport_size)
632            };
633            if let Err(err) = rebuild_result {
634                log::error!("renderer rebuild failed: {err:?}");
635                self.renderer.scene_mut().clear();
636            }
637        } else {
638            self.renderer.scene_mut().clear();
639        }
640        if let Some(retained_nodes) = self.renderer.scene().retained_visual_observation_nodes() {
641            cranpose_ui::prune_draw_observations_to_nodes(&retained_nodes);
642        }
643
644        if self.dev_options.fps_counter {
645            self.refresh_dev_overlay_text_for_frame_at(viewport_size, Instant::now());
646            let renderer = &mut self.renderer;
647            let text = self.dev_overlay_text.as_str();
648            renderer.draw_dev_overlay(text, viewport_size);
649        }
650
651        FrameUpdateResult {
652            visual_changed: true,
653            structure_changed,
654        }
655    }
656
657    fn refresh_dev_overlay_text_for_frame_at(&mut self, viewport_size: Size, now: Instant) {
658        if !self.dev_overlay_text_needs_refresh(viewport_size, now) {
659            return;
660        }
661        self.dev_overlay_text = self.build_dev_overlay_text(viewport_size);
662        self.dev_overlay_last_refresh = Some(now);
663        self.dev_overlay_viewport = Some(viewport_size);
664    }
665
666    fn dev_overlay_text_needs_refresh(&self, viewport_size: Size, now: Instant) -> bool {
667        if self.dev_overlay_text.is_empty() || self.dev_overlay_viewport != Some(viewport_size) {
668            return true;
669        }
670
671        self.dev_overlay_last_refresh
672            .map(|last| {
673                now.checked_duration_since(last).unwrap_or_default() >= DEV_OVERLAY_REFRESH_INTERVAL
674            })
675            .unwrap_or(true)
676    }
677
678    fn build_dev_overlay_text(&mut self, viewport_size: Size) -> String {
679        self.dev_overlay_controls.clear();
680
681        let stats = self.fps_monitor.stats();
682        let mut text = format!(
683            "{:.0} FPS | avg {:.1}ms | p95 {:.1}ms | max {:.1}ms | work {:.1}ms | {} recomp/s",
684            stats.fps,
685            stats.avg_ms,
686            stats.p95_ms,
687            stats.max_ms,
688            stats.work_avg_ms,
689            stats.recomps_per_second
690        );
691
692        if !self.dev_options.frame_pacing_controls {
693            return text;
694        }
695
696        text.push_str(" | ");
697        let mut controls = Vec::with_capacity(FramePacingMode::ALL.len());
698        for (index, mode) in FramePacingMode::ALL.into_iter().enumerate() {
699            if index > 0 {
700                text.push(' ');
701            }
702            let start = text.len();
703            if mode == self.dev_options.frame_pacing_mode {
704                text.push('[');
705                text.push_str(mode.label());
706                text.push(']');
707            } else {
708                text.push_str(mode.label());
709            }
710            controls.push((start, text.len(), mode));
711        }
712
713        let overlay_width = text.len() as f32 * DEV_OVERLAY_CHAR_WIDTH;
714        let overlay_x = (viewport_size.width - overlay_width - DEV_OVERLAY_PADDING * 2.0)
715            .max(DEV_OVERLAY_PADDING);
716        let overlay_y = DEV_OVERLAY_PADDING;
717        let text_x = overlay_x + DEV_OVERLAY_PADDING / 2.0;
718        let text_y = overlay_y + DEV_OVERLAY_PADDING / 4.0;
719        let text_height = DEV_OVERLAY_FONT_SIZE * 1.4;
720
721        self.dev_overlay_controls = controls
722            .into_iter()
723            .map(|(start, end, mode)| DevOverlayControl {
724                bounds: Rect {
725                    x: text_x + start as f32 * DEV_OVERLAY_CHAR_WIDTH - 3.0,
726                    y: text_y - 3.0,
727                    width: (end - start) as f32 * DEV_OVERLAY_CHAR_WIDTH + 6.0,
728                    height: text_height + 6.0,
729                },
730                mode,
731            })
732            .collect();
733
734        text
735    }
736}
737
738fn clear_dispatch_invalidation(
739    applier: &mut MemoryApplier,
740    node_id: NodeId,
741    invalidation: DispatchInvalidationKind,
742) -> Result<bool, NodeError> {
743    match invalidation {
744        DispatchInvalidationKind::Pointer => {
745            match applier.with_node::<LayoutNode, _>(node_id, |node| {
746                let needs_pointer_pass = node.needs_pointer_pass();
747                if needs_pointer_pass {
748                    node.clear_needs_pointer_pass();
749                }
750                needs_pointer_pass
751            }) {
752                Ok(cleared) => Ok(cleared),
753                Err(NodeError::TypeMismatch { .. }) => applier
754                    .with_node::<SubcomposeLayoutNode, _>(node_id, |node| {
755                        let needs_pointer_pass = node.needs_pointer_pass();
756                        if needs_pointer_pass {
757                            node.clear_needs_pointer_pass();
758                        }
759                        needs_pointer_pass
760                    }),
761                Err(err) => Err(err),
762            }
763        }
764        DispatchInvalidationKind::Focus => {
765            match applier.with_node::<LayoutNode, _>(node_id, |node| {
766                let needs_focus_sync = node.needs_focus_sync();
767                if needs_focus_sync {
768                    node.clear_needs_focus_sync();
769                }
770                needs_focus_sync
771            }) {
772                Ok(cleared) => Ok(cleared),
773                Err(NodeError::TypeMismatch { .. }) => applier
774                    .with_node::<SubcomposeLayoutNode, _>(node_id, |node| {
775                        let needs_focus_sync = node.needs_focus_sync();
776                        if needs_focus_sync {
777                            node.clear_needs_focus_sync();
778                        }
779                        needs_focus_sync
780                    }),
781                Err(err) => Err(err),
782            }
783        }
784    }
785}
786
787pub(crate) fn build_draw_refresh_scope(
788    applier: &mut MemoryApplier,
789    dirty_nodes: &HashSet<NodeId>,
790) -> HashSet<NodeId> {
791    let mut refresh_scope = HashSet::with_capacity(dirty_nodes.len());
792    for &dirty_node in dirty_nodes {
793        let mut current = Some(dirty_node);
794        while let Some(node_id) = current {
795            if !refresh_scope.insert(node_id) {
796                break;
797            }
798            current = applier.get_mut(node_id).ok().and_then(|node| node.parent());
799        }
800    }
801    refresh_scope
802}
803
804fn collect_retained_redraw_nodes(
805    applier: &mut MemoryApplier,
806    node_id: NodeId,
807    dirty_nodes: &mut Vec<NodeId>,
808) {
809    let children = match applier.get_mut(node_id) {
810        Ok(node) => node.children(),
811        Err(_) => return,
812    };
813
814    let redraw = match applier.with_node::<LayoutNode, _>(node_id, |node| {
815        let needs_redraw = node.needs_redraw();
816        if needs_redraw {
817            node.clear_needs_redraw();
818        }
819        needs_redraw
820    }) {
821        Ok(needs_redraw) => needs_redraw,
822        Err(NodeError::TypeMismatch { .. }) => applier
823            .with_node::<SubcomposeLayoutNode, _>(node_id, |node| {
824                let needs_redraw = node.needs_redraw();
825                if needs_redraw {
826                    node.clear_needs_redraw();
827                }
828                needs_redraw
829            })
830            .unwrap_or(false),
831        Err(_) => false,
832    };
833    if redraw {
834        dirty_nodes.push(node_id);
835    }
836
837    for child in children {
838        collect_retained_redraw_nodes(applier, child, dirty_nodes);
839    }
840}
841
842fn refresh_layout_box_data(
843    applier: &mut MemoryApplier,
844    layout: &mut cranpose_ui::layout::LayoutBox,
845    refresh_scope: &HashSet<NodeId>,
846    dirty_nodes: &HashSet<NodeId>,
847) {
848    if !refresh_scope.contains(&layout.node_id) {
849        return;
850    }
851
852    if dirty_nodes.contains(&layout.node_id) {
853        if let Ok((modifier, resolved_modifiers, slices)) =
854            applier.with_node::<LayoutNode, _>(layout.node_id, |node| {
855                node.clear_needs_redraw();
856                (
857                    node.modifier.clone(),
858                    node.resolved_modifiers(),
859                    node.modifier_slices_snapshot(),
860                )
861            })
862        {
863            layout.node_data.modifier = modifier;
864            layout.node_data.resolved_modifiers = resolved_modifiers;
865            layout.node_data.modifier_slices = slices;
866        } else if let Ok((modifier, resolved_modifiers)) = applier
867            .with_node::<SubcomposeLayoutNode, _>(layout.node_id, |node| {
868                node.clear_needs_redraw();
869                (node.modifier(), node.resolved_modifiers())
870            })
871        {
872            layout.node_data.modifier = modifier.clone();
873            layout.node_data.resolved_modifiers = resolved_modifiers;
874            layout.node_data.modifier_slices =
875                std::rc::Rc::new(cranpose_ui::collect_slices_from_modifier(&modifier));
876        }
877    }
878
879    for child in &mut layout.children {
880        refresh_layout_box_data(applier, child, refresh_scope, dirty_nodes);
881    }
882}