Skip to content

Latest commit

 

History

History
523 lines (433 loc) · 29.4 KB

File metadata and controls

523 lines (433 loc) · 29.4 KB

Termie Architecture

Termie is a GUI terminal emulator. In one sentence:

Termie is the puppeteer that holds a PTY master — it forkptys a login shell onto the slave, then every egui frame it drains the master (shell output → screen) and feeds the master (keystrokes → shell input).

This doc is diagram-first and covers every module. Code anchors are written as path::function so they survive line drift.

Reading order / module map

 main.rs ─► gui/ ─────────────► terminal_emulator/ ───────► io/
 (args,   (egui app, frame      (state machine that          (TermIo backends:
  replay)  loop, input map)      turns bytes into a screen)    real pty / replay)

 terminal_emulator/
   mod.rs            TerminalEmulator<Io>  — owns everything, drives read/write
   io/pty.rs         PtyIo                 — forkpty, master fd, TIOCSWINSZ   ── Part 2
   io/mod.rs         TermIo trait          — read / write / set_win_size      ── Part 7
   ansi.rs           AnsiParser            — bytes → TerminalOutput ops       ── Part 5
   buffer.rs         TerminalBuffer        — the on-screen character grid     ── Part 6
   format_tracker.rs FormatTracker         — color/bold spans over the grid   ── Part 6
   recording.rs      Recorder / Recording  — snapshot + byte log → JSON       ── Part 7
   replay.rs         ReplayIo / ReplayControl — play a recording back         ── Part 7
   error.rs, log.rs  backtraced_err, log!  — error-chain fmt + TERMIE_LOG

Parts: 1 PTY mental model · 2 building the PTY · 3 runtime loop · 4 curses & terminfo · 5 the ANSI parser · 6 the screen model · 7 record & replay.


Part 1 — The mental model: what a PTY is faking

A "terminal" used to be physical hardware: a keyboard + screen on a serial cable. The kernel's terminal driver (the line discipline) sat in the middle doing echo, line editing, Ctrl-C → SIGINT, and window size.

   PHYSICAL WORLD              INSIDE THE KERNEL              PROGRAM
   ┌──────────────┐         ┌────────────────────────┐     ┌─────────┐
   │  TERMINAL    │         │    TERMINAL DRIVER      │     │  shell  │
   │ ┌──────────┐ │  cable  │    (the "tty")          │     │         │
   │ │ keyboard ─┼─┼────────┼──► • echo what's typed   │────►│ read()  │
   │ │  screen ◄─┼─┼────────┼─── • backspace / ctrl-U  │◄────┤ write() │
   │ └──────────┘ │         │    • ctrl-C → SIGINT     │     │         │
   └──────────────┘         │    • line buffering      │     └─────────┘
      hardware              │    • window size, raw    │
                            └────────────────────────┘

A pseudo-terminal (PTY) is that exact driver with the hardware sawn off and a file descriptor screwed onto the stump. The driver is unchanged; only the far "keyboard + screen" end is replaced by a program (here, termie).

   ┌─────────────┐        ┌──────────────────────┐        ┌─────────┐
   │   TERMIE     │        │   TERMINAL DRIVER     │        │  shell  │
   │ (the GUI)    │  ════► │   (still 100% real)   │  ════► │ read()  │
   │  plays the   │ MASTER │  echo, ctrl-C, winsz  │ SLAVE  │ write() │
   │  keyboard +  │  end   │                       │  end   │         │
   │  screen      │ ◄════  │                       │ ◄════  │         │
   └─────────────┘        └──────────────────────┘        └─────────┘
   PtyIo.fd  ◄── termie holds THIS                  shell's fd 0/1/2 ──┘

Master vs slave = the two ends of the fake cable

End Old physical role Who plugs in (termie) Looks like
slave /dev/pts/N hardware port the shell talked through the $SHELL -l child a real terminal — isatty = true
master /dev/ptmx the keyboard + screen on the cable termie's PtyIo.fd a raw byte fd you read/write

