Modular Computers the manual GitHub

Terminal

A terminal is a screen and a keyboard in one device, like a VT100 on a serial line. A program writes text to it, with escape sequences to move the cursor and set colors, and reads back the keys typed.

Facts

| | | |—|—| | Type | terminal | | Tiers | 1 (50 by 16, monochrome), 2 (80 by 25, 16 colors), 3 (160 by 50, every color and sixel pictures) | | Interrupt | Raised while keys wait to be read | | Power | 10 W while powered, and a little per write | | Info | tier, width, height, colors |

A tier 3 screen block showing text and a small magenta sixel picture A tier 3 terminal showing text and a sixel picture.

For the block you place and look at, see Screen.

In mcos

A program uses a terminal through its tty, as on Linux: its standard input, output and error, /dev/tty (the terminal of the process that opens it) and /dev/console. The kernel’s line discipline sits in between: it echoes what you type, edits the line, turns Ctrl+C into a signal and \n into \r\n on the way out. A program draws by writing escape sequences to its tty (see What a terminal understands), and reads keys from it, a line at a time or, with the line discipline’s raw mode (stty, TCSETS), as they come. The tty’s ioctls (TCGETS, TCSETS, TIOCGWINSZ for its columns and rows) are in the sys library.

Terminals are grouped into the console: mcos reads the keys typed at any of them and writes to all of them through /dev/console. The console’s size is the largest every terminal in the group can show. See /dev.

Each terminal also has a file of its own, /dev/terminal0 (terminal1 for the next):

local columns, rows, mostColumns, mostRows = sys.ioctl(fd, "getSize")

A write through the file’s ioctl skips the tty: it reaches that one terminal, raw (no \r added), and works from a program with no terminal of its own (a service). Every terminal is in the console’s group, so what the console writes shows there too, and a read through the ioctl races the console for the keys: each key goes to whichever reads first. Read keys through the tty.

Methods

write(text) → nothing

