Skip to main content

src/etui/backend/erlang.gleam

/// Erlang/BEAM terminal backend with true raw mode.
/// Uses native Erlang modules for terminal control (inspired by Etch).
import etui/backend.{
  type Error, type InputEvent, type RenderOp, type TerminalSize, ClearScreen,
  DisableMouse, EnableMouse, EnterAltScreen, ExitAltScreen, IOError, MouseLeft,
  MouseMiddle, MousePress, MouseRelease, MouseRight, MouseScroll, MoveCursor,
  Write,
}
import gleam/int
import gleam/list
import gleam/string

// ─────────────────────────────────────────────────────────────────
// Types

pub type ErlangTerminalState {
  ErlangTerminalState(raw_mode_active: Bool, cols: Int, rows: Int, mouse: Bool)
}

// ─────────────────────────────────────────────────────────────────
// Backend construction

pub fn new() -> backend.Backend(ErlangTerminalState) {
  new_impl(False)
}

pub fn new_with_mouse() -> backend.Backend(ErlangTerminalState) {
  new_impl(True)
}

fn new_impl(mouse: Bool) -> backend.Backend(ErlangTerminalState) {
  backend.Backend(
    init: fn() { init_terminal(mouse) },
    render: render_ops,
    poll: poll_input,
    next_size: get_terminal_size,
    cleanup: cleanup_terminal,
  )
}

// ─────────────────────────────────────────────────────────────────
// FFI declarations (native Erlang)

@external(erlang, "etui_tty_state", "init")
fn init_tty_state() -> Nil {
  panic as "etui/backend/erlang requires the Erlang target"
}

@external(erlang, "etui_tty_state", "set_raw")
fn set_raw_state(is_raw: Bool) -> Nil {
  let _ = is_raw
  panic as "etui/backend/erlang requires the Erlang target"
}

@external(erlang, "etui_terminal_ffi", "enter_raw")
fn enter_raw_ffi() -> Nil {
  panic as "etui/backend/erlang requires the Erlang target"
}

@external(erlang, "etui_terminal_ffi", "exit_raw")
fn exit_raw_ffi() -> Nil {
  panic as "etui/backend/erlang requires the Erlang target"
}

@external(erlang, "etui_terminal_ffi", "window_size")
fn window_size_ffi() -> Result(#(Int, Int), String) {
  panic as "etui/backend/erlang requires the Erlang target"
}

@external(erlang, "io", "put_chars")
fn write_string(s: String) -> Nil {
  let _ = s
  panic as "etui/backend/erlang requires the Erlang target"
}

@external(erlang, "etui_terminal_ffi", "read_with_timeout")
fn read_with_timeout_ffi(timeout_ms: Int) -> Result(String, Nil) {
  let _ = timeout_ms
  panic as "etui/backend/erlang requires the Erlang target"
}

@external(erlang, "etui_terminal_ffi", "install_sigint_cleanup")
fn install_sigint_cleanup_ffi(cleanup: fn() -> Nil) -> Nil {
  let _ = cleanup
  panic as "etui/backend/erlang requires the Erlang target"
}

@external(erlang, "etui_terminal_ffi", "uninstall_sigint_cleanup")
fn uninstall_sigint_cleanup_ffi() -> Nil {
  panic as "etui/backend/erlang requires the Erlang target"
}

@external(erlang, "etui_terminal_ffi", "write_cleanup")
fn write_cleanup_ffi() -> Nil {
  panic as "etui/backend/erlang requires the Erlang target"
}

// ─────────────────────────────────────────────────────────────────
// Implementation

fn init_terminal(mouse: Bool) -> Result(ErlangTerminalState, Error) {
  init_tty_state()
  let init_ops = case mouse {
    True -> [EnterAltScreen, ClearScreen, EnableMouse]
    False -> [EnterAltScreen, ClearScreen]
  }
  case write_ops_to_stdout(init_ops) {
    Ok(Nil) -> {
      enter_raw_ffi()
      set_raw_state(True)
      let #(cols, rows) = case window_size_ffi() {
        Ok(#(c, r)) -> #(c - 1, r)
        Error(_) -> #(79, 24)
      }
      install_sigint_cleanup_ffi(fn() { terminal_cleanup() })
      Ok(ErlangTerminalState(
        raw_mode_active: True,
        cols: cols,
        rows: rows,
        mouse: mouse,
      ))
    }
    Error(reason) -> Error(IOError(reason))
  }
}

fn render_ops(
  state: ErlangTerminalState,
  ops: List(RenderOp),
) -> Result(ErlangTerminalState, Error) {
  case write_ops_to_stdout(ops) {
    Ok(Nil) -> Ok(state)
    Error(reason) -> Error(IOError(reason))
  }
}

