Back to blog

An ANSI Styling Pipeline in Odin

the color problem

so terminals are a mess when it comes to color support. some terminals can do 24-bit TrueColor — 16 million colors, the whole RGB spectrum. some can do 256 colors. some are stuck with the original 16 ANSI colors from the 80s. and if you're building a TUI tool like Wayu that needs to look good everywhere, you can't just pick one and hope for the best.

the approach I landed on is a three-tier pipeline. you define your colors once in TrueColor and the system automatically downgrades them based on what the terminal actually supports. no conditional logic scattered through your rendering code. no "if truecolor do this else do that" everywhere. just one color definition, three output paths.

detecting what the terminal can do

the detection is surprisingly simple. terminals advertise their capabilities through environment variables. $COLORTERM set to truecolor or 24bit means full RGB support. $TERM containing 256color means you get the extended palette. anything else and you fall back to the basic 16.

Color_Mode :: enum {
    Basic,      // 16 colors
    Extended,   // 256 colors
    TrueColor,  // 24-bit RGB
}

detect_color_mode :: proc() -> Color_Mode {
    colorterm := os.get_env("COLORTERM")
    if colorterm == "truecolor" || colorterm == "24bit" {
        return .TrueColor
    }

    term := os.get_env("TERM")
    if strings.contains(term, "256color") {
        return .Extended
    }

    return .Basic
}

you call this once at startup and store the result. every color operation from that point forward just checks the cached mode. no repeated env lookups, no per-frame detection.

defining colors once

the key insight is that every color in the system is defined as a TrueColor RGB value. that's the source of truth. the 256-color and 16-color versions are derived from it automatically.

the downgrade from TrueColor to ANSI256 finds the closest match in the 256-color palette using euclidean distance in RGB space. it's not perceptually perfect — you'd want CIELAB for that — but it's fast and the results are good enough that I've never noticed a bad mapping in practice. the downgrade from 256 to basic ANSI is a simple lookup table since there are only 16 targets.

when you emit a color escape sequence, the pipeline checks the detected mode and formats accordingly. TrueColor uses \x1b[38;2;{r};{g};{b}m. ANSI256 uses \x1b[38;5;{n}m. basic ANSI uses \x1b[{n}m with the classic 30-37 range. same color definition, three different wire formats.

the style builder

here's where it gets fun. I wanted a way to compose styles — foreground color, background color, bold, italic, underline — without mutation. in Odin structs are value types by default, which means you can build a pipeline of transformations where each step returns a new copy.

Style :: struct {
    fg:        Color,
    bg:        Color,
    bold:      bool,
    italic:    bool,
    underline: bool,
}

style :: proc() -> Style {
    return Style{}
}

fg :: proc(s: Style, c: Color) -> Style {
    result := s
    result.fg = c
    return result
}

bold :: proc(s: Style) -> Style {
    result := s
    result.bold = true
    return result
}

// usage: render(bold(fg(style(), RED)), "error!")

each function takes a Style by value, copies it, modifies the copy, and returns it. no pointers, no mutation of the original. you can branch off a base style without worrying about one branch corrupting the other. this matters a lot in a TUI where you might have a base style for a list item and then derive focused, selected, and disabled variants from it.

the value-copy semantics are the whole trick. in Go or Rust you'd probably reach for a builder pattern with method chaining on a mutable reference. in Odin you just lean into the fact that structs copy on assignment. it's simpler and there's no aliasing to worry about.

why this matters

the real payoff is that the rendering code doesn't think about terminal capabilities at all. a view says "this text should be bold red on a dark background" and the styling pipeline handles the rest. if you're on a fancy terminal you get the exact RGB values. if you're SSH'd into a server from a basic terminal you get the closest 16-color approximation. same code path either way.

I've tested Wayu on iTerm2 (TrueColor), the default macOS Terminal (256 color), and over SSH to a remote box with a bare xterm (16 color). the UI is recognizable in all three. the colors aren't identical obviously — you lose nuance as you downgrade — but the hierarchy and contrast survive the translation. that's all you really need.

the whole styling system is maybe 200 lines. detect once, define once, render everywhere. sometimes the simple approach is the right one.