quadraui 0.6.1

Cross-platform Rust UI toolkit: write one AppLogic and run it unmodified on TUI, GTK4 (Linux), macOS, and Windows backends
Documentation
//! `Spinner` primitive: an indeterminate activity indicator. Used for
//! ongoing operations whose duration isn't known ahead of time —
//! LSP boot, git clone, Mason installs, project search.
//!
//! The spinner carries a `frame_idx` (app-incremented per tick) and a
//! label. Backends pick the glyph from their own frame table using
//! `frame_idx % frame_count`. TUI commonly uses braille frames
//! (`⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏`), GUI backends may use a rotating arc or native
//! equivalent.
//!
//! # Backend contract
//!
//! **Declarative snapshot.** The app advances `frame_idx` on its own
//! animation ticker (~100 ms for braille, faster for arcs). The
//! primitive has no built-in timer — it's a paint description only.
//! Backends rasterise the current glyph + label.
//!
//! # No [`crate::Style`] tokens
//!
//! `Toast`/`Tooltip`/`ContextMenu`/`Palette`/`Dialog` each read
//! [`crate::Style::corner_radius`]/`border_width`/`shadow_elevation`,
//! and `ProgressBar`'s track reads `corner_radius`. `Spinner` has no
//! bordered box at all — every backend paints a bare glyph-plus-label,
//! matching VS Code's own activity spinner (a rotating icon, no card)
//! — so there is no rectangle for a radius to round or a shadow to sit
//! behind. Each backend's `draw_spinner` stays untouched by that token
//! set.

use crate::event::Rect;
use crate::types::{Color, WidgetId};
use serde::{Deserialize, Serialize};

/// Declarative description of an indeterminate spinner.
///
/// # Examples
///
/// ```
/// use quadraui::{Spinner, SpinnerHit, SpinnerMeasure, WidgetId};
///
/// let spinner = Spinner {
///     id: WidgetId::new("spinner:lsp_boot"),
///     label: "Indexing…".to_string(),
///     frame_idx: 3,
///     accent: None,
/// };
///
/// let layout = spinner.layout(0.0, 0.0, SpinnerMeasure::new(80.0, 1.0));
/// assert_eq!(layout.hit_test(5.0, 0.0, &spinner.id), SpinnerHit::Body(spinner.id.clone()));
/// ```
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Spinner {
    pub id: WidgetId,
    /// Label shown next to the spinner, e.g. "Indexing…". Empty =
    /// glyph only.
    #[serde(default)]
    pub label: String,
    /// Animation frame index. Apps increment this on their ticker;
    /// backends render `frame_idx % frame_count` of their own frame
    /// table.
    #[serde(default)]
    pub frame_idx: usize,
    /// Optional accent colour for the glyph. `None` = backend decides.
    #[serde(default)]
    pub accent: Option<Color>,
}

// ── D6 Layout API ───────────────────────────────────────────────────────────

/// Measurement for a `Spinner`. Backends report the inline width of
/// the glyph + label at current font metrics.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct SpinnerMeasure {
    pub width: f32,
    pub height: f32,
}

impl SpinnerMeasure {
    pub fn new(width: f32, height: f32) -> Self {
        Self { width, height }
    }
}

/// Classification of a hit-test result. Spinners are not interactive —
/// only `Body` / `Empty`. `Body` is returned so apps can still use a
/// spinner click as "focus the originating operation" if they want.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SpinnerHit {
    Body(WidgetId),
    Empty,
}

/// Fully-resolved spinner layout.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct SpinnerLayout {
    pub bounds: Rect,
}

impl SpinnerLayout {
    pub fn hit_test(&self, x: f32, y: f32, id: &WidgetId) -> SpinnerHit {
        if x >= self.bounds.x
            && x < self.bounds.x + self.bounds.width
            && y >= self.bounds.y
            && y < self.bounds.y + self.bounds.height
        {
            SpinnerHit::Body(*id)
        } else {
            SpinnerHit::Empty
        }
    }
}

impl Spinner {
    /// Compute the spinner's bounds given backend measurement.
    ///
    /// # Arguments
    ///
    /// - `origin_x`, `origin_y` — top-left position in the parent
    ///   surface's coordinate space.
    /// - `measure` — width/height for the current `label` at font
    ///   metrics (TUI: glyph + space + label chars; GTK: Pango).
    pub fn layout(&self, origin_x: f32, origin_y: f32, measure: SpinnerMeasure) -> SpinnerLayout {
        SpinnerLayout {
            bounds: Rect::new(origin_x, origin_y, measure.width, measure.height),
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    // Spinner + ProgressBar primitive tests

    #[test]
    fn spinner_layout_bounds() {
        let s = Spinner {
            id: WidgetId::new("installing"),
            label: "Installing rust-analyzer…".to_string(),
            frame_idx: 42,
            accent: None,
        };
        let layout = s.layout(10.0, 20.0, SpinnerMeasure::new(200.0, 16.0));
        assert_eq!(layout.bounds.x, 10.0);
        assert_eq!(layout.bounds.y, 20.0);
        assert_eq!(layout.bounds.width, 200.0);
        // Hit-test on bounds returns Body(id).
        match layout.hit_test(100.0, 28.0, &s.id) {
            SpinnerHit::Body(id) => assert_eq!(id.as_str(), "installing"),
            _ => panic!(),
        }
        assert_eq!(layout.hit_test(500.0, 28.0, &s.id), SpinnerHit::Empty);
    }
}