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) -> Vec<NodeId> {
227        let dirty_nodes = take_draw_repass_nodes();
228        if dirty_nodes.is_empty() {
229            return dirty_nodes;
230        }
231
232        let Some(layout_tree) = self.layout_tree.as_mut() else {
233            return dirty_nodes;
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        dirty_set.into_iter().collect()
246    }
247
248    pub(crate) fn run_render_phase(&mut self) {
249        let render_dirty = take_render_invalidation();
250        let pointer_dirty = take_pointer_invalidation();
251        take_focus_invalidation();
252        let draw_repass_pending = cranpose_ui::has_pending_draw_repasses();
253        // Tick cursor blink timer - only marks dirty when visibility state changes
254        let cursor_blink_dirty = cranpose_ui::tick_cursor_blink();
255
256        let render_only_dirty = (render_dirty && !draw_repass_pending) || cursor_blink_dirty;
257        // Pointer invalidations can replace hit-test handler closures inside modifier nodes.
258        // The scene caches those closures, so it must be rebuilt to avoid dispatching stale input.
259        let scene_dirty = self.scene_dirty;
260        let needs_scene_rebuild =
261            scene_dirty || draw_repass_pending || render_only_dirty || pointer_dirty;
262
263        if !needs_scene_rebuild {
264            return;
265        }
266        self.scene_dirty = false;
267        let draw_dirty_nodes = self.refresh_draw_repasses();
268        let viewport_size = Size {
269            width: self.viewport.0,
270            height: self.viewport.1,
271        };
272
273        // Use new direct traversal rendering
274        if let Some(root) = self.composition.root() {
275            let mut applier = self.composition.applier_mut();
276            let rebuild_result = if !draw_dirty_nodes.is_empty()
277                && !render_only_dirty
278                && !pointer_dirty
279                && !scene_dirty
280            {
281                self.renderer.update_scene_from_applier(
282                    &mut applier,
283                    root,
284                    viewport_size,
285                    &draw_dirty_nodes,
286                )
287            } else {
288                self.renderer
289                    .rebuild_scene_from_applier(&mut applier, root, viewport_size)
290            };
291            if let Err(err) = rebuild_result {
292                // Fallback to clearing scene on error
293                log::error!("renderer rebuild failed: {err:?}");
294                self.renderer.scene_mut().clear();
295            }
296        } else {
297            self.renderer.scene_mut().clear();
298        }
299
300        // Draw FPS overlay if enabled (directly by renderer, no composition)
301        if self.dev_options.fps_counter {
302            let text = self.build_dev_overlay_text(viewport_size);
303            self.renderer.draw_dev_overlay(&text, viewport_size);
304        }
305    }
306
307    fn build_dev_overlay_text(&mut self, viewport_size: Size) -> String {
308        self.dev_overlay_controls.clear();
309
310        let stats = fps_monitor::fps_stats();
311        let mut text = format!(
312            "{:.0} FPS | {:.1}ms | {} recomp/s",
313            stats.fps, stats.avg_ms, stats.recomps_per_second
314        );
315
316        if !self.dev_options.frame_pacing_controls {
317            return text;
318        }
319
320        text.push_str(" | ");
321        let mut controls = Vec::with_capacity(FramePacingMode::ALL.len());
322        for (index, mode) in FramePacingMode::ALL.into_iter().enumerate() {
323            if index > 0 {
324                text.push(' ');
325            }
326            let start = text.len();
327            if mode == self.dev_options.frame_pacing_mode {
328                text.push('[');
329                text.push_str(mode.label());
330                text.push(']');
331            } else {
332                text.push_str(mode.label());
333            }
334            controls.push((start, text.len(), mode));
335        }
336
337        let overlay_width = text.len() as f32 * DEV_OVERLAY_CHAR_WIDTH;
338        let overlay_x = (viewport_size.width - overlay_width - DEV_OVERLAY_PADDING * 2.0)
339            .max(DEV_OVERLAY_PADDING);
340        let overlay_y = DEV_OVERLAY_PADDING;
341        let text_x = overlay_x + DEV_OVERLAY_PADDING / 2.0;
342        let text_y = overlay_y + DEV_OVERLAY_PADDING / 4.0;
343        let text_height = DEV_OVERLAY_FONT_SIZE * 1.4;
344
345        self.dev_overlay_controls = controls
346            .into_iter()
347            .map(|(start, end, mode)| DevOverlayControl {
348                bounds: Rect {
349                    x: text_x + start as f32 * DEV_OVERLAY_CHAR_WIDTH - 3.0,
350                    y: text_y - 3.0,
351                    width: (end - start) as f32 * DEV_OVERLAY_CHAR_WIDTH + 6.0,
352                    height: text_height + 6.0,
353                },
354                mode,
355            })
356            .collect();
357
358        text
359    }
360}
361
362fn clear_dispatch_invalidation(
363    applier: &mut MemoryApplier,
364    node_id: NodeId,
365    invalidation: DispatchInvalidationKind,
366) -> Result<bool, NodeError> {
367    match invalidation {
368        DispatchInvalidationKind::Pointer => {
369            match applier.with_node::<LayoutNode, _>(node_id, |node| {
370                let needs_pointer_pass = node.needs_pointer_pass();
371                if needs_pointer_pass {
372                    node.clear_needs_pointer_pass();
373                }
374                needs_pointer_pass
375            }) {
376                Ok(cleared) => Ok(cleared),
377                Err(NodeError::TypeMismatch { .. }) => applier
378                    .with_node::<SubcomposeLayoutNode, _>(node_id, |node| {
379                        let needs_pointer_pass = node.needs_pointer_pass();
380                        if needs_pointer_pass {
381                            node.clear_needs_pointer_pass();
382                        }
383                        needs_pointer_pass
384                    }),
385                Err(err) => Err(err),
386            }
387        }
388        DispatchInvalidationKind::Focus => {
389            match applier.with_node::<LayoutNode, _>(node_id, |node| {
390                let needs_focus_sync = node.needs_focus_sync();
391                if needs_focus_sync {
392                    node.clear_needs_focus_sync();
393                }
394                needs_focus_sync
395            }) {
396                Ok(cleared) => Ok(cleared),
397                Err(NodeError::TypeMismatch { .. }) => applier
398                    .with_node::<SubcomposeLayoutNode, _>(node_id, |node| {
399                        let needs_focus_sync = node.needs_focus_sync();
400                        if needs_focus_sync {
401                            node.clear_needs_focus_sync();
402                        }
403                        needs_focus_sync
404                    }),
405                Err(err) => Err(err),
406            }
407        }
408    }
409}
410
411pub(crate) fn build_draw_refresh_scope(
412    applier: &mut MemoryApplier,
413    dirty_nodes: &HashSet<NodeId>,
414) -> HashSet<NodeId> {
415    let mut refresh_scope = HashSet::with_capacity(dirty_nodes.len());
416    for &dirty_node in dirty_nodes {
417        let mut current = Some(dirty_node);
418        while let Some(node_id) = current {
419            if !refresh_scope.insert(node_id) {
420                break;
421            }
422            current = applier.get_mut(node_id).ok().and_then(|node| node.parent());
423        }
424    }
425    refresh_scope
426}
427
428fn refresh_layout_box_data(
429    applier: &mut MemoryApplier,
430    layout: &mut cranpose_ui::layout::LayoutBox,
431    refresh_scope: &HashSet<NodeId>,
432    dirty_nodes: &HashSet<NodeId>,
433) {
434    if !refresh_scope.contains(&layout.node_id) {
435        return;
436    }
437
438    if dirty_nodes.contains(&layout.node_id) {
439        if let Ok((modifier, resolved_modifiers, slices)) =
440            applier.with_node::<LayoutNode, _>(layout.node_id, |node| {
441                node.clear_needs_redraw();
442                (
443                    node.modifier.clone(),
444                    node.resolved_modifiers(),
445                    node.modifier_slices_snapshot(),
446                )
447            })
448        {
449            layout.node_data.modifier = modifier;
450            layout.node_data.resolved_modifiers = resolved_modifiers;
451            layout.node_data.modifier_slices = slices;
452        } else if let Ok((modifier, resolved_modifiers)) = applier
453            .with_node::<SubcomposeLayoutNode, _>(layout.node_id, |node| {
454                node.clear_needs_redraw();
455                (node.modifier(), node.resolved_modifiers())
456            })
457        {
458            layout.node_data.modifier = modifier.clone();
459            layout.node_data.resolved_modifiers = resolved_modifiers;
460            layout.node_data.modifier_slices =
461                std::rc::Rc::new(cranpose_ui::collect_slices_from_modifier(&modifier));
462        }
463    }
464
465    for child in &mut layout.children {
466        refresh_layout_box_data(applier, child, refresh_scope, dirty_nodes);
467    }
468}