rs-mrxvt/ARCHITECTURE.md

275 lines
13 KiB
Markdown
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:
- **`softbuffer`** provides a CPU-writable pixel buffer that the compositor
displays as a window surface. No GPU driver required.
- **`tiny-skia`** is a Skia port that rasterizes paths, text, and shapes
on the CPU.
- **`ab_glyph`** shapes 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 flag
- `Resize(u16, u16)` — window/drawing area resized
- `FocusGained` / `FocusLost` — window focus changes
- `Paste(String)` — clipboard paste
- `Quit` — 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:
1. `manager.poll_all()` — non-blocking drain of every tab's reader-thread channel.
2. `renderer.poll_event(timeout)` — wait up to 50ms for an `AppEvent`.
3. While events arrive: `app.handle_event(ev)` → either routes raw bytes
via `manager.route_input` (honoring broadcast mode), or dispatches a
`Command` via `manager.execute`.
4. `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
```rust
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:
```rust
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:
```rust
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 to `Command`
variants. `InputRouter::handle` resolves a `KeyEvent` to a `Command`
(or routes raw bytes if no binding matches).
- **Palette**: `Command::defaults()` returns a list of `(Command, name,
category)` triples for display. The palette uses `fuzzy-matcher`'s
SkimMatcherV2 to filter.
- **Execution**: both paths funnel through `TerminalManager::execute(&cmd)`
(for manager-affecting commands) or `App::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:
```rust
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.