fn poll_input(
  state: ErlangTerminalState,
  timeout_ms: Int,
) -> Result(#(InputEvent, ErlangTerminalState), Error) {
  let input_event = case read_with_timeout_ffi(timeout_ms) {
    Ok(input) -> parse_input(input)
    Error(_) -> backend.Tick
  }
  case window_size_ffi() {
    Ok(#(c, r)) ->
      case c - 1 == state.cols && r == state.rows {
        True -> Ok(#(input_event, state))
        False ->
          Ok(#(
            backend.Resize(c - 1, r),
            ErlangTerminalState(..state, cols: c - 1, rows: r),
          ))
      }
    Error(_) -> Ok(#(input_event, state))
  }
}

fn get_terminal_size(
  state: ErlangTerminalState,
) -> Result(#(TerminalSize, ErlangTerminalState), Error) {
  case window_size_ffi() {
    Ok(#(w, h)) -> Ok(#(backend.TerminalSize(width: w - 1, height: h), state))
    Error(_) -> Ok(#(backend.TerminalSize(width: 79, height: 24), state))
  }
}

// Shared cleanup: idempotent, safe to call from both normal exit and SIGINT.
// Order matters: write escape sequences BEFORE exit_raw_ffi so the sequences
// reach the terminal while the I/O group leader is still set up correctly.
fn terminal_cleanup() -> Nil {
  uninstall_sigint_cleanup_ffi()
  write_cleanup_ffi()
  exit_raw_ffi()
  set_raw_state(False)
  Nil
}

fn cleanup_terminal(_state: ErlangTerminalState) -> Nil {
  terminal_cleanup()
}

// ─────────────────────────────────────────────────────────────────
// Helpers

fn write_ops_to_stdout(ops: List(RenderOp)) -> Result(Nil, String) {
  let output =
    ops
    |> list.fold("", fn(acc, op) { acc <> render_op_to_string(op) })

  case output {
    "" -> Ok(Nil)
    s -> {
      write_string(s)
      Ok(Nil)
    }
  }
}

fn render_op_to_string(op: RenderOp) -> String {
  case op {
    MoveCursor(x, y) ->
      "\u{001B}[" <> int_to_string(y + 1) <> ";" <> int_to_string(x + 1) <> "H"
    Write(s) -> s
    ClearScreen -> "\u{001B}[2J\u{001B}[H"
    EnterAltScreen -> "\u{001B}[?1049h"
    ExitAltScreen -> "\u{001B}[?1049l"
    // Enable SGR extended mouse tracking (button + scroll events).
    EnableMouse -> "\u{001B}[?1000h\u{001B}[?1006h"
    // Clear all common xterm mouse/alt-scroll modes so the shell does not
    // inherit wheel/click reporting after the app exits.
    DisableMouse ->
      "\u{001B}[?1007l\u{001B}[?1015l\u{001B}[?1006l\u{001B}[?1005l\u{001B}[?1003l\u{001B}[?1002l\u{001B}[?1000l"
  }
}

// Parse a raw terminal input string into an InputEvent.
// Normalises escape sequences to friendly key names so keys.match/1 works.
fn parse_input(input: String) -> InputEvent {
  case input {
    "" -> backend.Tick
    // SGR mouse: \e[<Cb;Cx;CyM (press) or \e[<Cb;Cx;Cym (release)
    _ ->
      case string.starts_with(input, "\u{001B}[<") {
        True -> parse_sgr_mouse(string.drop_start(input, 3))
        False -> backend.KeyPress(normalise_key(input))
      }
  }
}

// Map raw terminal byte sequences to friendly key name strings.
// These match the constants expected by keys.match/1 in keys.gleam.
fn normalise_key(raw: String) -> String {
  case raw {
    // ── Arrow keys ─────────────────────────────────────────────
    "\u{001B}[A" | "\u{001B}OA" -> "up"
    "\u{001B}[B" | "\u{001B}OB" -> "down"
    "\u{001B}[C" | "\u{001B}OC" -> "right"
    "\u{001B}[D" | "\u{001B}OD" -> "left"
    // ── Enter / newline ────────────────────────────────────────
    "\r" | "\n" -> "enter"
    // ── Backspace / Delete ─────────────────────────────────────
    "\u{007F}" | "\u{0008}" -> "backspace"
    "\u{001B}[3~" -> "delete"
    // ── Tab / Shift-Tab ────────────────────────────────────────
    "\t" -> "tab"
    "\u{001B}[Z" -> "backtab"
    // ── Escape (lone) ──────────────────────────────────────────
    "\u{001B}" -> "esc"
    // ── Insert / Page / Home / End ─────────────────────────────
    "\u{001B}[2~" -> "insert"
    "\u{001B}[5~" -> "pageup"
    "\u{001B}[6~" -> "pagedown"
    "\u{001B}[H" | "\u{001B}OH" | "\u{001B}[1~" -> "home"
    "\u{001B}[F" | "\u{001B}OF" | "\u{001B}[4~" -> "end"
    // ── Function keys (xterm VT220 + SS3 variants) ─────────────
    "\u{001B}[11~" | "\u{001B}OP" -> "f1"
    "\u{001B}[12~" | "\u{001B}OQ" -> "f2"
    "\u{001B}[13~" | "\u{001B}OR" -> "f3"
    "\u{001B}[14~" | "\u{001B}OS" -> "f4"
    "\u{001B}[15~" -> "f5"
    "\u{001B}[17~" -> "f6"
    "\u{001B}[18~" -> "f7"
    "\u{001B}[19~" -> "f8"
    "\u{001B}[20~" -> "f9"
    "\u{001B}[21~" -> "f10"
    "\u{001B}[23~" -> "f11"
    "\u{001B}[24~" -> "f12"
    // ── Ctrl+letter: codepoints 0x01–0x1A (a–z) ───────────────
    "\u{0001}" -> "ctrl+a"
    "\u{0002}" -> "ctrl+b"
    "\u{0003}" -> "ctrl+c"
    "\u{0004}" -> "ctrl+d"
    "\u{0005}" -> "ctrl+e"
    "\u{0006}" -> "ctrl+f"
    "\u{0007}" -> "ctrl+g"
    "\u{000B}" -> "ctrl+k"
    "\u{000C}" -> "ctrl+l"
    "\u{000E}" -> "ctrl+n"
    "\u{000F}" -> "ctrl+o"
    "\u{0010}" -> "ctrl+p"
    "\u{0011}" -> "ctrl+q"
    "\u{0012}" -> "ctrl+r"
    "\u{0013}" -> "ctrl+s"
    "\u{0014}" -> "ctrl+t"
    "\u{0015}" -> "ctrl+u"
    "\u{0016}" -> "ctrl+v"
    "\u{0017}" -> "ctrl+w"
    "\u{0018}" -> "ctrl+x"
    "\u{0019}" -> "ctrl+y"
    "\u{001A}" -> "ctrl+z"
    // ── Alt+letter: ESC followed by a single printable char ────
    s ->
      case string.starts_with(s, "\u{001B}") && string.length(s) == 2 {
        True -> "alt+" <> string.drop_start(s, 1)
        False -> s
      }
  }
}

// Parse the payload after "\e[<": "Cb;Cx;CyM" or "Cb;Cx;Cym"
fn parse_sgr_mouse(payload: String) -> InputEvent {
  let is_press = string.ends_with(payload, "M")
  let trimmed = case is_press {
    True -> string.drop_end(payload, 1)
    False -> string.drop_end(payload, 1)
  }
  case string.split(trimmed, ";") {
    [cb_str, cx_str, cy_str] ->
      case int.parse(cb_str), int.parse(cx_str), int.parse(cy_str) {
        Ok(cb), Ok(cx), Ok(cy) -> {
          // Coordinates are 1-based in SGR; convert to 0-based.
          let x = cx - 1
          let y = cy - 1
          case cb {
            // Scroll events (button code 64 = up, 65 = down)
            64 -> MouseScroll(x, y, True)
            65 -> MouseScroll(x, y, False)
            // Button press / release
            _ -> {
              let btn = case cb % 4 {
                0 -> MouseLeft
                1 -> MouseMiddle
                2 -> MouseRight
                _ -> MouseLeft
              }
              case is_press {
                True -> MousePress(x, y, btn)
                False -> MouseRelease(x, y, btn)
              }
            }
          }
        }
        _, _, _ -> backend.KeyPress("\u{001B}[<" <> payload)
      }
    _ -> backend.KeyPress("\u{001B}[<" <> payload)
  }
}

fn int_to_string(n: Int) -> String {
  case n {
    0 -> "0"
    1 -> "1"
    2 -> "2"
    3 -> "3"
    4 -> "4"
    5 -> "5"
    6 -> "6"
    7 -> "7"
    8 -> "8"
    9 -> "9"
    _ -> {
      let digit = case n % 10 {
        0 -> "0"
        1 -> "1"
        2 -> "2"
        3 -> "3"
        4 -> "4"
        5 -> "5"
        6 -> "6"
        7 -> "7"
        8 -> "8"
        9 -> "9"
        _ -> "?"
      }
      int_to_string(n / 10) <> digit
    }
  }
}