Skip to main content

rosace_widgets/tree/
sheet.rs

1use rosace_core::types::{Point, Rect, Size};
2use rosace_layout::Constraints;
3use rosace_render::Color;
4use rosace_scroll::ScrollController;
5use rosace_shader::ShaderMaterial;
6use super::{Widget, LayoutCtx, PaintCtx, BoxedWidget};
7use super::container::draw_rounded_rect_pub;
8use super::material::{resolve_material, SheetMaterial};
9use super::padding::EdgeInsets;
10use super::scroll_view::ScrollView;
11use super::spacer::Spacer;
12
13/// How a [`Sheet`] resolves its height (D115/Phase 32 Step 1).
14#[derive(Clone, Copy, Debug, PartialEq)]
15enum SheetHeight {
16    /// Natural content height (the default, the original behavior),
17    /// capped at the available height.
18    Content,
19    /// A fixed height in logical pixels, capped at the available height.
20    Fixed(f32),
21    /// A fraction (0..=1) of the available height — a detent.
22    Detent(f32),
23    /// The full available height — the full-screen presentation.
24    Full,
25}
26
27/// A bottom sheet surface: full-width panel with rounded top corners and a
28/// grab handle. Pair with [`OverlayApi::sheet`], which anchors it to the
29/// bottom edge and supplies the scrim + tap-to-dismiss.
30///
31/// Height (D115/Phase 32 Step 1): natural content height by default;
32/// [`Sheet::height`] fixes it, [`Sheet::detent`] takes a fraction of the
33/// window, [`Sheet::full_screen`] takes all of it. [`Sheet::scrollable`]
34/// wraps the content in a [`ScrollView`] so it scrolls when it overflows
35/// the sheet. [`Sheet::background`] / [`Sheet::handle_color`] replace the
36/// theme-derived defaults.
37///
38/// [`OverlayApi::sheet`]: super::overlay_api::OverlayApi::sheet
39pub struct Sheet {
40    pub child: BoxedWidget,
41    pub radius: f32,
42    pub padding: EdgeInsets,
43    pub show_handle: bool,
44    height_mode: SheetHeight,
45    background: Option<Color>,
46    handle_color: Option<Color>,
47    material: Option<ShaderMaterial>,
48    scrollable: bool,
49}
50
51impl Sheet {
52    pub fn new(child: impl Widget + 'static) -> Self {
53        Self {
54            child: Box::new(child),
55            radius: 16.0,
56            padding: EdgeInsets::all(20.0),
57            show_handle: true,
58            height_mode: SheetHeight::Content,
59            background: None,
60            handle_color: None,
61            material: None,
62            scrollable: false,
63        }
64    }
65
66    pub fn radius(mut self, r: f32) -> Self { self.radius = r; self }
67    pub fn padding(mut self, p: EdgeInsets) -> Self { self.padding = p; self }
68    pub fn no_handle(mut self) -> Self { self.show_handle = false; self }
69
70    /// A fixed sheet height in logical pixels (capped at the window height).
71    pub fn height(mut self, h: f32) -> Self { self.height_mode = SheetHeight::Fixed(h); self }
72
73    /// A detent: the sheet takes this fraction (0..=1) of the available
74    /// height — `.detent(0.5)` is the half-open sheet.
75    pub fn detent(mut self, fraction: f32) -> Self {
76        self.height_mode = SheetHeight::Detent(fraction);
77        self
78    }
79
80    /// Take the full available height — the full-screen presentation
81    /// (still bottom-anchored, still rounded at the top).
82    pub fn full_screen(mut self) -> Self { self.height_mode = SheetHeight::Full; self }
83
84    /// Sheet fill — defaults to the theme's `surface`.
85    pub fn background(mut self, c: Color) -> Self { self.background = Some(c); self }
86
87    /// Grab-handle color — defaults to the theme's `outline`.
88    pub fn handle_color(mut self, c: Color) -> Self { self.handle_color = Some(c); self }
89    /// Per-instance shader material — replaces the surface fill when
90    /// resolved. Beats the theme's `SheetMaterial` default (D124 Step 5).
91    pub fn material(mut self, m: ShaderMaterial) -> Self { self.material = Some(m); self }
92
93    /// Wrap the content in a [`ScrollView`] so it scrolls when it overflows
94    /// the sheet's height. Without an explicit [`Sheet::height`] /
95    /// [`Sheet::detent`], a scrollable sheet has no natural content height
96    /// and takes the full available height.
97    ///
98    /// The scroll position lives in a controller created here — it persists
99    /// as long as this `Sheet` instance does (an overlay entry's widget
100    /// survives until its owner repaints). For a position that survives
101    /// owner rebuilds too, keep a [`ScrollController`] in app state and pass
102    /// it to [`Sheet::scrollable_with`].
103    pub fn scrollable(self) -> Self {
104        self.scrollable_with(ScrollController::new())
105    }
106
107    /// [`Sheet::scrollable`] with an app-owned [`ScrollController`] — the
108    /// scroll position (and programmatic scrolling) survives rebuilds.
109    /// The explicit controller also keeps the scroll view on the base
110    /// (CPU-painted) path, which is what overlay content requires.
111    pub fn scrollable_with(mut self, controller: ScrollController) -> Self {
112        let child = std::mem::replace(&mut self.child, Box::new(Spacer::new(0.0)));
113        self.child = Box::new(ScrollView::new(child).controller(controller));
114        self.scrollable = true;
115        self
116    }
117
118    fn handle_space(&self) -> f32 {
119        if self.show_handle { 16.0 } else { 0.0 }
120    }
121}
122
123impl Widget for Sheet {
124    fn layout(&self, ctx: &LayoutCtx) -> Size {
125        let width = ctx.constraints.max_width_f32();
126        let height = match self.height_mode {
127            SheetHeight::Content => {
128                // Natural content height. A scrollable child is a
129                // ScrollView, which fills whatever it is given — an
130                // unbounded measure comes back infinite and the constrain
131                // below caps it at the available height (the documented
132                // "scrollable with no explicit height = full height").
133                let inner_c = Constraints::loose(
134                    (width - self.padding.total_h()).max(0.0),
135                    f32::INFINITY,
136                );
137                let child_size = self.child.layout(&ctx.with_constraints(inner_c));
138                child_size.height + self.padding.total_v() + self.handle_space()
139            }
140            SheetHeight::Fixed(h) => h,
141            SheetHeight::Detent(f) => {
142                ctx.constraints.max_height_f32() * f.clamp(0.0, 1.0)
143            }
144            SheetHeight::Full => ctx.constraints.max_height_f32(),
145        };
146        ctx.constraints.constrain(Size { width, height })
147    }
148
149    fn paint(&self, ctx: &mut PaintCtx) {
150        // No title field to label itself with (unlike Dialog) — still worth
151        // marking as a modal region boundary, unlabeled, so assistive tech
152        // knows it's entered one.
153        ctx.semantics(super::Semantics::new(rosace_core::Role::Dialog));
154        // Hoisted theme reads (borrow must end before mutable painting).
155        let (surface, handle_color) = {
156            let t = &ctx.theme.colors;
157            (
158                self.background.unwrap_or_else(|| ctx.tc(t.surface)),
159                self.handle_color.unwrap_or_else(|| ctx.tc(t.outline)),
160            )
161        };
162        let r = ctx.rect;
163
164        // Rounded surface, then square off the bottom corners — the sheet
165        // sits flush against the window's bottom edge. With a material,
166        // only paint a fallback it EXPLICITLY carries — an unconditional
167        // base fill would be what a backdrop-sampling glass material sees
168        // behind itself, instead of the real content (same rule as
169        // Container/Card).
170        let material = resolve_material::<SheetMaterial>(&ctx.theme, self.material.as_ref());
171        let fill = match &material {
172            Some(m) => m.fallback,
173            None => Some(surface),
174        };
175        if let Some(fill) = fill {
176            draw_rounded_rect_pub(ctx, r, fill, self.radius);
177            ctx.fill_rect(Rect {
178                origin: Point { x: r.origin.x, y: r.origin.y + r.size.height - self.radius },
179                size: Size { width: r.size.width, height: self.radius },
180            }, fill);
181        }
182        if let Some(m) = &material {
183            ctx.shader_fill(r, m.pipeline, m.uniforms.clone());
184        }
185
186        if self.show_handle {
187            let handle_w = 36.0;
188            ctx.fill_rrect(Rect {
189                origin: Point {
190                    x: r.origin.x + (r.size.width - handle_w) / 2.0,
191                    y: r.origin.y + 6.0,
192                },
193                size: Size { width: handle_w, height: 4.0 },
194            }, 2.0, handle_color);
195        }
196
197        let content = Rect {
198            origin: Point { x: r.origin.x, y: r.origin.y + self.handle_space() },
199            size: Size { width: r.size.width, height: r.size.height - self.handle_space() },
200        };
201        self.child.paint(&mut ctx.child(self.padding.shrink(content)));
202    }
203}
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208    use super::super::spacer::Spacer;
209    use rosace_layout::Constraints;
210
211    #[test]
212    fn instance_material_paints_a_shader_fill() {
213        let font = rosace_render::FontCache::embedded();
214        let theme = rosace_theme::built_in::dark_theme();
215        let mut recorder = rosace_render::PictureRecorder::new();
216        let tree = std::rc::Rc::new(std::cell::RefCell::new(super::super::render_tree::RenderTree::new()));
217        let rect = Rect { origin: Point { x: 0.0, y: 0.0 }, size: Size { width: 400.0, height: 300.0 } };
218        let mut ctx = PaintCtx::root(&mut recorder, rect, &font, theme, tree);
219        let m = ShaderMaterial::new(rosace_shader::PipelineId::user(0x4001), vec![0u8; 16]);
220        Sheet::new(Spacer::new(0.0)).material(m).paint(&mut ctx);
221        let picture = recorder.finish();
222        assert!(picture.commands.iter().any(|c| matches!(c, rosace_render::DrawCommand::ShaderFill { .. })));
223    }
224
225    fn ctx_800x600<'a>(
226        font: &'a rosace_render::FontCache,
227        theme: &'a rosace_theme::ThemeData,
228    ) -> LayoutCtx<'a> {
229        LayoutCtx::new(Constraints::loose(800.0, 600.0), font, theme)
230    }
231
232    #[test]
233    fn content_mode_takes_the_natural_child_height_plus_chrome() {
234        let font = rosace_render::FontCache::embedded();
235        let theme = rosace_theme::built_in::dark_theme();
236        let ctx = ctx_800x600(&font, &theme);
237        let size = Sheet::new(Spacer::gap(0.0, 100.0)).layout(&ctx);
238        assert_eq!(size.width, 800.0);
239        // 100 content + 40 padding (20 all sides) + 16 handle space.
240        assert_eq!(size.height, 156.0);
241    }
242
243    #[test]
244    fn fixed_detent_and_full_screen_heights_resolve_against_the_window() {
245        let font = rosace_render::FontCache::embedded();
246        let theme = rosace_theme::built_in::dark_theme();
247        let ctx = ctx_800x600(&font, &theme);
248
249        let fixed = Sheet::new(Spacer::gap(0.0, 100.0)).height(220.0).layout(&ctx);
250        assert_eq!(fixed.height, 220.0);
251
252        let detent = Sheet::new(Spacer::gap(0.0, 100.0)).detent(0.5).layout(&ctx);
253        assert_eq!(detent.height, 300.0);
254
255        let full = Sheet::new(Spacer::gap(0.0, 100.0)).full_screen().layout(&ctx);
256        assert_eq!(full.height, 600.0);
257    }
258
259    #[test]
260    fn fixed_height_is_capped_at_the_available_height() {
261        let font = rosace_render::FontCache::embedded();
262        let theme = rosace_theme::built_in::dark_theme();
263        let ctx = ctx_800x600(&font, &theme);
264        let size = Sheet::new(Spacer::gap(0.0, 100.0)).height(10_000.0).layout(&ctx);
265        assert_eq!(size.height, 600.0);
266    }
267
268    #[test]
269    fn scrollable_without_an_explicit_height_takes_the_full_height() {
270        let font = rosace_render::FontCache::embedded();
271        let theme = rosace_theme::built_in::dark_theme();
272        let ctx = ctx_800x600(&font, &theme);
273        let size = Sheet::new(Spacer::gap(0.0, 5_000.0)).scrollable().layout(&ctx);
274        assert_eq!(size.height, 600.0, "a scrollable sheet has no natural height");
275    }
276
277    #[test]
278    fn scrollable_with_a_detent_keeps_the_detent_height() {
279        let font = rosace_render::FontCache::embedded();
280        let theme = rosace_theme::built_in::dark_theme();
281        let ctx = ctx_800x600(&font, &theme);
282        let size = Sheet::new(Spacer::gap(0.0, 5_000.0))
283            .scrollable()
284            .detent(0.5)
285            .layout(&ctx);
286        assert_eq!(size.height, 300.0);
287    }
288}