This document describes the code architecture of Clashtui for developers to understand the project structure.
AGENTS.md is a more complete reference, containing all macros, conventions, and details. This document focuses on the architecture overview.
- Language: Rust (Edition 2024)
- Build System: Cargo
- TUI Framework: ratatui + crossterm
- Async Runtime: tokio
- Config/Persistence: serde + serde_yml
- HTTP Client: minreq
- CLI Parser: clap
src/
├── main.rs # Entry point: five-phase startup flow
├── cli.rs # CLI module entry, re-exports from cli/
│ └── cli/
│ ├── handler.rs # CLI subcommand handling (profile/service/mode/update)
│ ├── widgets.rs # CLI interactive components (Confirm, Select)
│ └── utils.rs # Version info, shell completion generation
├── config.rs # Config module entry, re-exports from config/
│ └── config/
│ ├── core.rs # CoreType enum, ServiceController
│ ├── database.rs # ProfileManager (Profile persistence structures)
│ └── util.rs # Config dir resolution, load_save! macro, file path constants
├── functions.rs # Business logic entry, re-exports from functions/
│ └── functions/
│ ├── command.rs # System operations (file permissions, service start/stop, directory opening)
│ ├── file.rs # File operations (Profile import/update, Template processing)
│ └── restful.rs # REST API calls (config/mode/proxies/connections)
├── tui.rs # TUI module entry, re-exports from tui/
│ └── tui/
│ ├── app.rs # App struct, event loop (~50fps), key routing
│ ├── agent.rs # Keymap loading (keymap.yaml)
│ ├── key.rs # Key struct (code + modifiers)
│ ├── signals.rs # OS signal handling
│ ├── term.rs # Terminal raw mode enter/exit/suspend
│ ├── theme.rs # Theme loading
│ ├── utils.rs # Utility functions
│ ├── keymap_default.yaml # Default key bindings
│ ├── popmsg.rs # Popup definitions (Confirm, Input, etc.)
│ ├── widget/
│ │ ├── mod.rs # new_type_impl_tuiwidget! macro
│ │ ├── chord.rs # Chord key handler
│ │ ├── dualtab.rs # Dual-panel tab container
│ │ ├── fzffind.rs # Fuzzy search component
│ │ ├── help.rs # Help panel
│ │ ├── popmsg.rs # Popup container (rendering + event dispatch)
│ │ └── tab.rs # Single-panel tab container + BasicTabContent/TabContent trait
│ └── tab/
│ ├── mod.rs # Tab enum, newtype_tab!/enum_dispatch! macros, agent macros
│ ├── status.rs # Status tab
│ ├── files.rs # FileTab (DualTab: Profile + Template)
│ ├── proxies.rs # Proxies tab
│ ├── connections.rs # Connections tab
│ ├── logs.rs # Logs tab
│ ├── settings.rs # Settings tab
│ └── srvctl.rs # Core service control tab
The program entry point is src/main.rs, which executes five phases in order:
- CLI Parse — Parse command-line arguments and environment variables (
CLASHTUI_CONFIG_DIR), handle early exits (e.g.--generate-shell-completion) - Config Init — Determine config directory, load
config.yaml+clashtui.db, create missing directories and files - TUI Init — Load keymap (
keymap.yaml), theme (theme.yaml), set terminal raw mode, register panic hook - Event Loop — Run
App::serve(), looping to process rendering, events, and async tasks - Restore & Save — Exit raw mode, save
clashtui.db
If the command line has subcommands (profile, service, mode, update), phases 3-5 are skipped and the subcommand executes directly before exiting.
Priority from high to low:
--config-dircommand-line argumentCLASHTUI_CONFIG_DIRenvironment variabledata/subdirectory next to the executable (portable mode)$XDG_CONFIG_HOME/clashtui~/.config/clashtui
ConfigFile— Loads core paths and service config fromconfig.yamlBasicInfo— Loads API address, secret, etc. fromcore_override_config.yamlProfileManager— Loads Profile list and current selection fromclashtui.db- The three are merged into a
Configstruct, globally accessible viaconfig::CONFIG
Uses the load_save! macro to auto-generate from_file() and to_file() methods. Format is YAML.
Runs in App::serve(), looping at ~50fps (20ms/frame):
Per-frame flow:
1. Handle resize (atomic flag, processed at frame top to avoid races)
2. terminal.draw(render) — render current frame
3. sync() — advance completed async tasks
4. tokio::select! wait for next event (key/tick/resize)
5. Handle key event
Keys are processed in the following order, stopping at the first match:
| Layer | Handler | Purpose |
|---|---|---|
| 0 — PopUp | popup.handle_key_event |
Popups/dialogs hijack all keys |
| 0.5 — GlobalChord | global_chord.handle |
Global chords (e.g. Ctrl-g c to open config dir) |
| 1 — Help | help.dismiss |
When help panel is open, press any key to dismiss |
| 2 — Chord | chord.handle |
Tab-level multi-key chords |
| 3 — Tab | tabs[ti].handle_key_event |
Current tab handles keys |
| 4 — Global | handle_global_kv |
Tab switching (1-7, Tab), quit (q, Ctrl-c), help (?) |
All renderable, key-handling elements implement the TuiWidget trait:
handle_key_event(&mut self, kv: &Key)— handle key eventsrender(&mut self, f, area)— draw UIsync(&mut self)— advance async taskson_enter(&mut self)/on_leave(&mut self)— callbacks when switching tabs
Do NOT modify state during rendering (render accepts &self). State changes should happen in handle_key_event or sync callbacks.
Tab<C> is a generic container. C must implement two traits:
BasicTabContent— definesKeyenum (which keys trigger),Statetype, titleTabContent— definesinit,handle_key_event,render
DualTab<C1, C2> is used for scenarios needing two panel switching (e.g. Files tab with Profile and Template). The two content types reference each other via DualTabContent / DualTabContentMate traits.
The enum_dispatch! macro unifies all tabs into a single Tab enum. Each variant uses the newtype_tab! macro to generate a wrapper and implement TuiWidget and TuiTab.
Async I/O operations are managed via FutureSet<C> (i.e. tokio::task::JoinSet):
- Spawn async tasks in
handle_key_eventorinitviatask_set.spawn(async { ... }) - Completed tasks produce
Callback<C>(i.e.Box<dyn FnOnce(&mut C)>) sync()advances completed callbacks each frame; state changes happen here uniformly
Error handling:
tri!()macro — capture errors and display a popup to the usertri!(, or_cancel)— silently swallow errors
Popups use a oneshot channel pattern:
- Call
Input::new().with_title(...).build_and_send().awaitto block and wait for user input - Popup events are managed by
PopUp::check()andPopUp::handle_key_event() - Use popups only when user input is required; simple confirm/error uses inline state display
| Macro | Location | Purpose |
|---|---|---|
tri! |
tab/mod.rs |
Error handling in async callbacks |
mod_agent! |
tab/mod.rs |
Define tab default key bindings and chord shortcuts |
newtype_tab! |
tab/mod.rs |
Generate Tab wrapper, implement TuiWidget + TuiTab |
enum_dispatch! |
tab/mod.rs |
Dispatch Tab enum variants to trait methods |
new_type_impl_tuiwidget! |
widget/mod.rs |
Auto-implement TuiWidget for newtype wrappers |
load_save! |
config/util.rs |
Generate from_file() / to_file() for YAML config types |
Two key definition styles:
[KeyCode::Char('j')]— plain character keykey("<C-a>")— modifier syntax (C=Ctrl, A=Alt, S=Shift)
Supports two keymap.yaml formats:
- Mapping:
j: SelectDown(simple, no description or chords) - Sequence:
[{on: j, action: SelectDown, desc: "Move down"}](with description, supports chords)
| Feature | Default | Description |
|---|---|---|
customized-theme |
☑ | Custom theme support (auto-enables tui) |
tui |
(indirect) | ratatui + crossterm + tokio dependencies |
deprecated |
☐ | Deprecated functionality |
Use #[cfg(feature = "tui")] rather than #[cfg(feature = "customized-theme")] for conditional compilation, unless it's theme-specific code.
The functions/ directory contains all business logic, divided into three modules:
| Module | Responsibility |
|---|---|
command |
System-level operations: service start/stop (systemd), file permission fixes, open directory, file editor |
file |
Profile management: import, update (download + parse), Template expansion, subscription type detection |
restful |
REST API: get/set config, switch proxies, query connections, get logs |
build.rs generates a version number via git, formatted as {CARGO_PKG_VERSION}-{git-short-hash}[-dirty], stored in the CLASHTUI_VERSION environment variable.
- Crate name is
clashtui(Cargo.toml) - Internal identifiers use
clashtui(CLASHTUI_VERSION,CLASHTUI_CONFIG_DIR, config directory~/.config/clashtui) - All environment variables, YAML keys, and user-visible strings use
clashtui
Approximate steps:
- Define the content type, implement
BasicTabContent+TabContent(orDualTabContent) - In
tab/mod.rs:- Add
mod mytab; - Use
newtype_tab!to generate the wrapper - Register in
prelude'senum_dispatch!and agent_init - Add variant to the
Tabenum
- Add
- In
app.rs:- Add instance to the
tabsvec inApp::new() - Update
TAB_COUNTand the'1'..='7'range - Call init in
prelude::agent_init
- Add instance to the
- If dual panel: the two content types specify each other's associated type via
DualTabContent/DualTabContentMate