13 KiB
Executable File
Architecture
This document explains how rs-mrxvt fits together, why the modules are where they are, and how to extend it (especially with the planned wgpu GUI backend).
1. Module map
src/
├── lib.rs — public API surface; re-exports alacritty_terminal
├── main.rs — binary entry point; CLI parse → App::run
├── cli.rs — clap-derived CLI struct (mrxvt flag compatibility)
├── command.rs — the Command enum (single source of truth for actions)
├── config.rs — TOML config + ConfigSource trait (future: Lua)
├── app.rs — App struct: owns TerminalManager + InputRouter + PaletteState
├── input/
│ ├── mod.rs
│ ├── bindings.rs — KeyChord + KeyBindingTable (default mrxvt chords)
│ └── router.rs — translate crossterm KeyEvents to bytes OR Commands
├── terminal/
│ ├── mod.rs
│ ├── pty.rs — PtySession: spawn child + read/write/resize
│ ├── tab.rs — TerminalTab: PtySession + Term<NoopListener> + reader thread
│ └── manager.rs — TerminalManager: tabs vec, active idx, broadcast router
└── ui/
├── mod.rs — Renderer trait (the swap point for backends)
├── event.rs — AppEvent / AppKey / AppModifiers (backend-agnostic input)
├── backend.rs — Backend enum, BackendRegistry, auto-detect chain
├── mock.rs — MockRenderer for tests
├── palette.rs — PaletteState: fuzzy-search overlay
├── tui.rs — TuiRenderer: ratatui + crossterm (default, always works)
├── wgpu.rs — WgpuRenderer: GPU acceleration (behind `gpu` feature)
└── soft.rs — SoftRenderer: CPU rasterizer (behind `gpu` feature)
2. The backend fallback chain
This is the core of the distro-agnostic design. At startup, Backend::Auto
probes factories in priority order:
┌─────────────────────────────────────────────────────────────┐
│ 1. WgpuFactory.available()? │
│ └─ probe: create wgpu::Instance, request_adapter │
│ └─ needs: Vulkan or GL drivers + dev headers at build │
│ YES → use WgpuRenderer (full GPU acceleration) │
│ NO ↓ │
│ 2. SoftFactory.available()? │
│ └─ probe: $DISPLAY or $WAYLAND_DISPLAY set? │
│ └─ needs: a display server (X11 or Wayland) │
│ YES → use SoftRenderer (CPU raster, "VESA mode") │
│ NO ↓ │
│ 3. TuiFactory.available()? │
│ └─ always true │
│ └─ use TuiRenderer (ratatui + crossterm) │
└─────────────────────────────────────────────────────────────┘
The "VESA mode" rationale
Classic VESA VBE was a CPU-driven linear framebuffer with no GPU
acceleration. It worked on any VGA card because the CPU did all the pixel
pushing. softbuffer + tiny-skia is the modern equivalent:
softbufferprovides a CPU-writable pixel buffer that the compositor displays as a window surface. No GPU driver required.tiny-skiais a Skia port that rasterizes paths, text, and shapes on the CPU.ab_glyphshapes and rasterizes glyphs.
This stack runs on any machine with a display server — including retro hardware with no working GPU driver, or VMs with broken 3D acceleration. It's slower than wgpu but it always works, which is exactly the property the fallback tier needs.
3. Event abstraction
Every backend translates its native events into [AppEvent] at the renderer
boundary. This lets App::handle_event stay backend-agnostic:
crossterm::KeyEvent ─┐
├─→ AppEvent ─→ App::handle_event ─→ InputRouter / Palette
winit::KeyEvent ─────┘
AppEvent variants:
Key(AppKeyEvent)— normalized key + modifiers + released flagResize(u16, u16)— window/drawing area resizedFocusGained/FocusLost— window focus changesPaste(String)— clipboard pasteQuit— window close, Ctrl+C in TUI, etc.
The AppKeyEvent uses our own AppKey enum (not crossterm's or winit's)
so binding tables work across backends.
4. Data flow (one frame)
┌────────────────────────────────────────────────────────────┐
│ Event Loop (app.rs) │
│ │
│ ┌──────────────────┐ poll_all() ┌────────────────┐ │
│ │ TerminalManager │ ◄─────────────── │ poll_pty() │ │
│ │ - tabs[i] │ │ on each tab │ │
│ │ - active │ │ (non-blocking)│ │
│ │ - broadcast │ └────────────────┘ │
│ └────────┬─────────┘ │
│ │ │
│ │ route_input(bytes) per broadcast mode │
│ ▼ │
│ ┌──────────────────┐ write_all() ┌────────────────┐ │
│ │ InputRouter │ ──────────────► │ PtySession │ │
│ │ - bindings │ │ .master │ │
│ │ - key_to_bytes │ └────────────────┘ │
│ └────────┬─────────┘ │
│ │ │
│ │ Command (from binding) │
│ ▼ │
│ ┌──────────────────┐ execute() ┌────────────────┐ │
│ │ App::run_command │ ──────────────► │ TerminalManager │ │
│ │ (or palette) │ │ ::execute() │ │
│ └──────────────────┘ └────────────────┘ │
│ │
│ renderer.render(self) ◄────────────────────────────────┘ │
└────────────────────────────────────────────────────────────┘
The loop:
manager.poll_all()— non-blocking drain of every tab's reader-thread channel.renderer.poll_event(timeout)— wait up to 50ms for anAppEvent.- While events arrive:
app.handle_event(ev)→ either routes raw bytes viamanager.route_input(honoring broadcast mode), or dispatches aCommandviamanager.execute. renderer.render(app)— draws tab bar, active terminal grid, status bar, and (if open) the palette overlay.
5. Threading model
Each tab owns a dedicated reader thread (tab.rs::TerminalTab::new) that
does blocking read() on the PTY master FD and forwards bytes through an
mpsc::channel. The main loop drains the channel with try_recv(), so a
tab with no output costs ~0 CPU.
This is the fix for the original mrxvt's "heavy process in tab 1 lags tab 2" problem: each PTY read is on its own thread, and the main loop never blocks on a single tab's I/O.
6. The Renderer trait — the swap point
pub trait Renderer {
fn init(&mut self) -> Result<()>;
fn fini(&mut self) -> Result<()>;
fn poll_event(&mut self, timeout_ms: u64) -> Result<Option<AppEvent>>;
fn render(&mut self, app: &mut App) -> Result<()>;
fn size(&self) -> (u16, u16);
}
App::run takes Box<dyn Renderer>, so the backend is chosen at runtime.
Three implementations exist today:
| Backend | File | Feature | Status |
|---|---|---|---|
| TuiRenderer | ui/tui.rs |
default | ✅ working |
| WgpuRenderer | ui/wgpu.rs |
gpu |
🚧 scaffold |
| SoftRenderer | ui/soft.rs |
gpu |
🚧 scaffold |
| MockRenderer | ui/mock.rs |
default | ✅ for tests |
The scaffolds compile and open windows but only render a solid background color. The glyph atlas + text rendering pipeline is the next phase.
7. Backend selection — BackendRegistry
The BackendRegistry holds a priority-ordered list of BackendFactory
trait objects. Each factory has:
pub trait BackendFactory: Send + 'static {
fn available(&self) -> bool;
fn create(&self) -> Result<Box<dyn Renderer>>;
}
default_registry() registers them in order: wgpu → soft → tui (when gpu
feature is on), or just tui (when it's off). The registry pattern makes it
easy to add new backends (e.g. a future WaylandRenderer that bypasses
winit) without touching the selection logic.
8. Broadcasting — the mrxvt killer feature
BroadcastTarget is the central type:
pub enum BroadcastTarget {
Active,
All,
Group(String),
}
TerminalManager::route_input(bytes) matches on this enum and writes to
either the active tab, every tab, or only tabs whose tag == Some(group).
The "fall back to active when no tab matches the group" rule preserves the
classic mrxvt UX: you never type into the void.
The CLI flag -j (alias --broadcast) flips BroadcastTarget::Active to
BroadcastTarget::All at startup. -g <tag> (alias --tag) assigns the
given tag to all startup tabs, so rs-mrxvt -n 5 -j -g web gives you 5
tabs all tagged "web" with broadcast on.
9. Command palette — modernizing hidden shortcuts
Command is the single source of truth for actions. Both the keybinding
table and the palette consume it:
- Keybindings:
KeyBindingTable::defaults()maps chords toCommandvariants.InputRouter::handleresolves aKeyEventto aCommand(or routes raw bytes if no binding matches). - Palette:
Command::defaults()returns a list of(Command, name, category)triples for display. The palette usesfuzzy-matcher's SkimMatcherV2 to filter. - Execution: both paths funnel through
TerminalManager::execute(&cmd)(for manager-affecting commands) orApp::run_command(&cmd)(for palette/quit).
When the router returns InputAction::OpenPalette, the app layer opens the
palette — the router itself doesn't touch UI state, keeping it pure.
10. Config — TOML today, Lua tomorrow
ConfigSource is the trait that lets us swap config backends:
pub trait ConfigSource {
fn load(&self) -> Result<Config>;
}
FileConfigSource is the TOML impl. A future LuaConfigSource (using
mlua) can implement the same trait and be selected at runtime via a
--config-format lua flag or by file extension. The rest of the codebase
won't need to change.
11. Testing strategy
- Unit tests (
cargo test --lib): 76 tests covering input routing, config parsing, tab manager state transitions, palette filtering, keybinding resolution, key-to-bytes translation, event abstraction, backend selection logic, and app event handling. - Integration tests (
tests/integration.rs): 10 end-to-end tests spawning real subprocesses via PTY and verifying the alacritty_terminal emulator renders the output. - Broadcasting integration tests (
tests/broadcasting.rs): 7 tests specifically for the mrxvt killer feature. - Stress harness (
scripts/stress_test.py): Python script that spawns N PTYs (default 50), broadcasts a marker, and verifies all N received it within the timeout.
Total: 93 Rust tests + 1 Python harness, all passing. The same suite
passes with --features gpu enabled.
12. Build configurations
| Config | Cargo command | Backends available |
|---|---|---|
| Default (TUI only) | cargo build |
TUI |
| With GPU backends | cargo build --features gpu |
TUI + Wgpu + Soft |
The gpu feature pulls in wgpu, winit, softbuffer, tiny-skia,
ab_glyph, and pollster. These need system dev headers
(libvulkan-dev, libwayland-dev, libxkbcommon-dev on Debian) at build
time, but the default build has zero system graphics dependencies.