1use std::rc::Rc;
39
40use teksilo_canvas::{Point, Rect, Size, SizeProposal};
41use teksilo_core::accessibility::AccessNodeBuilder;
42use teksilo_core::color_prop::ColorProp;
43use teksilo_core::signal::Prop;
44use teksilo_core::styles::{CardStyleConfig, CardVariant, SharedCardStyle};
45use teksilo_core::widget::{LayoutContext, PendingChild, Widget, WidgetPlacement};
46use teksilo_core::widget_id::WidgetId;
47use teksilo_tokens::Shadow;
48
49use crate::primitives::VStack;
50
51pub struct Card {
53 header_id: Option<WidgetId>,
54 content_id: Option<WidgetId>,
55 footer_id: Option<WidgetId>,
56 pending_header: Option<PendingChild>,
57 pending_content: Option<PendingChild>,
58 pending_footer: Option<PendingChild>,
59 shadow: Option<Shadow>,
60 background: Option<ColorProp>,
61 corner_radius: Option<Prop<f32>>,
62 padding: Option<Prop<f32>>,
63 variant: CardVariant,
64 style_override: Option<SharedCardStyle>,
65 root_child_id: Option<WidgetId>,
66}
67
68impl std::fmt::Debug for Card {
69 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
70 f.debug_struct("Card")
71 .field("variant", &self.variant)
72 .finish()
73 }
74}
75
76impl Card {
77 pub fn new() -> Self {
79 Self {
80 header_id: None,
81 content_id: None,
82 footer_id: None,
83 pending_header: None,
84 pending_content: None,
85 pending_footer: None,
86 shadow: None,
87 background: None,
88 corner_radius: None,
89 padding: None,
90 variant: CardVariant::default(),
91 style_override: None,
92 root_child_id: None,
93 }
94 }
95
96 pub fn header(mut self, widget: impl Widget + 'static) -> Self {
98 self.pending_header = Some(PendingChild::Deferred(Box::new(widget)));
99 self
100 }
101
102 pub fn header_id(mut self, id: WidgetId) -> Self {
104 self.pending_header = Some(PendingChild::Id(id));
105 self
106 }
107
108 pub fn content(mut self, widget: impl Widget + 'static) -> Self {
110 self.pending_content = Some(PendingChild::Deferred(Box::new(widget)));
111 self
112 }
113
114 pub fn content_id(mut self, id: WidgetId) -> Self {
116 self.pending_content = Some(PendingChild::Id(id));
117 self
118 }
119
120 pub fn footer(mut self, widget: impl Widget + 'static) -> Self {
122 self.pending_footer = Some(PendingChild::Deferred(Box::new(widget)));
123 self
124 }
125
126 pub fn footer_id(mut self, id: WidgetId) -> Self {
128 self.pending_footer = Some(PendingChild::Id(id));
129 self
130 }
131
132 pub fn shadow(mut self, shadow: Shadow) -> Self {
136 self.shadow = Some(shadow);
137 self
138 }
139
140 pub fn background(mut self, color: impl Into<ColorProp>) -> Self {
144 self.background = Some(color.into());
145 self
146 }
147
148 pub fn corner_radius(mut self, radius: impl Into<Prop<f32>>) -> Self {
151 self.corner_radius = Some(radius.into());
152 self
153 }
154
155 pub fn padding(mut self, padding: impl Into<Prop<f32>>) -> Self {
158 self.padding = Some(padding.into());
159 self
160 }
161
162 pub fn variant(mut self, variant: CardVariant) -> Self {
168 self.variant = variant;
169 self
170 }
171
172 pub fn style(mut self, style: impl teksilo_core::styles::CardStyle) -> Self {
175 self.style_override = Some(Rc::new(style));
176 self
177 }
178
179 fn resolve_padding(&self, _theme: &teksilo_core::Theme) -> f32 {
180 self.padding
181 .as_ref()
182 .map(|p| p.get())
183 .unwrap_or(crate::styles::recipe_card_style::CARD_PADDING)
184 }
185}
186
187impl Default for Card {
188 fn default() -> Self {
189 Self::new()
190 }
191}
192
193impl Widget for Card {
194 fn build(&mut self, ctx: &mut teksilo_core::build_context::BuildContext) -> Vec<WidgetId> {
195 if let Some(h) = self.pending_header.take() {
196 self.header_id = Some(match h {
197 PendingChild::Id(id) => id,
198 PendingChild::Deferred(w) => ctx.add_boxed(w),
199 });
200 }
201 if let Some(c) = self.pending_content.take() {
202 self.content_id = Some(match c {
203 PendingChild::Id(id) => id,
204 PendingChild::Deferred(w) => ctx.add_boxed(w),
205 });
206 }
207 if let Some(f) = self.pending_footer.take() {
208 self.footer_id = Some(match f {
209 PendingChild::Id(id) => id,
210 PendingChild::Deferred(w) => ctx.add_boxed(w),
211 });
212 }
213
214 let pad = self.resolve_padding(ctx.theme());
219 let spacing = pad * 0.5;
220 let mut stack = VStack::new().spacing(spacing);
221 for slot in [self.header_id, self.content_id, self.footer_id]
222 .into_iter()
223 .flatten()
224 {
225 stack = stack.add_child(slot);
226 }
227 let content = ctx.add(stack);
228
229 let style: SharedCardStyle = self
230 .style_override
231 .clone()
232 .or_else(|| ctx.theme().style_slots.card.clone())
233 .unwrap_or_else(|| Rc::new(crate::styles::RecipeCardStyle::default()));
234 let cfg = CardStyleConfig {
235 content,
236 is_hovered: None,
237 variant: self.variant,
238 background_override: self.background.clone(),
239 corner_radius_override: self.corner_radius.clone(),
240 padding_override: self.padding.clone(),
241 shadow_override: self.shadow,
242 };
243 let root_id = style.make_body(&cfg, ctx);
244 self.root_child_id = Some(root_id);
245 vec![root_id]
246 }
247
248 fn layout_response(
249 &self,
250 proposal: SizeProposal,
251 ctx: &LayoutContext,
252 ) -> teksilo_core::widget::LayoutResponse {
253 if let Some(root) = self.root_child_id
254 && let Some(size) = ctx.child_size(root, proposal)
255 {
256 return (size).into();
257 }
258 proposal.resolve(0.0, 0.0).into()
259 }
260
261 fn place_children(
262 &self,
263 bounds: Rect,
264 _proposal: SizeProposal,
265 children: &mut [WidgetPlacement],
266 _ctx: &LayoutContext,
267 ) {
268 for child in children.iter_mut() {
269 child.origin = Point::new(bounds.x, bounds.y);
270 child.size = Size::new(bounds.width, bounds.height);
271 }
272 }
273
274 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
275 builder.set_role(teksilo_core::accesskit::Role::Group);
276 }
277
278 fn children(&self) -> Vec<WidgetId> {
279 self.root_child_id.into_iter().collect()
280 }
281}
282
283#[cfg(test)]
284mod tests {
285 use super::*;
286 use teksilo_core::widget_tree::WidgetTree;
287
288 #[derive(Debug)]
289 struct FixedLeaf(f32, f32);
290 impl Widget for FixedLeaf {
291 fn layout_response(
292 &self,
293 _proposal: SizeProposal,
294 _ctx: &LayoutContext,
295 ) -> teksilo_core::widget::LayoutResponse {
296 Size::new(self.0, self.1).into()
297 }
298 }
299
300 #[test]
301 fn card_renders_shadow_and_background() {
302 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
303 let _content = tree.add(FixedLeaf(100.0, 50.0));
304 tree.add(
305 Card::new()
306 .variant(CardVariant::Elevated)
307 .content(FixedLeaf(100.0, 50.0)),
308 );
309 tree.layout(SizeProposal::exact(200.0, 200.0));
310 let frame = tree.render();
311 assert!(
313 !frame.shapes.is_empty() || !frame.shadows.is_empty(),
314 "card should render shadow and/or background"
315 );
316 }
317}