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 {
#[arg(long, default_value_t = 5000)]
balls: usize,
#[arg(long)]
no_shapes: bool,
#[arg(long)]
color: bool,
#[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")
}