mirror of https://github.com/ospab/ostp.git
319 lines
11 KiB
Rust
319 lines
11 KiB
Rust
pub mod components;
|
|
|
|
use std::io;
|
|
use std::time::Duration;
|
|
|
|
use anyhow::Result;
|
|
use crossterm::event::{self, Event, KeyCode, KeyEventKind};
|
|
use crossterm::execute;
|
|
use crossterm::terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen};
|
|
use ratatui::backend::CrosstermBackend;
|
|
use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect};
|
|
use ratatui::style::{Color, Style};
|
|
use ratatui::text::{Line, Span};
|
|
use ratatui::widgets::{Block, Borders, Clear, Paragraph};
|
|
use ratatui::Terminal;
|
|
use tokio::sync::mpsc;
|
|
|
|
use crate::app::{AppState, BridgeCommand, UiEvent};
|
|
use crate::config::ClientConfig;
|
|
use crate::tui::components::controls::ControlsComponent;
|
|
use crate::tui::components::dashboard::DashboardComponent;
|
|
use crate::tui::components::logs::LogsComponent;
|
|
use crate::tui::components::traffic::TrafficComponent;
|
|
|
|
struct KeyEditorState {
|
|
open: bool,
|
|
focus: KeyEditorField,
|
|
server_addr: String,
|
|
access_key: String,
|
|
}
|
|
|
|
#[derive(Clone, Copy)]
|
|
enum KeyEditorField {
|
|
ServerAddr,
|
|
AccessKey,
|
|
}
|
|
|
|
enum KeyEditorAction {
|
|
Noop,
|
|
Saved,
|
|
Canceled,
|
|
}
|
|
|
|
pub struct TuiRuntime {
|
|
state: AppState,
|
|
config: ClientConfig,
|
|
dashboard: DashboardComponent,
|
|
logs: LogsComponent,
|
|
traffic: TrafficComponent,
|
|
controls: ControlsComponent,
|
|
key_editor: KeyEditorState,
|
|
}
|
|
|
|
pub enum TuiExit {
|
|
Exit,
|
|
Background,
|
|
}
|
|
|
|
impl TuiRuntime {
|
|
pub fn new(config: ClientConfig) -> Self {
|
|
let key_editor = KeyEditorState {
|
|
open: false,
|
|
focus: KeyEditorField::ServerAddr,
|
|
server_addr: config.ostp.server_addr.clone(),
|
|
access_key: config.ostp.access_key.clone(),
|
|
};
|
|
|
|
Self {
|
|
state: AppState::new(),
|
|
config,
|
|
dashboard: DashboardComponent,
|
|
logs: LogsComponent,
|
|
traffic: TrafficComponent,
|
|
controls: ControlsComponent,
|
|
key_editor,
|
|
}
|
|
}
|
|
|
|
pub async fn run(
|
|
mut self,
|
|
ui_rx: mpsc::Receiver<UiEvent>,
|
|
cmd_tx: mpsc::Sender<BridgeCommand>,
|
|
) -> Result<TuiExit> {
|
|
enable_raw_mode()?;
|
|
let mut stdout = io::stdout();
|
|
execute!(stdout, EnterAlternateScreen)?;
|
|
let backend = CrosstermBackend::new(stdout);
|
|
let mut terminal = Terminal::new(backend)?;
|
|
|
|
let result = self.event_loop(&mut terminal, ui_rx, cmd_tx).await;
|
|
|
|
disable_raw_mode()?;
|
|
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
|
|
terminal.show_cursor()?;
|
|
|
|
result
|
|
}
|
|
|
|
async fn event_loop(
|
|
&mut self,
|
|
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
|
|
mut ui_rx: mpsc::Receiver<UiEvent>,
|
|
cmd_tx: mpsc::Sender<BridgeCommand>,
|
|
) -> Result<TuiExit> {
|
|
loop {
|
|
while let Ok(ev) = ui_rx.try_recv() {
|
|
self.state.apply_event(ev);
|
|
}
|
|
|
|
terminal.draw(|frame| {
|
|
let root = Layout::default()
|
|
.direction(Direction::Vertical)
|
|
.constraints([
|
|
Constraint::Length(6),
|
|
Constraint::Length(12),
|
|
Constraint::Min(8),
|
|
Constraint::Length(6),
|
|
])
|
|
.split(frame.area());
|
|
|
|
self.dashboard.render(frame, root[0], &self.state);
|
|
self.traffic.render(frame, root[1], &self.state);
|
|
self.logs.render(frame, root[2], &self.state);
|
|
self.controls.render(frame, root[3]);
|
|
|
|
if self.key_editor.open {
|
|
render_key_editor(frame, frame.area(), &self.key_editor);
|
|
}
|
|
})?;
|
|
|
|
if event::poll(Duration::from_millis(50))? {
|
|
if let Event::Key(key) = event::read()? {
|
|
if key.kind == KeyEventKind::Press {
|
|
if self.key_editor.open {
|
|
match self.handle_key_editor_input(key.code) {
|
|
KeyEditorAction::Saved => {
|
|
let _ = cmd_tx.send(BridgeCommand::ReloadConfig).await;
|
|
continue;
|
|
}
|
|
KeyEditorAction::Canceled | KeyEditorAction::Noop => {
|
|
continue;
|
|
}
|
|
}
|
|
}
|
|
|
|
match key.code {
|
|
KeyCode::Char('q') | KeyCode::Esc => {
|
|
let _ = cmd_tx.send(BridgeCommand::Shutdown).await;
|
|
return Ok(TuiExit::Exit);
|
|
}
|
|
KeyCode::Char('b') | KeyCode::Char('B') => {
|
|
self.push_local_log("TUI detached; client continues in background".to_string());
|
|
return Ok(TuiExit::Background);
|
|
}
|
|
KeyCode::Char('k') | KeyCode::Char('K') => {
|
|
self.key_editor.open = true;
|
|
self.key_editor.focus = KeyEditorField::ServerAddr;
|
|
self.key_editor.server_addr = self.config.ostp.server_addr.clone();
|
|
self.key_editor.access_key = self.config.ostp.access_key.clone();
|
|
}
|
|
KeyCode::Char(' ') => {
|
|
let _ = cmd_tx.send(BridgeCommand::ToggleTunnel).await;
|
|
}
|
|
KeyCode::Tab => {
|
|
let _ = cmd_tx.send(BridgeCommand::NextProfile).await;
|
|
}
|
|
KeyCode::Up => {
|
|
self.state.log_scroll = self.state.log_scroll.saturating_sub(1);
|
|
}
|
|
KeyCode::Down => {
|
|
self.state.log_scroll = self.state.log_scroll.saturating_add(1);
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
tokio::time::sleep(Duration::from_millis(16)).await;
|
|
}
|
|
|
|
#[allow(unreachable_code)]
|
|
Ok(TuiExit::Exit)
|
|
}
|
|
|
|
fn handle_key_editor_input(&mut self, code: KeyCode) -> KeyEditorAction {
|
|
match code {
|
|
KeyCode::Esc => {
|
|
self.key_editor.open = false;
|
|
self.push_local_log("Key editor canceled".to_string());
|
|
KeyEditorAction::Canceled
|
|
}
|
|
KeyCode::Tab => {
|
|
self.key_editor.focus = match self.key_editor.focus {
|
|
KeyEditorField::ServerAddr => KeyEditorField::AccessKey,
|
|
KeyEditorField::AccessKey => KeyEditorField::ServerAddr,
|
|
};
|
|
KeyEditorAction::Noop
|
|
}
|
|
KeyCode::Backspace => {
|
|
match self.key_editor.focus {
|
|
KeyEditorField::ServerAddr => {
|
|
self.key_editor.server_addr.pop();
|
|
}
|
|
KeyEditorField::AccessKey => {
|
|
self.key_editor.access_key.pop();
|
|
}
|
|
}
|
|
KeyEditorAction::Noop
|
|
}
|
|
KeyCode::Enter => {
|
|
if self.key_editor.server_addr.trim().is_empty() {
|
|
self.push_local_log("Save failed: server address cannot be empty".to_string());
|
|
return KeyEditorAction::Noop;
|
|
}
|
|
if self.key_editor.access_key.trim().is_empty() {
|
|
self.push_local_log("Save failed: access key cannot be empty".to_string());
|
|
return KeyEditorAction::Noop;
|
|
}
|
|
|
|
self.config.ostp.server_addr = self.key_editor.server_addr.trim().to_string();
|
|
self.config.ostp.access_key = self.key_editor.access_key.trim().to_string();
|
|
|
|
match self.config.save_to_json_near_binary() {
|
|
Ok(()) => self.push_local_log(
|
|
"Config saved to config.json"
|
|
.to_string(),
|
|
),
|
|
Err(err) => self.push_local_log(format!("Save failed: {err}")),
|
|
}
|
|
|
|
self.key_editor.open = false;
|
|
KeyEditorAction::Saved
|
|
}
|
|
KeyCode::Char(c) => {
|
|
match self.key_editor.focus {
|
|
KeyEditorField::ServerAddr => self.key_editor.server_addr.push(c),
|
|
KeyEditorField::AccessKey => self.key_editor.access_key.push(c),
|
|
}
|
|
KeyEditorAction::Noop
|
|
}
|
|
_ => KeyEditorAction::Noop,
|
|
}
|
|
}
|
|
|
|
fn push_local_log(&mut self, line: String) {
|
|
self.state.apply_event(UiEvent::Log(line));
|
|
}
|
|
}
|
|
|
|
fn render_key_editor(frame: &mut ratatui::Frame<'_>, area: Rect, editor: &KeyEditorState) {
|
|
let popup = centered_rect(80, 45, area);
|
|
frame.render_widget(Clear, popup);
|
|
|
|
let block = Block::default().title("Edit Keys").borders(Borders::ALL);
|
|
frame.render_widget(block, popup);
|
|
|
|
let inner = Rect {
|
|
x: popup.x + 2,
|
|
y: popup.y + 1,
|
|
width: popup.width.saturating_sub(4),
|
|
height: popup.height.saturating_sub(2),
|
|
};
|
|
|
|
let lines = vec![
|
|
Line::from(vec![
|
|
Span::styled(
|
|
"Server Addr:",
|
|
if matches!(editor.focus, KeyEditorField::ServerAddr) {
|
|
Style::default().fg(Color::Yellow)
|
|
} else {
|
|
Style::default()
|
|
},
|
|
),
|
|
Span::raw(" "),
|
|
Span::raw(editor.server_addr.as_str()),
|
|
]),
|
|
Line::from(vec![
|
|
Span::styled(
|
|
"Access Key:",
|
|
if matches!(editor.focus, KeyEditorField::AccessKey) {
|
|
Style::default().fg(Color::Yellow)
|
|
} else {
|
|
Style::default()
|
|
},
|
|
),
|
|
Span::raw(" "),
|
|
Span::raw(editor.access_key.as_str()),
|
|
]),
|
|
Line::from(""),
|
|
Line::from("Tab switch field, Enter save+reload, Esc cancel"),
|
|
];
|
|
|
|
let widget = Paragraph::new(lines).alignment(Alignment::Left);
|
|
frame.render_widget(widget, inner);
|
|
}
|
|
|
|
fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
|
|
let vertical = Layout::default()
|
|
.direction(Direction::Vertical)
|
|
.constraints([
|
|
Constraint::Percentage((100 - percent_y) / 2),
|
|
Constraint::Percentage(percent_y),
|
|
Constraint::Percentage((100 - percent_y) / 2),
|
|
])
|
|
.split(r);
|
|
|
|
let horizontal = Layout::default()
|
|
.direction(Direction::Horizontal)
|
|
.constraints([
|
|
Constraint::Percentage((100 - percent_x) / 2),
|
|
Constraint::Percentage(percent_x),
|
|
Constraint::Percentage((100 - percent_x) / 2),
|
|
])
|
|
.split(vertical[1]);
|
|
|
|
horizontal[1]
|
|
}
|