use crate::core::{Color, Point, Rect};
use crate::event::{Event, EventHandler};
use crate::render::RenderContext;
use crate::widget::capability::coercion::{expect_bool, expect_string};
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::metrics::{dimensions, ControlMetrics};
use crate::widget::{BaseWidget, Draw, Widget, WidgetKind};
use crate::{impl_widget_property_hooks, property_names_of};
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum SkeletonShape {
Rect(u32, u32),
Circle(u32),
TextLine(u32),
}
pub fn skeleton_shape_to_str(shape: SkeletonShape) -> &'static str {
match shape {
SkeletonShape::Rect(..) => "rect",
SkeletonShape::Circle(..) => "circle",
SkeletonShape::TextLine(..) => "text_line",
}
}
fn expect_skeleton_shape(value: CapabilityValue) -> Result<SkeletonShape, CapabilityAccessError> {
match expect_string(value)?.as_str() {
"rect" => Ok(SkeletonShape::Rect(200, 20)),
"circle" => Ok(SkeletonShape::Circle(20)),
"text_line" => Ok(SkeletonShape::TextLine(200)),
_ => Err(CapabilityAccessError::TypeMismatch),
}
}
const SKELETON_ANIMATION_TIMER_ID: u32 = 0x534B;
const DEFAULT_PULSE_PERIOD_MS: u32 = 1200;
const PULSE_SLOW_MULTIPLE: u32 = 4;
pub struct SkeletonLoader {
base: BaseWidget,
shape: SkeletonShape,
animated: bool,
phase: f32,
}
impl SkeletonLoader {
pub fn new(geometry: Rect) -> Self {
Self {
base: BaseWidget::new(WidgetKind::SkeletonLoader, geometry, "SkeletonLoader"),
shape: SkeletonShape::Rect(200, 20),
animated: true,
phase: 0.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 triangle = 1.0 - (2.0 * self.phase - 1.0).abs();
0.1 + 0.2 * triangle
}
pub fn tick(&mut self, delta_ms: u32) -> bool {
if !self.animated {
return false;
}
self.phase = (self.phase + delta_ms as f32 / self.pulse_period_ms() as f32).fract();
self.base.request_redraw();
true
}
fn pulse_period_ms(&self) -> u32 {
let slow = crate::style::motion_tokens().2;
if slow == 0 {
DEFAULT_PULSE_PERIOD_MS
} else {
slow.saturating_mul(PULSE_SLOW_MULTIPLE).max(1)
}
}
}
impl Widget for SkeletonLoader {
fn base(&self) -> &BaseWidget {
&self.base
}
fn base_mut(&mut self) -> &mut BaseWidget {
&mut self.base
}
fn size_hint(&self) -> crate::core::Size {
crate::core::Size::new(300, 20)
}
fn tick(&mut self, delta_ms: u32) -> bool {
SkeletonLoader::tick(self, delta_ms)
}
fn is_animating(&self) -> bool {
self.animated
}
impl_draw_bridge!();
impl_widget_property_hooks!();
}
impl WidgetProperties for SkeletonLoader {
fn get(&self, name: &str) -> Result<CapabilityValue, CapabilityAccessError> {
match name {
"active" => Ok(CapabilityValue::Bool(self.is_animated())),
"shape" => Ok(CapabilityValue::String(skeleton_shape_to_str(self.shape()).to_string())),
_ => base_property_get(self, name),
}
}
fn set(&mut self, name: &str, value: CapabilityValue) -> Result<(), CapabilityAccessError> {
match name {
"active" => {
self.set_animated(expect_bool(value)?);
Ok(())
}
"shape" => {
self.set_shape(expect_skeleton_shape(value)?);
Ok(())
}
_ => base_property_set(self, name, value),
}
}
fn property_names(&self) -> &'static [&'static str] {
property_names_of!["active", "shape", BASE_PROPERTY_NAMES]
}
fn command(&mut self, name: &str) -> Result<(), CapabilityAccessError> {
match name {
"set_active" => Err(CapabilityAccessError::OutOfRange),
_ => Err(CapabilityAccessError::UnknownCommand),
}
}
}
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 style = self.base.style().clone();
let themed = crate::style::resolved_theme_style("skeleton_loader");
let themed_bg = themed.as_ref().and_then(|r| r.background_color);
let themed_text = themed.as_ref().and_then(|r| r.text_color);
let surface = style.background_color.or(themed_bg).unwrap_or(Color::WHITE);
let ink = style.text_color.or(themed_text).unwrap_or(Color::rgb(200, 200, 200));
let skeleton = surface.blend(&ink, 0.35);
let opacity = self.current_opacity();
let base_color =
Color::rgba(skeleton.r, skeleton.g, skeleton.b, (opacity * 255.0).round() as u8);
match self.shape {
SkeletonShape::Rect(w, h) => {
let w = w.min(rect.width).max(1);
let h = h.min(rect.height).max(1);
let shape_rect = ControlMetrics::center_in(rect, crate::core::Size::new(w, h));
context.fill_rounded_rect(shape_rect, 4, base_color);
}
SkeletonShape::Circle(r) => {
let r = r.min(rect.width.min(rect.height) / 2).max(1);
let center = Point::new(cx, cy);
context.fill_circle_aa(center, r, base_color);
}
SkeletonShape::TextLine(w) => {
let line_h = dimensions::SKELETON_ROW_HEIGHT;
let gap = dimensions::SKELETON_ROW_GAP;
let total_h = 3 * line_h + 2 * gap;
let start_y = cy - total_h as i32 / 2;
let row_w = w.clamp(1, rect.width.max(1));
for i in 0..3 {
let y = start_y + i * (line_h + gap) as i32;
let line_rect = Rect::new(cx - row_w as i32 / 2, y, row_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;
}
self.base.handle_event(event);
if let Event::Timer { id } = event {
if *id == SKELETON_ANIMATION_TIMER_ID && self.animated {
self.tick(16);
}
}
}
}
#[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_pulses_over_time_and_holds_still_when_static() {
let mut sl = SkeletonLoader::new(Rect::new(0, 0, 200, 100));
assert!(sl.is_animated());
let period = sl.pulse_period_ms();
let at_rest = sl.current_opacity();
let opacities: Vec<f32> = (0..10)
.map(|_| {
assert!(sl.tick(period / 10), "an animated pulse owes another frame");
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}");
assert!(
(sl.current_opacity() - at_rest).abs() < 0.02,
"one period returns to the start: {} vs {at_rest}",
sl.current_opacity()
);
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}");
assert!(!sl.tick(period), "a static placeholder must not request another frame");
sl.set_animated(true);
let before = sl.current_opacity();
sl.handle_event(&Event::Timer { id: SKELETON_ANIMATION_TIMER_ID });
assert_ne!(sl.current_opacity(), before, "the legacy timer still nudges the pulse");
}
#[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());
}
#[test]
fn a_text_line_skeleton_stacks_fixed_rows() {
let _theme_guard = crate::style::theme_test_guard();
let total = 3 * dimensions::SKELETON_ROW_HEIGHT + 2 * dimensions::SKELETON_ROW_GAP;
assert!(total < 120, "the default stack is shorter than the census cell");
for rect in [Rect::new(0, 0, 240, 120), Rect::new(0, 0, 240, 300)] {
let mut sl = SkeletonLoader::new(rect);
sl.set_shape(SkeletonShape::TextLine(180));
sl.set_animated(false);
let svg = render_to_svg(&mut sl);
assert_eq!(
svg.matches(&format!("height=\"{}\"", dimensions::SKELETON_ROW_HEIGHT)).count(),
3,
"three rows at SKELETON_ROW_HEIGHT in {rect:?}: {svg}"
);
}
}
#[test]
fn a_rect_skeleton_is_clamped_to_its_control_and_never_empty() {
let mut oversized = SkeletonLoader::new(Rect::new(0, 0, 100, 40));
oversized.set_shape(SkeletonShape::Rect(500, 500));
oversized.set_animated(false);
let svg = render_to_svg(&mut oversized);
assert!(
svg.contains("height=\"40\""),
"an oversized placeholder clamps to the control: {svg}"
);
let mut empty = SkeletonLoader::new(Rect::new(0, 0, 100, 40));
empty.set_shape(SkeletonShape::Rect(0, 0));
empty.set_animated(false);
let svg = render_to_svg(&mut empty);
assert!(!svg.contains("width=\"0\""), "nothing is emitted zero-wide: {svg}");
assert!(!svg.contains("height=\"0\""), "nothing is emitted zero-tall: {svg}");
}
#[test]
fn a_circle_skeleton_stays_square_and_inside() {
let mut sl = SkeletonLoader::new(Rect::new(0, 0, 60, 120));
sl.set_shape(SkeletonShape::Circle(200));
sl.set_animated(false);
let svg = render_to_svg(&mut sl);
assert!(!svg.contains("r=\"0\""), "a disc is never zero-radius: {svg}");
}
}