summaryrefslogtreecommitdiff
path: root/src/tui.rs
diff options
context:
space:
mode:
authorAndrew Guschin <guschin@altlinux.org>2026-10-30 03:10:41 +0400
committerAndrew Guschin <guschin@altlinux.org>2026-10-30 03:10:41 +0400
commitd608ffeeda1a479c9dc0e5f6ad269ba07a510d7f (patch)
tree3824ec9da5ca0adac913cc47e3e20054c3dc843a /src/tui.rs
parent40cf3276cdc74bf6f1795990ed600891a637d1fe (diff)
Implement Linux proxy controller, subscription state, and CLI/TUI
Diffstat (limited to 'src/tui.rs')
-rw-r--r--src/tui.rs565
1 files changed, 565 insertions, 0 deletions
diff --git a/src/tui.rs b/src/tui.rs
new file mode 100644
index 0000000..9cb69a7
--- /dev/null
+++ b/src/tui.rs
@@ -0,0 +1,565 @@
+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<CrosstermBackend<io::Stdout>>;
+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<String>,
+ 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<SubscriptionSummary>,
+ entries: Vec<ConfigurationSummary>,
+ settings: Settings,
+ message: String,
+ logs: Vec<String>,
+}
+
+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(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",
+ ],
+ &current,
+ )? {
+ let parsed = (values[2].parse::<u64>(), values[3].parse::<u64>());
+ 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<Option<Vec<String>>> {
+ 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::<Vec<_>>()
+ .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::<Vec<_>>().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))
+}