use crate::core::{Color, Point, Rect};
use crate::event::{Event, EventHandler};
use crate::render::RenderContext;
use crate::widget::{BaseWidget, Draw, Widget, WidgetKind};
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum SkeletonShape {
Rect(u32, u32),
Circle(u32),
TextLine(u32),
}
const SKELETON_ANIMATION_TIMER_ID: u32 = 0x534B;
pub struct SkeletonLoader {
base: BaseWidget,
shape: SkeletonShape,
animated: bool,
animation_counter: u32,
}
impl SkeletonLoader {
pub fn new(geometry: Rect) -> Self {
Self {
base: BaseWidget::new(WidgetKind::SkeletonLoader, geometry, "SkeletonLoader"),
shape: SkeletonShape::Rect(200, 20),
animated: true,
animation_counter: 0,
}
}
pub fn set_shape(&mut self, shape: SkeletonShape) {
self.shape = shape;
}
pub fn shape(&self) -> SkeletonShape {
self.shape
}
pub fn set_animated(&mut self, animated: bool) {
self.animated = animated;
}
pub fn is_animated(&self) -> bool {
self.animated
}
fn current_opacity(&self) -> f32 {
if !self.animated {
return 0.2;
}
let phase = self.animation_counter % 20;
let t = if phase < 10 { phase as f32 / 9.0 } else { (19 - phase) as f32 / 9.0 };
0.1 + 0.2 * t
}
}
impl Widget for SkeletonLoader {
fn base(&self) -> &BaseWidget {
&self.base
}
fn base_mut(&mut self) -> &mut BaseWidget {
&mut self.base
}
}
impl Draw for SkeletonLoader {
fn draw(&mut self, context: &mut RenderContext) {
let rect = self.geometry();
let cx = rect.x + rect.width as i32 / 2;
let cy = rect.y + rect.height as i32 / 2;
let opacity = self.current_opacity();
let base_color = Color::rgba(200, 200, 200, (opacity * 255.0).round() as u8);
match self.shape {
SkeletonShape::Rect(w, h) => {
let shape_rect = Rect::new(cx - w as i32 / 2, cy - h as i32 / 2, w, h);
context.fill_rounded_rect(shape_rect, 4, base_color);
}
SkeletonShape::Circle(r) => {
let center = Point::new(cx, cy);
context.fill_circle_aa(center, r, base_color);
}
SkeletonShape::TextLine(w) => {
let line_h: u32 = 12;
let gap: u32 = 6;
let total_h = 3 * line_h + 2 * gap;
let start_y = cy - total_h as i32 / 2;
for i in 0..3 {
let y = start_y + i * (line_h + gap) as i32;
let line_rect = Rect::new(cx - w as i32 / 2, y, w, line_h);
context.fill_rounded_rect(line_rect, 3, base_color);
}
}
}
}
}
impl EventHandler for SkeletonLoader {
fn handle_event(&mut self, event: &Event) {
if !self.base.is_enabled() {
return;
}
match event {
Event::Timer { id } if *id == SKELETON_ANIMATION_TIMER_ID => {
if self.animated {
self.animation_counter = self.animation_counter.wrapping_add(1);
self.base.request_redraw();
}
}
_ => {
self.base.handle_event(event);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::widget::svg::render_to_svg;
#[test]
fn skeleton_loader_default_creation() {
let sl = SkeletonLoader::new(Rect::new(0, 0, 200, 100));
assert_eq!(sl.kind(), WidgetKind::SkeletonLoader);
assert_eq!(sl.geometry(), Rect::new(0, 0, 200, 100));
assert_eq!(sl.shape(), SkeletonShape::Rect(200, 20));
assert!(sl.is_animated());
}
#[test]
fn skeleton_loader_rect_shape() {
let mut sl = SkeletonLoader::new(Rect::new(0, 0, 200, 100));
sl.set_shape(SkeletonShape::Rect(100, 50));
assert_eq!(sl.shape(), SkeletonShape::Rect(100, 50));
}
#[test]
fn skeleton_loader_circle_shape() {
let mut sl = SkeletonLoader::new(Rect::new(0, 0, 200, 100));
sl.set_shape(SkeletonShape::Circle(30));
assert_eq!(sl.shape(), SkeletonShape::Circle(30));
}
#[test]
fn skeleton_loader_text_line_shape() {
let mut sl = SkeletonLoader::new(Rect::new(0, 0, 200, 100));
sl.set_shape(SkeletonShape::TextLine(150));
assert_eq!(sl.shape(), SkeletonShape::TextLine(150));
}
#[test]
fn skeleton_loader_animated_timer_creates_oscillation() {
let mut sl = SkeletonLoader::new(Rect::new(0, 0, 200, 100));
assert!(sl.is_animated());
assert_eq!(sl.animation_counter, 0);
sl.handle_event(&Event::Timer { id: SKELETON_ANIMATION_TIMER_ID });
assert_eq!(sl.animation_counter, 1);
for _ in 0..10 {
sl.handle_event(&Event::Timer { id: SKELETON_ANIMATION_TIMER_ID });
}
assert_eq!(sl.animation_counter, 11);
let opacities: Vec<f32> = (0..20)
.map(|_| {
sl.handle_event(&Event::Timer { id: SKELETON_ANIMATION_TIMER_ID });
sl.current_opacity()
})
.collect();
let min_op = opacities.iter().cloned().fold(f32::MAX, f32::min);
let max_op = opacities.iter().cloned().fold(f32::MIN, f32::max);
assert!(min_op < 0.15, "minimum opacity should be near 0.1, got {}", min_op);
assert!(max_op > 0.25, "maximum opacity should be near 0.3, got {}", max_op);
sl.set_animated(false);
assert!(!sl.is_animated());
let static_op = sl.current_opacity();
assert!((static_op - 0.2).abs() < 0.01, "static opacity should be 0.2, got {}", static_op);
}
#[test]
fn skeleton_loader_svg_output() {
let mut sl = SkeletonLoader::new(Rect::new(0, 0, 200, 100));
let svg = render_to_svg(&mut sl);
assert!(svg.starts_with("<svg"));
assert!(svg.contains("width=\"200\""));
assert!(svg.contains("height=\"100\""));
}
#[test]
fn skeleton_loader_set_animated_flag() {
let mut sl = SkeletonLoader::new(Rect::new(0, 0, 200, 100));
assert!(sl.is_animated());
sl.set_animated(false);
assert!(!sl.is_animated());
sl.set_animated(true);
assert!(sl.is_animated());
}
}