hanki

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