why no libraries
so i'm building this tool called Wayu and it needs a terminal UI. the obvious move is to reach for ncurses or termbox or one of the million TUI libraries out there. but i'm writing this in Odin and i wanted to understand what's actually happening when a terminal turns into an interactive app. it's not that complicated. just a lot of escape sequences and some careful bookkeeping.
the whole TUI ended up at 16,830 lines. 8 views, modals, a fuzzy finder. all built on raw terminal control. here's how that works.
raw mode
by default a terminal runs in "cooked" mode. it buffers input line by line and echoes characters back. that's great for a shell but terrible for a TUI where you need to react to every keypress. the first step is flipping the terminal into raw mode using termios.
enable_raw_mode :: proc() -> os.termios {
original: os.termios
os.tcgetattr(os.STDIN_FILENO, &original)
raw := original
raw.c_lflag &~= {.ECHO, .ICANON, .ISIG}
raw.c_iflag &~= {.IXON, .ICRNL}
raw.c_oflag &~= {.OPOST}
raw.c_cc[.VMIN] = 1
raw.c_cc[.VTIME] = 0
os.tcsetattr(os.STDIN_FILENO, .TCSAFLUSH, &raw)
return original
}you save the original termios state so you can restore it on exit. forget this step and the terminal is cooked (pun intended). no echo, no line editing, you'll have to close the tab. ECHO stops the terminal from printing what you type. ICANON disables line buffering so you get bytes one at a time. ISIG disables ctrl-c and ctrl-z signals because we handle those ourselves.
the alternate screen buffer
terminals have two screen buffers: the normal one with scrollback history and an alternate one that programs like vim use. entering the alternate buffer preserves scrollback. leaving it restores everything. it's one escape sequence.
ENTER_ALT_SCREEN :: "\x1b[?1049h"
LEAVE_ALT_SCREEN :: "\x1b[?1049l"
HIDE_CURSOR :: "\x1b[?25l"
SHOW_CURSOR :: "\x1b[?25h"
CLEAR_SCREEN :: "\x1b[2J"
MOVE_HOME :: "\x1b[H"
init_screen :: proc(writer: ^bufio.Writer) {
bufio.writer_write_string(writer, ENTER_ALT_SCREEN)
bufio.writer_write_string(writer, HIDE_CURSOR)
bufio.writer_write_string(writer, CLEAR_SCREEN)
bufio.writer_flush(writer)
}all of these are ANSI escape sequences. \x1b[ is the CSI (control sequence introducer) and then you tack on whatever command you need. cursor movement is \x1b[{row};{col}H. colors are \x1b[38;5;{n}m for foreground. once you internalize the pattern it's just string formatting.
differential rendering
the naive approach is to clear the screen and redraw everything every frame. that works but it flickers and it's slow. the fix: only redraw cells that changed since the last frame.
i keep two buffers: the current frame and the previous frame. each cell stores a character, foreground color, and background color. on each render pass i walk both buffers and only emit escape sequences for cells that differ.
render :: proc(tui: ^TUI) {
writer := &tui.writer
for row in 0..<tui.height {
for col in 0..<tui.width {
idx := row * tui.width + col
curr := tui.current_buf[idx]
prev := tui.previous_buf[idx]
if curr == prev do continue
fmt.sbprintf(&tui.seq_buf, "\x1b[{};{}H", row + 1, col + 1)
fmt.sbprintf(&tui.seq_buf, "\x1b[38;5;{}m", curr.fg)
fmt.sbprintf(&tui.seq_buf, "\x1b[48;5;{}m", curr.bg)
fmt.sbprintf(&tui.seq_buf, "{}", curr.ch)
}
}
bufio.writer_write_string(writer, strings.to_string(tui.seq_buf))
bufio.writer_flush(writer)
tui.previous_buf, tui.current_buf = tui.current_buf, tui.previous_buf
}the key insight is batching all the escape sequences into a single string buffer and flushing once. if you write each sequence individually you get visible tearing because the terminal processes them as they arrive.
handling resize
when someone resizes their terminal window the kernel sends SIGWINCH. the handler catches that, queries the new terminal dimensions with an ioctl, and re-layouts everything. in Odin you register a signal handler and set a flag that the main loop checks.
this was the trickiest part. the resize can happen mid-render so you need to be careful about buffer sizes. i ended up reallocating both buffers on resize and forcing a full redraw for that one frame.
what i learned
terminals are just streams of bytes with special escape sequences mixed in. there's no magic. vim, htop, all of them are doing exactly this. the protocol is ancient and weird but simple once you see through it.
the fuzzy finder was the most fun to build. a text input that filters a list in real time, navigated with arrow keys. getting the rendering right (keeping the cursor position synced with the visible selection while the list changes underneath) took some iteration.
16,830 lines sounds like a lot but most of it is the 8 different views and their layout logic. the core terminal abstraction is maybe 400 lines. the rest is just application code that happens to draw to a terminal instead of a browser.
