use crate::core::{Color, Point, Rect, Size};
use crate::event::{Event, EventHandler};
use crate::render::RenderContext;
use crate::widget::capability::coercion::{expect_bool, expect_f64};
use crate::widget::capability::properties_trait::{base_property_get, base_property_set};
use crate::widget::capability::types::{CapabilityAccessError, CapabilityValue};
use crate::widget::capability::WidgetProperties;
use crate::widget::{BaseWidget, Draw, Widget, WidgetKind};
use crate::{impl_widget_property_hooks, property_names_of};
const TRACK_GAP: f32 = 4.0;
const DEFAULT_SPIN_PERIOD_MS: u32 = 1400;
const SPIN_SLOW_MULTIPLE: u32 = 5;
pub struct ProgressCircle {
base: BaseWidget,
value: f32,
indeterminate: bool,
track_color: Color,
progress_color: Color,
stroke_width: f32,
diameter: u32,
sweep: f32,
}
impl ProgressCircle {
pub fn new(geometry: Rect) -> Self {
Self {
base: BaseWidget::new(WidgetKind::ProgressCircle, geometry, "ProgressCircle"),
value: 0.0,
indeterminate: false,
track_color: Color::rgba(220, 220, 220, 200),
progress_color: crate::style::resolved_theme_style("progress_bar")
.and_then(|resolved| resolved.background_color)
.unwrap_or(Color::PRIMARY),
stroke_width: 4.0,
diameter: geometry.width.min(geometry.height),
sweep: 0.0,
}
}
pub fn value(&self) -> f32 {
self.value
}
pub fn set_value(&mut self, value: f32) {
self.value = value.clamp(0.0, 1.0);
self.base.request_redraw();
}
pub fn is_indeterminate(&self) -> bool {
self.indeterminate
}
pub fn set_indeterminate(&mut self, indeterminate: bool) {
self.indeterminate = indeterminate;
if indeterminate {
self.sweep = 0.0;
}
self.base.request_redraw();
}
pub fn sweep_angle(&self) -> f32 {
self.sweep * 2.0 * core::f32::consts::PI
}
pub fn tick(&mut self, delta_ms: u32) -> bool {
if !self.indeterminate {
return false;
}
let period = self.spin_period_ms();
self.sweep = (self.sweep + delta_ms as f32 / period as f32).fract();
self.base.request_redraw();
true
}
fn spin_period_ms(&self) -> u32 {
let slow = crate::style::motion_tokens().2;
if slow == 0 {
DEFAULT_SPIN_PERIOD_MS
} else {
slow.saturating_mul(SPIN_SLOW_MULTIPLE).max(1)
}
}
pub fn track_color(&self) -> Color {
self.track_color
}
pub fn set_track_color(&mut self, color: Color) {
self.track_color = color;
self.base.request_redraw();
}
pub fn progress_color(&self) -> Color {
self.progress_color
}
pub fn set_progress_color(&mut self, color: Color) {
self.progress_color = color;
self.base.request_redraw();
}
pub fn stroke_width(&self) -> f32 {
self.stroke_width
}
pub fn thickness(&self) -> f32 {
self.stroke_width
}
pub fn diameter(&self) -> u32 {
self.diameter
}
pub fn set_diameter(&mut self, diameter: u32) {
self.diameter = diameter.max(1);
self.base.request_redraw();
}
pub fn set_stroke_width(&mut self, width: f32) {
self.stroke_width = width.max(0.5);
self.base.request_redraw();
}
fn center_and_radius(&self) -> Option<(Point, f32)> {
let rect = self.geometry();
if rect.width == 0 || rect.height == 0 {
return None;
}
let cx = rect.x + (rect.width as i32) / 2;
let cy = rect.y + (rect.height as i32) / 2;
let radius = (rect.width.min(rect.height) as f32 / 2.0) - self.stroke_width / 2.0 - 1.0;
if radius <= 0.0 {
return None;
}
Some((Point::new(cx, cy), radius))
}
}
impl Widget for ProgressCircle {
fn base(&self) -> &BaseWidget {
&self.base
}
fn base_mut(&mut self) -> &mut BaseWidget {
&mut self.base
}
fn size_hint(&self) -> Size {
Size::new(self.diameter.max(60), self.diameter.max(60))
}
fn tick(&mut self, delta_ms: u32) -> bool {
ProgressCircle::tick(self, delta_ms)
}
fn is_animating(&self) -> bool {
self.indeterminate
}
impl_draw_bridge!();
impl_widget_property_hooks!();
}
impl WidgetProperties for ProgressCircle {
fn get(&self, name: &str) -> Result<CapabilityValue, CapabilityAccessError> {
match name {
"value" => Ok(CapabilityValue::Float(f64::from(self.value()))),
"thickness" => Ok(CapabilityValue::Float(f64::from(self.thickness()))),
"indeterminate" => Ok(CapabilityValue::Bool(self.is_indeterminate())),
_ => base_property_get(self, name),
}
}
fn set(&mut self, name: &str, value: CapabilityValue) -> Result<(), CapabilityAccessError> {
match name {
"value" => {
self.set_value(expect_f64(value)? as f32);
Ok(())
}
"thickness" => {
self.set_stroke_width(expect_f64(value)? as f32);
Ok(())
}
"indeterminate" => {
self.set_indeterminate(expect_bool(value)?);
Ok(())
}
_ => base_property_set(self, name, value),
}
}
fn property_names(&self) -> &'static [&'static str] {
property_names_of!["value", "thickness", "indeterminate", BASE_PROPERTY_NAMES]
}
fn command(&mut self, name: &str) -> Result<(), CapabilityAccessError> {
match name {
"set_value" | "set_thickness" | "set_indeterminate" => {
Err(CapabilityAccessError::OutOfRange)
}
_ => Err(CapabilityAccessError::UnknownCommand),
}
}
}
impl Draw for ProgressCircle {
fn draw(&mut self, context: &mut RenderContext) {
let rect = self.geometry();
if rect.width == 0 || rect.height == 0 {
return;
}
let Some((center, radius)) = self.center_and_radius() else {
return;
};
let is_enabled = self.base.is_enabled();
let stroke_w = self.stroke_width as u32;
let style = self.base.style().clone();
let themed = crate::style::resolved_theme_style("progress_circle");
let themed_bg = themed.as_ref().and_then(|r| r.background_color);
let fallback_track = themed_bg.unwrap_or(Color::rgba(220, 220, 220, 200));
let surface = style.background_color.unwrap_or(fallback_track);
let track_color = if is_enabled {
surface
} else {
surface.blend(&Color::DISABLED_FOREGROUND, 0.5)
};
context.draw_circle_stroke(center, radius as u32, track_color, stroke_w.max(1));
if self.indeterminate {
let arc_sweep = 2.4; let start_angle = self.sweep_angle();
let end_angle = start_angle + arc_sweep;
let prog_color =
if is_enabled { self.progress_color } else { Color::DISABLED_FOREGROUND };
crate::render::draw_arc_segments(
context,
center,
radius,
start_angle,
end_angle,
prog_color,
stroke_w.max(1),
);
} else if self.value > 0.0 {
let start_angle = -std::f32::consts::FRAC_PI_2;
let gap_radians = TRACK_GAP / radius.max(1.0);
let start_angle = start_angle + gap_radians;
let end_angle = -std::f32::consts::FRAC_PI_2 + 2.0 * std::f32::consts::PI * self.value;
if end_angle <= start_angle {
return;
}
let sweep_px = (radius * (end_angle - start_angle)).round() as u32;
if sweep_px > 0 {
let prog_color =
if is_enabled { self.progress_color } else { Color::DISABLED_FOREGROUND };
crate::render::draw_arc_segments(
context,
center,
radius,
start_angle,
end_angle,
prog_color,
stroke_w.max(1),
);
}
}
}
}
impl EventHandler for ProgressCircle {
fn handle_event(&mut self, event: &Event) {
self.base.handle_event(event);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn progress_circle_default_creation() {
let pc = ProgressCircle::new(Rect::new(0, 0, 48, 48));
assert_eq!(pc.kind(), WidgetKind::ProgressCircle);
assert!((pc.value() - 0.0).abs() < f32::EPSILON);
assert!(!pc.is_indeterminate());
assert!((pc.stroke_width() - 4.0).abs() < f32::EPSILON);
assert_eq!(pc.track_color(), Color::rgba(220, 220, 220, 200));
let expected = crate::style::resolved_theme_style("progress_bar")
.and_then(|resolved| resolved.background_color)
.unwrap_or(Color::PRIMARY);
assert_eq!(pc.progress_color(), expected);
}
#[test]
fn progress_circle_set_value() {
let mut pc = ProgressCircle::new(Rect::new(0, 0, 48, 48));
pc.set_value(0.5);
assert!((pc.value() - 0.5).abs() < f32::EPSILON);
pc.set_value(1.5); assert!((pc.value() - 1.0).abs() < f32::EPSILON);
pc.set_value(-0.5); assert!((pc.value() - 0.0).abs() < f32::EPSILON);
}
#[test]
fn progress_circle_indeterminate_toggle() {
let mut pc = ProgressCircle::new(Rect::new(0, 0, 48, 48));
assert!(!pc.is_indeterminate());
pc.set_indeterminate(true);
assert!(pc.is_indeterminate());
pc.set_indeterminate(false);
assert!(!pc.is_indeterminate());
}
#[test]
fn progress_circle_colors_and_stroke() {
let mut pc = ProgressCircle::new(Rect::new(0, 0, 48, 48));
pc.set_track_color(Color::LIGHT_GRAY);
assert_eq!(pc.track_color(), Color::LIGHT_GRAY);
pc.set_progress_color(Color::SUCCESS);
assert_eq!(pc.progress_color(), Color::SUCCESS);
pc.set_stroke_width(6.0);
assert!((pc.stroke_width() - 6.0).abs() < f32::EPSILON);
pc.set_stroke_width(-1.0); assert!((pc.stroke_width() - 0.5).abs() < f32::EPSILON);
}
#[test]
fn progress_circle_svg_output_determinate() {
let mut pc = ProgressCircle::new(Rect::new(0, 0, 48, 48));
pc.set_value(0.75);
let svg = crate::widget::svg::render_to_svg(&mut pc);
assert!(svg.starts_with("<svg"));
assert!(svg.ends_with("</svg>"));
}
#[test]
fn progress_circle_svg_output_indeterminate() {
let mut pc = ProgressCircle::new(Rect::new(0, 0, 48, 48));
pc.set_indeterminate(true);
let svg = crate::widget::svg::render_to_svg(&mut pc);
assert!(svg.starts_with("<svg"));
assert!(svg.ends_with("</svg>"));
}
#[test]
fn progress_circle_zero_geometry_no_crash() {
let mut pc = ProgressCircle::new(Rect::new(0, 0, 0, 0));
let svg = crate::widget::svg::render_to_svg(&mut pc);
assert!(svg.starts_with("<svg"));
}
fn arc_segments(svg: &str) -> Vec<(i32, i32, i32, i32)> {
svg.lines()
.filter(|line| line.contains("<line") && line.contains("stroke-width=\"4\""))
.map(|line| {
let attr = |name: &str| -> i32 {
line.split(&std::format!("{name}=\""))
.nth(1)
.and_then(|rest| rest.split('"').next())
.and_then(|value| value.parse().ok())
.unwrap_or_else(|| panic!("no {name} on: {line}"))
};
(attr("x1"), attr("y1"), attr("x2"), attr("y2"))
})
.collect()
}
#[test]
fn progress_circle_ignores_a_value_too_small_to_sweep_a_pixel() {
let mut pc = ProgressCircle::new(Rect::new(0, 0, 48, 48));
pc.set_value(0.001);
let svg = crate::widget::svg::render_to_svg(&mut pc);
let segments = arc_segments(&svg);
assert!(
segments.iter().all(|(x1, y1, x2, y2)| x1 == x2 && y1 == y2),
"no arc segment of a swept-away value may have length: {segments:?}"
);
assert!(
svg.contains("<circle") && svg.contains("fill=\"none\""),
"the track ring is chrome and must still be drawn: {svg}"
);
}
#[test]
fn progress_circle_draws_an_arc_that_sweeps_whole_pixels() {
let mut pc = ProgressCircle::new(Rect::new(0, 0, 48, 48));
pc.set_value(0.25);
let svg = crate::widget::svg::render_to_svg(&mut pc);
let segments = arc_segments(&svg);
assert!(
!segments.is_empty() && segments.iter().any(|(x1, y1, x2, y2)| x1 != x2 || y1 != y2),
"a quarter turn is a visible arc, not 40 dots: {segments:?}"
);
}
#[test]
fn progress_circle_event_forwarding() {
let mut pc = ProgressCircle::new(Rect::new(0, 0, 48, 48));
pc.handle_event(&Event::MouseMove { pos: Point::new(10, 10) });
pc.handle_event(&Event::MousePress { pos: Point::new(10, 10), button: 1 });
}
#[test]
fn the_indeterminate_ring_turns_on_the_ticks_it_is_given() {
let _theme_guard = crate::style::theme_test_guard();
let mut pc = ProgressCircle::new(Rect::new(0, 0, 48, 48));
pc.set_indeterminate(true);
let first = crate::widget::svg::render_to_svg(&mut pc);
let angle_before = pc.sweep_angle();
assert!(pc.tick(100), "an indeterminate ring owes another frame");
let second = crate::widget::svg::render_to_svg(&mut pc);
assert!(pc.tick(100), "and the frame after that");
let third = crate::widget::svg::render_to_svg(&mut pc);
assert!(pc.sweep_angle() > angle_before, "the ring must advance, not sit still");
assert_ne!(first, second, "frame one and frame two must not be the same picture");
assert_ne!(second, third, "nor frame two and frame three");
}
#[test]
fn a_determinate_ring_owes_no_frames() {
let _theme_guard = crate::style::theme_test_guard();
let mut pc = ProgressCircle::new(Rect::new(0, 0, 48, 48));
pc.set_value(0.5);
assert!(!pc.tick(100), "a determinate ring must not request another frame");
let before = crate::widget::svg::render_to_svg(&mut pc);
assert!(!pc.tick(100), "and still not after being asked twice");
assert_eq!(before, crate::widget::svg::render_to_svg(&mut pc), "its picture is unchanged");
}
#[test]
fn the_smallest_visible_arc_starts_after_a_track_gap() {
let mut pc = ProgressCircle::new(Rect::new(0, 0, 48, 48));
pc.set_value(0.25);
let svg = crate::widget::svg::render_to_svg(&mut pc);
let segments = arc_segments(&svg);
assert!(!segments.is_empty(), "a quarter ring is a visible arc: {svg}");
let radius = 48.0 / 2.0 - 4.0 / 2.0 - 1.0;
let gapped_px = (radius * core::f32::consts::FRAC_PI_2 - TRACK_GAP).ceil() as usize;
let ungapped_px = (radius * core::f32::consts::FRAC_PI_2).ceil() as usize;
assert!(
ungapped_px > gapped_px,
"the fixture must distinguish the two cases, or the assertion below is vacuous"
);
assert!(
segments.len() <= gapped_px && segments.len() < ungapped_px,
"the arc must be the gapped sweep, not the raw one: {} segments, expected <= \
{gapped_px} (gapped) and < {ungapped_px} (ungapped)",
segments.len()
);
}
}