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 teksilo_core::IntoTeksiChild) -> Self {
98 self.pending_header = Some(teksilo_core::IntoTeksiChild::into_pending(widget));
99 self
100 }
101
102 pub fn content(mut self, widget: impl teksilo_core::IntoTeksiChild) -> Self {
104 self.pending_content = Some(teksilo_core::IntoTeksiChild::into_pending(widget));
105 self
106 }
107
108 pub fn footer(mut self, widget: impl teksilo_core::IntoTeksiChild) -> Self {
110 self.pending_footer = Some(teksilo_core::IntoTeksiChild::into_pending(widget));
111 self
112 }
113
114 pub fn shadow(mut self, shadow: Shadow) -> Self {
118 self.shadow = Some(shadow);
119 self
120 }
121
122 pub fn background(mut self, color: impl Into<ColorProp>) -> Self {
126 self.background = Some(color.into());
127 self
128 }
129
130 pub fn corner_radius(mut self, radius: impl Into<Prop<f32>>) -> Self {
133 self.corner_radius = Some(radius.into());
134 self
135 }
136
137 pub fn padding(mut self, padding: impl Into<Prop<f32>>) -> Self {
140 self.padding = Some(padding.into());
141 self
142 }
143
144 pub fn variant(mut self, variant: CardVariant) -> Self {
150 self.variant = variant;
151 self
152 }
153
154 pub fn style(mut self, style: impl teksilo_core::styles::CardStyle) -> Self {
157 self.style_override = Some(Rc::new(style));
158 self
159 }
160
161 fn resolve_padding(&self, _theme: &teksilo_core::Theme) -> f32 {
162 self.padding
163 .as_ref()
164 .map(|p| p.get())
165 .unwrap_or(crate::styles::recipe_card_style::CARD_PADDING)
166 }
167}
168
169impl Default for Card {
170 fn default() -> Self {
171 Self::new()
172 }
173}
174
175impl Widget for Card {
176 fn build(&mut self, ctx: &mut teksilo_core::build_context::BuildContext) -> Vec<WidgetId> {
177 if let Some(h) = self.pending_header.take() {
178 self.header_id = Some(match h {
179 PendingChild::Id(id) => id,
180 PendingChild::Deferred(w) => ctx.add_boxed(w),
181 });
182 }
183 if let Some(c) = self.pending_content.take() {
184 self.content_id = Some(match c {
185 PendingChild::Id(id) => id,
186 PendingChild::Deferred(w) => ctx.add_boxed(w),
187 });
188 }
189 if let Some(f) = self.pending_footer.take() {
190 self.footer_id = Some(match f {
191 PendingChild::Id(id) => id,
192 PendingChild::Deferred(w) => ctx.add_boxed(w),
193 });
194 }
195
196 let pad = self.resolve_padding(ctx.theme());
201 let spacing = pad * 0.5;
202 let mut stack = VStack::new().spacing(spacing);
203 for slot in [self.header_id, self.content_id, self.footer_id]
204 .into_iter()
205 .flatten()
206 {
207 stack = stack.child(slot);
208 }
209 let content = ctx.add(stack);
210
211 let style: SharedCardStyle = self
212 .style_override
213 .clone()
214 .or_else(|| ctx.theme().style_slots.card.clone())
215 .unwrap_or_else(|| {
216 Rc::new(crate::styles::RecipeCardStyle::for_tokens(
217 &ctx.theme().input,
218 ))
219 });
220 let cfg = CardStyleConfig {
221 content,
222 is_hovered: None,
223 variant: self.variant,
224 background_override: self.background.clone(),
225 corner_radius_override: self.corner_radius.clone(),
226 padding_override: self.padding.clone(),
227 shadow_override: self.shadow,
228 };
229 let root_id = style.make_body(&cfg, ctx);
230 self.root_child_id = Some(root_id);
231 vec![root_id]
232 }
233
234 fn layout_response(
235 &self,
236 proposal: SizeProposal,
237 ctx: &LayoutContext,
238 ) -> teksilo_core::widget::LayoutResponse {
239 if let Some(root) = self.root_child_id
240 && let Some(size) = ctx.child_size(root, proposal)
241 {
242 return (size).into();
243 }
244 proposal.resolve(0.0, 0.0).into()
245 }
246
247 fn place_children(
248 &self,
249 bounds: Rect,
250 _proposal: SizeProposal,
251 children: &mut [WidgetPlacement],
252 _ctx: &LayoutContext,
253 ) {
254 for child in children.iter_mut() {
255 child.origin = Point::new(bounds.x, bounds.y);
256 child.size = Size::new(bounds.width, bounds.height);
257 }
258 }
259
260 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
261 builder.set_role(teksilo_core::accesskit::Role::Group);
262 }
263
264 fn children(&self) -> Vec<WidgetId> {
265 self.root_child_id.into_iter().collect()
266 }
267}
268
269#[cfg(test)]
270mod tests {
271 use super::*;
272 use teksilo_core::widget_tree::WidgetTree;
273
274 #[derive(Debug)]
275 struct FixedLeaf(f32, f32);
276 impl Widget for FixedLeaf {
277 fn layout_response(
278 &self,
279 _proposal: SizeProposal,
280 _ctx: &LayoutContext,
281 ) -> teksilo_core::widget::LayoutResponse {
282 Size::new(self.0, self.1).into()
283 }
284 }
285
286 #[test]
287 fn card_renders_shadow_and_background() {
288 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
289 let _content = tree.add(FixedLeaf(100.0, 50.0));
290 tree.add(
291 Card::new()
292 .variant(CardVariant::Elevated)
293 .content(FixedLeaf(100.0, 50.0)),
294 );
295 tree.layout(SizeProposal::exact(200.0, 200.0));
296 let frame = tree.render();
297 assert!(
299 !frame.shapes.is_empty() || !frame.shadows.is_empty(),
300 "card should render shadow and/or background"
301 );
302 }
303}