use ratatui::widgets::Paragraph;
use super::{Component, RenderContext};
#[derive(Clone, Debug, Default, PartialEq, Eq)]
#[cfg_attr(
feature = "serialization",
derive(serde::Serialize, serde::Deserialize)
)]
pub enum SpinnerStyle {
#[default]
Dots,
Line,
Circle,
Bounce,
Custom(Vec<char>),
}
impl SpinnerStyle {
pub fn frames(&self) -> &[char] {
const DOTS: &[char] = &['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
const LINE: &[char] = &['|', '/', '-', '\\'];
const CIRCLE: &[char] = &['◐', '◓', '◑', '◒'];
const BOUNCE: &[char] = &['⠁', '⠂', '⠄', '⠂'];
const EMPTY: &[char] = &[' '];
match self {
SpinnerStyle::Dots => DOTS,
SpinnerStyle::Line => LINE,
SpinnerStyle::Circle => CIRCLE,
SpinnerStyle::Bounce => BOUNCE,
SpinnerStyle::Custom(frames) => {
if frames.is_empty() {
EMPTY
} else {
frames
}
}
}
}
pub fn frame_count(&self) -> usize {
self.frames().len()
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum SpinnerMessage {
Tick,
Start,
Stop,
}
#[derive(Clone, Debug, PartialEq)]
#[cfg_attr(
feature = "serialization",
derive(serde::Serialize, serde::Deserialize)
)]
pub struct SpinnerState {
style: SpinnerStyle,
frame: usize,
spinning: bool,
label: Option<String>,
disabled: bool,
}
impl Default for SpinnerState {
fn default() -> Self {
Self {
style: SpinnerStyle::default(),
frame: 0,
spinning: true,
label: None,
disabled: false,
}
}
}
impl SpinnerState {
pub fn new() -> Self {
Self::default()
}
pub fn with_style(style: SpinnerStyle) -> Self {
Self {
style,
..Self::default()
}
}
pub fn with_label(label: impl Into<String>) -> Self {
Self {
label: Some(label.into()),
..Self::default()
}
}
pub fn current_frame(&self) -> char {
let frames = self.style.frames();
frames[self.frame % frames.len()]
}
pub fn is_spinning(&self) -> bool {
self.spinning
}
pub fn set_spinning(&mut self, spinning: bool) {
self.spinning = spinning;
}
pub fn label(&self) -> Option<&str> {
self.label.as_deref()
}
pub fn set_label(&mut self, label: Option<String>) {
self.label = label;
}
pub fn style(&self) -> &SpinnerStyle {
&self.style
}
pub fn set_style(&mut self, style: SpinnerStyle) {
self.style = style;
self.frame = 0;
}
pub fn frame_index(&self) -> usize {
self.frame
}
pub fn is_disabled(&self) -> bool {
self.disabled
}
pub fn set_disabled(&mut self, disabled: bool) {
self.disabled = disabled;
}
pub fn with_disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
}
pub struct Spinner;
impl Component for Spinner {
type State = SpinnerState;
type Message = SpinnerMessage;
type Output = ();
fn init() -> Self::State {
SpinnerState::default()
}
fn update(state: &mut Self::State, msg: Self::Message) -> Option<Self::Output> {
match msg {
SpinnerMessage::Tick => {
if state.spinning {
let frame_count = state.style.frame_count();
state.frame = (state.frame + 1) % frame_count;
}
}
SpinnerMessage::Start => {
state.spinning = true;
}
SpinnerMessage::Stop => {
state.spinning = false;
}
}
None
}
fn view(state: &Self::State, ctx: &mut RenderContext<'_, '_>) {
let spinner_char = if state.spinning {
state.current_frame().to_string()
} else {
" ".to_string()
};
let text = match &state.label {
Some(label) => format!("{} {}", spinner_char, label),
None => spinner_char,
};
let paragraph = Paragraph::new(text).style(ctx.theme.info_style());
let annotation = crate::annotation::Annotation::spinner("spinner")
.with_label(state.label.as_deref().unwrap_or(""));
let annotated = crate::annotation::Annotate::new(paragraph, annotation);
ctx.frame.render_widget(annotated, ctx.area);
}
}
#[cfg(test)]
mod tests;