Packages

A terminal emulator in pure Elixir: bytes in, cell grid out.

Current section

Files

Jump to
e_tee README.md
Raw

README.md

# ETee
A terminal emulator in pure Elixir. Bytes in, cell grid out.
ETee takes the byte stream a program writes to a terminal and maintains what that program
drew — grid, cursor, styles, modes, scrollback. It has no opinion about rendering, no
dependency on a UI framework, and performs no syscalls, so the byte source can be a pty, a
socket, a file, or a recorded session.
```elixir
term =
ETee.new(40, 6)
|> ETee.feed("\e[1;34m~/src\e[0m $ ls\r\n")
|> ETee.feed("mix.exs lib test\r\n")
ETee.screen_text(term)
# => ["~/src $ ls", "mix.exs lib test", "", "", "", ""]
ETee.grid(term) |> ETee.Grid.get(0, 0)
# => %ETee.Cell{char: "~", style: %ETee.Style{fg: 4, attrs: 1}, width: 1}
```
## Installation
```elixir
def deps do
[
{:e_tee, "~> 0.1"}
]
end
```
## What it is for
The contract is bytes in, grid out, with no renderer attached. A grid can be drawn by a UI
framework, multiplexed, rendered to HTML, or asserted on in a test that needs to know what a
program *drew* rather than what bytes it emitted — feed recorded sequences, assert on the
grid, no tty involved.
## How a row is stored
A row is not a sequence of cells. It is a list of segments:
```elixir
{:text, span, binary, style} # span single-width graphemes sharing one style
{:wide, grapheme, style} # one double-width grapheme, spanning two columns
```
Printing writes a run, not a character: 54 characters are one segment holding the binary the
parser produced, which is a sub-binary of the bytes fed in and so is not copied.
Two behaviours follow from the structure:
**Blanks are spaces.** Spans sum to exactly `cols`, so there is no unwritten column and no
"absent means blank, unless it was erased with a background colour" case. Erasing writes
spaces. A blank row is one shared segment.
**Splitting repairs double-width graphemes.** Splitting a row inside a `:wide` segment yields
a blank on each side, which is what overwriting half a wide grapheme leaves behind.
The one tuned constant: adjacent segments sharing a style coalesce only when the incoming
segment spans eight columns or fewer. A program writing a character at a time would otherwise
accumulate a segment per column, while a bulk run would otherwise copy itself into its
neighbour on every line.
## Feeding
`feed/2` accepts whatever bytes arrived, however they were chopped. A sequence split across
reads parses exactly as it would have whole — including a UTF-8 character split
mid-codepoint, or an OSC string split immediately before its terminator. The test suite
feeds a stream a byte at a time and asserts the result is identical to feeding it whole.
## Damage
`damage/1` returns the rows whose content changed since the last `clear_damage/1`.
```elixir
term = term |> ETee.clear_damage() |> ETee.feed("\e[2;1Hupdated")
ETee.damage(term)
# => MapSet.new([1])
```
A consumer decides when to paint; ETee only reports what moved. This is what keeps a flood
of output from costing a repaint per chunk — parse at full speed, paint on whatever schedule
the consumer wants, and the rows are still correct. Operations that touch the whole screen
record that fact rather than enumerating rows, so a program scrolling flat out does not pay
per row per line.
## What it emulates
Scoped to what real programs use, not to covering the spec.
- Text, carriage return, line feed, backspace, and tab with settable tab stops
- SGR: 16 colour, 256 colour, truecolour, and attributes, in both semicolon and colon
subparameter forms
- Cursor positioning, save and restore, origin mode
- Erase in line and display, including scrollback clear
- Scroll regions, insert and delete line, insert, delete and erase character, insert mode
- Alternate screen, cursor visibility, bracketed paste, focus reporting, mouse tracking
modes reported to the consumer
- OSC window title and OSC 8 hyperlinks, which attach to the cells written while open
- Wide characters and combining marks by UAX #11 with emoji presentation
- Full reset and the DECALN alignment pattern
Deferred until something real needs them: sixel, DECRQSS, and generating mouse reports
(the modes are tracked and exposed; encoding events is the consumer's business).
## Performance
Measured on a 120x40 terminal under `MIX_ENV=prod`:
| Workload | Throughput |
|---|---|
| Parser alone, plain text | 76.6 MB/s |
| Full emulator, plain text | 51.5 MB/s |
| Full emulator, 54-char lines with scrolling | 19.0 MB/s |
| Full emulator, styled lines | 9.1 MB/s |
| Full emulator, heavy SGR (5 sequences per line) | 3.3 MB/s |
| Full emulator, CJK and mixed UTF-8 | 3.1 MB/s |
Three properties worth relying on:
**Writing text costs one segment, not one cell per character.** A run of printable text is
stored as the binary the parser produced, which is a sub-binary of the bytes fed in, so the
characters are never copied.
**Scroll cost is independent of terminal height.** A full-screen scroll changes a row-index
origin and drops the rows that left, rather than rewriting every row's position. A 200-row
terminal scrolls as fast as a 40-row one.
**Blank cells cost nothing.** A blank is a space inside a text segment, and a blank row is one
shared segment. Erasing writes spaces rather than clearing per-cell state.
Where a line of ordinary output now goes:
| | ns/line |
|---|---|
| `put_run` — writing 54 characters | 957 |
| parser | 591 |
| `scroll_up` | 518 |
| scrollback push | 234 |
What remains is dominated by fixed per-line costs rather than per-character ones. The two
untouched levers are the parser allocating an event per escape sequence — which is why heavy
SGR output is the slowest workload above — and scrollback pushing a row per line.
## Character width
`ETee.CharacterWidth` was ported from `Drafter.CharacterWidth` so that both agree on how
wide a grapheme is. They are currently duplicated; drafter should delegate to this copy once
it depends on ETee, because two independently drifting width tables would misalign the grid
against what the host draws.
## License
MIT