joecalsend/src/app/widgets.rs
2025-08-18 17:14:28 -07:00

495 lines
16 KiB
Rust

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, Wrap,
},
};
use tui_logger::{TuiLoggerLevelOutput, TuiLoggerWidget, TuiWidgetState};
use super::{App, CurrentScreen, FileMode, 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(),
" Help ".into(),
"<H|?>".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![
" Fuzzy Search ".into(),
"</>".blue().bold(),
" Select Previous ".into(),
"<UP>".blue().bold(),
" Select Next ".into(),
"<DOWN>".blue().bold(),
" Send File ".into(),
"<ENTER>".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(),
" Send to Peer ".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(),
])
});
static CONTENT_SEND_TEXT_MENU: LazyLock<Line> = LazyLock::new(|| {
Line::from(vec![
" Send Text ".into(),
"<ENTER>".blue().bold(),
" Peers ".into(),
"<TAB>".blue().bold(),
" Cancel ".into(),
"<ESC>".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 top_heavy = Layout::vertical([Constraint::Percentage(66), Constraint::Percentage(34)]);
let [heavy_top, _skinny_bottom] = top_heavy.areas(area);
let subscreen_margin = Margin::new(1, 2);
let current_screen = self.screen();
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);
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 => {
outer_frame(&current_screen, &MAIN_MENU, area, buf);
help_screen(area.inner(subscreen_margin), buf);
}
CurrentScreen::Logging | CurrentScreen::Stopping => {
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(sending_screen) => {
match sending_screen {
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 => {
outer_frame(&current_screen, &CONTENT_SEND_TEXT_MENU, area, buf);
}
}
let file_area = header_left.inner(header_margin);
match sending_screen {
SendingScreen::Files(FileMode::Picking)
| SendingScreen::Peers
| SendingScreen::Text => {
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(heavy_top, Constraint::Percentage(80), Constraint::Max(6));
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 help_screen(area: Rect, buf: &mut Buffer) {
let intro = "JocalSend is a mode-based application that responds to key-presses. Most modes support the following key bindings:".to_line().centered();
let intro = Paragraph::new(intro).wrap(Wrap { trim: true });
let spacer = "".to_line().centered();
let main_bindings = vec![
Row::new(vec!["".to_line(), spacer.clone(), "".to_line()]),
Row::new(vec![
// Sending
"Send data".bold().into_right_aligned_line(),
spacer.clone(),
"S".bold().into_left_aligned_line(),
]),
// Receiving
Row::new(vec![
"Manage incoming data requests (receive data)"
.bold()
.into_right_aligned_line(),
spacer.clone(),
"R".bold().into_left_aligned_line(),
]),
// logging
Row::new(vec![
"View logs and change log level"
.bold()
.into_right_aligned_line(),
spacer.clone(),
"L".bold().into_left_aligned_line(),
]),
// misc: pop
Row::new(vec![
"Go to previous screen".bold().into_right_aligned_line(),
spacer.clone(),
"ESC".bold().into_left_aligned_line(),
]),
// misc: main menu
Row::new(vec![
"Go to the main screen".bold().into_right_aligned_line(),
spacer.clone(),
"M".bold().into_left_aligned_line(),
]),
// misc: quit
Row::new(vec![
"Quit the application".bold().into_right_aligned_line(),
spacer.clone(),
"Q".bold().into_left_aligned_line(),
]),
// misc: help
Row::new(vec![
"This help screen".bold().into_right_aligned_line(),
spacer.clone(),
"H or ?".bold().into_left_aligned_line(),
]),
];
let layout = Layout::vertical(vec![Constraint::Max(3), Constraint::Min(1)]);
let [intro_area, bindings_area] = layout.areas(area);
let widths = vec![
Constraint::Percentage(50),
Constraint::Max(6),
Constraint::Percentage(50),
];
let main_bindings = Table::new(main_bindings, widths).header(Row::new(vec![
"Action".bold().into_right_aligned_line(),
spacer,
"Key input".bold().into_left_aligned_line(),
]));
Clear.render(area, buf);
intro.render(intro_area, buf);
main_bindings.render(bindings_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::<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);
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
}
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);
}