Sends text to the terminal. Escape sequences in it are decoded by the terminal itself. Writes are raw: a line feed only moves the cursor down, unless the program turns on new line mode (ESC[20h), so write \r\n for a new line. Each write costs a little power.

read([max]) → string or nil

Takes the bytes waiting, at most max of them, as a serial line gives them: the keys typed at this terminal (an arrow key is its escape sequence’s bytes among the others) and the terminal’s answers to reports (such as the cursor position). Keys are never split; a read gives at least one key even if it is longer than max. With nothing waiting, it gives nil.

getSize() → columns, rows, maxColumns, maxRows

The size now, then the largest it can be made (in the console’s group, the group’s largest). A program resizes it with xterm’s sequence ESC[8;rows;cols t, up to the largest; ESC[18t reports the size and ESC[19t the largest. resize does it from the shell.

Events

A terminal raises its interrupt while it has bytes waiting. The queue holds at most 256 keys and reports; past that, new keys are dropped. Keys typed while no program runs on the computer are dropped too.

Typing comes from the screen view: only the terminal a player has open gets the keys.

What a terminal understands

All tiers use the same VT decoder, and differ in colors:

Tier Model Colors Answers Device Attributes (ESC[c) with
1 VT102 one phosphor, shown as intensities (bold, normal, reverse) ESC[?6c
2 VT525 the 16 basic colors (others show as the nearest) ESC[?65;22c
3 VT340 every 24-bit color, and sixel pictures (DCS q) ESC[?63;4;22c

It understands the common xterm subset: cursor movement, erasing and editing, scroll regions, SGR (bold, reverse, 16, 256 and 24-bit colors), the alternate screen, the cursor’s shape, device status and cursor position reports, and the kitty keyboard protocol (flags 1, 2 and 8). Anything else is parsed and ignored. A BEL (\a) rings the terminal’s bell at its block.

Every tier reads text as UTF-8, as terminal emulators do today: a program writes UTF-8 bytes, and the terminal shows the characters (a byte that isn’t UTF-8 shows as �). Each character takes the cells its width says, as glibc’s wcwidth gives it (each code point its own width, as xterm’s):

The table is mcos’s /usr/share/wcwidth, so a program that measures text (mcos’s line editing) and the screen agree. Keys typed and pasted reach programs as UTF-8. The DEC character sets work too: ESC ( 0 puts DEC’s line drawing in G0 (ESC ( B back to ASCII, ESC ( A the UK set), ESC ) 0 in G1, and SO (\14) and SI (\15) shift between them; lqqk in line drawing is ┌──┐.

A tier 3 screen's view: box drawing, Chinese and Japanese, an emoji, café spelled two ways, DEC line drawing UTF-8 on a tier 3 screen: wide characters in two cells, an accent combined, DEC’s line drawing.

Box drawing (U+2500 to U+257F), block elements (U+2580 to U+259F) and braille (U+2800 to U+28FF) aren’t drawn from the font: the terminal draws them itself, filling their cells, as terminal emulators do, so lines join from cell to cell (light, heavy, double, dashed, rounded and diagonal), blocks and shades meet, and braille’s dots sit on their grid, for drawings and charts made of text.

Blocks, shades, quadrants and braille; double lines, dashed lines, diagonals and a rounded box Characters the terminal draws itself.

Some sequences a program uses most (ESC is "\27" in Lua):

Sequence Does
ESC[row;colH moves the cursor (from 1;1, the top left)
ESC[2J clears the screen
ESC[K clears to the end of the line
ESC7, ESC8 saves the cursor, puts it back
ESC[0m plain text again
ESC[1m, ESC[7m bold, reverse video
ESC[31m to ESC[37m, ESC[41m to ESC[47m a text color, a background color
ESC[38;2;r;g;bm a 24-bit text color (48 for the background)
ESC[?25l, ESC[?25h hides the cursor, shows it
ESC[?1049h, ESC[?1049l the alternate screen (a full-screen program’s), and back
ESC[6n asks where the cursor is: the answer, ESC[row;colR, comes back as typed

/usr/bin/vtdemo.lua shows every tier’s colors and a picture.

The mouse

In a screen’s view the mouse works as on xterm: nothing reaches a program until it asks, with the same modes, and then each click, release, wheel notch or move (as it asked) comes as typed input, at the cell it was over (columns and rows from 1).

Sequence Reports
ESC[?9h presses only (X10’s), no modifiers
ESC[?1000h presses, releases and the wheel
ESC[?1002h those, and moves while a button is held (drags)
ESC[?1003h those, and every move
ESC[?1006h reports in SGR’s encoding: ESC[<b;x;yM, m for a release
ESC[?…l turns one off; ESC c (a reset) turns them all off

Without 1006, a report is X10’s: ESC[M and three bytes, the button, the column and the row, each 32 past its number (so a position past 223 can’t be reported). The button: 0 left, 1 middle, 2 right (3: a release, in X10’s encoding), 64 and 65 the wheel up and down, plus 32 for a move; Shift adds 4, Alt 8, Control 16. While no program tracks the mouse, the wheel scrolls the screen view back over what scrolled off.

-- Click on the screen: each click (and wheel notch) in the screen view is reported where it was,
-- until q is typed. The program asks the terminal for the mouse as on any xterm: ESC[?1000h
-- (presses and releases), ESC[?1006h (SGR's encoding: ESC[<button;column;rowM, m for a release),
-- and reads them as typed input, raw.
local sys = require("sys")

local saved = io.popen("stty -g"):read("l")
os.execute("stty raw -echo")
io.write("\27[?1000h\27[?1006h", "click anywhere (q quits)\r\n")
io.stdout:flush()

local BUTTONS = {[0] = "left", [1] = "middle", [2] = "right", [64] = "wheel up", [65] = "wheel down"}
local pending = ""
while true do
  pending = pending .. assert(sys.read(0, 64))
  if pending:find("q", 1, true) then
    break
  end
  -- Every whole report in what's come so far.
  for button, column, row, kind in pending:gmatch("\27%[<(%d+);(%d+);(%d+)([Mm])") do
    local what = BUTTONS[tonumber(button) & ~28] or ("button " .. button) -- (28: the modifiers' bits)
    io.write(what, kind == "m" and " released" or "", " at column ", column, ", row ", row, "\r\n")
  end
  pending = pending:match("\27%[<[%d;]*$") or "" -- (keep a report still coming)
  io.stdout:flush()
end

io.write("\27[?1006l\27[?1000l")
os.execute("stty " .. saved)

Lines that scroll off the top of the main screen are kept, 200 of them, and the screen view scrolls back over them. They are lost when the block unloads.

Examples

A clock in the corner of your terminal, drawn with escape sequences written to the tty while you work (start it with &):

-- A clock in the top right corner of the terminal, kept there while you work: lua terminal-clock.lua &
--
-- A program draws on its terminal by writing escape sequences to it, as on any VT100 or xterm:
-- ESC 7 saves the cursor, ESC [ row ; column H moves it, ESC [ 7 m turns on reverse video, ESC [ 0 m
-- turns it off, and ESC 8 puts the cursor back where it was, so whatever you are typing is not
-- disturbed. The terminal's width comes from the tty (TIOCGWINSZ), asked again at each redraw, so
-- the clock follows a resize. It redraws once a second of game time (sys.sleep blocks meanwhile).
local sys = require("sys")

local ESC = "\27"
local EVERY = 1             -- seconds between redraws
local FORMAT = "Day %d %R"  -- os.date's: the world's day and its time, hours and minutes

local TTY = 1  -- standard output: the terminal it was started from
assert(sys.isatty(TTY), "the clock needs a terminal")

while true do
  local columns = sys.ioctl(TTY, "TIOCGWINSZ")
  local text = " " .. os.date(FORMAT) .. " "
  sys.write(TTY, ESC .. "7" .. ESC .. "[1;" .. math.max(1, columns - #text + 1) .. "H"
    .. ESC .. "[7m" .. text .. ESC .. "[0m" .. ESC .. "8")
  sys.sleep(EVERY)
end

The terminals on the bus and their sizes, from each one’s getSize ioctl:

-- List the terminals on the bus, with their size now and the largest each can be made:
-- lua terminal-list.lua
--
-- sys.devices() gives every device the kernel knows, and each terminal has a file in /dev
-- (terminal0, terminal1...). The file has no data of its own (a read says what the device is), but
-- its ioctls are the terminal's methods, so getSize is one call on it.
local sys = require("sys")

print(("%-12s %-9s %-9s %s"):format("NAME", "SIZE", "LARGEST", "LABEL"))
for _, device in ipairs(sys.devices()) do
  if device.type == "terminal" then
    local fd = assert(sys.open("/dev/" .. device.name))
    local columns, rows, mostColumns, mostRows = sys.ioctl(fd, "getSize")
    sys.close(fd)
    if columns then
      print(("%-12s %-9s %-9s %s"):format(device.name, columns .. "x" .. rows,
        mostColumns .. "x" .. mostRows, device.label or "-"))
    else
      io.stderr:write(device.name, ": ", tostring(rows), "\n")  -- (rows holds why it failed)
    end
  end
end

A notice in the middle of a screen, written through the terminal’s own write ioctl:

-- Put up a notice on a screen: lua terminal-sign.lua terminal0 "Back in five minutes"
--
-- The program writes to the terminal itself, through its file's write ioctl (the terminal's
-- method), not through a tty: so it works from a service with no terminal of its own too. A
-- terminal's writes are raw (no tty turns "\n" into "\r\n"), and the program places every line
-- with the cursor-position sequence anyway. It clears the screen, draws a framed box in the middle
-- in bright yellow on blue, and hides the cursor. getSize says how big the screen is.
local sys = require("sys")

local ESC = "\27"
local COLORS = ESC .. "[1;33;44m"  -- bold, yellow text, blue background
local PLAIN = ESC .. "[0m"
local PADDING = 2                  -- spaces between the message and the frame

local name, message = arg[1], arg[2]
if not (name and message) then
  io.stderr:write("usage: terminal-sign.lua TERMINAL MESSAGE\n")
  os.exit(2)
end

local fd = assert(sys.open("/dev/" .. name))
local columns, rows = sys.ioctl(fd, "getSize")
assert(columns, rows)

local width = math.min(#message + 2 * PADDING + 2, columns)
message = message:sub(1, width - 2 - 2 * PADDING)
local inner = width - 2
local top = (rows - 3) // 2 + 1
local left = (columns - width) // 2 + 1

local function at(row, text)
  return ESC .. "[" .. row .. ";" .. left .. "H" .. text
end

local edge = "+" .. ("-"):rep(inner) .. "+"
local middle = "|" .. (" "):rep((inner - #message) // 2) .. message
middle = middle .. (" "):rep(inner + 1 - #middle) .. "|"

assert(sys.ioctl(fd, "write", ESC .. "[2J" .. ESC .. "[?25l" .. COLORS
  .. at(top, edge) .. at(top + 1, middle) .. at(top + 2, edge) .. PLAIN))
sys.close(fd)