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::density::{dp, spacing};
32use teksilo_core::styles::{RadioTileStyle, RadioTileStyleConfig, RadioTileVariant};
33use teksilo_core::widget::{
34 LayoutContext, LayoutResponse, PaintContext, PendingChild, Widget, WidgetPlacement,
35};
36use teksilo_core::widget_id::WidgetId;
37use teksilo_tokens::{BorderRole, CornerRadius, InputTokens, SurfaceRole, TargetRole};
38
39pub const RADIO_TILE_CORNER_RADIUS: f32 = 4.0;
43pub const RADIO_TILE_PADDING: f32 = 14.0;
44pub const RADIO_TILE_BORDER_WIDTH: f32 = 1.0;
45pub const RADIO_TILE_SELECTED_BORDER_WIDTH: f32 = 1.5;
46pub const RADIO_TILE_FOCUS_RING_WIDTH: f32 = 2.0;
47pub const RADIO_TILE_SHADOW_DENSITY: f32 = 0.5;
49pub const RADIO_TILE_VERTICAL_ROW_HEIGHT: f32 = 44.0;
52
53#[derive(Debug, Clone, Copy, PartialEq)]
57pub struct RadioTileRecipe {
58 pub corner_radius: f32,
59 pub padding: f32,
60 pub border_width: f32,
61 pub selected_border_width: f32,
62 pub focus_ring_width: f32,
63 pub shadow_density: f32,
64 pub vertical_row_height: f32,
66}
67
68impl RadioTileRecipe {
69 pub fn for_tokens(tokens: &InputTokens) -> Self {
75 Self {
76 corner_radius: RADIO_TILE_CORNER_RADIUS,
77 padding: spacing(RADIO_TILE_PADDING, tokens),
78 border_width: RADIO_TILE_BORDER_WIDTH,
79 selected_border_width: RADIO_TILE_SELECTED_BORDER_WIDTH,
80 focus_ring_width: RADIO_TILE_FOCUS_RING_WIDTH,
81 shadow_density: RADIO_TILE_SHADOW_DENSITY,
82 vertical_row_height: dp(RADIO_TILE_VERTICAL_ROW_HEIGHT, TargetRole::Target, tokens),
83 }
84 }
85}
86
87impl Default for RadioTileRecipe {
88 fn default() -> Self {
89 Self::for_tokens(&InputTokens::default())
90 }
91}
92
93#[derive(Debug, Default, Clone, Copy)]
95pub struct RecipeRadioTileStyle {
96 pub recipe: RadioTileRecipe,
97}
98
99impl RecipeRadioTileStyle {
100 pub fn new(recipe: RadioTileRecipe) -> Self {
101 Self { recipe }
102 }
103
104 pub fn for_tokens(tokens: &InputTokens) -> Self {
111 Self {
112 recipe: RadioTileRecipe::for_tokens(tokens),
113 }
114 }
115}
116
117impl RadioTileStyle for RecipeRadioTileStyle {
118 fn vertical_row_height(&self) -> f32 {
119 self.recipe.vertical_row_height
120 }
121
122 fn make_body(&self, cfg: &RadioTileStyleConfig, ctx: &mut BuildContext) -> WidgetId {
123 let variant = cfg.variant;
124 let bg_role = tile_bg_signal(
129 &cfg.is_selected,
130 &cfg.is_pressed,
131 &cfg.is_hovered,
132 &cfg.is_window_active,
133 variant,
134 );
135
136 let border_width_base = self.recipe.border_width;
137
138 let border_state = cfg.is_hovered.map(move |hov| {
141 if *hov {
142 (BorderRole::Strong, border_width_base)
143 } else {
144 match variant {
147 RadioTileVariant::Filled => (BorderRole::Transparent, border_width_base),
148 _ => (BorderRole::Default, border_width_base),
149 }
150 }
151 });
152 let border_role = border_state.map(|(r, _)| *r);
153 let border_width = border_state.map(|(_, w)| *w);
154
155 let frame = RadioTileFrame {
156 content_id: None,
157 pending_content: Some(PendingChild::Id(cfg.content)),
158 bg: ColorProp::DynamicSurfaceRole(bg_role),
159 border: ColorProp::DynamicBorderRole(border_role),
160 border_width: Prop::Bound(border_width),
161 corner_radius: self.recipe.corner_radius,
162 padding: self.recipe.padding,
163 variant,
164 shadow_density: self.recipe.shadow_density,
165 is_compact: cfg.is_compact,
166 };
167 ctx.add(frame)
168 }
169}
170
171fn tile_bg_signal(
178 is_selected: &Signal<bool>,
179 is_pressed: &Signal<bool>,
180 is_hovered: &Signal<bool>,
181 is_window_active: &Signal<bool>,
182 variant: RadioTileVariant,
183) -> Signal<SurfaceRole> {
184 let combined = is_selected.zip3(is_pressed, is_hovered);
185 combined
186 .zip(is_window_active)
187 .map(move |((selected, pressed, hovered), window_active)| {
188 let resting = match variant {
189 RadioTileVariant::Filled => SurfaceRole::Container,
190 _ => SurfaceRole::Transparent,
191 };
192 if *selected {
193 if *window_active {
194 SurfaceRole::AccentSubtle
195 } else {
196 SurfaceRole::SelectedInactive
197 }
198 } else if *pressed {
199 SurfaceRole::Pressed
200 } else if *hovered {
201 SurfaceRole::Hover
202 } else {
203 resting
204 }
205 })
206}
207
208struct RadioTileFrame {
212 content_id: Option<WidgetId>,
213 pending_content: Option<PendingChild>,
214 bg: ColorProp,
215 border: ColorProp,
216 border_width: Prop<f32>,
217 corner_radius: f32,
218 padding: f32,
219 variant: RadioTileVariant,
220 shadow_density: f32,
221 is_compact: bool,
224}
225
226impl std::fmt::Debug for RadioTileFrame {
227 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
228 f.debug_struct("RadioTileFrame")
229 .field("variant", &self.variant)
230 .finish()
231 }
232}
233
234impl Widget for RadioTileFrame {
235 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
236 if let Some(pending) = self.pending_content.take() {
237 self.content_id = Some(match pending {
238 PendingChild::Id(id) => id,
239 PendingChild::Deferred(w) => ctx.add_boxed(w),
240 });
241 }
242 let id = ctx.self_id();
243 let registry = ctx.binding_registry();
244 self.bg
245 .register_if_bound(id, registry, BindingLevel::RepaintOnly);
246 self.border
247 .register_if_bound(id, registry, BindingLevel::RepaintOnly);
248 self.border_width
249 .register_if_bound(id, registry, BindingLevel::RepaintOnly);
250 self.content_id.into_iter().collect()
251 }
252
253 fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
254 let inset = self.padding * 2.0;
255 if let Some(child_id) = self.content_id {
256 let inner_proposal = SizeProposal {
257 width: proposal.width.map(|w| (w - inset).max(0.0)),
258 height: proposal.height.map(|h| (h - inset).max(0.0)),
259 };
260 if let Some(child_size) = ctx.child_size(child_id, inner_proposal) {
261 return (Size::new(child_size.width + inset, child_size.height + inset)).into();
262 }
263 }
264 proposal.resolve(inset, inset).into()
265 }
266
267 fn place_children(
268 &self,
269 bounds: Rect,
270 _proposal: SizeProposal,
271 children: &mut [WidgetPlacement],
272 ctx: &LayoutContext,
273 ) {
274 let pad = self.padding;
275 let inner_w = (bounds.width - pad * 2.0).max(0.0);
276 for child in children.iter_mut() {
277 if self.is_compact {
278 let content_h = ctx
283 .child_size(
284 child.id,
285 SizeProposal {
286 width: Some(inner_w),
287 height: None,
288 },
289 )
290 .map(|s| s.height)
291 .unwrap_or(bounds.height)
292 .min(bounds.height);
293 let y = bounds.y + ((bounds.height - content_h) * 0.5).max(0.0);
294 child.origin = teksilo_canvas::Point::new(bounds.x + pad, y);
295 child.size = Size::new(inner_w, content_h);
296 } else {
297 child.origin = teksilo_canvas::Point::new(bounds.x + pad, bounds.y + pad);
298 child.size = Size::new(inner_w, (bounds.height - pad * 2.0).max(0.0));
299 }
300 }
301 }
302
303 fn paint(&self, bounds: Rect, canvas: &mut Canvas, ctx: &PaintContext) {
304 let cr = CornerRadius::uniform(self.corner_radius);
305
306 if matches!(self.variant, RadioTileVariant::Elevated) {
308 crate::shadow::paint_layered_shadow(
309 canvas,
310 bounds,
311 cr,
312 &ctx.theme.shape.shadow_md,
313 &ctx.theme.shape.shadow_inner_md,
314 self.shadow_density,
315 None,
316 );
317 }
318
319 let bg = self.bg.resolve(ctx.theme, ctx.effective_enabled);
321 canvas.fill_rounded_rect(bounds, cr, bg);
322
323 let bc = self.border.resolve(ctx.theme, ctx.effective_enabled);
326 let bw = self.border_width.get();
327 if bw > 0.0 && bc.a() > 0.0 {
328 canvas.stroke_rounded_rect(bounds, cr, bc, bw);
329 }
330 }
331
332 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
333 builder.set_role(teksilo_core::accesskit::Role::GenericContainer);
339 }
340
341 fn children(&self) -> Vec<WidgetId> {
342 self.content_id.into_iter().collect()
343 }
344}
345
346#[cfg(test)]
347mod tests {
348 use super::*;
349
350 #[test]
351 fn selected_surface_tracks_window_active_resting_is_transparent() {
352 let selected = Signal::new(true);
353 let pressed = Signal::new(false);
354 let hovered = Signal::new(false);
355 let window_active = Signal::new(true);
356
357 let role = tile_bg_signal(
358 &selected,
359 &pressed,
360 &hovered,
361 &window_active,
362 RadioTileVariant::Outlined,
363 );
364 assert_eq!(role.get(), SurfaceRole::AccentSubtle);
366 window_active.set(false);
367 assert_eq!(role.get(), SurfaceRole::SelectedInactive);
368
369 selected.set(false);
371 window_active.set(true);
372 assert_eq!(role.get(), SurfaceRole::Transparent);
373 }
374
375 #[test]
376 fn filled_variant_rests_on_container_surface() {
377 let selected = Signal::new(false);
378 let pressed = Signal::new(false);
379 let hovered = Signal::new(false);
380 let window_active = Signal::new(true);
381 let role = tile_bg_signal(
382 &selected,
383 &pressed,
384 &hovered,
385 &window_active,
386 RadioTileVariant::Filled,
387 );
388 assert_eq!(role.get(), SurfaceRole::Container);
389 }
390}