etui/app

Types

pub type AppResult(state) {
  Success(final_state: state)
  Error(reason: String)
}

Constructors

  • Success(final_state: state)
  • Error(reason: String)

Values

pub fn run(
  b: backend.Backend(backend_state),
  init_state: state,
  render: fn(state) -> List(backend.RenderOp),
  on_event: fn(backend.InputEvent, state) -> state,
  should_quit: fn(state) -> Bool,
  poll_timeout_ms: Int,
) -> AppResult(state)

Run the app loop.

Lifecycle:

  1. b.init(), enter raw mode, alt screen.
  2. Loop: render(state) → emit ops → b.poll()on_event().
  3. Exit when should_quit(state) returns True.
  4. b.cleanup(), always runs, even on panic.
app.run(
  default.new(),
  Model(count: 0),
  fn(m) { [Write(int.to_string(m.count))] },
  fn(ev, m) { case ev { KeyPress("q") -> m KeyPress(_) -> Model(count: m.count + 1) _ -> m } },
  fn(m) { m.count >= 10 },
  16,
)
pub fn run_animated(
  b: backend.Backend(backend_state),
  init_state: state,
  render: fn(state, geometry.Rect, anim.AnimState) -> buffer.Buffer,
  on_event: fn(backend.InputEvent, state) -> state,
  should_quit: fn(state) -> Bool,
  poll_timeout_ms: Int,
) -> AppResult(state)

Like run_buffered but passes an anim.AnimState to the render function, auto-ticked every frame. Use when your UI has spinners, blinking widgets, marquees, or any frame-dependent animation, no manual tick needed.

app.run_animated(
  default.new(),
  Model(quit: False),
  fn(m, screen, anim_state) {
    let frame = anim_state.frame
    buffer.buffer_new(screen)
    |> spinner.render(area, spinner.spinner_new() |> spinner.with_frame(frame))
  },
  fn(ev, m) { case ev { backend.KeyPress("q") -> Model(quit: True) _ -> m } },
  fn(m) { m.quit },
  16,
)
pub fn run_buffered(
  b: backend.Backend(backend_state),
  init_state: state,
  render: fn(state, geometry.Rect) -> buffer.Buffer,
  on_event: fn(backend.InputEvent, state) -> state,
  should_quit: fn(state) -> Bool,
  poll_timeout_ms: Int,
) -> AppResult(state)

High-level app loop. The render function produces a Buffer; the loop diffs it against the previous frame and emits only the changed cells.

First frame: full to_ansi (clean slate). Subsequent frames: diff_to_ansi. On Resize: full re-render at new size.

app.run_buffered(
  default.new(),
  Model(count: 0),
  fn(m, screen) {
    buffer.buffer_new(screen)
    |> paragraph.render(screen, paragraph.paragraph_new(int.to_string(m.count)))
  },
  fn(ev, m) { case ev { KeyPress("q") -> m _ -> m } },
  fn(m) { m.quit },
  16,
)
pub fn run_buffered_cursor(
  b: backend.Backend(backend_state),
  init_state: state,
  render: fn(state, geometry.Rect) -> #(
    buffer.Buffer,
    Result(geometry.Position, Nil),
  ),
  on_event: fn(backend.InputEvent, state) -> state,
  should_quit: fn(state) -> Bool,
  poll_timeout_ms: Int,
) -> AppResult(state)

Like run_buffered but the render function also returns an optional cursor position as Result(geometry.Position, Nil).

  • Ok(pos), shows the cursor at pos (0-based). Use for text inputs and text areas where the user needs to see the insertion point.
  • Error(Nil), hides the cursor. Use for read-only views.

The cursor is hidden automatically on init and restored on exit.

app.run_buffered_cursor(
  default.new(),
  Model(text: "", cursor: 0),
  fn(m, screen) {
    let buf = buffer.buffer_new(screen) |> input.render(area, w, input_state)
    let cursor_pos = geometry.Position(x: area.x + input_state.cursor_x + 1, y: area.y)
    #(buf, Ok(cursor_pos))
  },
  on_event,
  fn(m) { m.quit },
  16,
)
Search Document