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:
| Option | Default | Effect |
|---|---|---|
.Alt_Screen | on | Draw on the alternate screen; the shell's scrollback is untouched. |
.Hide_Cursor | on | Hide the cursor (a focused text input shows it at the caret). |
.Signal_Handlers | on | Restore the terminal on SIGINT/TERM/HUP/QUIT and crash signals, then re-raise. |
.Bracketed_Paste | on | A paste arrives as one Paste event instead of a burst of keys. |
.Mouse_Capture | off | Report clicks, drags, motion and wheel. While on, the terminal's own text selection needs Shift. |
.Kitty_Keyboard | on | Ask 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
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:
| Event | Fields |
|---|---|
Key | code (.Char, .Enter, .Up, .F5, …), char (for .Char), mods (.Shift .Alt .Ctrl .Super) |
Mouse | kind (.Press .Release .Drag .Move .Scroll), button, x, y, mods |
Paste | text, the whole pasted string |
Resize | cols, 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
| Wrap | Behavior |
|---|---|
.Word | Break at spaces; a word longer than the line breaks anywhere. |
.Char | Break anywhere. |
.Clip | No wrapping; what doesn't fit is cut. |
.Ellipsis | No 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[/]")
| Attribute | Meaning |
|---|---|
b d i u r s | bold, dim, italic, underline, reverse, strike (also bold, italic, … and blink, hidden) |
red … white, bright_red | the 16 terminal colors; default resets |
#ff8800 · @208 | truecolor · 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)})
- Cells and percents are served first come, first served; if space runs out, later ones shrink.
- Fills share what is left by weight, rounded so the pieces always add up exactly.
split_slicedoes the same for a count known at run time.
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
- Library procedures take an allocator parameter or use
context.allocator; the terminal's buffers live untildeinit. - Per-frame data (event slices, paste text, widget scratch,
fmt.tprintf) usescontext.temp_allocator. Callfree_all(context.temp_allocator)once per frame. - The only global state is the terminal-restore hook signal handlers need.
Input_State.textgrows with your allocator; free it withhnoss.input_destroy.
Rendering
Each frame is drawn into a back buffer of cells and diffed against the front buffer:
- a cursor move is emitted only when the next write isn't contiguous, and colors only when they change;
- the frame is wrapped in synchronized output (DEC 2026), so the terminal never shows a half-drawn frame;
- everything goes out in one
write(2).
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.