The data flow is crossed, because the master impersonates a human:

   MASTER (termie)              TERMINAL DRIVER              SLAVE (shell)
   write("ls\n")  ──────────────►  treated as            ──►  read() gets "ls\n"
     "I am typing"               KEYSTROKES                    "someone typed!"

   read() gets "file1\n" ◄─────  treated as              ◄──  write("file1\n")
     "I am the screen"           SCREEN OUTPUT                 "printing output"

A plain pipe won't do: it is a dumb byte tube with no echo, no Ctrl-C, no window size, so isatty is false and curses programs break. The PTY gives termie the semantics of a terminal, not just the bytes.


Part 2 — How termie builds its PTY

All of this lives in terminal_emulator/io/pty.rs.

spawn_shell() — one forkpty call does the whole dance

   nix::pty::forkpty(None, None)          ← pty::spawn_shell()
        │
        │  forkpty bundles the entire recipe:
        │  ┌─────────────────────────────────────────────────┐
        │  │ open /dev/ptmx → grantpt → unlockpt → ptsname     │
        │  │ fork()                                            │
        │  │   child: setsid()                                 │
        │  │          open(/dev/pts/N)  ⇒ controlling tty      │
        │  │          dup2 slave → fd 0,1,2                     │
        │  └─────────────────────────────────────────────────┘
        ▼
   ┌──────────────────────────────┬───────────────────────────────┐
   │ PARENT (termie)               │ CHILD (becomes the shell)      │
   ├──────────────────────────────┼───────────────────────────────┤
   │ ForkResult::Parent            │ ForkResult::Child              │
   │ return res.master  ──► PtyIo  │ set TERM=termie                │
   │                               │ set TERMINFO=<tempdir>         │
   │                               │ execvp($SHELL, [SHELL, "-l"])  │
   │                               │   └► login shell, fd0/1/2=slave│
   └──────────────────────────────┴───────────────────────────────┘

The parent keeps res.master and wraps it in PtyIo { fd, _terminfo_dir } (pty::PtyIo::new). The child never returns — it execvps the shell and from that moment its stdin/stdout/stderr are the slave terminal.

Non-blocking master, so the GUI never stalls

   set_nonblock(&fd)               ← fcntl O_NONBLOCK on the master
        │
        ▼
   PtyIo::read → nix::read(master)
        ├─ Ok(n)            → ReadResponse::Success(n)
        └─ Err(EAGAIN)      → ReadResponse::Empty   ← "nothing right now,
                                                       paint the frame anyway"

A blocking read would freeze egui between keystrokes. Non-blocking turns "no data yet" into ReadResponse::Empty, which the frame loop treats as done draining.

Window size: the one ioctl termie issues directly

   set_window_size_ioctl  (ioctl_write_ptr_bad! macro, TIOCSWINSZ)
        ▲
   PtyIo::set_win_size(width, height)
        ▲
   TerminalEmulator::set_win_size  ─ only fires the ioctl when size changed
        ▲
   gui: each frame measures the char grid from the egui panel

forkpty is called with None winsize; the real size is pushed right after, in TerminalEmulator::<PtyIo>::new (initial 50×16), and again on every resize.

The third spawn detail — bundling TERM=termie terminfo — needs its own section, because it is why terminal apps can drive termie at all. See Part 4.


Part 3 — Runtime architecture (who calls whom)

   ┌──────────────────────────── TERMIE PROCESS ───────────────────────────┐
   │                                                                        │
   │  eframe/egui event loop          terminal_emulator                     │
   │  ┌────────────────────┐          ┌──────────────────────────────────┐ │
   │  │ TermieGui::update   │          │ TerminalEmulator<PtyIo>          │ │
   │  │ (gui/mod.rs)        │          │                                  │ │
   │  │                     │ set_win  │  parser: AnsiParser              │ │
   │  │  measure grid ──────┼─────────►│  terminal_buffer: TerminalBuffer │ │
   │  │                     │  _size   │  format_tracker: FormatTracker   │ │
   │  │  TerminalWidget     │          │  cursor_state / decckm_mode      │ │
   │  │   ::show ───────────┼─ read() ►│  recorder: Recorder              │ │
   │  │   (gui/terminal.rs) │          │  io: PtyIo  ───────┐             │ │
   │  │     egui InputEvent │          └────────────────────┼────────────┘ │
   │  │       └─► write(in) ┼─ write() ──────────────────────┘             │ │
   │  └────────────────────┘                                 │             │
   │                                                          ▼             │
   │                                                   ┌─────────────┐      │
   │                                                   │  PTY master │      │
   │                                                   └──────┬──────┘      │
   └──────────────────────────────────────────────────────── │ ───────────┘
                                                    PTY pair   │
   ┌──────────────────────────────────────────────────────────▼───────────┐
   │  CHILD: $SHELL -l   (fd 0/1/2 = /dev/pts/N slave)                      │
   └────────────────────────────────────────────────────────────────────────┘

