Skip to content

Commit b81c639

Browse files
committed
feat: Add throbber component
1 parent 0c12339 commit b81c639

16 files changed

Lines changed: 521 additions & 20 deletions

throbber.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
2+
Add this type of component called a "throbber" to the tui-components library, similar to the shimmer component.
3+
```rs
4+
// Use legacy spinner animation
5+
let frames = ["", "", "", "", "", "", "", "", "", ""];
6+
let spinner_text = format!(
7+
"{} {selected_agent} processing...",
8+
frames[model.loading_frame % frames.len()],
9+
);
10+
let spinner = Paragraph::new(spinner_text);
11+
frame.render_widget(spinner, shimmer_chunk);
12+
```
13+
14+
Make sure to add snapshot tests and an example for the throbber, similar to @

tui-components/Cargo.toml

Lines changed: 0 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -7,26 +7,6 @@ description = "Reusable TUI components built on Ratatui"
77
keywords = ["tui", "terminal", "ratatui", "components"]
88
categories = ["command-line-interface"]
99

10-
[[example]]
11-
name = "textarea"
12-
path = "examples/textarea_snapshots.rs"
13-
14-
[[example]]
15-
name = "key_hint"
16-
path = "examples/key_hint_snapshots.rs"
17-
18-
[[example]]
19-
name = "shimmer"
20-
path = "examples/shimmer_snapshots.rs"
21-
22-
[[example]]
23-
name = "live_wrap"
24-
path = "examples/live_wrap_snapshots.rs"
25-
26-
[[example]]
27-
name = "selection"
28-
path = "examples/selection_snapshots.rs"
29-
3010
[dependencies]
3111
ratatui = { version = "0.29", features = ["unstable-widget-ref"] }
3212
crossterm = { version = "0.28", features = ["bracketed-paste"] }
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
Lines changed: 278 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,278 @@
1+
use std::time::{Duration, Instant};
2+
3+
use color_eyre::Result;
4+
use crossterm::event::{self, Event, KeyCode, KeyModifiers};
5+
use ratatui::{
6+
Frame,
7+
layout::{Constraint, Direction, Layout},
8+
style::{Color, Style},
9+
widgets::{Paragraph, WidgetRef},
10+
};
11+
use tui_components::throbber::Throbber;
12+
13+
#[cfg(test)]
14+
use ratatui::{Terminal, backend::TestBackend};
15+
16+
// Interactive example application
17+
struct App {
18+
examples: Vec<ThrobberExample>,
19+
}
20+
21+
struct ThrobberExample {
22+
label: String,
23+
throbber: Throbber,
24+
mode: AnimationMode,
25+
}
26+
27+
enum AnimationMode {
28+
FixedFps,
29+
OnKeypress { elapsed: Duration },
30+
}
31+
32+
impl AnimationMode {
33+
fn badge(&self) -> &'static str {
34+
match self {
35+
AnimationMode::FixedFps => "30 FPS",
36+
AnimationMode::OnKeypress { .. } => "On Key",
37+
}
38+
}
39+
}
40+
41+
impl ThrobberExample {
42+
fn fixed(label: impl Into<String>, throbber: Throbber) -> Self {
43+
Self {
44+
label: label.into(),
45+
throbber,
46+
mode: AnimationMode::FixedFps,
47+
}
48+
}
49+
50+
fn on_keypress(label: impl Into<String>, throbber: Throbber) -> Self {
51+
Self {
52+
label: label.into(),
53+
throbber,
54+
mode: AnimationMode::OnKeypress {
55+
elapsed: Duration::default(),
56+
},
57+
}
58+
}
59+
}
60+
61+
impl App {
62+
fn new() -> Self {
63+
let mut examples = Vec::new();
64+
65+
// Fixed 30 FPS throbbers
66+
examples.push(ThrobberExample::fixed(
67+
"Basic Throbber",
68+
Throbber::new("Loading..."),
69+
));
70+
71+
let frames = ["|", "/", "-", "\\"];
72+
let throbber = Throbber::with_frames("Processing data...", &frames);
73+
examples.push(ThrobberExample::fixed("ASCII Frames", throbber));
74+
75+
let frames = ["◐", "◓", "◑", "◒"];
76+
let throbber = Throbber::with_frames("Analyzing...", &frames);
77+
examples.push(ThrobberExample::fixed("Circle Frames", throbber));
78+
79+
let throbber = Throbber::new("Loading… 🚀 Processing data 📊");
80+
examples.push(ThrobberExample::fixed("Unicode Text", throbber));
81+
82+
// Keypress-driven throbbers
83+
let throbber = Throbber::new("Performing complex calculations that take some time...");
84+
examples.push(ThrobberExample::on_keypress(
85+
"Long Text (Keypress)",
86+
throbber,
87+
));
88+
89+
let throbber = Throbber::new("");
90+
examples.push(ThrobberExample::on_keypress(
91+
"Empty Text (Keypress)",
92+
throbber,
93+
));
94+
95+
let frames = ["▏", "▎", "▍", "▌", "▋", "▊", "▉", "█"];
96+
let throbber = Throbber::with_frames("Syncing files...", &frames);
97+
examples.push(ThrobberExample::on_keypress(
98+
"Progress Bar (Keypress)",
99+
throbber,
100+
));
101+
102+
let frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
103+
let throbber = Throbber::with_frames("Error checking...", &frames);
104+
examples.push(ThrobberExample::on_keypress(
105+
"Braille Frames (Keypress)",
106+
throbber,
107+
));
108+
109+
Self { examples }
110+
}
111+
112+
fn run(&mut self, terminal: &mut ratatui::DefaultTerminal) -> Result<()> {
113+
let tick_rate = Duration::from_millis(33);
114+
let mut last_tick = Instant::now();
115+
116+
loop {
117+
let timeout = tick_rate
118+
.checked_sub(last_tick.elapsed())
119+
.unwrap_or_else(|| Duration::from_millis(0));
120+
let mut should_redraw = false;
121+
122+
if event::poll(timeout)? {
123+
match event::read()? {
124+
Event::Key(key) => {
125+
if key.code == KeyCode::Esc
126+
|| (key.code == KeyCode::Char('c')
127+
&& key.modifiers.contains(KeyModifiers::CONTROL))
128+
{
129+
break;
130+
}
131+
132+
self.advance_manual_examples(tick_rate);
133+
should_redraw = true;
134+
}
135+
Event::Resize(_, _) => {
136+
should_redraw = true;
137+
}
138+
_ => {}
139+
}
140+
}
141+
142+
if last_tick.elapsed() >= tick_rate {
143+
last_tick = Instant::now();
144+
should_redraw = true;
145+
}
146+
147+
if should_redraw {
148+
terminal.draw(|frame| self.draw(frame))?;
149+
}
150+
}
151+
Ok(())
152+
}
153+
154+
fn advance_manual_examples(&mut self, delta: Duration) {
155+
for example in &mut self.examples {
156+
if let AnimationMode::OnKeypress { elapsed } = &mut example.mode {
157+
*elapsed += delta;
158+
}
159+
}
160+
}
161+
162+
fn draw(&mut self, frame: &mut Frame) {
163+
let num_throbbers = self.examples.len();
164+
let constraints = vec![Constraint::Length(3); num_throbbers];
165+
166+
let chunks = Layout::default()
167+
.direction(Direction::Vertical)
168+
.constraints(constraints)
169+
.split(frame.area());
170+
171+
for (i, example) in self.examples.iter().enumerate() {
172+
let area = chunks[i];
173+
let inner_layout = Layout::default()
174+
.direction(Direction::Vertical)
175+
.constraints([
176+
Constraint::Length(1),
177+
Constraint::Length(1),
178+
Constraint::Length(1),
179+
])
180+
.split(area);
181+
182+
let label_text =
183+
Paragraph::new(format!("[ {} • {} ]", example.label, example.mode.badge()))
184+
.style(Style::default().fg(Color::Yellow));
185+
frame.render_widget(label_text, inner_layout[0]);
186+
187+
match &example.mode {
188+
AnimationMode::FixedFps => {
189+
example
190+
.throbber
191+
.render_ref(inner_layout[1], frame.buffer_mut());
192+
}
193+
AnimationMode::OnKeypress { elapsed } => {
194+
example.throbber.render_with_elapsed(
195+
*elapsed,
196+
inner_layout[1],
197+
frame.buffer_mut(),
198+
);
199+
}
200+
}
201+
}
202+
}
203+
}
204+
205+
fn main() -> Result<()> {
206+
color_eyre::install()?;
207+
let mut terminal = ratatui::init();
208+
let mut app = App::new();
209+
let result = app.run(&mut terminal);
210+
ratatui::restore();
211+
result
212+
}
213+
214+
#[test]
215+
fn test_throbber_basic() {
216+
let throbber = Throbber::new("Loading...");
217+
let mut terminal = Terminal::new(TestBackend::new(20, 1)).unwrap();
218+
terminal
219+
.draw(|frame| {
220+
frame.render_widget_ref(throbber, frame.area());
221+
})
222+
.unwrap();
223+
224+
insta::assert_snapshot!(terminal.backend());
225+
}
226+
227+
#[test]
228+
fn test_throbber_empty() {
229+
let throbber = Throbber::new("");
230+
let mut terminal = Terminal::new(TestBackend::new(20, 1)).unwrap();
231+
terminal
232+
.draw(|frame| {
233+
frame.render_widget_ref(throbber, frame.area());
234+
})
235+
.unwrap();
236+
237+
insta::assert_snapshot!(terminal.backend());
238+
}
239+
240+
#[test]
241+
fn test_throbber_long_text() {
242+
let throbber = Throbber::new("Processing a very long operation that takes time...");
243+
let mut terminal = Terminal::new(TestBackend::new(60, 1)).unwrap();
244+
terminal
245+
.draw(|frame| {
246+
frame.render_widget_ref(throbber, frame.area());
247+
})
248+
.unwrap();
249+
250+
insta::assert_snapshot!(terminal.backend());
251+
}
252+
253+
#[test]
254+
fn test_throbber_custom_frames() {
255+
let frames = ["|", "/", "-", "\\"];
256+
let throbber = Throbber::with_frames("Custom frames", &frames);
257+
let mut terminal = Terminal::new(TestBackend::new(20, 1)).unwrap();
258+
terminal
259+
.draw(|frame| {
260+
frame.render_widget_ref(throbber, frame.area());
261+
})
262+
.unwrap();
263+
264+
insta::assert_snapshot!(terminal.backend());
265+
}
266+
267+
#[test]
268+
fn test_throbber_unicode() {
269+
let throbber = Throbber::new("Loading… 🚀");
270+
let mut terminal = Terminal::new(TestBackend::new(20, 1)).unwrap();
271+
terminal
272+
.draw(|frame| {
273+
frame.render_widget_ref(throbber, frame.area());
274+
})
275+
.unwrap();
276+
277+
insta::assert_snapshot!(terminal.backend());
278+
}

tui-components/src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ pub mod render;
4343
// Animation and visual effects
4444
pub mod key_hint;
4545
pub mod shimmer;
46+
pub mod throbber;
4647

4748
// Text handling and utilities
4849
pub mod live_wrap;
@@ -68,6 +69,7 @@ pub use selection::{
6869
};
6970
pub use shimmer::Shimmer;
7071
pub use textarea::{TextArea, TextAreaConfig, TextAreaState};
72+
pub use throbber::Throbber;
7173
pub use wrapping::{
7274
RtOptions, prefix_lines, word_wrap_line, word_wrap_lines, word_wrap_lines_borrowed,
7375
};

0 commit comments

Comments
 (0)