Skip to main content

cranpose_ui/widgets/
scaffold.rs

1//! Compose-like window scaffold with framework-owned system insets.
2
3#![allow(non_snake_case)]
4
5use super::SubcomposeLayout;
6use crate::layout_direction::{layout_direction, LayoutDirection};
7use crate::subcompose_layout::{SubcomposeLayoutScope, SubcomposeMeasureScope};
8use crate::{composable, Modifier};
9use cranpose_core::{NodeId, SlotId};
10use cranpose_ui_graphics::{Color, EdgeInsets};
11use cranpose_ui_layout::{Constraints, Placement};
12use std::rc::Rc;
13
14/// Insets supplied to a scaffold's content slot, in reading order.
15///
16/// `start` and `end` are the reading-order edges: in a left-to-right layout
17/// `start` is the left edge, in a right-to-left layout it is the right one.
18#[derive(Clone, Copy, Debug, Default, PartialEq)]
19pub struct PaddingValues {
20    pub start: f32,
21    pub top: f32,
22    pub end: f32,
23    pub bottom: f32,
24}
25
26impl PaddingValues {
27    pub const fn new(start: f32, top: f32, end: f32, bottom: f32) -> Self {
28        Self {
29            start,
30            top,
31            end,
32            bottom,
33        }
34    }
35
36    /// The same padding on every edge.
37    pub const fn all(value: f32) -> Self {
38        Self::new(value, value, value, value)
39    }
40
41    /// Applies these values in the composition's current reading order.
42    pub fn apply_to(self, modifier: Modifier) -> Modifier {
43        self.apply_to_in(modifier, layout_direction())
44    }
45
46    /// Applies these values against an explicit reading order.
47    pub fn apply_to_in(self, modifier: Modifier, direction: LayoutDirection) -> Modifier {
48        modifier.padding_relative_in(direction, self.start, self.top, self.end, self.bottom)
49    }
50
51    /// The physical left/top/right/bottom edges in `direction`.
52    pub fn physical(self, direction: LayoutDirection) -> EdgeInsets {
53        let (left, right) = direction.resolve(self.start, self.end);
54        EdgeInsets::from_components(left, self.top, right, self.bottom)
55    }
56
57    /// The larger of each edge, used to merge bar heights with window insets.
58    pub fn max(self, other: Self) -> Self {
59        Self::new(
60            self.start.max(other.start),
61            self.top.max(other.top),
62            self.end.max(other.end),
63            self.bottom.max(other.bottom),
64        )
65    }
66}
67
68/// The surfaces a scaffold paints behind its slots.
69///
70/// A scaffold that states no colours paints nothing and leaves every surface to
71/// its slots, which is what a full-bleed game or a custom-chrome window wants.
72#[derive(Clone, Copy, Debug, Default, PartialEq)]
73pub struct ScaffoldColors {
74    /// Painted across the whole scaffold, behind every slot.
75    pub background: Option<Color>,
76    /// Painted behind the top bar, including the status-bar area it draws under.
77    pub top_bar: Option<Color>,
78    /// Painted behind the bottom bar, including the navigation-bar area.
79    pub bottom_bar: Option<Color>,
80}
81
82impl ScaffoldColors {
83    /// A scaffold whose background is `background` and whose bars are painted
84    /// with the same colour.
85    pub fn uniform(background: Color) -> Self {
86        Self {
87            background: Some(background),
88            top_bar: Some(background),
89            bottom_bar: Some(background),
90        }
91    }
92
93    /// Sets the whole-scaffold background.
94    pub fn with_background(mut self, color: Color) -> Self {
95        self.background = Some(color);
96        self
97    }
98}
99
100/// Which window insets a scaffold consumes on the application's behalf.
101///
102/// The default consumes every side, which is what an ordinary screen wants. A
103/// screen that draws its own edge-to-edge content — a map, a photo viewer —
104/// turns off the sides it handles itself.
105#[derive(Clone, Copy, Debug, PartialEq, Eq)]
106pub struct ScaffoldContentInsets {
107    /// Consume the top window inset (status bar, notch).
108    pub top: bool,
109    /// Consume the bottom window inset (navigation bar, home indicator, IME).
110    pub bottom: bool,
111    /// Consume the reading-order start inset (a cutout, a curved edge).
112    pub start: bool,
113    /// Consume the reading-order end inset.
114    pub end: bool,
115}
116
117impl Default for ScaffoldContentInsets {
118    fn default() -> Self {
119        Self {
120            top: true,
121            bottom: true,
122            start: true,
123            end: true,
124        }
125    }
126}
127
128impl ScaffoldContentInsets {
129    /// Consume nothing: every slot sees the raw window bounds.
130    pub const fn none() -> Self {
131        Self {
132            top: false,
133            bottom: false,
134            start: false,
135            end: false,
136        }
137    }
138
139    /// Keeps only the sides this asked for.
140    fn filter(self, insets: PaddingValues) -> PaddingValues {
141        PaddingValues::new(
142            if self.start { insets.start } else { 0.0 },
143            if self.top { insets.top } else { 0.0 },
144            if self.end { insets.end } else { 0.0 },
145            if self.bottom { insets.bottom } else { 0.0 },
146        )
147    }
148}
149
150/// Everything a scaffold shows besides its content.
151#[derive(Clone)]
152struct ScaffoldSlots {
153    top_bar: Rc<dyn Fn()>,
154    bottom_bar: Rc<dyn Fn()>,
155    floating_action: Rc<dyn Fn()>,
156    content: Rc<dyn Fn(PaddingValues)>,
157}
158
159impl PartialEq for ScaffoldSlots {
160    fn eq(&self, other: &Self) -> bool {
161        Rc::ptr_eq(&self.top_bar, &other.top_bar)
162            && Rc::ptr_eq(&self.bottom_bar, &other.bottom_bar)
163            && Rc::ptr_eq(&self.floating_action, &other.floating_action)
164            && Rc::ptr_eq(&self.content, &other.content)
165    }
166}
167
168/// How a scaffold is configured beyond its slots.
169#[derive(Clone, Copy, Debug, Default, PartialEq)]
170pub struct ScaffoldSpec {
171    /// Surfaces painted behind the slots.
172    pub colors: ScaffoldColors,
173    /// Which window insets the scaffold consumes.
174    pub content_insets: ScaffoldContentInsets,
175}
176
177impl ScaffoldSpec {
178    /// Sets the surfaces.
179    pub fn with_colors(mut self, colors: ScaffoldColors) -> Self {
180        self.colors = colors;
181        self
182    }
183}
184
185/// Places optional window bars and a floating action over a full-size content
186/// slot, and reports the space occupied by the bars and by platform
187/// obstructions as reading-order inner padding.
188///
189/// Bars receive the full window bounds so they can draw behind system areas; a
190/// bar that places controls there uses [`crate::window_insets`].
191pub fn Scaffold<T, B, C>(modifier: Modifier, top_bar: T, bottom_bar: B, content: C) -> NodeId
192where
193    T: Fn() + 'static,
194    B: Fn() + 'static,
195    C: Fn(PaddingValues) + 'static,
196{
197    ScaffoldWith(
198        modifier,
199        ScaffoldSpec::default(),
200        top_bar,
201        bottom_bar,
202        || {},
203        content,
204    )
205}
206
207/// A scaffold with surfaces, inset policy and a floating-action slot.
208pub fn ScaffoldWith<T, B, F, C>(
209    modifier: Modifier,
210    spec: ScaffoldSpec,
211    top_bar: T,
212    bottom_bar: B,
213    floating_action: F,
214    content: C,
215) -> NodeId
216where
217    T: Fn() + 'static,
218    B: Fn() + 'static,
219    F: Fn() + 'static,
220    C: Fn(PaddingValues) + 'static,
221{
222    ScaffoldImpl(
223        modifier,
224        spec,
225        ScaffoldSlots {
226            top_bar: Rc::new(top_bar),
227            bottom_bar: Rc::new(bottom_bar),
228            floating_action: Rc::new(floating_action),
229            content: Rc::new(content),
230        },
231    )
232}
233
234/// The gap a floating action keeps from the window edges.
235const FLOATING_ACTION_MARGIN: f32 = 16.0;
236
237#[composable]
238fn ScaffoldImpl(modifier: Modifier, spec: ScaffoldSpec, slots: ScaffoldSlots) -> NodeId {
239    let direction = layout_direction();
240    let insets = crate::safe_area::window_insets().combined();
241    let (start_inset, end_inset) = match direction {
242        LayoutDirection::Ltr => (insets.left, insets.right),
243        LayoutDirection::Rtl => (insets.right, insets.left),
244    };
245    let window_padding = spec.content_insets.filter(PaddingValues::new(
246        start_inset,
247        insets.top,
248        end_inset,
249        insets.bottom,
250    ));
251
252    let colors = spec.colors;
253    let modifier = match colors.background {
254        Some(background) => modifier.background(background),
255        None => modifier,
256    };
257
258    let top_bar = slots.top_bar;
259    let bottom_bar = slots.bottom_bar;
260    let floating_action = slots.floating_action;
261    let content = slots.content;
262
263    SubcomposeLayout(modifier, move |scope, constraints| {
264        let width = constraints.max_width.max(constraints.min_width);
265        let height = constraints.max_height.max(constraints.min_height);
266        let loose = Constraints::loose(width, height);
267
268        let top_content = Rc::clone(&top_bar);
269        let top_nodes = scope.subcompose(SlotId::new(0), move || top_content());
270        let mut top_height = 0.0_f32;
271        let mut top_placements = Vec::with_capacity(top_nodes.len());
272        for node in top_nodes {
273            let placeable = scope.measure(node, loose);
274            top_height = top_height.max(placeable.height());
275            top_placements.push(Placement::new(placeable.node_id(), 0.0, 0.0, 1));
276        }
277
278        let bottom_content = Rc::clone(&bottom_bar);
279        let bottom_nodes = scope.subcompose(SlotId::new(1), move || bottom_content());
280        let mut bottom_height = 0.0_f32;
281        let mut bottom_placeables = Vec::with_capacity(bottom_nodes.len());
282        for node in bottom_nodes {
283            let placeable = scope.measure(node, loose);
284            bottom_height = bottom_height.max(placeable.height());
285            bottom_placeables.push(placeable);
286        }
287
288        // A bar already covers the window inset behind it, so the content is
289        // pushed past whichever is larger rather than past both.
290        let padding = window_padding.max(PaddingValues::new(0.0, top_height, 0.0, bottom_height));
291        let content_slot = Rc::clone(&content);
292        let content_nodes = scope.subcompose(SlotId::new(2), move || content_slot(padding));
293
294        let mut placements = Vec::with_capacity(
295            top_placements.len() + bottom_placeables.len() + content_nodes.len() + 1,
296        );
297        for node in content_nodes {
298            let placeable = scope.measure(node, Constraints::tight(width, height));
299            placements.push(Placement::new(placeable.node_id(), 0.0, 0.0, 0));
300        }
301        placements.extend(top_placements);
302        for placeable in bottom_placeables {
303            placements.push(Placement::new(
304                placeable.node_id(),
305                0.0,
306                (height - placeable.height()).max(0.0),
307                1,
308            ));
309        }
310
311        // The floating action sits above everything, inside the bottom bar and
312        // the window insets, on the reading-order end side.
313        let floating_content = Rc::clone(&floating_action);
314        let floating_nodes = scope.subcompose(SlotId::new(3), move || floating_content());
315        for node in floating_nodes {
316            let placeable = scope.measure(node, loose);
317            let bottom_gap = padding.bottom + FLOATING_ACTION_MARGIN;
318            let end_gap = padding.end + FLOATING_ACTION_MARGIN;
319            let x = match direction {
320                LayoutDirection::Ltr => (width - placeable.width() - end_gap).max(0.0),
321                LayoutDirection::Rtl => end_gap.min((width - placeable.width()).max(0.0)),
322            };
323            let y = (height - placeable.height() - bottom_gap).max(0.0);
324            placements.push(Placement::new(placeable.node_id(), x, y, 2));
325        }
326
327        scope.layout(width, height, placements)
328    })
329}
330
331#[cfg(test)]
332mod tests {
333    use super::*;
334
335    #[test]
336    fn padding_values_preserve_each_edge() {
337        assert_eq!(
338            PaddingValues::new(1.0, 2.0, 3.0, 4.0),
339            PaddingValues {
340                start: 1.0,
341                top: 2.0,
342                end: 3.0,
343                bottom: 4.0,
344            }
345        );
346        assert_eq!(
347            PaddingValues::all(6.0),
348            PaddingValues::new(6.0, 6.0, 6.0, 6.0)
349        );
350    }
351
352    #[test]
353    fn reading_order_padding_swaps_sides_in_a_right_to_left_layout() {
354        let padding = PaddingValues::new(24.0, 2.0, 8.0, 4.0);
355        assert_eq!(
356            padding.physical(LayoutDirection::Ltr),
357            EdgeInsets::from_components(24.0, 2.0, 8.0, 4.0)
358        );
359        assert_eq!(
360            padding.physical(LayoutDirection::Rtl),
361            EdgeInsets::from_components(8.0, 2.0, 24.0, 4.0)
362        );
363    }
364
365    #[test]
366    fn merging_takes_the_larger_of_each_edge() {
367        let bars = PaddingValues::new(0.0, 56.0, 0.0, 0.0);
368        let insets = PaddingValues::new(0.0, 24.0, 0.0, 48.0);
369        assert_eq!(insets.max(bars), PaddingValues::new(0.0, 56.0, 0.0, 48.0));
370    }
371
372    #[test]
373    fn a_screen_can_keep_the_insets_it_draws_under() {
374        let insets = PaddingValues::new(4.0, 24.0, 4.0, 48.0);
375        let consumed = ScaffoldContentInsets {
376            top: false,
377            ..ScaffoldContentInsets::default()
378        }
379        .filter(insets);
380        assert_eq!(consumed, PaddingValues::new(4.0, 0.0, 4.0, 48.0));
381        assert_eq!(
382            ScaffoldContentInsets::none().filter(insets),
383            PaddingValues::default()
384        );
385    }
386
387    #[test]
388    fn a_scaffold_states_no_surfaces_by_default() {
389        assert_eq!(ScaffoldSpec::default().colors, ScaffoldColors::default());
390        let colors = ScaffoldColors::uniform(Color(0.1, 0.1, 0.1, 1.0));
391        assert_eq!(colors.background, colors.top_bar);
392        assert_eq!(colors.background, colors.bottom_bar);
393    }
394}