Skip to main content

src/etui@app.erl

-module(etui@app).
-compile([no_auto_import, nowarn_unused_vars, nowarn_unused_function, nowarn_nomatch, inline]).
-define(FILEPATH, "src/etui/app.gleam").
-export([run/6, run_buffered/6, run_animated/6, run_buffered_cursor/6]).
-export_type([app_result/1, loop_step/2]).

-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 app_result(EIJ) :: {success, EIJ} | {error, binary()}.

-type loop_step(EIK, EIL) :: {step_quit, EIK, EIL} |
    {step_continue, etui@backend:input_event(), EIK, EIL}.

-file("src/etui/app.gleam", 100).
-spec step(
    etui@backend:backend(EIS),
    EIS,
    EIU,
    list(etui@backend:render_op()),
    fun((etui@backend:input_event(), EIU) -> EIU),
    fun((EIU) -> boolean()),
    integer()
) -> loop_step(EIU, EIS).
step(B, Bs, State, Ops, On_event, Should_quit, Poll_timeout_ms) ->
    case (erlang:element(3, B))(Bs, Ops) of
        {ok, Bs2} ->
            case (erlang:element(4, B))(Bs2, Poll_timeout_ms) of
                {ok, {Event, Bs3}} ->
                    Next = On_event(Event, State),
                    case Should_quit(Next) of
                        true ->
                            {step_quit, Next, Bs3};

                        false ->
                            {step_continue, Event, Next, Bs3}
                    end;

                _ ->
                    {step_quit, State, Bs2}
            end;

        _ ->
            {step_quit, State, Bs}
    end.

-file("src/etui/app.gleam", 126).
-spec loop(
    etui@backend:backend(EIY),
    EIY,
    EJA,
    fun((EJA) -> list(etui@backend:render_op())),
    fun((etui@backend:input_event(), EJA) -> EJA),
    fun((EJA) -> boolean()),
    integer()
) -> {EJA, EIY}.
loop(B, Bs, State, Render, On_event, Should_quit, Poll_timeout_ms) ->
    case step(
        B,
        Bs,
        State,
        Render(State),
        On_event,
        Should_quit,
        Poll_timeout_ms
    ) of
        {step_quit, S, Final_bs} ->
            {S, Final_bs};

        {step_continue, _, Next, Bs3} ->
            loop(B, Bs3, Next, Render, On_event, Should_quit, Poll_timeout_ms)
    end.

-file("src/etui/app.gleam", 56).
?DOC(
    " Run the app loop.\n"
    "\n"
    " Lifecycle:\n"
    " 1. `b.init()`, enter raw mode, alt screen.\n"
    " 2. Loop: `render(state)` → emit ops → `b.poll()` → `on_event()`.\n"
    " 3. Exit when `should_quit(state)` returns `True`.\n"
    " 4. `b.cleanup()`, always runs, even on panic.\n"
    "\n"
    " ```gleam\n"
    " app.run(\n"
    "   default.new(),\n"
    "   Model(count: 0),\n"
    "   fn(m) { [Write(int.to_string(m.count))] },\n"
    "   fn(ev, m) { case ev { KeyPress(\"q\") -> m KeyPress(_) -> Model(count: m.count + 1) _ -> m } },\n"
    "   fn(m) { m.count >= 10 },\n"
    "   16,\n"
    " )\n"
    " ```\n"
).
-spec run(
    etui@backend:backend(any()),
    EIP,
    fun((EIP) -> list(etui@backend:render_op())),
    fun((etui@backend:input_event(), EIP) -> EIP),
    fun((EIP) -> boolean()),
    integer()
) -> app_result(EIP).
run(B, Init_state, Render, On_event, Should_quit, Poll_timeout_ms) ->
    case (erlang:element(2, B))() of
        {ok, Bs} ->
            etui_run_ffi:with_cleanup(
                fun() ->
                    {Final_state, Final_bs} = loop(
                        B,
                        Bs,
                        Init_state,
                        Render,
                        On_event,
                        Should_quit,
                        Poll_timeout_ms
                    ),
                    (erlang:element(6, B))(Final_bs),
                    {success, Final_state}
                end,
                fun() -> (erlang:element(6, B))(Bs) end
            );

        _ ->
            {error, <<"Terminal init failed"/utf8>>}
    end.

