Skip to main content

src/tty.erl

-module(tty).
-compile([no_auto_import, nowarn_unused_vars, nowarn_unused_function, nowarn_nomatch, inline]).
-define(FILEPATH, "src/tty.gleam").
-export([color_level_compare/2, color_level_at_least/2, is_tty/1, detect_color_level/1, detect_background/1]).
-export_type([stream/0, color_level/0, background/0]).

-if(?OTP_RELEASE >= 27).
-define(MODULEDOC(Str), -moduledoc(Str)).
-define(DOC(Str), -doc(Str)).
-else.
-define(MODULEDOC(Str), -compile([])).
-define(DOC(Str), -compile([])).
-endif.

?MODULEDOC(
    " TTY and ANSI color-support detection.\n"
    "\n"
    " This module answers two questions a CLI program asks at startup:\n"
    " 1. Is this stream connected to a terminal? (`is_tty`)\n"
    " 2. What level of ANSI color does it support? (`detect_color_level`)\n"
).

-type stream() :: stdin | stdout | stderr.

-type color_level() :: no_color | basic | ansi256 | true_color.

-type background() :: light | dark | unknown.

-file("src/tty.gleam", 103).
?DOC(
    " Internal capability rank for a `ColorLevel` (`NoColor`=0 .. `TrueColor`=3).\n"
    " The numeric values are an implementation detail, not part of the public\n"
    " 1.x API — callers should use `color_level_compare`/`color_level_at_least`.\n"
).
-spec color_level_rank(color_level()) -> integer().
color_level_rank(Level) ->
    case Level of
        no_color ->
            0;

        basic ->
            1;

        ansi256 ->
            2;

        true_color ->
            3
    end.

-file("src/tty.gleam", 96).
?DOC(
    " Orders two color levels by capability, where\n"
    " `NoColor` < `Basic` < `Ansi256` < `TrueColor`. Returns a `gleam/order`\n"
    " `Order`, so it composes with `list.sort`, `order.reverse`, and friends.\n"
    "\n"
    " ```gleam\n"
    " tty.color_level_compare(Basic, Ansi256)\n"
    " // -> order.Lt\n"
    " ```\n"
).
-spec color_level_compare(color_level(), color_level()) -> gleam@order:order().
color_level_compare(A, B) ->
    gleam@int:compare(color_level_rank(A), color_level_rank(B)).

-file("src/tty.gleam", 78).
?DOC(
    " Returns `True` if the actual color level is at least as capable as the\n"
    " required level. Use this to gate features without matching every variant:\n"
    "\n"
    " ```gleam\n"
    " let level = tty.detect_color_level(Stdout)\n"
    " case tty.color_level_at_least(actual: level, at_least: Ansi256) {\n"
    "   True -> render_256_color()\n"
    "   False -> render_basic()\n"
    " }\n"
    " ```\n"
).
-spec color_level_at_least(color_level(), color_level()) -> boolean().
color_level_at_least(Actual, Required) ->
    case color_level_compare(Actual, Required) of
        lt ->
            false;

        eq ->
            true;

        gt ->
            true
    end.

-file("src/tty.gleam", 115).
?DOC(
    " Inverse of `color_level_rank`: maps a `0..3` rank back to a `ColorLevel`,\n"
    " returning `Error(Nil)` for any out-of-range value. Used to convert the\n"
    " internal resolver's rank into a `ColorLevel`.\n"
).
-spec color_level_from_rank(integer()) -> {ok, color_level()} | {error, nil}.
color_level_from_rank(Rank) ->
    case Rank of
        0 ->
            {ok, no_color};

        1 ->
            {ok, basic};

        2 ->
            {ok, ansi256};

        3 ->
            {ok, true_color};

        _ ->
            {error, nil}
    end.

-file("src/tty.gleam", 128).
?DOC(
    " Inverse of the internal background rank: maps a `0..2` rank back to a\n"
    " `Background`, returning `Error(Nil)` for any out-of-range value. Used to\n"
    " convert the internal resolver's rank into a `Background`.\n"
).
-spec background_from_rank(integer()) -> {ok, background()} | {error, nil}.
background_from_rank(Rank) ->
    case Rank of
        0 ->
            {ok, unknown};

        1 ->
            {ok, dark};

        2 ->
            {ok, light};

        _ ->
            {error, nil}
    end.

