1#![expect(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,
42 pub label_style: WearTextStyle,
44 pub secondary_style: WearTextStyle,
46 pub min_height: f32,
48 pub corner_radius: f32,
50 pub padding_horizontal: f32,
52 pub padding_vertical: f32,
54 pub switch_width: f32,
56 pub switch_slot_height: f32,
58 pub switch_height: f32,
60 pub track_border_width: f32,
62 pub thumb_radius_unchecked: f32,
64 pub thumb_radius_checked: f32,
66 pub control_spacing: f32,
68 pub label_spacing: f32,
70 pub progress: f32,
77}
78
79impl Default for SwitchButtonSpec {
80 fn default() -> Self {
81 Self {
82 colors: WearColors::default(),
83 label_style: WearTextStyle::LABEL_MEDIUM,
84 secondary_style: WearTextStyle::LABEL_SMALL,
85 min_height: 52.0,
86 corner_radius: 26.0,
87 padding_horizontal: 14.0,
88 padding_vertical: 8.0,
89 switch_width: 32.0,
90 switch_slot_height: 24.0,
91 switch_height: 22.0,
92 track_border_width: 2.0,
93 thumb_radius_unchecked: 6.0,
94 thumb_radius_checked: 9.0,
95 control_spacing: 6.0,
96 label_spacing: 1.0,
97 progress: 0.0,
98 }
99 }
100}
101
102impl SwitchButtonSpec {
103 pub fn colors(mut self, colors: WearColors) -> Self {
105 self.colors = colors;
106 self
107 }
108
109 pub fn progress(mut self, progress: f32) -> Self {
111 self.progress = progress.clamp(0.0, 1.0);
112 self
113 }
114}
115
116pub const SWITCH_THUMB_STIFFNESS: f32 = 1400.0;
122pub const SWITCH_COLOR_STIFFNESS: f32 = 260.0;
124pub const SWITCH_DAMPING_RATIO: f32 = 1.0;
126
127#[derive(Clone, Copy, Debug, PartialEq)]
129pub struct SwitchColors {
130 pub container: Color,
132 pub label: Color,
134 pub secondary_label: Color,
136 pub track: Color,
138 pub track_border: Color,
140 pub thumb: Color,
142 pub tick: Color,
144}
145
146impl SwitchColors {
147 pub fn of(colors: WearColors, checked: bool) -> Self {
153 let (container, label, secondary_label, track, border, thumb, tick) = if checked {
154 (
155 colors.primary_container,
156 colors.on_primary_container,
157 fade(colors.on_primary_container, 0.9),
158 colors.primary,
159 colors.primary,
160 colors.primary_container,
161 colors.primary,
162 )
163 } else {
164 (
165 colors.surface_container,
166 colors.on_surface,
167 colors.on_surface_variant,
168 colors.surface_container,
169 colors.outline,
170 colors.outline,
171 colors.primary,
172 )
173 };
174 let track_border = if border == track {
175 Color::rgba(border.0, border.1, border.2, 0.0)
176 } else {
177 border
178 };
179 Self {
180 container,
181 label,
182 secondary_label,
183 track,
184 track_border,
185 thumb,
186 tick,
187 }
188 }
189}
190
191fn fade(color: Color, alpha: f32) -> Color {
192 Color::rgba(color.0, color.1, color.2, color.3 * alpha)
193}
194
195pub fn switch_thumb(spec: SwitchButtonSpec, progress: f32) -> (f32, f32) {
200 let progress = progress.clamp(0.0, 1.0);
201 let radius = spec.thumb_radius_unchecked
202 + (spec.thumb_radius_checked - spec.thumb_radius_unchecked) * progress;
203 let half = spec.switch_height * 0.5;
204 let start = radius + (half - spec.thumb_radius_unchecked);
205 let end = spec.switch_width - radius - (half - spec.thumb_radius_checked);
206 (start + (end - start) * progress, radius)
207}
208
209pub fn switch_tick_scale(progress: f32) -> f32 {
214 let remaining = 1.0 - progress.clamp(0.0, 1.0);
215 1.0 - remaining * remaining * remaining
216}
217
218pub fn draw_switch(scope: &mut dyn DrawScope, spec: SwitchButtonSpec, colors: SwitchColors) {
220 let size = scope.size();
221 if size.width <= 0.0 || size.height <= 0.0 {
222 return;
223 }
224 let radius = size.height * 0.5;
225 scope.draw_round_rect(Brush::Solid(colors.track), CornerRadii::uniform(radius));
226
227 if colors.track_border.3 > 0.0 {
228 let inset = spec.track_border_width * 0.5;
229 scope.draw_round_rect_at_stroked(
230 Rect {
231 x: inset,
232 y: inset,
233 width: size.width - spec.track_border_width,
234 height: size.height - spec.track_border_width,
235 },
236 Brush::Solid(colors.track_border),
237 CornerRadii::uniform(radius - inset),
238 cranpose_ui_graphics::Stroke::new(spec.track_border_width),
239 );
240 }
241
242 let (centre_x, thumb_radius) = switch_thumb(spec, spec.progress);
243 scope.draw_circle(
244 Brush::Solid(colors.thumb),
245 Point {
246 x: centre_x,
247 y: radius,
248 },
249 thumb_radius,
250 );
251
252 draw_tick(
253 scope,
254 Point {
255 x: centre_x,
256 y: radius,
257 },
258 switch_tick_scale(spec.progress),
259 colors.tick,
260 );
261}
262
263fn draw_tick(scope: &mut dyn DrawScope, centre: Point, scale: f32, color: Color) {
269 if scale <= 0.0 || color.3 <= 0.0 {
270 return;
271 }
272 const SEGMENTS: [((f32, f32), (f32, f32)); 2] = [
273 ((7.4 - 12.0, 13.0 - 12.0), (9.9 - 12.0, 15.5 - 12.0)),
274 ((10.5 - 12.0, 15.1 - 12.0), (16.5 - 12.0, 9.1 - 12.0)),
275 ];
276 const STROKE: f32 = 2.0;
277 let half = STROKE * scale * 0.5;
278 for (start, end) in SEGMENTS {
279 let a = Point {
280 x: centre.x + start.0 * scale,
281 y: centre.y + start.1 * scale,
282 };
283 let b = Point {
284 x: centre.x + end.0 * scale,
285 y: centre.y + end.1 * scale,
286 };
287 stroke_round_capped(scope, a, b, half, color);
288 }
289}
290
291fn stroke_round_capped(scope: &mut dyn DrawScope, a: Point, b: Point, half: f32, color: Color) {
298 if half <= 0.0 {
299 return;
300 }
301 let (dx, dy) = (b.x - a.x, b.y - a.y);
302 let length = (dx * dx + dy * dy).sqrt();
303 if length > f32::EPSILON {
304 let (nx, ny) = (-dy / length * half, dx / length * half);
305 let body = format!(
306 "M {} {} L {} {} L {} {} L {} {} Z",
307 a.x + nx,
308 a.y + ny,
309 b.x + nx,
310 b.y + ny,
311 b.x - nx,
312 b.y - ny,
313 a.x - nx,
314 a.y - ny
315 );
316 if let Ok(path) = VectorPath::parse(&body) {
317 scope.draw_vector_path(&path, Brush::Solid(color));
318 }
319 }
320 scope.draw_circle(Brush::Solid(color), a, half);
321 scope.draw_circle(Brush::Solid(color), b, half);
322}
323
324pub fn SwitchButton<F>(
331 modifier: Modifier,
332 spec: SwitchButtonSpec,
333 checked: bool,
334 label: String,
335 secondary_label: Option<String>,
336 on_checked_change: F,
337) -> NodeId
338where
339 F: Fn(bool) + 'static,
340{
341 SwitchButtonNode(modifier, spec, checked, label, secondary_label, move || {
342 on_checked_change(!checked);
343 })
344}
345
346#[composable]
348pub fn SwitchButtonNode<F>(
349 modifier: Modifier,
350 spec: SwitchButtonSpec,
351 checked: bool,
352 label: String,
353 secondary_label: Option<String>,
354 on_toggle: F,
355) -> NodeId
356where
357 F: FnMut() + 'static,
358{
359 let density = crate::density::density();
360 let colors = SwitchColors::of(spec.colors, checked);
361 let radius = density.dp(spec.corner_radius);
362 let container = colors.container;
363 let chrome = modifier
364 .draw_behind(move |scope: &mut dyn DrawScope| {
365 scope.draw_round_rect(Brush::Solid(container), CornerRadii::uniform(radius));
366 })
367 .toggleable(
368 checked,
369 Some(format!("{label}, {}", if checked { "on" } else { "off" })),
370 Some(SemanticsWidgetRole::Switch),
371 move |_next| on_toggle(),
372 );
373
374 let label_style = spec.label_style.resolve(colors.label);
375 let secondary_style = spec.secondary_style.resolve(colors.secondary_label);
376 let label_text = label;
377 Layout(
378 chrome,
379 SwitchButtonMeasurePolicy {
380 spec,
381 density: density.density(),
382 has_secondary: secondary_label.is_some(),
383 },
384 move || {
385 Text(label_text.clone(), Modifier::empty(), label_style.clone());
386 if let Some(secondary) = secondary_label.clone() {
387 Text(secondary, Modifier::empty(), secondary_style.clone());
388 }
389 SwitchGraphic(Modifier::empty(), spec, colors);
390 },
391 )
392}
393
394#[composable]
397pub fn SwitchGraphic(modifier: Modifier, spec: SwitchButtonSpec, colors: SwitchColors) -> NodeId {
398 crate::widgets::Canvas(
399 modifier.size_points(spec.switch_width, spec.switch_height),
400 move |scope: &mut dyn DrawScope| draw_switch(scope, spec, colors),
401 )
402}
403
404#[derive(Clone, Debug, PartialEq)]
405struct SwitchButtonMeasurePolicy {
406 spec: SwitchButtonSpec,
407 density: f32,
408 has_secondary: bool,
409}
410
411impl MeasurePolicy for SwitchButtonMeasurePolicy {
412 fn measure(
413 &self,
414 scope: &dyn MeasureScope,
415 measurables: &[Box<dyn Measurable>],
416 constraints: Constraints,
417 ) -> MeasureResult {
418 let mut placements = Vec::new();
419 let measurement = self.measure_into(scope, measurables, constraints, &mut placements);
420 MeasureResult::new(measurement, placements)
421 }
422
423 fn measure_into(
424 &self,
425 _scope: &dyn MeasureScope,
426 measurables: &[Box<dyn Measurable>],
427 constraints: Constraints,
428 placements: &mut Vec<Placement>,
429 ) -> cranpose_ui_layout::Measurement {
430 placements.clear();
431 let density = Density::new(self.density, 1.0);
432 let horizontal = density.dp(self.spec.padding_horizontal) * 2.0;
433 let vertical = density.dp(self.spec.padding_vertical) * 2.0;
434 let width = if constraints.max_width.is_finite() {
435 constraints.max_width
436 } else {
437 constraints.min_width
438 };
439
440 let switch_width = density.dp(self.spec.switch_width);
441 let spacing = density.dp(self.spec.control_spacing);
442 let label_width = (width - horizontal - switch_width - spacing).max(0.0);
443 let label_constraints = Constraints {
444 min_width: 0.0,
445 max_width: label_width,
446 min_height: 0.0,
447 max_height: f32::INFINITY,
448 };
449
450 let label_count = if self.has_secondary { 2 } else { 1 };
451 let mut labels = Vec::with_capacity(label_count);
452 for measurable in measurables.iter().take(label_count) {
453 labels.push(measurable.measure(label_constraints));
454 }
455 let switch = measurables.get(label_count).map(|measurable| {
456 measurable.measure(Constraints {
457 min_width: 0.0,
458 max_width: switch_width,
459 min_height: 0.0,
460 max_height: f32::INFINITY,
461 })
462 });
463
464 let label_spacing = density.dp(self.spec.label_spacing);
465 let column: f32 = labels
466 .iter()
467 .map(|placeable| density.ceil(placeable.height()))
468 .sum::<f32>()
469 + label_spacing * labels.len().saturating_sub(1) as f32;
470 let slot = density.dp(self.spec.switch_slot_height);
471 let content_demand = column.max(slot);
472 let height = density
473 .dp(self.spec.min_height)
474 .max(density.ceil(content_demand) + vertical)
475 .clamp(constraints.min_height, constraints.max_height);
476
477 let content = height - vertical;
478 let padding_top = density.dp(self.spec.padding_vertical);
479 let mut y = padding_top + density.centre(content, column);
480 let x = density.dp(self.spec.padding_horizontal);
481 for placeable in &labels {
482 placements.push(Placement::new(placeable.node_id(), x, y, 0));
483 y += density.ceil(placeable.height()) + label_spacing;
484 }
485
486 if let Some(switch) = switch {
487 let slot_top = padding_top + density.centre(content, slot);
488 let switch_x = width - density.dp(self.spec.padding_horizontal) - switch.width();
489 placements.push(Placement::new(switch.node_id(), switch_x, slot_top, 0));
490 }
491
492 Size::new(width, height).into()
493 }
494
495 fn min_intrinsic_width(&self, measurables: &[Box<dyn Measurable>], height: f32) -> f32 {
496 let density = Density::new(self.density, 1.0);
497 measurables
498 .iter()
499 .map(|m| m.min_intrinsic_width(height))
500 .fold(0.0, f32::max)
501 + density.dp(self.spec.padding_horizontal) * 2.0
502 + density.dp(self.spec.switch_width)
503 + density.dp(self.spec.control_spacing)
504 }
505
506 fn max_intrinsic_width(&self, measurables: &[Box<dyn Measurable>], height: f32) -> f32 {
507 self.min_intrinsic_width(measurables, height)
508 }
509
510 fn min_intrinsic_height(&self, measurables: &[Box<dyn Measurable>], width: f32) -> f32 {
511 let density = Density::new(self.density, 1.0);
512 let label_count = if self.has_secondary { 2 } else { 1 };
513 let column: f32 = measurables
514 .iter()
515 .take(label_count)
516 .map(|m| m.min_intrinsic_height(width))
517 .sum::<f32>();
518 density.dp(self.spec.min_height).max(
519 density.ceil(column.max(density.dp(self.spec.switch_slot_height)))
520 + density.dp(self.spec.padding_vertical) * 2.0,
521 )
522 }
523
524 fn max_intrinsic_height(&self, measurables: &[Box<dyn Measurable>], width: f32) -> f32 {
525 self.min_intrinsic_height(measurables, width)
526 }
527}
528
529#[cfg(test)]
530#[path = "tests/switch_button_tests.rs"]
531mod tests;