Skip to main content

src/etui@text.erl

-module(etui@text).
-compile([no_auto_import, nowarn_unused_vars, nowarn_unused_function, nowarn_nomatch, inline]).
-define(FILEPATH, "src/etui/text.gleam").
-export([graphemes/1, codepoint_cell_width/1, grapheme_cell_width/1, cell_width/1, truncate/3, wrap/2, pad_right/2, pad_left/2, align/3, strip_ansi/1]).
-export_type([alignment/0, strip_state/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.

-type alignment() :: left | center | right.

-type strip_state() :: plain | esc_seen | csi_body | osc_body | osc_esc_seen.

-file("src/etui/text.gleam", 33).
?DOC(
    " Split a string into grapheme clusters (UAX #29, via Erlang Unicode).\n"
    "\n"
    " Each element is one user-perceived character: a base letter, a ZWJ\n"
    " sequence, a flag pair, an emoji with modifiers, etc.\n"
    "\n"
    " ```gleam\n"
    " graphemes(\"café\") // [\"c\", \"a\", \"f\", \"é\"]\n"
    " graphemes(\"👨‍👩‍👧‍👦\") // [\"👨‍👩‍👧‍👦\"], one cluster\n"
    " ```\n"
).
-spec graphemes(binary()) -> list(binary()).
graphemes(S) ->
    gleam@string:to_graphemes(S).

-file("src/etui/text.gleam", 58).
?DOC(
    " Cell width of a single Unicode codepoint.\n"
    " Returns 0 for control / combining / zero-width, 2 for wide (CJK / emoji /\n"
    " fullwidth), 1 otherwise.\n"
).
-spec codepoint_cell_width(integer()) -> integer().
codepoint_cell_width(Cp) ->
    case Cp of
        N when N < 16#20 ->
            0;

        16#7F ->
            0;

        N@1 when (N@1 >= 16#0300) andalso (N@1 =< 16#036F) ->
            0;

        N@2 when (N@2 >= 16#1160) andalso (N@2 =< 16#11FF) ->
            0;

        N@3 when (N@3 >= 16#FE00) andalso (N@3 =< 16#FE0F) ->
            0;

        N@4 when (N@4 >= 16#E0100) andalso (N@4 =< 16#E01EF) ->
            0;

        16#200B ->
            0;

        16#200C ->
            0;

        16#200D ->
            0;

        16#FEFF ->
            0;

        N@5 when (N@5 >= 16#1100) andalso (N@5 =< 16#115F) ->
            2;

        N@6 when (N@6 >= 16#2E80) andalso (N@6 =< 16#303E) ->
            2;

        N@7 when (N@7 >= 16#3041) andalso (N@7 =< 16#33FF) ->
            2;

        N@8 when (N@8 >= 16#3400) andalso (N@8 =< 16#4DBF) ->
            2;

        N@9 when (N@9 >= 16#4E00) andalso (N@9 =< 16#9FFF) ->
            2;

        N@10 when (N@10 >= 16#A000) andalso (N@10 =< 16#A4CF) ->
            2;

        N@11 when (N@11 >= 16#AC00) andalso (N@11 =< 16#D7A3) ->
            2;

        N@12 when (N@12 >= 16#F900) andalso (N@12 =< 16#FAFF) ->
            2;

        N@13 when (N@13 >= 16#FE30) andalso (N@13 =< 16#FE4F) ->
            2;

        N@14 when (N@14 >= 16#FF00) andalso (N@14 =< 16#FF60) ->
            2;

        N@15 when (N@15 >= 16#FFE0) andalso (N@15 =< 16#FFE6) ->
            2;

        N@16 when (N@16 >= 16#1F1E6) andalso (N@16 =< 16#1F1FF) ->
            2;

        N@17 when (N@17 >= 16#1F300) andalso (N@17 =< 16#1F5FF) ->
            2;

        N@18 when (N@18 >= 16#1F600) andalso (N@18 =< 16#1F64F) ->
            2;

        N@19 when (N@19 >= 16#1F680) andalso (N@19 =< 16#1F6FF) ->
            2;

        N@20 when (N@20 >= 16#1F700) andalso (N@20 =< 16#1F77F) ->
            2;

        N@21 when (N@21 >= 16#1F780) andalso (N@21 =< 16#1F7FF) ->
            2;

        N@22 when (N@22 >= 16#1F800) andalso (N@22 =< 16#1F8FF) ->
            2;

        N@23 when (N@23 >= 16#1F900) andalso (N@23 =< 16#1F9FF) ->
            2;

        N@24 when (N@24 >= 16#1FA00) andalso (N@24 =< 16#1FAFF) ->
            2;

        N@25 when (N@25 >= 16#20000) andalso (N@25 =< 16#2FFFD) ->
            2;

        N@26 when (N@26 >= 16#30000) andalso (N@26 =< 16#3FFFD) ->
            2;

        _ ->
            1
    end.

-file("src/etui/text.gleam", 48).
?DOC(
    " Cell width of a single grapheme cluster.\n"
    " Uses the first codepoint's East Asian Width / emoji classification.\n"
    " Subsequent codepoints in a grapheme (combining, ZWJ, variation selectors)\n"
    " contribute 0, so the first determines the visible cell count.\n"
).
-spec grapheme_cell_width(binary()) -> integer().
grapheme_cell_width(G) ->
    case gleam@string:to_utf_codepoints(G) of
        [] ->
            0;

        [Cp | _] ->
            codepoint_cell_width(gleam_stdlib:identity(Cp))
    end.

-file("src/etui/text.gleam", 38).
?DOC(" Cell width of a string (sum of grapheme widths).\n").
-spec cell_width(binary()) -> integer().
cell_width(S) ->
    _pipe = S,
    _pipe@1 = gleam@string:to_graphemes(_pipe),
    gleam@list:fold(_pipe@1, 0, fun(Acc, G) -> Acc + grapheme_cell_width(G) end).

-file("src/etui/text.gleam", 159).
-spec take_prefix(list(binary()), integer(), integer(), binary()) -> binary().
take_prefix(Graphemes, Available, Width, Acc) ->
    case Graphemes of
        [] ->
            Acc;

        [G | Rest] ->
            G_width = grapheme_cell_width(G),
            case (Width + G_width) =< Available of
                true ->
                    take_prefix(
                        Rest,
                        Available,
                        Width + G_width,
                        <<Acc/binary, G/binary>>
                    );

                false ->
                    Acc
            end
    end.

-file("src/etui/text.gleam", 141).
?DOC(
    " Truncate to max_width cells. Appends ellipsis only if truncation occurs.\n"
    " The ellipsis itself counts toward the budget.\n"
).
-spec truncate(binary(), integer(), binary()) -> binary().
truncate(S, Max_width, Ellipsis) ->
    case Max_width of
        W when W =< 0 ->
            <<""/utf8>>;

        _ ->
            S_width = cell_width(S),
            case S_width =< Max_width of
                true ->
                    S;

                false ->
                    Ellipsis_width = cell_width(Ellipsis),
                    Available = gleam@int:max(0, Max_width - Ellipsis_width),
                    Gs = gleam@string:to_graphemes(S),
                    <<(take_prefix(Gs, Available, 0, <<""/utf8>>))/binary,
                        Ellipsis/binary>>
            end
    end.

-file("src/etui/text.gleam", 250).
-spec hard_break_acc(
    list(binary()),
    integer(),
    integer(),
    binary(),
    list(binary())
) -> list(binary()).
hard_break_acc(Gs, Max_width, Curr_w, Curr, Acc) ->
    case Gs of
        [] ->
            case Curr of
                <<""/utf8>> ->
                    Acc;

                _ ->
                    lists:append(Acc, [Curr])
            end;

        [G | Rest] ->
            Gw = grapheme_cell_width(G),
            case (Curr_w > 0) andalso ((Curr_w + Gw) > Max_width) of
                true ->
                    hard_break_acc(
                        Rest,
                        Max_width,
                        Gw,
                        G,
                        lists:append(Acc, [Curr])
                    );

                false ->
                    hard_break_acc(
                        Rest,
                        Max_width,
                        Curr_w + Gw,
                        <<Curr/binary, G/binary>>,
                        Acc
                    )
            end
    end.

-file("src/etui/text.gleam", 246).
-spec hard_break_word(binary(), integer()) -> list(binary()).
hard_break_word(S, Max_width) ->
    hard_break_acc(gleam@string:to_graphemes(S), Max_width, 0, <<""/utf8>>, []).

-file("src/etui/text.gleam", 196).
-spec wrap_para_words(binary(), integer()) -> list(binary()).
wrap_para_words(S, Max_width) ->
    Words = gleam@string:split(S, <<" "/utf8>>),
    {Rev_lines, Curr} = gleam@list:fold(
        Words,
        {[], <<""/utf8>>},
        fun(Acc, Word) ->
            {Lines_acc, Curr_line} = Acc,
            W_width = cell_width(Word),
            Curr_width = cell_width(Curr_line),
            Space_w = case Curr_line of
                <<""/utf8>> ->
                    0;

                _ ->
                    1
            end,
            case ((Curr_width + Space_w) + W_width) =< Max_width of
                true ->
                    New_line = case Curr_line of
                        <<""/utf8>> ->
                            Word;

                        _ ->
                            <<<<Curr_line/binary, " "/utf8>>/binary,
                                Word/binary>>
                    end,
                    {Lines_acc, New_line};

                false ->
                    Lines2 = case Curr_line of
                        <<""/utf8>> ->
                            Lines_acc;

                        _ ->
                            [Curr_line | Lines_acc]
                    end,
                    case W_width =< Max_width of
                        true ->
                            {Lines2, Word};

                        false ->
                            Chunks = hard_break_word(Word, Max_width),
                            case lists:reverse(Chunks) of
                                [] ->
                                    {Lines2, <<""/utf8>>};

                                [Last | Rest_rev] ->
                                    {lists:append(Rest_rev, Lines2), Last}
                            end
                    end
            end
        end
    ),
    All_rev = case Curr of
        <<""/utf8>> ->
            Rev_lines;

        _ ->
            [Curr | Rev_lines]
    end,
    lists:reverse(All_rev).

-file("src/etui/text.gleam", 188).
-spec wrap_para(binary(), integer()) -> list(binary()).
wrap_para(S, Max_width) ->
    case S of
        <<""/utf8>> ->
            [<<""/utf8>>];

        _ ->
            wrap_para_words(S, Max_width)
    end.

-file("src/etui/text.gleam", 179).
?DOC(
    " Word-wrap to max_width cells. Handles explicit `\\n` newlines.\n"
    " Returns list of lines, each padded to max_width cells.\n"
).
-spec wrap(binary(), integer()) -> list(binary()).
wrap(S, Max_width) ->
    case Max_width of
        W when W =< 0 ->
            [];

        _ ->
            _pipe = gleam@string:split(S, <<"\n"/utf8>>),
            gleam@list:flat_map(
                _pipe,
                fun(Para) -> wrap_para(Para, Max_width) end
            )
    end.

-file("src/etui/text.gleam", 275).
?DOC(" Pad right with spaces to reach `width` cells. Cell-aware.\n").
-spec pad_right(binary(), integer()) -> binary().
pad_right(S, Width) ->
    Cw = cell_width(S),
    case Cw >= Width of
        true ->
            S;

        false ->
            <<S/binary, (gleam@string:repeat(<<" "/utf8>>, Width - Cw))/binary>>
    end.

-file("src/etui/text.gleam", 284).
?DOC(" Pad left with spaces to reach `width` cells. Cell-aware.\n").
-spec pad_left(binary(), integer()) -> binary().
pad_left(S, Width) ->
    Cw = cell_width(S),
    case Cw >= Width of
        true ->
            S;

        false ->
            <<(gleam@string:repeat(<<" "/utf8>>, Width - Cw))/binary, S/binary>>
    end.

-file("src/etui/text.gleam", 293).
?DOC(" Align left/center/right within `width` cells. Cell-aware.\n").
-spec align(binary(), integer(), alignment()) -> binary().
align(S, Width, Alignment) ->
    case Alignment of
        left ->
            pad_right(S, Width);

        right ->
            pad_left(S, Width);

        center ->
            Cw = cell_width(S),
            case Cw >= Width of
                true ->
                    S;

                false ->
                    Total = Width - Cw,
                    Left = Total div 2,
                    Right = Total - Left,
                    <<<<(gleam@string:repeat(<<" "/utf8>>, Left))/binary,
                            S/binary>>/binary,
                        (gleam@string:repeat(<<" "/utf8>>, Right))/binary>>
            end
    end.

-file("src/etui/text.gleam", 359).
-spec is_csi_final(binary()) -> boolean().
is_csi_final(G) ->
    case gleam@string:to_utf_codepoints(G) of
        [Cp | _] ->
            N = gleam_stdlib:identity(Cp),
            (N >= 16#40) andalso (N =< 16#7E);

        [] ->
            false
    end.

-file("src/etui/text.gleam", 329).
-spec strip_loop(list(binary()), binary(), strip_state()) -> binary().
strip_loop(Gs, Acc, State) ->
    case Gs of
        [] ->
            Acc;

        [G | Rest] ->
            {New_acc, New_state} = case {State, G} of
                {plain, <<"\x{001B}"/utf8>>} ->
                    {Acc, esc_seen};

                {plain, _} ->
                    {<<Acc/binary, G/binary>>, plain};

                {esc_seen, <<"["/utf8>>} ->
                    {Acc, csi_body};

                {esc_seen, <<"]"/utf8>>} ->
                    {Acc, osc_body};

                {esc_seen, _} ->
                    {Acc, plain};

                {csi_body, C} ->
                    case is_csi_final(C) of
                        true ->
                            {Acc, plain};

                        false ->
                            {Acc, csi_body}
                    end;

                {osc_body, <<"\x{0007}"/utf8>>} ->
                    {Acc, plain};

                {osc_body, <<"\x{001B}"/utf8>>} ->
                    {Acc, osc_esc_seen};

                {osc_body, _} ->
                    {Acc, osc_body};

                {osc_esc_seen, <<"\\"/utf8>>} ->
                    {Acc, plain};

                {osc_esc_seen, _} ->
                    {Acc, osc_body}
            end,
            strip_loop(Rest, New_acc, New_state)
    end.

-file("src/etui/text.gleam", 317).
?DOC(
    " Strip ANSI escape sequences. Handles CSI (`\\e[…<final>`) and OSC\n"
    " (`\\e]…ST`/`\\e]…BEL`) sequences.\n"
).
-spec strip_ansi(binary()) -> binary().
strip_ansi(S) ->
    strip_loop(gleam@string:to_graphemes(S), <<""/utf8>>, plain).