use alloc::string::String;
use denise::Pen;
use denise::{ElementState, InputEvent, KeyCode, Point, Radius, Rect, Role, Theme};
use denise_text::{TextEngine, TextStyle};
use crate::widget::{
Event, EventCtx, Handled, MeasureCtx, Measured, Offer, PaintCtx, VisualState, Widget,
};
use crate::widgets::describe::{
Describe, DynDescribe, Group, Mismatch, Payload, Property, PropertyKind, ROLES, Value,
};
use crate::widgets::style::{Align, draw_aligned, focus_ring, interactive_pair};
#[derive(Clone, Debug)]
pub struct Checkbox<M> {
label: String,
checked: bool,
message: Option<fn(bool) -> M>,
role: Role,
style: TextStyle,
}
impl<M> Checkbox<M> {
pub fn new(label: impl Into<String>, message: fn(bool) -> M) -> Self {
Self {
label: label.into(),
checked: false,
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,
message: None,
role: Role::Primary,
style: TextStyle::built_in(16),
}
}
pub fn with_checked(mut self, checked: bool) -> Self {
self.checked = 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;
}
#[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 side = theme.metrics.size_selector;
let text = engine.measure_line(self.style, &self.label);
if self.label.is_empty() {
side
} else {
side + gap(side) + text
}
}
}
#[inline]
const fn gap(side: i32) -> i32 {
if side < 2 { 1 } else { side / 2 }
}
fn box_rect(bounds: Rect, theme: &Theme) -> Rect {
let side = theme
.metrics
.size_selector
.min(bounds.height)
.min(bounds.width)
.max(1);
Rect::new(bounds.x, bounds.y + (bounds.height - side) / 2, side, side)
}
#[inline]
const fn tick_weight(side: i32) -> i32 {
if side / 8 < 2 { 2 } else { side / 8 }
}
fn draw_tick(canvas: &mut Pen<'_>, area: Rect, color: denise::Color, thickness: i32) {
let s = area.width;
let start = Point::new(area.x + s * 7 / 32, area.y + s * 17 / 32);
let elbow = Point::new(area.x + s * 13 / 32, area.y + s * 23 / 32);
let end = Point::new(area.x + s * 25 / 32, area.y + s * 9 / 32);
for step in 0..thickness.max(1) {
let dy = step;
canvas.draw_line(
Point::new(start.x, start.y + dy),
Point::new(elbow.x, elbow.y + dy),
color,
);
canvas.draw_line(
Point::new(elbow.x, elbow.y + dy),
Point::new(end.x, end.y + dy),
color,
);
}
}
impl<M: 'static> Widget<M> for Checkbox<M> {
fn describe(&self) -> Option<&dyn DynDescribe> {
Some(self)
}
fn describe_mut(&mut self) -> Option<&mut dyn DynDescribe> {
Some(self)
}
fn measure(&self, ctx: &mut MeasureCtx<'_>, _offered: Offer) -> Measured {
Measured::both(
self.preferred_width(ctx.theme, ctx.text),
ctx.theme.metrics.size_selector.max(1),
)
}
fn paint(&self, ctx: &mut PaintCtx<'_>, canvas: &mut Pen<'_>) {
let area = box_rect(ctx.bounds, ctx.theme);
let radius = ctx.theme.radius(Radius::Selector).min(area.width / 2);
let (surface, on_surface) = interactive_pair(ctx.theme, Role::Base100, ctx.state);
if self.checked {
let (fill, mark) = interactive_pair(ctx.theme, self.role, ctx.state);
canvas.fill_rounded_rect(area, radius, fill);
draw_tick(canvas, area, mark, tick_weight(area.width));
} else {
canvas.fill_rounded_rect(area, radius, surface);
canvas.stroke_rounded_rect(
area,
radius,
ctx.theme.metrics.border,
ctx.theme.color(Role::Base300),
);
}
if ctx.state.contains(VisualState::FOCUSED) {
focus_ring(
ctx.theme,
ctx.bounds,
ctx.theme.radius(Radius::Field),
canvas,
);
}
if self.label.is_empty() {
return;
}
let text = Rect::from_edges(
area.right() + gap(area.width),
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.checked = !self.checked;
if let Some(message) = self.message {
ctx.emit(message(self.checked));
}
Handled::Yes
}
fn accepts_pointer(&self) -> bool {
true
}
fn focusable(&self) -> bool {
true
}
}
impl<M> Describe for Checkbox<M> {
const KIND: &'static str = "checkbox";
const DOC: &'static str = "A box and a tick: one thing that is either on or off.";
const GROUP: Group = Group::Input;
const ICON: &'static denise::icon::Icon = &super::icons::CHECKBOX;
const PROPERTIES: &'static [Property] = &[
Property::new("text", PropertyKind::Text, "The label beside the box."),
Property::new("checked", PropertyKind::Bool, "Whether the box is ticked."),
Property::new(
"on-change",
PropertyKind::Message(Payload::Bool),
"The message built from the value the box changes to. Omitted, the checkbox is inert.",
),
Property::new(
"role",
PropertyKind::Enum(ROLES),
"Colour role of the filled box. The tick comes from the theme's pairing, so it stays readable whichever role is chosen.",
),
Property::new(
"size",
PropertyKind::Int { min: 6, max: 96 },
"Label text size in logical pixels. The box itself is a theme metric.",
)
.in_pixels(),
];
fn get(&self, name: &str) -> Option<Value> {
Some(match name {
"text" => Value::text(self.label.as_str()),
"checked" => Value::Bool(self.checked),
"on-change" => return None,
"role" => Value::role(self.role),
"size" => Value::Int(i32::from(self.style.size_px)),
_ => return None,
})
}
fn apply(&mut self, name: &str, value: Value) -> Result<(), Mismatch> {
match name {
"text" => self.label = value.as_text()?,
"checked" => self.set_checked(value.as_bool()?),
"on-change" => return Err(Mismatch::Supplied),
"role" => self.role = value.as_role()?,
"size" => self.style.size_px = value.as_size()?,
_ => return Err(Mismatch::Unknown),
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use denise::theme;
#[test]
fn the_box_follows_the_theme_and_sits_at_the_leading_edge() {
let bounds = Rect::new(10, 20, 200, 40);
let mouse = box_rect(bounds, &theme::DARK);
assert_eq!(mouse.width, theme::DARK.metrics.size_selector);
assert_eq!(mouse.x, bounds.x, "the box is at the leading edge");
assert_eq!(
mouse.y + mouse.height / 2,
bounds.y + bounds.height / 2,
"and centred against the label beside it"
);
let touch = theme::DARK.with_metrics(denise::theme::Metrics::TOUCH);
assert!(box_rect(bounds, &touch).width > mouse.width);
}
#[test]
fn a_short_row_shrinks_the_box_instead_of_overflowing() {
let bounds = Rect::new(0, 0, 200, 12);
let area = box_rect(bounds, &theme::DARK);
assert!(area.width <= 12);
assert!(area.height <= bounds.height);
assert!(area.width >= 1, "and never collapses to nothing");
}
#[test]
fn degenerate_bounds_still_give_a_square_with_area() {
for bounds in [
Rect::new(0, 0, 0, 0),
Rect::new(0, 0, 1, 40),
Rect::new(0, 0, 40, 1),
] {
let area = box_rect(bounds, &theme::DARK);
assert!(area.width >= 1 && area.height >= 1, "{bounds:?}");
assert_eq!(area.width, area.height, "{bounds:?} is not square");
}
}
#[test]
fn the_preferred_width_covers_the_box_the_gap_and_the_label() {
let mut engine = TextEngine::new();
let style = TextStyle::built_in(16);
let side = theme::DARK.metrics.size_selector;
let labelled: Checkbox<()> = Checkbox::inert("Enable logging");
let text = engine.measure_line(style, "Enable logging");
assert_eq!(
labelled.preferred_width(&theme::DARK, &mut engine),
side + gap(side) + text
);
let bare: Checkbox<()> = Checkbox::inert("");
assert_eq!(bare.preferred_width(&theme::DARK, &mut engine), side);
}
#[test]
fn setting_the_value_programmatically_is_silent() {
let mut checkbox: Checkbox<bool> = Checkbox::new("Mute", |on| on);
assert!(!checkbox.checked());
checkbox.set_checked(true);
assert!(checkbox.checked());
}
}