aisling 0.3.17

Embeddable terminal text effects and loaders for Rust TUI applications.
Documentation
use std::env;
use std::io::{self, Write};
use std::process::Command;
use std::thread;
use std::time::Duration;

use aisling::{Cell, Color, Frame, Loader, LoaderConfig, LoaderKind, LoaderProgress};

const NEW_LOADERS: &[LoaderKind] = &[
    LoaderKind::Ember,
    LoaderKind::Cipher,
    LoaderKind::Glitch,
    LoaderKind::Vortex,
    LoaderKind::StormFlicker,
    LoaderKind::LaserSweep,
    LoaderKind::Cascade,
    LoaderKind::GridPulse,
];

#[derive(Clone, Copy, Debug)]
struct Args {
    seconds: u64,
    fps: u64,
    width_override: Option<usize>,
    height_override: Option<usize>,
}

fn main() {
    let args = Args::parse();
    let (terminal_width, terminal_height) = terminal_size();
    let width = args.width_override.unwrap_or(terminal_width.max(80));
    let height = args
        .height_override
        .unwrap_or(terminal_height.max(24))
        .max(24);

    let fps = args.fps.max(1);
    let total_ticks = (args.seconds.max(1) * fps) as usize;
    let per_loader_ticks = (total_ticks / NEW_LOADERS.len().max(1)).max(1);
    let mut tick = 0usize;

    let mut stdout = io::stdout();
    let _guard = TerminalGuard::enter(&mut stdout);

    loop {
        let local_cycle = tick / per_loader_ticks;
        let current = local_cycle % NEW_LOADERS.len();
        let local_tick = tick % per_loader_ticks;
        let progress =
            LoaderProgress::from_counts(local_tick as u64, per_loader_ticks.max(1) as u64);

        let kind = NEW_LOADERS[current];
        let mut screen = Frame::with_background(width, height, Some(Color::rgb(4, 7, 11)));

        draw_header(
            &mut screen,
            width,
            fps,
            current,
            kind,
            args.seconds,
            local_cycle + 1,
        );
        let inner_w = width.saturating_sub(4).max(20);
        let inner_h = height.saturating_sub(8).max(14);

        let config = LoaderConfig::default()
            .with_size(inner_w, inner_h)
            .with_duration(per_loader_ticks.max(1))
            .with_label(format!(
                "{} ({}/{})",
                kind.name(),
                current + 1,
                NEW_LOADERS.len()
            ));
        let loader = Loader::with_config(kind, config);
        let frame = loader.frame(local_tick, progress);
        overlay_frame(&mut screen, 2, 4, &frame);

        write!(stdout, "\x1b[H{}", screen.to_ansi_string()).unwrap();
        stdout.flush().unwrap();

        tick += 1;
        if tick >= total_ticks {
            break;
        }
        thread::sleep(Duration::from_millis(1000 / fps));
    }
}

fn draw_header(
    screen: &mut Frame,
    width: usize,
    fps: u64,
    current: usize,
    kind: LoaderKind,
    seconds: u64,
    cycle: usize,
) {
    put_text(
        screen,
        1,
        0,
        "Aisling new loader showcase",
        Color::rgb(255, 178, 76),
        true,
    );

    let status = format!(
        "pulse/effect variants | {}s | {}fps | variant {}/{} | {}",
        seconds,
        fps,
        current + 1,
        NEW_LOADERS.len(),
        kind.name(),
    );
    put_text(screen, 1, 1, &status, Color::rgb(126, 186, 255), false);

    let timeline = NEW_LOADERS
        .iter()
        .enumerate()
        .map(|(i, kind)| {
            if i == current {
                format!("[{kind}] ")
            } else {
                format!(" {kind} ")
            }
        })
        .collect::<String>();
    let mut timeline = timeline;
    if timeline.len() > width.saturating_sub(2) {
        timeline.truncate(width.saturating_sub(2));
    }
    put_text(
        screen,
        1,
        2,
        &format!("cycle #{:02} • {}", cycle, timeline),
        Color::rgb(130, 146, 160),
        false,
    );

    if width > 20 {
        let key = "Press Ctrl+C to stop, or let it run for the full duration.";
        let mut help = key.to_string();
        if help.len() > width.saturating_sub(2) {
            help.truncate(width.saturating_sub(2));
        }
        put_text(
            screen,
            1,
            height_hint(screen),
            &help,
            Color::rgb(86, 102, 118),
            false,
        );
    }
}

