use alloc::string::{String, ToString};
use denise::{Point, Rect, Role};
use denise_render::{Canvas, TURN};
use denise_text::TextStyle;
use crate::widget::{PaintCtx, Widget};
use crate::widgets::style::{Align, draw_aligned, interactive_pair};
#[derive(Clone, Debug)]
pub struct RadialProgress {
value: f32,
label: String,
role: Role,
thickness: Option<i32>,
style: TextStyle,
}
impl RadialProgress {
pub fn new(value: f32) -> Self {
Self {
value: clamp(value),
label: String::new(),
role: Role::Primary,
thickness: None,
style: TextStyle::built_in(16),
}
}
pub fn with_label(mut self, label: impl Into<String>) -> Self {
self.label = label.into();
self
}
pub fn with_role(mut self, role: Role) -> Self {
self.role = role;
self
}
pub fn with_thickness(mut self, thickness: i32) -> Self {
self.thickness = Some(thickness);
self
}
pub fn with_style(mut self, style: TextStyle) -> Self {
self.style = style;
self
}
#[inline]
pub const fn value(&self) -> f32 {
self.value
}
pub fn set_value(&mut self, value: f32) {
self.value = clamp(value);
}
pub fn update(&mut self, value: f32) -> bool {
let value = clamp(value);
let changed = value != self.value;
self.value = value;
changed
}
#[inline]
pub fn label(&self) -> &str {
&self.label
}
pub fn set_label(&mut self, label: impl Into<String>) {
self.label = label.into();
}
pub fn update_label(&mut self, label: &str) -> bool {
let changed = self.label != label;
if changed {
self.label = label.to_string();
}
changed
}
pub fn set_role(&mut self, role: Role) {
self.role = role;
}
pub fn set_style(&mut self, style: TextStyle) {
self.style = style;
}
}
impl Default for RadialProgress {
fn default() -> Self {
Self::new(0.0)
}
}
#[inline]
fn clamp(value: f32) -> f32 {
if value.is_nan() {
0.0
} else {
value.clamp(0.0, 1.0)
}
}
pub(crate) fn ring(bounds: Rect) -> (Point, i32) {
let radius = bounds.width.min(bounds.height) / 2;
let centre = Point::new(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2);
(centre, radius.max(0))
}
pub(crate) fn thickness_for(radius: i32, requested: Option<i32>) -> i32 {
let limit = radius.max(1);
match requested {
Some(thickness) => thickness.clamp(1, limit),
None => (radius / 5).clamp(1, limit),
}
}
pub(crate) fn ring_colors(
theme: &denise::Theme,
state: crate::widget::VisualState,
role: Role,
) -> (denise::Color, denise::Color) {
let (track, _) = interactive_pair(theme, Role::Base300, state);
if state.contains(crate::widget::VisualState::DISABLED) {
(track, theme.color(Role::Base300))
} else {
(track, interactive_pair(theme, role, state).0)
}
}
fn sweep_of(value: f32, radius: i32) -> i32 {
if value.is_nan() || value <= 0.0 {
return 0;
}
if value >= 1.0 {
return TURN;
}
let exact = (value * TURN as f32) as i32;
let floor = (TURN / (6 * radius.max(1))).max(1);
exact.clamp(floor, TURN)
}
impl<M: 'static> Widget<M> for RadialProgress {
fn paint(&self, ctx: &mut PaintCtx<'_>, canvas: &mut Canvas<'_>) {
let bounds = ctx.bounds;
if bounds.is_empty() {
return;
}
let (centre, radius) = ring(bounds);
if radius <= 0 {
return;
}
let thickness = thickness_for(radius, self.thickness);
let (track, fill) = ring_colors(ctx.theme, ctx.state, self.role);
canvas.stroke_circle(centre, radius, thickness, track);
let sweep = sweep_of(self.value, radius);
if sweep > 0 {
canvas.stroke_arc(centre, radius, thickness, 0, sweep, fill);
}
if self.label.is_empty() {
return;
}
let content = interactive_pair(ctx.theme, Role::Base100, ctx.state).1;
draw_aligned(
canvas,
ctx.text,
self.style,
bounds,
(Align::Center, Align::Center),
&self.label,
content,
);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_sweep_is_exact_at_both_ends() {
for radius in [4, 20, 200] {
assert_eq!(sweep_of(0.0, radius), 0, "radius {radius}");
assert_eq!(sweep_of(1.0, radius), TURN, "radius {radius}");
}
assert_eq!(sweep_of(0.5, 100), TURN / 2);
assert_eq!(sweep_of(0.25, 100), TURN / 4);
}
#[test]
fn a_value_that_is_not_a_number_draws_an_empty_ring() {
let done = core::hint::black_box(0.0f32);
let total = core::hint::black_box(0.0f32);
let zero_over_zero = done / total;
assert!(zero_over_zero.is_nan(), "the premise");
assert_eq!(clamp(zero_over_zero), 0.0);
assert_eq!(sweep_of(zero_over_zero, 40), 0);
let mut ring = RadialProgress::new(0.5);
ring.set_value(zero_over_zero);
assert_eq!(ring.value(), 0.0, "and it does not keep the old value");
}
#[test]
fn infinities_clamp_to_the_end_they_point_at() {
assert_eq!(clamp(f32::INFINITY), 1.0);
assert_eq!(clamp(f32::NEG_INFINITY), 0.0);
assert_eq!(sweep_of(f32::INFINITY, 40), TURN);
assert_eq!(sweep_of(f32::NEG_INFINITY, 40), 0);
}
#[test]
fn values_outside_the_range_are_clamped() {
assert_eq!(RadialProgress::new(42.0).value(), 1.0);
assert_eq!(RadialProgress::new(-42.0).value(), 0.0);
}
#[test]
fn a_value_just_above_zero_shows_an_arc_at_any_radius() {
for radius in [4, 8, 20, 60, 200] {
let tiny = sweep_of(0.000_01, radius);
assert!(tiny > 0, "radius {radius} showed nothing");
let length = radius as f32 * (tiny as f32 / TURN as f32) * core::f32::consts::TAU;
assert!(
(0.8..4.0).contains(&length),
"radius {radius}: floor is {length} pixels of arc"
);
}
}
#[test]
fn more_progress_is_never_less_sweep() {
for radius in [4, 40, 200] {
let mut previous = -1;
for step in 0..=1000 {
let sweep = sweep_of(step as f32 / 1000.0, radius);
assert!(sweep >= previous, "radius {radius} went back at {step}");
assert!(sweep <= TURN, "radius {radius} overran at {step}");
previous = sweep;
}
}
}
#[test]
fn the_ring_inscribes_itself_in_any_rectangle() {
for bounds in [
Rect::new(0, 0, 100, 40),
Rect::new(10, 20, 40, 100),
Rect::new(-5, -5, 60, 60),
Rect::new(0, 0, 1, 1),
] {
let (centre, radius) = ring(bounds);
assert_eq!(radius, bounds.width.min(bounds.height) / 2, "{bounds:?}");
let circle = Rect::new(centre.x - radius, centre.y - radius, radius * 2, radius * 2);
assert!(
bounds.contains_rect(&circle),
"{bounds:?}: the ring {circle:?} escaped"
);
}
}
#[test]
fn the_thickness_never_exceeds_the_radius() {
for radius in [0, 1, 2, 5, 20, 200] {
for requested in [None, Some(-5), Some(0), Some(1), Some(9_999)] {
let t = thickness_for(radius, requested);
assert!(t >= 1, "radius {radius} {requested:?} gave {t}");
assert!(t <= radius.max(1), "radius {radius} {requested:?} gave {t}");
}
}
assert_eq!(thickness_for(50, None), 10, "a fifth of the radius");
assert_eq!(thickness_for(50, Some(4)), 4);
}
#[test]
fn writing_the_same_value_or_label_reports_no_change() {
let mut ring = RadialProgress::new(0.0);
assert!(ring.update(0.4));
assert!(!ring.update(0.4));
assert!(ring.update(0.6));
assert!(ring.update_label("60 %"));
assert!(!ring.update_label("60 %"));
assert!(ring.update_label("61 %"));
assert_eq!(ring.label(), "61 %");
}
#[test]
fn a_disabled_ring_still_shows_its_value() {
use crate::widget::VisualState;
use denise::Theme;
for theme in Theme::BUILT_IN {
for role in [Role::Primary, Role::Warning, Role::Success] {
let (track, arc) = ring_colors(&theme, VisualState::DISABLED, role);
assert_ne!(
track, arc,
"{} {role:?}: a disabled ring lost its value",
theme.name
);
let (track, arc) = ring_colors(&theme, VisualState::NONE, role);
assert_ne!(track, arc, "{} {role:?} enabled", theme.name);
}
}
}
#[test]
fn the_label_is_readable_on_the_panel_in_every_theme() {
use crate::widget::VisualState;
use denise::Theme;
use denise::theme::{AA_LARGE, contrast_x100};
for theme in Theme::BUILT_IN {
for state in [VisualState::NONE, VisualState::DISABLED] {
let (surface, content) = interactive_pair(&theme, Role::Base100, state);
let ratio = contrast_x100(surface, content);
assert!(
ratio >= AA_LARGE,
"{} {state:?}: label on the panel is {ratio}, floor is {AA_LARGE}",
theme.name
);
}
}
}
}