1use teksilo_canvas::{Canvas, Rect, Size, SizeProposal};
18use teksilo_core::accessibility::AccessNodeBuilder;
19use teksilo_core::binding::BindingLevel;
20use teksilo_core::build_context::BuildContext;
21use teksilo_core::focus::FocusOrigin;
22use teksilo_core::signal::Signal;
23use teksilo_core::styles::density::{dp, spacing};
24use teksilo_core::styles::{RadioStyle, RadioStyleConfig, RadioVariant};
25use teksilo_core::widget::{LayoutContext, LayoutResponse, PaintContext, Widget, WidgetPlacement};
26use teksilo_core::widget_id::WidgetId;
27use teksilo_tokens::{Color, CornerRadius, InputTokens, TargetRole};
28
29pub const RADIO_VISUAL_SIZE: f32 = 19.0;
31pub const RADIO_HIT_AREA: f32 = 24.0;
32pub const RADIO_LABEL_GAP: f32 = 6.0;
33pub const RADIO_INNER_DOT_SIZE: f32 = 7.0;
34
35#[derive(Debug, Clone, Copy, PartialEq)]
39pub struct RadioRecipe {
40 pub visual_size: f32,
41 pub hit_area: f32,
42 pub label_gap: f32,
43 pub inner_dot_size: f32,
44}
45
46impl RadioRecipe {
47 pub fn for_tokens(tokens: &InputTokens) -> Self {
53 Self {
54 visual_size: RADIO_VISUAL_SIZE,
55 hit_area: dp(RADIO_HIT_AREA, TargetRole::Target, tokens),
56 label_gap: spacing(RADIO_LABEL_GAP, tokens),
57 inner_dot_size: RADIO_INNER_DOT_SIZE,
58 }
59 }
60}
61
62impl Default for RadioRecipe {
63 fn default() -> Self {
64 Self::for_tokens(&InputTokens::default())
65 }
66}
67
68#[derive(Debug, Default, Clone, Copy)]
72pub struct RecipeRadioStyle {
73 pub recipe: RadioRecipe,
74}
75
76impl RecipeRadioStyle {
77 pub fn new(recipe: RadioRecipe) -> Self {
78 Self { recipe }
79 }
80
81 pub fn for_tokens(tokens: &InputTokens) -> Self {
88 Self {
89 recipe: RadioRecipe::for_tokens(tokens),
90 }
91 }
92}
93
94impl RadioStyle for RecipeRadioStyle {
95 fn make_body(&self, cfg: &RadioStyleConfig, ctx: &mut BuildContext) -> WidgetId {
96 let focus_origin = cfg
97 .is_focused
98 .zip(&cfg.is_hovered)
99 .map(|(focused, hovered)| {
100 if *focused {
101 Some(if *hovered {
102 FocusOrigin::POINTER
103 } else {
104 FocusOrigin::Keyboard
105 })
106 } else {
107 None
108 }
109 });
110
111 ctx.add(RadioBody {
112 is_selected: cfg.is_selected.clone(),
113 is_hovered: cfg.is_hovered.clone(),
114 is_pressed: cfg.is_pressed.clone(),
115 is_disabled: cfg.is_disabled.clone(),
116 focus_origin,
117 variant: cfg.variant,
118 recipe: self.recipe,
119 })
120 }
121}
122
123struct RadioBody {
124 is_selected: Signal<bool>,
125 is_hovered: Signal<bool>,
126 is_pressed: Signal<bool>,
127 is_disabled: Signal<bool>,
128 focus_origin: Signal<Option<FocusOrigin>>,
129 variant: RadioVariant,
130 recipe: RadioRecipe,
131}
132
133impl std::fmt::Debug for RadioBody {
134 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
135 f.debug_struct("RadioBody")
136 .field("variant", &self.variant)
137 .finish()
138 }
139}
140
141impl Widget for RadioBody {
142 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
143 let id = ctx.self_id();
144 let registry = ctx.binding_registry();
145 self.is_selected
146 .bind_to(id, registry, BindingLevel::RepaintOnly);
147 self.is_hovered
148 .bind_to(id, registry, BindingLevel::RepaintOnly);
149 self.is_pressed
150 .bind_to(id, registry, BindingLevel::RepaintOnly);
151 self.is_disabled
152 .bind_to(id, registry, BindingLevel::RepaintOnly);
153 self.focus_origin
154 .bind_to(id, registry, BindingLevel::RepaintOnly);
155 vec![]
156 }
157
158 fn layout_response(&self, _proposal: SizeProposal, _ctx: &LayoutContext) -> LayoutResponse {
159 Size::new(self.recipe.visual_size, self.recipe.visual_size).into()
160 }
161
162 fn place_children(
163 &self,
164 _bounds: Rect,
165 _proposal: SizeProposal,
166 _children: &mut [WidgetPlacement],
167 _ctx: &LayoutContext,
168 ) {
169 }
170
171 fn paint(&self, bounds: Rect, canvas: &mut Canvas, ctx: &PaintContext) {
172 let colors = &ctx.theme.colors;
173 let selected = self.is_selected.get();
174 let disabled = self.is_disabled.get();
175 let pressed = self.is_pressed.get();
176 let hovered = self.is_hovered.get();
177 let focused = self.focus_origin.get().is_some();
178
179 let border_color = if focused {
182 colors.border_focused
183 } else if disabled {
184 colors.accent_disabled
185 } else if selected {
186 colors.accent
187 } else if hovered {
188 colors.border_strong
189 } else {
190 colors.border
191 };
192
193 let border_width = if focused {
194 ctx.theme.shape.focus_ring_width
195 } else {
196 ctx.theme.shape.border_width
197 };
198
199 let outer_corner = match self.variant {
201 RadioVariant::Circle => CornerRadius::uniform(self.recipe.visual_size / 2.0),
202 RadioVariant::Square => CornerRadius::uniform(0.0),
203 RadioVariant::Rounded => CornerRadius::uniform(self.recipe.visual_size * 0.25),
204 };
205
206 canvas.fill_rounded_rect(bounds, outer_corner, Color::TRANSPARENT);
208 if border_width > 0.0 && border_color != Color::TRANSPARENT {
209 canvas.stroke_rounded_rect(bounds, outer_corner, border_color, border_width);
210 }
211
212 if !selected {
216 return;
217 }
218 let dot_color = if disabled {
219 colors.accent_disabled
220 } else if pressed {
221 colors.accent_pressed
222 } else if hovered {
223 colors.accent_hover
224 } else {
225 colors.accent
226 };
227 let dot_size = self.recipe.inner_dot_size;
228 let dot_rect = Rect::new(
229 bounds.x + (bounds.width - dot_size) / 2.0,
230 bounds.y + (bounds.height - dot_size) / 2.0,
231 dot_size,
232 dot_size,
233 );
234 let dot_corner = match self.variant {
235 RadioVariant::Circle => CornerRadius::uniform(dot_size / 2.0),
236 RadioVariant::Square => CornerRadius::uniform(0.0),
237 RadioVariant::Rounded => CornerRadius::uniform(dot_size * 0.25),
238 };
239 canvas.fill_rounded_rect(dot_rect, dot_corner, dot_color);
240 }
241
242 fn accessibility(&self, _builder: &mut AccessNodeBuilder) {
243 }
246}