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