ballin 0.1.2

A colorful interactive physics simulator with thousands of balls, but in your terminal.
Documentation
//! Ballin - A physics simulation in the terminal.
//!
//! Run thousands of bouncing balls in your terminal using Unicode Braille
//! characters for high-resolution rendering. Press ? for help.

use std::io::{self, stdout};
use std::time::Duration;

use anyhow::{Context, Result};
use clap::Parser;
use crossterm::{
    event::{self, DisableMouseCapture, EnableMouseCapture, Event},
    execute,
    terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::prelude::*;
use tracing::error;

use ballin::event::{handle_key_event, handle_mouse_event, handle_resize_event};
use ballin::{App, MAX_BALLS};

#[derive(Parser, Debug)]
#[command(name = "ballin")]
#[command(about = "A physics simulation with thousands of bouncing balls in the terminal")]
struct Args {
    /// Initial number of balls
    #[arg(long, default_value_t = 5000)]
    balls: usize,

    /// Disable automatic placement of shapes on startup
    #[arg(long)]
    no_shapes: bool,

    /// Enable Color Mode (geysers color balls, random spawn colors)
    #[arg(long)]
    color: bool,

    /// Open a saved level configuration JSON file on startup
    #[arg(long, value_name = "FILE")]
    open: Option<String>,
}

const POLL_TIMEOUT_MS: u64 = 1;

fn main() -> Result<()> {
    let args = Args::parse();
    let initial_balls = args.balls.min(MAX_BALLS);
    let place_shapes = !args.no_shapes;
    let color_mode = args.color;
    let open_path = args.open;

    tracing_subscriber::fmt()
        .with_env_filter(
            tracing_subscriber::EnvFilter::from_default_env()
                .add_directive(tracing::Level::WARN.into()),
        )
        .with_target(false)
        .init();

    let result = run_app(initial_balls, place_shapes, color_mode, open_path);

    if let Err(e) = restore_terminal() {
        error!("Failed to restore terminal: {}", e);
    }

    result
}

fn run_app(
    initial_balls: usize,
    place_shapes: bool,
    color_mode: bool,
    open_path: Option<String>,
) -> Result<()> {
    let mut terminal = setup_terminal().context("Failed to setup terminal")?;
    let size = terminal.size().context("Failed to get terminal size")?;
    let should_place_shapes = place_shapes && open_path.is_none();

    let mut app = App::new(
        size.width,
        size.height,
        initial_balls,
        should_place_shapes,
        color_mode,
    )
    .context("Failed to create application")?;

    if let Some(path) = open_path {
        if let Err(e) = app.load_level_from_path(&path) {
            error!("Failed to load level file '{}': {}", path, e);
        }
    }

    while app.is_running() {
        let delta = app.begin_frame();

        if event::poll(Duration::from_millis(POLL_TIMEOUT_MS))
            .context("Failed to poll for events")?
        {
            let event = event::read().context("Failed to read event")?;
            handle_terminal_event(&mut app, event)?;
        }

        app.update_physics(delta);

        terminal
            .draw(|frame| {
                app.render(frame);
            })
            .context("Failed to draw frame")?;

        app.end_frame();
    }

    Ok(())
}

fn handle_terminal_event(app: &mut App, event: Event) -> Result<()> {
    let ctx = app.event_context();
    let app_event = match event {
        Event::Key(key_event) => handle_key_event(key_event, &ctx),
        Event::Mouse(mouse_event) => handle_mouse_event(mouse_event, &ctx),
        Event::Resize(width, height) => {
            handle_resize_event(width, height, ctx.terminal_width, ctx.terminal_height)
        }
        _ => ballin::event::AppEvent::None,
    };
    app.handle_event(app_event)
        .context("Failed to handle event")?;
    Ok(())
}

fn setup_terminal() -> Result<Terminal<CrosstermBackend<io::Stdout>>> {
    enable_raw_mode().context("Failed to enable raw mode")?;
    let mut stdout = stdout();
    execute!(stdout, EnterAlternateScreen, EnableMouseCapture)
        .context("Failed to enter alternate screen")?;
    let backend = CrosstermBackend::new(stdout);
    Terminal::new(backend).context("Failed to create terminal")
}

fn restore_terminal() -> Result<()> {
    disable_raw_mode().context("Failed to disable raw mode")?;
    execute!(stdout(), LeaveAlternateScreen, DisableMouseCapture)
        .context("Failed to leave alternate screen")
}