Skip to main content

frust_core/app/
layout.rs

1//! `RenderRoot`'s layout pass: lays the root out against the window and runs
2//! the optional layout audit.
3
4use std::any::Any;
5use std::collections::HashMap;
6
7use kurbo::{Point, Size};
8
9use super::RenderRoot;
10use crate::layout::BoxConstraints;
11use crate::view::{View, WidgetId};
12use crate::widget::LayoutCtx;
13
14/// Layout-audit bookkeeping ([`RenderRoot::set_layout_audit`]): an audited
15/// layout runs the skipping pass, then a forced full pass over the same inputs;
16/// each pod whose geometry differs bumps `mismatches` and logs a `layout-audit:`
17/// warning (once per 60 audited frames per widget type and label).
18#[derive(Default)]
19pub(super) struct LayoutAudit {
20    pub(super) enabled: bool,
21    pub(super) mismatches: u64,
22    /// Audited frames so far.
23    frame: u64,
24    /// Audited frame each `(type, label)` was last logged on.
25    last_logged: HashMap<(&'static str, Option<String>), u64>,
26}
27
28/// One node's geometry in a layout-audit snapshot.
29struct AuditGeom {
30    id: WidgetId,
31    type_name: &'static str,
32    label: Option<String>,
33    origin: Point,
34    size: Size,
35}
36
37impl<State: 'static, V: View<State>> RenderRoot<State, V> {
38    /// Lay out the root widget against `window_size` and record its geometry.
39    ///
40    /// The root gets loose constraints and sits at the origin; returns its size.
41    /// No text context is threaded (use [`RenderRoot::layout_with_text`]).
42    pub fn layout(&mut self, window_size: Size) -> Size {
43        self.layout_inner(window_size, None)
44    }
45
46    /// Lay out the root widget, threading a shared text-shaping context down to
47    /// text widgets.
48    ///
49    /// `text_ctx` is the shell-owned `frust_text::TextContext`, type-erased so
50    /// this crate needs no `frust-text` dependency; widgets recover it via
51    /// [`crate::widget::LayoutCtx::text_context`].
52    pub fn layout_with_text(&mut self, window_size: Size, text_ctx: &mut dyn Any) -> Size {
53        self.layout_inner(window_size, Some(text_ctx))
54    }
55
56    /// Shared body of [`RenderRoot::layout`] and [`RenderRoot::layout_with_text`].
57    fn layout_inner(&mut self, window_size: Size, text_ctx: Option<&mut dyn Any>) -> Size {
58        // The window size is a global layout input (overlay pods lay out
59        // against it), so a change invalidates every cached layout.
60        if self.window_size != window_size {
61            self.bump_layout_epoch();
62        }
63        self.window_size = window_size;
64        // A floated surface dirtied outside its owner's pod chain (see
65        // `overlay_pods`) is reachable only through a full pass.
66        if self.overlay_pod_dirty() {
67            self.bump_layout_epoch();
68        }
69        if self.root_id.is_none() {
70            return Size::ZERO;
71        }
72        if !(self.layout_audit.enabled && self.layout_skip) {
73            return self.layout_pass(window_size, text_ctx, !self.layout_skip);
74        }
75        // Audit: the forced pass runs last, so its geometry is what stays.
76        let mut text_ctx = text_ctx;
77        self.layout_pass(window_size, text_ctx.as_deref_mut(), false);
78        let before = self.audit_snapshot();
79        let size = self.layout_pass(window_size, text_ctx, true);
80        let after = self.audit_snapshot();
81        self.audit_compare(&before, &after);
82        size
83    }
84
85    /// Every node's `(id, type, label, origin relative to parent, size)`.
86    fn audit_snapshot(&self) -> Vec<AuditGeom> {
87        let nodes = self.tree.inspect();
88        let origins: HashMap<WidgetId, Point> =
89            nodes.iter().map(|n| (n.id, n.bounds.origin())).collect();
90        nodes
91            .into_iter()
92            .map(|n| {
93                let parent = n
94                    .parent
95                    .and_then(|p| origins.get(&p).copied())
96                    .unwrap_or(Point::ZERO);
97                AuditGeom {
98                    id: n.id,
99                    type_name: n.type_name,
100                    label: n.debug_label.map(|l| l.into_owned()),
101                    origin: Point::new(n.bounds.x0 - parent.x, n.bounds.y0 - parent.y),
102                    size: n.bounds.size(),
103                }
104            })
105            .collect()
106    }
107
108    /// Report every pod that differs between the two snapshots.
109    fn audit_compare(&mut self, before: &[AuditGeom], after: &[AuditGeom]) {
110        const TOLERANCE: f64 = 1e-3;
111        let frame = self.layout_audit.frame;
112        self.layout_audit.frame += 1;
113        let by_id: HashMap<WidgetId, &AuditGeom> = before.iter().map(|g| (g.id, g)).collect();
114        for now in after {
115            let Some(was) = by_id.get(&now.id) else {
116                continue;
117            };
118            let differs = (was.origin.x - now.origin.x).abs() > TOLERANCE
119                || (was.origin.y - now.origin.y).abs() > TOLERANCE
120                || (was.size.width - now.size.width).abs() > TOLERANCE
121                || (was.size.height - now.size.height).abs() > TOLERANCE;
122            if !differs {
123                continue;
124            }
125            self.layout_audit.mismatches += 1;
126            let key = (now.type_name, now.label.clone());
127            let due = self
128                .layout_audit
129                .last_logged
130                .get(&key)
131                .is_none_or(|last| frame.saturating_sub(*last) >= 60);
132            if due {
133                self.layout_audit.last_logged.insert(key, frame);
134                log::warn!(
135                    "layout-audit: {} (label {:?}) changed geometry without signalling layout: skipped pass origin {:?} size {:?}, forced pass origin {:?} size {:?}",
136                    now.type_name,
137                    now.label,
138                    was.origin,
139                    was.size,
140                    now.origin,
141                    now.size,
142                );
143            }
144        }
145    }
146
147    /// One layout pass over the root; `force` lays out every pod regardless of
148    /// the skip rule.
149    fn layout_pass(
150        &mut self,
151        window_size: Size,
152        text_ctx: Option<&mut dyn Any>,
153        force: bool,
154    ) -> Size {
155        let Some(root_id) = self.root_id else {
156            return Size::ZERO;
157        };
158        let bc = BoxConstraints::loose(window_size);
159        let theme = self.theme.as_deref();
160        let insets = self.insets;
161        let layout_epoch = self.layout_epoch;
162        let Some(pod) = self.tree.pod_mut(root_id) else {
163            return Size::ZERO;
164        };
165        let mut ctx = LayoutCtx::with_resources(text_ctx, theme);
166        // One context reaches the whole tree, so the global inputs (insets,
167        // window size, layout epoch, force switch) are set once here. The root
168        // sits in no `ChildPod`, so it is always laid out.
169        ctx.set_window_insets(insets);
170        ctx.set_window_size(window_size);
171        ctx.set_layout_epoch(layout_epoch);
172        ctx.set_layout_force(force);
173        let size = pod.widget_mut().layout(&mut ctx, &bc);
174        pod.set_layout(Point::ZERO, size);
175        size
176    }
177}