1#![allow(non_snake_case)]
19
20use crate::composable;
21use crate::modifier::{Brush, Color, CornerRadii, Modifier, Point, Rect};
22use crate::widgets::wear::density::WearDensity;
23use crate::widgets::wear::theme::{WearColors, WearTextStyle};
24use crate::widgets::{Layout, Text};
25use cranpose_core::NodeId;
26use cranpose_foundation::SemanticsWidgetRole;
27use cranpose_ui_graphics::{DrawScope, Size, VectorPath};
28use cranpose_ui_layout::{Constraints, Measurable, MeasurePolicy, MeasureResult, Placement};
29
30#[derive(Clone, Copy, Debug, PartialEq)]
32pub struct SwitchButtonSpec {
33 pub colors: WearColors,
34 pub label_style: WearTextStyle,
35 pub secondary_style: WearTextStyle,
36 pub min_height: f32,
37 pub corner_radius: f32,
38 pub padding_horizontal: f32,
39 pub padding_vertical: f32,
40 pub switch_width: f32,
42 pub switch_slot_height: f32,
44 pub switch_height: f32,
46 pub track_border_width: f32,
48 pub thumb_radius_unchecked: f32,
49 pub thumb_radius_checked: f32,
50 pub control_spacing: f32,
52 pub label_spacing: f32,
54 pub progress: f32,
61}
62
63impl Default for SwitchButtonSpec {
64 fn default() -> Self {
65 Self {
66 colors: WearColors::default(),
67 label_style: WearTextStyle::LABEL_MEDIUM,
68 secondary_style: WearTextStyle::LABEL_SMALL,
69 min_height: 52.0,
70 corner_radius: 26.0,
71 padding_horizontal: 14.0,
72 padding_vertical: 8.0,
73 switch_width: 32.0,
74 switch_slot_height: 24.0,
75 switch_height: 22.0,
76 track_border_width: 2.0,
77 thumb_radius_unchecked: 6.0,
78 thumb_radius_checked: 9.0,
79 control_spacing: 6.0,
80 label_spacing: 1.0,
81 progress: 0.0,
82 }
83 }
84}
85
86impl SwitchButtonSpec {
87 pub fn colors(mut self, colors: WearColors) -> Self {
88 self.colors = colors;
89 self
90 }
91
92 pub fn progress(mut self, progress: f32) -> Self {
94 self.progress = progress.clamp(0.0, 1.0);
95 self
96 }
97}
98
99pub const SWITCH_THUMB_STIFFNESS: f32 = 1400.0;
105pub const SWITCH_COLOR_STIFFNESS: f32 = 260.0;
106pub const SWITCH_DAMPING_RATIO: f32 = 1.0;
107
108#[derive(Clone, Copy, Debug, PartialEq)]
110pub struct SwitchColors {
111 pub container: Color,
112 pub label: Color,
113 pub secondary_label: Color,
114 pub track: Color,
115 pub track_border: Color,
117 pub thumb: Color,
118 pub tick: Color,
119}
120
121impl SwitchColors {
122 pub fn of(colors: WearColors, checked: bool) -> Self {
128 let (container, label, secondary_label, track, border, thumb, tick) = if checked {
129 (
130 colors.primary_container,
131 colors.on_primary_container,
132 fade(colors.on_primary_container, 0.9),
133 colors.primary,
134 colors.primary,
135 colors.primary_container,
136 colors.primary,
137 )
138 } else {
139 (
140 colors.surface_container,
141 colors.on_surface,
142 colors.on_surface_variant,
143 colors.surface_container,
144 colors.outline,
145 colors.outline,
146 colors.primary,
147 )
148 };
149 let track_border = if border == track {
150 Color::rgba(border.0, border.1, border.2, 0.0)
151 } else {
152 border
153 };
154 Self {
155 container,
156 label,
157 secondary_label,
158 track,
159 track_border,
160 thumb,
161 tick,
162 }
163 }
164}
165
166fn fade(color: Color, alpha: f32) -> Color {
167 Color::rgba(color.0, color.1, color.2, color.3 * alpha)
168}
169
170pub fn switch_thumb(spec: SwitchButtonSpec, progress: f32) -> (f32, f32) {
175 let progress = progress.clamp(0.0, 1.0);
176 let radius = spec.thumb_radius_unchecked
177 + (spec.thumb_radius_checked - spec.thumb_radius_unchecked) * progress;
178 let half = spec.switch_height * 0.5;
179 let start = radius + (half - spec.thumb_radius_unchecked);
180 let end = spec.switch_width - radius - (half - spec.thumb_radius_checked);
181 (start + (end - start) * progress, radius)
182}
183
184pub fn switch_tick_scale(progress: f32) -> f32 {
189 let remaining = 1.0 - progress.clamp(0.0, 1.0);
190 1.0 - remaining * remaining * remaining
191}
192
193pub fn draw_switch(scope: &mut dyn DrawScope, spec: SwitchButtonSpec, colors: SwitchColors) {
195 let size = scope.size();
196 if size.width <= 0.0 || size.height <= 0.0 {
197 return;
198 }
199 let radius = size.height * 0.5;
200 scope.draw_round_rect(Brush::Solid(colors.track), CornerRadii::uniform(radius));
201
202 if colors.track_border.3 > 0.0 {
203 let inset = spec.track_border_width * 0.5;
206 scope.draw_round_rect_at_stroked(
207 Rect {
208 x: inset,
209 y: inset,
210 width: size.width - spec.track_border_width,
211 height: size.height - spec.track_border_width,
212 },
213 Brush::Solid(colors.track_border),
214 CornerRadii::uniform(radius - inset),
215 cranpose_ui_graphics::Stroke::new(spec.track_border_width),
216 );
217 }
218
219 let (centre_x, thumb_radius) = switch_thumb(spec, spec.progress);
220 scope.draw_circle(
221 Brush::Solid(colors.thumb),
222 Point {
223 x: centre_x,
224 y: radius,
225 },
226 thumb_radius,
227 );
228
229 draw_tick(
230 scope,
231 Point {
232 x: centre_x,
233 y: radius,
234 },
235 switch_tick_scale(spec.progress),
236 colors.tick,
237 );
238}
239
240fn draw_tick(scope: &mut dyn DrawScope, centre: Point, scale: f32, color: Color) {
246 if scale <= 0.0 || color.3 <= 0.0 {
247 return;
248 }
249 const SEGMENTS: [((f32, f32), (f32, f32)); 2] = [
251 ((7.4 - 12.0, 13.0 - 12.0), (9.9 - 12.0, 15.5 - 12.0)),
252 ((10.5 - 12.0, 15.1 - 12.0), (16.5 - 12.0, 9.1 - 12.0)),
253 ];
254 const STROKE: f32 = 2.0;
255 let half = STROKE * scale * 0.5;
256 for (start, end) in SEGMENTS {
257 let a = Point {
258 x: centre.x + start.0 * scale,
259 y: centre.y + start.1 * scale,
260 };
261 let b = Point {
262 x: centre.x + end.0 * scale,
263 y: centre.y + end.1 * scale,
264 };
265 stroke_round_capped(scope, a, b, half, color);
266 }
267}
268
269fn stroke_round_capped(scope: &mut dyn DrawScope, a: Point, b: Point, half: f32, color: Color) {
276 if half <= 0.0 {
277 return;
278 }
279 let (dx, dy) = (b.x - a.x, b.y - a.y);
280 let length = (dx * dx + dy * dy).sqrt();
281 if length > f32::EPSILON {
282 let (nx, ny) = (-dy / length * half, dx / length * half);
283 let body = format!(
284 "M {} {} L {} {} L {} {} L {} {} Z",
285 a.x + nx,
286 a.y + ny,
287 b.x + nx,
288 b.y + ny,
289 b.x - nx,
290 b.y - ny,
291 a.x - nx,
292 a.y - ny
293 );
294 if let Ok(path) = VectorPath::parse(&body) {
295 scope.draw_vector_path(&path, Brush::Solid(color));
296 }
297 }
298 scope.draw_circle(Brush::Solid(color), a, half);
299 scope.draw_circle(Brush::Solid(color), b, half);
300}
301
302pub fn SwitchButton<F>(
309 modifier: Modifier,
310 spec: SwitchButtonSpec,
311 checked: bool,
312 label: String,
313 secondary_label: Option<String>,
314 on_checked_change: F,
315) -> NodeId
316where
317 F: Fn(bool) + 'static,
318{
319 SwitchButtonNode(modifier, spec, checked, label, secondary_label, move || {
320 on_checked_change(!checked)
321 })
322}
323
324#[composable]
326pub fn SwitchButtonNode<F>(
327 modifier: Modifier,
328 spec: SwitchButtonSpec,
329 checked: bool,
330 label: String,
331 secondary_label: Option<String>,
332 on_toggle: F,
333) -> NodeId
334where
335 F: FnMut() + 'static,
336{
337 let density = WearDensity::current();
338 let colors = SwitchColors::of(spec.colors, checked);
339 let radius = density.dp(spec.corner_radius);
340 let container = colors.container;
341 let chrome = modifier
342 .draw_behind(move |scope: &mut dyn DrawScope| {
343 scope.draw_round_rect(Brush::Solid(container), CornerRadii::uniform(radius));
344 })
345 .toggleable(
351 checked,
352 Some(format!("{label}, {}", if checked { "on" } else { "off" })),
353 Some(SemanticsWidgetRole::Switch),
354 move |_next| on_toggle(),
355 );
356
357 let label_style = spec.label_style.resolve(colors.label);
358 let secondary_style = spec.secondary_style.resolve(colors.secondary_label);
359 let label_text = label;
360 Layout(
361 chrome,
362 SwitchButtonMeasurePolicy {
363 spec,
364 density: density.density(),
365 has_secondary: secondary_label.is_some(),
366 },
367 move || {
368 Text(label_text.clone(), Modifier::empty(), label_style.clone());
369 if let Some(secondary) = secondary_label.clone() {
370 Text(secondary, Modifier::empty(), secondary_style.clone());
371 }
372 SwitchGraphic(Modifier::empty(), spec, colors);
373 },
374 )
375}
376
377#[composable]
380pub fn SwitchGraphic(modifier: Modifier, spec: SwitchButtonSpec, colors: SwitchColors) -> NodeId {
381 crate::widgets::Canvas(
382 modifier.size_points(spec.switch_width, spec.switch_height),
383 move |scope: &mut dyn DrawScope| draw_switch(scope, spec, colors),
384 )
385}
386
387#[derive(Clone, Debug, PartialEq)]
388struct SwitchButtonMeasurePolicy {
389 spec: SwitchButtonSpec,
390 density: f32,
391 has_secondary: bool,
392}
393
394impl MeasurePolicy for SwitchButtonMeasurePolicy {
395 fn measure(
396 &self,
397 measurables: &[Box<dyn Measurable>],
398 constraints: Constraints,
399 ) -> MeasureResult {
400 let mut placements = Vec::new();
401 let size = self.measure_into(measurables, constraints, &mut placements);
402 MeasureResult::new(size, placements)
403 }
404
405 fn measure_into(
406 &self,
407 measurables: &[Box<dyn Measurable>],
408 constraints: Constraints,
409 placements: &mut Vec<Placement>,
410 ) -> Size {
411 placements.clear();
412 let density = WearDensity::new(self.density, 1.0);
413 let horizontal = density.dp(self.spec.padding_horizontal) * 2.0;
414 let vertical = density.dp(self.spec.padding_vertical) * 2.0;
415 let width = if constraints.max_width.is_finite() {
416 constraints.max_width
417 } else {
418 constraints.min_width
419 };
420
421 let switch_width = density.dp(self.spec.switch_width);
425 let spacing = density.dp(self.spec.control_spacing);
426 let label_width = (width - horizontal - switch_width - spacing).max(0.0);
427 let label_constraints = Constraints {
428 min_width: 0.0,
429 max_width: label_width,
430 min_height: 0.0,
431 max_height: f32::INFINITY,
432 };
433
434 let label_count = if self.has_secondary { 2 } else { 1 };
435 let mut labels = Vec::with_capacity(label_count);
436 for measurable in measurables.iter().take(label_count) {
437 labels.push(measurable.measure(label_constraints));
438 }
439 let switch = measurables.get(label_count).map(|measurable| {
440 measurable.measure(Constraints {
441 min_width: 0.0,
442 max_width: switch_width,
443 min_height: 0.0,
444 max_height: f32::INFINITY,
445 })
446 });
447
448 let label_spacing = density.dp(self.spec.label_spacing);
449 let column: f32 = labels
450 .iter()
451 .map(|placeable| density.ceil(placeable.height()))
452 .sum::<f32>()
453 + label_spacing * labels.len().saturating_sub(1) as f32;
454 let slot = density.dp(self.spec.switch_slot_height);
455 let content_demand = column.max(slot);
456 let height = density
457 .dp(self.spec.min_height)
458 .max(density.ceil(content_demand) + vertical)
459 .clamp(constraints.min_height, constraints.max_height);
460
461 let content = height - vertical;
462 let padding_top = density.dp(self.spec.padding_vertical);
463 let mut y = padding_top + density.centre(content, column);
464 let x = density.dp(self.spec.padding_horizontal);
465 for placeable in &labels {
466 placements.push(Placement::new(placeable.node_id(), x, y, 0));
467 y += density.ceil(placeable.height()) + label_spacing;
468 }
469
470 if let Some(switch) = switch {
471 let slot_top = padding_top + density.centre(content, slot);
475 let switch_x = width - density.dp(self.spec.padding_horizontal) - switch.width();
476 placements.push(Placement::new(switch.node_id(), switch_x, slot_top, 0));
477 }
478
479 Size::new(width, height)
480 }
481
482 fn min_intrinsic_width(&self, measurables: &[Box<dyn Measurable>], height: f32) -> f32 {
483 let density = WearDensity::new(self.density, 1.0);
484 measurables
485 .iter()
486 .map(|m| m.min_intrinsic_width(height))
487 .fold(0.0, f32::max)
488 + density.dp(self.spec.padding_horizontal) * 2.0
489 + density.dp(self.spec.switch_width)
490 + density.dp(self.spec.control_spacing)
491 }
492
493 fn max_intrinsic_width(&self, measurables: &[Box<dyn Measurable>], height: f32) -> f32 {
494 self.min_intrinsic_width(measurables, height)
495 }
496
497 fn min_intrinsic_height(&self, measurables: &[Box<dyn Measurable>], width: f32) -> f32 {
498 let density = WearDensity::new(self.density, 1.0);
499 let label_count = if self.has_secondary { 2 } else { 1 };
500 let column: f32 = measurables
501 .iter()
502 .take(label_count)
503 .map(|m| m.min_intrinsic_height(width))
504 .sum::<f32>();
505 density.dp(self.spec.min_height).max(
506 density.ceil(column.max(density.dp(self.spec.switch_slot_height)))
507 + density.dp(self.spec.padding_vertical) * 2.0,
508 )
509 }
510
511 fn max_intrinsic_height(&self, measurables: &[Box<dyn Measurable>], width: f32) -> f32 {
512 self.min_intrinsic_height(measurables, width)
513 }
514}
515
516#[cfg(test)]
517mod tests {
518 use super::*;
519 use cranpose_ui_graphics::{DrawScopeDefault, Size as GraphicsSize};
520
521 fn colors() -> WearColors {
522 WearColors {
523 primary: Color::from_rgb_u8(0xB9, 0xF2, 0xFF),
524 primary_container: Color::from_rgb_u8(0x0F, 0x36, 0x4E),
525 surface_container: Color::from_rgb_u8(0x0A, 0x16, 0x22),
526 outline: Color::from_rgb_u8(0x1D, 0x4D, 0x69),
527 ..WearColors::default()
528 }
529 }
530
531 #[test]
532 fn the_defaults_are_the_ones_the_tokens_declare() {
533 let spec = SwitchButtonSpec::default();
534 assert_eq!(spec.min_height, 52.0);
535 assert_eq!(spec.corner_radius, 26.0);
536 assert_eq!(spec.padding_horizontal, 14.0);
537 assert_eq!(spec.padding_vertical, 8.0);
538 assert_eq!(spec.switch_width, 32.0);
539 assert_eq!(spec.switch_slot_height, 24.0);
540 assert_eq!(spec.switch_height, 22.0);
541 assert_eq!(spec.track_border_width, 2.0);
542 assert_eq!(spec.thumb_radius_unchecked, 6.0);
543 assert_eq!(spec.thumb_radius_checked, 9.0);
544 assert_eq!(spec.control_spacing, 6.0);
545 }
546
547 #[test]
548 fn the_thumb_travels_between_the_two_measured_positions() {
549 let spec = SwitchButtonSpec::default();
550 let (unchecked, radius_off) = switch_thumb(spec, 0.0);
551 assert_eq!(unchecked, 11.0, "22px at density 2");
552 assert_eq!(radius_off, 6.0);
553 let (checked, radius_on) = switch_thumb(spec, 1.0);
554 assert_eq!(checked, 21.0, "42px at density 2");
555 assert_eq!(radius_on, 9.0);
556 }
557
558 #[test]
559 fn a_checked_switch_draws_no_border_and_an_unchecked_one_does() {
560 let checked = SwitchColors::of(colors(), true);
561 assert_eq!(
562 checked.track_border.3, 0.0,
563 "track and border are both primary, so the border is suppressed"
564 );
565 assert_eq!(checked.track, colors().primary);
566 assert_eq!(checked.thumb, colors().primary_container);
567
568 let unchecked = SwitchColors::of(colors(), false);
569 assert!(unchecked.track_border.3 > 0.0);
570 assert_eq!(unchecked.track_border, colors().outline);
571 }
572
573 #[test]
574 fn a_checked_thumb_is_the_container_colour_and_still_gets_drawn() {
575 let scheme = colors();
576 let checked = SwitchColors::of(scheme, true);
577 assert_eq!(
578 checked.thumb, checked.container,
579 "invisible against the row, and still occluding the track"
580 );
581 let mut scope = DrawScopeDefault::new(GraphicsSize::new(32.0, 22.0));
582 draw_switch(
583 &mut scope,
584 SwitchButtonSpec::default().colors(scheme).progress(1.0),
585 checked,
586 );
587 let primitives = scope.into_primitives();
588 assert_eq!(primitives.len(), 8);
590 }
591
592 #[test]
593 fn an_unchecked_switch_draws_its_ring_and_no_tick() {
594 let mut scope = DrawScopeDefault::new(GraphicsSize::new(32.0, 22.0));
595 draw_switch(
596 &mut scope,
597 SwitchButtonSpec::default().colors(colors()).progress(0.0),
598 SwitchColors::of(colors(), false),
599 );
600 assert_eq!(scope.into_primitives().len(), 3);
603 }
604
605 #[test]
606 fn the_tick_eases_out_rather_than_growing_linearly() {
607 assert_eq!(switch_tick_scale(0.0), 0.0);
608 assert_eq!(switch_tick_scale(1.0), 1.0);
609 assert!(switch_tick_scale(0.5) > 0.5);
611 assert!((switch_tick_scale(0.5) - 0.875).abs() < 1e-6);
612 }
613
614 #[test]
615 fn the_springs_are_the_ones_the_standard_motion_scheme_declares() {
616 assert_eq!(SWITCH_THUMB_STIFFNESS, 1400.0);
617 assert_eq!(SWITCH_COLOR_STIFFNESS, 260.0);
618 assert_eq!(SWITCH_DAMPING_RATIO, 1.0);
619 }
620}