Skip to main content

rosace_widgets/tree/
card.rs

1use rosace_core::types::Size;
2use rosace_layout::Constraints;
3use rosace_render::Color;
4use rosace_shader::ShaderMaterial;
5use super::{Widget, LayoutCtx, PaintCtx, BoxedWidget};
6use super::padding::EdgeInsets;
7use super::container::draw_rounded_rect_pub;
8use super::material::{resolve_material, CardMaterial};
9
10/// An elevated surface — background + rounded corners + optional shadow.
11///
12/// The most common surface for grouping content (task card, profile card, etc.).
13pub struct Card {
14    pub background: Color,
15    pub border_color: Option<Color>,
16    pub radius: f32,
17    pub elevation: f32,
18    pub padding: EdgeInsets,
19    pub width: Option<f32>,
20    pub material: Option<ShaderMaterial>,
21    pub child: BoxedWidget,
22}
23
24impl Card {
25    pub fn new(child: impl Widget + 'static) -> Self {
26        Self {
27            background: Color::rgba(0, 0, 0, 0), // sentinel: use theme.surface_variant
28            border_color: Some(Color::rgba(0, 0, 0, 0)), // sentinel: use theme.outline
29            radius: 8.0,
30            elevation: 4.0,
31            padding: EdgeInsets::all(12.0),
32            width: None,
33            material: None,
34            child: Box::new(child),
35        }
36    }
37
38    pub fn background(mut self, c: Color) -> Self { self.background = c; self }
39    pub fn border(mut self, c: Color) -> Self { self.border_color = Some(c); self }
40    pub fn no_border(mut self) -> Self { self.border_color = None; self }
41    pub fn radius(mut self, r: f32) -> Self { self.radius = r; self }
42    pub fn elevation(mut self, e: f32) -> Self { self.elevation = e; self }
43    pub fn padding(mut self, p: EdgeInsets) -> Self { self.padding = p; self }
44    pub fn width(mut self, w: f32) -> Self { self.width = Some(w); self }
45    /// Per-instance shader material — replaces the background fill when
46    /// resolved. Beats the theme's `CardMaterial` default. Corners are drawn
47    /// square under the shader (no rounded-clip primitive yet, D124 Step 4+).
48    pub fn material(mut self, m: ShaderMaterial) -> Self { self.material = Some(m); self }
49}
50
51impl Widget for Card {
52    fn layout(&self, ctx: &LayoutCtx) -> Size {
53        let constraints = ctx.constraints;
54        // A fixed width bounds the child too (same rule as Container).
55        let avail_w = self.width.unwrap_or_else(|| constraints.max_width_f32());
56        let inner_c = Constraints::loose(
57            (avail_w - self.padding.total_h()).max(0.0),
58            (constraints.max_height_f32() - self.padding.total_v()).max(0.0),
59        );
60        let child_size = self.child.layout(&ctx.with_constraints(inner_c));
61        let total = self.padding.grow(child_size);
62        constraints.constrain(Size {
63            width:  self.width.unwrap_or(total.width),
64            height: total.height,
65        })
66    }
67
68    fn paint(&self, ctx: &mut PaintCtx) {
69        let r = ctx.rect;
70
71        if self.elevation > 0.5 {
72            ctx.fill_shadow_rrect(r, self.radius, Color::rgba(0, 0, 0, 80), self.elevation);
73        }
74
75        let material = resolve_material::<CardMaterial>(&ctx.theme, self.material.as_ref());
76        if let Some(m) = &material {
77            // Only paint a fallback the material EXPLICITLY carries (same
78            // rule as Container). Painting one unconditionally broke
79            // backdrop-sampling glass materials: the opaque rect landed in
80            // the scene right before the shader quad, so the glass sampled
81            // the fallback instead of the real content behind the card.
82            if let Some(fallback) = m.fallback {
83                draw_rounded_rect_pub(ctx, r, fallback, self.radius);
84            }
85            ctx.shader_fill(r, m.pipeline, m.uniforms.clone());
86        } else {
87            let bg = if self.background.a == 0 {
88                ctx.tc(ctx.theme.colors.surface_variant)
89            } else {
90                self.background
91            };
92            draw_rounded_rect_pub(ctx, r, bg, self.radius);
93        }
94
95        if let Some(bc) = self.border_color {
96            let bc = if bc.a == 0 { ctx.tc(ctx.theme.colors.outline) } else { bc };
97            ctx.stroke_rrect(r, self.radius, bc, 1.0);
98        }
99
100        // Child
101        let inner = self.padding.shrink(r);
102        self.child.paint(&mut ctx.child(inner));
103    }
104}
105
106#[cfg(test)]
107mod material_cascade_tests {
108    use super::*;
109    use rosace_shader::PipelineId;
110    use rosace_core::types::{Point, Rect, Size};
111
112    fn mat(id: u64) -> ShaderMaterial {
113        ShaderMaterial::new(PipelineId::user(0x3000 + id), vec![id as u8])
114    }
115
116    fn paint_and_check(card: Card, theme: rosace_theme::ThemeData) -> bool {
117        let font = rosace_render::FontCache::embedded();
118        let mut recorder = rosace_render::PictureRecorder::new();
119        let tree = std::rc::Rc::new(std::cell::RefCell::new(super::super::render_tree::RenderTree::new()));
120        let rect = Rect {
121            origin: Point { x: 0.0, y: 0.0 },
122            size: Size { width: 100.0, height: 100.0 },
123        };
124        let mut ctx = PaintCtx::root(&mut recorder, rect, &font, theme, tree);
125        card.paint(&mut ctx);
126        let picture = recorder.finish();
127        picture.commands.iter().any(|c| matches!(c, rosace_render::DrawCommand::ShaderFill { .. }))
128    }
129
130    #[test]
131    fn instance_material_paints_shader_fill() {
132        let theme = rosace_theme::built_in::dark_theme();
133        assert!(paint_and_check(Card::new(super::super::spacer::Spacer::new(0.0)).material(mat(1)), theme));
134    }
135
136    #[test]
137    fn theme_material_used_when_no_instance() {
138        let theme = rosace_theme::built_in::dark_theme().with_ext(super::super::material::CardMaterial(mat(2)));
139        assert!(paint_and_check(Card::new(super::super::spacer::Spacer::new(0.0)), theme));
140    }
141
142    #[test]
143    fn no_material_renders_as_before() {
144        let theme = rosace_theme::built_in::dark_theme();
145        assert!(!paint_and_check(Card::new(super::super::spacer::Spacer::new(0.0)), theme));
146    }
147}