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