rs-mrxvt/ARCHITECTURE.md

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:

  • 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

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 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:

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.