//// 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_background as background_resolver
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
}
/// Detected terminal background.
/// This variant set is intentionally stable for 1.x.
///
/// ```gleam
/// case tty.detect_background(Stdout) {
/// Light -> render_for_light_background()
/// Dark -> render_for_dark_background()
/// Unknown -> render_default_theme()
/// }
/// ```
pub type Background {
/// Terminal has a light background.
Light
/// Terminal has a dark background.
Dark
/// Background could not be determined.
Unknown
}
/// 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)
}
}
/// Inverse of the internal background rank: maps a `0..2` rank back to a
/// `Background`, returning `Error(Nil)` for any out-of-range value. Used to
/// convert the internal resolver's rank into a `Background`.
fn background_from_rank(rank: Int) -> Result(Background, Nil) {
case rank {
0 -> Ok(Unknown)
1 -> Ok(Dark)
2 -> Ok(Light)
_ -> 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.
// nolint: assert_ok_pattern -- internal 0..3 invariant guard; public fn returns ColorLevel by design (see comment above)
let assert Ok(level) = color_level_from_rank(rank)
as "resolve_color_level must return a rank in 0..3"
level
}
/// Detects whether the terminal background is light or dark from `COLORFGBG`.
///
/// The `stream` argument is accepted for API symmetry with `detect_color_level`
/// and to leave room for future stream-specific detection; the current
/// `COLORFGBG` signal is environment-wide and stream-independent.
///
/// 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
/// returns `Unknown`.
///
/// Returns `Unknown` when `COLORFGBG` is unset, malformed, or does not contain a
/// clear background palette index.
///
/// ```gleam
/// case tty.detect_background(Stdout) {
/// Light -> render_dark_text()
/// Dark -> render_light_text()
/// Unknown -> render_default_theme()
/// }
/// ```
pub fn detect_background(_stream: Stream) -> Background {
let rank = background_resolver.resolve_background(env: get_env)
// The resolver is statically guaranteed to return a rank in 0..2, so this
// can only fail if that internal invariant is ever broken. Crashing loudly
// is preferable to silently degrading background detection to Unknown.
// nolint: assert_ok_pattern -- internal 0..2 invariant guard; public fn returns Background by design (see comment above)
let assert Ok(background) = background_from_rank(rank)
as "resolve_background must return a rank in 0..2"
background
}
@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)
}
}