use std::{ collections::BTreeMap, net::SocketAddr, sync::{LazyLock, OnceLock}, time::Duration, }; use crossterm::event::{Event, EventStream, KeyCode, KeyEvent, KeyEventKind}; use futures::{FutureExt, StreamExt}; use joecalsend::{ Config, JoecalState, Listeners, TransferEvent, UploadDialog, error::{LocalSendError, Result}, models::Device, }; use log::{LevelFilter, error, info}; use native_dialog::MessageDialogBuilder; use ratatui::{ DefaultTerminal, Frame, buffer::Buffer, layout::{Constraint, Layout, Margin, Rect}, style::{Style, Stylize}, symbols::border, text::{Line, Text}, widgets::{Block, Paragraph, Widget}, }; use tokio::{ sync::mpsc::{UnboundedReceiver, unbounded_channel}, task::JoinSet, }; use tui_logger::{TuiLoggerLevelOutput, TuiLoggerWidget, TuiWidgetState}; pub mod widgets; use widgets::*; pub type Peers = BTreeMap; pub struct App { pub state: OnceLock, pub screen: Vec, pub events: EventStream, // addr -> (alias, fingerprint) pub peers: Peers, // for getting messages back from the web server or web client about things we've done; the // other end is held by the state transfer_event_rx: OnceLock>, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum CurrentScreen { Main, Sending, Receiving, Stopping, Logging, } impl Default for App { fn default() -> Self { Self::new() } } impl App { pub fn new() -> Self { App { state: Default::default(), screen: vec![CurrentScreen::Main], peers: Default::default(), events: Default::default(), transfer_event_rx: Default::default(), } } #[tokio::main] pub async fn start_and_run( &mut self, terminal: &mut DefaultTerminal, config: Config, device: Device, ) -> Result<()> { let (transfer_event_tx, transfer_event_rx) = unbounded_channel(); let state = JoecalState::new(device, transfer_event_tx) .await .expect("Could not create JoecalState"); let _ = self.transfer_event_rx.set(transfer_event_rx); let mut handles = JoinSet::new(); state.start(&config, &mut handles).await; let _ = self.state.set(state); loop { terminal.draw(|frame| self.draw(frame))?; self.handle_events().await?; if let Some(&top) = self.screen.last() && top == CurrentScreen::Stopping { self.state.get().unwrap().stop().await; break; } let peers = self.state.get().unwrap().peers.lock().await; self.peers.clear(); peers.iter().for_each(|(fingerprint, (addr, device))| { let alias = device.alias.clone(); self.peers .insert(addr.to_owned(), (alias, fingerprint.to_owned())); }); } shutdown(&mut handles).await; Ok(()) } async fn handle_events(&mut self) -> Result<()> { tokio::select! { event = self.events.next().fuse() => { if let Some(Ok(evt)) = event { match evt { Event::Key(key) if key.kind == KeyEventKind::Press => self.handle_key_event(key), Event::Mouse(_) => {} Event::Resize(_, _) => {} _ => {} } } } transfer_event = self.transfer_event_rx.get_mut().unwrap().recv() => { if let Some(event) = transfer_event { match event { TransferEvent::UploadRequest { alias, id } => { let sender = self .state .get() .unwrap() .get_upload_request(id) .await .ok_or(LocalSendError::SessionInactive)?; // TODO: replace this with ratatui widget dialog let upload_confirmed = MessageDialogBuilder::default() .set_title(&alias) .set_text("Do you want to receive files from this device?") .confirm() .show() .unwrap(); if upload_confirmed { let _ = sender.send(UploadDialog::UploadConfirm); } else { let _ = sender.send(UploadDialog::UploadDeny); } } TransferEvent::Sent => {} _ => {} } } } _ = tokio::time::sleep(Duration::from_millis(200)) => {} } Ok(()) } fn handle_key_event(&mut self, key_event: KeyEvent) { match self.screen.last().unwrap() { CurrentScreen::Logging => match key_event.code { KeyCode::Esc => self.pop(), KeyCode::Left => change_log_level(-1), KeyCode::Right => change_log_level(1), KeyCode::Char('q') => self.exit(), _ => {} }, _ => match key_event.code { KeyCode::Char('q') => self.exit(), KeyCode::Char('s') => self.send(), KeyCode::Char('r') => self.recv(), KeyCode::Char('l') => self.logs(), KeyCode::Esc => self.pop(), _ => {} }, } } fn draw(&self, frame: &mut Frame) { frame.render_widget(self, frame.area()); } fn exit(&mut self) { self.screen.push(CurrentScreen::Stopping); } fn send(&mut self) { let last = self.screen.last(); match last { Some(CurrentScreen::Sending) => {} _ => self.screen.push(CurrentScreen::Sending), } } fn recv(&mut self) { let last = self.screen.last(); match last { Some(CurrentScreen::Receiving) => {} _ => self.screen.push(CurrentScreen::Receiving), } } fn logs(&mut self) { let last = self.screen.last(); match last { Some(CurrentScreen::Logging) => {} _ => self.screen.push(CurrentScreen::Logging), } } fn pop(&mut self) { self.screen.pop(); if self.screen.last().is_none() { self.screen.push(CurrentScreen::Main); } } } fn change_log_level(delta: isize) { let level = log::max_level() as isize; let max = log::LevelFilter::max() as isize; let level = (level + delta).clamp(0, max) as usize; // levelfilter is repr(usize) so this is safe let level = unsafe { std::mem::transmute::(level) }; log::set_max_level(level); } static MAIN_MENU: LazyLock = LazyLock::new(|| { Line::from(vec![ " Send ".into(), "".blue().bold(), " Receive ".into(), "".blue().bold(), " Logs ".into(), "".blue().bold(), " Previous Screen ".into(), "".blue().bold(), " Quit ".into(), "".blue().bold(), ]) }); static LOGGING_MENU: LazyLock = LazyLock::new(|| { Line::from(vec![ " Reduce Logging Level ".into(), "".blue().bold(), " Increase Logging Level ".into(), "".blue().bold(), " Previous Screen ".into(), "".blue().bold(), " Quit ".into(), "".blue().bold(), ]) }); impl Widget for &App { fn render(self, area: Rect, buf: &mut Buffer) { let main_layout = Layout::vertical([Constraint::Min(5), Constraint::Min(10), Constraint::Min(3)]); let [top, _middle, bottom] = main_layout.areas(area); let footer_layout = Layout::horizontal([Constraint::Percentage(30), Constraint::Percentage(70)]); let [footer_left, footer_right] = footer_layout.areas(bottom); let header_layout = Layout::horizontal([Constraint::Percentage(50), Constraint::Percentage(50)]); let [_header_left, header_right] = header_layout.areas(top); let mode = self.screen.last().unwrap(); match mode { CurrentScreen::Main => { main_page(*mode, &MAIN_MENU, area, buf); logger(header_right.inner(Margin::new(1, 2)), buf); let peers = PeersWidget { peers: &self.peers }; peers.render(footer_right.inner(Margin::new(1, 1)), buf); NetworkInfoWidget.render(footer_left.inner(Margin::new(1, 1)), buf); } CurrentScreen::Logging => { main_page(*mode, &LOGGING_MENU, area, buf); logger(area.inner(Margin::new(2, 4)), buf); } CurrentScreen::Receiving => { main_page(*mode, &MAIN_MENU, area, buf); } _ => { main_page(*mode, &MAIN_MENU, area, buf); } } } } fn logger(area: Rect, buf: &mut Buffer) { let title = Line::from(log::max_level().as_str()); let logger = TuiLoggerWidget::default() .output_separator('|') .output_timestamp(Some("%H:%M:%S%.3f".to_string())) .output_level(Some(TuiLoggerLevelOutput::Abbreviated)) .output_target(true) .output_file(false) .output_line(false) .block( Block::bordered() .border_set(border::THICK) .title(title.centered()), ) .style(Style::default()) .state(&TuiWidgetState::new().set_default_display_level(LevelFilter::Debug)); logger.render(area, buf); } fn main_page(screen: CurrentScreen, menu: &Line, area: Rect, buf: &mut Buffer) { let title = Line::from(" Joecalsend ".bold()); let block = Block::bordered() .title(title.centered()) .title_bottom(menu.clone().centered()) .border_set(border::THICK); let current_screen = format!("{screen:?}",); let text = Text::from(Line::from(current_screen.yellow())); Paragraph::new(text) .centered() .block(block) .render(area, buf); } async fn shutdown(handles: &mut JoinSet) { let mut alarm = tokio::time::interval(tokio::time::Duration::from_secs(5)); alarm.tick().await; loop { tokio::select! { join_result = handles.join_next() => { match join_result { Some(handle) => match handle { Ok(h) => info!("Stopped {h:?}"), Err(e) => error!("Got error {e:?}"), } None => break, } } _ = alarm.tick() => { info!("Exit timeout reached, aborting all unjoined tasks"); handles.abort_all(); break; }, } } }