Current section
Files
Jump to
Current section
Files
src/tty.gleam
//// TTY and ANSI color-support detection.
////
//// This module answers two questions a CLI program asks at startup:
//// 1. Is this stream connected to a terminal? (`is_tty`)
//// 2. What level of ANSI color does it support? (`detect_color_level`)
@target(javascript)
import gleam/dynamic.{type Dynamic}
@target(javascript)
import gleam/dynamic/decode
import gleam/int
import gleam/order
import tty/resolve_color_level as resolver
/// The standard I/O streams of the running process.
/// This variant set is intentionally stable for 1.x.
pub type Stream {
/// Standard input.
Stdin
/// Standard output.
Stdout
/// Standard error.
Stderr
}
/// Level of ANSI color support detected for a stream.
/// This variant set is intentionally stable for 1.x.
///
/// ```gleam
/// case tty.detect_color_level(Stdout) {
/// NoColor -> render_plain_text()
/// Basic -> render_basic_ansi()
/// Ansi256 -> render_256_color()
/// TrueColor -> render_rgb_color()
/// }
/// ```
pub type ColorLevel {
/// No ANSI escape codes should be emitted.
NoColor
/// 16-color (basic ANSI) is supported.
Basic
/// 256-color (xterm-256) is supported.
Ansi256
/// 24-bit truecolor (RGB) is supported.
TrueColor
}
/// Returns `True` if the actual color level is at least as capable as the
/// required level. Use this to gate features without matching every variant:
///
/// ```gleam
/// let level = tty.detect_color_level(Stdout)
/// case tty.color_level_at_least(actual: level, at_least: Ansi256) {
/// True -> render_256_color()
/// False -> render_basic()
/// }
/// ```
pub fn color_level_at_least(
actual actual: ColorLevel,
at_least required: ColorLevel,
) -> Bool {
case color_level_compare(actual, required) {
order.Lt -> False
order.Eq | order.Gt -> True
}
}
/// Orders two color levels by capability, where
/// `NoColor` < `Basic` < `Ansi256` < `TrueColor`. Returns a `gleam/order`
/// `Order`, so it composes with `list.sort`, `order.reverse`, and friends.
///
/// ```gleam
/// tty.color_level_compare(Basic, Ansi256)
/// // -> order.Lt
/// ```
pub fn color_level_compare(a: ColorLevel, b: ColorLevel) -> order.Order {
int.compare(color_level_rank(a), color_level_rank(b))
}
/// Internal capability rank for a `ColorLevel` (`NoColor`=0 .. `TrueColor`=3).
/// The numeric values are an implementation detail, not part of the public
/// 1.x API — callers should use `color_level_compare`/`color_level_at_least`.
fn color_level_rank(level: ColorLevel) -> Int {
case level {
NoColor -> 0
Basic -> 1
Ansi256 -> 2
TrueColor -> 3
}
}
/// Inverse of `color_level_rank`: maps a `0..3` rank back to a `ColorLevel`,
/// returning `Error(Nil)` for any out-of-range value. Used to convert the
/// internal resolver's rank into a `ColorLevel`.
fn color_level_from_rank(rank: Int) -> Result(ColorLevel, Nil) {
case rank {
0 -> Ok(NoColor)
1 -> Ok(Basic)
2 -> Ok(Ansi256)
3 -> Ok(TrueColor)
_ -> Error(Nil)
}
}
/// Returns `True` if the given stream is connected to a terminal.
///
/// On the Erlang target this uses `io:getopts/1` (requires OTP 26+). If
/// terminal options cannot be read, this returns `False`.
/// On the JavaScript target this uses `process.stdin.isTTY`,
/// `process.stdout.isTTY`, or `process.stderr.isTTY`, so it requires a
/// Node-style runtime with those streams.
///
/// ```gleam
/// case tty.is_tty(Stdout) {
/// True -> show_spinner()
/// False -> print_plain_progress()
/// }
/// ```
pub fn is_tty(stream: Stream) -> Bool {
case stream {
Stdin -> stdin_is_tty()
Stdout -> stdout_is_tty()
Stderr -> stderr_is_tty()
}
}
/// Detects color support for a stream, honoring `NO_COLOR`, `FORCE_COLOR`,
/// `CI`, `TERM`, and `COLORTERM` environment variables.
///
/// On the JavaScript target this reads `process.env`, so it requires a
/// Node-style runtime.
///
/// When a JavaScript runtime does not provide `process` or `process.env`,
/// environment variables are treated as unset and this function falls back to
/// `NoColor` unless other forced inputs are available.
///
/// ```gleam
/// case tty.detect_color_level(Stdout) {
/// NoColor -> render_without_ansi()
/// Basic -> render_with_basic_ansi()
/// Ansi256 -> render_with_256_colors()
/// TrueColor -> render_with_truecolor()
/// }
/// ```
pub fn detect_color_level(stream: Stream) -> ColorLevel {
let rank = resolver.resolve_color_level(is_tty: is_tty(stream), env: get_env)
// The resolver is statically guaranteed to return a rank in 0..3, so this
// can only fail if that internal invariant is ever broken. Crashing loudly
// is preferable to silently degrading color detection to NoColor.
let assert Ok(level) = color_level_from_rank(rank)
as "resolve_color_level must return a rank in 0..3"
level
}
@external(erlang, "tty_ffi", "stdin_is_tty")
@external(javascript, "./tty_ffi.mjs", "stdinIsTty")
fn stdin_is_tty() -> Bool
@external(erlang, "tty_ffi", "stdout_is_tty")
@external(javascript, "./tty_ffi.mjs", "stdoutIsTty")
fn stdout_is_tty() -> Bool
@external(erlang, "tty_ffi", "stderr_is_tty")
@external(javascript, "./tty_ffi.mjs", "stderrIsTty")
fn stderr_is_tty() -> Bool
@target(erlang)
@external(erlang, "tty_ffi", "get_env")
fn raw_get_env_erlang(name: String) -> Result(String, Nil)
@target(javascript)
@external(javascript, "./tty_ffi.mjs", "getEnv")
fn raw_get_env_js(name: String) -> Dynamic
@target(erlang)
fn get_env(name: String) -> Result(String, Nil) {
raw_get_env_erlang(name)
}
@target(javascript)
fn get_env(name: String) -> Result(String, Nil) {
case decode.run(raw_get_env_js(name), decode.string) {
Ok(s) -> Ok(s)
Error(_) -> Error(Nil)
}
}