Skip to main content

cranpose_app_shell/
shell_frame.rs

1use super::*;
2
3const DEV_OVERLAY_PADDING: f32 = 8.0;
4const DEV_OVERLAY_FONT_SIZE: f32 = 14.0;
5const DEV_OVERLAY_CHAR_WIDTH: f32 = 7.0;
6const DEV_OVERLAY_REFRESH_INTERVAL: std::time::Duration = std::time::Duration::from_millis(250);
7const DEFAULT_FRAME_STAGE_TELEMETRY_THRESHOLD_MS: f64 = 4.0;
8
9#[derive(Copy, Clone)]
10enum DispatchInvalidationKind {
11    Pointer,
12    Focus,
13}
14
15fn frame_stage_telemetry_threshold_ms() -> Option<f64> {
16    static THRESHOLD_MS: std::sync::OnceLock<Option<f64>> = std::sync::OnceLock::new();
17    *THRESHOLD_MS.get_or_init(|| {
18        let explicit = std::env::var("CRANPOSE_FRAME_STAGE_TELEMETRY_MS")
19            .ok()
20            .and_then(|value| value.parse::<f64>().ok())
21            .filter(|value| value.is_finite() && *value >= 0.0);
22        explicit.or_else(|| {
23            std::env::var_os("CRANPOSE_FRAME_STAGE_TELEMETRY")
24                .is_some()
25                .then_some(DEFAULT_FRAME_STAGE_TELEMETRY_THRESHOLD_MS)
26        })
27    })
28}
29
30fn log_frame_stage_telemetry(
31    frame_start: Instant,
32    after_initial_layout: Instant,
33    after_layout: Instant,
34    after_dispatch: Instant,
35    after_render: Instant,
36) {
37    let Some(threshold_ms) = frame_stage_telemetry_threshold_ms() else {
38        return;
39    };
40
41    let total_ms = after_render.duration_since(frame_start).as_secs_f64() * 1000.0;
42    if total_ms < threshold_ms {
43        return;
44    }
45
46    let layout_ms = after_layout.duration_since(frame_start).as_secs_f64() * 1000.0;
47    let initial_layout_ms = after_initial_layout
48        .duration_since(frame_start)
49        .as_secs_f64()
50        * 1000.0;
51    let post_layout_ms = after_layout
52        .duration_since(after_initial_layout)
53        .as_secs_f64()
54        * 1000.0;
55    let dispatch_ms = after_dispatch.duration_since(after_layout).as_secs_f64() * 1000.0;
56    let scene_ms = after_render.duration_since(after_dispatch).as_secs_f64() * 1000.0;
57    log::warn!(
58        "[frame-stage-telemetry] total_ms={total_ms:.2} layout_ms={layout_ms:.2} initial_layout_ms={initial_layout_ms:.2} post_layout_ms={post_layout_ms:.2} dispatch_ms={dispatch_ms:.2} scene_ms={scene_ms:.2}",
59    );
60}
61
62fn render_phase_dirty_diagnostics_enabled() -> bool {
63    cranpose_core::env_flag!("CRANPOSE_RENDER_PHASE_DIRTY_DIAG")
64}
65
66struct RenderPhaseDirtyDiagnostics<'a> {
67    render_dirty: bool,
68    pointer_dirty: bool,
69    scene_dirty: bool,
70    draw_repass_pending: bool,
71    draw_dirty_nodes: usize,
72    layout_dirty_nodes: usize,
73    structural_dirty_nodes: usize,
74    partial_dirty_nodes: usize,
75    dirty_node_ids: Option<String>,
76    render_only_dirty: bool,
77    recomposed_this_frame: bool,
78    path: &'a str,
79}
80
81fn log_render_phase_dirty_diagnostics(diagnostics: RenderPhaseDirtyDiagnostics<'_>) {
82    if !render_phase_dirty_diagnostics_enabled() {
83        return;
84    }
85
86    let RenderPhaseDirtyDiagnostics {
87        render_dirty,
88        pointer_dirty,
89        scene_dirty,
90        draw_repass_pending,
91        draw_dirty_nodes,
92        layout_dirty_nodes,
93        structural_dirty_nodes,
94        partial_dirty_nodes,
95        dirty_node_ids,
96        render_only_dirty,
97        recomposed_this_frame,
98        path,
99    } = diagnostics;
100    if let Some(dirty_node_ids) = dirty_node_ids {
101        log::warn!(
102            "[render-phase-dirty] path={path} render_dirty={render_dirty} pointer_dirty={pointer_dirty} scene_dirty={scene_dirty} draw_repass_pending={draw_repass_pending} draw_dirty_nodes={draw_dirty_nodes} layout_dirty_nodes={layout_dirty_nodes} structural_dirty_nodes={structural_dirty_nodes} partial_dirty_nodes={partial_dirty_nodes} render_only_dirty={render_only_dirty} recomposed_this_frame={recomposed_this_frame} ids={dirty_node_ids}",
103        );
104    } else {
105        log::warn!(
106            "[render-phase-dirty] path={path} render_dirty={render_dirty} pointer_dirty={pointer_dirty} scene_dirty={scene_dirty} draw_repass_pending={draw_repass_pending} draw_dirty_nodes={draw_dirty_nodes} layout_dirty_nodes={layout_dirty_nodes} structural_dirty_nodes={structural_dirty_nodes} partial_dirty_nodes={partial_dirty_nodes} render_only_dirty={render_only_dirty} recomposed_this_frame={recomposed_this_frame}",
107        );
108    }
109}
110
111impl<R> AppShell<R>
112where
113    R: Renderer,
114    R::Error: Debug,
115{
116    pub fn set_semantics_enabled(&mut self, enabled: bool) {
117        if self.semantics_enabled == enabled {
118            return;
119        }
120        self.semantics_enabled = enabled;
121        self.semantics_snapshot_revision = self.semantics_snapshot_revision.wrapping_add(1);
122        if enabled {
123            self.request_forced_layout_pass();
124            self.mark_dirty();
125        } else {
126            self.semantics_tree = None;
127        }
128    }
129
130    /// Sets how the platform should vote the display's frame rate for this
131    /// app. The default, [`FrameRatePreference::Auto`], mirrors Compose: the
132    /// panel's fastest rate while frames are being produced, no preference
133    /// when the scene is still.
134    pub fn set_frame_rate_preference(&mut self, preference: crate::FrameRatePreference) {
135        self.frame_rate_preference = preference;
136    }
137
138    /// The app's current display frame-rate preference. Read by platform
139    /// backends each frame; the vote itself is applied by the backend that
140    /// owns the native window.
141    pub fn frame_rate_preference(&self) -> crate::FrameRatePreference {
142        self.frame_rate_preference
143    }
144
145    pub(crate) fn process_frame(&mut self) -> FrameUpdateResult {
146        let app_context = Rc::clone(&self.app_context);
147        app_context.enter(|| self.process_frame_in_context(false))
148    }
149
150    pub(crate) fn process_frame_in_context(
151        &mut self,
152        recomposed_before_frame: bool,
153    ) -> FrameUpdateResult {
154        let frame_start = Instant::now();
155
156        self.run_layout_phase();
157        let after_initial_layout = Instant::now();
158
159        let recomposed_this_frame = recomposed_before_frame || self.run_post_layout_recomposition();
160
161        let after_layout = Instant::now();
162
163        self.run_dispatch_queues();
164
165        let after_dispatch = Instant::now();
166
167        clear_transient_scroll_motion_contexts();
168
169        let result = self.run_render_phase_with_recomposition_state(recomposed_this_frame);
170        let after_render = Instant::now();
171        log_frame_stage_telemetry(
172            frame_start,
173            after_initial_layout,
174            after_layout,
175            after_dispatch,
176            after_render,
177        );
178        result
179    }
180
181    pub(crate) fn run_layout_phase(&mut self) {
182        let app_context = Rc::clone(&self.app_context);
183        app_context.enter(|| self.run_layout_phase_in_context());
184    }
185
186    fn run_layout_phase_in_context(&mut self) {
187        // Both scoped layout repasses (re-placement) and scoped measure repasses
188        // (re-sizing, e.g. a collapsing swipe-dismissed row) drive a layout pass.
189        let has_scoped_repasses = cranpose_ui::has_pending_layout_repasses()
190            || cranpose_ui::has_pending_measure_repasses();
191        // Both kinds carry the node they were scheduled for, and the scene
192        // phase scopes its graph update to exactly those nodes. Collecting only
193        // the layout ids silently dropped the measure ones, which is the path a
194        // scrolling `LazyColumn` takes: the scene phase then saw an empty dirty
195        // set with the scene marked dirty, read that as "everything changed",
196        // and rebuilt the graph from the composition root on every scrolled
197        // frame.
198        let mut scoped_layout_nodes = if cranpose_ui::has_pending_layout_repasses() {
199            cranpose_ui::pending_layout_repass_nodes_snapshot()
200        } else {
201            Vec::new()
202        };
203        if cranpose_ui::has_pending_measure_repasses() {
204            for node in cranpose_ui::pending_measure_repass_nodes_snapshot() {
205                if !scoped_layout_nodes.contains(&node) {
206                    scoped_layout_nodes.push(node);
207                }
208            }
209        }
210
211        // Global layout invalidation is reserved for app-wide inputs such as
212        // viewport, density, font-scale, or debug layout changes. Normal node
213        // updates should arrive through scoped repasses.
214        let invalidation_requested = take_layout_invalidation();
215        let global_layout_invalidation = invalidation_requested && !has_scoped_repasses;
216        let force_layout_pass = self.force_layout_pass;
217
218        if invalidation_requested && !has_scoped_repasses {
219            cranpose_ui::layout::invalidate_all_layout_caches();
220
221            // Mark root as needing layout AND measure so tree_needs_layout() returns true
222            // and intrinsic sizes are recalculated (e.g., text field resizing on content change)
223            if let Some(root) = self.composition.root() {
224                let mut applier = self.composition.applier_mut();
225                match applier.with_node::<LayoutNode, _>(root, |node| {
226                    node.mark_needs_measure();
227                    node.mark_needs_layout();
228                }) {
229                    Ok(()) | Err(NodeError::Missing { .. }) => {}
230                    Err(NodeError::TypeMismatch { .. }) => {
231                        let _ = applier.with_node::<SubcomposeLayoutNode, _>(root, |node| {
232                            node.mark_needs_measure();
233                            node.mark_needs_layout_flag();
234                        });
235                    }
236                    Err(_) => {}
237                }
238            }
239            self.request_forced_layout_pass();
240        } else if invalidation_requested || has_scoped_repasses {
241            self.request_layout_pass();
242        }
243
244        if !self.layout_requested {
245            return;
246        }
247
248        let viewport_size = Size {
249            width: self.viewport.0,
250            height: self.viewport.1,
251        };
252        if let Some(root) = self.composition.root() {
253            let handle = self.composition.runtime_handle();
254            let mut applier = self.composition.applier_mut();
255            applier.set_runtime_handle(handle);
256
257            let tree_needs_layout_check = cranpose_ui::tree_needs_layout(&mut *applier, root)
258                .unwrap_or_else(|err| {
259                    log::warn!(
260                        "Cannot check layout dirty status for root #{}: {}",
261                        root,
262                        err
263                    );
264                    true // Assume dirty on error
265                });
266
267            let needs_layout =
268                self.force_layout_pass || has_scoped_repasses || tree_needs_layout_check;
269
270            if !needs_layout {
271                log::trace!("Skipping layout: tree is clean");
272                self.layout_requested = false;
273                self.force_layout_pass = false;
274                applier.clear_runtime_handle();
275                return;
276            }
277
278            self.layout_requested = false;
279            self.force_layout_pass = false;
280
281            // Ensure slots exist and borrow mutably (handled inside measure_layout via MemoryApplier)
282            match cranpose_ui::measure_layout_with_options(
283                &mut applier,
284                root,
285                viewport_size,
286                MeasureLayoutOptions {
287                    collect_semantics: false,
288                    build_layout_tree: false,
289                },
290            ) {
291                Ok(_measurements) => {
292                    self.layout_tree = None;
293                    self.semantics_snapshot_revision =
294                        self.semantics_snapshot_revision.wrapping_add(1);
295                    if self.semantics_enabled {
296                        self.semantics_tree = None;
297                    }
298                    // A frame runs this phase more than once — the initial pass
299                    // and again after post-layout recomposition — and the scene
300                    // phase consumes the ids once, at the end. Assigning here
301                    // let a later pass with nothing pending wipe the ids an
302                    // earlier one recorded, which is how a scrolling
303                    // `LazyColumn` reached the scene phase with an empty dirty
304                    // set and got a full rebuild. Accumulate instead; only an
305                    // app-wide relayout, where scoping means nothing, clears.
306                    if global_layout_invalidation || force_layout_pass {
307                        self.scoped_layout_scene_nodes.clear();
308                        let _ = cranpose_ui::take_geometry_scene_nodes();
309                    } else {
310                        // HashSet dedup: a big pass can move thousands of
311                        // nodes, and Vec::contains per id is quadratic.
312                        let mut seen: HashSet<NodeId> =
313                            self.scoped_layout_scene_nodes.iter().copied().collect();
314                        if has_scoped_repasses {
315                            for node in scoped_layout_nodes {
316                                if seen.insert(node) {
317                                    self.scoped_layout_scene_nodes.push(node);
318                                }
319                            }
320                        }
321                        // Nodes the pass actually moved or resized. Repass ids
322                        // only say where invalidation STARTED; a sibling pushed
323                        // down by another row's growth starts nothing, and a
324                        // scoped scene update that never hears about it keeps
325                        // drawing it at the old geometry until the next full
326                        // pass. Each id is resolved the way structural records
327                        // are — nearest non-virtual ancestor, still attached —
328                        // because an id the graph cannot resolve forces the
329                        // scoped update to give up and rebuild the whole scene.
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        // Process pointer input repasses
407        // Similar to Jetpack Compose's pointer input invalidation processing,
408        // we service nodes that need pointer input state updates without forcing layout/draw
409        if has_pending_pointer_repasses() {
410            let mut applier = self.composition.applier_mut();
411            process_pointer_repasses(|node_id| {
412                match clear_dispatch_invalidation(
413                    &mut applier,
414                    node_id,
415                    DispatchInvalidationKind::Pointer,
416                ) {
417                    Ok(true) => {
418                        log::trace!("Cleared pointer repass flag for node #{}", node_id);
419                    }
420                    Ok(false) => {}
421                    Err(err) => {
422                        log::debug!(
423                            "Could not process pointer repass for node #{}: {}",
424                            node_id,
425                            err
426                        );
427                    }
428                }
429            });
430        }
431
432        // Process focus invalidations
433        // Mirrors Jetpack Compose's FocusInvalidationManager.invalidateNodes(),
434        // processing nodes that need focus state synchronization
435        if has_pending_focus_invalidations() {
436            let mut applier = self.composition.applier_mut();
437            process_focus_invalidations(|node_id| {
438                match clear_dispatch_invalidation(
439                    &mut applier,
440                    node_id,
441                    DispatchInvalidationKind::Focus,
442                ) {
443                    Ok(true) => {
444                        log::trace!("Cleared focus sync flag for node #{}", node_id);
445                    }
446                    Ok(false) => {}
447                    Err(err) => {
448                        log::debug!(
449                            "Could not process focus invalidation for node #{}: {}",
450                            node_id,
451                            err
452                        );
453                    }
454                }
455            });
456        }
457
458        // Process semantics invalidations raised from outside composition.
459        //
460        // Mirrors Compose's `SemanticsModifierNode.invalidateSemantics()`: an
461        // app whose recorder reads state the composition does not observe says
462        // so here, and the flag is bubbled to the root because
463        // `tree_needs_semantics` reads the root alone. Without this a screen
464        // that never relays out — a game drawn on one `Canvas` — publishes its
465        // semantics once and then keeps publishing that same stale tree.
466        if has_pending_semantics_invalidations() {
467            let mut applier = self.composition.applier_mut();
468            process_semantics_invalidations(|node_id| {
469                cranpose_core::bubble_semantics_dirty(&mut *applier, node_id);
470            });
471        }
472    }
473
474    fn refresh_draw_repasses(&mut self) -> Vec<NodeId> {
475        let dirty_nodes = take_draw_repass_nodes();
476        if dirty_nodes.is_empty() {
477            return dirty_nodes;
478        }
479        self.refresh_draw_nodes(dirty_nodes)
480    }
481
482    fn refresh_retained_redraw_nodes(&mut self) -> Vec<NodeId> {
483        let Some(root) = self.composition.root() else {
484            return Vec::new();
485        };
486        let mut dirty_nodes = Vec::new();
487        {
488            let mut applier = self.composition.applier_mut();
489            collect_retained_redraw_nodes(&mut applier, root, &mut dirty_nodes);
490        }
491        if dirty_nodes.is_empty() {
492            return dirty_nodes;
493        }
494        self.refresh_draw_nodes(dirty_nodes)
495    }
496
497    fn refresh_draw_nodes(&mut self, dirty_nodes: Vec<NodeId>) -> Vec<NodeId> {
498        let Some(layout_tree) = self.layout_tree.as_mut() else {
499            return dirty_nodes;
500        };
501
502        let dirty_set: HashSet<NodeId> = dirty_nodes.into_iter().collect();
503        let mut applier = self.composition.applier_mut();
504        let refresh_scope = build_draw_refresh_scope(&mut applier, &dirty_set);
505        refresh_layout_box_data(
506            &mut applier,
507            layout_tree.root_mut(),
508            &refresh_scope,
509            &dirty_set,
510        );
511        dirty_set.into_iter().collect()
512    }
513
514    #[cfg(test)]
515    pub(crate) fn run_render_phase(&mut self) -> FrameUpdateResult {
516        let app_context = Rc::clone(&self.app_context);
517        app_context.enter(|| self.run_render_phase_in_context(false))
518    }
519
520    fn run_render_phase_with_recomposition_state(
521        &mut self,
522        recomposed_this_frame: bool,
523    ) -> FrameUpdateResult {
524        let app_context = Rc::clone(&self.app_context);
525        app_context.enter(|| self.run_render_phase_in_context(recomposed_this_frame))
526    }
527
528    fn run_render_phase_in_context(&mut self, recomposed_this_frame: bool) -> FrameUpdateResult {
529        // Tick cursor blink before the dirty sets are drained: a visibility
530        // flip schedules a scoped draw repass on the focused field plus a
531        // render invalidation, and both must land in THIS frame's take.
532        cranpose_ui::tick_cursor_blink();
533        let render_dirty = take_render_invalidation();
534        let pointer_dirty = take_pointer_invalidation();
535        take_focus_invalidation();
536        let draw_repass_pending = cranpose_ui::has_pending_draw_repasses();
537        let mut draw_dirty_nodes = self.refresh_draw_repasses();
538        if render_dirty && !draw_repass_pending && draw_dirty_nodes.is_empty() {
539            draw_dirty_nodes = self.refresh_retained_redraw_nodes();
540        }
541        let layout_dirty_nodes = std::mem::take(&mut self.scoped_layout_scene_nodes);
542        // Parents whose child lists changed structurally this frame. A scoped
543        // scene update patches only the dirty subtrees, so every structural
544        // parent must be in scope or a removed subtree's layers stay in the
545        // persistent render graph and keep compositing (the cross-tab ghost).
546        let structural_parents = if cranpose_core::env_flag!("CRANPOSE_DISABLE_STRUCTURAL_DIRT") {
547            Vec::new()
548        } else if let Some(root) = self.composition.root() {
549            self.composition
550                .applier_mut()
551                .take_structural_change_parents_attached_to(root)
552        } else {
553            Vec::new()
554        };
555        let structural_dirty = !structural_parents.is_empty();
556        let mut partial_dirty_nodes = draw_dirty_nodes.clone();
557        partial_dirty_nodes.extend(layout_dirty_nodes.iter().copied());
558        partial_dirty_nodes.extend(structural_parents.iter().copied());
559        partial_dirty_nodes.sort_unstable();
560        partial_dirty_nodes.dedup();
561        let draw_dirty_node_count = draw_dirty_nodes.len();
562        let layout_dirty_node_count = layout_dirty_nodes.len();
563        let structural_dirty_node_count = structural_parents.len();
564        let partial_dirty_node_count = partial_dirty_nodes.len();
565        // Detect a focused text field that left the composition this frame:
566        // the check clears the stale focus entry and asks the platform to
567        // hide its soft keyboard (no-op while focus is live or when no
568        // keyboard was requested).
569        let _ = cranpose_ui::has_focused_field();
570
571        // A render invalidation with every tracked dirty set empty is a
572        // frame REQUEST, not a scene change: everything that alters recorded
573        // draws names its node (draw observations for snapshot reads, scoped
574        // repasses for caret/focus/press state). The retained scene is
575        // re-presented; nothing is re-recorded.
576        let render_only_dirty = render_dirty
577            && partial_dirty_nodes.is_empty()
578            && !draw_repass_pending
579            && !structural_dirty;
580        let scene_dirty = self.scene_dirty;
581        let draw_only_partial_dirty = !draw_dirty_nodes.is_empty()
582            && layout_dirty_nodes.is_empty()
583            && !pointer_dirty
584            && !recomposed_this_frame
585            && !structural_dirty;
586        let scoped_scene_dirty = scene_dirty && !layout_dirty_nodes.is_empty();
587        let full_scene_dirty = scene_dirty && !scoped_scene_dirty && !draw_only_partial_dirty;
588        let partial_scene_dirty = !partial_dirty_nodes.is_empty();
589        let needs_scene_rebuild = full_scene_dirty
590            || scoped_scene_dirty
591            || partial_scene_dirty
592            || draw_repass_pending
593            || structural_dirty;
594
595        if !needs_scene_rebuild {
596            log_render_phase_dirty_diagnostics(RenderPhaseDirtyDiagnostics {
597                render_dirty,
598                pointer_dirty,
599                scene_dirty,
600                draw_repass_pending,
601                draw_dirty_nodes: draw_dirty_node_count,
602                layout_dirty_nodes: layout_dirty_node_count,
603                structural_dirty_nodes: structural_dirty_node_count,
604                partial_dirty_nodes: partial_dirty_node_count,
605                dirty_node_ids: render_phase_dirty_diagnostics_enabled().then(|| {
606                    format!(
607                        "draw={:?} layout={:?} structural={:?} partial={:?}",
608                        draw_dirty_nodes,
609                        layout_dirty_nodes,
610                        structural_parents,
611                        partial_dirty_nodes
612                    )
613                }),
614                render_only_dirty,
615                recomposed_this_frame,
616                path: if render_only_dirty {
617                    "present-retained"
618                } else {
619                    "skip"
620                },
621            });
622            self.scoped_layout_scene_nodes = layout_dirty_nodes;
623            // A pure frame request still presents: encode re-runs against the
624            // retained scene with fresh per-frame inputs (animated motion
625            // contexts, shader time), it just records no draws. The dev
626            // overlay must keep counting through such streams — an animation
627            // that never dirties a node would otherwise freeze its numbers.
628            if render_only_dirty && self.dev_options.fps_counter {
629                let viewport_size = Size {
630                    width: self.viewport.0,
631                    height: self.viewport.1,
632                };
633                self.refresh_dev_overlay_text_for_frame_at(viewport_size, Instant::now());
634                let text = self.dev_overlay_text.as_str();
635                self.renderer.draw_dev_overlay(text, viewport_size);
636            }
637            return FrameUpdateResult {
638                visual_changed: render_only_dirty,
639                structure_changed: false,
640            };
641        }
642        self.scene_dirty = false;
643        let viewport_size = Size {
644            width: self.viewport.0,
645            height: self.viewport.1,
646        };
647        let visual_update_only =
648            draw_only_partial_dirty && !partial_dirty_nodes.is_empty() && !full_scene_dirty;
649        let structure_changed = !render_only_dirty && !visual_update_only;
650
651        // Use new direct traversal rendering
652        if let Some(root) = self.composition.root() {
653            let mut applier = self.composition.applier_mut();
654            let use_partial_update =
655                !partial_dirty_nodes.is_empty() && !render_only_dirty && !full_scene_dirty;
656            let use_visual_update = use_partial_update && draw_only_partial_dirty;
657            log_render_phase_dirty_diagnostics(RenderPhaseDirtyDiagnostics {
658                render_dirty,
659                pointer_dirty,
660                scene_dirty,
661                draw_repass_pending,
662                draw_dirty_nodes: draw_dirty_node_count,
663                layout_dirty_nodes: layout_dirty_node_count,
664                structural_dirty_nodes: structural_dirty_node_count,
665                partial_dirty_nodes: partial_dirty_node_count,
666                dirty_node_ids: render_phase_dirty_diagnostics_enabled().then(|| {
667                    format!(
668                        "draw={:?} layout={:?} structural={:?} partial={:?}",
669                        draw_dirty_nodes,
670                        layout_dirty_nodes,
671                        structural_parents,
672                        partial_dirty_nodes
673                    )
674                }),
675                render_only_dirty,
676                recomposed_this_frame,
677                path: if use_visual_update {
678                    "visual-update"
679                } else if use_partial_update {
680                    "update"
681                } else {
682                    "rebuild"
683                },
684            });
685            let rebuild_result = if use_visual_update {
686                self.renderer.update_visual_scene_from_applier(
687                    &mut applier,
688                    root,
689                    viewport_size,
690                    &partial_dirty_nodes,
691                )
692            } else if use_partial_update {
693                self.renderer.update_scene_from_applier(
694                    &mut applier,
695                    root,
696                    viewport_size,
697                    &partial_dirty_nodes,
698                )
699            } else {
700                self.renderer
701                    .rebuild_scene_from_applier(&mut applier, root, viewport_size)
702            };
703            if let Err(err) = rebuild_result {
704                // Fallback to clearing scene on error
705                log::error!("renderer rebuild failed: {err:?}");
706                self.renderer.scene_mut().clear();
707            }
708        } else {
709            self.renderer.scene_mut().clear();
710        }
711        if let Some(retained_nodes) = self.renderer.scene().retained_visual_observation_nodes() {
712            cranpose_ui::prune_draw_observations_to_nodes(&retained_nodes);
713        }
714
715        // Draw FPS overlay if enabled (directly by renderer, no composition)
716        if self.dev_options.fps_counter {
717            self.refresh_dev_overlay_text_for_frame_at(viewport_size, Instant::now());
718            let renderer = &mut self.renderer;
719            let text = self.dev_overlay_text.as_str();
720            renderer.draw_dev_overlay(text, viewport_size);
721        }
722
723        FrameUpdateResult {
724            visual_changed: true,
725            structure_changed,
726        }
727    }
728
729    fn refresh_dev_overlay_text_for_frame_at(&mut self, viewport_size: Size, now: Instant) {
730        if !self.dev_overlay_text_needs_refresh(viewport_size, now) {
731            return;
732        }
733        self.dev_overlay_text = self.build_dev_overlay_text(viewport_size);
734        self.dev_overlay_last_refresh = Some(now);
735        self.dev_overlay_viewport = Some(viewport_size);
736    }
737
738    fn dev_overlay_text_needs_refresh(&self, viewport_size: Size, now: Instant) -> bool {
739        if self.dev_overlay_text.is_empty() || self.dev_overlay_viewport != Some(viewport_size) {
740            return true;
741        }
742
743        self.dev_overlay_last_refresh
744            .map(|last| {
745                now.checked_duration_since(last).unwrap_or_default() >= DEV_OVERLAY_REFRESH_INTERVAL
746            })
747            .unwrap_or(true)
748    }
749
750    fn build_dev_overlay_text(&mut self, viewport_size: Size) -> String {
751        self.dev_overlay_controls.clear();
752
753        let stats = self.fps_monitor.stats();
754        let mut text = format!(
755            "{:.0} FPS | avg {:.1}ms | p95 {:.1}ms | max {:.1}ms | work {:.1}ms | {} recomp/s",
756            stats.fps,
757            stats.avg_ms,
758            stats.p95_ms,
759            stats.max_ms,
760            stats.work_avg_ms,
761            stats.recomps_per_second
762        );
763
764        if !self.dev_options.frame_pacing_controls {
765            return text;
766        }
767
768        text.push_str(" | ");
769        let mut controls = Vec::with_capacity(FramePacingMode::ALL.len());
770        for (index, mode) in FramePacingMode::ALL.into_iter().enumerate() {
771            if index > 0 {
772                text.push(' ');
773            }
774            let start = text.len();
775            if mode == self.dev_options.frame_pacing_mode {
776                text.push('[');
777                text.push_str(mode.label());
778                text.push(']');
779            } else {
780                text.push_str(mode.label());
781            }
782            controls.push((start, text.len(), mode));
783        }
784
785        let overlay_width = text.len() as f32 * DEV_OVERLAY_CHAR_WIDTH;
786        let overlay_x = (viewport_size.width - overlay_width - DEV_OVERLAY_PADDING * 2.0)
787            .max(DEV_OVERLAY_PADDING);
788        let overlay_y = DEV_OVERLAY_PADDING;
789        let text_x = overlay_x + DEV_OVERLAY_PADDING / 2.0;
790        let text_y = overlay_y + DEV_OVERLAY_PADDING / 4.0;
791        let text_height = DEV_OVERLAY_FONT_SIZE * 1.4;
792
793        self.dev_overlay_controls = controls
794            .into_iter()
795            .map(|(start, end, mode)| DevOverlayControl {
796                bounds: Rect {
797                    x: text_x + start as f32 * DEV_OVERLAY_CHAR_WIDTH - 3.0,
798                    y: text_y - 3.0,
799                    width: (end - start) as f32 * DEV_OVERLAY_CHAR_WIDTH + 6.0,
800                    height: text_height + 6.0,
801                },
802                mode,
803            })
804            .collect();
805
806        text
807    }
808}
809
810fn clear_dispatch_invalidation(
811    applier: &mut MemoryApplier,
812    node_id: NodeId,
813    invalidation: DispatchInvalidationKind,
814) -> Result<bool, NodeError> {
815    match invalidation {
816        DispatchInvalidationKind::Pointer => {
817            match applier.with_node::<LayoutNode, _>(node_id, |node| {
818                let needs_pointer_pass = node.needs_pointer_pass();
819                if needs_pointer_pass {
820                    node.clear_needs_pointer_pass();
821                }
822                needs_pointer_pass
823            }) {
824                Ok(cleared) => Ok(cleared),
825                Err(NodeError::TypeMismatch { .. }) => applier
826                    .with_node::<SubcomposeLayoutNode, _>(node_id, |node| {
827                        let needs_pointer_pass = node.needs_pointer_pass();
828                        if needs_pointer_pass {
829                            node.clear_needs_pointer_pass();
830                        }
831                        needs_pointer_pass
832                    }),
833                Err(err) => Err(err),
834            }
835        }
836        DispatchInvalidationKind::Focus => {
837            match applier.with_node::<LayoutNode, _>(node_id, |node| {
838                let needs_focus_sync = node.needs_focus_sync();
839                if needs_focus_sync {
840                    node.clear_needs_focus_sync();
841                }
842                needs_focus_sync
843            }) {
844                Ok(cleared) => Ok(cleared),
845                Err(NodeError::TypeMismatch { .. }) => applier
846                    .with_node::<SubcomposeLayoutNode, _>(node_id, |node| {
847                        let needs_focus_sync = node.needs_focus_sync();
848                        if needs_focus_sync {
849                            node.clear_needs_focus_sync();
850                        }
851                        needs_focus_sync
852                    }),
853                Err(err) => Err(err),
854            }
855        }
856    }
857}
858
859pub(crate) fn build_draw_refresh_scope(
860    applier: &mut MemoryApplier,
861    dirty_nodes: &HashSet<NodeId>,
862) -> HashSet<NodeId> {
863    let mut refresh_scope = HashSet::with_capacity(dirty_nodes.len());
864    for &dirty_node in dirty_nodes {
865        let mut current = Some(dirty_node);
866        while let Some(node_id) = current {
867            if !refresh_scope.insert(node_id) {
868                break;
869            }
870            current = applier.get_mut(node_id).ok().and_then(|node| node.parent());
871        }
872    }
873    refresh_scope
874}
875
876fn collect_retained_redraw_nodes(
877    applier: &mut MemoryApplier,
878    node_id: NodeId,
879    dirty_nodes: &mut Vec<NodeId>,
880) {
881    let children = match applier.get_mut(node_id) {
882        Ok(node) => node.children(),
883        Err(_) => return,
884    };
885
886    let redraw = match applier.with_node::<LayoutNode, _>(node_id, |node| {
887        let needs_redraw = node.needs_redraw();
888        if needs_redraw {
889            node.clear_needs_redraw();
890        }
891        needs_redraw
892    }) {
893        Ok(needs_redraw) => needs_redraw,
894        Err(NodeError::TypeMismatch { .. }) => applier
895            .with_node::<SubcomposeLayoutNode, _>(node_id, |node| {
896                let needs_redraw = node.needs_redraw();
897                if needs_redraw {
898                    node.clear_needs_redraw();
899                }
900                needs_redraw
901            })
902            .unwrap_or(false),
903        Err(_) => false,
904    };
905    if redraw {
906        dirty_nodes.push(node_id);
907    }
908
909    for child in children {
910        collect_retained_redraw_nodes(applier, child, dirty_nodes);
911    }
912}
913
914fn refresh_layout_box_data(
915    applier: &mut MemoryApplier,
916    layout: &mut cranpose_ui::layout::LayoutBox,
917    refresh_scope: &HashSet<NodeId>,
918    dirty_nodes: &HashSet<NodeId>,
919) {
920    if !refresh_scope.contains(&layout.node_id) {
921        return;
922    }
923
924    if dirty_nodes.contains(&layout.node_id) {
925        if let Ok((modifier, resolved_modifiers, slices)) =
926            applier.with_node::<LayoutNode, _>(layout.node_id, |node| {
927                node.clear_needs_redraw();
928                (
929                    node.modifier.clone(),
930                    node.resolved_modifiers(),
931                    node.modifier_slices_snapshot(),
932                )
933            })
934        {
935            layout.node_data.modifier = modifier;
936            layout.node_data.resolved_modifiers = resolved_modifiers;
937            layout.node_data.modifier_slices = slices;
938        } else if let Ok((modifier, resolved_modifiers)) = applier
939            .with_node::<SubcomposeLayoutNode, _>(layout.node_id, |node| {
940                node.clear_needs_redraw();
941                (node.modifier(), node.resolved_modifiers())
942            })
943        {
944            layout.node_data.modifier = modifier.clone();
945            layout.node_data.resolved_modifiers = resolved_modifiers;
946            layout.node_data.modifier_slices =
947                std::rc::Rc::new(cranpose_ui::collect_slices_from_modifier(&modifier));
948        }
949    }
950
951    for child in &mut layout.children {
952        refresh_layout_box_data(applier, child, refresh_scope, dirty_nodes);
953    }
954}