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