use std::{net::Ipv4Addr, sync::LazyLock}; use jocalsend::ReceiveRequest; use log::LevelFilter; use ratatui::{ buffer::Buffer, layout::{Constraint, Flex, Layout, Margin, Rect}, style::{Color, Style, Stylize}, symbols::border, text::{Line, Text, ToLine}, widgets::{ Block, Borders, Clear, List, ListItem, ListState, Padding, Paragraph, Row, Table, TableState, Widget, }, }; use tui_logger::{TuiLoggerLevelOutput, TuiLoggerWidget, TuiWidgetState}; use super::{App, CurrentScreen, FileMode, Peer, SendingScreen}; 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(), ]) }); static CONTENT_RECEIVE_MENU: LazyLock = LazyLock::new(|| { Line::from(vec![ " Select Previous ".into(), "".blue().bold(), " Select Next ".into(), "".blue().bold(), " Approve Selection ".into(), "".blue().bold(), " Deny Selection ".into(), "".blue().bold(), " Previous Screen ".into(), "".blue().bold(), " Quit ".into(), "".blue().bold(), ]) }); static CONTENT_SEND_FILE_MENU: LazyLock = LazyLock::new(|| { Line::from(vec![ " Fuzzy Search ".into(), "".blue().bold(), " Select Previous ".into(), "".blue().bold(), " Select Next ".into(), "".blue().bold(), " Send File ".into(), "".blue().bold(), " Enter Text ".into(), "".blue().bold(), " Peers ".into(), "".blue().bold(), " Previous Screen ".into(), "".blue().bold(), " Quit ".into(), "".blue().bold(), ]) }); static CONTENT_SEND_PEERS_MENU: LazyLock = LazyLock::new(|| { Line::from(vec![ " Select Previous ".into(), "".blue().bold(), " Select Next ".into(), "".blue().bold(), " Send to Peer ".into(), "".blue().bold(), " Enter Text ".into(), "".blue().bold(), " Files ".into(), "".blue().bold(), " Previous Screen ".into(), "".blue().bold(), " Quit ".into(), "".blue().bold(), ]) }); static CONTENT_SEND_TEXT_MENU: LazyLock = LazyLock::new(|| { Line::from(vec![ " Send Text ".into(), "".blue().bold(), " Peers ".into(), "".blue().bold(), " Cancel ".into(), "".blue().bold(), ]) }); impl Widget for &mut App { fn render(self, area: Rect, buf: &mut Buffer) { let main_layout = Layout::vertical([Constraint::Min(5), Constraint::Min(3)]); let [top, 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 footer_margin = Margin::new(1, 1); let header_layout = Layout::horizontal([Constraint::Percentage(50), Constraint::Percentage(50)]); let [header_left, header_right] = header_layout.areas(top); let header_margin = Margin::new(1, 2); let subscreen_margin = Margin::new(1, 2); // it's safe to call `unwrap()` here because we ensure there's always at least // one element in `self.screen`; see the `self.pop()` method let current_screen = self.screen(); match current_screen { CurrentScreen::Main => { let rx_reqs: Vec<_> = self.receive_requests.values().collect(); outer_frame(¤t_screen, &MAIN_MENU, area, buf); logger(header_right.inner(header_margin), buf); peers( &self.peers, &mut self.peer_state, footer_right.inner(footer_margin), buf, ); network_info( &self.service.config.local_ip_addr, footer_left.inner(footer_margin), buf, ); receive_requests( &rx_reqs, &mut self.receiving_state, header_left.inner(header_margin), buf, ); } CurrentScreen::Help => { // TODO: display help } CurrentScreen::Logging | CurrentScreen::Stopping => { outer_frame(¤t_screen, &LOGGING_MENU, area, buf); logger(area.inner(subscreen_margin), buf); } CurrentScreen::Receiving => { let rx_reqs: Vec<_> = self.receive_requests.values().collect(); outer_frame(¤t_screen, &CONTENT_RECEIVE_MENU, area, buf); receive_requests( &rx_reqs, &mut self.receiving_state, top.inner(subscreen_margin), buf, ); logger(bottom.inner(subscreen_margin), buf); } CurrentScreen::Sending(sending_screen) => { match sending_screen { SendingScreen::Files(_) => { outer_frame(¤t_screen, &CONTENT_SEND_FILE_MENU, area, buf) } SendingScreen::Peers => { outer_frame(¤t_screen, &CONTENT_SEND_PEERS_MENU, area, buf) } SendingScreen::Text => { outer_frame(¤t_screen, &CONTENT_SEND_TEXT_MENU, area, buf); } } let file_area = header_left.inner(header_margin); match sending_screen { SendingScreen::Files(FileMode::Picking) => { self.file_finder.widget().render(file_area, buf); } SendingScreen::Files(FileMode::Fuzzy) => { let layout = Layout::vertical([Constraint::Max(6), Constraint::Min(5)]); let [input, files] = layout.areas(file_area); text_popup(self.file_finder.input.value(), "fuzzy search", input, buf); self.file_finder.widget().render(files, buf); } _ => {} } logger(header_right.inner(header_margin), buf); peers( &self.peers, &mut self.peer_state, bottom.inner(subscreen_margin), buf, ); if sending_screen == SendingScreen::Text { let rect = centered_rect(area, Constraint::Percentage(80), Constraint::Max(10)); let text = if let Some(text) = self.text.as_ref() { text } else { "" }; text_popup(text, " Enter Text to Send ", rect, buf); // TODO: add cursor, need to do that in the `draw` method // because we need the frame to do it; add cursor position // fields to `App` and mutate them in the text input // function } } } } } fn outer_frame(screen: &CurrentScreen, menu: &Line, area: Rect, buf: &mut Buffer) { let title = Line::from(" Jocalsend ".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); } fn text_popup(text: &str, title: &str, area: Rect, buf: &mut Buffer) { let title = Line::from(title.bold()); let block = Block::bordered().title(title.centered()); Clear.render(area, buf); block.render(area, buf); let (_, len) = unicode_segmentation::UnicodeSegmentation::graphemes(text, true).size_hint(); let len = len.unwrap_or(text.len()) as u16 + 2; let area = centered_rect(area, Constraint::Length(len), Constraint::Length(1)); Paragraph::new(text).centered().yellow().render(area, buf); } fn logger(area: Rect, buf: &mut Buffer) { let title = Line::from(format!(" {} logs ", 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().title(title.centered())) .style(Style::default()) .state(&TuiWidgetState::new().set_default_display_level(LevelFilter::Trace)); logger.render(area, buf); } fn receive_requests( requests: &[&ReceiveRequest], state: &mut TableState, area: Rect, buf: &mut Buffer, ) { let title = Line::from(" Incoming Transfer Requests ").bold(); let block = Block::bordered().title(title.centered()); let mut rows = Vec::new(); for &req in requests { let src = req.alias.to_line().left_aligned(); let mut size = 0; let files = req .files .values() .map(|f| { size += f.size; f.file_name.clone() }) .collect::>(); let files = files.join(", "); let files = Line::from(files).centered(); let size = Line::from(format!("{size}")).centered(); rows.push(Row::new([src, size, files]).yellow()); } if state.selected().is_none() && !rows.is_empty() { state.select(Some(0)); } else if rows.is_empty() { state.select(None); }; let widths = [ Constraint::Max(20), Constraint::Max(15), Constraint::Min(50), ]; let table = Table::new(rows, widths) .block(block) .header(Row::new([ "Sender".bold().into_left_aligned_line(), "Bytes".bold().into_centered_line(), "Files".bold().into_centered_line(), ])) .row_highlight_style(Style::new().bg(Color::Rgb(99, 99, 99))); ratatui::widgets::StatefulWidget::render(table, area, buf, state); if let Some(idx) = state.selected() { let area = centered_rect(area, Constraint::Percentage(80), Constraint::Max(7)); let request = requests[idx]; if let Some(md) = request.files.values().next() && let Some(ref preview) = md.preview { Clear.render(area, buf); text_popup(preview, " preview ", area, buf); } } } fn peers(peers: &[Peer], state: &mut ListState, area: Rect, buf: &mut Buffer) { if peers.is_empty() { state.select(None); } if state.selected().is_none() { state.select(Some(0)); } let mut items = Vec::with_capacity(peers.len()); for Peer { addr, alias, fingerprint, } in peers { let item = format!("{alias} ({addr:?}) -- {fingerprint}"); let s = Line::from(item.yellow()); let item = ListItem::new(s); items.push(item); } let title = Line::from(" Peers ".bold()).centered(); let block = Block::default() .title(title) .borders(Borders::all()) .padding(Padding::uniform(1)); let list = List::new(items) .block(block) .highlight_style(Style::new().bg(Color::Rgb(99, 99, 99))); ratatui::widgets::StatefulWidget::render(list, area, buf, state); } fn network_info(local_ip_addr: &Ipv4Addr, area: Rect, buf: &mut Buffer) { let local_addr = format!("{local_ip_addr:?}:{}", jocalsend::DEFAULT_PORT); let local_addr = local_addr.to_line().right_aligned(); let http = "HTTP address"; let http = Row::new(vec![http.to_line().left_aligned(), local_addr.clone()]).yellow(); let udp = "UDP socket"; let udp = udp.to_line().left_aligned(); let udp = Row::new(vec![udp, local_addr]).yellow(); let mip = format!( "{:?}:{:?}", jocalsend::MULTICAST_IP, jocalsend::DEFAULT_PORT ); let multicast = "Multicast address"; let multicast = Row::new(vec![ multicast.to_line().left_aligned(), mip.to_line().right_aligned(), ]) .yellow(); let rows = vec![http, udp, multicast]; let widths = vec![Constraint::Percentage(50), Constraint::Percentage(50)]; let title = Line::from(" Listeners ".bold()).centered(); let block = Block::default() .title(title) .borders(Borders::all()) .padding(Padding::uniform(1)); let table = Table::new(rows, widths).block(block); table.render(area, buf); } // helpers fn centered_rect(area: Rect, horizontal: Constraint, vertical: Constraint) -> Rect { let [area] = Layout::horizontal([horizontal]) .flex(Flex::Center) .areas(area); let [area] = Layout::vertical([vertical]).flex(Flex::Center).areas(area); area }