hnoss

Guide

Everything you need to build a terminal app with hnoss, from the first frame to themed widgets.

Install

hnoss is a plain Odin package with no dependencies beyond core: and base:. Put the repository next to your code (a git submodule works well) and import it:

git submodule add https://github.com/Huseynteymurzade28/hnoss libs/hnoss   # inside a git repo (git init first)
# or, without git in your project:
git clone https://github.com/Huseynteymurzade28/hnoss libs/hnoss
package main

import hnoss "libs/hnoss"

A path import is resolved relative to the file that contains it, so "libs/hnoss" works from the project root. To import it the same way from any directory, register a collection when you build:

odin run . -collection:libs=libs
import hnoss "libs:hnoss"

To stay on a released version, check out a tag inside the submodule (git -C libs/hnoss checkout v0.1.0) and commit the submodule.

Linux is supported today. The repository's ./build.sh runs odin check with -vet -strict-style, the test suite and builds every example; ./build.sh run <example> runs one.

The frame loop

hnoss is immediate mode: each frame you describe the whole screen, then hnoss compares it with the previous frame and writes only the difference.

main :: proc() {
    context.assertion_failure_proc = hnoss.assertion_failure_proc  // see below

    term, err := hnoss.init()
    if err != .None { return }
    defer hnoss.deinit(&term)

    events: []hnoss.Event
    running := true
    for running {
        f := hnoss.begin_frame(&term)      // a blank frame, resized to the terminal
        ui := hnoss.ui(f, events)          // widgets for this frame

        hnoss.markup(f, {2, 1, 40, 1}, "[b]Salam[/], [cyan]dünya[/]!")

        for ev in hnoss.unhandled(ui) {
            if hnoss.is_char(ev, 'q') { running = false }
        }
        hnoss.end_frame(&term)             // diff + one write
        free_all(context.temp_allocator)   // everything per-frame lives here
        events = hnoss.poll_events(&term, 16)
        if term.error != .None { break }  // e.g. the terminal closed
    }
}

Polling after drawing means the first frame appears immediately, and events from one poll are handled by the next frame's widgets. Use a timeout of -1 to sleep until input arrives, or a number of milliseconds for animations.

Terminal & safety

hnoss.init(opts) opens /dev/tty (so redirecting stdout still works), switches to raw mode and the alternate screen and hides the cursor. Options are a bit set:

OptionDefaultEffect
.Alt_ScreenonDraw on the alternate screen; the shell's scrollback is untouched.
.Hide_CursoronHide the cursor (a focused text input shows it at the caret).
.Signal_HandlersonRestore the terminal on SIGINT/TERM/HUP/QUIT and crash signals, then re-raise.
.Bracketed_PasteonA paste arrives as one Paste event instead of a burst of keys.
.Mouse_CaptureoffReport clicks, drags, motion and wheel. While on, the terminal's own text selection needs Shift.
.Kitty_KeyboardonAsk for the kitty keyboard protocol: unambiguous keys and instant Esc. Ignored by terminals that don't support it; term.kitty_keyboard tells you whether it is active.
term, err := hnoss.init(hnoss.DEFAULT_OPTIONS + {.Mouse_Capture})

The terminal is restored by deinit, when main returns, and on signals and crashes. For panics, install the provided assertion handler: it restores the terminal before the panic message is printed, so you can read it:

context.assertion_failure_proc = hnoss.assertion_failure_proc
Raw mode turns off signal generation, so Ctrl+C arrives as a key (hnoss.is_char(ev, 'c', {.Ctrl})) and your app decides what it means. A SIGINT sent from outside still restores the terminal.

Events

poll_events(&term, timeout_ms) returns every event that arrived, as a slice in the temp allocator:

EventFields
Keycode (.Char, .Enter, .Up, .F5, …), char (for .Char), mods (.Shift .Alt .Ctrl .Super)
Mousekind (.Press .Release .Drag .Move .Scroll), button, x, y, mods
Pastetext, the whole pasted string
Resizecols, rows (frames resize themselves; this is informational)
for ev in events {
    switch e in ev {
    case hnoss.Key:    // ...
    case hnoss.Mouse:  // ...
    case hnoss.Paste:  // e.text
    case hnoss.Resize: // e.cols, e.rows
    }
    if hnoss.is_key(ev, .Up, {.Ctrl}) { /* Ctrl+Up */ }
}

In a classic terminal a lone Esc is ambiguous (it also starts every escape sequence), so hnoss waits term.esc_timeout_ms (25 ms) for more bytes before reporting it, and some combinations can't be told apart at all: Ctrl+I is Tab, Ctrl+H is Backspace, Shift+Enter is Enter.

Terminals that speak the kitty keyboard protocol (kitty, foot, ghostty, WezTerm, recent Alacritty, …) fix both: with .Kitty_Keyboard (on by default) every one of those keys arrives distinctly and Esc is reported in well under a millisecond instead of after 25 ms. Nothing changes in your code; you just get more precise Key events.

Text & the pen

Text is written with a pen: it flows styled text straight into an area of the frame, wrapping, aligning and scrolling as it goes. There is no intermediate text structure to build.

