Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

2 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

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

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages