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-checksSTDOUT.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 plainString(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. Readsioctl(TIOCGWINSZ)on POSIX,GetConsoleScreenBufferInfovia libc on Windows, falls back to$LINES/$COLUMNSenv, then 24×80. Pair withterm-cursorfor 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-cursorcombo will do.
Idiomatic patterns
# 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")# 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# 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 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 %}