use std::io;
use std::sync::Arc;
use std::time::Duration;
use anyhow::Result;
use crossterm::event::{Event, EventStream};
use crossterm::execute;
use crossterm::terminal::{
EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
};
use futures::StreamExt;
use ratatui::Terminal;
use ratatui::backend::CrosstermBackend;
use tokio::sync::Mutex;
use tokio::task::JoinHandle;
mod client;
mod device_cache;
mod filebrowser;
mod iso;
mod menu;
mod state;
mod ui;
use crate::cli::tui::state::{AppState, Status};
#[derive(clap::Args, Debug)]
#[command(after_help = crate::cli::help_footer())]
pub struct Args {
#[arg(long, conflicts_with = "log")]
host: Option<String>,
#[arg(long, default_value_t = 2597)]
snapshot_port: u16,
#[arg(long, default_value_t = 2598)]
stream_port: u16,
#[arg(long, value_name = "PATH")]
log: Option<std::path::PathBuf>,
}
pub async fn run(args: Args) -> Result<()> {
if let Ok(log_path) = std::env::var("CANBOAT_TUI_LOG").or_else(|_| {
std::env::var("TMPDIR")
.map(|d| format!("{d}/canboat-tui.log"))
.or_else(|_| Ok::<_, std::env::VarError>("/tmp/canboat-tui.log".to_string()))
}) && let Ok(file) = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&log_path)
{
let _ = env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("warn"))
.target(env_logger::Target::Pipe(Box::new(file)))
.try_init();
crate::cli::log_startup(env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION"));
}
let idle = args.log.is_none() && args.host.is_none();
let status = match (&args.log, &args.host) {
(Some(path), _) => Status::new_log(path.display().to_string()),
(None, Some(host)) => Status::new_live(host.clone(), args.snapshot_port, args.stream_port),
(None, None) => Status::new_idle(),
};
let device_cache_path = device_cache::default_path();
let device_cache = device_cache::load_or_default(&device_cache_path);
let state = Arc::new(Mutex::new(AppState::new(status, device_cache)));
let (writer, writer_rx) = client::make_writer();
let mut tasks: Vec<JoinHandle<()>> = Vec::new();
match (args.log.clone(), args.host.clone()) {
(Some(path), _) => {
drop(writer_rx);
tasks.extend(client::spawn_log_load(path, state.clone()));
}
(None, Some(host)) => {
tasks.push(client::spawn_snapshot_load(
host.clone(),
args.snapshot_port,
state.clone(),
));
spawn_live_connections(&mut tasks, host, args.stream_port, &state, writer_rx);
}
(None, None) => {
drop(writer_rx);
}
}
let mut app = ui::App::new();
if idle {
app.prompt_load();
}
let mut tty = setup_tty()?;
let res = run_loop(&mut tty, &mut app, state.clone(), writer, tasks).await;
restore_tty(&mut tty)?;
{
let s = state.lock().await;
if let Err(e) = device_cache::save(&s.device_cache, &device_cache_path) {
eprintln!("canboat-tui: failed to save device cache: {e:#}");
}
}
res
}
async fn run_loop(
tty: &mut ui::Tty,
app: &mut ui::App,
state: Arc<Mutex<AppState>>,
mut writer: client::Writer,
mut tasks: Vec<JoinHandle<()>>,
) -> Result<()> {
let mut events = EventStream::new();
let mut saving = false;
loop {
let frame_delay = if app.splash.is_some() {
Duration::from_millis(60)
} else if saving {
Duration::from_millis(100)
} else {
Duration::from_millis(250)
};
tokio::select! {
biased;
ev = events.next() => {
match ev {
Some(Ok(Event::Key(key))) => {
let s = state.lock().await;
app.handle_key(key, &s, &writer);
}
Some(Ok(_)) => {}
Some(Err(e)) => {
let mut s = state.lock().await;
s.status.last_error = Some(format!("terminal: {e}"));
}
None => break,
}
}
_ = tokio::time::sleep(frame_delay) => {}
}
if app.should_quit {
break;
}
if let Some(cmd) = app.pending_command.take() {
execute_command(cmd, &mut writer, &mut tasks, &state, app).await;
}
let mut s = state.lock().await;
if let Some((src, pgn)) = app.pending_override_forget.take() {
s.forget_override(src, pgn);
}
s.prune_overrides();
if let Some(notice) = s.notice.take() {
app.toast = Some(notice);
}
saving = s.save_progress.is_some();
ui::draw(tty, app, &s)?;
}
Ok(())
}
fn write_ports(stream_port: u16) -> (u16, u16, u16) {
let base = stream_port.wrapping_sub(1);
(
base.wrapping_add(3),
base.wrapping_add(8),
base.wrapping_add(9),
)
}
fn spawn_live_connections(
tasks: &mut Vec<JoinHandle<()>>,
host: String,
stream_port: u16,
state: &Arc<Mutex<AppState>>,
writer_rx: client::WriterRx,
) {
let (input_port, filter_port, overrides_port) = write_ports(stream_port);
tasks.push(client::spawn_stream_connection(
host.clone(),
stream_port,
state.clone(),
));
tasks.push(client::spawn_input_connection(
host.clone(),
input_port,
state.clone(),
writer_rx.input,
));
tasks.push(client::spawn_filter_connection(
host.clone(),
filter_port,
state.clone(),
writer_rx.filter,
));
tasks.push(client::spawn_overrides_connection(
host,
overrides_port,
state.clone(),
writer_rx.overrides,
));
}
async fn execute_command(
cmd: ui::PendingCommand,
writer: &mut client::Writer,
tasks: &mut Vec<JoinHandle<()>>,
state: &Arc<Mutex<AppState>>,
app: &mut ui::App,
) {
if matches!(
cmd,
ui::PendingCommand::Connect { .. } | ui::PendingCommand::Load { .. }
) && state.lock().await.save_progress.is_some()
{
app.toast = Some("Wait for the save to finish".to_string());
return;
}
match cmd {
ui::PendingCommand::Connect {
host,
snapshot_port,
stream_port,
} => {
for t in tasks.drain(..) {
t.abort();
}
{
let mut s = state.lock().await;
let cache = std::mem::take(&mut s.device_cache);
*s = AppState::new(
Status::new_live(host.clone(), snapshot_port, stream_port),
cache,
);
}
let (new_writer, rx) = client::make_writer();
tasks.push(client::spawn_snapshot_load(
host.clone(),
snapshot_port,
state.clone(),
));
spawn_live_connections(tasks, host, stream_port, state, rx);
*writer = new_writer;
app.reset_views();
app.connecting_dismissed = false;
}
ui::PendingCommand::Load { path } => {
for t in tasks.drain(..) {
t.abort();
}
{
let mut s = state.lock().await;
let cache = std::mem::take(&mut s.device_cache);
*s = AppState::new(Status::new_log(path.display().to_string()), cache);
}
tasks.extend(client::spawn_log_load(path, state.clone()));
app.reset_views();
app.connecting_dismissed = true;
}
ui::PendingCommand::Save { path, format } => {
let (loading, saving) = {
let s = state.lock().await;
(!s.status.snapshot_loaded, s.save_progress.is_some())
};
if loading {
app.toast = Some("Still loading — try Save again once it's done".to_string());
} else if saving {
app.toast = Some("A save is already in progress".to_string());
} else {
tasks.push(client::spawn_save(path, format, state.clone()));
}
}
}
}
fn setup_tty() -> Result<ui::Tty> {
enable_raw_mode()?;
let mut out = io::stdout();
execute!(out, EnterAlternateScreen)?;
let backend = CrosstermBackend::new(out);
Ok(Terminal::new(backend)?)
}
fn restore_tty(tty: &mut ui::Tty) -> Result<()> {
disable_raw_mode()?;
execute!(tty.backend_mut(), LeaveAlternateScreen)?;
tty.show_cursor()?;
Ok(())
}