1use teksilo_canvas::{Canvas, Rect, Size, SizeProposal};
26use teksilo_core::accessibility::AccessNodeBuilder;
27use teksilo_core::binding::BindingLevel;
28use teksilo_core::build_context::BuildContext;
29use teksilo_core::color_prop::ColorProp;
30use teksilo_core::signal::{Prop, Signal};
31use teksilo_core::styles::{RadioTileStyle, RadioTileStyleConfig, RadioTileVariant};
32use teksilo_core::widget::{
33 LayoutContext, LayoutResponse, PaintContext, PendingChild, Widget, WidgetPlacement,
34};
35use teksilo_core::widget_id::WidgetId;
36use teksilo_tokens::{BorderRole, CornerRadius, SurfaceRole};
37
38pub const RADIO_TILE_CORNER_RADIUS: f32 = 4.0;
42pub const RADIO_TILE_PADDING: f32 = 14.0;
43pub const RADIO_TILE_BORDER_WIDTH: f32 = 1.0;
44pub const RADIO_TILE_SELECTED_BORDER_WIDTH: f32 = 1.5;
45pub const RADIO_TILE_FOCUS_RING_WIDTH: f32 = 2.0;
46pub const RADIO_TILE_SHADOW_DENSITY: f32 = 0.5;
48pub const RADIO_TILE_VERTICAL_ROW_HEIGHT: f32 = 44.0;
51
52#[derive(Debug, Clone, Copy, PartialEq)]
56pub struct RadioTileRecipe {
57 pub corner_radius: f32,
58 pub padding: f32,
59 pub border_width: f32,
60 pub selected_border_width: f32,
61 pub focus_ring_width: f32,
62 pub shadow_density: f32,
63 pub vertical_row_height: f32,
65}
66
67impl Default for RadioTileRecipe {
68 fn default() -> Self {
69 Self {
70 corner_radius: RADIO_TILE_CORNER_RADIUS,
71 padding: RADIO_TILE_PADDING,
72 border_width: RADIO_TILE_BORDER_WIDTH,
73 selected_border_width: RADIO_TILE_SELECTED_BORDER_WIDTH,
74 focus_ring_width: RADIO_TILE_FOCUS_RING_WIDTH,
75 shadow_density: RADIO_TILE_SHADOW_DENSITY,
76 vertical_row_height: RADIO_TILE_VERTICAL_ROW_HEIGHT,
77 }
78 }
79}
80
81#[derive(Debug, Default, Clone, Copy)]
83pub struct RecipeRadioTileStyle {
84 pub recipe: RadioTileRecipe,
85}
86
87impl RecipeRadioTileStyle {
88 pub fn new(recipe: RadioTileRecipe) -> Self {
89 Self { recipe }
90 }
91}
92
93impl RadioTileStyle for RecipeRadioTileStyle {
94 fn vertical_row_height(&self) -> f32 {
95 self.recipe.vertical_row_height
96 }
97
98 fn make_body(&self, cfg: &RadioTileStyleConfig, ctx: &mut BuildContext) -> WidgetId {
99 let variant = cfg.variant;
100 let bg_role = tile_bg_signal(
105 &cfg.is_selected,
106 &cfg.is_pressed,
107 &cfg.is_hovered,
108 &cfg.is_window_active,
109 variant,
110 );
111
112 let border_width_base = self.recipe.border_width;
113
114 let border_state = cfg.is_hovered.map(move |hov| {
117 if *hov {
118 (BorderRole::Strong, border_width_base)
119 } else {
120 match variant {
123 RadioTileVariant::Filled => (BorderRole::Transparent, border_width_base),
124 _ => (BorderRole::Default, border_width_base),
125 }
126 }
127 });
128 let border_role = border_state.map(|(r, _)| *r);
129 let border_width = border_state.map(|(_, w)| *w);
130
131 let frame = RadioTileFrame {
132 content_id: None,
133 pending_content: Some(PendingChild::Id(cfg.content)),
134 bg: ColorProp::DynamicSurfaceRole(bg_role),
135 border: ColorProp::DynamicBorderRole(border_role),
136 border_width: Prop::Bound(border_width),
137 corner_radius: self.recipe.corner_radius,
138 padding: self.recipe.padding,
139 variant,
140 shadow_density: self.recipe.shadow_density,
141 is_compact: cfg.is_compact,
142 };
143 ctx.add(frame)
144 }
145}
146
147fn tile_bg_signal(
154 is_selected: &Signal<bool>,
155 is_pressed: &Signal<bool>,
156 is_hovered: &Signal<bool>,
157 is_window_active: &Signal<bool>,
158 variant: RadioTileVariant,
159) -> Signal<SurfaceRole> {
160 let combined = is_selected.zip3(is_pressed, is_hovered);
161 combined
162 .zip(is_window_active)
163 .map(move |((selected, pressed, hovered), window_active)| {
164 let resting = match variant {
165 RadioTileVariant::Filled => SurfaceRole::Container,
166 _ => SurfaceRole::Transparent,
167 };
168 if *selected {
169 if *window_active {
170 SurfaceRole::AccentSubtle
171 } else {
172 SurfaceRole::SelectedInactive
173 }
174 } else if *pressed {
175 SurfaceRole::Pressed
176 } else if *hovered {
177 SurfaceRole::Hover
178 } else {
179 resting
180 }
181 })
182}
183
184struct RadioTileFrame {
188 content_id: Option<WidgetId>,
189 pending_content: Option<PendingChild>,
190 bg: ColorProp,
191 border: ColorProp,
192 border_width: Prop<f32>,
193 corner_radius: f32,
194 padding: f32,
195 variant: RadioTileVariant,
196 shadow_density: f32,
197 is_compact: bool,
200}
201
202impl std::fmt::Debug for RadioTileFrame {
203 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
204 f.debug_struct("RadioTileFrame")
205 .field("variant", &self.variant)
206 .finish()
207 }
208}
209
210impl Widget for RadioTileFrame {
211 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
212 if let Some(pending) = self.pending_content.take() {
213 self.content_id = Some(match pending {
214 PendingChild::Id(id) => id,
215 PendingChild::Deferred(w) => ctx.add_boxed(w),
216 });
217 }
218 let id = ctx.self_id();
219 let registry = ctx.binding_registry();
220 self.bg
221 .register_if_bound(id, registry, BindingLevel::RepaintOnly);
222 self.border
223 .register_if_bound(id, registry, BindingLevel::RepaintOnly);
224 self.border_width
225 .register_if_bound(id, registry, BindingLevel::RepaintOnly);
226 self.content_id.into_iter().collect()
227 }
228
229 fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
230 let inset = self.padding * 2.0;
231 if let Some(child_id) = self.content_id {
232 let inner_proposal = SizeProposal {
233 width: proposal.width.map(|w| (w - inset).max(0.0)),
234 height: proposal.height.map(|h| (h - inset).max(0.0)),
235 };
236 if let Some(child_size) = ctx.child_size(child_id, inner_proposal) {
237 return (Size::new(child_size.width + inset, child_size.height + inset)).into();
238 }
239 }
240 proposal.resolve(inset, inset).into()
241 }
242
243 fn place_children(
244 &self,
245 bounds: Rect,
246 _proposal: SizeProposal,
247 children: &mut [WidgetPlacement],
248 ctx: &LayoutContext,
249 ) {
250 let pad = self.padding;
251 let inner_w = (bounds.width - pad * 2.0).max(0.0);
252 for child in children.iter_mut() {
253 if self.is_compact {
254 let content_h = ctx
259 .child_size(
260 child.id,
261 SizeProposal {
262 width: Some(inner_w),
263 height: None,
264 },
265 )
266 .map(|s| s.height)
267 .unwrap_or(bounds.height)
268 .min(bounds.height);
269 let y = bounds.y + ((bounds.height - content_h) * 0.5).max(0.0);
270 child.origin = teksilo_canvas::Point::new(bounds.x + pad, y);
271 child.size = Size::new(inner_w, content_h);
272 } else {
273 child.origin = teksilo_canvas::Point::new(bounds.x + pad, bounds.y + pad);
274 child.size = Size::new(inner_w, (bounds.height - pad * 2.0).max(0.0));
275 }
276 }
277 }
278
279 fn paint(&self, bounds: Rect, canvas: &mut Canvas, ctx: &PaintContext) {
280 let cr = CornerRadius::uniform(self.corner_radius);
281
282 if matches!(self.variant, RadioTileVariant::Elevated) {
284 crate::shadow::paint_layered_shadow(
285 canvas,
286 bounds,
287 cr,
288 &ctx.theme.shape.shadow_md,
289 &ctx.theme.shape.shadow_inner_md,
290 self.shadow_density,
291 None,
292 );
293 }
294
295 let bg = self.bg.resolve(ctx.theme, ctx.effective_enabled);
297 canvas.fill_rounded_rect(bounds, cr, bg);
298
299 let bc = self.border.resolve(ctx.theme, ctx.effective_enabled);
302 let bw = self.border_width.get();
303 if bw > 0.0 && bc.a() > 0.0 {
304 canvas.stroke_rounded_rect(bounds, cr, bc, bw);
305 }
306 }
307
308 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
309 builder.set_hidden();
311 }
312
313 fn children(&self) -> Vec<WidgetId> {
314 self.content_id.into_iter().collect()
315 }
316}
317
318#[cfg(test)]
319mod tests {
320 use super::*;
321
322 #[test]
323 fn selected_surface_tracks_window_active_resting_is_transparent() {
324 let selected = Signal::new(true);
325 let pressed = Signal::new(false);
326 let hovered = Signal::new(false);
327 let window_active = Signal::new(true);
328
329 let role = tile_bg_signal(
330 &selected,
331 &pressed,
332 &hovered,
333 &window_active,
334 RadioTileVariant::Outlined,
335 );
336 assert_eq!(role.get(), SurfaceRole::AccentSubtle);
338 window_active.set(false);
339 assert_eq!(role.get(), SurfaceRole::SelectedInactive);
340
341 selected.set(false);
343 window_active.set(true);
344 assert_eq!(role.get(), SurfaceRole::Transparent);
345 }
346
347 #[test]
348 fn filled_variant_rests_on_container_surface() {
349 let selected = Signal::new(false);
350 let pressed = Signal::new(false);
351 let hovered = Signal::new(false);
352 let window_active = Signal::new(true);
353 let role = tile_bg_signal(
354 &selected,
355 &pressed,
356 &hovered,
357 &window_active,
358 RadioTileVariant::Filled,
359 );
360 assert_eq!(role.get(), SurfaceRole::Container);
361 }
362}