use crate::{ bus::Client, controller::Operation, model::{ConfigurationSummary, Settings, Status, SubscriptionSummary}, }; use anyhow::{Result, ensure}; use crossterm::{ event::{self, Event, KeyCode, KeyModifiers}, execute, terminal::{self, EnterAlternateScreen, LeaveAlternateScreen}, }; use ratatui::{ Terminal, backend::CrosstermBackend, layout::{Constraint, Layout}, style::{Color, Modifier, Style}, widgets::{Block, Borders, Cell, Paragraph, Row, Table, TableState, Tabs}, }; use std::{ io::{self, IsTerminal}, time::Duration, }; type Screen = Terminal>; struct Restore; impl Drop for Restore { fn drop(&mut self) { let _ = terminal::disable_raw_mode(); let _ = execute!(io::stdout(), LeaveAlternateScreen); } } struct Ui { tab: usize, row: usize, viewed: Option, filter: String, editing_filter: bool, chooser: bool, chooser_query: String, chooser_row: usize, console: bool, details: bool, help: bool, first_g: bool, status: Status, subscriptions: Vec, entries: Vec, settings: Settings, message: String, logs: Vec, } impl Ui { async fn refresh(&mut self, client: &Client) -> Result<()> { self.status = client.read("Status").await?; self.subscriptions = client.read("Subscriptions").await?; if !self .subscriptions .iter() .any(|s| Some(&s.name) == self.viewed.as_ref()) { self.viewed = self.subscriptions.first().map(|s| s.name.clone()); } self.entries = client.configurations(self.viewed.as_deref()).await?; self.settings = client.read("Settings").await?; self.logs = client.read("Logs").await?; self.row = self.row.min( if self.tab == 1 { self.subscriptions.len() } else { self.visible().len() } .saturating_sub(1), ); Ok(()) } fn visible(&self) -> Vec<&ConfigurationSummary> { let query = self.filter.to_lowercase(); self.entries .iter() .filter(|e| { e.name.to_lowercase().contains(&query) || e.host .as_deref() .unwrap_or("") .to_lowercase() .contains(&query) }) .collect() } fn choices(&self) -> Vec<&SubscriptionSummary> { self.subscriptions .iter() .filter(|s| fuzzy(&self.chooser_query, &s.name)) .collect() } fn selected_row(&self) -> Option<&ConfigurationSummary> { self.visible().get(self.row).copied() } } pub async fn run(client: Client) -> Result<()> { ensure!( io::stdin().is_terminal() && io::stdout().is_terminal(), "The TUI needs a terminal; use status --json for scripts" ); let status = client.read("Status").await?; let mut ui = Ui { tab: 0, row: 0, viewed: None, filter: String::new(), editing_filter: false, chooser: false, chooser_query: String::new(), chooser_row: 0, console: false, details: false, help: false, first_g: false, status, subscriptions: vec![], entries: vec![], settings: Settings::default(), message: String::new(), logs: vec![], }; ui.refresh(&client).await?; terminal::enable_raw_mode()?; execute!(io::stdout(), EnterAlternateScreen)?; let _restore = Restore; let mut screen = Terminal::new(CrosstermBackend::new(io::stdout()))?; let mut last_refresh = std::time::Instant::now(); loop { draw(&mut screen, &ui)?; if !event::poll(Duration::from_millis(200))? { if last_refresh.elapsed() >= Duration::from_secs(2) { if let Err(error) = ui.refresh(&client).await { ui.message = error.to_string(); } last_refresh = std::time::Instant::now(); } continue; } let Event::Key(key) = event::read()? else { continue; }; if key.kind == event::KeyEventKind::Release { continue; } if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) { break; } if ui.chooser { match key.code { KeyCode::Esc => ui.chooser = false, KeyCode::Enter => { if let Some(name) = ui.choices().get(ui.chooser_row).map(|s| s.name.clone()) { ui.viewed = Some(name); ui.filter.clear(); ui.row = 0; } ui.chooser = false; ui.refresh(&client).await?; } KeyCode::Down => { ui.chooser_row = (ui.chooser_row + 1).min(ui.choices().len().saturating_sub(1)) } KeyCode::Up => ui.chooser_row = ui.chooser_row.saturating_sub(1), KeyCode::Char('n' | 'j') if key.modifiers.contains(KeyModifiers::CONTROL) => { ui.chooser_row = (ui.chooser_row + 1).min(ui.choices().len().saturating_sub(1)) } KeyCode::Char('p' | 'k') if key.modifiers.contains(KeyModifiers::CONTROL) => { ui.chooser_row = ui.chooser_row.saturating_sub(1) } KeyCode::Backspace => { ui.chooser_query.pop(); ui.chooser_row = 0; } KeyCode::Char(c) => { ui.chooser_query.push(c); ui.chooser_row = 0; } _ => {} } continue; } if ui.editing_filter { match key.code { KeyCode::Esc => { ui.filter.clear(); ui.editing_filter = false; } KeyCode::Enter => ui.editing_filter = false, KeyCode::Backspace => { ui.filter.pop(); } KeyCode::Char(c) => ui.filter.push(c), _ => {} } ui.row = 0; continue; } if ui.details || ui.help { if matches!(key.code, KeyCode::Esc | KeyCode::Enter | KeyCode::Char('q')) { ui.details = false; ui.help = false; } continue; } let mut operation = None; match key.code { KeyCode::Char('q') => break, KeyCode::Esc => { ui.tab = 0; ui.filter.clear(); } KeyCode::Char('h') | KeyCode::Left => { ui.tab = (ui.tab + 2) % 3; ui.row = 0; } KeyCode::Char('l') | KeyCode::Right => { ui.tab = (ui.tab + 1) % 3; ui.row = 0; } KeyCode::Char('j') | KeyCode::Down => ui.row = ui.row.saturating_add(1), KeyCode::Char('k') | KeyCode::Up => ui.row = ui.row.saturating_sub(1), KeyCode::Char('g') if ui.first_g => { ui.row = 0; ui.first_g = false; } KeyCode::Char('g') => { ui.first_g = true; continue; } KeyCode::Char('G') => { ui.row = if ui.tab == 1 { ui.subscriptions.len() } else { ui.visible().len() } .saturating_sub(1) } KeyCode::Char('/') if ui.tab == 0 => { ui.editing_filter = true; } KeyCode::Char('s') => { ui.chooser = true; ui.chooser_query.clear(); ui.chooser_row = 0; } KeyCode::Char('J' | 'K') => { if !ui.subscriptions.is_empty() { let index = ui .subscriptions .iter() .position(|s| Some(&s.name) == ui.viewed.as_ref()) .unwrap_or(0); let next = if key.code == KeyCode::Char('J') { (index + 1) % ui.subscriptions.len() } else { (index + ui.subscriptions.len() - 1) % ui.subscriptions.len() }; ui.viewed = Some(ui.subscriptions[next].name.clone()); ui.filter.clear(); ui.row = 0; ui.refresh(&client).await?; } } KeyCode::Char('c') => ui.console = !ui.console, KeyCode::Char('?') => ui.help = true, KeyCode::Char('d') if ui.tab == 0 => ui.details = true, KeyCode::Char(' ') => { operation = Some(if ui.status.requested_enabled { Operation::Disable } else { Operation::Enable }) } KeyCode::Char('x') if ui.tab == 0 => operation = Some(Operation::Unset), KeyCode::Char('u') => { operation = Some(Operation::Refresh { name: if ui.tab == 1 { ui.subscriptions.get(ui.row).map(|s| s.name.clone()) } else { ui.viewed.clone() }, }) } KeyCode::Enter if ui.tab == 0 => { if let Some(entry) = ui.selected_row() { operation = Some(Operation::Select { id: entry.id.clone(), }); } } KeyCode::Enter if ui.tab == 1 => { if let Some(s) = ui.subscriptions.get(ui.row) { ui.viewed = Some(s.name.clone()); ui.filter.clear(); ui.row = 0; ui.tab = 0; ui.refresh(&client).await?; } } KeyCode::Char('a') if ui.tab == 1 => { match form( &mut screen, "Add subscription", &["Name", "URL"], &[String::new(), String::new()], )? { Some(values) => { operation = Some(Operation::AddSubscription { name: values[0].clone(), url: values[1].clone(), }) } None => {} } } KeyCode::Char('e') if ui.tab == 1 => { if let Some(s) = ui.subscriptions.get(ui.row) { if let Some(values) = form( &mut screen, "Edit subscription URL", &["URL"], &[String::new()], )? { operation = Some(Operation::EditSubscription { name: s.name.clone(), url: values[0].clone(), }); } } } KeyCode::Char('x') if ui.tab == 1 => { if let Some(s) = ui.subscriptions.get(ui.row) { if let Some(values) = form( &mut screen, "Type delete to remove this subscription", &["Confirmation"], &[String::new()], )? { if values[0] == "delete" { operation = Some(Operation::DeleteSubscription { name: s.name.clone(), }); } } } } KeyCode::Char('e') | KeyCode::Enter if ui.tab == 2 => { let current = [ &ui.settings.ping_url, &ui.settings.speed_url, &ui.settings.maximum_download_mib.to_string(), &ui.settings.maximum_duration_seconds.to_string(), ] .map(|v| v.to_string()); if let Some(values) = form( &mut screen, "Settings", &[ "HTTPS Ping URL", "Speed URL", "Maximum MiB", "Maximum seconds", ], ¤t, )? { let parsed = (values[2].parse::(), values[3].parse::()); match parsed { (Ok(bytes), Ok(seconds)) => { operation = Some(Operation::SaveSettings { settings: Settings { ping_url: values[0].clone(), speed_url: values[1].clone(), maximum_download_mib: bytes, maximum_duration_seconds: seconds, }, }) } _ => ui.message = "Limits must be positive integers".into(), } } } KeyCode::Char('e') => { suspend(&mut screen)?; let result = crate::editor::open(&ui.logs); resume(&mut screen)?; if let Err(error) = result { ui.message = error.to_string(); } } KeyCode::Char('t' | 'b') if ui.tab == 0 => { if let Some(entry) = ui.selected_row() { let id = entry.id.clone(); suspend(&mut screen)?; let result = async { let _agent = crate::authentication::TerminalAgent::start(true)?; client .diagnostic(Some(&id), key.code == KeyCode::Char('b'), true) .await } .await; resume(&mut screen)?; ui.message = match result { Ok(value) => value, Err(error) => error.to_string(), }; } } _ => {} } ui.first_g = false; if let Some(operation) = operation { suspend(&mut screen)?; let result = async { let _agent = crate::authentication::TerminalAgent::start(true)?; client.change(operation, true).await } .await; resume(&mut screen)?; ui.message = match result { Ok(()) => "Applied".into(), Err(error) => error.to_string(), }; ui.refresh(&client).await?; } ui.row = ui.row.min( if ui.tab == 1 { ui.subscriptions.len() } else { ui.visible().len() } .saturating_sub(1), ); } Ok(()) } fn suspend(screen: &mut Screen) -> Result<()> { terminal::disable_raw_mode()?; execute!(screen.backend_mut(), LeaveAlternateScreen)?; Ok(()) } fn resume(screen: &mut Screen) -> Result<()> { terminal::enable_raw_mode()?; execute!(screen.backend_mut(), EnterAlternateScreen)?; screen.clear()?; Ok(()) } fn form( screen: &mut Screen, title: &str, labels: &[&str], initial: &[String], ) -> Result>> { let mut values = initial.to_vec(); let mut field = 0; let mut discarding = false; loop { screen.draw(|frame| { let text = labels .iter() .enumerate() .map(|(i, label)| { let value = if *label == "URL" { "*".repeat(values[i].chars().count()) } else { values[i].clone() }; format!("{} {label}: {value}", if field == i { ">" } else { " " }) }) .collect::>() .join("\n\n"); let help = if discarding { "Discard edits? y confirms, any other key resumes" } else { "Tab moves fields. Enter saves from the last field. Esc cancels." }; frame.render_widget( Paragraph::new(format!("{text}\n\n{help}")) .block(Block::default().borders(Borders::ALL).title(title)), frame.area(), ); })?; let Event::Key(key) = event::read()? else { continue; }; if discarding { if key.code == KeyCode::Char('y') { return Ok(None); } discarding = false; continue; } match key.code { KeyCode::Esc => { if values == initial { return Ok(None); } discarding = true; } KeyCode::Tab => field = (field + 1) % values.len(), KeyCode::BackTab => field = (field + values.len() - 1) % values.len(), KeyCode::Enter if field + 1 == values.len() => return Ok(Some(values)), KeyCode::Enter => field += 1, KeyCode::Backspace => { values[field].pop(); } KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => { if values[field].len() < 16384 { values[field].push(c); } } _ => {} } } } fn draw(screen: &mut Screen, ui: &Ui) -> Result<()> { screen.draw(|frame| { let layout = Layout::vertical([Constraint::Length(1), Constraint::Length(2), Constraint::Min(4), Constraint::Length(if ui.console { 6 } else { 0 }), Constraint::Length(2)]).split(frame.area()); frame.render_widget(Tabs::new(["Connections", "Subscriptions", "Settings"]).select(ui.tab).highlight_style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)), layout[0]); let actions = match ui.tab { 0 => format!("{} [s] Update [u] Details [d] Ping [t] Speed [b] {}", ui.viewed.as_deref().unwrap_or("No subscription"), if ui.filter.is_empty() && !ui.editing_filter { String::new() } else { format!("/ {}", ui.filter) }), 1 => "Add [a] Edit [e] Update [u] Delete [x] Open [Enter]".into(), _ => "Edit settings [e]".into(), }; frame.render_widget(Paragraph::new(actions), layout[1]); if ui.help { frame.render_widget(Paragraph::new("h/l: screens j/k: rows gg/G: first/last\nEnter: select/open Space: enable/disable x: unset/delete\ns: subscription chooser J/K: cycle subscriptions /: filter\nd: details t: Ping b: Speed c: console e: logs/editor\n?: help Esc: back/clear q: quit\nSelection and viewed subscription are separate.\nClosing this interface leaves the service running."), layout[2]); } else if ui.details { let text = ui.selected_row().map(|e| format!("{}\nID: {}\nHost: {}\nPort: {}\nStatus: {}\n{}", e.name, e.id, e.host.as_deref().unwrap_or("unavailable"), e.port.map(|p| p.to_string()).unwrap_or_default(), e.status, e.explanation.as_deref().unwrap_or("Trojan / TCP / TLS"))).unwrap_or_else(|| "No configuration highlighted".into()); frame.render_widget(Paragraph::new(text), layout[2]); } else if ui.chooser { let choices = ui.choices(); let rows = choices.iter().map(|s| Row::new([s.name.as_str()])); let mut state = TableState::default().with_selected(Some(ui.chooser_row)); frame.render_stateful_widget(Table::new(rows, [Constraint::Percentage(100)]).block(Block::default().borders(Borders::ALL).title(format!("Choose subscription / {}", ui.chooser_query))).row_highlight_style(Style::default().bg(Color::DarkGray)), layout[2], &mut state); } else if ui.tab == 0 { let rows = ui.visible().into_iter().map(|entry| Row::new([ Cell::from(if entry.selected { "*" } else { " " }), Cell::from(format!("{}{}", if entry.status == "supported" { String::new() } else { format!("[{}] ", entry.status) }, entry.name)), Cell::from(entry.host.clone().unwrap_or_default()), Cell::from(entry.ping_ms.map(|v| format!("{v} ms")).unwrap_or_else(|| "?".into())), Cell::from(entry.speed_bytes_per_second.map(|v| format!("{:.1} MiB/s", v / 1048576.0)).unwrap_or_else(|| "?".into())), ])); let mut state = TableState::default().with_selected(Some(ui.row)); frame.render_stateful_widget(Table::new(rows, [Constraint::Length(1), Constraint::Percentage(40), Constraint::Percentage(35), Constraint::Length(9), Constraint::Length(9)]).header(Row::new(["", "Name", "Host", "Delay", "Speed"])).row_highlight_style(Style::default().bg(Color::DarkGray)), layout[2], &mut state); } else if ui.tab == 1 { let mut state = TableState::default().with_selected(Some(ui.row)); frame.render_stateful_widget(Table::new(ui.subscriptions.iter().map(|s| Row::new([s.name.as_str()])), [Constraint::Percentage(100)]).row_highlight_style(Style::default().bg(Color::DarkGray)), layout[2], &mut state); } else { frame.render_widget(Paragraph::new(format!("HTTPS Ping URL: {}\nSpeed URL: {}\nMaximum download MiB: {}\nMaximum duration seconds: {}", ui.settings.ping_url, ui.settings.speed_url, ui.settings.maximum_download_mib, ui.settings.maximum_duration_seconds)), layout[2]); } if ui.console { frame.render_widget(Paragraph::new(ui.logs.iter().rev().take(5).cloned().collect::>().join("\n")).block(Block::default().borders(Borders::TOP)), layout[3]); } frame.render_widget(Paragraph::new(format!("{} | Routing: {} | Health: {} | Selected: {}\n{}", if ui.status.requested_enabled { "Enabled" } else { "Disabled" }, ui.status.routing, ui.status.health, ui.status.selected_configuration.as_deref().unwrap_or("unset"), ui.message)), layout[4]); })?; Ok(()) } fn fuzzy(query: &str, value: &str) -> bool { let value = value.to_lowercase(); let mut chars = value.chars(); query .to_lowercase() .chars() .all(|wanted| chars.by_ref().any(|c| c == wanted)) }