1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
// SPDX-License-Identifier: MPL-2.0
// SPDX-FileCopyrightText: 2026 FernTech
//! Card — a surface container with optional header, content, and footer slots.
//!
//! `Card` renders an opaque or tinted rounded-rectangle backdrop, an optional
//! drop shadow, and up to three stacked content slots (header / content /
//! footer). It is the standard building block for list-item cards, dashboard
//! tiles, onboarding panels, and any widget that needs a visually distinct
//! raised or outlined surface. Chrome (shadow, background, corner radius,
//! padding) is delegated to the active [`CardStyle`](teksilo_core::styles::CardStyle)
//! so the visual language can be changed per-call (`.style(...)`) or
//! theme-wide via `theme.style_slots.card`.
//!
//! ## When to use
//!
//! - `CardVariant::Elevated` — a dashboard tile or list card that should
//! "float" above the page surface.
//! - `CardVariant::Outlined` — a bordered grouping box without shadow.
//! - `CardVariant::Plain` — the content sits on the default surface; no
//! visible chrome (useful for spacing only).
//!
//! ## Accessibility
//!
//! Announces as `Role::Group`. The slots' own accessibility nodes are
//! included in the subtree; the card itself carries no additional AT name.
//!
//! ```rust
//! # use teksilo_widgets::Card;
//! # use teksilo_core::styles::CardVariant;
//! # use teksilo_widgets::primitives::TextWidget;
//! # use teksilo_i18n::lit;
//! let _card = Card::new()
//! .variant(CardVariant::Elevated)
//! .content(TextWidget::new(lit!("Hello, card!")));
//! ```
use std::rc::Rc;
use teksilo_canvas::{Point, Rect, Size, SizeProposal};
use teksilo_core::accessibility::AccessNodeBuilder;
use teksilo_core::color_prop::ColorProp;
use teksilo_core::signal::Prop;
use teksilo_core::styles::{CardStyleConfig, CardVariant, SharedCardStyle};
use teksilo_core::widget::{LayoutContext, PendingChild, Widget, WidgetPlacement};
use teksilo_core::widget_id::WidgetId;
use teksilo_tokens::Shadow;
use crate::primitives::VStack;
/// A card container with shadow, background, and optional header/content/footer.
pub struct Card {
header_id: Option<WidgetId>,
content_id: Option<WidgetId>,
footer_id: Option<WidgetId>,
pending_header: Option<PendingChild>,
pending_content: Option<PendingChild>,
pending_footer: Option<PendingChild>,
shadow: Option<Shadow>,
background: Option<ColorProp>,
corner_radius: Option<Prop<f32>>,
padding: Option<Prop<f32>>,
variant: CardVariant,
style_override: Option<SharedCardStyle>,
root_child_id: Option<WidgetId>,
}
impl std::fmt::Debug for Card {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Card")
.field("variant", &self.variant)
.finish()
}
}
impl Card {
/// Construct an empty card with no slots and the default `CardVariant::Plain`.
pub fn new() -> Self {
Self {
header_id: None,
content_id: None,
footer_id: None,
pending_header: None,
pending_content: None,
pending_footer: None,
shadow: None,
background: None,
corner_radius: None,
padding: None,
variant: CardVariant::default(),
style_override: None,
root_child_id: None,
}
}
/// Set the header slot (topmost section) to an inline widget.
pub fn header(mut self, widget: impl Widget + 'static) -> Self {
self.pending_header = Some(PendingChild::Deferred(Box::new(widget)));
self
}
/// Set the header slot to a pre-registered `WidgetId`.
pub fn header_id(mut self, id: WidgetId) -> Self {
self.pending_header = Some(PendingChild::Id(id));
self
}
/// Set the main content slot (middle section) to an inline widget.
pub fn content(mut self, widget: impl Widget + 'static) -> Self {
self.pending_content = Some(PendingChild::Deferred(Box::new(widget)));
self
}
/// Set the main content slot to a pre-registered `WidgetId`.
pub fn content_id(mut self, id: WidgetId) -> Self {
self.pending_content = Some(PendingChild::Id(id));
self
}
/// Set the footer slot (bottommost section) to an inline widget.
pub fn footer(mut self, widget: impl Widget + 'static) -> Self {
self.pending_footer = Some(PendingChild::Deferred(Box::new(widget)));
self
}
/// Set the footer slot to a pre-registered `WidgetId`.
pub fn footer_id(mut self, id: WidgetId) -> Self {
self.pending_footer = Some(PendingChild::Id(id));
self
}
/// Override the drop shadow. Accepts a `Shadow` token (see
/// `teksilo_tokens::Shadow`). The default shadow comes from the active
/// `CardStyle` for the chosen `CardVariant`.
pub fn shadow(mut self, shadow: Shadow) -> Self {
self.shadow = Some(shadow);
self
}
/// Override the background. Default (unset) is the variant's default
/// (`SurfaceRole::Main` for Plain/Outlined/Elevated, `SurfaceRole::Raised`
/// for Filled). Accepts `Color`, a role, or `Signal<Color>`.
pub fn background(mut self, color: impl Into<ColorProp>) -> Self {
self.background = Some(color.into());
self
}
/// Override the corner radius (default: theme `components.card.corner_radius`).
/// Accepts a static `f32` or a reactive `Signal<f32>`.
pub fn corner_radius(mut self, radius: impl Into<Prop<f32>>) -> Self {
self.corner_radius = Some(radius.into());
self
}
/// Override the padding (default: theme `components.card.padding`).
/// Accepts a static `f32` or a reactive `Signal<f32>`.
pub fn padding(mut self, padding: impl Into<Prop<f32>>) -> Self {
self.padding = Some(padding.into());
self
}
/// Pick the design-language variant. Default `Plain`. The active
/// `CardStyle` decides what each variant means visually (the IntUI
/// default maps Plain → no shadow + surface_main, Elevated →
/// shadow_md + surface_main, Outlined → border + surface_main,
/// Filled → shadow_md + surface_raised).
pub fn variant(mut self, variant: CardVariant) -> Self {
self.variant = variant;
self
}
/// Per-call style override. Replaces the theme-wide default
/// `CardStyle` for just this Card instance.
pub fn style(mut self, style: impl teksilo_core::styles::CardStyle) -> Self {
self.style_override = Some(Rc::new(style));
self
}
fn resolve_padding(&self, _theme: &teksilo_core::Theme) -> f32 {
self.padding
.as_ref()
.map(|p| p.get())
.unwrap_or(crate::styles::recipe_card_style::CARD_PADDING)
}
}
impl Default for Card {
fn default() -> Self {
Self::new()
}
}
impl Widget for Card {
fn build(&mut self, ctx: &mut teksilo_core::build_context::BuildContext) -> Vec<WidgetId> {
if let Some(h) = self.pending_header.take() {
self.header_id = Some(match h {
PendingChild::Id(id) => id,
PendingChild::Deferred(w) => ctx.add_boxed(w),
});
}
if let Some(c) = self.pending_content.take() {
self.content_id = Some(match c {
PendingChild::Id(id) => id,
PendingChild::Deferred(w) => ctx.add_boxed(w),
});
}
if let Some(f) = self.pending_footer.take() {
self.footer_id = Some(match f {
PendingChild::Id(id) => id,
PendingChild::Deferred(w) => ctx.add_boxed(w),
});
}
// Compose the three slots into a single content widget — a VStack
// with `padding/2` spacing between sections (mirrors the
// pre-refactor in-card section spacing). Empty if all three are
// None (the style still gets a `content: WidgetId` to wrap).
let pad = self.resolve_padding(ctx.theme());
let spacing = pad * 0.5;
let mut stack = VStack::new().spacing(spacing);
for slot in [self.header_id, self.content_id, self.footer_id]
.into_iter()
.flatten()
{
stack = stack.add_child(slot);
}
let content = ctx.add(stack);
let style: SharedCardStyle = self
.style_override
.clone()
.or_else(|| ctx.theme().style_slots.card.clone())
.unwrap_or_else(|| Rc::new(crate::styles::RecipeCardStyle::default()));
let cfg = CardStyleConfig {
content,
is_hovered: None,
variant: self.variant,
background_override: self.background.clone(),
corner_radius_override: self.corner_radius.clone(),
padding_override: self.padding.clone(),
shadow_override: self.shadow,
};
let root_id = style.make_body(&cfg, ctx);
self.root_child_id = Some(root_id);
vec![root_id]
}
fn layout_response(
&self,
proposal: SizeProposal,
ctx: &LayoutContext,
) -> teksilo_core::widget::LayoutResponse {
if let Some(root) = self.root_child_id
&& let Some(size) = ctx.child_size(root, proposal)
{
return (size).into();
}
proposal.resolve(0.0, 0.0).into()
}
fn place_children(
&self,
bounds: Rect,
_proposal: SizeProposal,
children: &mut [WidgetPlacement],
_ctx: &LayoutContext,
) {
for child in children.iter_mut() {
child.origin = Point::new(bounds.x, bounds.y);
child.size = Size::new(bounds.width, bounds.height);
}
}
fn accessibility(&self, builder: &mut AccessNodeBuilder) {
builder.set_role(teksilo_core::accesskit::Role::Group);
}
fn children(&self) -> Vec<WidgetId> {
self.root_child_id.into_iter().collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
use teksilo_core::widget_tree::WidgetTree;
#[derive(Debug)]
struct FixedLeaf(f32, f32);
impl Widget for FixedLeaf {
fn layout_response(
&self,
_proposal: SizeProposal,
_ctx: &LayoutContext,
) -> teksilo_core::widget::LayoutResponse {
Size::new(self.0, self.1).into()
}
}
#[test]
fn card_renders_shadow_and_background() {
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
let _content = tree.add(FixedLeaf(100.0, 50.0));
tree.add(
Card::new()
.variant(CardVariant::Elevated)
.content(FixedLeaf(100.0, 50.0)),
);
tree.layout(SizeProposal::exact(200.0, 200.0));
let frame = tree.render();
// Should have shapes for shadow + background
assert!(
!frame.shapes.is_empty() || !frame.shadows.is_empty(),
"card should render shadow and/or background"
);
}
}