674 lines
26 KiB
Rust
674 lines
26 KiB
Rust
use crate::network::{DiscoveryService, InterfaceInfo, PeerInfo};
|
|
use crate::transfer::{
|
|
PrepareTransferRequest, TransferClient, TransferProgress, TransferServer, TransferState,
|
|
};
|
|
use crossterm::{
|
|
event::{self, Event, KeyCode, KeyEventKind},
|
|
execute,
|
|
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
|
|
};
|
|
use human_bytes::human_bytes;
|
|
use ratatui::{
|
|
backend::CrosstermBackend,
|
|
layout::{Alignment, Constraint, Direction, Layout, Rect},
|
|
style::{Color, Modifier, Style},
|
|
text::{Line, Span},
|
|
widgets::{
|
|
Block, BorderType, Borders, Clear, List, ListItem, ListState, Paragraph, Wrap,
|
|
},
|
|
Frame, Terminal,
|
|
};
|
|
use std::io;
|
|
use std::path::PathBuf;
|
|
use std::sync::Arc;
|
|
use std::time::{Duration, Instant};
|
|
use tokio::sync::mpsc;
|
|
|
|
pub enum ModalState {
|
|
None,
|
|
ConfirmTransfer(PrepareTransferRequest),
|
|
InputFilePath {
|
|
peer_url: String,
|
|
peer_name: String,
|
|
input: String,
|
|
},
|
|
InputDirectIp {
|
|
input: String,
|
|
},
|
|
}
|
|
|
|
pub struct AppState {
|
|
pub my_name: String,
|
|
pub iface: InterfaceInfo,
|
|
pub peers: Vec<PeerInfo>,
|
|
pub peer_list_state: ListState,
|
|
pub transfers: Vec<TransferProgress>,
|
|
pub logs: Vec<String>,
|
|
pub modal: ModalState,
|
|
pub should_quit: bool,
|
|
}
|
|
|
|
pub async fn run_tui(
|
|
discovery: DiscoveryService,
|
|
server: TransferServer,
|
|
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
|
// 1. Setup Terminal
|
|
enable_raw_mode()?;
|
|
let mut stdout = io::stdout();
|
|
execute!(stdout, EnterAlternateScreen)?;
|
|
let backend = CrosstermBackend::new(stdout);
|
|
let mut terminal = Terminal::new(backend)?;
|
|
|
|
let server_state = server.state();
|
|
let my_info = discovery.my_info().clone();
|
|
let iface = discovery.selected_interface().clone();
|
|
|
|
let mut app = AppState {
|
|
my_name: my_info.name.clone(),
|
|
iface,
|
|
peers: Vec::new(),
|
|
peer_list_state: ListState::default(),
|
|
transfers: Vec::new(),
|
|
logs: vec![
|
|
"⚡ ZeroSend initialized".to_string(),
|
|
format!("📡 Listening on interface {} ({})", my_info.ip, my_info.net_type),
|
|
"🔍 UDP Peer Discovery active".to_string(),
|
|
],
|
|
modal: ModalState::None,
|
|
should_quit: false,
|
|
};
|
|
|
|
let (log_tx, mut log_rx) = mpsc::unbounded_channel::<String>();
|
|
let (transfer_update_tx, mut transfer_update_rx) = mpsc::unbounded_channel::<TransferProgress>();
|
|
|
|
// Listen for server incoming transfer requests
|
|
let mut req_rx = server_state.incoming_request_events.subscribe();
|
|
let mut trans_rx = server_state.transfer_events.subscribe();
|
|
|
|
let client = Arc::new(TransferClient::new());
|
|
let peers_store = discovery.peers();
|
|
|
|
// Event Loop
|
|
let mut last_tick = Instant::now();
|
|
let tick_rate = Duration::from_millis(100);
|
|
|
|
while !app.should_quit {
|
|
// Refresh peer list from discovery
|
|
{
|
|
let peers_map = peers_store.read().await;
|
|
let mut list: Vec<PeerInfo> = peers_map.values().cloned().collect();
|
|
list.sort_by(|a, b| b.is_online.cmp(&a.is_online).then_with(|| a.name.cmp(&b.name)));
|
|
app.peers = list;
|
|
|
|
if !app.peers.is_empty() && app.peer_list_state.selected().is_none() {
|
|
app.peer_list_state.select(Some(0));
|
|
}
|
|
}
|
|
|
|
// Receive logs
|
|
while let Ok(msg) = log_rx.try_recv() {
|
|
app.logs.push(msg);
|
|
if app.logs.len() > 100 {
|
|
app.logs.remove(0);
|
|
}
|
|
}
|
|
|
|
// Receive transfer updates from client or server
|
|
while let Ok(t) = transfer_update_rx.try_recv() {
|
|
if let Some(pos) = app.transfers.iter().position(|x| x.session_id == t.session_id) {
|
|
app.transfers[pos] = t;
|
|
} else {
|
|
app.transfers.push(t);
|
|
}
|
|
}
|
|
|
|
while let Ok(t) = trans_rx.try_recv() {
|
|
if let Some(pos) = app.transfers.iter().position(|x| x.session_id == t.session_id) {
|
|
app.transfers[pos] = t;
|
|
} else {
|
|
app.transfers.push(t);
|
|
}
|
|
}
|
|
|
|
// Check for incoming confirmation requests
|
|
while let Ok(req) = req_rx.try_recv() {
|
|
app.logs.push(format!(
|
|
"📥 Transfer request from {} ({} files)",
|
|
req.sender_name,
|
|
req.files.len()
|
|
));
|
|
app.modal = ModalState::ConfirmTransfer(req);
|
|
}
|
|
|
|
// Draw UI
|
|
terminal.draw(|f| draw_ui(f, &mut app))?;
|
|
|
|
// Handle Keys
|
|
let timeout = tick_rate
|
|
.checked_sub(last_tick.elapsed())
|
|
.unwrap_or_else(|| Duration::from_secs(0));
|
|
|
|
if event::poll(timeout)? {
|
|
if let Event::Key(key) = event::read()? {
|
|
if key.kind == KeyEventKind::Press {
|
|
handle_key_event(
|
|
key.code,
|
|
&mut app,
|
|
&server,
|
|
&client,
|
|
&my_info,
|
|
&discovery,
|
|
log_tx.clone(),
|
|
transfer_update_tx.clone(),
|
|
)
|
|
.await;
|
|
}
|
|
}
|
|
}
|
|
|
|
if last_tick.elapsed() >= tick_rate {
|
|
last_tick = Instant::now();
|
|
}
|
|
}
|
|
|
|
// Restore terminal
|
|
disable_raw_mode()?;
|
|
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
|
|
terminal.show_cursor()?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
async fn handle_key_event(
|
|
code: KeyCode,
|
|
app: &mut AppState,
|
|
server: &TransferServer,
|
|
client: &Arc<TransferClient>,
|
|
my_info: &crate::network::DiscoveryBeacon,
|
|
discovery: &DiscoveryService,
|
|
log_tx: mpsc::UnboundedSender<String>,
|
|
transfer_tx: mpsc::UnboundedSender<TransferProgress>,
|
|
) {
|
|
match &mut app.modal {
|
|
ModalState::ConfirmTransfer(req) => {
|
|
let session_id = req.session_id.clone();
|
|
match code {
|
|
KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Char('a') | KeyCode::Char('A') | KeyCode::Enter => {
|
|
let _ = server.accept_transfer(&session_id).await;
|
|
let _ = log_tx.send(format!("✅ Accepted transfer from {}", req.sender_name));
|
|
app.modal = ModalState::None;
|
|
}
|
|
KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Char('r') | KeyCode::Char('R') | KeyCode::Esc => {
|
|
let _ = server.reject_transfer(&session_id).await;
|
|
let _ = log_tx.send(format!("❌ Rejected transfer from {}", req.sender_name));
|
|
app.modal = ModalState::None;
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
ModalState::InputFilePath { peer_url, peer_name, input } => {
|
|
match code {
|
|
KeyCode::Enter => {
|
|
let path_str = input.trim().to_string();
|
|
if !path_str.is_empty() {
|
|
let path = PathBuf::from(path_str.clone());
|
|
let peer_url = peer_url.clone();
|
|
let peer_name = peer_name.clone();
|
|
let client_clone = client.clone();
|
|
let my_info_clone = my_info.clone();
|
|
let log_tx_clone = log_tx.clone();
|
|
let transfer_tx_clone = transfer_tx.clone();
|
|
|
|
tokio::spawn(async move {
|
|
let _ = log_tx_clone.send(format!("📤 Sending {:?} to {}...", path, peer_name));
|
|
let res = client_clone
|
|
.send_files(&peer_url, &peer_name, &my_info_clone, &[path], true, 0, None, move |progress| {
|
|
let _ = transfer_tx_clone.send(progress);
|
|
})
|
|
.await;
|
|
|
|
match res {
|
|
Ok(_) => {
|
|
let _ = log_tx_clone.send(format!("🎉 Sent successfully to {}!", peer_name));
|
|
}
|
|
Err(e) => {
|
|
let _ = log_tx_clone.send(format!("⚠️ Send failed to {}: {}", peer_name, e));
|
|
}
|
|
}
|
|
});
|
|
}
|
|
app.modal = ModalState::None;
|
|
}
|
|
KeyCode::Esc => {
|
|
app.modal = ModalState::None;
|
|
}
|
|
KeyCode::Backspace => {
|
|
input.pop();
|
|
}
|
|
KeyCode::Char(c) => {
|
|
input.push(c);
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
ModalState::InputDirectIp { input } => {
|
|
match code {
|
|
KeyCode::Enter => {
|
|
let ip_str = input.trim().to_string();
|
|
if !ip_str.is_empty() {
|
|
let client_clone = client.clone();
|
|
let log_tx_clone = log_tx.clone();
|
|
let disc_peers = discovery.peers();
|
|
|
|
tokio::spawn(async move {
|
|
let _ = log_tx_clone.send(format!("🔍 Pinging direct node at {}...", ip_str));
|
|
match client_clone.ping_peer(&ip_str, 53318).await {
|
|
Ok(beacon) => {
|
|
let _ = log_tx_clone.send(format!("✨ Found peer: {} ({})", beacon.name, beacon.ip));
|
|
let mut p_map = disc_peers.write().await;
|
|
p_map.insert(
|
|
beacon.peer_id.clone(),
|
|
PeerInfo {
|
|
peer_id: beacon.peer_id,
|
|
name: beacon.name,
|
|
os: beacon.os,
|
|
ip: beacon.ip,
|
|
transfer_port: beacon.transfer_port,
|
|
net_type: beacon.net_type,
|
|
version: beacon.version,
|
|
last_seen: chrono::Utc::now(),
|
|
is_online: true,
|
|
ping_ms: None,
|
|
shared_folders: beacon.shared_folders,
|
|
},
|
|
);
|
|
}
|
|
Err(e) => {
|
|
let _ = log_tx_clone.send(format!("❌ Direct ping error: {}", e));
|
|
}
|
|
}
|
|
});
|
|
}
|
|
app.modal = ModalState::None;
|
|
}
|
|
KeyCode::Esc => {
|
|
app.modal = ModalState::None;
|
|
}
|
|
KeyCode::Backspace => {
|
|
input.pop();
|
|
}
|
|
KeyCode::Char(c) => {
|
|
input.push(c);
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
ModalState::None => {
|
|
match code {
|
|
KeyCode::Char('q') | KeyCode::Char('Q') => {
|
|
app.should_quit = true;
|
|
}
|
|
KeyCode::Up | KeyCode::Char('k') => {
|
|
if !app.peers.is_empty() {
|
|
let i = match app.peer_list_state.selected() {
|
|
Some(i) => {
|
|
if i == 0 {
|
|
app.peers.len() - 1
|
|
} else {
|
|
i - 1
|
|
}
|
|
}
|
|
None => 0,
|
|
};
|
|
app.peer_list_state.select(Some(i));
|
|
}
|
|
}
|
|
KeyCode::Down | KeyCode::Char('j') => {
|
|
if !app.peers.is_empty() {
|
|
let i = match app.peer_list_state.selected() {
|
|
Some(i) => {
|
|
if i >= app.peers.len() - 1 {
|
|
0
|
|
} else {
|
|
i + 1
|
|
}
|
|
}
|
|
None => 0,
|
|
};
|
|
app.peer_list_state.select(Some(i));
|
|
}
|
|
}
|
|
KeyCode::Char('s') | KeyCode::Char('S') => {
|
|
if let Some(i) = app.peer_list_state.selected() {
|
|
if let Some(peer) = app.peers.get(i) {
|
|
app.modal = ModalState::InputFilePath {
|
|
peer_url: peer.transfer_url(),
|
|
peer_name: peer.name.clone(),
|
|
input: String::new(),
|
|
};
|
|
}
|
|
}
|
|
}
|
|
KeyCode::Char('d') | KeyCode::Char('D') => {
|
|
app.modal = ModalState::InputDirectIp {
|
|
input: String::new(),
|
|
};
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn draw_ui(f: &mut Frame, app: &mut AppState) {
|
|
let size = f.area();
|
|
|
|
let chunks = Layout::default()
|
|
.direction(Direction::Vertical)
|
|
.constraints([
|
|
Constraint::Length(3), // Header
|
|
Constraint::Min(10), // Body
|
|
Constraint::Length(3), // Footer / Shortcuts
|
|
])
|
|
.split(size);
|
|
|
|
// 1. Header
|
|
let header_text = vec![
|
|
Span::styled("⚡ ZeroSend ", Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)),
|
|
Span::raw("| Node: "),
|
|
Span::styled(&app.my_name, Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
|
|
Span::raw(" | Interface: "),
|
|
Span::styled(
|
|
format!("{} ({})", app.iface.ip, app.iface.net_type.as_str()),
|
|
Style::default().fg(Color::Green).add_modifier(Modifier::BOLD),
|
|
),
|
|
Span::raw(" | Port: "),
|
|
Span::styled("53318", Style::default().fg(Color::Magenta)),
|
|
];
|
|
let header = Paragraph::new(Line::from(header_text))
|
|
.block(
|
|
Block::default()
|
|
.borders(Borders::ALL)
|
|
.border_type(BorderType::Rounded)
|
|
.border_style(Style::default().fg(Color::Cyan)),
|
|
)
|
|
.alignment(Alignment::Left);
|
|
f.render_widget(header, chunks[0]);
|
|
|
|
// 2. Body (Left: Peers, Right: Transfers & Logs)
|
|
let body_chunks = Layout::default()
|
|
.direction(Direction::Horizontal)
|
|
.constraints([Constraint::Percentage(45), Constraint::Percentage(55)])
|
|
.split(chunks[1]);
|
|
|
|
// Left Panel: Discovered Peers
|
|
let items: Vec<ListItem> = app
|
|
.peers
|
|
.iter()
|
|
.map(|p| {
|
|
let status_icon = if p.is_online { "🟢" } else { "⚪" };
|
|
let lines = vec![
|
|
Line::from(vec![
|
|
Span::raw(format!("{} ", status_icon)),
|
|
Span::styled(&p.name, Style::default().fg(Color::White).add_modifier(Modifier::BOLD)),
|
|
Span::styled(format!(" [{}]", p.net_type), Style::default().fg(Color::Blue)),
|
|
]),
|
|
Line::from(vec![
|
|
Span::raw(" IP: "),
|
|
Span::styled(format!("{}:{}", p.ip, p.transfer_port), Style::default().fg(Color::Gray)),
|
|
Span::raw(" | OS: "),
|
|
Span::styled(&p.os, Style::default().fg(Color::DarkGray)),
|
|
]),
|
|
];
|
|
ListItem::new(lines)
|
|
})
|
|
.collect();
|
|
|
|
let peers_block = Block::default()
|
|
.title(" 📡 Discovered Peers (ZeroTier / VPN) ")
|
|
.borders(Borders::ALL)
|
|
.border_type(BorderType::Rounded)
|
|
.border_style(Style::default().fg(Color::Blue));
|
|
|
|
let peers_list = List::new(items)
|
|
.block(peers_block)
|
|
.highlight_style(
|
|
Style::default()
|
|
.bg(Color::Rgb(30, 58, 138))
|
|
.fg(Color::Yellow)
|
|
.add_modifier(Modifier::BOLD),
|
|
)
|
|
.highlight_symbol("▶ ");
|
|
|
|
f.render_stateful_widget(peers_list, body_chunks[0], &mut app.peer_list_state);
|
|
|
|
// Right Panel: Transfers (Top) & Logs (Bottom)
|
|
let right_chunks = Layout::default()
|
|
.direction(Direction::Vertical)
|
|
.constraints([Constraint::Percentage(55), Constraint::Percentage(45)])
|
|
.split(body_chunks[1]);
|
|
|
|
// Transfers List
|
|
let transfer_block = Block::default()
|
|
.title(" 📦 Active & Recent Transfers ")
|
|
.borders(Borders::ALL)
|
|
.border_type(BorderType::Rounded)
|
|
.border_style(Style::default().fg(Color::Green));
|
|
|
|
if app.transfers.is_empty() {
|
|
let no_transfers = Paragraph::new("No active transfers. Press [S] to send a file.")
|
|
.style(Style::default().fg(Color::DarkGray))
|
|
.block(transfer_block)
|
|
.alignment(Alignment::Center);
|
|
f.render_widget(no_transfers, right_chunks[0]);
|
|
} else {
|
|
let transfer_items: Vec<ListItem> = app
|
|
.transfers
|
|
.iter()
|
|
.map(|t| {
|
|
let direction = if t.is_incoming { "📥 IN" } else { "📤 OUT" };
|
|
let speed = if t.speed_bps > 0 {
|
|
format!(" ({}/s)", human_bytes(t.speed_bps as f64))
|
|
} else {
|
|
String::new()
|
|
};
|
|
|
|
let state_str = match &t.state {
|
|
TransferState::PendingConfirmation => "⏳ Waiting confirmation...".to_string(),
|
|
TransferState::InProgress => format!("{:.1}%{}", t.percentage(), speed),
|
|
TransferState::Completed => "✅ Completed".to_string(),
|
|
TransferState::Failed(e) => format!("❌ Failed: {}", e),
|
|
TransferState::Rejected => "⛔ Rejected".to_string(),
|
|
TransferState::Paused => "⏸ Paused".to_string(),
|
|
TransferState::Canceled => "🛑 Canceled".to_string(),
|
|
};
|
|
|
|
let lines = vec![
|
|
Line::from(vec![
|
|
Span::styled(format!("[{}] ", direction), Style::default().fg(Color::Magenta)),
|
|
Span::styled(&t.current_file_name, Style::default().fg(Color::White).add_modifier(Modifier::BOLD)),
|
|
Span::raw(format!(" -> {}", t.peer_name)),
|
|
]),
|
|
Line::from(vec![
|
|
Span::raw(" "),
|
|
Span::styled(
|
|
format!("{}/{}", human_bytes(t.bytes_transferred as f64), human_bytes(t.total_bytes as f64)),
|
|
Style::default().fg(Color::Cyan),
|
|
),
|
|
Span::raw(" | "),
|
|
Span::styled(state_str, Style::default().fg(Color::Yellow)),
|
|
]),
|
|
];
|
|
ListItem::new(lines)
|
|
})
|
|
.collect();
|
|
|
|
let trans_list = List::new(transfer_items).block(transfer_block);
|
|
f.render_widget(trans_list, right_chunks[0]);
|
|
}
|
|
|
|
// Activity Logs
|
|
let logs_block = Block::default()
|
|
.title(" 📝 Activity Log ")
|
|
.borders(Borders::ALL)
|
|
.border_type(BorderType::Rounded)
|
|
.border_style(Style::default().fg(Color::Gray));
|
|
|
|
let log_lines: Vec<Line> = app
|
|
.logs
|
|
.iter()
|
|
.rev()
|
|
.take(10)
|
|
.rev()
|
|
.map(|l| Line::from(Span::styled(l, Style::default().fg(Color::White))))
|
|
.collect();
|
|
|
|
let logs_widget = Paragraph::new(log_lines)
|
|
.block(logs_block)
|
|
.wrap(Wrap { trim: true });
|
|
f.render_widget(logs_widget, right_chunks[1]);
|
|
|
|
// 3. Footer / Shortcuts
|
|
let shortcuts = Line::from(vec![
|
|
Span::styled("[S] ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
|
|
Span::raw("Send File "),
|
|
Span::styled("[D] ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
|
|
Span::raw("Direct IP "),
|
|
Span::styled("[↑/↓] ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
|
|
Span::raw("Select Peer "),
|
|
Span::styled("[Q] ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
|
|
Span::raw("Quit"),
|
|
]);
|
|
let footer = Paragraph::new(shortcuts)
|
|
.block(
|
|
Block::default()
|
|
.borders(Borders::ALL)
|
|
.border_type(BorderType::Rounded)
|
|
.border_style(Style::default().fg(Color::DarkGray)),
|
|
)
|
|
.alignment(Alignment::Center);
|
|
f.render_widget(footer, chunks[2]);
|
|
|
|
// Modals
|
|
match &app.modal {
|
|
ModalState::ConfirmTransfer(req) => {
|
|
let area = centered_rect(60, 30, size);
|
|
f.render_widget(Clear, area);
|
|
|
|
let block = Block::default()
|
|
.title(" 📥 Incoming File Transfer ")
|
|
.borders(Borders::ALL)
|
|
.border_type(BorderType::Double)
|
|
.border_style(Style::default().fg(Color::Yellow));
|
|
|
|
let files_summary = req
|
|
.files
|
|
.iter()
|
|
.map(|f| format!(" - {} ({})", f.name, human_bytes(f.size as f64)))
|
|
.collect::<Vec<_>>()
|
|
.join("\n");
|
|
|
|
let text = vec![
|
|
Line::from(vec![
|
|
Span::styled(&req.sender_name, Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)),
|
|
Span::raw(format!(" wants to send you {} file(s) ({})", req.files.len(), human_bytes(req.total_size as f64))),
|
|
]),
|
|
Line::from(""),
|
|
Line::from(files_summary),
|
|
Line::from(""),
|
|
Line::from(vec![
|
|
Span::styled("[Y/A] Accept", Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)),
|
|
Span::raw(" | "),
|
|
Span::styled("[N/R] Reject", Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)),
|
|
]),
|
|
];
|
|
|
|
let modal = Paragraph::new(text)
|
|
.block(block)
|
|
.alignment(Alignment::Center)
|
|
.wrap(Wrap { trim: true });
|
|
f.render_widget(modal, area);
|
|
}
|
|
ModalState::InputFilePath { peer_name, input, .. } => {
|
|
let area = centered_rect(65, 25, size);
|
|
f.render_widget(Clear, area);
|
|
|
|
let block = Block::default()
|
|
.title(format!(" 📤 Send File to {} ", peer_name))
|
|
.borders(Borders::ALL)
|
|
.border_type(BorderType::Rounded)
|
|
.border_style(Style::default().fg(Color::Cyan));
|
|
|
|
let text = vec![
|
|
Line::from("Enter absolute or relative path to file/folder:"),
|
|
Line::from(""),
|
|
Line::from(vec![
|
|
Span::styled("> ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
|
|
Span::styled(input, Style::default().fg(Color::White).add_modifier(Modifier::BOLD)),
|
|
Span::styled("█", Style::default().fg(Color::Cyan)),
|
|
]),
|
|
Line::from(""),
|
|
Line::from(vec![
|
|
Span::styled("[Enter] Send", Style::default().fg(Color::Green)),
|
|
Span::raw(" "),
|
|
Span::styled("[Esc] Cancel", Style::default().fg(Color::DarkGray)),
|
|
]),
|
|
];
|
|
|
|
let modal = Paragraph::new(text).block(block).wrap(Wrap { trim: true });
|
|
f.render_widget(modal, area);
|
|
}
|
|
ModalState::InputDirectIp { input } => {
|
|
let area = centered_rect(60, 25, size);
|
|
f.render_widget(Clear, area);
|
|
|
|
let block = Block::default()
|
|
.title(" 🔍 Connect Direct IP (Tailscale / Custom) ")
|
|
.borders(Borders::ALL)
|
|
.border_type(BorderType::Rounded)
|
|
.border_style(Style::default().fg(Color::Magenta));
|
|
|
|
let text = vec![
|
|
Line::from("Enter peer IP address (e.g. 10.147.20.55 or 100.80.x.x):"),
|
|
Line::from(""),
|
|
Line::from(vec![
|
|
Span::styled("> ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
|
|
Span::styled(input, Style::default().fg(Color::White).add_modifier(Modifier::BOLD)),
|
|
Span::styled("█", Style::default().fg(Color::Magenta)),
|
|
]),
|
|
Line::from(""),
|
|
Line::from(vec![
|
|
Span::styled("[Enter] Connect", Style::default().fg(Color::Green)),
|
|
Span::raw(" "),
|
|
Span::styled("[Esc] Cancel", Style::default().fg(Color::DarkGray)),
|
|
]),
|
|
];
|
|
|
|
let modal = Paragraph::new(text).block(block).wrap(Wrap { trim: true });
|
|
f.render_widget(modal, area);
|
|
}
|
|
ModalState::None => {}
|
|
}
|
|
}
|
|
|
|
/// Helper function to create centered modal rectangle
|
|
fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
|
|
let popup_layout = Layout::default()
|
|
.direction(Direction::Vertical)
|
|
.constraints([
|
|
Constraint::Percentage((100 - percent_y) / 2),
|
|
Constraint::Percentage(percent_y),
|
|
Constraint::Percentage((100 - percent_y) / 2),
|
|
])
|
|
.split(r);
|
|
|
|
Layout::default()
|
|
.direction(Direction::Horizontal)
|
|
.constraints([
|
|
Constraint::Percentage((100 - percent_x) / 2),
|
|
Constraint::Percentage(percent_x),
|
|
Constraint::Percentage((100 - percent_x) / 2),
|
|
])
|
|
.split(popup_layout[1])[1]
|
|
}
|