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