Skip to main content

src/etui@widgets@textarea.erl

-module(etui@widgets@textarea).
-compile([no_auto_import, nowarn_unused_vars, nowarn_unused_function, nowarn_nomatch, inline]).
-define(FILEPATH, "src/etui/widgets/textarea.gleam").
-export([textarea_new/0, with_max_lines/2, with_max_line_length/2, with_colors/3, with_style/2, with_cursor_style/2, state_new/0, state_from_string/1, value/1, line_count/1, effective_offset/2, cursor_screen_pos/2, insert_char/3, backspace/1, newline/2, move_cursor_left/1, move_cursor_right/1, move_cursor_up/1, move_cursor_down/1, move_to_line_start/1, move_to_line_end/1, delete_to_line_end/1, render/4]).
-export_type([text_area/0, text_area_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 text_area() :: {text_area,
        integer(),
        integer(),
        etui@style:color(),
        etui@style:color(),
        etui@style:style()}.

-type text_area_state() :: {text_area_state,
        list(binary()),
        integer(),
        integer()}.

-file("src/etui/widgets/textarea.gleam", 72).
?DOC(" New textarea with default styles and no limits.\n").
-spec textarea_new() -> text_area().
textarea_new() ->
    {text_area,
        0,
        0,
        default,
        default,
        {style, default, default, etui@style:reverse()}}.

-file("src/etui/widgets/textarea.gleam", 86).
-spec with_max_lines(text_area(), integer()) -> text_area().
with_max_lines(W, N) ->
    {text_area,
        N,
        erlang:element(3, W),
        erlang:element(4, W),
        erlang:element(5, W),
        erlang:element(6, W)}.

-file("src/etui/widgets/textarea.gleam", 90).
-spec with_max_line_length(text_area(), integer()) -> text_area().
with_max_line_length(W, N) ->
    {text_area,
        erlang:element(2, W),
        N,
        erlang:element(4, W),
        erlang:element(5, W),
        erlang:element(6, W)}.

-file("src/etui/widgets/textarea.gleam", 94).
-spec with_colors(text_area(), etui@style:color(), etui@style:color()) -> text_area().
with_colors(W, Fg, Bg) ->
    {text_area,
        erlang:element(2, W),
        erlang:element(3, W),
        Fg,
        Bg,
        erlang:element(6, W)}.

-file("src/etui/widgets/textarea.gleam", 98).
-spec with_style(text_area(), etui@style:style()) -> text_area().
with_style(W, S) ->
    {text_area,
        erlang:element(2, W),
        erlang:element(3, W),
        erlang:element(2, S),
        erlang:element(3, S),
        erlang:element(6, W)}.

-file("src/etui/widgets/textarea.gleam", 102).
-spec with_cursor_style(text_area(), etui@style:style()) -> text_area().
with_cursor_style(W, S) ->
    {text_area,
        erlang:element(2, W),
        erlang:element(3, W),
        erlang:element(4, W),
        erlang:element(5, W),
        S}.

-file("src/etui/widgets/textarea.gleam", 110).
?DOC(" Empty state: one empty line, cursor at top-left.\n").
-spec state_new() -> text_area_state().
state_new() ->
    {text_area_state, [<<""/utf8>>], 0, 0}.

-file("src/etui/widgets/textarea.gleam", 115).
?DOC(" State pre-populated from a string (splits on `\\n`).\n").
-spec state_from_string(binary()) -> text_area_state().
state_from_string(S) ->
    Lines = gleam@string:split(S, <<"\n"/utf8>>),
    Row = erlang:length(Lines) - 1,
    Last_line = case gleam@list:last(Lines) of
        {ok, L} ->
            L;

        {error, _} ->
            <<""/utf8>>
    end,
    {text_area_state,
        Lines,
        etui@text:cell_width(Last_line),
        gleam@int:max(0, Row)}.

-file("src/etui/widgets/textarea.gleam", 133).
?DOC(" All lines joined with `\"\\n\"`.\n").
-spec value(text_area_state()) -> binary().
value(State) ->
    gleam@string:join(erlang:element(2, State), <<"\n"/utf8>>).

-file("src/etui/widgets/textarea.gleam", 138).
?DOC(" Number of lines.\n").
-spec line_count(text_area_state()) -> integer().
line_count(State) ->
    erlang:length(erlang:element(2, State)).

-file("src/etui/widgets/textarea.gleam", 482).
-spec scroll_offset(integer(), integer()) -> integer().
scroll_offset(Cursor_y, Visible_h) ->
    case Visible_h =< 0 of
        true ->
            0;

        false ->
            case Cursor_y < Visible_h of
                true ->
                    0;

                false ->
                    (Cursor_y - Visible_h) + 1
            end
    end.

-file("src/etui/widgets/textarea.gleam", 145).
?DOC(
    " Effective scroll offset for a viewport of `visible_h` rows.\n"
    " Returns the index of the first visible line so the cursor stays in view.\n"
    " Pass as `offset` to `scrollbar.scrollbar_new`.\n"
).
-spec effective_offset(text_area_state(), integer()) -> integer().
effective_offset(State, Visible_h) ->
    scroll_offset(erlang:element(4, State), Visible_h).

-file("src/etui/widgets/textarea.gleam", 152).
?DOC(
    " Screen position of the hardware cursor within `area`.\n"
    " Returns `Error(Nil)` when the cursor column is beyond the area width\n"
    " (mirrors the render rule: no cursor cell is drawn off-screen).\n"
).
-spec cursor_screen_pos(text_area_state(), etui@geometry:rect()) -> {ok,
        etui@geometry:position()} |
    {error, nil}.
cursor_screen_pos(State, Area) ->
    case erlang:element(3, State) >= erlang:element(2, erlang:element(3, Area)) of
        true ->
            {error, nil};

        false ->
            Scroll = scroll_offset(
                erlang:element(4, State),
                erlang:element(3, erlang:element(3, Area))
            ),
            {ok,
                {position,
                    erlang:element(2, erlang:element(2, Area)) + erlang:element(
                        3,
                        State
                    ),
                    (erlang:element(3, erlang:element(2, Area)) + erlang:element(
                        4,
                        State
                    ))
                    - Scroll}}
    end.

-file("src/etui/widgets/textarea.gleam", 437).
-spec set_line(list(binary()), integer(), binary()) -> list(binary()).
set_line(Lines, Idx, New_val) ->
    gleam@list:index_map(Lines, fun(Line, I) -> case I =:= Idx of
                true ->
                    New_val;

                false ->
                    Line
            end end).

-file("src/etui/widgets/textarea.gleam", 426).
-spec get_line(list(binary()), integer()) -> binary().
get_line(Lines, Idx) ->
    case Idx < 0 of
        true ->
            <<""/utf8>>;

        false ->
            case gleam@list:drop(Lines, Idx) of
                [H | _] ->
                    H;

                [] ->
                    <<""/utf8>>
            end
    end.

-file("src/etui/widgets/textarea.gleam", 172).
?DOC(" Insert a character at the current cursor position.\n").
-spec insert_char(text_area(), text_area_state(), binary()) -> text_area_state().
insert_char(W, State, Ch) ->
    Line = get_line(erlang:element(2, State), erlang:element(4, State)),
    Line_cells = etui@text:cell_width(Line),
    case (erlang:element(3, W) > 0) andalso (Line_cells >= erlang:element(3, W)) of
        true ->
            State;

        false ->
            Before = etui@text:truncate(
                Line,
                erlang:element(3, State),
                <<""/utf8>>
            ),
            After = gleam@string:drop_start(Line, string:length(Before)),
            New_line = <<<<Before/binary, Ch/binary>>/binary, After/binary>>,
            {text_area_state,
                set_line(
                    erlang:element(2, State),
                    erlang:element(4, State),
                    New_line
                ),
                erlang:element(3, State) + etui@text:cell_width(Ch),
                erlang:element(4, State)}
    end.

-file("src/etui/widgets/textarea.gleam", 446).
-spec delete_line(list(binary()), integer()) -> list(binary()).
delete_line(Lines, Idx) ->
    gleam@list:index_fold(Lines, [], fun(Acc, Line, I) -> case I =:= Idx of
                true ->
                    Acc;

                false ->
                    lists:append(Acc, [Line])
            end end).

-file("src/etui/widgets/textarea.gleam", 196).
?DOC(
    " Delete the character immediately before the cursor.\n"
    " If at column 0, merges the current line with the previous line.\n"
).
-spec backspace(text_area_state()) -> text_area_state().
backspace(State) ->
    case erlang:element(3, State) > 0 of
        true ->
            Line = get_line(erlang:element(2, State), erlang:element(4, State)),
            Before = etui@text:truncate(
                Line,
                erlang:element(3, State) - 1,
                <<""/utf8>>
            ),
            Graphemes_before = string:length(
                etui@text:truncate(Line, erlang:element(3, State), <<""/utf8>>)
            ),
            After = gleam@string:drop_start(Line, Graphemes_before),
            {text_area_state,
                set_line(
                    erlang:element(2, State),
                    erlang:element(4, State),
                    <<Before/binary, After/binary>>
                ),
                etui@text:cell_width(Before),
                erlang:element(4, State)};

        false ->
            case erlang:element(4, State) > 0 of
                false ->
                    State;

                true ->
                    Prev = get_line(
                        erlang:element(2, State),
                        erlang:element(4, State) - 1
                    ),
                    Curr = get_line(
                        erlang:element(2, State),
                        erlang:element(4, State)
                    ),
                    Merged = <<Prev/binary, Curr/binary>>,
                    New_x = etui@text:cell_width(Prev),
                    New_lines = begin
                        _pipe = delete_line(
                            erlang:element(2, State),
                            erlang:element(4, State)
                        ),
                        set_line(_pipe, erlang:element(4, State) - 1, Merged)
                    end,
                    {text_area_state,
                        New_lines,
                        New_x,
                        erlang:element(4, State) - 1}
            end
    end.

-file("src/etui/widgets/textarea.gleam", 463).
-spec insert_line_after_loop(
    list(binary()),
    integer(),
    binary(),
    integer(),
    list(binary())
) -> list(binary()).
insert_line_after_loop(Lines, Idx, New_line, I, Acc) ->
    case Lines of
        [] ->
            lists:reverse(Acc);

        [H | Rest] ->
            Acc2 = case I =:= Idx of
                true ->
                    [New_line, H | Acc];

                false ->
                    [H | Acc]
            end,
            insert_line_after_loop(Rest, Idx, New_line, I + 1, Acc2)
    end.

-file("src/etui/widgets/textarea.gleam", 455).
-spec insert_line_after(list(binary()), integer(), binary()) -> list(binary()).
insert_line_after(Lines, Idx, New_line) ->
    insert_line_after_loop(Lines, Idx, New_line, 0, []).

-file("src/etui/widgets/textarea.gleam", 232).
?DOC(" Insert a newline at the cursor. Splits the current line.\n").
-spec newline(text_area(), text_area_state()) -> text_area_state().
newline(W, State) ->
    N_lines = erlang:length(erlang:element(2, State)),
    case (erlang:element(2, W) > 0) andalso (N_lines >= erlang:element(2, W)) of
        true ->
            State;

        false ->
            Line = get_line(erlang:element(2, State), erlang:element(4, State)),
            Before = etui@text:truncate(
                Line,
                erlang:element(3, State),
                <<""/utf8>>
            ),
            After = gleam@string:drop_start(Line, string:length(Before)),
            New_lines = begin
                _pipe = set_line(
                    erlang:element(2, State),
                    erlang:element(4, State),
                    Before
                ),
                insert_line_after(_pipe, erlang:element(4, State), After)
            end,
            {text_area_state, New_lines, 0, erlang:element(4, State) + 1}
    end.

-file("src/etui/widgets/textarea.gleam", 252).
?DOC(" Move cursor one cell left. Wraps to end of previous line.\n").
-spec move_cursor_left(text_area_state()) -> text_area_state().
move_cursor_left(State) ->
    case erlang:element(3, State) > 0 of
        true ->
            Line = get_line(erlang:element(2, State), erlang:element(4, State)),
            New_x = etui@text:cell_width(
                etui@text:truncate(
                    Line,
                    erlang:element(3, State) - 1,
                    <<""/utf8>>
                )
            ),
            {text_area_state,
                erlang:element(2, State),
                New_x,
                erlang:element(4, State)};

        false ->
            case erlang:element(4, State) > 0 of
                false ->
                    State;

                true ->
                    Prev = get_line(
                        erlang:element(2, State),
                        erlang:element(4, State) - 1
                    ),
                    {text_area_state,
                        erlang:element(2, State),
                        etui@text:cell_width(Prev),
                        erlang:element(4, State) - 1}
            end
    end.

-file("src/etui/widgets/textarea.gleam", 500).
-spec grapheme_width_at(binary(), integer()) -> integer().
grapheme_width_at(S, Cell_pos) ->
    Prefix = etui@text:truncate(S, Cell_pos, <<""/utf8>>),
    Rest = gleam@string:drop_start(S, string:length(Prefix)),
    case gleam@string:to_graphemes(Rest) of
        [G | _] ->
            etui@text:cell_width(G);

        [] ->
            1
    end.

-file("src/etui/widgets/textarea.gleam", 275).
?DOC(" Move cursor one cell right. Wraps to start of next line.\n").
-spec move_cursor_right(text_area_state()) -> text_area_state().
move_cursor_right(State) ->
    Line = get_line(erlang:element(2, State), erlang:element(4, State)),
    case erlang:element(3, State) < etui@text:cell_width(Line) of
        true ->
            Step = grapheme_width_at(Line, erlang:element(3, State)),
            {text_area_state,
                erlang:element(2, State),
                erlang:element(3, State) + Step,
                erlang:element(4, State)};

        false ->
            N_lines = erlang:length(erlang:element(2, State)),
            case erlang:element(4, State) < (N_lines - 1) of
                false ->
                    State;

                true ->
                    {text_area_state,
                        erlang:element(2, State),
                        0,
                        erlang:element(4, State) + 1}
            end
    end.

-file("src/etui/widgets/textarea.gleam", 495).
-spec snap_to_boundary(binary(), integer()) -> integer().
snap_to_boundary(S, Cell_pos) ->
    Clamped = gleam@int:min(Cell_pos, etui@text:cell_width(S)),
    etui@text:cell_width(etui@text:truncate(S, Clamped, <<""/utf8>>)).

-file("src/etui/widgets/textarea.gleam", 294).
?DOC(" Move cursor up one line, clamping x to the new line's width.\n").
-spec move_cursor_up(text_area_state()) -> text_area_state().
move_cursor_up(State) ->
    case erlang:element(4, State) > 0 of
        false ->
            State;

        true ->
            New_y = erlang:element(4, State) - 1,
            Prev = get_line(erlang:element(2, State), New_y),
            {text_area_state,
                erlang:element(2, State),
                snap_to_boundary(Prev, erlang:element(3, State)),
                New_y}
    end.

-file("src/etui/widgets/textarea.gleam", 310).
?DOC(" Move cursor down one line, clamping x to the new line's width.\n").
-spec move_cursor_down(text_area_state()) -> text_area_state().
move_cursor_down(State) ->
    N_lines = erlang:length(erlang:element(2, State)),
    case erlang:element(4, State) < (N_lines - 1) of
        false ->
            State;

        true ->
            New_y = erlang:element(4, State) + 1,
            Next = get_line(erlang:element(2, State), New_y),
            {text_area_state,
                erlang:element(2, State),
                snap_to_boundary(Next, erlang:element(3, State)),
                New_y}
    end.

-file("src/etui/widgets/textarea.gleam", 327).
?DOC(" Move cursor to beginning of current line.\n").
-spec move_to_line_start(text_area_state()) -> text_area_state().
move_to_line_start(State) ->
    {text_area_state, erlang:element(2, State), 0, erlang:element(4, State)}.

-file("src/etui/widgets/textarea.gleam", 332).
?DOC(" Move cursor to end of current line.\n").
-spec move_to_line_end(text_area_state()) -> text_area_state().
move_to_line_end(State) ->
    Line = get_line(erlang:element(2, State), erlang:element(4, State)),
    {text_area_state,
        erlang:element(2, State),
        etui@text:cell_width(Line),
        erlang:element(4, State)}.

-file("src/etui/widgets/textarea.gleam", 338).
?DOC(" Delete from cursor to end of current line.\n").
-spec delete_to_line_end(text_area_state()) -> text_area_state().
delete_to_line_end(State) ->
    Line = get_line(erlang:element(2, State), erlang:element(4, State)),
    Before = etui@text:truncate(Line, erlang:element(3, State), <<""/utf8>>),
    {text_area_state,
        set_line(erlang:element(2, State), erlang:element(4, State), Before),
        erlang:element(3, State),
        erlang:element(4, State)}.

-file("src/etui/widgets/textarea.gleam", 509).
-spec grapheme_at_cell(binary(), integer()) -> binary().
grapheme_at_cell(S, Cell_pos) ->
    Prefix = etui@text:truncate(S, Cell_pos, <<""/utf8>>),
    Rest = gleam@string:drop_start(S, string:length(Prefix)),
    case gleam@string:to_graphemes(Rest) of
        [G | _] ->
            G;

        [] ->
            <<" "/utf8>>
    end.

-file("src/etui/widgets/textarea.gleam", 386).
-spec render_line(
    etui@buffer:buffer(),
    etui@geometry:rect(),
    text_area(),
    text_area_state(),
    binary(),
    integer(),
    boolean()
) -> etui@buffer:buffer().
render_line(Buf, Area, W, State, Line, Y, Is_cursor_row) ->
    Width = erlang:element(2, erlang:element(3, Area)),
    Truncated = etui@text:truncate(Line, Width, <<""/utf8>>),
    Padded = etui@text:pad_right(Truncated, Width),
    Buf2 = etui@buffer:set_string(
        Buf,
        {position, erlang:element(2, erlang:element(2, Area)), Y},
        Padded,
        erlang:element(4, W),
        erlang:element(5, W),
        etui@style:none()
    ),
    case Is_cursor_row andalso (erlang:element(3, State) < Width) of
        false ->
            Buf2;

        true ->
            Cursor_ch = grapheme_at_cell(Line, erlang:element(3, State)),
            etui@buffer:set_string(
                Buf2,
                {position,
                    erlang:element(2, erlang:element(2, Area)) + erlang:element(
                        3,
                        State
                    ),
                    Y},
                Cursor_ch,
                erlang:element(2, erlang:element(6, W)),
                erlang:element(3, erlang:element(6, W)),
                erlang:element(4, erlang:element(6, W))
            )
    end.

-file("src/etui/widgets/textarea.gleam", 365).
-spec render_lines(
    etui@buffer:buffer(),
    etui@geometry:rect(),
    text_area(),
    text_area_state(),
    integer(),
    integer()
) -> etui@buffer:buffer().
render_lines(Buf, Area, W, State, Scroll, Row_offset) ->
    case Row_offset >= erlang:element(3, erlang:element(3, Area)) of
        true ->
            Buf;

        false ->
            Line_idx = Scroll + Row_offset,
            Line = get_line(erlang:element(2, State), Line_idx),
            Y = erlang:element(3, erlang:element(2, Area)) + Row_offset,
            Is_cursor_row = Line_idx =:= erlang:element(4, State),
            Buf2 = render_line(Buf, Area, W, State, Line, Y, Is_cursor_row),
            render_lines(Buf2, Area, W, State, Scroll, Row_offset + 1)
    end.

-file("src/etui/widgets/textarea.gleam", 349).
?DOC(
    " Render the textarea. Scrolls vertically so the cursor line is visible.\n"
    " Highlights the cursor cell with `cursor_style`.\n"
).
-spec render(
    etui@buffer:buffer(),
    etui@geometry:rect(),
    text_area(),
    text_area_state()
) -> etui@buffer:buffer().
render(Buf, Area, W, State) ->
    case (erlang:element(3, erlang:element(3, Area)) =< 0) orelse (erlang:element(
        2,
        erlang:element(3, Area)
    )
    =< 0) of
        true ->
            Buf;

        false ->
            Visible_h = erlang:element(3, erlang:element(3, Area)),
            Scroll = scroll_offset(erlang:element(4, State), Visible_h),
            render_lines(Buf, Area, W, State, Scroll, 0)
    end.