1use teksilo_canvas::{Canvas, Rect, Size, SizeProposal};
22use teksilo_core::accessibility::AccessNodeBuilder;
23use teksilo_core::binding::BindingLevel;
24use teksilo_core::build_context::BuildContext;
25use teksilo_core::focus::FocusOrigin;
26use teksilo_core::signal::Signal;
27use teksilo_core::styles::density::spacing;
28use teksilo_core::styles::{FocusRingStroke, ToggleStyle, ToggleStyleConfig, ToggleVariant};
29use teksilo_core::widget::{LayoutContext, LayoutResponse, PaintContext, Widget, WidgetPlacement};
30use teksilo_core::widget_id::WidgetId;
31use teksilo_tokens::{Color, CornerRadius, InputTokens, ShapeTokens};
32
33pub const TOGGLE_TRACK_WIDTH: f32 = 28.0;
38pub const TOGGLE_TRACK_HEIGHT: f32 = 16.0;
39pub const TOGGLE_THUMB_DIAMETER: f32 = 12.0;
40pub const TOGGLE_THUMB_INSET: f32 = 2.0;
41
42#[derive(Debug, Clone, Copy, PartialEq)]
45pub struct ToggleRecipe {
46 pub track_width: f32,
47 pub track_height: f32,
48 pub thumb_diameter: f32,
49 pub thumb_inset: f32,
50}
51
52impl ToggleRecipe {
53 pub fn for_tokens(tokens: &InputTokens) -> Self {
59 Self {
60 track_width: TOGGLE_TRACK_WIDTH,
61 track_height: TOGGLE_TRACK_HEIGHT,
62 thumb_diameter: TOGGLE_THUMB_DIAMETER,
63 thumb_inset: spacing(TOGGLE_THUMB_INSET, tokens),
64 }
65 }
66}
67
68impl Default for ToggleRecipe {
69 fn default() -> Self {
70 Self::for_tokens(&InputTokens::default())
71 }
72}
73
74#[derive(Debug, Default, Clone, Copy)]
78pub struct RecipeToggleStyle {
79 pub recipe: ToggleRecipe,
80}
81
82impl RecipeToggleStyle {
83 pub fn new(recipe: ToggleRecipe) -> Self {
85 Self { recipe }
86 }
87
88 pub fn for_tokens(tokens: &InputTokens) -> Self {
95 Self {
96 recipe: ToggleRecipe::for_tokens(tokens),
97 }
98 }
99}
100
101impl ToggleStyle for RecipeToggleStyle {
102 fn make_body(&self, cfg: &ToggleStyleConfig, ctx: &mut BuildContext) -> WidgetId {
103 let initial = if cfg.is_on.get() { 1.0 } else { 0.0 };
106 let knob_position = ctx.animated_signal(initial);
107 let knob_anim = ctx.animate().fast().standard();
108
109 {
113 let knob_position = knob_position.clone();
114 let knob_anim = knob_anim.clone();
115 ctx.effect(&cfg.is_on, move |on| {
116 let target = if *on { 1.0 } else { 0.0 };
117 knob_anim.to_or_snap(&knob_position, target);
118 });
119 }
120
121 let focus_origin = cfg
126 .is_focused
127 .zip(&cfg.is_focus_visible)
128 .map(|(focused, visible)| {
129 if *focused {
130 Some(if *visible {
131 FocusOrigin::Keyboard
132 } else {
133 FocusOrigin::POINTER
134 })
135 } else {
136 None
137 }
138 });
139
140 ctx.add(ToggleBody {
141 knob_position,
142 is_disabled: cfg.is_disabled.clone(),
143 focus_origin,
144 variant: cfg.variant,
145 recipe: self.recipe,
146 })
147 }
148}
149
150struct ToggleBody {
154 knob_position: Signal<f32>,
155 is_disabled: Signal<bool>,
156 focus_origin: Signal<Option<FocusOrigin>>,
157 variant: ToggleVariant,
158 recipe: ToggleRecipe,
159}
160
161impl std::fmt::Debug for ToggleBody {
162 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
163 f.debug_struct("ToggleBody")
164 .field("variant", &self.variant)
165 .finish()
166 }
167}
168
169impl Widget for ToggleBody {
170 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
171 let id = ctx.self_id();
172 let registry = ctx.binding_registry();
173 self.knob_position
174 .bind_to(id, registry, BindingLevel::RepaintOnly);
175 self.is_disabled
176 .bind_to(id, registry, BindingLevel::RepaintOnly);
177 self.focus_origin
178 .bind_to(id, registry, BindingLevel::RepaintOnly);
179 vec![]
180 }
181
182 fn layout_response(&self, _proposal: SizeProposal, _ctx: &LayoutContext) -> LayoutResponse {
183 let row_h = self.recipe.track_height.max(24.0);
184 Size::new(self.recipe.track_width, row_h).into()
185 }
186
187 fn place_children(
188 &self,
189 _bounds: Rect,
190 _proposal: SizeProposal,
191 _children: &mut [WidgetPlacement],
192 _ctx: &LayoutContext,
193 ) {
194 }
195
196 fn paint(&self, bounds: Rect, canvas: &mut Canvas, ctx: &PaintContext) {
197 let colors = &ctx.theme.colors;
198 let track_w = self.recipe.track_width;
199 let track_h = self.recipe.track_height;
200 let knob_size = self.recipe.thumb_diameter;
201 let knob_inset = self.recipe.thumb_inset;
202 let enabled = !self.is_disabled.get();
203
204 let track_x = bounds.x;
206 let track_y = bounds.y + (bounds.height - track_h) / 2.0;
207 let track_rect = Rect::new(track_x, track_y, track_w, track_h);
208
209 let t = self.knob_position.get();
212 let track_color = if !enabled {
213 colors.accent_disabled
214 } else {
215 let off = colors.surface_sunken;
216 let on = colors.accent;
217 Color::new(
218 teksilo_tokens::lerp(off.r(), on.r(), t),
219 teksilo_tokens::lerp(off.g(), on.g(), t),
220 teksilo_tokens::lerp(off.b(), on.b(), t),
221 teksilo_tokens::lerp(off.a(), on.a(), t),
222 )
223 };
224
225 let track_corner = match self.variant {
228 ToggleVariant::Switch | ToggleVariant::Pill => CornerRadius::uniform(track_h / 2.0),
229 ToggleVariant::Square => CornerRadius::uniform(0.0),
230 ToggleVariant::Inset => CornerRadius::uniform(4.0),
231 };
232 canvas.fill_rounded_rect(track_rect, track_corner, track_color);
233
234 if self.focus_origin.get() == Some(FocusOrigin::Keyboard) {
237 let ring = focus_ring_stroke(self.variant, track_rect, &ctx.theme.shape);
238 canvas.stroke_rounded_rect(
239 ring.rect,
240 CornerRadius::uniform(ring.corner_radius),
241 colors.focus_ring,
242 ctx.theme.shape.focus_ring_width,
243 );
244 }
245
246 if matches!(self.variant, ToggleVariant::Switch | ToggleVariant::Square) {
248 let min_x = track_x + knob_inset;
249 let max_x = track_x + track_w - knob_size - knob_inset;
250 let knob_x = teksilo_tokens::lerp(min_x, max_x, t.clamp(0.0, 1.0));
251 let knob_y = track_y + (track_h - knob_size) / 2.0;
252 let knob_rect = Rect::new(knob_x, knob_y, knob_size, knob_size);
253 let knob_color = if !enabled {
254 colors.text_disabled
255 } else {
256 Color::WHITE
257 };
258 let knob_corner = match self.variant {
259 ToggleVariant::Switch => CornerRadius::uniform(knob_size / 2.0),
260 ToggleVariant::Square => CornerRadius::uniform(0.0),
261 _ => unreachable!(),
262 };
263 canvas.fill_rounded_rect(knob_rect, knob_corner, knob_color);
264 }
265 }
266
267 fn accessibility(&self, _builder: &mut AccessNodeBuilder) {
268 }
272}
273
274fn focus_ring_stroke(
278 variant: ToggleVariant,
279 track_rect: Rect,
280 shape: &ShapeTokens,
281) -> FocusRingStroke {
282 let track_radius = match variant {
283 ToggleVariant::Switch | ToggleVariant::Pill => track_rect.height / 2.0,
284 ToggleVariant::Square => 0.0,
285 ToggleVariant::Inset => 4.0,
286 };
287 FocusRingStroke::around(track_rect, track_radius, shape)
288}
289
290#[cfg(test)]
291mod focus_ring_tests {
292 use super::*;
293
294 fn shape(offset: f32) -> ShapeTokens {
295 ShapeTokens {
296 focus_ring_offset: offset,
297 focus_ring_width: 2.0,
298 ..ShapeTokens::default()
299 }
300 }
301
302 #[test]
303 fn a_negative_focus_ring_offset_shrinks_the_ring_without_inverting() {
304 let track = Rect::new(0.0, 0.0, 40.0, 20.0);
305 for variant in [
306 ToggleVariant::Switch,
307 ToggleVariant::Pill,
308 ToggleVariant::Square,
309 ToggleVariant::Inset,
310 ] {
311 let outer = focus_ring_stroke(variant, track, &shape(2.0));
312 let inner = focus_ring_stroke(variant, track, &shape(-1.0));
313 assert!(inner.rect.width < outer.rect.width, "{variant:?}");
314 assert!(inner.rect.height < outer.rect.height, "{variant:?}");
315 assert!(inner.rect.width >= 0.0 && inner.rect.height >= 0.0);
316 assert!(inner.corner_radius >= 0.0, "{variant:?}");
320
321 let deep = focus_ring_stroke(variant, track, &shape(-30.0));
322 assert!(deep.rect.width >= 0.0 && deep.rect.height >= 0.0);
323 assert!(deep.corner_radius >= 0.0);
324 }
325 }
326}