Skip to main content
ansicode
Crystal

ANSI escape codes in Crystal — \e, Colorize stdlib, term-cursor

Crystal has the most ergonomic stdlib ANSI of any modern language. The `"..."` string literal accepts every common ESC form — `"\e"`, `"\033"` (octal), `"\x1b"` (hex), `"\u001b"` (4-digit Unicode), and `"\u{1b}"` (variable-width Unicode) — pick the one your team reads fastest. `puts` and `print` write raw bytes (`puts "\e[1;31merror:\e[0m permission denied"` works on a freshly-installed compiler with no shards). On macOS, Linux, BSDs, and Windows 10+ Conhost 1709+ the terminal parses the bytes natively. Reach for the stdlib **`Colorize`** module (`require "colorize"`) first — it ships with the compiler and the API reads like English: `"error".colorize.red.bold`, `"warning".colorize(:yellow)`, `"hot".colorize.fore(255, 80, 0)` for truecolor, `"x".colorize.fore(196)` for 256-palette. Colorize **auto-checks `STDOUT.tty?` on first use** and silently no-ops when redirected to a file or pipe — you get the right behaviour with zero capability boilerplate. For cursor movement / clear / scroll / save-restore reach for the **`term-cursor`** shard (`Term::Cursor.move(0, 0)`, `Term::Cursor.clear_screen`, `Term::Cursor.save`). For TTY size — Crystal's stdlib intentionally omits a public terminal-size API — pull in the **`term-screen`** shard (`Term::Screen.size # => {height, width}`). For a full blessed-equivalent TUI runtime (windows, focus, mouse, redraw loop) reach for **`crysterm`**, the most mature Crystal TUI library. Capability gating: `STDOUT.tty?` is the stdlib check — returns `false` when stdout is redirected to a file, pipe, or non-terminal IO. Pair with `ENV["NO_COLOR"]?` for the universal env-var convention; `Colorize.enabled = false` to force-disable globally. **Windows VT note**: Crystal compiles to native binaries — on pre-Conhost-1709 Windows builds the terminal won't parse ANSI bytes until you call `LibC.SetConsoleMode` with `ENABLE_VIRTUAL_TERMINAL_PROCESSING`. Crystal's compile-time `{% if flag?(:win32) %}` macro is the idiomatic gate: the libc bindings only get compiled on Windows targets, so a single source file builds cleanly on every supported platform.

Recommended libraries

  • Colorize (stdlib)

    Ships with the compiler — `require "colorize"` and you're done. Reads like English: `"error".colorize.red.bold`, `"warning".colorize(:yellow)`, `"x".colorize.fore(255, 80, 0)` (truecolor), `.fore(196)` (256-palette), `.back(:blue)`, `.mode(:underline)`. Chain methods left-to-right. Auto-checks `STDOUT.tty?` on first use and silently no-ops when redirected — zero capability boilerplate. The most ergonomic stdlib ANSI helper in any modern language.

  • term-cursor

    Cursor movement / clear / save-restore / hide-show. `Term::Cursor.move(x, y)`, `Term::Cursor.clear_screen`, `Term::Cursor.clear_line`, `Term::Cursor.save` / `restore`, `Term::Cursor.hide` / `show`. Returns plain `String` (the raw escape sequence) — `print Term::Cursor.move(10, 5)` to apply, or store the string and concatenate with body text. Pure stdlib under the hood, no native bindings.

  • term-screen

    Terminal size — Crystal's stdlib intentionally omits a public terminal-size API, so this shard fills the gap. `Term::Screen.size # => {rows, cols}`, `Term::Screen.width`, `Term::Screen.height`. Reads `ioctl(TIOCGWINSZ)` on POSIX, `GetConsoleScreenBufferInfo` via libc on Windows, falls back to `$LINES` / `$COLUMNS` env, then 24×80. Pair with `term-cursor` for any layout work.

  • crysterm

    Full TUI framework — the Crystal answer to blessed (Node) / notcurses (C) / textual (Python). Window / box / list / form widgets, focus management, mouse + keyboard event loop, double-buffered redraw, alt-screen lifecycle. Reach for it when ANSI alone isn't enough — you're building a multi-pane interactive UI, not just colourising log lines. Heaviest dependency in this list; skip if a single `Colorize` + `term-cursor` combo will do.

Idiomatic patterns

Direct puts with \e — Crystal accepts every common ESC form
# Crystal "..." string literals accept five ESC forms.
# Pick whichever your team reads fastest — they all
# compile to the same single byte (0x1b).
#
#   "\e"       — ESC escape (most readable)
#   "\033"     — octal (1, 2, or 3 digits)
#   "\x1b"     — hex (exactly 2 digits)
#   "\u001b"   — Unicode (exactly 4 hex digits)
#   "\u{1b}"   — Unicode variable width

puts "\e[1;31merror:\e[0m permission denied"
puts "\033[33mwarn:\033[0m deprecated flag"
puts "\x1b[32mok:\x1b[0m 142 tests passed"
puts "\u001b[36minfo:\u001b[0m skipping cache"
puts "\u{1b}[35mdebug:\u{1b}[0m checkpoint hit"

# Truecolor — 38;2;R;G;B
puts "\e[38;2;255;128;0morange truecolor\e[0m"

# 256-palette — 38;5;n  (n = 0..255)
puts "\e[38;5;196mbright red\e[0m"

