1use alloc::string::String;
4
5use denise::Pen;
6use denise::{ElementState, InputEvent, KeyCode, Rect, Role, Theme};
7use denise_text::{TextEngine, TextStyle};
8
9use crate::motion::Wake;
10use crate::widget::{
11 Animation, Event, EventCtx, Handled, MeasureCtx, Measured, Offer, PaintCtx, VisualState, Widget,
12};
13use crate::widgets::describe::{
14 Describe, DynDescribe, Group, Mismatch, Payload, Property, PropertyKind, ROLES, Value,
15};
16use crate::widgets::style::{Align, draw_aligned, focus_ring, interactive_pair};
17
18const TRAVEL_MS: u64 = 120;
26
27const SCALE: i32 = 1000;
31
32#[derive(Clone, Debug)]
56pub struct Toggle<M> {
57 label: String,
58 checked: bool,
59 from: i32,
61 started_ms: u64,
63 message: Option<fn(bool) -> M>,
64 role: Role,
65 style: TextStyle,
66}
67
68impl<M> Toggle<M> {
69 pub fn new(label: impl Into<String>, message: fn(bool) -> M) -> Self {
71 Self {
72 label: label.into(),
73 checked: false,
74 from: 0,
75 started_ms: 0,
76 message: Some(message),
77 role: Role::Primary,
78 style: TextStyle::built_in(16),
79 }
80 }
81
82 pub fn inert(label: impl Into<String>) -> Self {
85 Self {
86 label: label.into(),
87 checked: false,
88 from: 0,
89 started_ms: 0,
90 message: None,
91 role: Role::Primary,
92 style: TextStyle::built_in(16),
93 }
94 }
95
96 pub fn with_checked(mut self, checked: bool) -> Self {
98 self.checked = checked;
99 self.from = Self::target(checked);
100 self
101 }
102
103 pub fn with_role(mut self, role: Role) -> Self {
105 self.role = role;
106 self
107 }
108
109 pub fn with_style(mut self, style: TextStyle) -> Self {
111 self.style = style;
112 self
113 }
114
115 pub fn with_size(mut self, size_px: u16) -> Self {
117 self.style.size_px = size_px;
118 self
119 }
120
121 #[inline]
123 pub const fn checked(&self) -> bool {
124 self.checked
125 }
126
127 pub fn set_checked(&mut self, checked: bool) {
141 self.checked = checked;
142 self.from = Self::target(checked);
143 self.started_ms = 0;
144 }
145
146 #[inline]
148 pub fn label(&self) -> &str {
149 &self.label
150 }
151
152 pub fn set_label(&mut self, label: impl Into<String>) {
154 self.label = label.into();
155 }
156
157 pub fn set_role(&mut self, role: Role) {
159 self.role = role;
160 }
161
162 pub fn set_style(&mut self, style: TextStyle) {
164 self.style = style;
165 }
166
167 pub fn preferred_width(&self, theme: &Theme, engine: &mut TextEngine) -> i32 {
169 let height = theme.metrics.size_selector;
170 let track = track_width(height);
171 if self.label.is_empty() {
172 track
173 } else {
174 track + gap(height) + engine.measure_line(self.style, &self.label)
175 }
176 }
177
178 #[inline]
179 const fn target(checked: bool) -> i32 {
180 if checked { SCALE } else { 0 }
181 }
182
183 fn position(&self, now_ms: u64) -> i32 {
189 let target = Self::target(self.checked);
190 if self.from == target {
191 return target;
192 }
193 let elapsed = now_ms.saturating_sub(self.started_ms);
194 if elapsed >= TRAVEL_MS {
195 return target;
196 }
197 let progress = (elapsed as i32) * SCALE / (TRAVEL_MS as i32);
198 self.from + (target - self.from) * progress / SCALE
199 }
200
201 #[inline]
203 fn moving(&self, now_ms: u64) -> bool {
204 self.from != Self::target(self.checked)
205 && now_ms.saturating_sub(self.started_ms) < TRAVEL_MS
206 }
207
208 fn settle(&mut self) {
213 self.from = Self::target(self.checked);
214 }
215}
216
217#[inline]
219const fn gap(height: i32) -> i32 {
220 if height < 2 { 1 } else { height / 2 }
221}
222
223#[inline]
225const fn track_width(height: i32) -> i32 {
226 let width = height * 9 / 5;
227 if width < height + 2 {
228 height + 2
229 } else {
230 width
231 }
232}
233
234fn track_rect(bounds: Rect, theme: &Theme) -> Rect {
236 let mut height = theme.metrics.size_selector.min(bounds.height);
237 if height < 1 {
238 height = 1;
239 }
240 let mut width = track_width(height);
241 if width > bounds.width && bounds.width >= 1 {
242 width = bounds.width;
243 }
244 Rect::new(
245 bounds.x,
246 bounds.y + (bounds.height - height) / 2,
247 width,
248 height,
249 )
250}
251
252fn knob_rect(track: Rect, position: i32) -> Rect {
254 let inset = (track.height / 8).max(1);
257 let size = (track.height - inset * 2).max(1);
258 let travel = (track.width - inset * 2 - size).max(0);
259 let x = track.x + inset + travel * position.clamp(0, SCALE) / SCALE;
260 Rect::new(x, track.y + inset, size, size)
261}
262
263impl<M: 'static> Widget<M> for Toggle<M> {
264 fn describe(&self) -> Option<&dyn DynDescribe> {
265 Some(self)
266 }
267
268 fn describe_mut(&mut self) -> Option<&mut dyn DynDescribe> {
269 Some(self)
270 }
271 fn measure(&self, ctx: &mut MeasureCtx<'_>, _offered: Offer) -> Measured {
272 Measured::both(
273 self.preferred_width(ctx.theme, ctx.text),
274 ctx.theme.metrics.size_selector.max(1),
275 )
276 }
277
278 fn paint(&self, ctx: &mut PaintCtx<'_>, canvas: &mut Pen<'_>) {
279 let track = track_rect(ctx.bounds, ctx.theme);
280 let radius = track.height / 2;
283
284 let on_surface = interactive_pair(ctx.theme, Role::Base100, ctx.state).1;
287
288 let (track_color, knob_color) = if self.checked {
299 interactive_pair(ctx.theme, self.role, ctx.state)
300 } else {
301 interactive_pair(ctx.theme, Role::Base300, ctx.state)
302 };
303 canvas.fill_rounded_rect(track, radius, track_color);
304
305 let knob = knob_rect(track, self.position(ctx.now_ms));
306 canvas.fill_rounded_rect(knob, knob.height / 2, knob_color);
307
308 if ctx.state.contains(VisualState::FOCUSED) {
309 focus_ring(
310 ctx.theme,
311 ctx.bounds,
312 ctx.theme.radius(denise::Radius::Field),
313 canvas,
314 );
315 }
316
317 if self.label.is_empty() {
318 return;
319 }
320 let text = Rect::from_edges(
321 track.right() + gap(track.height),
322 ctx.bounds.y,
323 ctx.bounds.right(),
324 ctx.bounds.bottom(),
325 );
326 if !text.is_empty() {
327 draw_aligned(
328 canvas,
329 ctx.text,
330 self.style,
331 text,
332 (Align::Start, Align::Center),
333 &self.label,
334 on_surface,
335 );
336 }
337 }
338
339 fn on_event(&mut self, event: &Event<'_>, ctx: &mut EventCtx<'_, M>) -> Handled {
340 let toggled = match event {
341 Event::Input(InputEvent::PointerButton {
342 state: ElementState::Up,
343 position,
344 ..
345 }) => ctx.bounds.contains(*position),
346 Event::Input(InputEvent::TouchUp {
347 position,
348 cancelled: false,
349 ..
350 }) => ctx.bounds.contains(*position),
351 Event::Input(InputEvent::Key {
354 code: KeyCode::Space,
355 state: ElementState::Down,
356 repeat: false,
357 ..
358 }) => ctx.state.contains(VisualState::FOCUSED),
359 _ => return Handled::No,
360 };
361 if !toggled {
362 return Handled::No;
363 }
364
365 self.from = self.position(ctx.now_ms);
368 self.started_ms = ctx.now_ms;
369 self.checked = !self.checked;
370 ctx.request_animation();
373 if let Some(message) = self.message {
374 ctx.emit(message(self.checked));
375 }
376 Handled::Yes
377 }
378
379 fn animate(&mut self, now_ms: u64) -> Animation {
380 if !self.moving(now_ms) {
381 let arriving = self.from != Self::target(self.checked);
399 self.settle();
400 return Animation {
401 repaint: arriving,
402 next: Wake::Never,
403 };
404 }
405 Animation::MOVING
409 }
410
411 fn snap(&mut self, now_ms: u64) -> Animation {
415 let moving = self.moving(now_ms);
416 self.settle();
417 Animation {
418 repaint: moving,
419 next: Wake::Never,
420 }
421 }
422
423 fn accepts_pointer(&self) -> bool {
424 true
425 }
426
427 fn focusable(&self) -> bool {
428 true
429 }
430}
431
432impl<M> Describe for Toggle<M> {
433 const KIND: &'static str = "toggle";
434 const DOC: &'static str = "The same on-or-off as a checkbox, shaped like a switch.";
435 const GROUP: Group = Group::Input;
436 const ICON: &'static denise::icon::Icon = &super::icons::TOGGLE;
437
438 const PROPERTIES: &'static [Property] = &[
439 Property::new("text", PropertyKind::Text, "The label beside the track."),
440 Property::new("checked", PropertyKind::Bool, "Whether the switch is on."),
441 Property::new(
442 "on-change",
443 PropertyKind::Message(Payload::Bool),
444 "The message built from the value the switch changes to. Omitted, the toggle is inert.",
445 ),
446 Property::new(
447 "role",
448 PropertyKind::Enum(ROLES),
449 "Colour role of the track when on. The knob comes from the theme's pairing, so it stays visible against it.",
450 ),
451 Property::new(
452 "size",
453 PropertyKind::Int { min: 6, max: 96 },
454 "Label text size in logical pixels. The track itself is a theme metric.",
455 )
456 .in_pixels(),
457 ];
458
459 fn get(&self, name: &str) -> Option<Value> {
460 Some(match name {
461 "text" => Value::text(self.label.as_str()),
462 "checked" => Value::Bool(self.checked),
463 "on-change" => return None,
466 "role" => Value::role(self.role),
467 "size" => Value::Int(i32::from(self.style.size_px)),
468 _ => return None,
469 })
470 }
471
472 fn apply(&mut self, name: &str, value: Value) -> Result<(), Mismatch> {
473 match name {
474 "text" => self.label = value.as_text()?,
475 "checked" => self.set_checked(value.as_bool()?),
480 "on-change" => return Err(Mismatch::Supplied),
481 "role" => self.role = value.as_role()?,
482 "size" => self.style.size_px = value.as_size()?,
483 _ => return Err(Mismatch::Unknown),
484 }
485 Ok(())
486 }
487}
488
489#[cfg(test)]
490mod tests {
491 use super::*;
492 use denise::theme;
493
494 #[test]
497 fn the_track_is_a_stadium_at_the_leading_edge() {
498 let bounds = Rect::new(10, 20, 300, 40);
499 let track = track_rect(bounds, &theme::DARK);
500
501 assert_eq!(track.height, theme::DARK.metrics.size_selector);
502 assert!(track.width > track.height, "a switch is not a circle");
503 assert_eq!(track.x, bounds.x);
504 assert_eq!(
505 track.y + track.height / 2,
506 bounds.y + bounds.height / 2,
507 "centred against the label beside it"
508 );
509 }
510
511 #[test]
514 fn degenerate_bounds_still_give_a_drawable_track_and_knob() {
515 for bounds in [
516 Rect::new(0, 0, 0, 0),
517 Rect::new(0, 0, 1, 40),
518 Rect::new(0, 0, 300, 1),
519 Rect::new(0, 0, 4, 4),
520 ] {
521 let track = track_rect(bounds, &theme::DARK);
522 assert!(track.height >= 1, "{bounds:?} gave no track");
523 for position in [0, SCALE / 2, SCALE] {
524 let knob = knob_rect(track, position);
525 assert!(
526 knob.width >= 1 && knob.height >= 1,
527 "{bounds:?} @ {position}"
528 );
529 }
530 }
531 }
532
533 #[test]
536 fn the_knob_stays_inside_the_track_across_its_whole_travel() {
537 for metrics in [theme::Metrics::DEFAULT, theme::Metrics::TOUCH] {
538 let theme = theme::DARK.with_metrics(metrics);
539 let track = track_rect(Rect::new(0, 0, 300, 60), &theme);
540 let (mut previous, mut moved) = (i32::MIN, false);
541
542 for position in 0..=SCALE {
543 let knob = knob_rect(track, position);
544 assert!(
545 track.contains_rect(&knob),
546 "{metrics:?} @ {position}: {knob:?} escaped {track:?}"
547 );
548 if knob.x > previous && previous != i32::MIN {
549 moved = true;
550 }
551 assert!(knob.x >= previous, "the knob went backwards");
552 previous = knob.x;
553 }
554 assert!(moved, "the knob never moved at all");
555 }
556 }
557
558 #[test]
562 fn a_position_outside_the_range_is_clamped() {
563 let track = track_rect(Rect::new(0, 0, 300, 40), &theme::DARK);
564 assert_eq!(knob_rect(track, -5000), knob_rect(track, 0));
565 assert_eq!(knob_rect(track, SCALE * 3), knob_rect(track, SCALE));
566 }
567
568 #[test]
571 fn the_knob_crosses_over_the_travel_time_and_then_stops() {
572 let mut toggle: Toggle<bool> = Toggle::new("Mute", |on| on);
573 toggle.checked = true;
574 toggle.from = 0;
575 toggle.started_ms = 1_000;
576
577 assert_eq!(toggle.position(1_000), 0);
578 let middle = toggle.position(1_000 + TRAVEL_MS / 2);
579 assert!(
580 middle > 0 && middle < SCALE,
581 "{middle} is not between the ends"
582 );
583 assert_eq!(toggle.position(1_000 + TRAVEL_MS), SCALE);
584 assert_eq!(
585 toggle.position(9_999_999),
586 SCALE,
587 "and stays there rather than overshooting"
588 );
589 assert!(!toggle.moving(1_000 + TRAVEL_MS));
590 }
591
592 #[test]
603 fn the_frame_that_lands_the_crossing_repaints() {
604 let mut toggle: Toggle<bool> = Toggle::new("Mute", |on| on);
605 toggle.checked = true;
606 toggle.from = 0;
607 toggle.started_ms = 1_000;
608
609 let last_moving = toggle.animate(1_000 + TRAVEL_MS - 1);
610 assert!(last_moving.repaint, "still crossing");
611 assert!(
612 toggle.position(1_000 + TRAVEL_MS - 1) < SCALE,
613 "and short of the end, which is what makes the next frame matter"
614 );
615
616 let landing = toggle.animate(1_000 + TRAVEL_MS);
617 assert!(
618 landing.repaint,
619 "the knob reached the end on this frame; somebody has to draw it there"
620 );
621 assert_eq!(landing.next, Wake::Never, "and then stop asking");
622 }
623
624 #[test]
628 fn a_settled_toggle_asks_for_nothing() {
629 let mut toggle: Toggle<bool> = Toggle::new("Mute", |on| on);
630 toggle.checked = true;
631 toggle.from = 0;
632 toggle.started_ms = 1_000;
633 toggle.animate(1_000 + TRAVEL_MS);
634
635 for now in [1_000 + TRAVEL_MS, 2_000, 9_999_999] {
636 let idle = toggle.animate(now);
637 assert!(!idle.repaint, "nothing changed at {now} ms");
638 assert_eq!(idle.next, Wake::Never);
639 }
640 }
641
642 #[test]
645 fn a_clock_that_goes_backwards_does_not_underflow() {
646 let mut toggle: Toggle<bool> = Toggle::new("Mute", |on| on);
647 toggle.checked = true;
648 toggle.from = 0;
649 toggle.started_ms = 5_000;
650 assert_eq!(toggle.position(1), 0, "before the start is the start");
651 }
652
653 #[test]
656 fn flipping_back_halfway_reverses_from_where_it_got_to() {
657 let mut toggle: Toggle<bool> = Toggle::new("Mute", |on| on);
658 toggle.checked = true;
659 toggle.from = 0;
660 toggle.started_ms = 0;
661
662 let halfway = toggle.position(TRAVEL_MS / 2);
663 toggle.from = halfway;
665 toggle.started_ms = TRAVEL_MS / 2;
666 toggle.checked = false;
667
668 assert_eq!(toggle.position(TRAVEL_MS / 2), halfway, "no jump");
669 assert_eq!(toggle.position(TRAVEL_MS / 2 + TRAVEL_MS), 0);
670 }
671
672 #[test]
675 fn setting_the_value_programmatically_is_silent_and_lands_immediately() {
676 let mut toggle: Toggle<bool> = Toggle::new("Mute", |on| on);
677 toggle.set_checked(true);
678 assert!(toggle.checked());
679 assert_eq!(toggle.position(0), SCALE);
680 assert!(!toggle.moving(0));
681 }
682
683 #[test]
692 fn the_knob_is_visible_against_its_track_in_every_theme_and_state() {
693 use denise::theme::{AA_LARGE, contrast_x100};
694
695 for theme in Theme::BUILT_IN {
696 for state in [
697 VisualState::NONE,
698 VisualState::HOVERED,
699 VisualState::PRESSED,
700 VisualState::DISABLED,
701 VisualState::FOCUSED,
702 ] {
703 for (name, role) in [("off", Role::Base300), ("on", Role::Primary)] {
704 let (track, knob) = interactive_pair(&theme, role, state);
705 let ratio = contrast_x100(track, knob);
706 assert!(
707 ratio >= AA_LARGE,
708 "{} {name} {state:?}: knob against track is {ratio}, \
709 floor is {AA_LARGE}",
710 theme.name
711 );
712 }
713 }
714 }
715 }
716}