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