-file("src/etui/app.gleam", 217).
-spec loop_buffered(
    etui@backend:backend(EJG),
    EJG,
    EJI,
    fun((EJI, etui@geometry:rect()) -> etui@buffer:buffer()),
    fun((etui@backend:input_event(), EJI) -> EJI),
    fun((EJI) -> boolean()),
    integer(),
    etui@buffer:buffer(),
    boolean()
) -> {EJI, EJG}.
loop_buffered(
    B,
    Bs,
    State,
    Render,
    On_event,
    Should_quit,
    Poll_timeout_ms,
    Prev_buf,
    First_frame
) ->
    Screen = etui@buffer:area(Prev_buf),
    Curr_buf = Render(State, Screen),
    Ansi = case First_frame of
        true ->
            etui@buffer:to_ansi(Curr_buf);

        false ->
            etui@buffer:diff_to_ansi(Prev_buf, Curr_buf)
    end,
    Ops = case Ansi of
        <<""/utf8>> ->
            [];

        _ ->
            case First_frame of
                true ->
                    [clear_screen, {move_cursor, 0, 0}, {write, Ansi}];

                false ->
                    [{write, Ansi}]
            end
    end,
    case step(B, Bs, State, Ops, On_event, Should_quit, Poll_timeout_ms) of
        {step_quit, S, Final_bs} ->
            {S, Final_bs};

        {step_continue, Event, Next, Bs3} ->
            {New_prev, Is_first} = case Event of
                {resize, W, H} ->
                    New_screen = etui@geometry:rect_new(0, 0, W, H),
                    {etui@buffer:buffer_new(New_screen), true};

                _ ->
                    {Curr_buf, false}
            end,
            loop_buffered(
                B,
                Bs3,
                Next,
                Render,
                On_event,
                Should_quit,
                Poll_timeout_ms,
                New_prev,
                Is_first
            )
    end.

-file("src/etui/app.gleam", 167).
?DOC(
    " High-level app loop. The render function produces a `Buffer`; the loop\n"
    " diffs it against the previous frame and emits only the changed cells.\n"
    "\n"
    " First frame: full `to_ansi` (clean slate). Subsequent frames: `diff_to_ansi`.\n"
    " On `Resize`: full re-render at new size.\n"
    "\n"
    " ```gleam\n"
    " app.run_buffered(\n"
    "   default.new(),\n"
    "   Model(count: 0),\n"
    "   fn(m, screen) {\n"
    "     buffer.buffer_new(screen)\n"
    "     |> paragraph.render(screen, paragraph.paragraph_new(int.to_string(m.count)))\n"
    "   },\n"
    "   fn(ev, m) { case ev { KeyPress(\"q\") -> m _ -> m } },\n"
    "   fn(m) { m.quit },\n"
    "   16,\n"
    " )\n"
    " ```\n"
).
-spec run_buffered(
    etui@backend:backend(any()),
    EJE,
    fun((EJE, etui@geometry:rect()) -> etui@buffer:buffer()),
    fun((etui@backend:input_event(), EJE) -> EJE),
    fun((EJE) -> boolean()),
    integer()
) -> app_result(EJE).
run_buffered(B, Init_state, Render, On_event, Should_quit, Poll_timeout_ms) ->
    case (erlang:element(2, B))() of
        {ok, Bs} ->
            etui_run_ffi:with_cleanup(
                fun() ->
                    _ = (erlang:element(3, B))(
                        Bs,
                        [{write, etui@cursor:hide()}]
                    ),
                    {Size, Bs2} = case (erlang:element(5, B))(Bs) of
                        {ok, {Sz, Bs1}} ->
                            {Sz, Bs1};

                        _ ->
                            {{terminal_size, 80, 24}, Bs}
                    end,
                    Screen = etui@geometry:rect_new(
                        0,
                        0,
                        erlang:element(2, Size),
                        erlang:element(3, Size)
                    ),
                    Blank = etui@buffer:buffer_new(Screen),
                    Init_state@1 = On_event(
                        {resize,
                            erlang:element(2, Size),
                            erlang:element(3, Size)},
                        Init_state
                    ),
                    {Final_state, Final_bs} = loop_buffered(
                        B,
                        Bs2,
                        Init_state@1,
                        Render,
                        On_event,
                        Should_quit,
                        Poll_timeout_ms,
                        Blank,
                        true
                    ),
                    _ = (erlang:element(3, B))(
                        Final_bs,
                        [{write, etui@cursor:show()}]
                    ),
                    (erlang:element(6, B))(Final_bs),
                    {success, Final_state}
                end,
                fun() ->
                    _ = (erlang:element(3, B))(
                        Bs,
                        [{write, etui@cursor:show()}]
                    ),
                    (erlang:element(6, B))(Bs)
                end
            );

        _ ->
            {error, <<"Terminal init failed"/utf8>>}
    end.

