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 honestly I just wanted to understand what's actually happening when your terminal turns into an interactive app. turns out it's not that complicated — it's 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 your terminal is in "cooked" mode which means it buffers input line by line and echoes characters back to you. that's great for a shell but terrible for a TUI where you need to react to every single keypress immediately. so the first thing you do is flip 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 when you exit. if you forget this step your 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 want to handle those ourselves.
the alternate screen buffer
this one blew my mind when I first learned about it. terminals have two screen buffers — the normal one with your scrollback history and an alternate one that programs like vim use. when you enter the alternate buffer your scrollback is preserved and when you leave it everything goes back to how it was. it's just an 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 like crazy and it's slow. what you actually want is to only redraw the 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. you need to catch that, query the new terminal dimensions with an ioctl, and re-layout everything. in Odin you register a signal handler and set a flag that the main loop checks.
this was the trickiest part honestly. the resize can happen mid-render so you need to be careful about buffer sizes. I ended up just reallocating both buffers on resize and forcing a full redraw for that one frame.
what I learned
building a TUI from scratch taught me more about how terminals work than years of using them. 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 it's surprisingly simple once you see through it.
the fuzzy finder was the most fun to build. it's basically a text input that filters a list in real time and you navigate with arrow keys. sounds simple but getting the rendering right — keeping the cursor position synced with the visible selection while the list is changing underneath — that 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.