Skip to main content

cranpose_app_shell/
shell_frame.rs

1use super::*;
2
3#[derive(Copy, Clone)]
4enum DispatchInvalidationKind {
5    Pointer,
6    Focus,
7}
8
9impl<R> AppShell<R>
10where
11    R: Renderer,
12    R::Error: Debug,
13{
14    pub fn set_semantics_enabled(&mut self, enabled: bool) {
15        if self.semantics_enabled == enabled {
16            return;
17        }
18        self.semantics_enabled = enabled;
19        if enabled {
20            self.request_forced_layout_pass();
21            self.mark_dirty();
22        } else {
23            self.semantics_tree = None;
24        }
25    }
26
27    pub(crate) fn process_frame(&mut self) {
28        // Record frame for FPS tracking
29        fps_monitor::record_frame();
30
31        #[cfg(debug_assertions)]
32        let _frame_start = Instant::now();
33
34        self.run_layout_phase();
35
36        #[cfg(debug_assertions)]
37        let _after_layout = Instant::now();
38
39        self.run_dispatch_queues();
40
41        #[cfg(debug_assertions)]
42        let _after_dispatch = Instant::now();
43
44        self.run_render_phase();
45    }
46
47    pub(crate) fn run_layout_phase(&mut self) {
48        let has_scoped_repasses = cranpose_ui::has_pending_layout_repasses();
49
50        // ═══════════════════════════════════════════════════════════════════════════════
51        // GLOBAL LAYOUT INVALIDATION (rare fallback for true global events)
52        // ═══════════════════════════════════════════════════════════════════════════════
53        // This is the "nuclear option" - invalidates ALL layout caches across the entire app.
54        //
55        // WHEN THIS SHOULD FIRE:
56        //   ✓ Window/viewport resize
57        //   ✓ Global font scale or density changes
58        //   ✓ Debug toggles that affect layout globally
59        //
60        // WHEN THIS SHOULD *NOT* FIRE:
61        //   ✗ Scroll (use schedule_layout_repass instead)
62        //   ✗ Single widget updates (use schedule_layout_repass instead)
63        //   ✗ Any local layout change (use schedule_layout_repass instead)
64        //
65        // If you see this firing frequently during normal interactions,
66        // someone is abusing request_layout_invalidation() - investigate!
67        let invalidation_requested = take_layout_invalidation();
68
69        if invalidation_requested && !has_scoped_repasses {
70            // Invalidate all caches (O(app size) - expensive!)
71            // This is internal-only API, only accessible via the internal path
72            cranpose_ui::layout::invalidate_all_layout_caches();
73
74            // Mark root as needing layout AND measure so tree_needs_layout() returns true
75            // and intrinsic sizes are recalculated (e.g., text field resizing on content change)
76            if let Some(root) = self.composition.root() {
77                let mut applier = self.composition.applier_mut();
78                match applier.with_node::<LayoutNode, _>(root, |node| {
79                    node.mark_needs_measure();
80                    node.mark_needs_layout();
81                }) {
82                    Ok(()) | Err(NodeError::Missing { .. }) => {}
83                    Err(NodeError::TypeMismatch { .. }) => {
84                        let _ = applier.with_node::<SubcomposeLayoutNode, _>(root, |node| {
85                            node.mark_needs_measure();
86                            node.mark_needs_layout_flag();
87                        });
88                    }
89                    Err(_) => {}
90                }
91            }
92            self.request_forced_layout_pass();
93        } else if invalidation_requested || has_scoped_repasses {
94            self.request_layout_pass();
95        }
96
97        if !self.layout_requested {
98            return;
99        }
100
101        let viewport_size = Size {
102            width: self.viewport.0,
103            height: self.viewport.1,
104        };
105        if let Some(root) = self.composition.root() {
106            let handle = self.composition.runtime_handle();
107            let mut applier = self.composition.applier_mut();
108            applier.set_runtime_handle(handle);
109
110            let tree_needs_layout_check = cranpose_ui::tree_needs_layout(&mut *applier, root)
111                .unwrap_or_else(|err| {
112                    log::warn!(
113                        "Cannot check layout dirty status for root #{}: {}",
114                        root,
115                        err
116                    );
117                    true // Assume dirty on error
118                });
119
120            let tree_needs_semantics_check = self.semantics_enabled
121                && cranpose_ui::tree_needs_semantics(&mut *applier, root).unwrap_or_else(|err| {
122                    log::warn!(
123                        "Cannot check semantics dirty status for root #{}: {}",
124                        root,
125                        err
126                    );
127                    true
128                });
129            let needs_layout = self.force_layout_pass
130                || has_scoped_repasses
131                || tree_needs_layout_check
132                || tree_needs_semantics_check;
133
134            if !needs_layout {
135                log::trace!("Skipping layout: tree is clean");
136                self.layout_requested = false;
137                self.force_layout_pass = false;
138                applier.clear_runtime_handle();
139                return;
140            }
141
142            self.layout_requested = false;
143            self.force_layout_pass = false;
144
145            // Ensure slots exist and borrow mutably (handled inside measure_layout via MemoryApplier)
146            match cranpose_ui::measure_layout_with_options(
147                &mut applier,
148                root,
149                viewport_size,
150                MeasureLayoutOptions {
151                    collect_semantics: self.semantics_enabled,
152                    build_layout_tree: true,
153                },
154            ) {
155                Ok(measurements) => {
156                    let semantics_tree = measurements.semantics_tree().cloned();
157                    self.layout_tree = Some(measurements.into_layout_tree());
158                    self.semantics_tree = semantics_tree;
159                    self.scene_dirty = true;
160                }
161                Err(err) => {
162                    log::error!("failed to compute layout: {err}");
163                    self.layout_tree = None;
164                    self.semantics_tree = None;
165                    self.scene_dirty = true;
166                }
167            }
168            applier.clear_runtime_handle();
169        } else {
170            self.layout_tree = None;
171            self.semantics_tree = None;
172            self.scene_dirty = true;
173            self.layout_requested = false;
174            self.force_layout_pass = false;
175        }
176    }
177
178    fn run_dispatch_queues(&mut self) {
179        // Process pointer input repasses
180        // Similar to Jetpack Compose's pointer input invalidation processing,
181        // we service nodes that need pointer input state updates without forcing layout/draw
182        if has_pending_pointer_repasses() {
183            let mut applier = self.composition.applier_mut();
184            process_pointer_repasses(|node_id| {
185                match clear_dispatch_invalidation(
186                    &mut applier,
187                    node_id,
188                    DispatchInvalidationKind::Pointer,
189                ) {
190                    Ok(true) => {
191                        log::trace!("Cleared pointer repass flag for node #{}", node_id);
192                    }
193                    Ok(false) => {}
194                    Err(err) => {
195                        log::debug!(
196                            "Could not process pointer repass for node #{}: {}",
197                            node_id,
198                            err
199                        );
200                    }
201                }
202            });
203        }
204
205        // Process focus invalidations
206        // Mirrors Jetpack Compose's FocusInvalidationManager.invalidateNodes(),
207        // processing nodes that need focus state synchronization
208        if has_pending_focus_invalidations() {
209            let mut applier = self.composition.applier_mut();
210            process_focus_invalidations(|node_id| {
211                match clear_dispatch_invalidation(
212                    &mut applier,
213                    node_id,
214                    DispatchInvalidationKind::Focus,
215                ) {
216                    Ok(true) => {
217                        log::trace!("Cleared focus sync flag for node #{}", node_id);
218                    }
219                    Ok(false) => {}
220                    Err(err) => {
221                        log::debug!(
222                            "Could not process focus invalidation for node #{}: {}",
223                            node_id,
224                            err
225                        );
226                    }
227                }
228            });
229        }
230    }
231
232    fn refresh_draw_repasses(&mut self) {
233        let dirty_nodes = take_draw_repass_nodes();
234        if dirty_nodes.is_empty() {
235            return;
236        }
237
238        let Some(layout_tree) = self.layout_tree.as_mut() else {
239            return;
240        };
241
242        let dirty_set: HashSet<NodeId> = dirty_nodes.into_iter().collect();
243        let mut applier = self.composition.applier_mut();
244        let refresh_scope = build_draw_refresh_scope(&mut applier, &dirty_set);
245        refresh_layout_box_data(
246            &mut applier,
247            layout_tree.root_mut(),
248            &refresh_scope,
249            &dirty_set,
250        );
251    }
252
253    pub(crate) fn run_render_phase(&mut self) {
254        let render_dirty = take_render_invalidation();
255        let pointer_dirty = take_pointer_invalidation();
256        take_focus_invalidation();
257        let draw_repass_pending = cranpose_ui::has_pending_draw_repasses();
258        // Tick cursor blink timer - only marks dirty when visibility state changes
259        let cursor_blink_dirty = cranpose_ui::tick_cursor_blink();
260
261        let render_only_dirty = render_dirty || cursor_blink_dirty;
262        // Pointer invalidations can replace hit-test handler closures inside modifier nodes.
263        // The scene caches those closures, so it must be rebuilt to avoid dispatching stale input.
264        let needs_scene_rebuild =
265            self.scene_dirty || draw_repass_pending || render_only_dirty || pointer_dirty;
266
267        if !needs_scene_rebuild {
268            return;
269        }
270        self.scene_dirty = false;
271        self.refresh_draw_repasses();
272        let viewport_size = Size {
273            width: self.viewport.0,
274            height: self.viewport.1,
275        };
276
277        // Use new direct traversal rendering
278        if let Some(root) = self.composition.root() {
279            let mut applier = self.composition.applier_mut();
280            if let Err(err) =
281                self.renderer
282                    .rebuild_scene_from_applier(&mut applier, root, viewport_size)
283            {
284                // Fallback to clearing scene on error
285                log::error!("renderer rebuild failed: {err:?}");
286                self.renderer.scene_mut().clear();
287            }
288        } else {
289            self.renderer.scene_mut().clear();
290        }
291
292        // Draw FPS overlay if enabled (directly by renderer, no composition)
293        if self.dev_options.fps_counter {
294            let stats = fps_monitor::fps_stats();
295            let text = format!(
296                "{:.0} FPS | {:.1}ms | {} recomp/s",
297                stats.fps, stats.avg_ms, stats.recomps_per_second
298            );
299            self.renderer.draw_dev_overlay(&text, viewport_size);
300        }
301    }
302}
303
304fn clear_dispatch_invalidation(
305    applier: &mut MemoryApplier,
306    node_id: NodeId,
307    invalidation: DispatchInvalidationKind,
308) -> Result<bool, NodeError> {
309    match invalidation {
310        DispatchInvalidationKind::Pointer => {
311            match applier.with_node::<LayoutNode, _>(node_id, |node| {
312                let needs_pointer_pass = node.needs_pointer_pass();
313                if needs_pointer_pass {
314                    node.clear_needs_pointer_pass();
315                }
316                needs_pointer_pass
317            }) {
318                Ok(cleared) => Ok(cleared),
319                Err(NodeError::TypeMismatch { .. }) => applier
320                    .with_node::<SubcomposeLayoutNode, _>(node_id, |node| {
321                        let needs_pointer_pass = node.needs_pointer_pass();
322                        if needs_pointer_pass {
323                            node.clear_needs_pointer_pass();
324                        }
325                        needs_pointer_pass
326                    }),
327                Err(err) => Err(err),
328            }
329        }
330        DispatchInvalidationKind::Focus => {
331            match applier.with_node::<LayoutNode, _>(node_id, |node| {
332                let needs_focus_sync = node.needs_focus_sync();
333                if needs_focus_sync {
334                    node.clear_needs_focus_sync();
335                }
336                needs_focus_sync
337            }) {
338                Ok(cleared) => Ok(cleared),
339                Err(NodeError::TypeMismatch { .. }) => applier
340                    .with_node::<SubcomposeLayoutNode, _>(node_id, |node| {
341                        let needs_focus_sync = node.needs_focus_sync();
342                        if needs_focus_sync {
343                            node.clear_needs_focus_sync();
344                        }
345                        needs_focus_sync
346                    }),
347                Err(err) => Err(err),
348            }
349        }
350    }
351}
352
353pub(crate) fn build_draw_refresh_scope(
354    applier: &mut MemoryApplier,
355    dirty_nodes: &HashSet<NodeId>,
356) -> HashSet<NodeId> {
357    let mut refresh_scope = HashSet::with_capacity(dirty_nodes.len());
358    for &dirty_node in dirty_nodes {
359        let mut current = Some(dirty_node);
360        while let Some(node_id) = current {
361            if !refresh_scope.insert(node_id) {
362                break;
363            }
364            current = applier.get_mut(node_id).ok().and_then(|node| node.parent());
365        }
366    }
367    refresh_scope
368}
369
370fn refresh_layout_box_data(
371    applier: &mut MemoryApplier,
372    layout: &mut cranpose_ui::layout::LayoutBox,
373    refresh_scope: &HashSet<NodeId>,
374    dirty_nodes: &HashSet<NodeId>,
375) {
376    if !refresh_scope.contains(&layout.node_id) {
377        return;
378    }
379
380    if dirty_nodes.contains(&layout.node_id) {
381        if let Ok((modifier, resolved_modifiers, slices)) =
382            applier.with_node::<LayoutNode, _>(layout.node_id, |node| {
383                node.clear_needs_redraw();
384                (
385                    node.modifier.clone(),
386                    node.resolved_modifiers(),
387                    node.modifier_slices_snapshot(),
388                )
389            })
390        {
391            layout.node_data.modifier = modifier;
392            layout.node_data.resolved_modifiers = resolved_modifiers;
393            layout.node_data.modifier_slices = slices;
394        } else if let Ok((modifier, resolved_modifiers)) = applier
395            .with_node::<SubcomposeLayoutNode, _>(layout.node_id, |node| {
396                node.clear_needs_redraw();
397                (node.modifier(), node.resolved_modifiers())
398            })
399        {
400            layout.node_data.modifier = modifier.clone();
401            layout.node_data.resolved_modifiers = resolved_modifiers;
402            layout.node_data.modifier_slices =
403                std::rc::Rc::new(cranpose_ui::collect_slices_from_modifier(&modifier));
404        }
405    }
406
407    for child in &mut layout.children {
408        refresh_layout_box_data(applier, child, refresh_scope, dirty_nodes);
409    }
410}