-file("src/etui/app.gleam", 341).
-spec loop_animated(
    etui@backend:backend(EJN),
    EJN,
    EJP,
    fun((EJP, etui@geometry:rect(), etui@anim:anim_state()) -> etui@buffer:buffer()),
    fun((etui@backend:input_event(), EJP) -> EJP),
    fun((EJP) -> boolean()),
    integer(),
    etui@buffer:buffer(),
    boolean(),
    etui@anim:anim_state()
) -> {EJP, EJN}.
loop_animated(
    B,
    Bs,
    State,
    Render,
    On_event,
    Should_quit,
    Poll_timeout_ms,
    Prev_buf,
    First_frame,
    Anim_state
) ->
    Screen = etui@buffer:area(Prev_buf),
    Curr_buf = Render(State, Screen, Anim_state),
    Ansi = case First_frame of
        true ->
            etui@buffer:to_ansi(Curr_buf);

        false ->
            etui@buffer:diff_to_ansi(Prev_buf, Curr_buf)
    end,
    Ops = case Ansi of
        <<""/utf8>> ->
            [];

        _ ->
            case First_frame of
                true ->
                    [clear_screen, {move_cursor, 0, 0}, {write, Ansi}];

                false ->
                    [{write, Ansi}]
            end
    end,
    Next_anim = etui@anim:tick(Anim_state),
    case step(B, Bs, State, Ops, On_event, Should_quit, Poll_timeout_ms) of
        {step_quit, S, Final_bs} ->
            {S, Final_bs};

        {step_continue, Event, Next, Bs3} ->
            {New_prev, Is_first} = case Event of
                {resize, W, H} ->
                    New_screen = etui@geometry:rect_new(0, 0, W, H),
                    {etui@buffer:buffer_new(New_screen), true};

                _ ->
                    {Curr_buf, false}
            end,
            loop_animated(
                B,
                Bs3,
                Next,
                Render,
                On_event,
                Should_quit,
                Poll_timeout_ms,
                New_prev,
                Is_first,
                Next_anim
            )
    end.

-file("src/etui/app.gleam", 293).
?DOC(
    " Like `run_buffered` but passes an `anim.AnimState` to the render function,\n"
    " auto-ticked every frame. Use when your UI has spinners, blinking widgets,\n"
    " marquees, or any frame-dependent animation, no manual tick needed.\n"
    "\n"
    " ```gleam\n"
    " app.run_animated(\n"
    "   default.new(),\n"
    "   Model(quit: False),\n"
    "   fn(m, screen, anim_state) {\n"
    "     let frame = anim_state.frame\n"
    "     buffer.buffer_new(screen)\n"
    "     |> spinner.render(area, spinner.spinner_new() |> spinner.with_frame(frame))\n"
    "   },\n"
    "   fn(ev, m) { case ev { backend.KeyPress(\"q\") -> Model(quit: True) _ -> m } },\n"
    "   fn(m) { m.quit },\n"
    "   16,\n"
    " )\n"
    " ```\n"
).
-spec run_animated(
    etui@backend:backend(any()),
    EJL,
    fun((EJL, etui@geometry:rect(), etui@anim:anim_state()) -> etui@buffer:buffer()),
    fun((etui@backend:input_event(), EJL) -> EJL),
    fun((EJL) -> boolean()),
    integer()
) -> app_result(EJL).
run_animated(B, Init_state, Render, On_event, Should_quit, Poll_timeout_ms) ->
    case (erlang:element(2, B))() of
        {ok, Bs} ->
            etui_run_ffi:with_cleanup(
                fun() ->
                    _ = (erlang:element(3, B))(
                        Bs,
                        [{write, etui@cursor:hide()}]
                    ),
                    {Size, Bs2} = case (erlang:element(5, B))(Bs) of
                        {ok, {Sz, Bs1}} ->
                            {Sz, Bs1};

                        _ ->
                            {{terminal_size, 80, 24}, Bs}
                    end,
                    Screen = etui@geometry:rect_new(
                        0,
                        0,
                        erlang:element(2, Size),
                        erlang:element(3, Size)
                    ),
                    Blank = etui@buffer:buffer_new(Screen),
                    Init_state@1 = On_event(
                        {resize,
                            erlang:element(2, Size),
                            erlang:element(3, Size)},
                        Init_state
                    ),
                    {Final_state, Final_bs} = loop_animated(
                        B,
                        Bs2,
                        Init_state@1,
                        Render,
                        On_event,
                        Should_quit,
                        Poll_timeout_ms,
                        Blank,
                        true,
                        etui@anim:anim_new()
                    ),
                    _ = (erlang:element(3, B))(
                        Final_bs,
                        [{write, etui@cursor:show()}]
                    ),
                    (erlang:element(6, B))(Final_bs),
                    {success, Final_state}
                end,
                fun() ->
                    _ = (erlang:element(3, B))(
                        Bs,
                        [{write, etui@cursor:show()}]
                    ),
                    (erlang:element(6, B))(Bs)
                end
            );

        _ ->
            {error, <<"Terminal init failed"/utf8>>}
    end.

-file("src/etui/app.gleam", 475).
-spec loop_buffered_cursor(
    etui@backend:backend(EJW),
    EJW,
    EJY,
    fun((EJY, etui@geometry:rect()) -> {etui@buffer:buffer(),
        {ok, etui@geometry:position()} | {error, nil}}),
    fun((etui@backend:input_event(), EJY) -> EJY),
    fun((EJY) -> boolean()),
    integer(),
    etui@buffer:buffer(),
    boolean()
) -> {EJY, EJW}.
loop_buffered_cursor(
    B,
    Bs,
    State,
    Render,
    On_event,
    Should_quit,
    Poll_timeout_ms,
    Prev_buf,
    First_frame
) ->
    Screen = etui@buffer:area(Prev_buf),
    {Curr_buf, Cursor_pos} = Render(State, Screen),
    Ansi = case First_frame of
        true ->
            etui@buffer:to_ansi(Curr_buf);

        false ->
            etui@buffer:diff_to_ansi(Prev_buf, Curr_buf)
    end,
    Cursor_ansi = case Cursor_pos of
        {ok, Pos} ->
            <<<<(etui@cursor:hide())/binary,
                    (etui@cursor:move_to(
                        erlang:element(3, Pos) + 1,
                        erlang:element(2, Pos) + 1
                    ))/binary>>/binary,
                (etui@cursor:show())/binary>>;

        _ ->
            etui@cursor:hide()
    end,
    Ops = case Ansi of
        <<""/utf8>> ->
            [{write, Cursor_ansi}];

        _ ->
            case First_frame of
                true ->
                    [clear_screen,
                        {move_cursor, 0, 0},
                        {write, <<Ansi/binary, Cursor_ansi/binary>>}];

                false ->
                    [{write, <<Ansi/binary, Cursor_ansi/binary>>}]
            end
    end,
    case step(B, Bs, State, Ops, On_event, Should_quit, Poll_timeout_ms) of
        {step_quit, S, Final_bs} ->
            {S, Final_bs};

        {step_continue, Event, Next, Bs3} ->
            {New_prev, Is_first} = case Event of
                {resize, W, H} ->
                    New_screen = etui@geometry:rect_new(0, 0, W, H),
                    {etui@buffer:buffer_new(New_screen), true};

                _ ->
                    {Curr_buf, false}
            end,
            loop_buffered_cursor(
                B,
                Bs3,
                Next,
                Render,
                On_event,
                Should_quit,
                Poll_timeout_ms,
                New_prev,
                Is_first
            )
    end.