The per-frame relay loop

Every egui repaint runs this. Output drains first, then input is sent, then the buffer is painted.

   TermieGui::update                                   each frame
        │
        ├─ measure char grid ─► TerminalEmulator::set_win_size ─► (TIOCSWINSZ if changed)
        │
        └─ TerminalWidget::show
              │
              ├─ 1. terminal_emulator.read()                       ── DRAIN OUTPUT
              │        loop io.read(4096) until ReadResponse::Empty
              │          → recorder.write(bytes)
              │          → handle_incoming_data(bytes)              (see Part 5)
              │
              ├─ 2. paint terminal_buffer + format tags to the egui canvas
              │
              └─ 3. for each egui InputEvent                        ── SEND INPUT
                       map → TerminalInput (Ascii/Ctrl/Enter/Arrows/…)
                       terminal_emulator.write(input)
                         → to_payload(decckm_mode)  (arrows depend on DECCKM)
                         → io.write(bytes)  → PTY master

gui/terminal.rs::TerminalWidget owns the keyboard map (egui KeyTerminalInput) and the painting; mod.rs::TerminalEmulator::write turns a TerminalInput into the actual escape bytes via to_payload.


Part 4 — Curses & terminfo: why TUI apps speak "terminal"

This is the question "what does curses do and why does everything use it." The short version: terminals disagree on their escape codes, so programs delegate the byte-level dialect to a library + a database.

The problem

Historically every terminal model (VT100, VT220, xterm, …) used different byte sequences for the same action. "Move cursor to row 3 col 5", "clear the screen", "turn on bold" — each was a different escape string per terminal. A program that hard-coded VT100 codes would render garbage on something else.

