415 lines
14 KiB
Rust
415 lines
14 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,
|
|
},
|
|
};
|
|
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(),
|
|
" 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 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::<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
|
|
}
|