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