p := hnoss.pen(f, area, wrap = .Word, align = .Center)
hnoss.write(&p, "CPU ", {add = {.Bold}})
hnoss.write(&p, fmt.tprintf("%.0f%%", cpu), {fg = .Red})
hnoss.newline(&p)
lines := hnoss.finish(&p)       // required: places the last line
WrapBehavior
.WordBreak at spaces; a word longer than the line breaks anywhere.
.CharBreak anywhere.
.ClipNo wrapping; what doesn't fit is cut.
.EllipsisNo wrapping; cut lines end in ….

scroll = n skips the first n lines. hnoss.measure(s, width) returns the number of lines and the widest line without drawing anything, which is handy for sizing and scrollbars.

Markup

hnoss.markup(f, area, src) (or hnoss.write_markup(&pen, src)) interprets inline tags. A tag is a list of attributes applied on top of the surrounding style; [/] closes the innermost one.

hnoss.markup(f, area, "[b]CPU[/] [red]92%[/]  [black on #ffcc00] WARN [/]  [i dim]idle[/]")
AttributeMeaning
b d i u r sbold, dim, italic, underline, reverse, strike (also bold, italic, … and blink, hidden)
red … white, bright_redthe 16 terminal colors; default resets
#ff8800 · @208truecolor · 256-color index
on <color>background
[[a literal [

Brackets that are not a valid tag stay as text, so "page [1/3]" or "[x] done" need no escaping.

Colors & style

A Style is a patch: non-nil colors replace, add modifiers switch on, sub switch off.

s := hnoss.Style{fg = .Cyan, bg = hnoss.rgb(20, 30, 40), add = {.Bold}}
c1: hnoss.Color = .Bright_Red            // 16 colors (+ .Reset)
c2: hnoss.Color = hnoss.Indexed(208)     // 256 colors
c3: hnoss.Color = hnoss.hex(0x22848c)    // truecolor
c4 := hnoss.gradient(stops, 0.7)          // interpolate []Rgb at t

Layout

Layout is plain Rect math: no solver, no allocation. The natural immediate-mode way is to cut pieces off the edges:

area := f.area
header  := hnoss.cut_top(&area, 1)
status  := hnoss.cut_bottom(&area, 1)
sidebar := hnoss.cut_left(&area, 30)
// area is now the middle

For proportions, split into a fixed-size array. A plain number is cells; percent(p) and fill(weight) share the rest:

rows := hnoss.split(area, .Vertical, {3, hnoss.fill(), hnoss.percent(25)}, gap = 1)
cols := hnoss.split(rows[1], .Horizontal, {hnoss.fill(2), hnoss.fill(1)})

Helpers: inset(r, n), pad(r, top, right, bottom, left), center(r, w, h) for dialogs, and grid_cell(r, cols, rows, col, row, gap).

The Ui context

Widgets are procedures that take the frame's ^Ui. Calling one draws it, handles the input meant for it and returns what happened:

ui := hnoss.ui(f, events)                        // optional: theme
inner := hnoss.box(ui, area, "[b]Processes[/]")
if hnoss.list(ui, inner, names, &state) {       // true: selection changed
    // ...
}
if state.activated { /* Enter on the selection */ }

Stateful widgets take a pointer to state you own (List_State, Input_State, …). See the widget reference for each one.

Focus & consumed events

The state pointer is also the widget's identity. Only the focused widget receives keys; the mouse works everywhere (and a click focuses).

hnoss.focus(ui, &search)                       // give focus
hnoss.focus_order(ui, {&list, &search, &tabs})   // Tab / Shift+Tab cycle
if hnoss.focused(ui, &search) { /* ... */ }

A widget consumes the events it uses. hnoss.unhandled(ui) returns the rest, so app shortcuts never fire while you type into a field. Call it after the widgets.

Themes

Every color a widget uses comes from ui.theme. Copy the default, change what you like, and pass it to hnoss.ui:

t := hnoss.DEFAULT_THEME
t.border_kind    = .Rounded                 // .Plain .Double .Thick .None
t.border_focused = {fg = hnoss.hex(0x5fc4c9)}
t.selected       = {fg = .Black, bg = hnoss.hex(0x5fc4c9), add = {.Bold}}
t.header         = {fg = hnoss.hex(0xf08a6a), add = {.Bold}}
ui := hnoss.ui(f, events, t)

Fields: text muted accent border border_focused title selected selected_unfocused header gauge_filled gauge_empty tab tab_selected input scrollbar_track scrollbar_thumb, plus border_kind.

Memory

Rendering

Each frame is drawn into a back buffer of cells and diffed against the front buffer:

A 200×60 frame where every cell changes truecolor diffs in about 2 ms; an unchanged frame in under 0.1 ms. Run ./build.sh bench to measure yours.

Unicode

Display widths come from a table generated from Unicode 16 data (tools/gen_width.py). Text is handled in grapheme clusters: combining marks, ZWJ sequences (👩‍💻), skin tones (👍🏽) and flags (🇦🇿) each count as one glyph, and wide characters take two cells. Wrapping never splits a glyph, and alignment is computed from what you see, not from bytes.