-file("src/etui/app.gleam", 425).
?DOC(
    " Like `run_buffered` but the render function also returns an optional cursor\n"
    " position as `Result(geometry.Position, Nil)`.\n"
    "\n"
    " - `Ok(pos)`, shows the cursor at `pos` (0-based). Use for text inputs and\n"
    "   text areas where the user needs to see the insertion point.\n"
    " - `Error(Nil)`, hides the cursor. Use for read-only views.\n"
    "\n"
    " The cursor is hidden automatically on init and restored on exit.\n"
    "\n"
    " ```gleam\n"
    " app.run_buffered_cursor(\n"
    "   default.new(),\n"
    "   Model(text: \"\", cursor: 0),\n"
    "   fn(m, screen) {\n"
    "     let buf = buffer.buffer_new(screen) |> input.render(area, w, input_state)\n"
    "     let cursor_pos = geometry.Position(x: area.x + input_state.cursor_x + 1, y: area.y)\n"
    "     #(buf, Ok(cursor_pos))\n"
    "   },\n"
    "   on_event,\n"
    "   fn(m) { m.quit },\n"
    "   16,\n"
    " )\n"
    " ```\n"
).
-spec run_buffered_cursor(
    etui@backend:backend(any()),
    EJS,
    fun((EJS, etui@geometry:rect()) -> {etui@buffer:buffer(),
        {ok, etui@geometry:position()} | {error, nil}}),
    fun((etui@backend:input_event(), EJS) -> EJS),
    fun((EJS) -> boolean()),
    integer()
) -> app_result(EJS).
run_buffered_cursor(
    B,
    Init_state,
    Render,
    On_event,
    Should_quit,
    Poll_timeout_ms
) ->
    case (erlang:element(2, B))() of
        {ok, Bs} ->
            etui_run_ffi:with_cleanup(
                fun() ->
                    {Size, Bs2} = case (erlang:element(5, B))(Bs) of
                        {ok, {Sz, Bs1}} ->
                            {Sz, Bs1};

                        _ ->
                            {{terminal_size, 80, 24}, Bs}
                    end,
                    Screen = etui@geometry:rect_new(
                        0,
                        0,
                        erlang:element(2, Size),
                        erlang:element(3, Size)
                    ),
                    Blank = etui@buffer:buffer_new(Screen),
                    _ = (erlang:element(3, B))(
                        Bs2,
                        [{write, etui@cursor:hide()}]
                    ),
                    Init_state@1 = On_event(
                        {resize,
                            erlang:element(2, Size),
                            erlang:element(3, Size)},
                        Init_state
                    ),
                    {Final_state, Final_bs} = loop_buffered_cursor(
                        B,
                        Bs2,
                        Init_state@1,
                        Render,
                        On_event,
                        Should_quit,
                        Poll_timeout_ms,
                        Blank,
                        true
                    ),
                    _ = (erlang:element(3, B))(
                        Final_bs,
                        [{write, etui@cursor:show()}]
                    ),
                    (erlang:element(6, B))(Final_bs),
                    {success, Final_state}
                end,
                fun() ->
                    _ = (erlang:element(3, B))(
                        Bs,
                        [{write, etui@cursor:show()}]
                    ),
                    (erlang:element(6, B))(Bs)
                end
            );

        _ ->
            {error, <<"Terminal init failed"/utf8>>}
    end.