sysl

sysl.term

The escape sequences a terminal understands — colour, emphasis, and the screen — as constants a program with no allocator can still name.

sysl.term is forty-odd const strings and nothing else. Each one is an ANSI escape sequence, and writing one into the output stream is how a terminal is told to change colour, start underlining, or clear itself.

import sysl.term.*

print(f"${red}${bold}error${reset}: the file was not there")

A sequence is written where the text it affects is written, because that is what it is — a mark in the stream rather than a property of a string. There is no coloured-string type here and nothing to wrap: red is text that happens to be invisible, it concatenates like any other text, and reset is how you stop.

Why constants, and what that buys

A string literal is immortal — it lives in the program’s own image with no owner and no reference count — so naming forty of them costs nothing at run time and nothing in storage. That is what lets this module declare @no_alloc, and it is the point of the whole design: colouring a line is exactly what a program that has given up its allocator most wants to do, and a facility such a program could not use would be no facility at all.

@no_alloc
@no_os

import sysl.term.*

main()
    print(f"${green}ok${reset}")

The module requires no capability at all, so an interrupt handler can name a colour.

The colours

Eight, each with a bright variant and a background form. The arithmetic between them is the specification rather than a coincidence: a background is its foreground plus ten, and a bright colour is its ordinary one plus sixty.

import sysl.term.*

// An escape is invisible, so this reads the parameter back out of one.
code(s: string) -> int
    var n = 0

    for b in s.bytes
        if b >= u8('0') && b <= u8('9') then n = n * 10 + int(b - u8('0'))

    n

main()
    print(code(red), code(bright_red), code(on_red), code(on_bright_red))
31 91 41 101
foregroundbrightbackgroundbright background
blackbright_blackon_blackon_bright_black
redbright_redon_redon_bright_red
greenbright_greenon_greenon_bright_green
yellowbright_yellowon_yellowon_bright_yellow
bluebright_blueon_blueon_bright_blue
magentabright_magentaon_magentaon_bright_magenta
cyanbright_cyanon_cyanon_bright_cyan
whitebright_whiteon_whiteon_bright_white

default_color and on_default put each back to whatever the terminal was using.

Emphasis, and why reset is not enough

namewhat it does
boldheavier, or brighter on a terminal with no bold face
dimfainter
italicslanted, where the terminal has it
underlineunderlined
blinkblinking, where the terminal allows it
reverseforeground and background swapped
hiddennot shown, but still selectable
strikestruck through
resetall of the above, and the colours, off at once

ANSI has no way to end one attribute and leave the others — reset ends everything there is. So a program that wants its colour back without losing an emphasis writes default_color rather than reset, and one that has ended a colour inside an underlined field has to open the underline again afterwards.

The screen and the cursor

namewhat it does
clear_screen / clear_linethe whole screen, the whole line — neither moves the cursor
clear_below / clear_to_line_endfrom the cursor onwards
homethe top left corner
hide_cursor / show_cursora program that hides it owns showing it again, including on the way out
save_cursor / restore_cursorone remembered position, the terminal’s own — these do not nest

clear_screen is nearly always written with home after it, since clearing does not move anything.

Whether to write escapes at all — sysl.posix.tty

Naming a colour and deciding to use one are different questions, and they live in different modules. Everything above asks for no capability at all, so an allocator-free, OS-free program can reach it. Asking whether output is a terminal needs isatty, which needs posix — and a capability requirement is module-wide, so one function here would have taken all forty constants away from the programs this module is arranged for. The answer sits in sysl.posix instead, and the split shows up in the import, which is honest about what the second one costs.

It is sysl.posix.tty rather than sysl.term.tty, and the namespace is the point. Everything under sysl.posix requires that one capability, so a freestanding target reaches none of it — which is now visible in the import line rather than only in the module’s own header. What this needs is isatty(3) and termios, so that is where it belongs, however much it reads as terminal handling.

nameanswers
is_tty(fd)is this descriptor a terminal?
color_wanted()does the environment want colour — NO_COLOR unset or empty, TERM not dumb?
color_on(fd)both, for one descriptor
color() / color_err()both, for standard output and standard error

Ask once and keep the answer. Each of these is a system call or an environment scan, and nothing a running program does changes what they say.

import sysl.term.{red, reset}
import sysl.posix.tty.color

main()
    val paint = color()
    val on    = if paint then red else ""
    val off   = if paint then reset else ""

    print(f"${on}error${off}: not found")
error: not found

That output is the point rather than an accident: this page’s programs run with their output captured, so color() answers false and the escapes are never written — which is exactly what the same program does in a pipeline or redirected to a file.

is_tty is worth having alone: a progress bar, a spinner and a prompt are all worth suppressing when output is a pipe, and none of them is about colour. So is color_wanted — a --color=always flag overrides the descriptor without overriding the user’s NO_COLOR, and that is exactly this function.

NO_COLOR is about the variable being there rather than about its value. Present and non-empty turns colour off whatever it contains, so NO_COLOR=0 means no colour, while set-and-empty does not. A program reading it as a boolean and looking for "1" has misread the convention.

Taking the terminal over — sysl.posix.tty.raw

A terminal at a shell is in cooked mode: the kernel’s line discipline echoes what is typed, honours backspace, and hands the program a whole line at Enter. That is why sysl.io.console_lines is all a hosted program usually needs — something else is doing the editing.

raw() puts that out of the way, so a program sees each keystroke as it is typed.

namedoes
raw()cbreak mode — keystrokes arrive as typed, nothing is echoed. Answers whether it worked
cooked()puts back what raw changed, and only that
flush()pushes out what C is holding — a prompt with no newline after it
tty_writer()standard output as a sink that flushes what it is given

