Implement Clash TUI with iocraft
Fullscreen terminal UI for Clash proxy manager with proxy selector, connections viewer, configurable keybindings, and TOML config support. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
commit
4d79ba5884
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
/target
|
||||||
2975
Cargo.lock
generated
Normal file
2975
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
17
Cargo.toml
Normal file
17
Cargo.toml
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
[package]
|
||||||
|
name = "clash_tui"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
iocraft = "0.8"
|
||||||
|
smol = "2"
|
||||||
|
surf = "2"
|
||||||
|
clap = { version = "4", features = ["derive"] }
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
toml = "0.8"
|
||||||
|
anyhow = "1"
|
||||||
|
dirs = "6"
|
||||||
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
|
crossterm = "0.29"
|
||||||
78
SUMMARY.md
Normal file
78
SUMMARY.md
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
# Clash TUI
|
||||||
|
|
||||||
|
A fullscreen terminal UI for controlling the [Clash](https://github.com/Dreamacro/clash) proxy manager API, built with Rust and [iocraft](https://github.com/ccbrown/iocraft).
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Proxy selector** -- browse proxy groups in a two-pane layout, switch active proxies
|
||||||
|
- **Connections viewer** -- view active connections in a sortable table, close individual or all connections
|
||||||
|
- **Configurable keybindings** -- customize all shortcuts via TOML config
|
||||||
|
- **Periodic auto-refresh** -- configurable refresh interval (default 2s)
|
||||||
|
- **Mouse support** -- click tabs to switch views
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Config file: `~/.config/clash_tui/config.toml`
|
||||||
|
|
||||||
|
```toml
|
||||||
|
clash_api_url = "http://127.0.0.1:9090"
|
||||||
|
clash_api_secret = "your-secret" # optional
|
||||||
|
refresh_interval = 2 # seconds
|
||||||
|
|
||||||
|
[keybindings]
|
||||||
|
quit = "q"
|
||||||
|
tab_next = "Tab"
|
||||||
|
tab_prev = "BackTab"
|
||||||
|
up = "Up"
|
||||||
|
down = "Down"
|
||||||
|
left = "Left"
|
||||||
|
right = "Right"
|
||||||
|
select = "Enter"
|
||||||
|
close_connection = "d"
|
||||||
|
close_all_connections = "shift+d"
|
||||||
|
refresh = "r"
|
||||||
|
```
|
||||||
|
|
||||||
|
All keybindings and settings are optional and use the defaults shown above.
|
||||||
|
|
||||||
|
## CLI Usage
|
||||||
|
|
||||||
|
```
|
||||||
|
cargo run -- [OPTIONS]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--api-address <URL> Override Clash API address
|
||||||
|
--api-secret <KEY> Override Clash API secret
|
||||||
|
--refresh-interval <SECS> Override refresh interval
|
||||||
|
```
|
||||||
|
|
||||||
|
CLI arguments override config file values.
|
||||||
|
|
||||||
|
## Default Keybindings
|
||||||
|
|
||||||
|
| Key | Action |
|
||||||
|
|-----------|-----------------------|
|
||||||
|
| `q` | Quit |
|
||||||
|
| `Tab` | Next tab |
|
||||||
|
| `BackTab` | Previous tab |
|
||||||
|
| `Up/Down` | Navigate list |
|
||||||
|
| `Left` | Switch to groups pane |
|
||||||
|
| `Right` | Switch to members pane|
|
||||||
|
| `Enter` | Select proxy |
|
||||||
|
| `d` | Close connection |
|
||||||
|
| `D` | Close all connections |
|
||||||
|
| `r` | Force refresh |
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
main.rs -- Entry point, CLI parsing, config loading
|
||||||
|
config.rs -- Config structs, TOML loading, CLI merging
|
||||||
|
keybindings.rs -- KeyBinding parsing and matching
|
||||||
|
clash.rs -- Clash API client + serde types
|
||||||
|
app.rs -- Root App component (fullscreen, tabs)
|
||||||
|
tab_bar.rs -- Clickable tab bar component
|
||||||
|
proxy_view.rs -- Proxy selector (two-pane layout)
|
||||||
|
connections_view.rs -- Connections table view
|
||||||
|
```
|
||||||
63
src/app.rs
Normal file
63
src/app.rs
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
use iocraft::prelude::*;
|
||||||
|
|
||||||
|
use crate::tab_bar;
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||||
|
pub enum Tab {
|
||||||
|
#[default]
|
||||||
|
Proxies,
|
||||||
|
Connections,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn App(mut hooks: Hooks) -> impl Into<AnyElement<'static>> {
|
||||||
|
let (width, height) = hooks.use_terminal_size();
|
||||||
|
let mut system = hooks.use_context_mut::<SystemContext>();
|
||||||
|
let ctx = hooks.use_context::<crate::AppContext>();
|
||||||
|
let mut active_tab = hooks.use_state(|| Tab::Proxies);
|
||||||
|
let mut should_exit = hooks.use_state(|| false);
|
||||||
|
|
||||||
|
let kb = ctx.keybindings.clone();
|
||||||
|
hooks.use_terminal_events(move |event| {
|
||||||
|
if let TerminalEvent::Key(key_event) = event {
|
||||||
|
if kb.quit.matches(&key_event) {
|
||||||
|
should_exit.set(true);
|
||||||
|
} else if kb.tab_next.matches(&key_event) || kb.tab_prev.matches(&key_event) {
|
||||||
|
active_tab.set(match active_tab.get() {
|
||||||
|
Tab::Proxies => Tab::Connections,
|
||||||
|
Tab::Connections => Tab::Proxies,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if should_exit.get() {
|
||||||
|
system.exit();
|
||||||
|
}
|
||||||
|
|
||||||
|
system.set_mouse_capture(true);
|
||||||
|
|
||||||
|
let tab = active_tab.get();
|
||||||
|
element! {
|
||||||
|
View(
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
flex_direction: FlexDirection::Column,
|
||||||
|
background_color: Color::Black,
|
||||||
|
) {
|
||||||
|
tab_bar::TabBar(
|
||||||
|
active_tab: tab,
|
||||||
|
on_proxies_click: move |_| active_tab.set(Tab::Proxies),
|
||||||
|
on_connections_click: move |_| active_tab.set(Tab::Connections),
|
||||||
|
)
|
||||||
|
#(match tab {
|
||||||
|
Tab::Proxies => element! {
|
||||||
|
crate::proxy_view::ProxyView
|
||||||
|
}.into_any(),
|
||||||
|
Tab::Connections => element! {
|
||||||
|
crate::connections_view::ConnectionsView
|
||||||
|
}.into_any(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
214
src/clash.rs
Normal file
214
src/clash.rs
Normal file
@ -0,0 +1,214 @@
|
|||||||
|
use anyhow::{anyhow, Result};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default, Deserialize)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct ProxyHistory {
|
||||||
|
pub time: String,
|
||||||
|
pub delay: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default, Deserialize)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct ProxyItem {
|
||||||
|
pub name: String,
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
pub proxy_type: String,
|
||||||
|
pub all: Option<Vec<String>>,
|
||||||
|
pub now: Option<String>,
|
||||||
|
pub history: Vec<ProxyHistory>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProxyItem {
|
||||||
|
pub fn latest_delay(&self) -> Option<u64> {
|
||||||
|
self.history.last().map(|h| h.delay)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_selector(&self) -> bool {
|
||||||
|
self.proxy_type == "Selector"
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_url_test(&self) -> bool {
|
||||||
|
self.proxy_type == "URLTest"
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_group(&self) -> bool {
|
||||||
|
self.is_selector() || self.is_url_test()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default, Deserialize)]
|
||||||
|
pub struct ProxiesResponse {
|
||||||
|
pub proxies: HashMap<String, ProxyItem>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default, Deserialize)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct ConnectionMetadata {
|
||||||
|
pub host: String,
|
||||||
|
pub network: String,
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
pub conn_type: String,
|
||||||
|
pub source_ip: String,
|
||||||
|
pub destination_ip: String,
|
||||||
|
pub source_port: u16,
|
||||||
|
pub destination_port: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default, Deserialize)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct ConnectionItem {
|
||||||
|
pub id: String,
|
||||||
|
pub metadata: ConnectionMetadata,
|
||||||
|
pub chains: Vec<String>,
|
||||||
|
pub rule: String,
|
||||||
|
pub start: String,
|
||||||
|
pub download: u64,
|
||||||
|
pub upload: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ConnectionItem {
|
||||||
|
pub fn display_host(&self) -> String {
|
||||||
|
if self.metadata.host.is_empty() {
|
||||||
|
format!(
|
||||||
|
"{}:{}",
|
||||||
|
self.metadata.destination_ip, self.metadata.destination_port
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
format!(
|
||||||
|
"{}:{}",
|
||||||
|
self.metadata.host, self.metadata.destination_port
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn display_chains(&self) -> String {
|
||||||
|
self.chains.join("->")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default, Deserialize)]
|
||||||
|
pub struct ConnectionsResponse {
|
||||||
|
#[serde(default)]
|
||||||
|
pub connections: Vec<ConnectionItem>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct ClashClient {
|
||||||
|
base_url: String,
|
||||||
|
secret: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ClashClient {
|
||||||
|
pub fn new(base_url: String, secret: Option<String>) -> Self {
|
||||||
|
Self { base_url, secret }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn auth_header(&self) -> Option<String> {
|
||||||
|
self.secret
|
||||||
|
.as_ref()
|
||||||
|
.map(|s| format!("Bearer {}", s))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_proxies(&self) -> Result<ProxiesResponse> {
|
||||||
|
let mut req = surf::get(format!("{}/proxies", self.base_url));
|
||||||
|
if let Some(ref auth) = self.auth_header() {
|
||||||
|
req = req.header("Authorization", auth.as_str());
|
||||||
|
}
|
||||||
|
let data: ProxiesResponse = req
|
||||||
|
.recv_json()
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow!("failed to get proxies: {}", e))?;
|
||||||
|
Ok(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn select_proxy(&self, group: &str, proxy: &str) -> Result<()> {
|
||||||
|
let mut req = surf::put(format!("{}/proxies/{}", self.base_url, group));
|
||||||
|
if let Some(ref auth) = self.auth_header() {
|
||||||
|
req = req.header("Authorization", auth.as_str());
|
||||||
|
}
|
||||||
|
let body = serde_json::json!({ "name": proxy });
|
||||||
|
req = req
|
||||||
|
.body_json(&body)
|
||||||
|
.map_err(|e| anyhow!("failed to serialize: {}", e))?;
|
||||||
|
req.await
|
||||||
|
.map_err(|e| anyhow!("failed to select proxy: {}", e))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_connections(&self) -> Result<ConnectionsResponse> {
|
||||||
|
let mut req = surf::get(format!("{}/connections", self.base_url));
|
||||||
|
if let Some(ref auth) = self.auth_header() {
|
||||||
|
req = req.header("Authorization", auth.as_str());
|
||||||
|
}
|
||||||
|
let data: ConnectionsResponse = req
|
||||||
|
.recv_json()
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow!("failed to get connections: {}", e))?;
|
||||||
|
Ok(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn close_connection(&self, id: &str) -> Result<()> {
|
||||||
|
let mut req = surf::delete(format!("{}/connections/{}", self.base_url, id));
|
||||||
|
if let Some(ref auth) = self.auth_header() {
|
||||||
|
req = req.header("Authorization", auth.as_str());
|
||||||
|
}
|
||||||
|
req.await
|
||||||
|
.map_err(|e| anyhow!("failed to close connection: {}", e))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn close_all_connections(&self) -> Result<()> {
|
||||||
|
let mut req = surf::delete(format!("{}/connections", self.base_url));
|
||||||
|
if let Some(ref auth) = self.auth_header() {
|
||||||
|
req = req.header("Authorization", auth.as_str());
|
||||||
|
}
|
||||||
|
req.await
|
||||||
|
.map_err(|e| anyhow!("failed to close all connections: {}", e))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn format_bytes(bytes: u64) -> String {
|
||||||
|
const KB: u64 = 1024;
|
||||||
|
const MB: u64 = KB * 1024;
|
||||||
|
const GB: u64 = MB * 1024;
|
||||||
|
|
||||||
|
if bytes >= GB {
|
||||||
|
format!("{:.1}GB", bytes as f64 / GB as f64)
|
||||||
|
} else if bytes >= MB {
|
||||||
|
format!("{:.1}MB", bytes as f64 / MB as f64)
|
||||||
|
} else if bytes >= KB {
|
||||||
|
format!("{:.1}KB", bytes as f64 / KB as f64)
|
||||||
|
} else {
|
||||||
|
format!("{}B", bytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn format_duration(start_str: &str) -> String {
|
||||||
|
let start = match chrono::DateTime::parse_from_rfc3339(start_str) {
|
||||||
|
Ok(t) => t.with_timezone(&chrono::Local),
|
||||||
|
Err(_) => return "?".to_string(),
|
||||||
|
};
|
||||||
|
let now = chrono::Local::now();
|
||||||
|
let diff = now.signed_duration_since(start);
|
||||||
|
|
||||||
|
if diff.num_seconds() < 0 {
|
||||||
|
return "0s".to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
let total_secs = diff.num_seconds();
|
||||||
|
let hours = total_secs / 3600;
|
||||||
|
let mins = (total_secs % 3600) / 60;
|
||||||
|
let secs = total_secs % 60;
|
||||||
|
|
||||||
|
if hours > 0 {
|
||||||
|
format!("{}h{}m", hours, mins)
|
||||||
|
} else if mins > 0 {
|
||||||
|
format!("{}m{}s", mins, secs)
|
||||||
|
} else {
|
||||||
|
format!("{}s", secs)
|
||||||
|
}
|
||||||
|
}
|
||||||
85
src/config.rs
Normal file
85
src/config.rs
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
use anyhow::Result;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct AppConfig {
|
||||||
|
pub clash_api_url: String,
|
||||||
|
pub clash_api_secret: Option<String>,
|
||||||
|
pub refresh_interval: u64,
|
||||||
|
pub keybindings: KeybindingsConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for AppConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
clash_api_url: "http://127.0.0.1:9090".to_string(),
|
||||||
|
clash_api_secret: None,
|
||||||
|
refresh_interval: 2,
|
||||||
|
keybindings: KeybindingsConfig::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct KeybindingsConfig {
|
||||||
|
pub quit: String,
|
||||||
|
pub tab_next: String,
|
||||||
|
pub tab_prev: String,
|
||||||
|
pub up: String,
|
||||||
|
pub down: String,
|
||||||
|
pub left: String,
|
||||||
|
pub right: String,
|
||||||
|
pub select: String,
|
||||||
|
pub close_connection: String,
|
||||||
|
pub close_all_connections: String,
|
||||||
|
pub refresh: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for KeybindingsConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
quit: "q".to_string(),
|
||||||
|
tab_next: "Tab".to_string(),
|
||||||
|
tab_prev: "BackTab".to_string(),
|
||||||
|
up: "Up".to_string(),
|
||||||
|
down: "Down".to_string(),
|
||||||
|
left: "Left".to_string(),
|
||||||
|
right: "Right".to_string(),
|
||||||
|
select: "Enter".to_string(),
|
||||||
|
close_connection: "d".to_string(),
|
||||||
|
close_all_connections: "shift+d".to_string(),
|
||||||
|
refresh: "r".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn config_path() -> Option<PathBuf> {
|
||||||
|
dirs::config_dir().map(|p| p.join("clash_tui").join("config.toml"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_config() -> Result<AppConfig> {
|
||||||
|
let path = match config_path() {
|
||||||
|
Some(p) => p,
|
||||||
|
None => return Ok(AppConfig::default()),
|
||||||
|
};
|
||||||
|
|
||||||
|
if !path.exists() {
|
||||||
|
return Ok(AppConfig::default());
|
||||||
|
}
|
||||||
|
|
||||||
|
let content = std::fs::read_to_string(&path)?;
|
||||||
|
let config: AppConfig = toml::from_str(&content)?;
|
||||||
|
Ok(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn merge_cli(config: AppConfig, api_address: Option<&str>, api_secret: Option<&str>, refresh_interval: Option<u64>) -> AppConfig {
|
||||||
|
AppConfig {
|
||||||
|
clash_api_url: api_address.map(String::from).unwrap_or(config.clash_api_url),
|
||||||
|
clash_api_secret: api_secret.map(String::from).or(config.clash_api_secret),
|
||||||
|
refresh_interval: refresh_interval.unwrap_or(config.refresh_interval),
|
||||||
|
..config
|
||||||
|
}
|
||||||
|
}
|
||||||
241
src/connections_view.rs
Normal file
241
src/connections_view.rs
Normal file
@ -0,0 +1,241 @@
|
|||||||
|
use iocraft::prelude::*;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use crate::clash::{format_bytes, ConnectionItem};
|
||||||
|
|
||||||
|
enum DataState {
|
||||||
|
Init,
|
||||||
|
Loading,
|
||||||
|
Loaded(Vec<ConnectionItem>),
|
||||||
|
Error(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn ConnectionsView(mut hooks: Hooks) -> impl Into<AnyElement<'static>> {
|
||||||
|
let ctx = hooks.use_context::<crate::AppContext>();
|
||||||
|
let (_width, height) = hooks.use_terminal_size();
|
||||||
|
|
||||||
|
let mut state = hooks.use_state(|| DataState::Init);
|
||||||
|
let mut selected = hooks.use_state(|| 0usize);
|
||||||
|
|
||||||
|
// Clone everything from context before closures
|
||||||
|
let client = ctx.client.clone();
|
||||||
|
let kb = ctx.keybindings.clone();
|
||||||
|
let refresh_interval = ctx.refresh_interval;
|
||||||
|
|
||||||
|
let load = hooks.use_async_handler(move |_: ()| {
|
||||||
|
let client = client.clone();
|
||||||
|
async move {
|
||||||
|
state.set(DataState::Loading);
|
||||||
|
match client.get_connections().await {
|
||||||
|
Ok(resp) => {
|
||||||
|
let mut conns = resp.connections;
|
||||||
|
conns.sort_by(|a, b| b.download.cmp(&a.download));
|
||||||
|
state.set(DataState::Loaded(conns));
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
state.set(DataState::Error(e.to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if matches!(*state.read(), DataState::Init) {
|
||||||
|
load(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let load_refresh = load.clone();
|
||||||
|
hooks.use_future(async move {
|
||||||
|
loop {
|
||||||
|
smol::Timer::after(Duration::from_secs(refresh_interval)).await;
|
||||||
|
load_refresh(());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let client_close = ctx.client.clone();
|
||||||
|
let client_close_all = ctx.client.clone();
|
||||||
|
let load_after_close = load.clone();
|
||||||
|
let load_after_close_all = load.clone();
|
||||||
|
hooks.use_terminal_events(move |event| {
|
||||||
|
if let TerminalEvent::Key(key_event) = event {
|
||||||
|
let state_read = state.read();
|
||||||
|
if let DataState::Loaded(conns) = &*state_read {
|
||||||
|
let count = conns.len();
|
||||||
|
if count == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let current = selected.get().min(count - 1);
|
||||||
|
|
||||||
|
if kb.up.matches(&key_event) {
|
||||||
|
if current > 0 {
|
||||||
|
selected.set(current - 1);
|
||||||
|
}
|
||||||
|
} else if kb.down.matches(&key_event) {
|
||||||
|
if current + 1 < count {
|
||||||
|
selected.set(current + 1);
|
||||||
|
}
|
||||||
|
} else if kb.close_connection.matches(&key_event) {
|
||||||
|
let id = conns[current].id.clone();
|
||||||
|
let client = client_close.clone();
|
||||||
|
let load = load_after_close.clone();
|
||||||
|
smol::spawn(async move {
|
||||||
|
let _ = client.close_connection(&id).await;
|
||||||
|
smol::Timer::after(Duration::from_millis(200)).await;
|
||||||
|
load(());
|
||||||
|
})
|
||||||
|
.detach();
|
||||||
|
} else if kb.close_all_connections.matches(&key_event) {
|
||||||
|
let client = client_close_all.clone();
|
||||||
|
let load = load_after_close_all.clone();
|
||||||
|
smol::spawn(async move {
|
||||||
|
let _ = client.close_all_connections().await;
|
||||||
|
smol::Timer::after(Duration::from_millis(200)).await;
|
||||||
|
load(());
|
||||||
|
})
|
||||||
|
.detach();
|
||||||
|
} else if kb.refresh.matches(&key_event) {
|
||||||
|
load(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let body_height = if height > 4 { height - 3 } else { 1 };
|
||||||
|
|
||||||
|
let state_read = state.read();
|
||||||
|
match &*state_read {
|
||||||
|
DataState::Init | DataState::Loading => {
|
||||||
|
element! {
|
||||||
|
View(
|
||||||
|
width: 100pct,
|
||||||
|
height: body_height,
|
||||||
|
justify_content: JustifyContent::Center,
|
||||||
|
align_items: AlignItems::Center,
|
||||||
|
) {
|
||||||
|
Text(content: "Loading connections...", color: Color::Grey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DataState::Error(e) => {
|
||||||
|
element! {
|
||||||
|
View(
|
||||||
|
width: 100pct,
|
||||||
|
height: body_height,
|
||||||
|
justify_content: JustifyContent::Center,
|
||||||
|
align_items: AlignItems::Center,
|
||||||
|
) {
|
||||||
|
Text(content: format!("Error: {}", e), color: Color::Red)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DataState::Loaded(conns) => {
|
||||||
|
let current_selected = selected.get().min(conns.len().saturating_sub(1));
|
||||||
|
|
||||||
|
element! {
|
||||||
|
View(width: 100pct, height: body_height, flex_direction: FlexDirection::Column) {
|
||||||
|
// Header row
|
||||||
|
View(
|
||||||
|
width: 100pct,
|
||||||
|
border_style: BorderStyle::Single,
|
||||||
|
border_edges: Edges::Bottom,
|
||||||
|
border_color: Color::DarkGrey,
|
||||||
|
background_color: Some(Color::DarkGrey),
|
||||||
|
) {
|
||||||
|
View(width: 28pct, padding_left: 1) {
|
||||||
|
Text(content: "Host", weight: Weight::Bold, color: Color::Cyan)
|
||||||
|
}
|
||||||
|
View(width: 6pct) {
|
||||||
|
Text(content: "Net", weight: Weight::Bold, color: Color::Cyan)
|
||||||
|
}
|
||||||
|
View(width: 7pct) {
|
||||||
|
Text(content: "Type", weight: Weight::Bold, color: Color::Cyan)
|
||||||
|
}
|
||||||
|
View(width: 24pct) {
|
||||||
|
Text(content: "Chains", weight: Weight::Bold, color: Color::Cyan)
|
||||||
|
}
|
||||||
|
View(width: 14pct) {
|
||||||
|
Text(content: "Rule", weight: Weight::Bold, color: Color::Cyan)
|
||||||
|
}
|
||||||
|
View(width: 10pct) {
|
||||||
|
Text(content: "DL", weight: Weight::Bold, color: Color::Cyan)
|
||||||
|
}
|
||||||
|
View(width: 10pct) {
|
||||||
|
Text(content: "UL", weight: Weight::Bold, color: Color::Cyan)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connections
|
||||||
|
ScrollView {
|
||||||
|
View(width: 100pct, flex_direction: FlexDirection::Column) {
|
||||||
|
#(conns.iter().enumerate().map(|(i, conn)| {
|
||||||
|
let is_selected = i == current_selected;
|
||||||
|
let bg = if is_selected {
|
||||||
|
Some(Color::DarkCyan)
|
||||||
|
} else if i % 2 == 0 {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(Color::DarkGrey)
|
||||||
|
};
|
||||||
|
let marker = if is_selected { ">" } else { " " };
|
||||||
|
let host = conn.display_host();
|
||||||
|
let chains = conn.display_chains();
|
||||||
|
let dl = format_bytes(conn.download);
|
||||||
|
let ul = format_bytes(conn.upload);
|
||||||
|
|
||||||
|
element! {
|
||||||
|
View(
|
||||||
|
width: 100pct,
|
||||||
|
background_color: bg,
|
||||||
|
) {
|
||||||
|
View(width: 28pct, padding_left: 1) {
|
||||||
|
Text(content: format!("{}{}", marker, truncate_str(&host, 25)), color: if is_selected { Color::White } else { Color::Grey })
|
||||||
|
}
|
||||||
|
View(width: 6pct) {
|
||||||
|
Text(content: truncate_str(&conn.metadata.network, 5), color: if is_selected { Color::White } else { Color::Grey })
|
||||||
|
}
|
||||||
|
View(width: 7pct) {
|
||||||
|
Text(content: truncate_str(&conn.metadata.conn_type, 6), color: if is_selected { Color::White } else { Color::Grey })
|
||||||
|
}
|
||||||
|
View(width: 24pct) {
|
||||||
|
Text(content: truncate_str(&chains, 22), color: if is_selected { Color::White } else { Color::Grey })
|
||||||
|
}
|
||||||
|
View(width: 14pct) {
|
||||||
|
Text(content: truncate_str(&conn.rule, 12), color: if is_selected { Color::White } else { Color::Grey })
|
||||||
|
}
|
||||||
|
View(width: 10pct) {
|
||||||
|
Text(content: dl, color: Color::Green)
|
||||||
|
}
|
||||||
|
View(width: 10pct) {
|
||||||
|
Text(content: ul, color: Color::Yellow)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Footer
|
||||||
|
View(
|
||||||
|
width: 100pct,
|
||||||
|
border_style: BorderStyle::Single,
|
||||||
|
border_edges: Edges::Top,
|
||||||
|
border_color: Color::DarkGrey,
|
||||||
|
padding_left: 1,
|
||||||
|
) {
|
||||||
|
Text(content: format!("[d] Close [D] Close All [r] Refresh [q] Quit {} connections", conns.len()), color: Color::DarkGrey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn truncate_str(s: &str, max_len: usize) -> String {
|
||||||
|
if s.len() <= max_len {
|
||||||
|
s.to_string()
|
||||||
|
} else {
|
||||||
|
let truncated: String = s.chars().take(max_len - 1).collect();
|
||||||
|
truncated + "~"
|
||||||
|
}
|
||||||
|
}
|
||||||
108
src/keybindings.rs
Normal file
108
src/keybindings.rs
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
use anyhow::{anyhow, Result};
|
||||||
|
use iocraft::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug)]
|
||||||
|
pub struct KeyBinding {
|
||||||
|
pub code: KeyCode,
|
||||||
|
pub modifiers: KeyModifiers,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl KeyBinding {
|
||||||
|
pub fn parse(s: &str) -> Result<Self> {
|
||||||
|
let s = s.trim();
|
||||||
|
let (modifiers, key_str) = if s.contains('+') {
|
||||||
|
let parts: Vec<&str> = s.splitn(2, '+').collect();
|
||||||
|
let mut mods = KeyModifiers::NONE;
|
||||||
|
for m in parts[0].to_lowercase().split('+') {
|
||||||
|
match m.trim() {
|
||||||
|
"ctrl" | "control" => mods.insert(KeyModifiers::CONTROL),
|
||||||
|
"shift" => mods.insert(KeyModifiers::SHIFT),
|
||||||
|
"alt" => mods.insert(KeyModifiers::ALT),
|
||||||
|
other => return Err(anyhow!("unknown modifier: {}", other)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(mods, parts[1].trim())
|
||||||
|
} else {
|
||||||
|
(KeyModifiers::NONE, s)
|
||||||
|
};
|
||||||
|
|
||||||
|
let code = parse_key_code(key_str)?;
|
||||||
|
Ok(Self { code, modifiers })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn matches(&self, event: &KeyEvent) -> bool {
|
||||||
|
event.kind != KeyEventKind::Release
|
||||||
|
&& event.code == self.code
|
||||||
|
&& event.modifiers == self.modifiers
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_key_code(s: &str) -> Result<KeyCode> {
|
||||||
|
let lower = s.to_lowercase();
|
||||||
|
match lower.as_str() {
|
||||||
|
"enter" | "return" => Ok(KeyCode::Enter),
|
||||||
|
"tab" => Ok(KeyCode::Tab),
|
||||||
|
"backtab" => Ok(KeyCode::BackTab),
|
||||||
|
"esc" | "escape" => Ok(KeyCode::Esc),
|
||||||
|
"up" => Ok(KeyCode::Up),
|
||||||
|
"down" => Ok(KeyCode::Down),
|
||||||
|
"left" => Ok(KeyCode::Left),
|
||||||
|
"right" => Ok(KeyCode::Right),
|
||||||
|
"home" => Ok(KeyCode::Home),
|
||||||
|
"end" => Ok(KeyCode::End),
|
||||||
|
"pageup" | "page_up" => Ok(KeyCode::PageUp),
|
||||||
|
"pagedown" | "page_down" => Ok(KeyCode::PageDown),
|
||||||
|
"delete" | "del" => Ok(KeyCode::Delete),
|
||||||
|
"space" => Ok(KeyCode::Char(' ')),
|
||||||
|
"backspace" => Ok(KeyCode::Backspace),
|
||||||
|
"insert" => Ok(KeyCode::Insert),
|
||||||
|
"f1" => Ok(KeyCode::F(1)),
|
||||||
|
"f2" => Ok(KeyCode::F(2)),
|
||||||
|
"f3" => Ok(KeyCode::F(3)),
|
||||||
|
"f4" => Ok(KeyCode::F(4)),
|
||||||
|
"f5" => Ok(KeyCode::F(5)),
|
||||||
|
"f6" => Ok(KeyCode::F(6)),
|
||||||
|
"f7" => Ok(KeyCode::F(7)),
|
||||||
|
"f8" => Ok(KeyCode::F(8)),
|
||||||
|
"f9" => Ok(KeyCode::F(9)),
|
||||||
|
"f10" => Ok(KeyCode::F(10)),
|
||||||
|
"f11" => Ok(KeyCode::F(11)),
|
||||||
|
"f12" => Ok(KeyCode::F(12)),
|
||||||
|
"null" => Ok(KeyCode::Null),
|
||||||
|
c if c.len() == 1 => Ok(KeyCode::Char(c.chars().next().unwrap())),
|
||||||
|
_ => Err(anyhow!("unknown key: {}", s)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct Keybindings {
|
||||||
|
pub quit: KeyBinding,
|
||||||
|
pub tab_next: KeyBinding,
|
||||||
|
pub tab_prev: KeyBinding,
|
||||||
|
pub up: KeyBinding,
|
||||||
|
pub down: KeyBinding,
|
||||||
|
pub left: KeyBinding,
|
||||||
|
pub right: KeyBinding,
|
||||||
|
pub select: KeyBinding,
|
||||||
|
pub close_connection: KeyBinding,
|
||||||
|
pub close_all_connections: KeyBinding,
|
||||||
|
pub refresh: KeyBinding,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Keybindings {
|
||||||
|
pub fn from_config(cfg: &crate::config::KeybindingsConfig) -> Result<Self> {
|
||||||
|
Ok(Self {
|
||||||
|
quit: KeyBinding::parse(&cfg.quit)?,
|
||||||
|
tab_next: KeyBinding::parse(&cfg.tab_next)?,
|
||||||
|
tab_prev: KeyBinding::parse(&cfg.tab_prev)?,
|
||||||
|
up: KeyBinding::parse(&cfg.up)?,
|
||||||
|
down: KeyBinding::parse(&cfg.down)?,
|
||||||
|
left: KeyBinding::parse(&cfg.left)?,
|
||||||
|
right: KeyBinding::parse(&cfg.right)?,
|
||||||
|
select: KeyBinding::parse(&cfg.select)?,
|
||||||
|
close_connection: KeyBinding::parse(&cfg.close_connection)?,
|
||||||
|
close_all_connections: KeyBinding::parse(&cfg.close_all_connections)?,
|
||||||
|
refresh: KeyBinding::parse(&cfg.refresh)?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
58
src/main.rs
Normal file
58
src/main.rs
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
mod app;
|
||||||
|
mod clash;
|
||||||
|
mod config;
|
||||||
|
mod connections_view;
|
||||||
|
mod keybindings;
|
||||||
|
mod proxy_view;
|
||||||
|
mod tab_bar;
|
||||||
|
|
||||||
|
use clap::Parser;
|
||||||
|
use iocraft::prelude::*;
|
||||||
|
|
||||||
|
#[derive(Parser)]
|
||||||
|
#[command(name = "clash_tui", about = "A TUI for Clash proxy manager")]
|
||||||
|
struct CliArgs {
|
||||||
|
#[arg(long = "api-address")]
|
||||||
|
api_address: Option<String>,
|
||||||
|
|
||||||
|
#[arg(long = "api-secret")]
|
||||||
|
api_secret: Option<String>,
|
||||||
|
|
||||||
|
#[arg(long = "refresh-interval")]
|
||||||
|
refresh_interval: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct AppContext {
|
||||||
|
pub client: clash::ClashClient,
|
||||||
|
pub keybindings: keybindings::Keybindings,
|
||||||
|
pub refresh_interval: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() -> anyhow::Result<()> {
|
||||||
|
let cli = CliArgs::parse();
|
||||||
|
let config = config::load_config()?;
|
||||||
|
let config = config::merge_cli(
|
||||||
|
config,
|
||||||
|
cli.api_address.as_deref(),
|
||||||
|
cli.api_secret.as_deref(),
|
||||||
|
cli.refresh_interval,
|
||||||
|
);
|
||||||
|
let keybindings = keybindings::Keybindings::from_config(&config.keybindings)?;
|
||||||
|
let client = clash::ClashClient::new(config.clash_api_url, config.clash_api_secret);
|
||||||
|
let ctx = AppContext {
|
||||||
|
client,
|
||||||
|
keybindings,
|
||||||
|
refresh_interval: config.refresh_interval,
|
||||||
|
};
|
||||||
|
|
||||||
|
smol::block_on(
|
||||||
|
element! {
|
||||||
|
ContextProvider(value: Context::owned(ctx)) {
|
||||||
|
app::App
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.fullscreen(),
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
306
src/proxy_view.rs
Normal file
306
src/proxy_view.rs
Normal file
@ -0,0 +1,306 @@
|
|||||||
|
use iocraft::prelude::*;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use crate::clash::ProxyItem;
|
||||||
|
|
||||||
|
enum DataState {
|
||||||
|
Init,
|
||||||
|
Loading,
|
||||||
|
Loaded(Vec<ProxyItem>),
|
||||||
|
Error(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, PartialEq)]
|
||||||
|
enum FocusPane {
|
||||||
|
Groups,
|
||||||
|
Members,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn ProxyView(mut hooks: Hooks) -> impl Into<AnyElement<'static>> {
|
||||||
|
let ctx = hooks.use_context::<crate::AppContext>();
|
||||||
|
let (width, height) = hooks.use_terminal_size();
|
||||||
|
|
||||||
|
let mut state = hooks.use_state(|| DataState::Init);
|
||||||
|
let mut selected_group = hooks.use_state(|| 0usize);
|
||||||
|
let mut selected_member = hooks.use_state(|| 0usize);
|
||||||
|
let mut focus_pane = hooks.use_state(|| FocusPane::Groups);
|
||||||
|
|
||||||
|
// Clone everything from context before any closures that need Send
|
||||||
|
let client = ctx.client.clone();
|
||||||
|
let kb = ctx.keybindings.clone();
|
||||||
|
let refresh_interval = ctx.refresh_interval;
|
||||||
|
|
||||||
|
let load = hooks.use_async_handler(move |_: ()| {
|
||||||
|
let client = client.clone();
|
||||||
|
async move {
|
||||||
|
state.set(DataState::Loading);
|
||||||
|
match client.get_proxies().await {
|
||||||
|
Ok(resp) => {
|
||||||
|
let mut groups: Vec<ProxyItem> = resp
|
||||||
|
.proxies
|
||||||
|
.into_values()
|
||||||
|
.filter(|p| p.is_group())
|
||||||
|
.collect();
|
||||||
|
groups.sort_by(|a, b| a.name.cmp(&b.name));
|
||||||
|
state.set(DataState::Loaded(groups));
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
state.set(DataState::Error(e.to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if matches!(*state.read(), DataState::Init) {
|
||||||
|
load(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let load_refresh = load.clone();
|
||||||
|
hooks.use_future(async move {
|
||||||
|
loop {
|
||||||
|
smol::Timer::after(Duration::from_secs(refresh_interval)).await;
|
||||||
|
load_refresh(());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let client_for_select = ctx.client.clone();
|
||||||
|
let load_select = load.clone();
|
||||||
|
hooks.use_terminal_events(move |event| {
|
||||||
|
if let TerminalEvent::Key(key_event) = event {
|
||||||
|
let state_read = state.read();
|
||||||
|
if let DataState::Loaded(groups) = &*state_read {
|
||||||
|
let group_count = groups.len();
|
||||||
|
if group_count == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let current_group = selected_group.get().min(group_count - 1);
|
||||||
|
let current_members = groups[current_group]
|
||||||
|
.all
|
||||||
|
.as_ref()
|
||||||
|
.map(|m| m.len())
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
match focus_pane.get() {
|
||||||
|
FocusPane::Groups => {
|
||||||
|
if kb.up.matches(&key_event) {
|
||||||
|
if current_group > 0 {
|
||||||
|
selected_group.set(current_group - 1);
|
||||||
|
selected_member.set(0);
|
||||||
|
}
|
||||||
|
} else if kb.down.matches(&key_event) {
|
||||||
|
if current_group + 1 < group_count {
|
||||||
|
selected_group.set(current_group + 1);
|
||||||
|
selected_member.set(0);
|
||||||
|
}
|
||||||
|
} else if (kb.right.matches(&key_event) || kb.select.matches(&key_event))
|
||||||
|
&& current_members > 0
|
||||||
|
{
|
||||||
|
focus_pane.set(FocusPane::Members);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
FocusPane::Members => {
|
||||||
|
if kb.up.matches(&key_event) {
|
||||||
|
if selected_member.get() > 0 {
|
||||||
|
selected_member.set(selected_member.get() - 1);
|
||||||
|
}
|
||||||
|
} else if kb.down.matches(&key_event) {
|
||||||
|
if selected_member.get() + 1 < current_members {
|
||||||
|
selected_member.set(selected_member.get() + 1);
|
||||||
|
}
|
||||||
|
} else if kb.left.matches(&key_event) {
|
||||||
|
focus_pane.set(FocusPane::Groups);
|
||||||
|
} else if kb.select.matches(&key_event) {
|
||||||
|
let group = &groups[current_group];
|
||||||
|
if let Some(ref members) = group.all
|
||||||
|
&& selected_member.get() < members.len()
|
||||||
|
{
|
||||||
|
let group_name = group.name.clone();
|
||||||
|
let member_name = members[selected_member.get()].clone();
|
||||||
|
let client = client_for_select.clone();
|
||||||
|
let load_after = load_select.clone();
|
||||||
|
smol::spawn(async move {
|
||||||
|
let _ = client.select_proxy(&group_name, &member_name).await;
|
||||||
|
smol::Timer::after(Duration::from_millis(200)).await;
|
||||||
|
load_after(());
|
||||||
|
})
|
||||||
|
.detach();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let body_height = if height > 4 { height - 3 } else { 1 };
|
||||||
|
let left_width = if width > 20 { width * 3 / 10 } else { 10 };
|
||||||
|
let right_width = width.saturating_sub(left_width);
|
||||||
|
|
||||||
|
let state_read = state.read();
|
||||||
|
match &*state_read {
|
||||||
|
DataState::Init | DataState::Loading => {
|
||||||
|
element! {
|
||||||
|
View(
|
||||||
|
width: 100pct,
|
||||||
|
height: body_height,
|
||||||
|
justify_content: JustifyContent::Center,
|
||||||
|
align_items: AlignItems::Center,
|
||||||
|
) {
|
||||||
|
Text(content: "Loading proxies...", color: Color::Grey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DataState::Error(e) => {
|
||||||
|
element! {
|
||||||
|
View(
|
||||||
|
width: 100pct,
|
||||||
|
height: body_height,
|
||||||
|
justify_content: JustifyContent::Center,
|
||||||
|
align_items: AlignItems::Center,
|
||||||
|
) {
|
||||||
|
Text(content: format!("Error: {}", e), color: Color::Red)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DataState::Loaded(groups) => {
|
||||||
|
let current_group_idx = selected_group.get().min(groups.len().saturating_sub(1));
|
||||||
|
let current_group = &groups[current_group_idx];
|
||||||
|
let members = current_group.all.as_deref().unwrap_or(&[]);
|
||||||
|
let current_now = current_group.now.as_deref().unwrap_or("");
|
||||||
|
|
||||||
|
element! {
|
||||||
|
View(width: 100pct, height: body_height, flex_direction: FlexDirection::Row) {
|
||||||
|
// Left pane: group list
|
||||||
|
View(
|
||||||
|
width: left_width,
|
||||||
|
height: body_height,
|
||||||
|
flex_direction: FlexDirection::Column,
|
||||||
|
border_style: BorderStyle::Single,
|
||||||
|
border_color: Color::DarkGrey,
|
||||||
|
border_edges: Edges::Right,
|
||||||
|
) {
|
||||||
|
View(
|
||||||
|
width: 100pct,
|
||||||
|
border_style: BorderStyle::Single,
|
||||||
|
border_edges: Edges::Bottom,
|
||||||
|
border_color: Color::DarkGrey,
|
||||||
|
padding_left: 1,
|
||||||
|
) {
|
||||||
|
Text(content: "Proxy Groups", weight: Weight::Bold, color: Color::Cyan)
|
||||||
|
}
|
||||||
|
ScrollView {
|
||||||
|
View(width: 100pct, flex_direction: FlexDirection::Column) {
|
||||||
|
#(groups.iter().enumerate().map(|(i, group)| {
|
||||||
|
let is_selected = i == current_group_idx;
|
||||||
|
let is_focused = focus_pane.get() == FocusPane::Groups && is_selected;
|
||||||
|
let bg = if is_focused {
|
||||||
|
Some(Color::DarkCyan)
|
||||||
|
} else if is_selected {
|
||||||
|
Some(Color::DarkGrey)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
let marker = if is_focused { ">" } else { " " };
|
||||||
|
let now_display = group.now.as_deref().unwrap_or("");
|
||||||
|
element! {
|
||||||
|
View(
|
||||||
|
width: 100pct,
|
||||||
|
background_color: bg,
|
||||||
|
padding_left: 1,
|
||||||
|
) {
|
||||||
|
Text(content: format!("{}{} ", marker, group.name), color: if is_selected { Color::White } else { Color::Grey })
|
||||||
|
Text(content: now_display.to_string(), color: Color::DarkGrey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Right pane: member list
|
||||||
|
View(
|
||||||
|
width: right_width,
|
||||||
|
height: body_height,
|
||||||
|
flex_direction: FlexDirection::Column,
|
||||||
|
) {
|
||||||
|
// Header
|
||||||
|
View(
|
||||||
|
width: 100pct,
|
||||||
|
border_style: BorderStyle::Single,
|
||||||
|
border_edges: Edges::Bottom,
|
||||||
|
border_color: Color::DarkGrey,
|
||||||
|
padding_left: 1,
|
||||||
|
flex_direction: FlexDirection::Column,
|
||||||
|
) {
|
||||||
|
View {
|
||||||
|
Text(content: format!("Group: {}", current_group.name), weight: Weight::Bold, color: Color::Cyan)
|
||||||
|
}
|
||||||
|
View {
|
||||||
|
Text(content: "Current: ", color: Color::Grey)
|
||||||
|
Text(content: current_now.to_string(), color: Color::Green, weight: Weight::Bold)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Members
|
||||||
|
ScrollView {
|
||||||
|
View(width: 100pct, flex_direction: FlexDirection::Column) {
|
||||||
|
#(members.iter().enumerate().map(|(i, member)| {
|
||||||
|
let is_selected = i == selected_member.get();
|
||||||
|
let is_focused = focus_pane.get() == FocusPane::Members && is_selected;
|
||||||
|
let is_current = member == current_now;
|
||||||
|
let bg = if is_focused {
|
||||||
|
Some(Color::DarkCyan)
|
||||||
|
} else if is_selected {
|
||||||
|
Some(Color::DarkGrey)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
let marker = if is_focused { ">" } else { " " };
|
||||||
|
|
||||||
|
let delay_str = groups
|
||||||
|
.iter()
|
||||||
|
.find(|g| g.name == *member)
|
||||||
|
.and_then(|g| g.latest_delay())
|
||||||
|
.map(|d| {
|
||||||
|
if d == 0 {
|
||||||
|
"timeout".to_string()
|
||||||
|
} else {
|
||||||
|
format!("{}ms", d)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
element! {
|
||||||
|
View(
|
||||||
|
width: 100pct,
|
||||||
|
background_color: bg,
|
||||||
|
padding_left: 1,
|
||||||
|
) {
|
||||||
|
Text(content: format!("{}{} ", marker, member), color: if is_current { Color::Green } else if is_selected { Color::White } else { Color::Grey })
|
||||||
|
Text(content: format!("({})", delay_str), color: Color::DarkGrey)
|
||||||
|
#(if is_current {
|
||||||
|
element! { Text(content: " <--", color: Color::Green) }.into_any()
|
||||||
|
} else {
|
||||||
|
element! { Fragment }.into_any()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Footer
|
||||||
|
View(
|
||||||
|
width: 100pct,
|
||||||
|
border_style: BorderStyle::Single,
|
||||||
|
border_edges: Edges::Top,
|
||||||
|
border_color: Color::DarkGrey,
|
||||||
|
padding_left: 1,
|
||||||
|
) {
|
||||||
|
Text(content: "[Enter] Select [Up/Down] Navigate [Left/Right] Switch Pane", color: Color::DarkGrey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
61
src/tab_bar.rs
Normal file
61
src/tab_bar.rs
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
use iocraft::prelude::*;
|
||||||
|
|
||||||
|
use crate::app::Tab;
|
||||||
|
|
||||||
|
#[derive(Default, Props)]
|
||||||
|
pub struct TabBarProps {
|
||||||
|
pub active_tab: Tab,
|
||||||
|
pub on_proxies_click: HandlerMut<'static, ()>,
|
||||||
|
pub on_connections_click: HandlerMut<'static, ()>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn TabBar(props: &mut TabBarProps) -> impl Into<AnyElement<'static>> {
|
||||||
|
let proxies_active = props.active_tab == Tab::Proxies;
|
||||||
|
let connections_active = props.active_tab == Tab::Connections;
|
||||||
|
|
||||||
|
element! {
|
||||||
|
View(
|
||||||
|
width: 100pct,
|
||||||
|
flex_direction: FlexDirection::Row,
|
||||||
|
) {
|
||||||
|
Button(handler: props.on_proxies_click.take()) {
|
||||||
|
View(
|
||||||
|
padding_left: 2,
|
||||||
|
padding_right: 2,
|
||||||
|
padding_top: 1,
|
||||||
|
padding_bottom: 1,
|
||||||
|
background_color: if proxies_active { Some(Color::Cyan) } else { Some(Color::DarkGrey) },
|
||||||
|
border_style: BorderStyle::Round,
|
||||||
|
border_edges: Edges::Bottom,
|
||||||
|
border_color: if proxies_active { Color::Cyan } else { Color::DarkGrey },
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
content: " Proxies ",
|
||||||
|
color: if proxies_active { Color::Black } else { Color::Grey },
|
||||||
|
weight: if proxies_active { Weight::Bold } else { Weight::Normal },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Button(handler: props.on_connections_click.take()) {
|
||||||
|
View(
|
||||||
|
padding_left: 2,
|
||||||
|
padding_right: 2,
|
||||||
|
padding_top: 1,
|
||||||
|
padding_bottom: 1,
|
||||||
|
background_color: if connections_active { Some(Color::Cyan) } else { Some(Color::DarkGrey) },
|
||||||
|
border_style: BorderStyle::Round,
|
||||||
|
border_edges: Edges::Bottom,
|
||||||
|
border_color: if connections_active { Color::Cyan } else { Color::DarkGrey },
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
content: " Connections ",
|
||||||
|
color: if connections_active { Color::Black } else { Color::Grey },
|
||||||
|
weight: if connections_active { Weight::Bold } else { Weight::Normal },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
View(flex_grow: 1.0, border_style: BorderStyle::Single, border_edges: Edges::Bottom, border_color: Color::DarkGrey) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user