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