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::{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
31pub const CARD_PADDING: f32 = 16.0;
33pub const CARD_CORNER_RADIUS: f32 = 8.0;
34pub const CARD_BORDER_WIDTH: f32 = 1.0;
35pub const CARD_SHADOW_DENSITY: f32 = 0.5;
37
38#[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#[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 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 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 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 builder.set_hidden();
217 }
218
219 fn children(&self) -> Vec<WidgetId> {
220 self.child_id.into_iter().collect()
221 }
222}