1use 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
32pub const CARD_PADDING: f32 = 16.0;
34pub const CARD_CORNER_RADIUS: f32 = 8.0;
35pub const CARD_BORDER_WIDTH: f32 = 1.0;
36pub const CARD_SHADOW_DENSITY: f32 = 0.5;
38
39#[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 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#[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 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 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 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 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 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}