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