The two-part fix the whole ecosystem standardized on

   ┌──────────────┐   high-level calls   ┌──────────────┐   reads     ┌─────────────┐
   │  TUI program │  move(y,x)/addstr()  │   ncurses    │  $TERM      │  terminfo   │
   │ vim,htop,top │ ───────────────────► │  (library)   │ ──────────► │  database   │
   └──────────────┘  attron(BOLD)        └──────┬───────┘             └─────────────┘
                                                │ emits the EXACT bytes
                                                │ for THIS terminal
                                                ▼
                                       escape sequence stream ─► the terminal
  1. terminfo — a database, one entry per terminal type, keyed by the $TERM env var. Each entry lists capabilities: booleans (am = auto-margins), numbers (colors#8), and string recipes (cup=\E[%i%p1%d;%p2%dH = the parameterized bytes to position the cursor).
  2. curses / ncurses — a library. The program calls move, addstr, attron(A_BOLD), refresh(); ncurses looks up $TERM, finds the right recipe for this terminal, and emits those bytes. It also keeps a model of the screen and emits only the cells that changed.

Why ~every TUI app uses it

  • Portability — write once against curses; the terminfo entry handles the per-terminal dialect.
  • Minimal redraw — curses diffs the screen and sends the smallest update, which mattered enormously over slow links and still saves work.
  • Primitives — windows, color pairs, raw/cooked input, and keypad decoding (turning an incoming \EOA back into "the Up arrow was pressed").

How termie closes the loop

Termie is a terminal whose "dialect" is exactly what ansi.rs can parse. So it ships its own terminfo entry named termie (res/termie.ti), compiled by tic at build time, tarred into the binary, and unpacked per-process:

   build.rs:  tic res/termie.ti ─► $OUT_DIR/terminfo ─► terminfo.tar (include_bytes!)
                                                              │
   PtyIo::new ─► extract_terminfo() unpack ─► <tempdir>/terminfo/...
        │
   child env:  TERM=termie   TERMINFO=<tempdir>
        │
        ▼
   ncurses in the shell reads termie's capabilities and emits ONLY
   sequences termie understands.

The terminfo file and the parser are two halves of one contract:

 res/termie.ti  (what termie ADVERTISES)        ansi.rs  (what termie PARSES)
 ─────────────────────────────────────────     ───────────────────────────────────
 cup=\E[%i%p1%d;%p2%dH   position cursor   ◄─►   CSI 'H'      → SetCursorPos
 cuu/cud/cuf/cub=\E[%p1%d A/B/C/D          ◄─►   CSI A/B/C/D  → SetCursorPosRel
 ed=\E[J  el=\E[K  E3=\E[3J  clear*        ◄─►   CSI 'J'/'K'  → ClearForwards/All/Line
 ich=\E[%p1%d@   insert spaces             ◄─►   CSI '@'      → InsertSpaces
 il=\E[%p1%dL  dch=\E[%p1%dP               ◄─►   CSI 'L'/'P'  → InsertLines/Delete
 colors#8        (8 ANSI colors)           ◄─►   CSI 'm'      → Sgr(color/bold)
 smkx=\E[?1h  rmkx=\E[?1l  (DECCKM)        ◄─►   CSI '?1' h/l → Set/ResetMode(Decckm)
 kcuu1=\EOA … (arrows in application mode)  ◄─►  to_payload emits \EOA when decckm

The last two rows are the elegant bit. When an app turns on cursor-key mode it sends smkx (\E[?1h); ansi.rs parses ?1 hSetMode(Decckm) and termie flips decckm_mode = true. From then on termie encodes its own arrow keys as \EOA/\EOB/… (mod.rs::to_payload) — exactly the kcuu1/kcud1 strings the same terminfo advertises. Output parsing and input encoding stay consistent because both read from one definition.

termie.ti is derived from dumb plus a hand-added CSI subset — deliberately small, matching the parser's coverage. Anything outside the table, ansi.rs logs as "Unhandled" and emits TerminalOutput::Invalid.


Part 5 — The ANSI parser (ansi.rs): bytes → ops

AnsiParser::push(&[u8]) is a byte-at-a-time state machine. It returns a Vec<TerminalOutput> — structured ops the emulator can apply. Printable runs are coalesced into TerminalOutput::Data.

                       bytes from the shell
                               │
        ┌──────────────────────▼─────────────────────────┐
        │ AnsiParserInner::Empty                          │
        │   printable  ─► push into data_output (Data)     │
        │   \r ─► CarriageReturn   \n ─► Newline           │
        │   0x08 ─► Backspace                              │
        │   0x1B (ESC) ─────────────────────────┐         │
        └─────────────────────────────────────── │ ───────┘
                                                  ▼
                                ┌── AnsiParserInner::Escape ──┐
                          '['   │                             │   ']'
                ┌───────────────┤  else ─► warn, back to Empty├──────────────┐
                ▼               └─────────────────────────────┘              ▼
   ┌─ Csi(CsiParser) ───────────────────────────┐                ┌─ Osc ──────────────┐
   │ Params       collect 0x30–0x3f  (digits;?)  │                │ swallow until BEL   │
   │   │ 0x20–2f                                  │                │ (0x07) or ESC \     │
   │   ▼                                          │                │ ─► back to Empty    │
   │ Intermediates collect 0x20–0x2f             │                └────────────────────┘
   │   │ 0x40–7e  (final byte)                    │
   │   ▼                                          │
   │ Finished(b) ─► emit op, back to Empty        │
   │ Invalid ─► consume to terminator, Invalid op │
   └──────────────────────────────────────────────┘
                               │
   final byte → op:
     'H'/'G' → SetCursorPos       'A/B/C/D' → SetCursorPosRel
     'J' → ClearForwards/ClearAll    'K' → ClearLineForwards
     'm' → Sgr(color/bold, incl. 256 & rgb)   '@' → InsertSpaces
     'L' → InsertLines    'P' → Delete    'h'/'l' → Set/ResetMode
     anything else → Invalid (logged)

Then mod.rs::handle_incoming_data walks the ops and mutates the three pieces of state — TerminalBuffer, FormatTracker, CursorState:

   AnsiParser::push ─► [TerminalOutput]
        │
        ├─ Data(bytes)        → buffer.insert_data        (+ format range adjust)
        ├─ SetCursorPos/Rel   → cursor_state.pos
        ├─ ClearForwards/All  → buffer.clear_* + format_tracker
        ├─ Newline/CR/Backsp  → cursor_state.pos
        ├─ Sgr(...)           → cursor_state.color / bold   (or Decckm via SetMode)
        ├─ InsertLines/Spaces → buffer.* + format range adjust
        └─ Delete             → buffer.delete_forwards + format_tracker.delete_range

(SetMode(Decckm)/ResetMode(Decckm) toggle decckm_mode, which later changes how arrow-key input is encoded — see Part 4.)


Part 6 — The screen model: TerminalBuffer + FormatTracker

buffer.rs — one flat Vec<u8>, lines computed on demand

The screen is not a 2-D grid. It is a single Vec<u8>; line boundaries are derived by calc_line_ranges(buf, width), which splits on \n or at width (soft wrap). "Visible" is the bottom height lines; everything above is scrollback.

   buf: "ls\n0123456789abc\n$ "          width = 5   height = 3
                  │ calc_line_ranges(width=5)
                  ▼
     line 0  "ls"      0..2     ┐
     line 1  "01234"   3..8     │  scrollback  (above the bottom `height`)
     line 2  "56789"   8..13    ┘
     line 3  "abc"     13..16   ┐
     line 4  "$ "      17..19   ┘  visible = bottom 3 lines
                  │
   data() ─► { scrollback: &buf[0..start], visible: &buf[start..] }
   cursor (x,y) ──► buf offset = visible_line_ranges[y].start + x   (and back)

Writes go through pad_buffer_for_write: it appends \n until the cursor's row exists and inserts spaces until the cursor's column exists, then overwrites. It returns the inserted_padding range so the FormatTracker can shift its spans by the same amount. set_win_size re-derives the cursor by mapping its old buffer offset under the new width — so text stays put relative to the cursor on resize.

format_tracker.rs — color/bold as offset spans, not per cell

Color/bold are stored as a sorted list of non-overlapping FormatTag { start, end, color, bold } over buffer offsets, seeded with one default tag 0..usize::MAX. Writing color carves a new span in; the existing spans split or trim around it.

   seed:                 [0 ───────────────────────────── MAX)  Default

   push_range(Yellow, 3..10):
                 [0──3)Default │ [3────────10)Yellow │ [10────MAX)Default

   push_range(Blue, 5..7):
       [0─3)Def │ [3─5)Yel │ [5─7)Blue │ [7─10)Yel │ [10──MAX)Def
                            └ the Yellow span is split around the new Blue ┘
  • push_range(cursor, range) — carve in the cursor's current color/bold (adjust_existing_format_ranges handles the 4 overlap cases).
  • push_range_adjustment(range) — when the buffer grows (inserted padding/lines) slide every later span by the inserted length, so spans keep tracking the same characters.
  • delete_range(range) — remove/shrink/shift spans when characters are deleted.
  • At paint time, mod.rs::split_format_data_for_scrollback cuts the tag list at the scrollback/visible boundary so each region paints with correct colors.

Part 7 — Record & replay (recording.rs, replay.rs) + the TermIo swap

The TermIo trait makes the I/O source pluggable

TerminalEmulator<Io: TermIo> is generic over its I/O backend; the parser / buffer / render pipeline never knows whether bytes come from a live shell or a saved file.

                     trait TermIo  (io/mod.rs)
              read() · write() · set_win_size()
                     ▲                     ▲
        ┌────────────┘                     └─────────────┐
   ┌──────────────┐                            ┌───────────────────┐
   │   PtyIo       │                            │   ReplayIo         │
   │ (io/pty.rs)   │                            │ (replay.rs)        │
   │ live forkpty  │                            │ recorded bytes via │
   │ + master fd   │                            │ an mpsc channel    │
   └──────────────┘                            └───────────────────┘
        ▲                                                ▲
   TerminalEmulator<PtyIo>::new            TerminalEmulator<ReplayIo>::from_snapshot
        ▲                                                ▲
   gui::run  (main, no --replay)           gui::run_replay  (main, --replay <file>)

What a recording is

A Recording (serialized to recordings/N.json via SnapshotItem + tinyjson):

   Recording {
     initial_state:  snapshot of parser + buffer + format_tracker + cursor
                     at the moment recording started   (so replay can fast-start)
     items: [ Write { data: [bytes] },        ◄─ every chunk the shell emitted
              SetWinSize { width, height },    ◄─ every resize
              Write { ... }, ... ]
   }

SnapshotItem (recording.rs) is a tiny JSON-shaped tree (Bool/Int/String/ Array/Map) that every stateful struct implements snapshot() / from_snapshot() against — that is the …snapshot machinery scattered through ansi.rs, buffer.rs, format_tracker.rs, and mod.rs.

Record vs replay, side by side

   RECORD (live)                              REPLAY (--replay N.json)
   ─────────────────────────                  ─────────────────────────────
   shell ─► PtyIo.read                        Recording::load(N.json)
        │                                            │
   TerminalEmulator::read                      ReplayControl::new
        ├─ recorder.write(bytes) ─┐                 │  io_handle() ─► ReplayIo
        └─ handle_incoming_data    │            from_snapshot(initial_state)
                                   ▼                 │
        Recorder (Weak handle,            step ─► ReplayControl::next:
        coalesces consecutive Writes)        Write{data} ─► mpsc.send ─► ReplayIo.read
                                   │             SetWinSize ─► set_win_size
                          on handle drop:             │
        Recording::to_json ─► recordings/N.json  SAME parser→buffer→FormatTracker
                                                 →egui pipeline, driven by a slider

The replay GUI (gui/mod.rs::ReplayTermieGui) adds a seek slider: stepping forward calls ReplayControl::next (feeding more recorded bytes through ReplayIo); seeking backward rebuilds from initial_state and replays N steps. Because both modes run the identical pipeline, a replay is byte-for-byte what the live session showed.


Where each piece lives

Concern Location
Build the PTY (forkpty, exec shell) terminal_emulator/io/pty.rs::spawn_shell
Non-blocking master, read/write, TIOCSWINSZ terminal_emulator/io/pty.rs (PtyIo)
Bundle/extract terminfo, TERM=termie pty.rs::extract_terminfo + build.rs + res/termie.ti
I/O backend abstraction terminal_emulator/io/mod.rs (TermIo, ReadResponse)
Emulator core (state + ops) terminal_emulator/mod.rs (TerminalEmulator)
Byte stream → structured ops terminal_emulator/ansi.rs (AnsiParser, CsiParser)
Character grid + scrollback + wrap terminal_emulator/buffer.rs (TerminalBuffer)
Color/bold spans terminal_emulator/format_tracker.rs (FormatTracker)
Snapshot tree + JSON record terminal_emulator/recording.rs (SnapshotItem, Recorder)
Replay backend + seek control terminal_emulator/replay.rs (ReplayIo, ReplayControl)
GUI shell, frame loop, resize, debug panel gui/mod.rs (TermieGui::update)
Replay GUI + slider gui/mod.rs (ReplayTermieGui)
Input mapping, drain+paint gui/terminal.rs (TerminalWidget::show)
Entry point, --replay / --recording-path main.rs
Error-chain formatting error.rs (backtraced_err)
Logging + TERMIE_LOG filtering log.rs (debug!/info!/warn!/error!)