# 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 + 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>; fn render(&mut self, app: &mut App) -> Result<()>; fn size(&self) -> (u16, u16); } ``` `App::run` takes `Box`, 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>; } ``` `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 ` (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; } ``` `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.