Skip to main content

rosace_widgets/tree/
container.rs

1use rosace_core::types::{Point, Rect, Size};
2use rosace_layout::Constraints;
3use rosace_render::{Color, DrawCommand};
4use rosace_shader::ShaderMaterial;
5use super::{Widget, LayoutCtx, PaintCtx, BoxedWidget, avail_w, avail_h};
6use super::padding::EdgeInsets;
7use super::material::{resolve_material, ContainerMaterial};
8
9/// Box shape (D095 — a circle is a Container, not a CircleWidget).
10#[derive(Clone, Copy, Debug, PartialEq, Default)]
11pub enum BoxShape {
12    /// Rounded rect using `radius` (0 = sharp corners).
13    #[default]
14    Rect,
15    /// Fully circular (radius = min(w, h) / 2).
16    Circle,
17    /// Pill: radius = height / 2.
18    Stadium,
19}
20
21/// The most fundamental building block — a maximally-configurable box:
22/// shape, background (solid or gradient), border, shadow, corner radius,
23/// padding, margin, fixed/min size, alignment, child clipping, and a child.
24///
25/// Everything box-shaped is a `Container` — there is no ColoredBox / CircleBox
26/// / GradientBox (D095). Analogous to a CSS `div` or Flutter's `Container`.
27pub struct Container {
28    pub background: Option<Color>,
29    pub gradient: Option<(Color, Color, bool)>, // (from, to, vertical)
30    pub border_color: Option<Color>,
31    pub border_width: f32,
32    pub border_radius: f32,
33    pub shape: BoxShape,
34    pub shadow_blur: f32,
35    pub shadow_color: Color,
36    pub padding: EdgeInsets,
37    pub margin: EdgeInsets,
38    pub width: Option<f32>,
39    pub height: Option<f32>,
40    pub min_width: f32,
41    pub min_height: f32,
42    pub clip: bool,
43    pub align: Option<super::Alignment>,
44    pub material: Option<ShaderMaterial>,
45    pub child: Option<BoxedWidget>,
46}
47
48impl Container {
49    pub fn new() -> Self {
50        Self {
51            background: None,
52            gradient: None,
53            border_color: None,
54            border_width: 1.0,
55            border_radius: 0.0,
56            shape: BoxShape::Rect,
57            shadow_blur: 0.0,
58            shadow_color: Color::rgba(0, 0, 0, 0),
59            padding: EdgeInsets::default(),
60            margin: EdgeInsets::default(),
61            width: None,
62            height: None,
63            min_width: 0.0,
64            min_height: 0.0,
65            clip: false,
66            align: None,
67            material: None,
68            child: None,
69        }
70    }
71
72    /// Effective corner radius given the shape and box size.
73    fn radius_for(&self, size: Size) -> f32 {
74        match self.shape {
75            BoxShape::Rect    => self.border_radius,
76            BoxShape::Circle  => size.width.min(size.height) / 2.0,
77            BoxShape::Stadium => size.height / 2.0,
78        }
79    }
80
81    pub fn align(mut self, a: super::Alignment) -> Self { self.align = Some(a); self }
82    pub fn background(mut self, c: Color) -> Self { self.background = Some(c); self }
83    /// Two-stop linear gradient background (overrides solid `background`).
84    pub fn gradient(mut self, from: Color, to: Color) -> Self { self.gradient = Some((from, to, true)); self }
85    pub fn gradient_h(mut self, from: Color, to: Color) -> Self { self.gradient = Some((from, to, false)); self }
86    pub fn border(mut self, c: Color, w: f32) -> Self { self.border_color = Some(c); self.border_width = w; self }
87    pub fn radius(mut self, r: f32) -> Self { self.border_radius = r; self }
88    pub fn shape(mut self, s: BoxShape) -> Self { self.shape = s; self }
89    pub fn circle(mut self) -> Self { self.shape = BoxShape::Circle; self }
90    pub fn stadium(mut self) -> Self { self.shape = BoxShape::Stadium; self }
91    pub fn shadow(mut self, color: Color, blur: f32) -> Self { self.shadow_color = color; self.shadow_blur = blur; self }
92    /// Material-style elevation shortcut (black shadow scaled by elevation).
93    pub fn elevation(mut self, e: f32) -> Self { self.shadow_color = Color::rgba(0, 0, 0, 90); self.shadow_blur = e; self }
94    pub fn padding(mut self, p: EdgeInsets) -> Self { self.padding = p; self }
95    pub fn margin(mut self, m: EdgeInsets) -> Self { self.margin = m; self }
96    /// Clip the child to the box shape (rounded/circle content masking).
97    pub fn clip(mut self) -> Self { self.clip = true; self }
98    pub fn width(mut self, w: f32) -> Self { self.width = Some(w); self }
99    pub fn height(mut self, h: f32) -> Self { self.height = Some(h); self }
100    pub fn size(mut self, w: f32, h: f32) -> Self { self.width = Some(w); self.height = Some(h); self }
101    pub fn min_size(mut self, w: f32, h: f32) -> Self { self.min_width = w; self.min_height = h; self }
102    pub fn child(mut self, w: impl Widget + 'static) -> Self { self.child = Some(Box::new(w)); self }
103    /// Per-instance shader material — replaces the background fill (gradient/
104    /// solid) when resolved. Beats the theme's `ContainerMaterial` default.
105    /// Corners are drawn square under the shader (no rounded-clip primitive
106    /// yet, D124 Step 4+); border/shadow/child/radius are unaffected.
107    pub fn material(mut self, m: ShaderMaterial) -> Self { self.material = Some(m); self }
108}
109
110impl Default for Container {
111    fn default() -> Self { Self::new() }
112}
113
114impl Widget for Container {
115    fn layout(&self, ctx: &LayoutCtx) -> Size {
116        let constraints = ctx.constraints;
117        let child_size = self.child.as_ref().map(|c| {
118            // A fixed width/height bounds the CHILD too — a 240px-wide card's
119            // text must wrap at 240px even when the parent offers infinity.
120            let avail_w = self.width.unwrap_or_else(|| avail_w(constraints));
121            let avail_h = self.height.unwrap_or_else(|| avail_h(constraints));
122            let inner_c = Constraints::loose(
123                (avail_w - self.padding.total_h()).max(0.0),
124                (avail_h - self.padding.total_v()).max(0.0),
125            );
126            self.padding.grow(c.layout(&ctx.with_constraints(inner_c)))
127        }).unwrap_or(Size { width: 0.0, height: 0.0 });
128
129        // With an alignment set, fill the available (bounded) space —
130        // Flutter semantics; a shrink-wrapped box has no room to align in.
131        let (fill_w, fill_h) = if self.align.is_some() {
132            (avail_w(constraints), avail_h(constraints))
133        } else {
134            (f32::INFINITY, f32::INFINITY) // sentinel: not used below
135        };
136        let w = self.width.unwrap_or_else(|| {
137            if self.align.is_some() && fill_w.is_finite() { fill_w }
138            else { child_size.width.max(self.min_width) }
139        });
140        let h = self.height.unwrap_or_else(|| {
141            if self.align.is_some() && fill_h.is_finite() { fill_h }
142            else { child_size.height.max(self.min_height) }
143        });
144
145        // Margin is added around the box — it occupies more layout space but
146        // the visual box (bg/border/child) is inset by the margin at paint.
147        constraints.constrain(Size {
148            width:  w.max(self.min_width) + self.margin.total_h(),
149            height: h.max(self.min_height) + self.margin.total_v(),
150        })
151    }
152
153    fn paint(&self, ctx: &mut PaintCtx) {
154        // Inset by margin: the box draws inside its allocated rect.
155        let rect = self.margin.shrink(ctx.rect);
156        let radius = self.radius_for(rect.size);
157
158        // Drop shadow — source shape matches the (possibly rounded) box.
159        if self.shadow_blur > 0.5 {
160            ctx.fill_shadow_rrect(rect, radius, self.shadow_color, self.shadow_blur);
161        }
162
163        // Background — material (instance, else theme default) wins over
164        // gradient/solid; gradient wins over solid.
165        let material = resolve_material::<ContainerMaterial>(&ctx.theme, self.material.as_ref());
166        if let Some(m) = material {
167            if let Some(fallback) = m.fallback {
168                if radius > 0.5 { ctx.fill_rrect(rect, radius, fallback); }
169                else { ctx.fill_rect(rect, fallback); }
170            }
171            ctx.shader_fill(rect, m.pipeline, m.uniforms);
172        } else if let Some((from, to, vertical)) = self.gradient {
173            ctx.fill_gradient(rect, radius, from, to, vertical);
174        } else if let Some(bg) = self.background {
175            if radius > 0.5 { ctx.fill_rrect(rect, radius, bg); }
176            else { ctx.fill_rect(rect, bg); }
177        }
178
179        // Border — same corner geometry as the background.
180        if let Some(bc) = self.border_color {
181            if radius > 0.5 { ctx.stroke_rrect(rect, radius, bc, self.border_width); }
182            else { ctx.stroke_rect(rect, bc, self.border_width); }
183        }
184
185        // Child — optionally clipped to the box, aligned or filling padded rect.
186        if let Some(child) = &self.child {
187            let inner = self.padding.shrink(rect);
188            let child_rect = if let Some(align) = self.align {
189                let inner_c = Constraints::loose(inner.size.width, inner.size.height);
190                let child_size = child.layout(&ctx.layout_ctx(inner_c));
191                let off = align.offset(inner.size, child_size);
192                Rect {
193                    origin: Point { x: inner.origin.x + off.x, y: inner.origin.y + off.y },
194                    size: child_size,
195                }
196            } else {
197                inner
198            };
199            if self.clip {
200                ctx.record(DrawCommand::PushClip { rect });
201                child.paint(&mut ctx.child(child_rect));
202                ctx.record(DrawCommand::PopClip);
203            } else {
204                child.paint(&mut ctx.child(child_rect));
205            }
206        }
207    }
208}
209
210/// Fill a rounded rectangle through a `PaintCtx` (used by widgets that need
211/// rounded corners but aren't `Container`).
212pub(super) fn draw_rounded_rect_pub(ctx: &mut PaintCtx, rect: Rect, color: Color, radius: f32) {
213    ctx.fill_rrect(rect, radius, color);
214}
215
216#[cfg(test)]
217mod material_cascade_tests {
218    use super::*;
219    use rosace_shader::PipelineId;
220
221    fn mat(id: u64) -> ShaderMaterial {
222        ShaderMaterial::new(PipelineId::user(0x2000 + id), vec![id as u8])
223    }
224
225    fn paint_and_check(container: Container, theme: rosace_theme::ThemeData) -> bool {
226        let font = rosace_render::FontCache::embedded();
227        let mut recorder = rosace_render::PictureRecorder::new();
228        let tree = std::rc::Rc::new(std::cell::RefCell::new(super::super::render_tree::RenderTree::new()));
229        let rect = Rect {
230            origin: Point { x: 0.0, y: 0.0 },
231            size: Size { width: 100.0, height: 100.0 },
232        };
233        let mut ctx = PaintCtx::root(&mut recorder, rect, &font, theme, tree);
234        container.paint(&mut ctx);
235        let picture = recorder.finish();
236        picture.commands.iter().any(|c| matches!(c, DrawCommand::ShaderFill { .. }))
237    }
238
239    #[test]
240    fn instance_material_paints_shader_fill() {
241        let theme = rosace_theme::built_in::dark_theme();
242        assert!(paint_and_check(Container::new().material(mat(1)), theme));
243    }
244
245    #[test]
246    fn theme_material_used_when_no_instance() {
247        let theme = rosace_theme::built_in::dark_theme().with_ext(super::super::material::ContainerMaterial(mat(2)));
248        assert!(paint_and_check(Container::new(), theme));
249    }
250
251    #[test]
252    fn no_material_renders_as_before() {
253        let theme = rosace_theme::built_in::dark_theme();
254        assert!(!paint_and_check(Container::new().background(Color::rgb(10, 10, 10)), theme));
255    }
256}