Lowent Manual←↑→

37 The terminal — term and tty

What to know first

chapter 33, Text and encodings · fmt assembles into the caller’s buffer and returns the next position
chapter 36, Input/output, networking, time, randomness, cryptography · separate computation from authority

Looking 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

Terminal programs do two things. They build control bytes to send to the screen, and they read the user’s keys. For both, the part touching the operating system is very thin, and the rest is computation. How much of the screen to redraw, how many cells one Korean character takes, which key 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

You will learn that 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

  1. 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]

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]

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 ==

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

A good idea, but this edition does not do so. 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#

ShapeMeaningWhy
term.goto · term.sgr · term.clear (buf pos …)assemble control bytes into the caller’s buffernothing is written to the screen — test by bytes
term.diff prev next w out posbytes that redraw only the changed runs — 0 if equalless flicker and less traffic
term.row_width row · term.row_clusters row · term.cp_width cpcolumns · grapheme clusters · code point widthcolumns are not bytes
term.fit_width row colsbyte length of the longest prefix fitting the columnsnever cut inside a character
tty.parse_key buf at · tty.key_of · tty.len_ofinterpret key bytes (pure) — none when incompletereading keys is computation
tty_raw t true · tty_read t buf · tty_size traw mode · read key bytes · screen sizecap tty — always restore raw mode
term.goto buf 0 2 5row and column counted from 0 — the bytes are ESC [ 3 ; 6 Hsame 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.