37 The terminal — term and tty
What to know first
fmt assembles into the caller’s buffer and returns the next positionLooking back
In chapter 36, why were the http parser and net different modules?
A. Because receiving bytes (authority — needs cap net) and interpreting them (computation — pure) are different jobs. Merged, even pure interpretation drags capabilities and effects along and becomes hard to test. This chapter’s terminal modules are split in half the same way.
The need for this chapter, and its context
ESC [ A is — these are computation. The standard library split this computation into pure term and tty parsing, and the part touching the operating system into three builtin ops needing cap tty. As the last chapter of the library tour, it shows what this split actually buys.By the end of this chapter
term writes nothing to the screen but assembles ANSI control bytes into the caller’s buffer, and how screen diffs redraw only changed cells. You will confirm that display width and grapheme clusters differ from byte counts. You will also interpret keys purely with tty.parse_key, and see why raw mode must always be restored when using the cap tty builtins that switch it on and off.The questions this chapter answers
- Doesn’t cell width differ between terminals?
37.1 term does not write to the screen#
Every op of term is effects none. It only assembles control bytes that move the cursor (goto), change colours (sgr, color256) or clear the screen (clear) into the caller’s buffer and returns the next position to write. Actually emitting to the screen is done by outbuf (chapter 36).
Not knowing this leads to the dead end “the code runs but nothing appears on screen”. But thanks to this split, all screen code can be tested without a screen. Just compare the assembled bytes.
examples/ch37/assemble.low
module assemble .
rem run: paint [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]
use term .
rem writes nothing to the screen; it appends control bytes to buf and returns how many bytes were written
proc paint input buf mut slice u8 . . output u64 . effects state .
do
rem move the cursor to row 2, column 5 counted from 0; the terminal counts from 1, so ESC [ 3 ; 6 H
let p1 option u64 be term.goto buf 0 2 5 .
guard is_some p1 . else return 0 .
rem bold (1): ESC [ 1 m, continuing from the position the previous op returned
let p2 option u64 be term.sgr buf (some_value p1) 1 .
guard is_some p2 . else return 0 .
let at u64 be some_value p2 .
set (index buf at) 104 .
set (index buf (add at 1)) 105 .
rem reset attributes (0): ESC [ 0 m
let p3 option u64 be term.sgr buf (add at 2) 0 .
guard is_some p3 . else return 0 .
return some_value p3 .
end
Output
$ lowentc --run paint assemble.low [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]
paint([27,91,51,59,54,72,27,91,49,109,104,105,27,91,48,109,0,0,0,0,0,0,0,0]) = 16
arg0 (written) = [27,91,51,59,54,72,27,91,49,109,104,105,27,91,48,109,0,0,0,0,0,0,0,0]
term.goto buf 0 2 5writes the cursor-move bytes intobuffrom position 0 and returns the next position to write, 6, as anoption. If the buffer is too short it writes nothing and givesnone, because half-written control bytes would garble the screen.- Rows and columns count from 0. The terminal convention (ANSI) counts from 1, so
gotoadds 1 and writesESC [ 3 ; 6 H(27 91 51 59 54 72). The choice keeps the same base as array indexes. term.sgr buf 6 1writesESC [ 1 m, which turns on bold, and the lastterm.sgr … 0writesESC [ 0 m, which resets attributes. The 104 and 105 in between are the lettershi.- Every op returns “the next position”, so the answer of one op becomes the
posof the next. Sixteen bytes are written in all, and thebufthe VM shows is exactly those bytes.
37.2 Redrawing only changed cells#
examples/ch37/diffing.low
module diffing .
rem run: main
use term .
proc main input al cap allocator . output u8 . effects alloc .
do
let g option mut slice u8 be alloc_bytes al capacity 64 .
guard is_some g . else return 255 .
let out mut slice u8 be some_value g .
let same option u64 be term.diff "hello " "hello " 8 out 0 .
guard is_some same . else return 254 .
let changed option u64 be term.diff "hello " "help! " 8 out 0 .
guard is_some changed . else return 253 .
return narrow u8 (add (mul (some_value same) 100) (some_value changed)) .
end
Output
$ lowentc --run main diffing.low
main() = 8
term.diff prev next w out pos compares the previous screen prev with the new screen next and writes into out only the changed runs, as “go there and write this”. If the two screens are the same it is 0 bytes — 0 bytes is exactly the answer “don’t touch the screen”. When “hello” becomes “help!”, it is 8 bytes of cursor movement and two changed characters. Redrawing the whole screen every time flickers, and over remote connections transfers a lot.
37.3 The number of cells is not the number of bytes#
examples/ch37/widths.low
module widths .
rem run: columns [104,105]
rem run: columns [236,149,136,235,133,149]
rem run: emoji_width 128512
rem run: clusters [101,204,129,120]
use term .
proc columns input row slice u8 . output u64 . effects none .
do
let w option u64 be term.row_width row .
guard is_some w . else return 999 .
return some_value w .
end
proc emoji_width input cp u64 . output u64 . effects none .
do
return term.cp_width cp .
end
proc clusters input row slice u8 . output u64 . effects none .
do
let n option u64 be term.row_clusters row .
guard is_some n . else return 999 .
return some_value n .
end
Output
$ lowentc --run columns widths.low [104,105]
columns([104,105]) = 2
arg0 (written) = [104,105]
$ lowentc --run columns widths.low [236,149,136,235,133,149]
columns([236,149,136,235,133,149]) = 4
arg0 (written) = [236,149,136,235,133,149]
$ lowentc --run emoji_width widths.low 128512
emoji_width(128512) = 2
$ lowentc --run clusters widths.low [101,204,129,120]
clusters([101,204,129,120]) = 2
arg0 (written) = [101,204,129,120]
hiis 2 bytes and 2 cells.- “안녕” is 6 bytes, but Korean characters are full-width, so it is 4 cells.
- The smiling face emoji (U+1F600) is 2 cells.
- The 4 bytes of
efollowed by a combining accent (U+0301) and thenxare, to a person, two characters (é, x).row_clusterscounts those clusters.
When aligning or cutting lines in a terminal, counting by bytes or code points misaligns cells for Korean and emoji. row_width, fit_width and cluster_len count by cells and clusters. The width table was extracted from Unicode data like the unicode module (chapter 33).
Q. Doesn’t cell width differ between terminals?
A. It does. East Asian “ambiguous width” characters and new emoji are drawn differently by different terminals. term follows the Unicode standard’s tables and writes that choice and its limits in the module document. Display width has a usable default — 1 even when wrong — so slight misalignment does not collapse the screen, but unicode’s tables judging whether something is a letter have no such default, so extracting them mechanically was essential.
37.4 Reading keys is computation#
examples/ch37/keys.low
module keys .
rem run: first_key [27,91,65,113]
rem run: first_key [113]
use tty .
proc first_key input buf slice u8 . output u64 . effects none .
do
let k option u64 be tty.parse_key buf 0 .
guard is_some k . else return 0 .
let code u64 be tty.key_of (some_value k) .
if eq code (tty.key_up) . do
return 1 .
end
if tty.is_char code . do
return 2 .
end
return 3 .
end
Output
$ lowentc --run first_key keys.low [27,91,65,113]
first_key([27,91,65,113]) = 1
arg0 (written) = [27,91,65,113]
$ lowentc --run first_key keys.low [113]
first_key([113]) = 2
arg0 (written) = [113]
What a terminal sends for the up arrow is the three bytes ESC [ A. tty.parse_key buf 0 reads those bytes as one value holding the key code and the length consumed. tty.key_of extracts the key code and tty.len_of the length. Ordinary characters are their byte value as is (1 … 255), and special keys are placed above 1000 (key_up is 1001). So one value distinguishes “this is a character” from “this is an arrow key”.
Reading a byte sequence as keys is computation, not input/output, so it is effects none and is verified by the VM/native cross-check without a screen. Unknown sequences or too few bytes are none.
37.5 Raw mode is a capability#
The part touching the operating system is only three builtin ops: tty_raw t on (enter and leave raw mode), tty_read t buf (read key bytes) and tty_size t (screen size). All take cap tty as their first argument.
examples/ch37/rawmode.low
module rawmode .
use tty .
proc main input t cap tty . input al cap allocator . output u8 . effects alloc .
do
let g option mut slice u8 be alloc_bytes al capacity 32 .
guard is_some g . else return 1 .
let buf mut slice u8 be some_value g .
guard tty_raw t true . else return 1 .
var going bool be true .
var last u64 be 0 .
while going . do
let n option u64 be tty_read t buf .
guard is_some n . else do
set going false .
continue .
end
let p option u64 be tty.parse_key (subslice buf 0 (some_value n)) 0 .
if is_some p . do
set last (tty.key_of (some_value p)) .
if eq last 113 . do
set going false .
end
end
end
let restored bool be tty_raw t false .
return narrow_sat u8 last .
end
Output
$ lowentc --check rawmode.low
== check: ok ==
- The buffer is obtained before entering raw mode. If allocation failed after entering and it returned, the terminal would be left in raw mode.
- Bytes read with
tty_readare interpreted withtty.parse_key, and pressingqends it. - At the end it always restores with
tty_raw t false.
Raw mode changes the user’s terminal settings. Even if the program dies, the change remains, and in the user’s shell input becomes invisible and line breaks stop working. If such a capability floated around ambiently, any library could wreck someone’s shell. So only its holder exercises it. This example waits for key input, so the verification script only checks it.
A common misconception. Restoring raw mode could just be enforced with ownership too
tty_raw is a builtin op returning a boolean, and there is no owned value representing “in raw mode”. So forgetting to restore is not stopped at translation. It contrasts with outbuf and files enforcing completion through ownership, and for now it is a discipline a person must remember. The module document warns about this in bold too.37.6 What is not built yet#
The tty module document lists these itself — mouse reporting, bracketed paste, interpreting terminal query responses, and screen resize signal (SIGWINCH) integration. Each is a separate piece to build. Because the list is in the source and document, anyone building an editor with this module knows what they must do themselves before importing it.
37.7 Common mistakes#
Counter-example. Creating a path that leaves raw mode without restoring it
examples/ch37/mistake_rawleak.low
module mistake_rawleak .
use tty .
proc main input t cap tty . input al cap allocator . output u8 . effects alloc .
do
guard tty_raw t true . else return 1 .
rem ✘ allocates *after* entering raw mode, and leaves without restoring if that fails
let g option mut slice u8 be alloc_bytes al capacity 32 .
guard is_some g . else return 2 .
let n option u64 be tty_read t (some_value g) .
let restored bool be tty_raw t false .
return 0 .
end
Output
$ lowentc --check mistake_rawleak.low
== check: ok ==
This code passes translation (restoring tty_raw is not enforced by ownership). But if allocation fails, it leaves through return 2 with the terminal still in raw mode. The user’s shell stops echoing input and line breaks go wrong. As rawmode.low in this chapter does, finish every preparation that can fail (getting the buffer) before entering raw mode, and make every path after entering it pass through tty_raw t false.
Counter-example. Cutting a row by byte count to fit the screen width
examples/ch37/mistake_bytecut.low
module mistake_bytecut .
rem run: cut_bytes [236,149,136,235,133,149]
rem run: cut_columns [236,149,136,235,133,149]
use term .
rem ✘ cuts at five bytes --- it splits the second character, leaving invalid text
proc cut_bytes input row slice u8 . output u64 . effects none .
requires ge (len row) 5 .
do
let w option u64 be term.row_width (subslice row 0 5) .
guard is_some w . else return 999 .
return some_value w .
end
rem ask where to cut by columns --- the longest prefix fitting three columns is the 3 bytes of "안"
proc cut_columns input row slice u8 . output u64 . effects none .
do
let n option u64 be term.fit_width row 3 .
guard is_some n . else return 999 .
return some_value n .
end
Output
$ lowentc --run cut_bytes mistake_bytecut.low [236,149,136,235,133,149]
cut_bytes([236,149,136,235,133,149]) = 999
arg0 (written) = [236,149,136,235,133,149]
$ lowentc --run cut_columns mistake_bytecut.low [236,149,136,235,133,149]
cut_columns([236,149,136,235,133,149]) = 3
arg0 (written) = [236,149,136,235,133,149]
Cutting “안녕” at five bytes splits the second character, which is no longer valid text, so row_width returns none (999). Sent to the screen as is, it would have printed broken characters. term.fit_width row 3 gives, as a byte length, the longest prefix that fits in three columns — one “안”, 3 bytes. Always cut by that answer.
A common misconception. The bytes from one read always hold one whole key
examples/ch37/partial_key.low
module partial_key .
rem run: first_key [27,91]
rem run: first_key [27,91,65]
use tty .
rem there is no guarantee that one read holds a whole key
proc first_key input buf slice u8 . output u64 . effects none .
do
let k option u64 be tty.parse_key buf 0 .
guard is_some k . else return 0 .
let code u64 be tty.key_of (some_value k) .
if eq code (tty.key_up) . do
return 1 .
end
return 3 .
end
Output
$ lowentc --run first_key partial_key.low [27,91]
first_key([27,91]) = 0
arg0 (written) = [27,91]
$ lowentc --run first_key partial_key.low [27,91,65]
first_key([27,91,65]) = 1
arg0 (written) = [27,91,65]
The up arrow is the three bytes ESC [ A, but a read may deliver only the first two. tty.parse_key then returns none (0 here), and once the third byte has arrived, it reads up (1). Do not throw the bytes away; prepend the leftover piece to the next read. Discarding none as “unknown key” becomes the bug where arrow keys occasionally vanish.
37.8 This chapter’s syntax at a glance#
| Shape | Meaning | Why |
|---|---|---|
term.goto · term.sgr · term.clear (buf pos …) | assemble control bytes into the caller’s buffer | nothing is written to the screen — test by bytes |
term.diff prev next w out pos | bytes that redraw only the changed runs — 0 if equal | less flicker and less traffic |
term.row_width row · term.row_clusters row · term.cp_width cp | columns · grapheme clusters · code point width | columns are not bytes |
term.fit_width row cols | byte length of the longest prefix fitting the columns | never cut inside a character |
tty.parse_key buf at · tty.key_of · tty.len_of | interpret key bytes (pure) — none when incomplete | reading keys is computation |
tty_raw t true · tty_read t buf · tty_size t | raw mode · read key bytes · screen size | cap tty — always restore raw mode |
term.goto buf 0 2 5 | row and column counted from 0 — the bytes are ESC [ 3 ; 6 H | same base as array indexes; the op adapts to the terminal’s 1-based count |
Table 37.1 — Shapes of the terminal modules — shape · meaning · why it looks this way
Recap
term is a pure module assembling ANSI control bytes into the caller’s buffer, and emitting to the screen is outbuf’s job. diff redraws only changed cells and is 0 bytes when nothing changed. Cell counts differ from byte counts, so count with row_width and row_clusters. tty.parse_key interprets key bytes purely. tty_raw, tty_read and tty_size, which touch the operating system, receive cap tty, and raw mode must always be restored.