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::density::spacing;
25use teksilo_core::styles::{CardStyle, CardStyleConfig, CardVariant};
26use teksilo_core::widget::{
27    LayoutContext, LayoutResponse, PaintContext, PendingChild, Widget, WidgetPlacement,
28};
29use teksilo_core::widget_id::WidgetId;
30use teksilo_tokens::{CornerRadius, InputTokens, Shadow};
31
32// IntUI design tokens for Card. The recipe owns its own dimensions.
33pub const CARD_PADDING: f32 = 16.0;
34pub const CARD_CORNER_RADIUS: f32 = 8.0;
35pub const CARD_BORDER_WIDTH: f32 = 1.0;
36/// 0..=1 multiplier on `shape.shadow_inner_md.color.a` at paint time.
37pub const CARD_SHADOW_DENSITY: f32 = 0.5;
38
39/// Dimension recipe for `RecipeCardStyle`. Mirrors the `pub const` defaults
40/// and allows per-instance overrides without a custom `CardStyle` impl.
41#[derive(Debug, Clone, Copy, PartialEq)]
42pub struct CardRecipe {
43    pub padding: f32,
44    pub corner_radius: f32,
45    pub border_width: f32,
46    pub shadow_density: f32,
47}
48
49impl CardRecipe {
50    /// This recipe's dimensions resolved against a density's [`InputTokens`].
51    ///
52    /// [`Default`] is `for_tokens(&InputTokens::default())` — the Compact
53    /// ladder — so the shipped values below are the Compact column by
54    /// construction and cannot drift from it.
55    pub fn for_tokens(tokens: &InputTokens) -> Self {
56        Self {
57            padding: spacing(CARD_PADDING, tokens),
58            corner_radius: CARD_CORNER_RADIUS,
59            border_width: CARD_BORDER_WIDTH,
60            shadow_density: CARD_SHADOW_DENSITY,
61        }
62    }
63}
64
65impl Default for CardRecipe {
66    fn default() -> Self {
67        Self::for_tokens(&InputTokens::default())
68    }
69}
70
71/// Default `CardStyle` shipped with Teksilo. Honours all four
72/// `CardVariant` values via background / shadow / border defaults.
73#[derive(Debug, Default, Clone, Copy)]
74pub struct RecipeCardStyle {
75    pub recipe: CardRecipe,
76}
77
78impl RecipeCardStyle {
79    pub fn new(recipe: CardRecipe) -> Self {
80        Self { recipe }
81    }
82
83    /// This style with every dimension resolved against a density's
84    /// [`InputTokens`], as `RecipeCardStyle::for_tokens(&ctx.theme().input)` at
85    /// the widget's own build site.
86    ///
87    /// [`Default`] is the `TargetDensity::Compact` projection, so a Compact
88    /// tree gets exactly the values this module documents.
89    pub fn for_tokens(tokens: &InputTokens) -> Self {
90        Self {
91            recipe: CardRecipe::for_tokens(tokens),
92        }
93    }
94}
95
96impl CardStyle for RecipeCardStyle {
97    fn make_body(&self, cfg: &CardStyleConfig, ctx: &mut BuildContext) -> WidgetId {
98        let frame = CardFrame {
99            child_id: None,
100            pending_child: Some(PendingChild::Id(cfg.content)),
101            variant: cfg.variant,
102            background: cfg.background_override.clone(),
103            corner_radius: cfg
104                .corner_radius_override
105                .clone()
106                .unwrap_or(Prop::Static(self.recipe.corner_radius)),
107            padding: cfg
108                .padding_override
109                .clone()
110                .unwrap_or(Prop::Static(self.recipe.padding)),
111            shadow_override: cfg.shadow_override,
112            recipe: self.recipe,
113        };
114        ctx.add(frame)
115    }
116}
117
118struct CardFrame {
119    child_id: Option<WidgetId>,
120    pending_child: Option<PendingChild>,
121    variant: CardVariant,
122    background: Option<ColorProp>,
123    corner_radius: Prop<f32>,
124    padding: Prop<f32>,
125    shadow_override: Option<Shadow>,
126    recipe: CardRecipe,
127}
128
129impl std::fmt::Debug for CardFrame {
130    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
131        f.debug_struct("CardFrame")
132            .field("variant", &self.variant)
133            .finish()
134    }
135}
136
137impl Widget for CardFrame {
138    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
139        if let Some(pending) = self.pending_child.take() {
140            self.child_id = Some(match pending {
141                PendingChild::Id(id) => id,
142                PendingChild::Deferred(w) => ctx.add_boxed(w),
143            });
144        }
145        let id = ctx.self_id();
146        let registry = ctx.binding_registry();
147        if let Some(p) = &self.background {
148            p.register_if_bound(id, registry, BindingLevel::RepaintOnly);
149        }
150        self.corner_radius
151            .register_if_bound(id, registry, BindingLevel::RepaintOnly);
152        self.padding
153            .register_if_bound(id, registry, BindingLevel::Relayout);
154        self.child_id.into_iter().collect()
155    }
156
157    fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
158        let pad = self.padding.get();
159        let inset = pad * 2.0;
160        if let Some(child_id) = self.child_id {
161            let inner_proposal = SizeProposal {
162                width: proposal.width.map(|w| (w - inset).max(0.0)),
163                height: proposal.height.map(|h| (h - inset).max(0.0)),
164            };
165            if let Some(child_size) = ctx.child_size(child_id, inner_proposal) {
166                return (Size::new(child_size.width + inset, child_size.height + inset)).into();
167            }
168        }
169        proposal.resolve(inset, inset).into()
170    }
171
172    fn place_children(
173        &self,
174        bounds: Rect,
175        _proposal: SizeProposal,
176        children: &mut [WidgetPlacement],
177        _ctx: &LayoutContext,
178    ) {
179        let pad = self.padding.get();
180        for child in children.iter_mut() {
181            child.origin = teksilo_canvas::Point::new(bounds.x + pad, bounds.y + pad);
182            child.size = Size::new(
183                (bounds.width - pad * 2.0).max(0.0),
184                (bounds.height - pad * 2.0).max(0.0),
185            );
186        }
187    }
188
189    fn paint(&self, bounds: Rect, canvas: &mut Canvas, ctx: &PaintContext) {
190        let radius = self.corner_radius.get();
191        let cr = CornerRadius::uniform(radius);
192
193        // Shadow (and inner counter-shadow) — variant decides density and
194        // outer base; caller override (`shadow_override`) wins for the
195        // outer shadow only. Plain / Outlined have no shadow; Elevated
196        // and Filled use the theme's shadow_md pair; the variant-default
197        // gets multiplied by `card.shadow_density` before painting.
198        let outer = self.shadow_override.or(match self.variant {
199            CardVariant::Plain | CardVariant::Outlined => None,
200            CardVariant::Elevated | CardVariant::Filled => Some(ctx.theme.shape.shadow_md),
201        });
202        if let Some(outer) = outer {
203            crate::shadow::paint_layered_shadow(
204                canvas,
205                bounds,
206                cr,
207                &outer,
208                &ctx.theme.shape.shadow_inner_md,
209                self.recipe.shadow_density,
210                None,
211            );
212        }
213
214        // Background — variant default with optional caller override.
215        let bg = if let Some(p) = &self.background {
216            p.resolve(ctx.theme, ctx.effective_enabled)
217        } else {
218            match self.variant {
219                CardVariant::Plain | CardVariant::Outlined | CardVariant::Elevated => {
220                    ctx.theme.colors.surface_main
221                }
222                CardVariant::Filled => ctx.theme.colors.surface_raised,
223            }
224        };
225        canvas.fill_rounded_rect(bounds, cr, bg);
226
227        // Outlined variant draws a 1 dp accent-neutral border.
228        if matches!(self.variant, CardVariant::Outlined) && self.recipe.border_width > 0.0 {
229            canvas.stroke_rounded_rect(
230                bounds,
231                cr,
232                ctx.theme.colors.border,
233                self.recipe.border_width,
234            );
235        }
236    }
237
238    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
239        // Presentational: the parent Card emits Role::Group. A bare
240        // `GenericContainer` is dropped by the walker and by every adapter
241        // with its children kept; `set_hidden()` would take the card's
242        // header, content and footer out of reach with it.
243        builder.set_role(teksilo_core::accesskit::Role::GenericContainer);
244    }
245
246    fn children(&self) -> Vec<WidgetId> {
247        self.child_id.into_iter().collect()
248    }
249}