1use std::env;
2use std::io::{self, Write};
3use std::process::Command;
4use std::str::FromStr;
5use std::thread;
6use std::time::Duration;
7
8use aisling::{Cell, Color, Frame, Loader, LoaderConfig, LoaderKind, LoaderProgress};
9
10fn main() {
11 let args = Args::parse();
12 if args.list {
13 for kind in LoaderKind::all() {
14 println!("{}", kind.name());
15 }
16 return;
17 }
18
19 let frame_delay = Duration::from_millis((1000 / args.fps.max(1)) as u64);
20 let total_ticks = (args.seconds.unwrap_or(20) * args.fps.max(1)) as usize;
21 let progress_cycle = total_ticks.max(1);
22 let terminal = terminal_size();
23 let mut stdout = io::stdout();
24 let _guard = TerminalGuard::enter(&mut stdout);
25 let mut tick = 0usize;
26
27 loop {
28 write!(stdout, "\x1b[H").unwrap();
29 if args.all {
30 render_all(&mut stdout, &args, tick, progress_cycle, terminal).unwrap();
31 } else {
32 let progress =
33 LoaderProgress::from_counts((tick % progress_cycle) as u64, progress_cycle as u64);
34 let loader = Loader::with_config(args.kind, args.config_for(args.kind));
35 write!(stdout, "{}", loader.frame(tick, progress).to_ansi_string()).unwrap();
36 }
37 stdout.flush().unwrap();
38
39 tick += 1;
40 if args.once || args.seconds.is_some() && tick >= total_ticks {
41 break;
42 }
43 thread::sleep(frame_delay);
44 }
45}
46
47#[derive(Clone, Debug)]
48struct Args {
49 kind: LoaderKind,
50 all: bool,
51 list: bool,
52 label: String,
53 unit: String,
54 width: usize,
55 height: usize,
56 seconds: Option<u64>,
57 fps: u64,
58 once: bool,
59 no_color: bool,
60}
61
62impl Args {
63 fn parse() -> Self {
64 let mut args = env::args().skip(1);
65 let mut parsed = Self {
66 kind: LoaderKind::Tqdm,
67 all: false,
68 list: false,
69 label: "download".to_string(),
70 unit: "B".to_string(),
71 width: 64,
72 height: 1,
73 seconds: Some(20),
74 fps: 10,
75 once: false,
76 no_color: false,
77 };
78
79 while let Some(arg) = args.next() {
80 match arg.as_str() {
81 "all" => parsed.all = true,
82 "list" => parsed.list = true,
83 "-h" | "--help" | "help" => {
84 usage();
85 std::process::exit(0);
86 }
87 "--label" => {
88 if let Some(value) = args.next() {
89 parsed.label = value;
90 }
91 }
92 "--unit" => {
93 if let Some(value) = args.next() {
94 parsed.unit = value;
95 }
96 }
97 "--width" => {
98 parsed.width = args
99 .next()
100 .and_then(|value| value.parse().ok())
101 .unwrap_or(parsed.width)
102 }
103 "--height" => {
104 parsed.height = args
105 .next()
106 .and_then(|value| value.parse().ok())
107 .unwrap_or(parsed.height)
108 }
109 "--seconds" => parsed.seconds = args.next().and_then(|value| value.parse().ok()),
110 "--fps" => {
111 parsed.fps = args
112 .next()
113 .and_then(|value| value.parse().ok())
114 .unwrap_or(parsed.fps)
115 }
116 "--forever" => parsed.seconds = None,
117 "--once" => parsed.once = true,
118 "--no-color" => parsed.no_color = true,
119 value => {
120 if let Ok(kind) = LoaderKind::from_str(value) {
121 parsed.kind = kind;
122 }
123 }
124 }
125 }
126 parsed.width = parsed.width.max(12);
127 parsed.height = parsed.height.max(1);
128 parsed
129 }
130
131 fn config_for(&self, kind: LoaderKind) -> LoaderConfig {
132 let height = if kind.is_effect_style() {
133 self.height.max(5)
134 } else {
135 self.height
136 };
137 let config = LoaderConfig::default()
138 .with_size(self.width, height)
139 .with_duration((self.seconds.unwrap_or(20) * self.fps.max(1)) as usize)
140 .with_label(self.label.clone())
141 .with_unit(self.unit.clone())
142 .with_fraction(matches!(
143 kind,
144 LoaderKind::Tqdm | LoaderKind::Download | LoaderKind::Upload
145 ));
146 if self.no_color {
147 config.without_color()
148 } else {
149 config
150 }
151 }
152}
153
154fn render_all(
155 stdout: &mut io::Stdout,
156 args: &Args,
157 tick: usize,
158 total_ticks: usize,
159 terminal: (usize, usize),
160) -> io::Result<()> {
161 let (screen_width, screen_height) = terminal;
162 let layout = GridLayout::new(
163 screen_width.max(96),
164 screen_height.max(44),
165 LoaderKind::all().len(),
166 );
167 let mut screen =
168 Frame::with_background(layout.width, layout.height, Some(Color::rgb(4, 7, 11)));
169 put_text(
170 &mut screen,
171 1,
172 0,
173 "Aisling loader effects",
174 Color::rgb(255, 178, 76),
175 true,
176 );
177 put_text(
178 &mut screen,
179 1,
180 1,
181 "all loaders at once | slower grid playback | pass --fps N or --seconds N to tune",
182 Color::rgb(126, 186, 255),
183 false,
184 );
185
186 for (index, kind) in LoaderKind::all().iter().enumerate() {
187 let progress_tick = (tick + index * 5) % total_ticks.max(1);
188 let progress = LoaderProgress::from_counts(progress_tick as u64, total_ticks.max(1) as u64);
189 let x = (index % layout.columns) * layout.card_width;
190 let y = layout.header_height + (index / layout.columns) * layout.card_height;
191 let card_width = layout.card_width.min(layout.width.saturating_sub(x));
192 let card_height = layout.card_height.min(layout.height.saturating_sub(y));
193 let inner_width = card_width.saturating_sub(2).max(1);
194 let inner_height = card_height.saturating_sub(1).max(1).min(args.height.max(1));
195 let config = args
196 .config_for(*kind)
197 .with_label(kind.name())
198 .with_size(inner_width, inner_height);
199 let loader = Loader::with_config(*kind, config);
200 let frame = loader.frame(tick + index, progress);
201 draw_card(
202 &mut screen,
203 &layout,
204 x,
205 y,
206 card_width,
207 card_height,
208 *kind,
209 &frame,
210 );
211 }
212 write!(stdout, "{}", screen.to_ansi_string())?;
213 Ok(())
214}
215
216#[derive(Clone, Copy, Debug)]
217struct GridLayout {
218 width: usize,
219 height: usize,
220 columns: usize,
221 card_width: usize,
222 card_height: usize,
223 header_height: usize,
224}
225
226impl GridLayout {
227 fn new(width: usize, height: usize, count: usize) -> Self {
228 let header_height = 2;
229 let usable_height = height.saturating_sub(header_height).max(1);
230 let max_columns = (width / 12).clamp(1, 8).min(count.max(1));
231 let mut best = None;
232 for columns in 1..=max_columns {
233 let rows = count.div_ceil(columns).max(1);
234 let card_height = usable_height / rows;
235 let card_width = width / columns;
236 let score = if card_height >= 3 {
237 card_height * 100 + card_width
238 } else {
239 columns * 10 + card_height
240 };
241 if best.map_or(true, |(_, best_score)| score > best_score) {
242 best = Some((columns, score));
243 }
244 }
245 let columns = best.map(|(columns, _)| columns).unwrap_or(1);
246 let rows = count.div_ceil(columns).max(1);
247 Self {
248 width,
249 height,
250 columns,
251 card_width: (width / columns).max(1),
252 card_height: (usable_height / rows).max(1),
253 header_height,
254 }
255 }
256}
257
258fn draw_card(
259 screen: &mut Frame,
260 layout: &GridLayout,
261 x: usize,
262 y: usize,
263 width: usize,
264 height: usize,
265 kind: LoaderKind,
266 frame: &Frame,
267) {
268 if width == 0 || height == 0 {
269 return;
270 }
271 let pane_bg = Color::rgb(9, 14, 22);
272 let border = Color::rgb(48, 72, 96);
273 let title = loader_title_color(kind);
274 for py in 0..height {
275 for px in 0..width {
276 screen.set(
277 x + px,
278 y + py,
279 colored_cell(' ', Color::rgb(40, 48, 56), Some(pane_bg), false, false),
280 );
281 }
282 }
283 if height >= 3 && width >= 8 {
284 for px in 0..width {
285 screen.set(
286 x + px,
287 y,
288 colored_cell('-', border, Some(pane_bg), false, true),
289 );
290 }
291 screen.set(x, y, colored_cell('+', border, Some(pane_bg), false, false));
292 screen.set(
293 x + width - 1,
294 y,
295 colored_cell('+', border, Some(pane_bg), false, false),
296 );
297 put_text(screen, x + 2, y, kind.name(), title, true);
298 } else {
299 put_text(screen, x, y, kind.name(), title, true);
300 }
301 if height == 1 {
302 return;
303 }
304
305 let inner_y = y + 1;
306 let inner_x = if width > 2 { x + 1 } else { x };
307 let inner_width = width.saturating_sub(2).max(1);
308 let inner_height = height.saturating_sub(1).max(1);
309 for fy in 0..frame.height().min(inner_height) {
310 for fx in 0..frame.width().min(inner_width) {
311 if let Some(cell) = frame.cell(fx, fy) {
312 if cell.ch != ' ' || cell.has_style() {
313 screen.set(inner_x + fx, inner_y + fy, cell.clone());
314 }
315 }
316 }
317 }
318
319 if layout.card_height <= 2 && width > 8 {
320 let percent_x = x + width.saturating_sub(5);
321 put_text(
322 screen,
323 percent_x,
324 y,
325 " ",
326 Color::rgb(120, 132, 144),
327 false,
328 );
329 }
330}
331
332fn put_text(screen: &mut Frame, x: usize, y: usize, text: &str, color: Color, bold: bool) {
333 if y >= screen.height() {
334 return;
335 }
336 for (offset, ch) in text.chars().enumerate() {
337 let px = x + offset;
338 if px >= screen.width() {
339 break;
340 }
341 screen.set(px, y, colored_cell(ch, color, None, bold, false));
342 }
343}
344
345fn colored_cell(ch: char, fg: Color, bg: Option<Color>, bold: bool, dim: bool) -> Cell {
346 let mut cell = Cell::new(ch);
347 cell.colors.fg = Some(fg);
348 cell.colors.bg = bg;
349 cell.bold = bold;
350 cell.dim = dim;
351 cell
352}
353
354fn loader_title_color(kind: LoaderKind) -> Color {
355 match kind {
356 LoaderKind::Burn
357 | LoaderKind::BouncyBalls
358 | LoaderKind::Crumble
359 | LoaderKind::Ember
360 | LoaderKind::Fireworks
361 | LoaderKind::Spray
362 | LoaderKind::StormFlicker
363 | LoaderKind::Thunderstorm
364 | LoaderKind::Unstable => Color::rgb(255, 142, 74),
365 LoaderKind::BinaryPath
366 | LoaderKind::ColorShift
367 | LoaderKind::Decrypt
368 | LoaderKind::ErrorCorrect
369 | LoaderKind::Cipher
370 | LoaderKind::GridPulse
371 | LoaderKind::Matrix
372 | LoaderKind::RandomSequence
373 | LoaderKind::SynthGrid
374 | LoaderKind::Wipe => Color::rgb(80, 255, 170),
375 LoaderKind::Beams
376 | LoaderKind::Bubbles
377 | LoaderKind::Cascade
378 | LoaderKind::LaserEtch
379 | LoaderKind::LaserSweep
380 | LoaderKind::Overflow
381 | LoaderKind::Pour
382 | LoaderKind::Rain
383 | LoaderKind::Slide
384 | LoaderKind::VhsTape
385 | LoaderKind::Waves => Color::rgb(104, 216, 255),
386 LoaderKind::Blackhole
387 | LoaderKind::Expand
388 | LoaderKind::Glitch
389 | LoaderKind::MiddleOut
390 | LoaderKind::OrbittingVolley
391 | LoaderKind::Rings
392 | LoaderKind::Scattered
393 | LoaderKind::Slice
394 | LoaderKind::Smoke
395 | LoaderKind::Spotlights
396 | LoaderKind::Swarm
397 | LoaderKind::Sweep
398 | LoaderKind::Vortex => Color::rgb(178, 154, 255),
399 LoaderKind::Highlight | LoaderKind::Print => Color::rgb(255, 232, 128),
400 _ => Color::rgb(226, 236, 244),
401 }
402}
403
404fn terminal_size() -> (usize, usize) {
405 let env_size = env::var("COLUMNS")
406 .ok()
407 .and_then(|columns| columns.parse::<usize>().ok())
408 .zip(
409 env::var("LINES")
410 .ok()
411 .and_then(|lines| lines.parse::<usize>().ok()),
412 );
413 if let Some((columns, lines)) = env_size {
414 return (columns, lines);
415 }
416 let output = Command::new("stty").arg("size").output();
417 if let Ok(output) = output {
418 if output.status.success() {
419 if let Ok(text) = String::from_utf8(output.stdout) {
420 let mut parts = text.split_whitespace();
421 if let (Some(lines), Some(columns)) = (parts.next(), parts.next()) {
422 if let (Ok(lines), Ok(columns)) =
423 (lines.parse::<usize>(), columns.parse::<usize>())
424 {
425 return (columns, lines);
426 }
427 }
428 }
429 }
430 }
431 (132, 44)
432}
433
434fn usage() {
435 println!("Usage:");
436 println!(" cargo run --example loaders -- [loader|all|list] [options]");
437 println!();
438 println!("Examples:");
439 println!(" cargo run --example loaders -- tqdm --label download");
440 println!(" cargo run --example loaders -- all --seconds 20");
441 println!(" cargo run --example loaders -- list");
442}
443
444struct TerminalGuard;
445
446impl TerminalGuard {
447 fn enter(stdout: &mut io::Stdout) -> Self {
448 write!(stdout, "\x1b[?25l\x1b[2J\x1b[H").unwrap();
449 stdout.flush().unwrap();
450 Self
451 }
452}
453
454impl Drop for TerminalGuard {
455 fn drop(&mut self) {
456 let mut stdout = io::stdout();
457 write!(stdout, "\x1b[?25h\n").unwrap();
458 stdout.flush().unwrap();
459 }
460}