use crate::core::{Color, Point, Rect, Size};
use crate::event::{Event, EventHandler};
use crate::render::RenderContext;
use crate::widget::{BaseWidget, Draw, Widget, WidgetKind};
pub struct Spinner {
base: BaseWidget,
active: bool,
angle: f32,
thickness: u32,
size_ratio: f32,
speed: f32,
}
impl Spinner {
pub fn new(rect: Rect) -> Self {
Self {
base: BaseWidget::new(WidgetKind::Spinner, rect, "Spinner"),
active: true,
angle: 0.0,
thickness: 4,
size_ratio: 0.8,
speed: 1.0,
}
}
pub fn is_active(&self) -> bool {
self.active
}
pub fn set_active(&mut self, active: bool) {
self.active = active;
}
pub fn set_thickness(&mut self, thickness: u32) {
self.thickness = thickness.max(1);
}
pub fn set_speed(&mut self, speed: f32) {
self.speed = speed.max(0.0);
}
pub fn set_size_ratio(&mut self, ratio: f32) {
self.size_ratio = ratio.clamp(0.0, 1.0);
}
pub fn angle(&self) -> f32 {
self.angle
}
pub fn thickness(&self) -> u32 {
self.thickness
}
pub fn size_ratio(&self) -> f32 {
self.size_ratio
}
pub fn speed(&self) -> f32 {
self.speed
}
pub fn tick(&mut self, delta_ms: u32) {
self.angle = (self.angle + delta_ms as f32 * 0.1 * self.speed) % 360.0;
}
fn center_and_radius(&self) -> Option<(Point, u32)> {
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 raw_radius = rect.width.min(rect.height) as f32 / 2.0 * self.size_ratio;
let radius = (raw_radius - self.thickness as f32 / 2.0 - 1.0).max(1.0);
Some((Point::new(cx, cy), radius as u32))
}
fn point_on_circle(center: Point, radius: u32, angle: f32) -> Point {
let r = radius as f32;
Point::new(
center.x + (r * angle.cos()).round() as i32,
center.y + (r * angle.sin()).round() as i32,
)
}
fn draw_arc_segments(
context: &mut RenderContext,
center: Point,
radius: u32,
start_angle: f32,
end_angle: f32,
color: Color,
stroke_width: u32,
) {
const SEGMENTS: u32 = 40;
let total_angle = end_angle - start_angle;
if total_angle.abs() < 0.001 {
return;
}
let step = total_angle / SEGMENTS as f32;
let mut prev = Self::point_on_circle(center, radius, start_angle);
for i in 1..=SEGMENTS {
let angle = start_angle + step * i as f32;
let curr = Self::point_on_circle(center, radius, angle);
context.draw_line_stroke(prev, curr, color, stroke_width);
prev = curr;
}
}
}
impl Widget for Spinner {
fn base(&self) -> &BaseWidget {
&self.base
}
fn base_mut(&mut self) -> &mut BaseWidget {
&mut self.base
}
fn size_hint(&self) -> Size {
Size::new(48, 48)
}
}
impl EventHandler for Spinner {
fn handle_event(&mut self, event: &Event) {
self.base.handle_event(event);
}
}
impl Draw for Spinner {
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.thickness.max(1);
let style = self.style();
let track_color = style.text_color.unwrap_or_else(|| Color::rgba(220, 220, 220, 200));
let arc_color = style.background_color.unwrap_or(Color::PRIMARY);
let effective_track =
if is_enabled { track_color } else { Color::rgba(200, 200, 200, 100) };
let effective_arc = if is_enabled { arc_color } else { Color::DISABLED_FOREGROUND };
context.draw_circle_stroke(center, radius, effective_track, stroke_w);
let arc_sweep = 2.4; let start_angle = self.angle.to_radians() - std::f32::consts::FRAC_PI_2;
let end_angle = start_angle + arc_sweep;
Self::draw_arc_segments(
context,
center,
radius,
start_angle,
end_angle,
effective_arc,
stroke_w,
);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::render::PaintBackend;
use crate::style::WidgetStyle;
#[test]
fn spinner_creation_defaults() {
let sp = Spinner::new(Rect::new(0, 0, 48, 48));
assert!(sp.is_active());
assert!((sp.angle() - 0.0).abs() < f32::EPSILON);
assert_eq!(sp.thickness(), 4);
assert!((sp.size_ratio() - 0.8).abs() < f32::EPSILON);
assert!((sp.speed() - 1.0).abs() < f32::EPSILON);
assert_eq!(sp.kind(), WidgetKind::Spinner);
}
#[test]
fn spinner_active_state() {
let mut sp = Spinner::new(Rect::new(0, 0, 48, 48));
assert!(sp.is_active());
sp.set_active(false);
assert!(!sp.is_active());
sp.set_active(true);
assert!(sp.is_active());
}
#[test]
fn spinner_tick_advances_angle() {
let mut sp = Spinner::new(Rect::new(0, 0, 48, 48));
sp.tick(100);
assert!((sp.angle() - 10.0).abs() < 0.01);
sp.tick(200);
assert!((sp.angle() - 30.0).abs() < 0.01);
sp.tick(3301); assert!((sp.angle() - 0.1).abs() < 0.01);
}
#[test]
fn spinner_tick_speed_multiplier() {
let mut sp = Spinner::new(Rect::new(0, 0, 48, 48));
sp.set_speed(2.0);
sp.tick(100);
assert!((sp.angle() - 20.0).abs() < 0.01);
}
#[test]
fn spinner_tick_inactive_does_not_auto_advance() {
let mut sp = Spinner::new(Rect::new(0, 0, 48, 48));
sp.set_active(false);
sp.tick(100);
assert!((sp.angle() - 10.0).abs() < 0.01);
}
#[test]
fn spinner_set_thickness() {
let mut sp = Spinner::new(Rect::new(0, 0, 48, 48));
sp.set_thickness(8);
assert_eq!(sp.thickness(), 8);
sp.set_thickness(0);
assert_eq!(sp.thickness(), 1);
}
#[test]
fn spinner_set_speed() {
let mut sp = Spinner::new(Rect::new(0, 0, 48, 48));
sp.set_speed(2.5);
assert!((sp.speed() - 2.5).abs() < f32::EPSILON);
sp.set_speed(-1.0);
assert!((sp.speed() - 0.0).abs() < f32::EPSILON);
}
#[test]
fn spinner_set_size_ratio() {
let mut sp = Spinner::new(Rect::new(0, 0, 48, 48));
sp.set_size_ratio(0.5);
assert!((sp.size_ratio() - 0.5).abs() < f32::EPSILON);
sp.set_size_ratio(1.5);
assert!((sp.size_ratio() - 1.0).abs() < f32::EPSILON);
sp.set_size_ratio(-0.5);
assert!((sp.size_ratio() - 0.0).abs() < f32::EPSILON);
}
#[test]
fn spinner_draw_does_not_panic() {
let rect = Rect::new(0, 0, 48, 48);
let mut sp = Spinner::new(rect);
let mut backend = crate::render::SoftwarePaintBackend::new(Size::new(100, 100), 1.0);
backend.begin_frame(Color::WHITE);
let mut ctx = crate::render::RenderContext::new(&mut backend);
sp.draw(&mut ctx);
backend.end_frame();
let rgba = backend.frame_rgba();
assert!(!rgba.is_empty());
}
#[test]
fn spinner_draw_zero_geometry_does_not_panic() {
let mut sp = Spinner::new(Rect::new(0, 0, 0, 0));
let mut backend = crate::render::SoftwarePaintBackend::new(Size::new(100, 100), 1.0);
backend.begin_frame(Color::WHITE);
let mut ctx = crate::render::RenderContext::new(&mut backend);
sp.draw(&mut ctx);
backend.end_frame();
let rgba = backend.frame_rgba();
assert!(!rgba.is_empty());
}
#[test]
fn spinner_style_roundtrip() {
let mut sp = Spinner::new(Rect::new(0, 0, 48, 48));
assert_eq!(*sp.style(), WidgetStyle::default());
let custom = WidgetStyle::default().with_background(Color::from_rgb(0, 150, 255));
sp.set_style(custom.clone());
assert_eq!(*sp.style(), custom);
}
#[test]
fn spinner_id_kind() {
let sp_a = Spinner::new(Rect::new(0, 0, 48, 48));
let sp_b = Spinner::new(Rect::new(0, 0, 48, 48));
assert_ne!(sp_a.id(), sp_b.id());
assert_eq!(sp_a.kind(), WidgetKind::Spinner);
assert_eq!(sp_b.kind(), WidgetKind::Spinner);
}
#[test]
fn spinner_geometry_delegation() {
let mut sp = Spinner::new(Rect::new(0, 0, 48, 48));
sp.set_geometry(Rect::new(10, 10, 60, 60));
assert_eq!(sp.geometry(), Rect::new(10, 10, 60, 60));
}
#[test]
fn spinner_visibility() {
let mut sp = Spinner::new(Rect::new(0, 0, 48, 48));
assert!(sp.is_visible());
sp.hide();
assert!(!sp.is_visible());
sp.show();
assert!(sp.is_visible());
}
#[test]
fn spinner_enabled() {
let mut sp = Spinner::new(Rect::new(0, 0, 48, 48));
assert!(sp.is_enabled());
sp.set_enabled(false);
assert!(!sp.is_enabled());
sp.set_enabled(true);
assert!(sp.is_enabled());
}
#[test]
fn spinner_size_hint() {
let sp = Spinner::new(Rect::new(0, 0, 48, 48));
assert_eq!(sp.size_hint(), Size::new(48, 48));
}
}