terminal
stdlib/extra/terminal.hk: low-level terminal control, escape builders, colors and styles, raw mode, and the full-screen pair.
The face pattern (like io over the stdio seam): everything here is pure apart from the thin [io] delegations at the bottom. An escape builder returns the VT/xterm control string, which leaves styling and cursor math doctestable with no terminal at all. The native seam underneath is sys.term_*! / sys.*_is_tty! (HANKI.md §17), whose exit-restore guarantee this face leans on: enter_full_screen! registers its undo bytes with sys.term_restore_write!, and even a crashed program leaves the user's terminal usable.
Model: one actor owns the terminal (single-owner, like a SqliteConn), interleaved writers corrupting escape state. Build frames as List<string> joined once ("".join(parts)), never by repeated string concatenation (quadratic).
_escape
def _escape() -> string
The ESC byte (0x1B) as a one-character string. Hanki string literals have no control-character escapes, and it is materialised from the byte.
controlsequence
def _control_sequence() -> string
ESC [, the Control Sequence Introducer every builder starts with.
move_to
def move_to(column: int, row: int) -> string
Move the cursor to (column, row), both zero-based (the ANSI wire form is 1-based row;column H; the conversion sits here to let callers count from zero like every other Hanki index).
move_to(2, 4) == "#{_control_sequence()}5;3H" => true
move_to(0, 0) == "#{_control_sequence()}1;1H" => true
cursor_hide
def cursor_hide() -> string
Hide the cursor (CSI ?25l).
cursor_hide() == "#{_control_sequence()}?25l" => true
cursor_show
def cursor_show() -> string
Show the cursor (CSI ?25h).
cursor_show() == "#{_control_sequence()}?25h" => true
clear_screen
def clear_screen() -> string
Clear the whole screen (CSI 2J; the cursor does not move, and a fresh frame therefore pairs this with move_to(0, 0)).
clear_screen() == "#{_control_sequence()}2J" => true
clear_line
def clear_line() -> string
Clear the cursor's whole line (CSI 2K).
clear_line() == "#{_control_sequence()}2K" => true
cleartoline_end
def clear_to_line_end() -> string
Clear from the cursor to the end of its line (CSI 0K).
clear_to_line_end() == "#{_control_sequence()}0K" => true
enteralternatescreen
def enter_alternate_screen() -> string
Switch to the alternate screen buffer (CSI ?1049h), the full-screen surface that restores the user's scrollback on exit.
enter_alternate_screen() == "#{_control_sequence()}?1049h" => true
exitalternatescreen
def exit_alternate_screen() -> string
Return from the alternate screen buffer (CSI ?1049l).
exit_alternate_screen() == "#{_control_sequence()}?1049l" => true
enablebracketedpaste
def enable_bracketed_paste() -> string
Enable bracketed paste (CSI ?2004h): the terminal then wraps a paste in ESC[200~ … ESC[201~, and [decode] therefore delivers it as one [TermEvent].Paste and no storm of key presses. enter_full_screen! turns this on for the session (and registers the off sequence as a crash-restore byte).
enable_bracketed_paste() == "#{_control_sequence()}?2004h" => true
disablebracketedpaste
def disable_bracketed_paste() -> string
Disable bracketed paste (CSI ?2004l).
disable_bracketed_paste() == "#{_control_sequence()}?2004l" => true
enable_mouse
def enable_mouse() -> string
Enable mouse tracking: any-motion reporting (CSI ?1002h) plus SGR-1006 extended coordinates (CSI ?1006h), and [decode] delivers each action as one [TermEvent].Mouse. enter_full_screen! turns this on for the session (and registers the off sequence as a crash-restore byte).
enable_mouse() == "#{_control_sequence()}?1002h#{_control_sequence()}?1006h" => true
disable_mouse
def disable_mouse() -> string
Disable mouse tracking (CSI ?1006l then CSI ?1002l).
disable_mouse() == "#{_control_sequence()}?1006l#{_control_sequence()}?1002l" => true
reset
def reset() -> string
Reset all text attributes (SGR 0).
reset() == "#{_control_sequence()}0m" => true
Color
type Color
Default
Black
Red
Green
Yellow
Blue
Magenta
Cyan
White
BrightBlack
BrightRed
BrightGreen
BrightYellow
BrightBlue
BrightMagenta
BrightCyan
BrightWhite
Indexed(u8)
Rgb(u8, u8, u8)
end
A terminal color. Face-owned pure data (no runtime tag coupling): the sixteen named ANSI colors, the 256-color palette (Indexed), and 24-bit truecolor (Rgb).
Style
struct Style
fg: Color
bg: Color
bold: bool
dim: bool
italic: bool
underline: bool
reversed: bool
strikethrough: bool
end
A text style: foreground, background, and the boolean attributes. Keyword construction requires every field, which leaves the chain as the ergonomic path: Style.new().fg(Red).bold().
impl Style
new
def new() -> Style
The default style: default colors, no attributes.
sgr(Style.new()) == "#{_control_sequence()}0m" => true
fg
def fg(self, c: Color) -> Style
This style with the foreground set.
sgr(Style.new().fg(Red)) == "#{_control_sequence()}0;31m" => true
bg
def bg(self, c: Color) -> Style
This style with the background set.
sgr(Style.new().bg(Blue)) == "#{_control_sequence()}0;44m" => true
bold
def bold(self) -> Style
This style, bold.
sgr(Style.new().bold()) == "#{_control_sequence()}0;1m" => true
dim
def dim(self) -> Style
This style, dim.
sgr(Style.new().dim()) == "#{_control_sequence()}0;2m" => true
italic
def italic(self) -> Style
This style, italic.
sgr(Style.new().italic()) == "#{_control_sequence()}0;3m" => true
underline
def underline(self) -> Style
This style, underlined.
sgr(Style.new().underline()) == "#{_control_sequence()}0;4m" => true
reversed
def reversed(self) -> Style
This style with foreground and background swapped by the terminal.
sgr(Style.new().reversed()) == "#{_control_sequence()}0;7m" => true
strikethrough
def strikethrough(self) -> Style
This style, struck through.
sgr(Style.new().strikethrough()) == "#{_control_sequence()}0;9m" => true
colorcode
def _color_code(c: Color, base: int) -> Option<string>
SGR color parameter for c at base (30 = foreground, 40 = background; a background code is its foreground code + 10). The eight standard colors are base + 0..7, the bright variants base + 60..67 (fg 90-97 / bg 100-107), and the 256-color / truecolor forms take the base + 8 prefix (fg 38;… / bg 48;…). Default emits no code (None), leaning on the leading 0 reset in sgr.
fgcode
def _fg_code(c: Color) -> Option<string>
The SGR parameter for a foreground color (base 30).
bgcode
def _bg_code(c: Color) -> Option<string>
The SGR parameter for a background color (base 40, the foreground codes + 10).
sgr
def sgr(style: Style) -> string
The SGR sequence for style. Always begins from 0 (full reset), which leaves the sequence self-contained: absent attributes are cleared and never inherited from whatever was on screen.
sgr(Style.new().fg(Red).bg(Blue).bold()) == "#{_control_sequence()}0;1;31;44m" => true
sgr(Style.new().fg(Indexed(208u8))) == "#{_control_sequence()}0;38;5;208m" => true
sgr(Style.new().fg(Rgb(1u8, 2u8, 3u8))) == "#{_control_sequence()}0;38;2;1;2;3m" => true
styled
def styled(s: string, style: Style) -> string
s rendered in style, reset afterwards: the everyday styled print.
styled("hi", Style.new().bold()) == "#{sgr(Style.new().bold())}hi#{reset()}" => true
stdinistty!
def stdin_is_tty!() -> bool [io]
Whether stdin is a terminal. @no-doctest: answers for the live process; environment-dependent
stdoutistty!
def stdout_is_tty!() -> bool [io]
Whether stdout is a terminal. @no-doctest: answers for the live process; environment-dependent
size!
def size!() -> Result<sys.TermSize, sys.TermError> [io]
The terminal size of stdout. @no-doctest: queries the live terminal; environment-dependent
enablerawmode!
def enable_raw_mode!() -> Result<(), sys.TermError> [io]
Enable raw mode (character-at-a-time input, no echo). The runtime restores the terminal on every exit path (sys.term_set_raw!'s guarantee). @no-doctest: mutates the live terminal; needs a real tty
disablerawmode!
def disable_raw_mode!() -> Result<(), sys.TermError> [io]
Disable raw mode, restoring the saved terminal state. @no-doctest: mutates the live terminal; needs a real tty
write!
def write!(s: string) -> () [io]
Write a string to stdout as-is (no newline). A TUI's frame writer, for which use terminal is the only import needed. @no-doctest: writes to stdout; side-effecting
enterfullscreen!
def enter_full_screen!() -> Result<(), sys.TermError> [io]
The full-screen entry every TUI calls: raw mode + alternate screen + hidden cursor, with the undo bytes (leave alt screen, show cursor) registered through sys.term_restore_write! first, which leaves the terminal usable after a crash between these steps or anywhere later. @no-doctest: mutates the live terminal; needs a real tty
leavefullscreen!
def leave_full_screen!() -> Result<(), sys.TermError> [io]
Leave full-screen mode: back to the main buffer, cursor shown, raw mode off, and the exit registration cleared (the orderly path; the registered bytes only matter for crashes). @no-doctest: mutates the live terminal; needs a real tty
rangescontain?
def _ranges_contain?(table: List<int>, cp: int) -> bool
Whether cp falls in one of table's flattened inclusive [start, end] pairs (binary search over the pair starts).
_Scalar
struct _Scalar
code_point: int
width: int
end
The first Unicode scalar at byte offset p of input: its code point and encoded width (input is a valid Hanki string's bytes).
scalarat
def _scalar_at(input: bytes, p: int) -> _Scalar
digitlesscont
def _digitless_cont(input: bytes, i: int) -> int
byteint
def _byte_int(b: u8) -> int
scalarcell_width
def _scalar_cell_width(cp: int) -> int
The terminal cell width of one scalar: 0 for combining marks, format characters, default-ignorables and controls; 2 for East-Asian Wide / Fullwidth and emoji-presentation; 1 otherwise.
_control?
def _control?(cp: int) -> bool
Whether cp is a C0 or C1 control. Controls score 0 like a combining mark does, and they break a cluster in place of joining one: a caller that drops controls before printing must still see them on their own.
emojimodifier?
def _emoji_modifier?(cp: int) -> bool
Whether cp is an emoji modifier (a skin tone), which follows the emoji it modifies and adds no cells.
regionalindicator?
def _regional_indicator?(cp: int) -> bool
Whether cp is a regional indicator: the letters a flag is spelled with, two at a time.
clusterbyte_length
def _cluster_byte_length(input: bytes, p: int) -> int
How many bytes the grapheme cluster starting at byte offset p spans. Absorbs, each adding no cells of its own: a zero-width scalar that is not a control (a combining mark, a variation selector, a ZWJ), whatever follows a ZWJ, an emoji modifier, and the second of a regional-indicator pair. A cluster's width is therefore its first scalar's width.
display_width
def display_width(s: string) -> int
The number of terminal cells s occupies: the sum of its grapheme clusters' widths (see the provenance block above for the width classes, and clusters for the joining rules).
display_width("hello") => 5
display_width("日本") => 4
display_width("héllo") => 5
display_width("") => 0
clusters
def clusters(s: string) -> List<string>
s split into the grapheme clusters a terminal draws as single glyphs: the seam a cell-placing caller walks, one cluster occupying one cell position. first_width gives a cluster's width, everything a cluster joins after its first scalar adding no cells.
Joined: combining marks and variation selectors, a ZWJ sequence (a family emoji), an emoji modifier (a skin tone), and a regional-indicator pair (a flag). Not joined: a control, which remains on its own for a caller to drop. The known residual is a keycap sequence, which measures 1 where most terminals draw 2.
clusters("héllo").length => 5
clusters("") => []
first_width
def first_width(s: string) -> int
The cell width of s's first scalar (0 for the empty string), and so also the width of a whole cluster handed to it, which is how a caller walking clusters measures each one.
first_width("日x") => 2
first_width("x日") => 1
first_width("") => 0
zerowidth_ranges
def _zero_width_ranges() -> List<int>
wideranges
def _wide_ranges() -> List<int>
cpstring
def _cp_string(cp: int) -> string
One code point as a one-scalar string (the core to_char_string seam).
Key
type Key
Char(string)
Enter
Tab
Backspace
Escape
Up
Down
Left
Right
Home
End
PageUp
PageDown
Insert
Delete
Function(u8)
end
One key. Char is one Unicode scalar as a string. (End is an ordinary uppercase identifier, distinct from the lowercase end keyword; probed before this relied on it.)
KeyEvent
struct KeyEvent
key: Key
ctrl: bool
alt: bool
shift: bool
end
A decoded key press with its modifiers. Modifier reporting is best-effort: terminals only transmit modifiers for some keys (arrows and tilde sequences bring them; a plain letter never reports shift, the shifted character arriving in its place).
MouseButton
type MouseButton
MouseLeft
MouseMiddle
MouseRight
end
Which mouse button an event concerns. A wheel scroll reports no button. The Mouse prefix separates these from Key's arrow variants, which share the module namespace.
MouseKind
type MouseKind
Press(MouseButton)
Release(MouseButton)
Drag(MouseButton)
Moved
ScrollUp
ScrollDown
ScrollLeft
ScrollRight
end
What a mouse event is: a button Press/Release, a Drag (motion with a button held), plain Moved (motion, no button), or a wheel Scroll*. Mirrors crossterm/ratatui's kinds (named Press/Release and never Down/Up, which Key already owns in this module).
MouseEvent
struct MouseEvent
kind: MouseKind
column: int
row: int
ctrl: bool
alt: bool
shift: bool
end
A decoded mouse event: what happened, at a ZERO-indexed cell (column, row) matching the buffer's coordinate convention, with best-effort modifiers. Reported only after mouse tracking is enabled (a separate [io] step) and the terminal sends SGR-1006 sequences.
TermEvent
type TermEvent
Key(KeyEvent)
Paste(string)
Mouse(MouseEvent)
end
A decoded terminal input event. Key is a key press (the overwhelmingly common case); Paste is the text of one bracketed-paste burst (ESC[200~ … ESC[201~) as a single event, which delivers a pasted newline or control character as literal text and never as the corresponding key; Mouse is one SGR-1006 mouse report. Recognising a paste or a mouse report needs no terminal state (decode spots the sequence in the byte stream); telling the terminal to send them is a separate [io] step.
Decoded
struct Decoded
events: List<TermEvent>
rest: bytes
end
The result of one incremental [decode] pass: the complete events, and the incomplete trailing bytes to prepend to the next chunk. A trailing lone ESC remains in rest (it may open a sequence); read_key!'s follow-up timeout is what turns silence after it into the Escape key. An unterminated bracketed paste likewise remains in rest until its ESC[201~ closer arrives.
_KeyStep
struct _KeyStep
event: Option<KeyEvent>
width: int
incomplete: bool
end
The step of one decode: the event (if the bytes formed one), how many bytes were consumed, and whether the input ended mid-sequence.
_step
def _step(event: Option<KeyEvent>, width: int) -> _KeyStep
incompletestep
def _incomplete_step() -> _KeyStep
_plain
def _plain(k: Key) -> KeyEvent
decode
def decode(input: bytes) -> Decoded
Decode as many complete key events as input contains, returning the incomplete tail in rest. Malformed or unrecognised sequences are consumed and dropped (documented): the decoder never desyncs and never fails. Feed it anything.
d = decode("hi".to_bytes())
d.events.length => 2
d.rest.length => 0
pasteopen
def _paste_open() -> bytes
The xterm bracketed-paste brackets: the terminal, once told to (a separate [io] step), wraps a paste in ESC[200~ … ESC[201~ so a pasted control character arrives as literal text and never as the key it spells.
pasteclose
def _paste_close() -> bytes
matchesat?
def _matches_at?(input: bytes, p: int, marker: bytes) -> bool
Does marker occur in input starting at p? False when input is too short to hold it there.
isatpastestart?
def _is_at_paste_start?(input: bytes, p: int) -> bool
_PasteScan
struct _PasteScan
event: Option<string>
width: int
pending: bool
end
The scan of a bracketed paste whose opener sits at p, in the same shape as [_KeyStep]: event is the pasted text (None = the body was not valid UTF-8, dropped per the never-desync rule), width the bytes to consume, pending that the ESC[201~ closer has not arrived (park the whole burst in rest).
scanpaste
def _scan_paste(input: bytes, p: int) -> _PasteScan
Scan from the paste opener at p for its closer, returning the enclosed text. Bracketed paste guarantees the body contains no closer, and the first ESC[201~ found ends the burst.
mouseopen
def _mouse_open() -> bytes
The SGR-1006 mouse-report opener, ESC [ <. Once mouse tracking is enabled (a separate [io] step) the terminal reports each action as ESC[<b;x;y followed by M (press/motion) or m (release).
atmouse_start?
def _at_mouse_start?(input: bytes, p: int) -> bool
_MouseScan
struct _MouseScan
event: Option<MouseEvent>
width: int
pending: bool
end
The scan of an SGR-1006 mouse report whose opener sits at p, in the same shape as [_PasteScan]: event is the decoded event (None = a malformed body, dropped per the never-desync rule), width the bytes consumed, pending that the terminating M/m has not arrived yet (park in rest).
scanmouse
def _scan_mouse(input: bytes, p: int) -> _MouseScan
mouseevent
def _mouse_event(cb: int, cx: int, cy: int, press: bool) -> MouseEvent
Build a MouseEvent from the SGR-1006 cb code, the 1-indexed cx/cy, and whether the terminator was M (press) or m (release). cb packs the button in its low 2 bits, shift/alt/ctrl in bits 2/3/4, a motion flag in bit 5, and a wheel flag in bit 6; coordinates report to zero-indexed cells.
mousebutton
def _mouse_button(low: int) -> MouseButton
scrollkind
def _scroll_kind(low: int) -> MouseKind
decodeone
def _decode_one(input: bytes, p: int) -> _KeyStep
Decode ONE event at p (dispatch on the first byte).
decodeplain
def _decode_plain(input: bytes, p: int, b: u8, alt: bool) -> _KeyStep
A non-ESC byte: C0 control or UTF-8 character. alt rides through from an ESC prefix.
_mod
def _mod(k: Key, ctrl: bool, alt: bool, shift: bool) -> KeyEvent
decodeutf8
def _decode_utf8(input: bytes, p: int, b: u8, alt: bool) -> _KeyStep
One UTF-8 scalar starting at p (lead byte b >= 0x20). An incomplete multi-byte tail is incomplete; an invalid sequence drops one byte (the never-desync rule).
decodeescape
def _decode_escape(input: bytes, p: int) -> _KeyStep
An ESC at p: CSI, SS3, or an alt-modified key. A lone trailing ESC is incomplete (see [Decoded]).
decodess3
def _decode_ss3(input: bytes, p: int) -> _KeyStep
ESC O x - SS3: F1-F4, application-mode arrows, Home/End.
decodecsi
def _decode_csi(input: bytes, p: int) -> _KeyStep
ESC [ params final, a CSI. Parameters are decimal, ;-separated; the final byte is 0x40..0x7e. The second parameter (when present) is the xterm modifier code: value - 1 is a bitfield of shift(1) alt(2) ctrl(4).
tildekey
def _tilde_key(n: int) -> Option<Key>
The CSI n ~ table: navigation plus the tilde-form function keys.
csiparams
def _csi_params(input: bytes, from: int, stop: int) -> List<int>
The decimal parameters between from and stop (exclusive), split on ;; an empty or absent parameter takes its xterm default at the use site (unwrap_or there).
digitint
def _digit_int(b: u8) -> int
One decimal digit byte as a lowercase int (the CSI parameter tier).
KeyRead
type KeyRead
Pressed(KeyEvent)
Pasted(string)
Moused(MouseEvent)
TimedOut
Eof
Failed(string)
end
One read_key! outcome. (Pressed and never Key: a variant sharing the Key type's name would collide in the value namespace.) Pasted is one bracketed-paste burst as text, reachable only once the terminal is emitting the brackets (enter_full_screen! / enable_bracketed_paste).
escapefollowupms
def _escape_follow_up_ms() -> i32
How long read_key! waits after a lone ESC before deciding it was the Escape key and no start of a sequence (the crossterm-precedent disambiguation window; sequences arrive in one burst, humans do not type [ in 25ms).
read_key!
def read_key!(timeout_ms: i32) -> KeyRead [io]
Read one key from stdin, waiting up to timeout_ms for the first byte (< 0 waits indefinitely, 0 polls). Reads byte-at-a-time through sys.stdin_read!, which never over-reads (stateless across calls); after a lone ESC a short follow-up window (_escape_follow_up_ms) disambiguates the Escape key from a sequence start. An unrecognised complete sequence is dropped and the read continues; a sequence left incomplete at the follow-up timeout is dropped and reports TimedOut (unreachable at human input rates). Bulk consumers should decode chunk reads instead. @no-doctest: reads live stdin; environment-dependent
Ticker
struct Ticker
label: string
total: int
done: int
columns: int
fancy: bool
end
A run of work reporting itself on one line. ticker_start! fills columns and fancy from the live terminal; the rest is the caller's.
defaultcolumns
def _default_columns() -> int
The width assumed when the terminal will not say: the conventional default, and only ever a drawing choice, never a correctness one.
barcells
def _bar_cells() -> int
How many cells the bar occupies. Fixed and never a fraction of the width: a bar that resizes with the terminal is harder to read than a short one, and the item name is what deserves the leftover room.
_bar
def _bar(done: int, total: int, cells: int) -> string
The bar for done of total in cells cells. A total of zero has no fraction to draw and comes out full, since a run with no steps is a finished one and no unstarted one.
_clip
def _clip(s: string, cells: int) -> string
s cut to at most cells terminal cells, walking scalars so a multi-byte character is never halved and a wide one is never miscounted as narrow. A budget of zero or less yields "".
ticker_line
def ticker_line(t: Ticker, item: string) -> string
The line a ticker draws for item: what reaches stdout, minus the control bytes wrapped around it. Pure, which leaves a tool's progress output testable with no terminal anywhere near it.
A fancy ticker gets the bar, the count, and as much of item as the captured width leaves. The final cell is left free by design: writing into a terminal's last column is what makes the line wrap, and a wrapped line is one a carriage return can no longer rewrite. An unfancy ticker gets the same facts with no escapes and no truncation, a log file having neither a width nor a cursor to respect.
t = Ticker(label="upload", total=38, done=12, columns=80, fancy=true)
ticker_line(t, "style.css") => "upload [======--------------] 12/38 style.css"
ticker_line(Ticker(label="", total=2, done=2, columns=80, fancy=true), "ok") => "[====================] 2/2 ok"
ticker_line(Ticker(label="upload", total=38, done=12, columns=80, fancy=false), "style.css") => "upload [12/38] style.css"
ticker_start!
def ticker_start!(label: string, total: int) -> Ticker [io]
Begin a run of total steps, reading the terminal once. Draws nothing: the first line appears on the first ticker_step!, and a run that turns out to have no work to do leaves no trace at all. @no-doctest: reads the live terminal; environment-dependent
ticker_step!
def ticker_step!(t: Ticker, item: string) -> Ticker [io]
Advance one step and draw item as the work now in flight, returning the ticker to pass to the next call.
The name is drawn ahead of the work it names and never after, which leaves a run that dies showing the failing step and no earlier one that succeeded. That is why an operator watches this line. @no-doctest: writes to stdout; side-effecting
ticker_done!
def ticker_done!(t: Ticker, summary: string) -> () [io]
End the run: clear the live line and leave summary in its place, which gives the scrollback the durable sentence and no bar frozen at full. Safe to call on a ticker that never stepped. @no-doctest: writes to stdout; side-effecting
_cat
def _cat(parts: List<bytes>) -> bytes
The pieces fused with no separator - join's empty-separator case, named once because this module assembles a lot of escape sequences from parts. bytes has no concat: a chain of them copies everything to the left of each link again; join fuses the whole list at once for that reason.
escseq
def _esc_seq(tail: string) -> bytes
showkey
def _show_key(k: Key) -> string
showkey_event
def _show_key_event(e: KeyEvent) -> string
showbutton
def _show_button(b: MouseButton) -> string
showmouse
def _show_mouse(me: MouseEvent) -> string
showterm_event
def _show_term_event(e: TermEvent) -> string
decodegolden
def _decode_golden(input: bytes) -> string