Skip to main content

teksilo_widgets/styles/
recipe_card_style.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Default `CardStyle` impl driven by paint-recipe data.
5//!
6//! `RecipeCardStyle` ships the IntUI card chrome — variant-driven
7//! background / shadow / border defaults, with caller overrides
8//! winning when set. Custom styles compose freely (glassmorphism card,
9//! brutalist box, neumorphic raised surface, etc.) by writing their
10//! own `impl CardStyle` block.
11//!
12//! Like `RecipePanelStyle`, the body is a single `CardFrame` container
13//! widget that paints the chrome AND positions the content with
14//! padding inset (one widget so the proposal-resolve / intrinsic-size
15//! logic mirrors the pre-refactor `Card` exactly — splitting into a
16//! ZStack would break proposal propagation).
17
18use teksilo_canvas::{Canvas, Rect, Size, SizeProposal};
19use teksilo_core::accessibility::AccessNodeBuilder;
20use teksilo_core::binding::BindingLevel;
21use teksilo_core::build_context::BuildContext;
22use teksilo_core::color_prop::ColorProp;
23use teksilo_core::signal::Prop;
24use teksilo_core::styles::{CardStyle, CardStyleConfig, CardVariant};
25use teksilo_core::widget::{
26    LayoutContext, LayoutResponse, PaintContext, PendingChild, Widget, WidgetPlacement,
27};
28use teksilo_core::widget_id::WidgetId;
29use teksilo_tokens::{CornerRadius, Shadow};
30
31// IntUI design tokens for Card. The recipe owns its own dimensions.
32pub const CARD_PADDING: f32 = 16.0;
33pub const CARD_CORNER_RADIUS: f32 = 8.0;
34pub const CARD_BORDER_WIDTH: f32 = 1.0;
35/// 0..=1 multiplier on `shape.shadow_inner_md.color.a` at paint time.
36pub const CARD_SHADOW_DENSITY: f32 = 0.5;
37
38/// Dimension recipe for `RecipeCardStyle`. Mirrors the `pub const` defaults
39/// and allows per-instance overrides without a custom `CardStyle` impl.
40#[derive(Debug, Clone, Copy, PartialEq)]
41pub struct CardRecipe {
42    pub padding: f32,
43    pub corner_radius: f32,
44    pub border_width: f32,
45    pub shadow_density: f32,
46}
47
48impl Default for CardRecipe {
49    fn default() -> Self {
50        Self {
51            padding: CARD_PADDING,
52            corner_radius: CARD_CORNER_RADIUS,
53            border_width: CARD_BORDER_WIDTH,
54            shadow_density: CARD_SHADOW_DENSITY,
55        }
56    }
57}
58
59/// Default `CardStyle` shipped with Teksilo. Honours all four
60/// `CardVariant` values via background / shadow / border defaults.
61#[derive(Debug, Default, Clone, Copy)]
62pub struct RecipeCardStyle {
63    pub recipe: CardRecipe,
64}
65
66impl RecipeCardStyle {
67    pub fn new(recipe: CardRecipe) -> Self {
68        Self { recipe }
69    }
70}
71
72impl CardStyle for RecipeCardStyle {
73    fn make_body(&self, cfg: &CardStyleConfig, ctx: &mut BuildContext) -> WidgetId {
74        let frame = CardFrame {
75            child_id: None,
76            pending_child: Some(PendingChild::Id(cfg.content)),
77            variant: cfg.variant,
78            background: cfg.background_override.clone(),
79            corner_radius: cfg
80                .corner_radius_override
81                .clone()
82                .unwrap_or(Prop::Static(self.recipe.corner_radius)),
83            padding: cfg
84                .padding_override
85                .clone()
86                .unwrap_or(Prop::Static(self.recipe.padding)),
87            shadow_override: cfg.shadow_override,
88            recipe: self.recipe,
89        };
90        ctx.add(frame)
91    }
92}
93
94struct CardFrame {
95    child_id: Option<WidgetId>,
96    pending_child: Option<PendingChild>,
97    variant: CardVariant,
98    background: Option<ColorProp>,
99    corner_radius: Prop<f32>,
100    padding: Prop<f32>,
101    shadow_override: Option<Shadow>,
102    recipe: CardRecipe,
103}
104
105impl std::fmt::Debug for CardFrame {
106    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
107        f.debug_struct("CardFrame")
108            .field("variant", &self.variant)
109            .finish()
110    }
111}
112
113impl Widget for CardFrame {
114    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
115        if let Some(pending) = self.pending_child.take() {
116            self.child_id = Some(match pending {
117                PendingChild::Id(id) => id,
118                PendingChild::Deferred(w) => ctx.add_boxed(w),
119            });
120        }
121        let id = ctx.self_id();
122        let registry = ctx.binding_registry();
123        if let Some(p) = &self.background {
124            p.register_if_bound(id, registry, BindingLevel::RepaintOnly);
125        }
126        self.corner_radius
127            .register_if_bound(id, registry, BindingLevel::RepaintOnly);
128        self.padding
129            .register_if_bound(id, registry, BindingLevel::Relayout);
130        self.child_id.into_iter().collect()
131    }
132
133    fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
134        let pad = self.padding.get();
135        let inset = pad * 2.0;
136        if let Some(child_id) = self.child_id {
137            let inner_proposal = SizeProposal {
138                width: proposal.width.map(|w| (w - inset).max(0.0)),
139                height: proposal.height.map(|h| (h - inset).max(0.0)),
140            };
141            if let Some(child_size) = ctx.child_size(child_id, inner_proposal) {
142                return (Size::new(child_size.width + inset, child_size.height + inset)).into();
143            }
144        }
145        proposal.resolve(inset, inset).into()
146    }
147
148    fn place_children(
149        &self,
150        bounds: Rect,
151        _proposal: SizeProposal,
152        children: &mut [WidgetPlacement],
153        _ctx: &LayoutContext,
154    ) {
155        let pad = self.padding.get();
156        for child in children.iter_mut() {
157            child.origin = teksilo_canvas::Point::new(bounds.x + pad, bounds.y + pad);
158            child.size = Size::new(
159                (bounds.width - pad * 2.0).max(0.0),
160                (bounds.height - pad * 2.0).max(0.0),
161            );
162        }
163    }
164
165    fn paint(&self, bounds: Rect, canvas: &mut Canvas, ctx: &PaintContext) {
166        let radius = self.corner_radius.get();
167        let cr = CornerRadius::uniform(radius);
168
169        // Shadow (and inner counter-shadow) — variant decides density and
170        // outer base; caller override (`shadow_override`) wins for the
171        // outer shadow only. Plain / Outlined have no shadow; Elevated
172        // and Filled use the theme's shadow_md pair; the variant-default
173        // gets multiplied by `card.shadow_density` before painting.
174        let outer = self.shadow_override.or(match self.variant {
175            CardVariant::Plain | CardVariant::Outlined => None,
176            CardVariant::Elevated | CardVariant::Filled => Some(ctx.theme.shape.shadow_md),
177        });
178        if let Some(outer) = outer {
179            crate::shadow::paint_layered_shadow(
180                canvas,
181                bounds,
182                cr,
183                &outer,
184                &ctx.theme.shape.shadow_inner_md,
185                self.recipe.shadow_density,
186                None,
187            );
188        }
189
190        // Background — variant default with optional caller override.
191        let bg = if let Some(p) = &self.background {
192            p.resolve(ctx.theme, ctx.effective_enabled)
193        } else {
194            match self.variant {
195                CardVariant::Plain | CardVariant::Outlined | CardVariant::Elevated => {
196                    ctx.theme.colors.surface_main
197                }
198                CardVariant::Filled => ctx.theme.colors.surface_raised,
199            }
200        };
201        canvas.fill_rounded_rect(bounds, cr, bg);
202
203        // Outlined variant draws a 1 dp accent-neutral border.
204        if matches!(self.variant, CardVariant::Outlined) && self.recipe.border_width > 0.0 {
205            canvas.stroke_rounded_rect(
206                bounds,
207                cr,
208                ctx.theme.colors.border,
209                self.recipe.border_width,
210            );
211        }
212    }
213
214    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
215        // Presentational — the parent Card emits Role::Group.
216        builder.set_hidden();
217    }
218
219    fn children(&self) -> Vec<WidgetId> {
220        self.child_id.into_iter().collect()
221    }
222}