use alloc::string::String;
use denise::{ElementState, InputEvent, KeyCode, Rect, Role, Theme};
use denise_render::Canvas;
use denise_text::{TextEngine, TextStyle};
use crate::widget::{Animation, Event, EventCtx, Handled, PaintCtx, VisualState, Widget};
use crate::widgets::style::{Align, draw_aligned, focus_ring, interactive_pair};
const TRAVEL_MS: u64 = 120;
const FRAME_MS: u64 = 16;
const SCALE: i32 = 1000;
#[derive(Clone, Debug)]
pub struct Toggle<M> {
label: String,
checked: bool,
from: i32,
started_ms: u64,
message: Option<fn(bool) -> M>,
role: Role,
style: TextStyle,
}
impl<M> Toggle<M> {
pub fn new(label: impl Into<String>, message: fn(bool) -> M) -> Self {
Self {
label: label.into(),
checked: false,
from: 0,
started_ms: 0,
message: Some(message),
role: Role::Primary,
style: TextStyle::built_in(16),
}
}
pub fn inert(label: impl Into<String>) -> Self {
Self {
label: label.into(),
checked: false,
from: 0,
started_ms: 0,
message: None,
role: Role::Primary,
style: TextStyle::built_in(16),
}
}
pub fn with_checked(mut self, checked: bool) -> Self {
self.checked = checked;
self.from = Self::target(checked);
self
}
pub fn with_role(mut self, role: Role) -> Self {
self.role = role;
self
}
pub fn with_style(mut self, style: TextStyle) -> Self {
self.style = style;
self
}
pub fn with_size(mut self, size_px: u16) -> Self {
self.style.size_px = size_px;
self
}
#[inline]
pub const fn checked(&self) -> bool {
self.checked
}
pub fn set_checked(&mut self, checked: bool) {
self.checked = checked;
self.from = Self::target(checked);
self.started_ms = 0;
}
#[inline]
pub fn label(&self) -> &str {
&self.label
}
pub fn set_label(&mut self, label: impl Into<String>) {
self.label = label.into();
}
pub fn set_role(&mut self, role: Role) {
self.role = role;
}
pub fn set_style(&mut self, style: TextStyle) {
self.style = style;
}
pub fn preferred_width(&self, theme: &Theme, engine: &mut TextEngine) -> i32 {
let height = theme.metrics.size_selector;
let track = track_width(height);
if self.label.is_empty() {
track
} else {
track + gap(height) + engine.measure_line(self.style, &self.label)
}
}
#[inline]
const fn target(checked: bool) -> i32 {
if checked { SCALE } else { 0 }
}
fn position(&self, now_ms: u64) -> i32 {
let target = Self::target(self.checked);
if self.from == target {
return target;
}
let elapsed = now_ms.saturating_sub(self.started_ms);
if elapsed >= TRAVEL_MS {
return target;
}
let progress = (elapsed as i32) * SCALE / (TRAVEL_MS as i32);
self.from + (target - self.from) * progress / SCALE
}
#[inline]
fn moving(&self, now_ms: u64) -> bool {
self.from != Self::target(self.checked)
&& now_ms.saturating_sub(self.started_ms) < TRAVEL_MS
}
fn settle(&mut self) {
self.from = Self::target(self.checked);
}
}
#[inline]
const fn gap(height: i32) -> i32 {
if height < 2 { 1 } else { height / 2 }
}
#[inline]
const fn track_width(height: i32) -> i32 {
let width = height * 9 / 5;
if width < height + 2 {
height + 2
} else {
width
}
}
fn track_rect(bounds: Rect, theme: &Theme) -> Rect {
let mut height = theme.metrics.size_selector.min(bounds.height);
if height < 1 {
height = 1;
}
let mut width = track_width(height);
if width > bounds.width && bounds.width >= 1 {
width = bounds.width;
}
Rect::new(
bounds.x,
bounds.y + (bounds.height - height) / 2,
width,
height,
)
}
fn knob_rect(track: Rect, position: i32) -> Rect {
let inset = (track.height / 8).max(1);
let size = (track.height - inset * 2).max(1);
let travel = (track.width - inset * 2 - size).max(0);
let x = track.x + inset + travel * position.clamp(0, SCALE) / SCALE;
Rect::new(x, track.y + inset, size, size)
}
impl<M: 'static> Widget<M> for Toggle<M> {
fn paint(&self, ctx: &mut PaintCtx<'_>, canvas: &mut Canvas<'_>) {
let track = track_rect(ctx.bounds, ctx.theme);
let radius = track.height / 2;
let on_surface = interactive_pair(ctx.theme, Role::Base100, ctx.state).1;
let (track_color, knob_color) = if self.checked {
interactive_pair(ctx.theme, self.role, ctx.state)
} else {
interactive_pair(ctx.theme, Role::Base300, ctx.state)
};
canvas.fill_rounded_rect(track, radius, track_color);
let knob = knob_rect(track, self.position(ctx.now_ms));
canvas.fill_rounded_rect(knob, knob.height / 2, knob_color);
if ctx.state.contains(VisualState::FOCUSED) {
focus_ring(
ctx.theme,
ctx.bounds,
ctx.theme.radius(denise::Radius::Field),
canvas,
);
}
if self.label.is_empty() {
return;
}
let text = Rect::from_edges(
track.right() + gap(track.height),
ctx.bounds.y,
ctx.bounds.right(),
ctx.bounds.bottom(),
);
if !text.is_empty() {
draw_aligned(
canvas,
ctx.text,
self.style,
text,
(Align::Start, Align::Center),
&self.label,
on_surface,
);
}
}
fn on_event(&mut self, event: &Event<'_>, ctx: &mut EventCtx<'_, M>) -> Handled {
let toggled = match event {
Event::Input(InputEvent::PointerButton {
state: ElementState::Up,
position,
..
}) => ctx.bounds.contains(*position),
Event::Input(InputEvent::TouchUp {
position,
cancelled: false,
..
}) => ctx.bounds.contains(*position),
Event::Input(InputEvent::Key {
code: KeyCode::Space,
state: ElementState::Down,
repeat: false,
..
}) => ctx.state.contains(VisualState::FOCUSED),
_ => return Handled::No,
};
if !toggled {
return Handled::No;
}
self.from = self.position(ctx.now_ms);
self.started_ms = ctx.now_ms;
self.checked = !self.checked;
ctx.request_animation();
if let Some(message) = self.message {
ctx.emit(message(self.checked));
}
Handled::Yes
}
fn animate(&mut self, now_ms: u64) -> Animation {
if !self.moving(now_ms) {
self.settle();
return Animation::NONE;
}
Animation {
repaint: true,
next_ms: Some(now_ms + FRAME_MS),
}
}
fn accepts_pointer(&self) -> bool {
true
}
fn focusable(&self) -> bool {
true
}
}
#[cfg(test)]
mod tests {
use super::*;
use denise::theme;
#[test]
fn the_track_is_a_stadium_at_the_leading_edge() {
let bounds = Rect::new(10, 20, 300, 40);
let track = track_rect(bounds, &theme::DARK);
assert_eq!(track.height, theme::DARK.metrics.size_selector);
assert!(track.width > track.height, "a switch is not a circle");
assert_eq!(track.x, bounds.x);
assert_eq!(
track.y + track.height / 2,
bounds.y + bounds.height / 2,
"centred against the label beside it"
);
}
#[test]
fn degenerate_bounds_still_give_a_drawable_track_and_knob() {
for bounds in [
Rect::new(0, 0, 0, 0),
Rect::new(0, 0, 1, 40),
Rect::new(0, 0, 300, 1),
Rect::new(0, 0, 4, 4),
] {
let track = track_rect(bounds, &theme::DARK);
assert!(track.height >= 1, "{bounds:?} gave no track");
for position in [0, SCALE / 2, SCALE] {
let knob = knob_rect(track, position);
assert!(
knob.width >= 1 && knob.height >= 1,
"{bounds:?} @ {position}"
);
}
}
}
#[test]
fn the_knob_stays_inside_the_track_across_its_whole_travel() {
for metrics in [theme::Metrics::DEFAULT, theme::Metrics::TOUCH] {
let theme = theme::DARK.with_metrics(metrics);
let track = track_rect(Rect::new(0, 0, 300, 60), &theme);
let (mut previous, mut moved) = (i32::MIN, false);
for position in 0..=SCALE {
let knob = knob_rect(track, position);
assert!(
track.contains_rect(&knob),
"{metrics:?} @ {position}: {knob:?} escaped {track:?}"
);
if knob.x > previous && previous != i32::MIN {
moved = true;
}
assert!(knob.x >= previous, "the knob went backwards");
previous = knob.x;
}
assert!(moved, "the knob never moved at all");
}
}
#[test]
fn a_position_outside_the_range_is_clamped() {
let track = track_rect(Rect::new(0, 0, 300, 40), &theme::DARK);
assert_eq!(knob_rect(track, -5000), knob_rect(track, 0));
assert_eq!(knob_rect(track, SCALE * 3), knob_rect(track, SCALE));
}
#[test]
fn the_knob_crosses_over_the_travel_time_and_then_stops() {
let mut toggle: Toggle<bool> = Toggle::new("Mute", |on| on);
toggle.checked = true;
toggle.from = 0;
toggle.started_ms = 1_000;
assert_eq!(toggle.position(1_000), 0);
let middle = toggle.position(1_000 + TRAVEL_MS / 2);
assert!(
middle > 0 && middle < SCALE,
"{middle} is not between the ends"
);
assert_eq!(toggle.position(1_000 + TRAVEL_MS), SCALE);
assert_eq!(
toggle.position(9_999_999),
SCALE,
"and stays there rather than overshooting"
);
assert!(!toggle.moving(1_000 + TRAVEL_MS));
}
#[test]
fn a_clock_that_goes_backwards_does_not_underflow() {
let mut toggle: Toggle<bool> = Toggle::new("Mute", |on| on);
toggle.checked = true;
toggle.from = 0;
toggle.started_ms = 5_000;
assert_eq!(toggle.position(1), 0, "before the start is the start");
}
#[test]
fn flipping_back_halfway_reverses_from_where_it_got_to() {
let mut toggle: Toggle<bool> = Toggle::new("Mute", |on| on);
toggle.checked = true;
toggle.from = 0;
toggle.started_ms = 0;
let halfway = toggle.position(TRAVEL_MS / 2);
toggle.from = halfway;
toggle.started_ms = TRAVEL_MS / 2;
toggle.checked = false;
assert_eq!(toggle.position(TRAVEL_MS / 2), halfway, "no jump");
assert_eq!(toggle.position(TRAVEL_MS / 2 + TRAVEL_MS), 0);
}
#[test]
fn setting_the_value_programmatically_is_silent_and_lands_immediately() {
let mut toggle: Toggle<bool> = Toggle::new("Mute", |on| on);
toggle.set_checked(true);
assert!(toggle.checked());
assert_eq!(toggle.position(0), SCALE);
assert!(!toggle.moving(0));
}
#[test]
fn the_knob_is_visible_against_its_track_in_every_theme_and_state() {
use denise::theme::{AA_LARGE, contrast_x100};
for theme in Theme::BUILT_IN {
for state in [
VisualState::NONE,
VisualState::HOVERED,
VisualState::PRESSED,
VisualState::DISABLED,
VisualState::FOCUSED,
] {
for (name, role) in [("off", Role::Base300), ("on", Role::Primary)] {
let (track, knob) = interactive_pair(&theme, role, state);
let ratio = contrast_x100(track, knob);
assert!(
ratio >= AA_LARGE,
"{} {name} {state:?}: knob against track is {ratio}, \
floor is {AA_LARGE}",
theme.name
);
}
}
}
}
}