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