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;
6
7#[derive(Copy, Clone)]
8enum DispatchInvalidationKind {
9    Pointer,
10    Focus,
11}
12
13impl<R> AppShell<R>
14where
15    R: Renderer,
16    R::Error: Debug,
17{
18    pub fn set_semantics_enabled(&mut self, enabled: bool) {
19        if self.semantics_enabled == enabled {
20            return;
21        }
22        self.semantics_enabled = enabled;
23        if enabled {
24            self.request_forced_layout_pass();
25            self.mark_dirty();
26        } else {
27            self.semantics_tree = None;
28        }
29    }
30
31    pub(crate) fn process_frame(&mut self) {
32        let app_context = Rc::clone(&self.app_context);
33        app_context.enter(|| self.process_frame_in_context());
34    }
35
36    pub(crate) fn process_frame_in_context(&mut self) {
37        let frame_start = Instant::now();
38
39        self.run_layout_phase();
40
41        #[cfg(debug_assertions)]
42        let _after_layout = Instant::now();
43
44        self.run_dispatch_queues();
45
46        #[cfg(debug_assertions)]
47        let _after_dispatch = Instant::now();
48
49        self.run_render_phase();
50        self.fps_monitor
51            .record_frame_work(frame_start, Instant::now());
52    }
53
54    pub(crate) fn run_layout_phase(&mut self) {
55        let app_context = Rc::clone(&self.app_context);
56        app_context.enter(|| self.run_layout_phase_in_context());
57    }
58
59    fn run_layout_phase_in_context(&mut self) {
60        let has_scoped_repasses = cranpose_ui::has_pending_layout_repasses();
61
62        // Global layout invalidation is reserved for app-wide inputs such as
63        // viewport, density, font-scale, or debug layout changes. Normal node
64        // updates should arrive through scoped repasses.
65        let invalidation_requested = take_layout_invalidation();
66
67        if invalidation_requested && !has_scoped_repasses {
68            cranpose_ui::layout::invalidate_all_layout_caches();
69
70            // Mark root as needing layout AND measure so tree_needs_layout() returns true
71            // and intrinsic sizes are recalculated (e.g., text field resizing on content change)
72            if let Some(root) = self.composition.root() {
73                let mut applier = self.composition.applier_mut();
74                match applier.with_node::<LayoutNode, _>(root, |node| {
75                    node.mark_needs_measure();
76                    node.mark_needs_layout();
77                }) {
78                    Ok(()) | Err(NodeError::Missing { .. }) => {}
79                    Err(NodeError::TypeMismatch { .. }) => {
80                        let _ = applier.with_node::<SubcomposeLayoutNode, _>(root, |node| {
81                            node.mark_needs_measure();
82                            node.mark_needs_layout_flag();
83                        });
84                    }
85                    Err(_) => {}
86                }
87            }
88            self.request_forced_layout_pass();
89        } else if invalidation_requested || has_scoped_repasses {
90            self.request_layout_pass();
91        }
92
93        if !self.layout_requested {
94            return;
95        }
96
97        let viewport_size = Size {
98            width: self.viewport.0,
99            height: self.viewport.1,
100        };
101        if let Some(root) = self.composition.root() {
102            let handle = self.composition.runtime_handle();
103            let mut applier = self.composition.applier_mut();
104            applier.set_runtime_handle(handle);
105
106            let tree_needs_layout_check = cranpose_ui::tree_needs_layout(&mut *applier, root)
107                .unwrap_or_else(|err| {
108                    log::warn!(
109                        "Cannot check layout dirty status for root #{}: {}",
110                        root,
111                        err
112                    );
113                    true // Assume dirty on error
114                });
115
116            let needs_layout =
117                self.force_layout_pass || has_scoped_repasses || tree_needs_layout_check;
118
119            if !needs_layout {
120                log::trace!("Skipping layout: tree is clean");
121                self.layout_requested = false;
122                self.force_layout_pass = false;
123                applier.clear_runtime_handle();
124                return;
125            }
126
127            self.layout_requested = false;
128            self.force_layout_pass = false;
129
130            // Ensure slots exist and borrow mutably (handled inside measure_layout via MemoryApplier)
131            match cranpose_ui::measure_layout_with_options(
132                &mut applier,
133                root,
134                viewport_size,
135                MeasureLayoutOptions {
136                    collect_semantics: false,
137                    build_layout_tree: false,
138                },
139            ) {
140                Ok(_measurements) => {
141                    self.layout_tree = None;
142                    if self.semantics_enabled {
143                        self.semantics_tree = None;
144                    }
145                    self.scene_dirty = true;
146                }
147                Err(err) => {
148                    log::error!("failed to compute layout: {err}");
149                    self.layout_tree = None;
150                    self.semantics_tree = None;
151                    self.scene_dirty = true;
152                }
153            }
154            applier.clear_runtime_handle();
155        } else {
156            self.layout_tree = None;
157            self.semantics_tree = None;
158            self.scene_dirty = true;
159            self.layout_requested = false;
160            self.force_layout_pass = false;
161        }
162    }
163
164    fn run_dispatch_queues(&mut self) {
165        // Process pointer input repasses
166        // Similar to Jetpack Compose's pointer input invalidation processing,
167        // we service nodes that need pointer input state updates without forcing layout/draw
168        if has_pending_pointer_repasses() {
169            let mut applier = self.composition.applier_mut();
170            process_pointer_repasses(|node_id| {
171                match clear_dispatch_invalidation(
172                    &mut applier,
173                    node_id,
174                    DispatchInvalidationKind::Pointer,
175                ) {
176                    Ok(true) => {
177                        log::trace!("Cleared pointer repass flag for node #{}", node_id);
178                    }
179                    Ok(false) => {}
180                    Err(err) => {
181                        log::debug!(
182                            "Could not process pointer repass for node #{}: {}",
183                            node_id,
184                            err
185                        );
186                    }
187                }
188            });
189        }
190
191        // Process focus invalidations
192        // Mirrors Jetpack Compose's FocusInvalidationManager.invalidateNodes(),
193        // processing nodes that need focus state synchronization
194        if has_pending_focus_invalidations() {
195            let mut applier = self.composition.applier_mut();
196            process_focus_invalidations(|node_id| {
197                match clear_dispatch_invalidation(
198                    &mut applier,
199                    node_id,
200                    DispatchInvalidationKind::Focus,
201                ) {
202                    Ok(true) => {
203                        log::trace!("Cleared focus sync flag for node #{}", node_id);
204                    }
205                    Ok(false) => {}
206                    Err(err) => {
207                        log::debug!(
208                            "Could not process focus invalidation for node #{}: {}",
209                            node_id,
210                            err
211                        );
212                    }
213                }
214            });
215        }
216    }
217
218    fn refresh_draw_repasses(&mut self) -> Vec<NodeId> {
219        let dirty_nodes = take_draw_repass_nodes();
220        if dirty_nodes.is_empty() {
221            return dirty_nodes;
222        }
223
224        let Some(layout_tree) = self.layout_tree.as_mut() else {
225            return dirty_nodes;
226        };
227
228        let dirty_set: HashSet<NodeId> = dirty_nodes.into_iter().collect();
229        let mut applier = self.composition.applier_mut();
230        let refresh_scope = build_draw_refresh_scope(&mut applier, &dirty_set);
231        refresh_layout_box_data(
232            &mut applier,
233            layout_tree.root_mut(),
234            &refresh_scope,
235            &dirty_set,
236        );
237        dirty_set.into_iter().collect()
238    }
239
240    pub(crate) fn run_render_phase(&mut self) {
241        let app_context = Rc::clone(&self.app_context);
242        app_context.enter(|| self.run_render_phase_in_context());
243    }
244
245    fn run_render_phase_in_context(&mut self) {
246        let render_dirty = take_render_invalidation();
247        let pointer_dirty = take_pointer_invalidation();
248        take_focus_invalidation();
249        let draw_repass_pending = cranpose_ui::has_pending_draw_repasses();
250        // Tick cursor blink timer - only marks dirty when visibility state changes
251        let cursor_blink_dirty = cranpose_ui::tick_cursor_blink();
252
253        let render_only_dirty = (render_dirty && !draw_repass_pending) || cursor_blink_dirty;
254        // Pointer invalidations can replace hit-test handler closures inside modifier nodes.
255        // The scene caches those closures, so it must be rebuilt to avoid dispatching stale input.
256        let scene_dirty = self.scene_dirty;
257        let needs_scene_rebuild =
258            scene_dirty || draw_repass_pending || render_only_dirty || pointer_dirty;
259
260        if !needs_scene_rebuild {
261            return;
262        }
263        self.scene_dirty = false;
264        let draw_dirty_nodes = self.refresh_draw_repasses();
265        let viewport_size = Size {
266            width: self.viewport.0,
267            height: self.viewport.1,
268        };
269
270        // Use new direct traversal rendering
271        if let Some(root) = self.composition.root() {
272            let mut applier = self.composition.applier_mut();
273            let rebuild_result = if !draw_dirty_nodes.is_empty()
274                && !render_only_dirty
275                && !pointer_dirty
276                && !scene_dirty
277            {
278                self.renderer.update_scene_from_applier(
279                    &mut applier,
280                    root,
281                    viewport_size,
282                    &draw_dirty_nodes,
283                )
284            } else {
285                self.renderer
286                    .rebuild_scene_from_applier(&mut applier, root, viewport_size)
287            };
288            if let Err(err) = rebuild_result {
289                // Fallback to clearing scene on error
290                log::error!("renderer rebuild failed: {err:?}");
291                self.renderer.scene_mut().clear();
292            }
293        } else {
294            self.renderer.scene_mut().clear();
295        }
296
297        // Draw FPS overlay if enabled (directly by renderer, no composition)
298        if self.dev_options.fps_counter {
299            let text = self.build_dev_overlay_text(viewport_size);
300            self.renderer.draw_dev_overlay(&text, viewport_size);
301        }
302    }
303
304    fn build_dev_overlay_text(&mut self, viewport_size: Size) -> String {
305        self.dev_overlay_controls.clear();
306
307        let stats = self.fps_monitor.stats();
308        let mut text = format!(
309            "{:.0} FPS | avg {:.1}ms | p95 {:.1}ms | work {:.1}ms | max {:.1}ms | {} recomp/s",
310            stats.fps,
311            stats.avg_ms,
312            stats.p95_ms,
313            stats.work_p95_ms,
314            stats.max_ms,
315            stats.recomps_per_second
316        );
317
318        if !self.dev_options.frame_pacing_controls {
319            return text;
320        }
321
322        text.push_str(" | ");
323        let mut controls = Vec::with_capacity(FramePacingMode::ALL.len());
324        for (index, mode) in FramePacingMode::ALL.into_iter().enumerate() {
325            if index > 0 {
326                text.push(' ');
327            }
328            let start = text.len();
329            if mode == self.dev_options.frame_pacing_mode {
330                text.push('[');
331                text.push_str(mode.label());
332                text.push(']');
333            } else {
334                text.push_str(mode.label());
335            }
336            controls.push((start, text.len(), mode));
337        }
338
339        let overlay_width = text.len() as f32 * DEV_OVERLAY_CHAR_WIDTH;
340        let overlay_x = (viewport_size.width - overlay_width - DEV_OVERLAY_PADDING * 2.0)
341            .max(DEV_OVERLAY_PADDING);
342        let overlay_y = DEV_OVERLAY_PADDING;
343        let text_x = overlay_x + DEV_OVERLAY_PADDING / 2.0;
344        let text_y = overlay_y + DEV_OVERLAY_PADDING / 4.0;
345        let text_height = DEV_OVERLAY_FONT_SIZE * 1.4;
346
347        self.dev_overlay_controls = controls
348            .into_iter()
349            .map(|(start, end, mode)| DevOverlayControl {
350                bounds: Rect {
351                    x: text_x + start as f32 * DEV_OVERLAY_CHAR_WIDTH - 3.0,
352                    y: text_y - 3.0,
353                    width: (end - start) as f32 * DEV_OVERLAY_CHAR_WIDTH + 6.0,
354                    height: text_height + 6.0,
355                },
356                mode,
357            })
358            .collect();
359
360        text
361    }
362}
363
364fn clear_dispatch_invalidation(
365    applier: &mut MemoryApplier,
366    node_id: NodeId,
367    invalidation: DispatchInvalidationKind,
368) -> Result<bool, NodeError> {
369    match invalidation {
370        DispatchInvalidationKind::Pointer => {
371            match applier.with_node::<LayoutNode, _>(node_id, |node| {
372                let needs_pointer_pass = node.needs_pointer_pass();
373                if needs_pointer_pass {
374                    node.clear_needs_pointer_pass();
375                }
376                needs_pointer_pass
377            }) {
378                Ok(cleared) => Ok(cleared),
379                Err(NodeError::TypeMismatch { .. }) => applier
380                    .with_node::<SubcomposeLayoutNode, _>(node_id, |node| {
381                        let needs_pointer_pass = node.needs_pointer_pass();
382                        if needs_pointer_pass {
383                            node.clear_needs_pointer_pass();
384                        }
385                        needs_pointer_pass
386                    }),
387                Err(err) => Err(err),
388            }
389        }
390        DispatchInvalidationKind::Focus => {
391            match applier.with_node::<LayoutNode, _>(node_id, |node| {
392                let needs_focus_sync = node.needs_focus_sync();
393                if needs_focus_sync {
394                    node.clear_needs_focus_sync();
395                }
396                needs_focus_sync
397            }) {
398                Ok(cleared) => Ok(cleared),
399                Err(NodeError::TypeMismatch { .. }) => applier
400                    .with_node::<SubcomposeLayoutNode, _>(node_id, |node| {
401                        let needs_focus_sync = node.needs_focus_sync();
402                        if needs_focus_sync {
403                            node.clear_needs_focus_sync();
404                        }
405                        needs_focus_sync
406                    }),
407                Err(err) => Err(err),
408            }
409        }
410    }
411}
412
413pub(crate) fn build_draw_refresh_scope(
414    applier: &mut MemoryApplier,
415    dirty_nodes: &HashSet<NodeId>,
416) -> HashSet<NodeId> {
417    let mut refresh_scope = HashSet::with_capacity(dirty_nodes.len());
418    for &dirty_node in dirty_nodes {
419        let mut current = Some(dirty_node);
420        while let Some(node_id) = current {
421            if !refresh_scope.insert(node_id) {
422                break;
423            }
424            current = applier.get_mut(node_id).ok().and_then(|node| node.parent());
425        }
426    }
427    refresh_scope
428}
429
430fn refresh_layout_box_data(
431    applier: &mut MemoryApplier,
432    layout: &mut cranpose_ui::layout::LayoutBox,
433    refresh_scope: &HashSet<NodeId>,
434    dirty_nodes: &HashSet<NodeId>,
435) {
436    if !refresh_scope.contains(&layout.node_id) {
437        return;
438    }
439
440    if dirty_nodes.contains(&layout.node_id) {
441        if let Ok((modifier, resolved_modifiers, slices)) =
442            applier.with_node::<LayoutNode, _>(layout.node_id, |node| {
443                node.clear_needs_redraw();
444                (
445                    node.modifier.clone(),
446                    node.resolved_modifiers(),
447                    node.modifier_slices_snapshot(),
448                )
449            })
450        {
451            layout.node_data.modifier = modifier;
452            layout.node_data.resolved_modifiers = resolved_modifiers;
453            layout.node_data.modifier_slices = slices;
454        } else if let Ok((modifier, resolved_modifiers)) = applier
455            .with_node::<SubcomposeLayoutNode, _>(layout.node_id, |node| {
456                node.clear_needs_redraw();
457                (node.modifier(), node.resolved_modifiers())
458            })
459        {
460            layout.node_data.modifier = modifier.clone();
461            layout.node_data.resolved_modifiers = resolved_modifiers;
462            layout.node_data.modifier_slices =
463                std::rc::Rc::new(cranpose_ui::collect_slices_from_modifier(&modifier));
464        }
465    }
466
467    for child in &mut layout.children {
468        refresh_layout_box_data(applier, child, refresh_scope, dirty_nodes);
469    }
470}