fn overlay_frame(screen: &mut Frame, x0: usize, y0: usize, src: &Frame) {
    for y in 0..src.height() {
        for x in 0..src.width() {
            if let Some(cell) = src.cell(x, y) {
                if cell.ch == ' ' && !cell.has_style() {
                    continue;
                }
                if x + x0 < screen.width() && y + y0 < screen.height() {
                    screen.set(x + x0, y + y0, cell.clone());
                }
            }
        }
    }
}

fn put_text(screen: &mut Frame, x: usize, y: usize, text: &str, color: Color, bold: bool) {
    if y >= screen.height() {
        return;
    }
    for (offset, ch) in text.chars().enumerate() {
        let px = x + offset;
        if px >= screen.width() {
            break;
        }
        screen.set(px, y, colored_cell(ch, color, None, bold, false));
    }
}

fn height_hint(screen: &Frame) -> usize {
    screen.height().saturating_sub(1).max(3)
}

fn colored_cell(ch: char, fg: Color, bg: Option<Color>, bold: bool, dim: bool) -> Cell {
    let mut cell = Cell::new(ch);
    cell.colors.fg = Some(fg);
    cell.colors.bg = bg;
    cell.bold = bold;
    cell.dim = dim;
    cell
}

impl Args {
    fn parse() -> Self {
        let mut args = env::args().skip(1);
        let mut parsed = Self {
            seconds: 24,
            fps: 10,
            width_override: None,
            height_override: None,
        };

        while let Some(arg) = args.next() {
            match arg.as_str() {
                "--seconds" => {
                    parsed.seconds = args
                        .next()
                        .and_then(|v| v.parse().ok())
                        .unwrap_or(parsed.seconds);
                }
                "--fps" => {
                    parsed.fps = args
                        .next()
                        .and_then(|v| v.parse().ok())
                        .unwrap_or(parsed.fps);
                }
                "--width" => {
                    parsed.width_override = args.next().and_then(|v| v.parse().ok());
                }
                "--height" => {
                    parsed.height_override = args.next().and_then(|v| v.parse().ok());
                }
                "--help" | "-h" | "help" => {
                    usage();
                    std::process::exit(0);
                }
                _ => {}
            }
        }

        parsed
    }
}

fn usage() {
    println!("Usage:");
    println!("  cargo run --example new_loaders -- [options]");
    println!("Options:");
    println!("  --seconds <n>      Total seconds to run (default: 24)");
    println!("  --fps <n>          Frame rate (default: 10)");
    println!("  --width <n>        Terminal width to target");
    println!("  --height <n>       Terminal height to target");
    println!("  --help             Show this help text");
}

fn terminal_size() -> (usize, usize) {
    let env_size = env::var("COLUMNS")
        .ok()
        .and_then(|columns| columns.parse::<usize>().ok())
        .zip(
            env::var("LINES")
                .ok()
                .and_then(|lines| lines.parse::<usize>().ok()),
        );

    if let Some((columns, lines)) = env_size {
        return (columns, lines);
    }

    let output = Command::new("stty").arg("size").output();
    if let Ok(output) = output {
        if output.status.success() {
            if let Ok(text) = String::from_utf8(output.stdout) {
                let mut parts = text.split_whitespace();
                if let (Some(lines), Some(columns)) = (parts.next(), parts.next()) {
                    if let (Ok(lines), Ok(columns)) =
                        (lines.parse::<usize>(), columns.parse::<usize>())
                    {
                        return (columns, lines);
                    }
                }
            }
        }
    }

    (120, 40)
}

struct TerminalGuard;

impl TerminalGuard {
    fn enter(stdout: &mut io::Stdout) -> Self {
        write!(stdout, "\x1b[?25l\x1b[2J\x1b[H").unwrap();
        stdout.flush().unwrap();
        Self
    }
}

impl Drop for TerminalGuard {
    fn drop(&mut self) {
        let mut stdout = io::stdout();
        write!(stdout, "\x1b[?25h\n").unwrap();
        stdout.flush().unwrap();
    }
}