raw() answering false is not an error — it is the other situation. With input redirected from a file or a pipe there is no terminal to change, and an editor is the wrong facility anyway: nothing is being typed and nothing should be echoed. So a program picks its reader from the answer, and prog < script.txt goes on working.

import sysl.io.{stdin, console_lines}
import sysl.posix.tty.{raw, cooked}

main()
    var input = stdin()

    if raw()
        print("a terminal")
        cooked()
    else
        var cursor = console_lines(&input)

        print("a pipe")
a pipe

That output is the point rather than an accident, exactly as above: this page’s programs run with their input closed, so raw() declines and the cooked path is what runs.

What it sets, and the one thing it gives up

-icanon -echo -isig opost onlcr. Output translation is asserted rather than assumed — nothing here turns it off, so leaving it out looked safe, and a terminal that arrives without it makes every print stair-step down the screen while the editor’s own output looks fine.

Signals go, and that is a choice rather than a limitation. Leaving isig alone would keep Ctrl-C interrupting, which reads like a feature for a REPL. It used not to be available at all: a program interrupted in cbreak mode must restore the terminal from a signal handler, and restoring meant allocating a command string and forking a shell, neither of which is async-signal-safe — so the handler deadlocked rather than tidying up. That was a fact about stty, and restoring is now one tcsetattr on a saved struct, which POSIX lists as async-signal-safe. The handler is still not written, because -isig means there is no signal to catch and every exit is an ordinary one. Ctrl-C arrives as byte 3 for the editor instead.

What that costs is worth saying plainly: a program that has stopped responding can no longer be interrupted from its own terminal, and the escape is kill from another one. What it buys is that the terminal is never left broken, and that a hosted program behaves exactly like one on a board — which never had signals to disable.

It is termios, through a shim, and it used to be stty through system. struct termios is caller-allocated and laid out differently on every platform, which is the transcription the library refuses — so the structure stays in C and a file descriptor is all that crosses. The shim sits in a per-OS directory (modules), which is what keeps a #include <termios.h> away from a target that has no terminal to configure.

Three things follow: no shell is forked to set two flags; cooked restores what was actually there, from a saved struct, where naming icanon echo isig to put back would restore a different terminal from the one it found; and it works when standard input is not the shell’s, since the shim is handed a descriptor where stty acted on whatever it inherited.

Reading a line — sysl.term.edit

The other half of what a console needs, and the reason it exists: a terminal with no line discipline gives a program nothing. Over a serial cable there is none at all; at a hosted terminal raw() has just removed it. Either way nothing appears as it is typed and a mistake cannot be corrected — which is not a program that reads badly but a program that looks broken.

editor(r, w) is a line editor over a *Reader and a *Writer. It answers whole lines through Iterate[string], the same as sysl.io.lines and console_lines, so the three are interchangeable at a call site and a program chooses by what is producing its input.

import sysl.io.{bytes_reader, bytes_writer}
import sysl.term.edit.editor

main()
    var typed = bytes_reader("one\rtwo\r".bytes)
    var echo  = bytes_writer()
    var ed    = editor(&typed, &echo)

    for line in ed
        print(line)
one
two
keysdo
Home Endmove within the line — and Ctrl-A / Ctrl-E / Ctrl-B / Ctrl-F
Backspace Deleteat the cursor, not only at the end
Ctrl-U Ctrl-Kkill the line, or from the cursor on
the last 64 lines — and Ctrl-P / Ctrl-N
Ctrl-Cabandon the line and answer an empty one
Ctrl-Dend the input, on an empty line only

The line is held as characters and measured in columns. A cursor is an index rather than a byte offset, so a half character can never be left behind by a backspace — one never enters the line. And a wide character occupies two columns, so erasing a CJK character or an emoji clears both; an editor counting characters leaves half of one on the screen.

Both spellings of an arrow key are read. ESC [ D is CSI and ESC O D is SS3, and a terminal chooses between them by whether application cursor key mode is on. Reading only the first is not a simplification — it means a left arrow inserts a stray D into the line.

What it is not

There is no completion, no multi-line editing and no absolute cursor addressing. A program wanting those wants linenoise. A line that wraps past the terminal’s width redraws wrong, which is the honest cost of moving the cursor by writing \b: it stops at column zero rather than climbing to the row above.

It asks for nothing of the platform — no capability, no C — which is what lets the same program run at a terminal and over a cable. It does allocate: the line is a Buf[char] and the answer is a string.

The prompt, and why the editor pokes its sink

The editor prints no prompt and is not told one. Every movement it makes is relative, so it never needs to know how far along the row the line starts, and a caller goes on printing its own prompt — which is what makes a REPL’s continuation prompt the caller’s business rather than a field here.

What it does do is hand its sink a zero-length write before waiting for a keystroke. A hosted sink buffers — putbytes goes through C’s putchar, which line-buffers a terminal — so a prompt with no newline after it would sit in the buffer until something wrote one, which is one keystroke too late. The poke gives a buffering sink its chance, and keeps the obligation off every caller that prints a prompt. A board pays nothing for it: a sink with no buffer writes no bytes.

What is deliberately not here

Anything that takes a number. Moving the cursor to a row and column means building ESC [ row ; col H, and building a string is an allocation — the one thing the module is arranged to avoid. The sequences above are the ones whose text is fixed; a program that wants the others writes f"\u{1b}[${row};${col}H" and knows what it is spending.

Search

Esc
to navigate to open Esc to close