# Reusable helper — works without any shard:
def esc(sgr : String, text : String) : String
  "\e[#{sgr}m#{text}\e[0m"
end

puts esc("1;31", "FATAL") + " server crashed"
puts esc("32",   "ok:")    + " 142 tests passed"
puts esc("38;2;255;128;0", "truecolor orange")
Colorize stdlib — "text".colorize.red.bold reads like English
# Ships with the compiler — no shard needed.
require "colorize"

# Named SGR colours — chain methods left-to-right:
puts "error: ".colorize.red.bold.to_s + "permission denied"
puts "ok: ".colorize.green.to_s        + "142 tests passed"
puts " CAUTION ".colorize.black.on_yellow.to_s + " brakes wet"

# Symbol form — equivalent, sometimes nicer in data-driven code:
levels = {error: :red, warn: :yellow, ok: :green, info: :cyan}
levels.each do |lvl, colour|
  puts "#{lvl.to_s.upcase.colorize(colour).bold}: example message"
end

# Truecolor — 38;2;R;G;B via .fore(r, g, b):
puts "hot pink".colorize.fore(255, 80, 200)
puts "deep teal".colorize.fore(0, 110, 130).bold

# 256-palette — .fore(n) with n in 0..255:
(0..255).step(16) do |n|
  print "#{n.to_s.rjust(3)} ".colorize.fore(n)
end
puts

# Background + mode chaining:
puts "selected".colorize.fore(:white).back(:blue).mode(:underline)

# Auto-disabled when stdout is redirected — try:
#   crystal run example.cr | cat
# Colorize.enabled? returns false; no escapes emitted.
puts "ok".colorize.green  # writes "ok" plain when piped
term-cursor + term-screen — cursor control and TTY size
# shard.yml:
#   dependencies:
#     term-cursor:
#       github: crystal-term/cursor
#     term-screen:
#       github: crystal-term/screen
#
# Then `shards install` and require below:

require "term-cursor"
require "term-screen"

# Term::Cursor methods return plain Strings (the raw
# escape sequence) — print them to apply, or concat:
print Term::Cursor.clear_screen
print Term::Cursor.move(0, 0)

# Right-aligned status bar pinned to bottom row:
rows, cols = Term::Screen.size
status = " 142 tests passed  "
print Term::Cursor.save
print Term::Cursor.move(0, rows - 1)
print status.rjust(cols)
print Term::Cursor.restore

# Spinner — hide cursor, animate, restore:
frames = %w[⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏]
print Term::Cursor.hide
begin
  10.times do |i|
    print "\r#{frames[i % frames.size]} working..."
    sleep 0.08.seconds
  end
ensure
  print "\r"
  print Term::Cursor.clear_line
  print Term::Cursor.show
  puts "done"
end

# Term::Screen.size handles every fallback chain:
#   TIOCGWINSZ ioctl  →  GetConsoleScreenBufferInfo
#   →  $LINES / $COLUMNS  →  24×80 default
puts "terminal: #{Term::Screen.width}×#{Term::Screen.height}"
Capability gate + Windows VT enable via {% if flag?(:win32) %}
# Capability gating in Crystal:
#   STDOUT.tty?               — stdlib, returns false when piped/redirected
#   ENV["NO_COLOR"]?          — universal opt-out env var
#   ENV["FORCE_COLOR"]?       — universal opt-in env var
#   Colorize.enabled = false  — global force-disable for the Colorize module
#
# Colorize auto-respects STDOUT.tty?, so most apps need
# nothing more than the require. Manual gating is for code
# that emits ANSI without going through Colorize.

require "colorize"

def ansi_capable? : Bool
  return false if ENV["NO_COLOR"]?
  return true  if ENV["FORCE_COLOR"]?
  STDOUT.tty?
end

# Honour the env vars in Colorize too — Colorize doesn't
# look at NO_COLOR on its own, so wire it explicitly:
Colorize.enabled = ansi_capable?

def styled(text : String, sgr : String) : String
  return text unless ansi_capable?
  "\e[#{sgr}m#{text}\e[0m"
end

puts styled("OK",   "32")
puts styled("FAIL", "1;31")

# Windows VT enable — pre-Conhost-1709 builds need an
# explicit SetConsoleMode call. The {% if flag?(:win32) %}
# macro guards the libc binding so the same source file
# compiles cleanly on Linux/macOS without the import.
{% if flag?(:win32) %}
  lib LibC
    ENABLE_VIRTUAL_TERMINAL_PROCESSING = 0x0004_u32
    STD_OUTPUT_HANDLE                  = -11_i32

    fun GetStdHandle(nStdHandle : Int32) : Void*
    fun GetConsoleMode(hConsoleHandle : Void*, lpMode : UInt32*) : Int32
    fun SetConsoleMode(hConsoleHandle : Void*, dwMode : UInt32) : Int32
  end

  def enable_vt_on_windows
    handle = LibC.GetStdHandle(LibC::STD_OUTPUT_HANDLE)
    mode = uninitialized UInt32
    LibC.GetConsoleMode(handle, pointerof(mode))
    LibC.SetConsoleMode(handle, mode | LibC::ENABLE_VIRTUAL_TERMINAL_PROCESSING)
  end

  enable_vt_on_windows
{% end %}

Related sequences

Other languages