joecalsend/src/app/widgets.rs
2025-08-04 19:08:53 -07:00

373 lines
12 KiB
Rust

use std::sync::LazyLock;
use joecalsend::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, List, ListItem, ListState, Padding, Paragraph, Row, Table, TableState,
Widget,
},
};
use tui_logger::{TuiLoggerLevelOutput, TuiLoggerWidget, TuiWidgetState};
use super::{App, CurrentScreen, Peer, SendingScreen};
static MAIN_MENU: LazyLock<Line> = LazyLock::new(|| {
Line::from(vec![
" Send ".into(),
"<S>".blue().bold(),
" Receive ".into(),
"<R>".blue().bold(),
" Logs ".into(),
"<L>".blue().bold(),
" Previous Screen ".into(),
"<ESC>".blue().bold(),
" Quit ".into(),
"<Q>".blue().bold(),
])
});
static LOGGING_MENU: LazyLock<Line> = LazyLock::new(|| {
Line::from(vec![
" Reduce Logging Level ".into(),
"<LEFT>".blue().bold(),
" Increase Logging Level ".into(),
"<RIGHT>".blue().bold(),
" Previous Screen ".into(),
"<ESC>".blue().bold(),
" Quit ".into(),
"<Q>".blue().bold(),
])
});
static CONTENT_RECEIVE_MENU: LazyLock<Line> = LazyLock::new(|| {
Line::from(vec![
" Select Previous ".into(),
"<UP>".blue().bold(),
" Select Next ".into(),
"<DOWN>".blue().bold(),
" Approve Selection ".into(),
"<A>".blue().bold(),
" Deny Selection ".into(),
"<D>".blue().bold(),
" Previous Screen ".into(),
"<ESC>".blue().bold(),
" Quit ".into(),
"<Q>".blue().bold(),
])
});
static CONTENT_SEND_FILE_MENU: LazyLock<Line> = LazyLock::new(|| {
Line::from(vec![
" Select Previous ".into(),
"<UP>".blue().bold(),
" Select Next ".into(),
"<DOWN>".blue().bold(),
" Select ".into(),
"<ENTER>".blue().bold(),
" Parent Dir ".into(),
"<LEFT>".blue().bold(),
" Child Dir ".into(),
"<RIGHT>".blue().bold(),
" Enter Text ".into(),
"<T>".blue().bold(),
" Peers ".into(),
"<TAB>".blue().bold(),
" Previous Screen ".into(),
"<ESC>".blue().bold(),
" Quit ".into(),
"<Q>".blue().bold(),
])
});
static CONTENT_SEND_PEERS_MENU: LazyLock<Line> = LazyLock::new(|| {
Line::from(vec![
" Select Previous ".into(),
"<UP>".blue().bold(),
" Select Next ".into(),
"<DOWN>".blue().bold(),
" Select ".into(),
"<ENTER>".blue().bold(),
" Enter Text ".into(),
"<T>".blue().bold(),
" Files ".into(),
"<TAB>".blue().bold(),
" Previous Screen ".into(),
"<ESC>".blue().bold(),
" Quit ".into(),
"<Q>".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(2, 4);
let current_screen = self.screen.last().unwrap();
match current_screen {
CurrentScreen::Main => {
let rx_reqs: Vec<_> = self.receive_requests.values().collect();
outer_frame(*current_screen, &MAIN_MENU, area, buf);
logger(header_right.inner(header_margin), buf);
let peers = PeersWidget { peers: &self.peers };
ratatui::widgets::StatefulWidget::render(
peers,
footer_right.inner(footer_margin),
buf,
&mut self.peer_state,
);
NetworkInfoWidget.render(footer_left.inner(footer_margin), buf);
receive_requests(
&rx_reqs,
&mut self.receiving_state,
header_left.inner(header_margin),
buf,
);
}
CurrentScreen::Logging => {
outer_frame(*current_screen, &LOGGING_MENU, area, buf);
logger(area.inner(subscreen_margin), buf);
}
CurrentScreen::Receiving => {
let rx_reqs: Vec<_> = self.receive_requests.values().collect();
outer_frame(*current_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(s) => {
match s {
SendingScreen::Files => {
outer_frame(*current_screen, &CONTENT_SEND_FILE_MENU, area, buf)
}
SendingScreen::Peers => {
outer_frame(*current_screen, &CONTENT_SEND_PEERS_MENU, area, buf)
}
SendingScreen::Text => {}
}
let peers = PeersWidget { peers: &self.peers };
self.file_picker
.widget()
.render(header_left.inner(header_margin), buf);
logger(header_right.inner(header_margin), buf);
ratatui::widgets::StatefulWidget::render(
peers,
bottom.inner(subscreen_margin),
buf,
&mut self.peer_state,
);
}
_ => {
outer_frame(*current_screen, &MAIN_MENU, area, buf);
}
}
}
}
fn outer_frame(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);
}
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().title(title.centered()))
.style(Style::default())
.state(&TuiWidgetState::new().set_default_display_level(LevelFilter::Debug));
logger.render(area, buf);
}
fn receive_requests(
requests: &[&ReceiveRequest],
state: &mut TableState,
area: Rect,
buf: &mut Buffer,
) {
let title = Line::from(" Upload 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::<Vec<_>>();
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);
}
#[derive(Debug, Clone)]
pub struct PeersWidget<'p> {
pub peers: &'p [Peer],
}
impl<'p> ratatui::widgets::StatefulWidget for PeersWidget<'p> {
type State = ListState;
fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State)
where
Self: Sized,
{
if self.peers.is_empty() {
state.select(None);
}
if state.selected().is_none() {
state.select(Some(0));
}
let mut items = Vec::with_capacity(self.peers.len());
for Peer {
addr,
alias,
fingerprint,
} in self.peers.iter()
{
let item = format!("{:?}: {} ({})", addr, alias, 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);
}
}
#[derive(Debug, Clone)]
pub struct NetworkInfoWidget;
impl Widget for NetworkInfoWidget {
fn render(self, area: Rect, buf: &mut Buffer)
where
Self: Sized,
{
let udp = "UDP socket";
let udp = udp.to_line().left_aligned();
let uaddr = format!("{:?}", joecalsend::LISTENING_SOCKET_ADDR);
let udp = Row::new(vec![udp, uaddr.to_line().right_aligned()]).yellow();
let mip = format!(
"{:?}:{:?}",
joecalsend::MULTICAST_IP,
joecalsend::DEFAULT_PORT
);
let multicast = "Multicast address";
let multicast = Row::new(vec![
multicast.to_line().left_aligned(),
mip.to_line().right_aligned(),
])
.yellow();
let haddr = format!("{:?}", joecalsend::LISTENING_SOCKET_ADDR);
let http = "HTTP address";
let http = Row::new(vec![
http.to_line().left_aligned(),
haddr.to_line().right_aligned(),
])
.yellow();
let rows = vec![udp, multicast, http];
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, width_pct: u16, height_pct: u16) -> Rect {
let horizontal = Layout::horizontal([Constraint::Percentage(width_pct)]).flex(Flex::Center);
let vertical = Layout::vertical([Constraint::Percentage(height_pct)]).flex(Flex::Center);
let [area] = vertical.areas(area);
let [area] = horizontal.areas(area);
area
}