use frus_core::{
BorderSide, Color, Path, Point, Rect, ResolvedTextStyle, Scene, Stroke, TextStyle,
};
use frus_layout::{Dimension, Style};
use crate::disabled::{disabled_content, disabled_mark};
use crate::interaction::Status;
use crate::theme::{TapTarget, Theme};
use crate::widget::Widget;
use crate::widgetstate::{WidgetState, WidgetStateProperty, WidgetStates};
const EDGE: f32 = 18.0;
const STROKE: f32 = 2.0;
const RADIUS: f32 = 2.0;
const TOGGLE_SECONDS: f32 = 0.2;
pub struct Checkbox<Msg = crate::callback::Callback> {
value: Option<bool>,
tristate: bool,
label: Option<String>,
size: f32,
enabled: bool,
error: bool,
semantic_label: Option<String>,
fill_color: Option<Color>,
check_color: Option<Color>,
border_color: Option<Color>,
active_border_color: Option<Color>,
overlay_color: Option<WidgetStateProperty<Color>>,
splash_radius: Option<f32>,
tap_target: Option<TapTarget>,
visual_density: Option<crate::VisualDensity>,
radius: Option<f32>,
label_color: Option<Color>,
on_toggle: Option<Box<dyn Fn(bool) -> Msg>>,
on_change: Option<Box<dyn Fn(Option<bool>) -> Msg>>,
}
impl<Msg> Checkbox<Msg> {
pub fn new(checked: bool) -> Self {
Self {
value: Some(checked),
tristate: false,
label: None,
size: 16.0,
enabled: true,
error: false,
semantic_label: None,
fill_color: None,
check_color: None,
border_color: None,
active_border_color: None,
overlay_color: None,
splash_radius: None,
tap_target: None,
visual_density: None,
radius: None,
label_color: None,
on_toggle: None,
on_change: None,
}
}
pub fn maybe(value: Option<bool>) -> Self {
let mut checkbox = Self::new(false);
checkbox.value = value;
checkbox.tristate = true;
checkbox
}
pub fn fill_color(mut self, color: Color) -> Self {
self.fill_color = Some(color);
self
}
pub fn check_color(mut self, color: Color) -> Self {
self.check_color = Some(color);
self
}
pub fn border_color(mut self, color: Color) -> Self {
self.border_color = Some(color);
self
}
pub fn active_border_color(mut self, color: Color) -> Self {
self.active_border_color = Some(color);
self
}
#[must_use]
pub fn overlay_color(mut self, overlay: WidgetStateProperty<Color>) -> Self {
self.overlay_color = Some(overlay);
self
}
#[must_use]
pub fn splash_radius(mut self, radius: f32) -> Self {
self.splash_radius = Some(radius);
self
}
#[must_use]
pub fn visual_density(mut self, density: crate::VisualDensity) -> Self {
self.visual_density = Some(density);
self
}
pub fn radius(mut self, radius: f32) -> Self {
self.radius = Some(radius);
self
}
pub fn label_color(mut self, color: Color) -> Self {
self.label_color = Some(color);
self
}
#[must_use]
pub fn error(mut self, error: bool) -> Self {
self.error = error;
self
}
#[must_use]
pub fn semantic_label(mut self, label: impl Into<String>) -> Self {
self.semantic_label = Some(label.into());
self
}
fn corner(&self, theme: &Theme) -> f32 {
self.radius
.or(theme.widgets.checkbox.radius)
.unwrap_or(RADIUS)
}
pub fn label(mut self, label: impl Into<String>) -> Self {
self.label = Some(label.into());
self
}
pub fn enabled(mut self, enabled: bool) -> Self {
self.enabled = enabled;
self
}
pub fn on_toggle<R: crate::callback::IntoMsg<Msg>>(
mut self,
on_toggle: impl Fn(bool) -> R + 'static,
) -> Self {
self.on_toggle = Some(Box::new(crate::callback::handler1(on_toggle)));
self
}
pub fn on_change<R: crate::callback::IntoMsg<Msg>>(
mut self,
on_change: impl Fn(Option<bool>) -> R + 'static,
) -> Self {
self.on_change = Some(Box::new(crate::callback::handler1(on_change)));
self
}
fn next(&self) -> Option<bool> {
if self.tristate {
match self.value {
Some(false) => Some(true),
Some(true) => None,
None => Some(false),
}
} else {
Some(self.value != Some(true))
}
}
pub fn tap_target(mut self, target: TapTarget) -> Self {
self.tap_target = Some(target);
self
}
fn label_style(&self) -> ResolvedTextStyle {
TextStyle::new(self.size).resolved()
}
fn square(&self, theme: &Theme) -> f32 {
let target = self
.tap_target
.or(theme.widgets.checkbox.tap_target)
.unwrap_or(theme.tap_target);
let side = match target {
TapTarget::Padded => crate::MIN_TAP_TARGET,
TapTarget::ShrinkWrap => crate::MIN_TAP_TARGET - 8.0,
};
let density = self
.visual_density
.or(theme.widgets.checkbox.visual_density)
.unwrap_or(crate::VisualDensity::STANDARD);
(side + density.base_size_adjustment().1).max(EDGE)
}
fn label_width(&self) -> f32 {
match &self.label {
Some(text) => frus_text::measure(text, self.size).width,
None => 0.0,
}
}
fn states(&self, status: &Status) -> WidgetStates {
let states = if self.enabled {
status.states()
} else {
WidgetStates::of(WidgetState::Disabled)
};
states
.set(WidgetState::Selected, self.value != Some(false))
.set(WidgetState::Error, self.error)
}
fn fill(&self, theme: &Theme, states: WidgetStates) -> Color {
let t = &theme.widgets.checkbox;
if states.contains(WidgetState::Disabled) {
return if states.contains(WidgetState::Selected) {
disabled_content(theme)
} else {
Color::TRANSPARENT
};
}
if !states.contains(WidgetState::Selected) {
return Color::TRANSPARENT;
}
if states.contains(WidgetState::Error) {
return theme.scheme.error;
}
self.fill_color
.or(t.fill_color)
.unwrap_or(theme.scheme.primary)
}
fn check(&self, theme: &Theme, states: WidgetStates) -> Color {
if states.contains(WidgetState::Disabled) {
return disabled_mark(theme);
}
if states.contains(WidgetState::Error) {
return theme.scheme.on_error;
}
self.check_color
.or(theme.widgets.checkbox.check_color)
.unwrap_or(theme.scheme.on_primary)
}
fn side(&self, theme: &Theme, states: WidgetStates) -> BorderSide {
let t = &theme.widgets.checkbox;
if states.contains(WidgetState::Selected) {
return BorderSide::new(Color::TRANSPARENT, 0.0);
}
let color = if states.contains(WidgetState::Disabled) {
disabled_content(theme)
} else if states.contains(WidgetState::Error) {
theme.scheme.error
} else {
let resting = self.border_color.or(t.border_color);
let active = states.contains(WidgetState::Pressed)
|| states.contains(WidgetState::Hovered)
|| states.contains(WidgetState::Focused);
if active {
self.active_border_color
.or(t.active_border_color)
.or(resting)
.unwrap_or(theme.scheme.on_surface)
} else {
resting.unwrap_or(theme.scheme.on_surface_variant)
}
};
BorderSide::new(color, STROKE)
}
fn overlay(&self, theme: &Theme, states: WidgetStates) -> Color {
if let Some(c) = self
.overlay_color
.as_ref()
.and_then(|p| p.resolve(states))
.or_else(|| {
theme
.widgets
.checkbox
.overlay_color
.as_ref()
.and_then(|p| p.resolve(states))
})
{
return *c;
}
if states.contains(WidgetState::Error) {
let c = theme.scheme.error;
if states.contains(WidgetState::Pressed) {
return c.with_alpha(0.1);
}
if states.contains(WidgetState::Hovered) {
return c.with_alpha(0.08);
}
if states.contains(WidgetState::Focused) {
return c.with_alpha(0.1);
}
}
crate::toggleable::overlay(theme, states)
}
}
fn outer_at(origin: Point, t: f32) -> Rect {
let inset = 1.0 - (t - 0.5).abs() * 2.0;
let size = EDGE - inset * STROKE;
Rect::new(origin.x + inset, origin.y + inset, size, size)
}
fn check_path(origin: Point, t: f32) -> Path {
let at = |x: f32, y: f32| Point::new(origin.x + EDGE * x, origin.y + EDGE * y);
let (start, mid, end) = (at(0.15, 0.45), at(0.4, 0.7), at(0.85, 0.25));
let lerp =
|a: Point, b: Point, k: f32| Point::new(a.x + (b.x - a.x) * k, a.y + (b.y - a.y) * k);
if t < 0.5 {
Path::new()
.move_to(start)
.line_to(lerp(start, mid, t * 2.0))
} else {
Path::new()
.move_to(start)
.line_to(mid)
.line_to(lerp(mid, end, (t - 0.5) * 2.0))
}
}
fn dash_path(origin: Point, t: f32) -> Path {
let y = origin.y + EDGE * 0.5;
let half = EDGE * 0.3 * t;
let mid = origin.x + EDGE * 0.5;
Path::new()
.move_to(Point::new(mid - half, y))
.line_to(Point::new(mid + half, y))
}
impl<Msg> Widget<Msg> for Checkbox<Msg> {
fn style(&self) -> Style {
Widget::<Msg>::style_themed(self, &Theme::default())
}
fn style_themed(&self, theme: &Theme) -> Style {
let square = self.square(theme);
let line = self.label_style().line_height();
Style {
width: Dimension::Length((square + self.label_width()).ceil()),
height: Dimension::Length(line.max(square).ceil()),
..Default::default()
}
}
fn children(&self) -> &[Box<dyn Widget<Msg>>] {
&[]
}
fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
let o = status.opacity;
let square = self.square(theme);
let centre = Point::new(bounds.x + square * 0.5, bounds.y + bounds.height * 0.5);
let states = self.states(&status);
if self.enabled {
let radius = self
.splash_radius
.or(theme.widgets.checkbox.splash_radius)
.unwrap_or(crate::toggleable::SPLASH);
crate::toggleable::paint_halos(scene, centre, radius, &status, |state| {
self.overlay(theme, states.set(state, true))
});
}
let t = status.value.clamp(0.0, 1.0);
let origin = Point::new(centre.x - EDGE * 0.5, centre.y - EDGE * 0.5);
let off = states.set(WidgetState::Selected, false);
let on = states.set(WidgetState::Selected, true);
let fill = if t >= 0.25 {
self.fill(theme, on)
} else {
self.fill(theme, off).lerp(self.fill(theme, on), t * 4.0)
};
let outer = outer_at(origin, t);
let corner = self.corner(theme);
let side = if t <= 0.5 {
let (a, b) = (self.side(theme, off), self.side(theme, on));
BorderSide::new(a.color.lerp(b.color, t), a.width + (b.width - a.width) * t)
} else {
self.side(theme, on)
};
scene.draw_rect(outer, fill.fade(o), corner, side.width, side.color.fade(o));
if t > 0.5 {
let k = (t - 0.5) * 2.0;
let path = match self.value {
None => dash_path(origin, k),
_ => check_path(origin, k),
};
scene.paint_path(
&path,
None,
Some(Stroke::new(self.check(theme, on).fade(o), STROKE)),
);
}
if let Some(label) = &self.label {
let color = if self.enabled {
self.label_color
.or(theme.widgets.checkbox.label_color)
.unwrap_or(theme.on_surface)
} else {
disabled_content(theme)
};
let style = self.label_style();
scene.text(
Point::new(
bounds.x + square,
bounds.y + (bounds.height - style.line_height()) * 0.5,
),
label.clone(),
&style,
color.fade(o),
);
}
}
fn on_click(&self) -> Option<Msg> {
if !self.enabled {
return None;
}
let next = self.next();
if let Some(make) = self.on_change.as_ref() {
return Some(make(next));
}
self.on_toggle
.as_ref()
.map(|make| make(next.unwrap_or(true)))
}
fn focusable(&self) -> bool {
self.enabled
}
fn anim_target(&self) -> Option<f32> {
Some(if self.value == Some(false) { 0.0 } else { 1.0 })
}
fn anim_duration(&self) -> f32 {
TOGGLE_SECONDS
}
fn semantics(&self) -> Option<frus_core::SemanticsProperties> {
let mut s = frus_core::SemanticsProperties::new(frus_core::Role::CheckBox)
.maybe_toggled(self.value);
s = if self.enabled {
s.clickable()
} else {
s.disabled(true)
};
if let Some(label) = self.label.as_ref().or(self.semantic_label.as_ref()) {
s = s.label(label.clone());
}
Some(s)
}
}
#[cfg(test)]
mod tests {
use super::*;
use frus_core::{PathVerb, Primitive};
#[derive(Clone, Debug, PartialEq)]
enum Msg {
Set(bool),
Maybe(Option<bool>),
}
fn settled(value: bool) -> Status {
Status {
opacity: 1.0,
value: if value { 1.0 } else { 0.0 },
..Default::default()
}
}
fn painted(c: &Checkbox<Msg>, status: Status, theme: &Theme) -> Vec<Primitive> {
let side = c.square(theme);
let mut scene = Scene::new();
Widget::<Msg>::paint(
c,
Rect::new(0.0, 0.0, side, side),
status,
theme,
&mut scene,
);
scene.primitives().to_vec()
}
fn the_box(prims: &[Primitive]) -> (Rect, Color, f32, Color, f32) {
prims
.iter()
.rev()
.find_map(|p| match p {
Primitive::Rect {
rect,
color,
border_width,
border_color,
radius,
..
} => Some((*rect, *color, *border_width, *border_color, radius.top_left)),
_ => None,
})
.expect("a box")
}
fn mark(prims: &[Primitive]) -> Option<(Vec<Point>, Color)> {
prims.iter().find_map(|p| match p {
Primitive::Path {
path,
stroke: Some(s),
..
} => Some((
path.verbs()
.iter()
.filter_map(|v| match v {
PathVerb::MoveTo(p) | PathVerb::LineTo(p) => Some(*p),
_ => None,
})
.collect(),
s.color,
)),
_ => None,
})
}
#[test]
fn the_box_is_the_reference_s() {
let theme = Theme::dark().with_platform(frus_core::TargetPlatform::Windows);
let c = Checkbox::<Msg>::new(false);
let style = Widget::<Msg>::style_themed(&c, &theme);
assert_eq!(
(style.width, style.height),
(Dimension::Length(48.0), Dimension::Length(48.0)),
"standard density even on a desktop"
);
let (rect, fill, border, outline, corner) = the_box(&painted(&c, settled(false), &theme));
assert_eq!(rect, Rect::new(15.0, 15.0, 18.0, 18.0));
assert_eq!((fill, border, corner), (Color::TRANSPARENT, 2.0, 2.0));
assert_eq!(outline, theme.scheme.on_surface_variant);
let compact =
c_style(Checkbox::<Msg>::new(false).visual_density(crate::VisualDensity::COMPACT));
assert_eq!(compact, 40.0);
let shrunk = c_style(Checkbox::<Msg>::new(false).tap_target(TapTarget::ShrinkWrap));
assert_eq!(shrunk, 40.0);
}
fn c_style(c: Checkbox<Msg>) -> f32 {
match Widget::<Msg>::style_themed(&c, &Theme::dark()).height {
Dimension::Length(v) => v,
_ => unreachable!(),
}
}
#[test]
fn the_tick_is_drawn_through_the_reference_s_points() {
let theme = Theme::dark();
let prims = painted(&Checkbox::<Msg>::new(true), settled(true), &theme);
let (rect, fill, border, _, _) = the_box(&prims);
assert_eq!(
(rect, fill, border),
(Rect::new(15.0, 15.0, 18.0, 18.0), theme.scheme.primary, 0.0)
);
let (points, colour) = mark(&prims).expect("a tick");
assert_eq!(colour, theme.scheme.on_primary);
let at = |x: f32, y: f32| Point::new(15.0 + 18.0 * x, 15.0 + 18.0 * y);
assert_eq!(points, vec![at(0.15, 0.45), at(0.4, 0.7), at(0.85, 0.25)]);
let (dash, _) = mark(&painted(
&Checkbox::<Msg>::maybe(None),
settled(true),
&theme,
))
.unwrap();
let near = |a: Point, b: Point| (a.x - b.x).abs() < 1e-3 && (a.y - b.y).abs() < 1e-3;
assert!(
near(dash[0], at(0.2, 0.5)) && near(dash[1], at(0.8, 0.5)),
"{dash:?}"
);
}
#[test]
fn ticking_animates_the_box_and_the_tick() {
let theme = Theme::dark();
let c = Checkbox::<Msg>::new(true);
assert_eq!(Widget::<Msg>::anim_target(&c), Some(1.0));
assert_eq!(
Widget::<Msg>::anim_target(&Checkbox::<Msg>::new(false)),
Some(0.0)
);
assert_eq!(
Widget::<Msg>::anim_target(&Checkbox::<Msg>::maybe(None)),
Some(1.0)
);
assert_eq!(Widget::<Msg>::anim_duration(&c), 0.2);
let half = painted(
&c,
Status {
value: 0.5,
..settled(true)
},
&theme,
);
assert_eq!(the_box(&half).0, Rect::new(16.0, 16.0, 16.0, 16.0));
assert!(mark(&half).is_none(), "no tick yet");
let three = painted(
&c,
Status {
value: 0.75,
..settled(true)
},
&theme,
);
let (points, _) = mark(&three).unwrap();
let at = |x: f32, y: f32| Point::new(15.0 + 18.0 * x, 15.0 + 18.0 * y);
assert_eq!(points, vec![at(0.15, 0.45), at(0.4, 0.7), at(0.4, 0.7)]);
}
#[test]
fn the_halo_is_the_reference_s() {
let theme = Theme::dark();
let hovered = |value: bool| Status {
hover_progress: 1.0,
interaction: crate::interaction::Interaction::Hovered,
..settled(value)
};
let prims = painted(&Checkbox::<Msg>::new(false), hovered(false), &theme);
let halo = prims.iter().find_map(|p| match p {
Primitive::Rect { rect, color, .. } if rect.width == 40.0 => Some(*color),
_ => None,
});
assert_eq!(halo, Some(theme.scheme.on_surface.with_alpha(0.08)));
let prims = painted(&Checkbox::<Msg>::new(true), hovered(true), &theme);
let halo = prims.iter().find_map(|p| match p {
Primitive::Rect { rect, color, .. } if rect.width == 40.0 => Some(*color),
_ => None,
});
assert_eq!(halo, Some(theme.scheme.primary.with_alpha(0.08)));
let (_, _, _, outline, _) = the_box(&painted(
&Checkbox::<Msg>::new(false),
hovered(false),
&theme,
));
assert_eq!(outline, theme.scheme.on_surface);
}
#[test]
fn an_error_takes_the_error_colours() {
let theme = Theme::dark();
let (_, _, _, outline, _) = the_box(&painted(
&Checkbox::<Msg>::new(false).error(true),
settled(false),
&theme,
));
assert_eq!(outline, theme.scheme.error);
let prims = painted(
&Checkbox::<Msg>::new(true).error(true),
settled(true),
&theme,
);
assert_eq!(the_box(&prims).1, theme.scheme.error);
assert_eq!(mark(&prims).unwrap().1, theme.scheme.on_error);
}
#[test]
fn a_disabled_box_is_inert_but_still_says_whether_it_is_ticked() {
let theme = Theme::dark();
let c = Checkbox::<Msg>::new(true)
.enabled(false)
.on_toggle(Msg::Set);
assert_eq!(Widget::<Msg>::on_click(&c), None);
assert!(!Widget::<Msg>::focusable(&c));
let prims = painted(&c, settled(true), &theme);
assert_eq!(the_box(&prims).1, disabled_content(&theme));
assert_eq!(mark(&prims).unwrap().1, disabled_mark(&theme));
}
#[test]
fn click_toggles() {
let c = Checkbox::<Msg>::new(false).on_toggle(Msg::Set);
assert_eq!(Widget::<Msg>::on_click(&c), Some(Msg::Set(true)));
let c = Checkbox::<Msg>::new(true).on_toggle(Msg::Set);
assert_eq!(Widget::<Msg>::on_click(&c), Some(Msg::Set(false)));
}
#[test]
fn a_tristate_box_cycles_through_the_third_answer() {
let next = |v: Option<bool>| {
Widget::<Msg>::on_click(&Checkbox::<Msg>::maybe(v).on_change(Msg::Maybe))
};
assert_eq!(next(Some(false)), Some(Msg::Maybe(Some(true))));
assert_eq!(next(Some(true)), Some(Msg::Maybe(None)));
assert_eq!(next(None), Some(Msg::Maybe(Some(false))));
let old = Checkbox::<Msg>::maybe(Some(true)).on_toggle(Msg::Set);
assert_eq!(Widget::<Msg>::on_click(&old), Some(Msg::Set(true)));
}
#[test]
fn partly_ticked_is_announced_as_mixed() {
let s = Widget::<Msg>::semantics(&Checkbox::<Msg>::maybe(None)).unwrap();
assert_eq!(s.toggled, frus_core::Toggled::Mixed);
let named =
Widget::<Msg>::semantics(&Checkbox::<Msg>::new(true).semantic_label("Agree")).unwrap();
assert_eq!(named.label.as_deref(), Some("Agree"));
}
#[test]
fn the_theme_answers_and_the_instance_overrules_it() {
let mut theme = Theme::dark();
let green = Color::rgb8(0, 160, 80);
let blue = Color::rgb8(0, 0, 200);
theme.widgets.checkbox.fill_color = Some(green);
theme.widgets.checkbox.radius = Some(4.0);
let themed = the_box(&painted(&Checkbox::<Msg>::new(true), settled(true), &theme));
assert_eq!((themed.1, themed.4), (green, 4.0));
let mine = the_box(&painted(
&Checkbox::<Msg>::new(true).fill_color(blue).radius(1.0),
settled(true),
&theme,
));
assert_eq!((mine.1, mine.4), (blue, 1.0));
let one = Checkbox::<Msg>::new(false).border_color(blue);
let hovered = Status {
interaction: crate::interaction::Interaction::Hovered,
..settled(false)
};
assert_eq!(the_box(&painted(&one, hovered, &theme)).3, blue);
}
#[test]
fn the_label_follows_the_square() {
let theme = Theme::dark();
let c = Checkbox::<Msg>::new(false)
.label("Remember me")
.label_color(Color::rgb8(9, 9, 9));
let prims = painted(&c, settled(false), &theme);
let at = prims.iter().find_map(|p| match p {
Primitive::Text {
position, color, ..
} => Some((*position, *color)),
_ => None,
});
let (position, colour) = at.expect("a label");
assert_eq!(position.x, 48.0);
assert_eq!(colour, Color::rgb8(9, 9, 9));
}
}