use anyhow::Result;
use clap::Parser;
use crossterm::{
event::{
self, DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture,
Event,
},
execute,
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
};
use o2_rs::{
core::app::{EditorState, PopupType},
editor::input,
ui::render,
};
use ratatui::{Terminal, backend::CrosstermBackend};
use std::{
io::{self, Stdout},
path::PathBuf,
time::{Duration, Instant},
};
#[derive(Parser, Debug)]
#[command(
name = "o2",
version,
override_usage = "o2 [options] [file]",
disable_help_flag = true,
disable_version_flag = true,
help_template = "Usage: {usage}\n\n{all-args}"
)]
struct Cli {
#[arg(
long,
default_value_t = 100,
hide_default_value = true,
value_name = "number",
help_heading = "General options",
verbatim_doc_comment
)]
undo_limit: usize,
#[arg(
long,
value_parser = parse_size,
value_name = "nxn",
help_heading = "General options",
verbatim_doc_comment
)]
initial_size: Option<(usize, usize)>,
#[arg(
long,
default_value_t = 120,
hide_default_value = true,
value_name = "number",
help_heading = "General options",
verbatim_doc_comment
)]
bpm: usize,
#[arg(
long,
default_value_t = 1,
hide_default_value = true,
value_name = "number",
help_heading = "General options",
verbatim_doc_comment
)]
seed: u64,
#[arg(
short = 'h',
long = "help",
action = clap::ArgAction::Help,
help_heading = "General options"
)]
help: Option<bool>,
#[arg(
short = 'V',
long = "version",
action = clap::ArgAction::Version,
help_heading = "General options"
)]
version: Option<bool>,
#[arg(long, help_heading = "OSC/MIDI options", verbatim_doc_comment)]
strict_timing: bool,
#[arg(long, help_heading = "OSC/MIDI options", verbatim_doc_comment)]
osc_midi_bidule: Option<String>,
#[arg(value_name = "file", hide = true)]
file: Option<PathBuf>,
}
struct TerminalGuard;
impl Drop for TerminalGuard {
fn drop(&mut self) {
restore_terminal();
}
}
fn parse_size(s: &str) -> Result<(usize, usize), String> {
let parts: Vec<&str> = s.split('x').collect();
if parts.len() != 2 {
return Err("Expected format NxM (e.g. 57x25)".to_string());
}
let w = parts[0].parse().map_err(|_| "Invalid width")?;
let h = parts[1].parse().map_err(|_| "Invalid height")?;
Ok((w, h))
}
fn restore_terminal() {
let _ = disable_raw_mode();
let _ = execute!(
io::stdout(),
crossterm::style::ResetColor,
LeaveAlternateScreen,
DisableMouseCapture,
DisableBracketedPaste,
crossterm::cursor::Show
);
}
fn emergency_save(app: &EditorState) {
let save_path = if let Some(path) = &app.current_file {
let mut os_string = path.as_os_str().to_os_string();
os_string.push(".save");
PathBuf::from(os_string)
} else {
PathBuf::from(format!("patch-{}.o2.save", input::arvelie_neralie()))
};
let mut content = String::with_capacity((app.engine.w + 1) * app.engine.h);
for y in 0..app.engine.h {
for x in 0..app.engine.w {
content.push(app.engine.cells[y * app.engine.w + x]);
}
content.push('\n');
}
if std::fs::write(&save_path, content.trim_end()).is_ok() {
eprintln!(
"\n[o2] Application panicked! Emergency save created at: {}",
save_path.display()
);
}
}
fn run_app(
app: &mut EditorState,
terminal: &mut Terminal<CrosstermBackend<Stdout>>,
cli: &Cli,
) -> Result<()> {
let mut next_clock_tick = Instant::now();
let mut clock_counter = 0;
let mut needs_draw = true;
loop {
if needs_draw {
let size = terminal.size()?;
let viewport_w = size.width as usize;
let viewport_h = size.height.saturating_sub(2) as usize;
app.update_scroll(viewport_w, viewport_h);
terminal.draw(|f| render::draw(f, app))?;
needs_draw = false;
}
let tick_rate = Duration::from_millis(if app.paused {
100
} else {
60000 / app.bpm.max(1) as u64 / 4
});
let clock_rate = tick_rate / 6;
let mut now = Instant::now();
let mut timeout = next_clock_tick.saturating_duration_since(now);
if cli.strict_timing && timeout > Duration::from_millis(2) {
timeout -= Duration::from_millis(2);
} else if cli.strict_timing {
timeout = Duration::from_millis(0);
}
if event::poll(timeout)? {
match event::read()? {
Event::Resize(cols, rows) => {
let new_w = (cols as usize).max(app.engine.w);
let new_h = (rows.saturating_sub(2) as usize).max(app.engine.h);
app.resize(new_w, new_h);
if app.paused {
app.update_ports();
}
needs_draw = true;
}
Event::Mouse(mouse_event) => {
input::handle_mouse(app, mouse_event);
if app.paused {
app.update_ports();
}
needs_draw = true;
}
Event::Key(key) => {
input::handle_key(app, key);
if app.paused {
app.update_ports();
}
needs_draw = true;
}
Event::Paste(ref text) => {
input::handle_paste(app, text);
if app.paused {
app.update_ports();
}
needs_draw = true;
}
_ => {}
}
}
now = Instant::now();
if cli.strict_timing {
while now < next_clock_tick {
std::hint::spin_loop();
now = Instant::now();
}
}
if now >= next_clock_tick {
if clock_counter == 0 && !app.paused {
app.operate();
app.midi.run();
app.engine.f += 1;
needs_draw = true;
}
if app.midi_bclock
&& !app.paused
&& let Some(conn) = app.midi.out.as_mut()
{
let _ = conn.send(&[0xF8]);
}
clock_counter = (clock_counter + 1) % 6;
next_clock_tick += clock_rate;
if now.duration_since(next_clock_tick) > clock_rate * 12 {
next_clock_tick = now + clock_rate;
}
}
if app
.popup
.iter()
.any(|p| matches!(p, PopupType::About { .. }))
{
needs_draw = true;
}
if !app.running {
app.midi.silence();
app.midi.send_clock_stop();
break;
}
}
Ok(())
}
fn main() -> Result<()> {
let cli = Cli::parse();
let original_hook = std::panic::take_hook();
std::panic::set_hook(Box::new(move |panic_info| {
restore_terminal();
original_hook(panic_info);
}));
enable_raw_mode()?;
execute!(
io::stdout(),
EnterAlternateScreen,
EnableMouseCapture,
EnableBracketedPaste,
crossterm::cursor::Hide
)?;
let _guard = TerminalGuard;
let backend = CrosstermBackend::new(io::stdout());
let mut terminal = Terminal::new(backend)?;
let size = terminal.size()?;
let mut term_w = size.width.max(1) as usize;
let mut term_h = (size.height.saturating_sub(2)).max(1) as usize;
if let Some((w, h)) = cli.initial_size {
term_w = w;
term_h = h;
}
let mut app = EditorState::new(term_w, term_h, cli.seed, cli.undo_limit);
app.set_bpm(cli.bpm);
app.midi.osc_midi_bidule = cli.osc_midi_bidule.clone();
if let Some(path) = &cli.file
&& let Ok(content) = std::fs::read_to_string(path)
{
app.load(&content, Some(path.clone()));
app.resize(term_w.max(app.engine.w), term_h.max(app.engine.h));
app.history.saved_absolute_index = Some(app.history.offset + app.history.index);
}
if app.paused {
app.update_ports();
}
let loop_result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
run_app(&mut app, &mut terminal, &cli)
}));
match loop_result {
Ok(result) => result,
Err(err) => {
emergency_save(&app);
std::panic::resume_unwind(err);
}
}
}