Home

🚧Blog

More

Building a TUI Library from Scratch: From Classes to Signals

Things I learned:

  • I learned tons of things but had to write tons of garbage for it and (of course) ended up using none of it
  • Separation of concerns is extremely important, never stop following this principle
  • Bun, FFI, some Rust, ANSI escape sequences, memory management

Tools used:

  • ampcode.com, aistudio.google.com, t3.chat, deepwiki.com

I've been using Neovim for years and enjoyed living inside my terminal. I was also super interested in different CLIs and TUIs. I got even more curious when I started hearing about Claude Code, Codex, Aider and similar AI agents that run in the terminal (about a year ago I think) more and more. I did some quick research and found react-ink, then opencode. Soon after I saw this post:

and right then I decided to build my own TUI library someday.

When I started building LeTUI around September, I decided not to follow or copy any tutorial, guide or existing repository. I wanted to build something from scratch, on my own. I did use AI for a general roadmap and learning, but I wrote every single line of code myself.

I reached for the most "simple to reason about" pattern - classes - and wanted to just go with the flow. I never wrote a library or worked with terminals before, so instead of making a detailed step-by-step plan, I decided to just start by playing around with the code.

As the result, initial implementation had View, Row, Column, Text, Button, and Input classes. It was a complete mess, but I learned a lot and it gave me enough of a base to build on later.

Here's an example snippet. Enjoy!

class Button {
  id: number;
  px = 4;
  py = 1;
  text: string;
  fg: number;
  active_fg: number;
  bg: number;
  active_bg: number;
  border: Border;
  prebuilt: BigUint64Array = new BigUint64Array();
  width: number;
  height: number;
  onClick: (() => Promise<void>) | (() => void) | null = null;
 
  // ... constructor and other methods ...
 
  render(xo: number, yo: number, { w, h }: { w: number; h: number }) {
    this.xo = xo;
    this.yo = yo;
 
    this.prerender(); // layout? painting? who knows!
 
    // top part - manual pixel pushing
    for (let cy = yo; cy < this.py + yo; cy++) {
      for (let cx = xo; cx < xo + this.size().w; cx++) {
        buffer.set(
          new BigUint64Array([
            BigInt(" ".codePointAt(0)!),
            BigInt(this.fg),
            BigInt(this.bg),
          ]),
          (terminalWidth * cy + cx) * 3
        );
      }
    }
 
    // bottom part - more manual pixel pushing
    for (let cy = yo + this.size().h - this.py; cy < yo + this.size().h; cy++) {
      for (let cx = xo; cx < xo + this.size().w; cx++) {
        buffer.set(
          new BigUint64Array([
            BigInt(" ".codePointAt(0)!),
            BigInt(this.fg),
            BigInt(this.bg),
          ]),
          (terminalWidth * cy + cx) * 3
        );
      }
    }
 
    // middle part - even more manual pixel pushing
    for (
      let cy = yo + this.size().h - 2 * this.py;
      cy < yo + this.size().h - 2 * this.py + this.height;
      cy++
    ) {
      for (let cx = xo; cx < xo + this.size().w; cx++) {
        if (cx < xo + this.px || cx > xo + this.px + this.width - 1) {
          buffer.set(
            new BigUint64Array([
              BigInt(" ".codePointAt(0)!),
              BigInt(this.active_fg),
              BigInt(this.active_bg),
            ]),
            (terminalWidth * cy + cx) * 3
          );
        }
      }
    }
 
    // actual text
    buffer.set(
      this.prebuilt.subarray(0),
      (terminalWidth * (yo + this.py) + xo + this.px) * 3
    );
 
    this.updateHitMap(xo, yo); // hit-testing mixed in here too!
  }
 
  updateHitMap(xo: number, yo: number) {
    for (let cy = yo; cy < yo + this.size().h; cy++) {
      for (let cx = xo; cx < xo + this.size().w; cx++) {
        hitMap.set(cy * terminalWidth + cx, this.id);
      }
    }
  }
 
  // ...
}

Layout and painting were hopelessly tangled - render() calculates positions and writes to the buffer and updates the hit-map for click detection. Every component duplicates border-drawing logic. The API is completely inflexible: want to change how buttons look when pressed? Good luck finding the right place. Want to add a new component type? Copy-paste 100 lines and pray you got the coordinate math right.

The real problem emerged when I needed state management and dynamic updates. User types in an Input, clicks a Button, and the UI needs to update. My class-based approach required manual setText() calls that triggered prerender() and render(). Every component held mutable state, and coordinating updates became a mess of method calls.

This was my first attempt at building a TUI library and it kinda worked. I could render containers and primitives, make them nested and show some colors. I started to understand the problem space - what a render loop actually needs, which parts should be separate, how hit-testing works, why you need separate layout and painting processes and especially, how I want to handle dynamic updates.

At this point, it was time to stop playing around and started from scratch again, but now with a better understanding of how things should work.

v1v2v3