-file("src/tty.gleam", 151).
?DOC(
    " Returns `True` if the given stream is connected to a terminal.\n"
    "\n"
    " On the Erlang target this uses `io:getopts/1` (requires OTP 26+). If\n"
    " terminal options cannot be read, this returns `False`.\n"
    " On the JavaScript target this uses `process.stdin.isTTY`,\n"
    " `process.stdout.isTTY`, or `process.stderr.isTTY`, so it requires a\n"
    " Node-style runtime with those streams.\n"
    "\n"
    " ```gleam\n"
    " case tty.is_tty(Stdout) {\n"
    "   True -> show_spinner()\n"
    "   False -> print_plain_progress()\n"
    " }\n"
    " ```\n"
).
-spec is_tty(stream()) -> boolean().
is_tty(Stream) ->
    case Stream of
        stdin ->
            tty_ffi:stdin_is_tty();

        stdout ->
            tty_ffi:stdout_is_tty();

        stderr ->
            tty_ffi:stderr_is_tty()
    end.

-file("src/tty.gleam", 241).
-spec get_env(binary()) -> {ok, binary()} | {error, nil}.
get_env(Name) ->
    tty_ffi:get_env(Name).

-file("src/tty.gleam", 177).
?DOC(
    " Detects color support for a stream, honoring `NO_COLOR`, `FORCE_COLOR`,\n"
    " `CI`, `TERM`, and `COLORTERM` environment variables.\n"
    "\n"
    " On the JavaScript target this reads `process.env`, so it requires a\n"
    " Node-style runtime.\n"
    "\n"
    " When a JavaScript runtime does not provide `process` or `process.env`,\n"
    " environment variables are treated as unset and this function falls back to\n"
    " `NoColor` unless other forced inputs are available.\n"
    "\n"
    " ```gleam\n"
    " case tty.detect_color_level(Stdout) {\n"
    "   NoColor -> render_without_ansi()\n"
    "   Basic -> render_with_basic_ansi()\n"
    "   Ansi256 -> render_with_256_colors()\n"
    "   TrueColor -> render_with_truecolor()\n"
    " }\n"
    " ```\n"
).
-spec detect_color_level(stream()) -> color_level().
detect_color_level(Stream) ->
    Rank = tty@resolve_color_level:resolve_color_level(
        is_tty(Stream),
        fun get_env/1
    ),
    Level@1 = case color_level_from_rank(Rank) of
        {ok, Level} -> Level;
        _assert_fail ->
            erlang:error(#{gleam_error => let_assert,
                        message => <<"resolve_color_level must return a rank in 0..3"/utf8>>,
                        file => <<?FILEPATH/utf8>>,
                        module => <<"tty"/utf8>>,
                        function => <<"detect_color_level"/utf8>>,
                        line => 183,
                        value => _assert_fail,
                        start => 5703,
                        'end' => 5753,
                        pattern_start => 5714,
                        pattern_end => 5723})
    end,
    Level@1.

-file("src/tty.gleam", 209).
?DOC(
    " Detects whether the terminal background is light or dark from `COLORFGBG`.\n"
    "\n"
    " The `stream` argument is accepted for API symmetry with `detect_color_level`\n"
    " and to leave room for future stream-specific detection; the current\n"
    " `COLORFGBG` signal is environment-wide and stream-independent.\n"
    "\n"
    " On the JavaScript target this reads `process.env`, so it requires a\n"
    " Node-style runtime. When a JavaScript runtime does not provide `process` or\n"
    " `process.env`, environment variables are treated as unset and this function\n"
    " returns `Unknown`.\n"
    "\n"
    " Returns `Unknown` when `COLORFGBG` is unset, malformed, or does not contain a\n"
    " clear background palette index.\n"
    "\n"
    " ```gleam\n"
    " case tty.detect_background(Stdout) {\n"
    "   Light -> render_dark_text()\n"
    "   Dark -> render_light_text()\n"
    "   Unknown -> render_default_theme()\n"
    " }\n"
    " ```\n"
).
-spec detect_background(stream()) -> background().
detect_background(_) ->
    Rank = tty@resolve_background:resolve_background(fun get_env/1),
    Background@1 = case background_from_rank(Rank) of
        {ok, Background} -> Background;
        _assert_fail ->
            erlang:error(#{gleam_error => let_assert,
                        message => <<"resolve_background must return a rank in 0..2"/utf8>>,
                        file => <<?FILEPATH/utf8>>,
                        module => <<"tty"/utf8>>,
                        function => <<"detect_background"/utf8>>,
                        line => 215,
                        value => _assert_fail,
                        start => 7164,
                        'end' => 7218,
                        pattern_start => 7175,
                        pattern_end => 7189})
    end,
    Background@1.