Skip to main content

textual_rs/widget/
loading_indicator.rs

1//! Loading spinner widget and overlay helper for showing async loading state.
2use ratatui::buffer::Buffer;
3use ratatui::layout::Rect;
4use ratatui::style::{Color, Style};
5
6use super::context::AppContext;
7use super::Widget;
8
9/// Braille spinner frames — 8 characters, one full rotation.
10/// At 30fps app tick with frame = tick/2, animation runs at ~15fps (one cycle per ~533ms).
11/// These are: ⣾ ⣽ ⣻ ⢿ ⡿ ⣟ ⣯ ⣷
12const SPINNER_FRAMES: [char; 8] = [
13    '\u{28FE}', '\u{28FD}', '\u{28FB}', '\u{283F}', '\u{285F}', '\u{289F}', '\u{28AF}', '\u{28B7}',
14];
15
16/// A standalone loading spinner widget.
17///
18/// Renders a braille spinner animation centered in its area.
19/// When `ctx.skip_animations` is true, renders static "Loading..." text instead,
20/// which is safe for snapshot tests.
21///
22/// # Default CSS
23/// ```css
24/// LoadingIndicator { width: 100%; height: 100%; min-height: 1; }
25/// ```
26///
27/// # Example
28/// ```no_run
29/// # use textual_rs::LoadingIndicator;
30/// # use textual_rs::widget::layout::Vertical;
31/// let layout = Vertical::with_children(vec![
32///     Box::new(LoadingIndicator::new()),
33/// ]);
34/// ```
35pub struct LoadingIndicator;
36
37impl LoadingIndicator {
38    /// Create a new LoadingIndicator.
39    pub fn new() -> Self {
40        Self
41    }
42}
43
44impl Default for LoadingIndicator {
45    fn default() -> Self {
46        Self::new()
47    }
48}
49
50impl Widget for LoadingIndicator {
51    fn widget_type_name(&self) -> &'static str {
52        "LoadingIndicator"
53    }
54
55    fn default_css() -> &'static str
56    where
57        Self: Sized,
58    {
59        "LoadingIndicator { width: 100%; height: 100%; min-height: 1; }"
60    }
61
62    fn render(&self, ctx: &AppContext, area: Rect, buf: &mut Buffer) {
63        if area.height == 0 || area.width == 0 {
64            return;
65        }
66
67        let style = Style::default().fg(Color::Rgb(0, 255, 163)); // accent green
68
69        if ctx.skip_animations {
70            // Deterministic: static text for snapshot tests
71            let text = "Loading...";
72            let x = area.x + area.width.saturating_sub(text.len() as u16) / 2;
73            let y = area.y + area.height / 2;
74            buf.set_string(x, y, text, style);
75            return;
76        }
77
78        // Animated: use spinner_tick from AppContext for synchronized animation
79        let frame_idx = (ctx.spinner_tick.get() / 2) as usize % SPINNER_FRAMES.len();
80        let ch = SPINNER_FRAMES[frame_idx];
81        let x = area.x + area.width / 2;
82        let y = area.y + area.height / 2;
83        buf.set_string(x, y, ch.to_string(), style);
84    }
85}
86
87/// Draw a loading spinner overlay on top of a widget's area.
88///
89/// Called by `render_widget_tree` when a widget's ID is in `ctx.loading_widgets`.
90/// Fills the area with a dark semi-opaque background and centers a spinner character.
91///
92/// `tick` is `ctx.spinner_tick.get()`. `skip_animations` gates deterministic mode.
93pub fn draw_loading_spinner_overlay(area: Rect, buf: &mut Buffer, tick: u8, skip_animations: bool) {
94    if area.height == 0 || area.width == 0 {
95        return;
96    }
97
98    // Fill area with dark background to dim the underlying widget
99    let bg_style = Style::default().bg(Color::Rgb(20, 20, 28));
100    for y in area.y..area.y + area.height {
101        for x in area.x..area.x + area.width {
102            if let Some(cell) = buf.cell_mut((x, y)) {
103                cell.set_char(' ');
104                cell.set_style(bg_style);
105            }
106        }
107    }
108
109    // Center the spinner or static text
110    let fg_style = Style::default()
111        .fg(Color::Rgb(0, 255, 163))
112        .bg(Color::Rgb(20, 20, 28));
113
114    if skip_animations {
115        let text = "Loading...";
116        let x = area.x + area.width.saturating_sub(text.len() as u16) / 2;
117        let y = area.y + area.height / 2;
118        buf.set_string(x, y, text, fg_style);
119    } else {
120        let frame_idx = (tick / 2) as usize % SPINNER_FRAMES.len();
121        let ch = SPINNER_FRAMES[frame_idx];
122        let x = area.x + area.width / 2;
123        let y = area.y + area.height / 2;
124        buf.set_string(x, y, ch.to_string(), fg_style);
125    }
126}