Skip to main content

loaders/
loaders.rs

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}