Skip to main content

src/etui/focus.gleam

/// Focus ring, cycle keyboard focus between named widget slots.
///
/// Keep a `FocusRing` in your app model. Route keyboard events to whichever
/// widget `is_focused`. Advance with Tab / Shift-Tab.
///
/// ```gleam
/// import etui/focus
///
/// let ring = focus.focus_new(["sidebar", "main", "statusbar"])
///
/// // In update:
/// let ring = case event {
///   KeyPress("tab")     -> focus.focus_next(ring)
///   KeyPress("backtab") -> focus.focus_prev(ring)   // Shift-Tab
///   _ -> ring
/// }
///
/// // In render:
/// let sidebar_active = focus.is_focused(ring, "sidebar")
/// ```
import gleam/int
import gleam/list

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

/// Ordered ring of focus slot IDs. Exactly one slot is active at a time.
pub type FocusRing {
  FocusRing(ids: List(String), current: Int)
}

// ─────────────────────────────────────────────────────────────────
// Constructors

/// Create a focus ring from a list of slot IDs.
/// The first slot starts focused. Empty list creates an inert ring.
pub fn focus_new(ids: List(String)) -> FocusRing {
  FocusRing(ids: ids, current: 0)
}

// ─────────────────────────────────────────────────────────────────
// Queries

/// ID of the currently focused slot, or `None` if the ring is empty.
pub fn focused(ring: FocusRing) -> Result(String, Nil) {
  get_at(ring.ids, ring.current)
}

/// `True` if `id` is the currently focused slot.
pub fn is_focused(ring: FocusRing, id: String) -> Bool {
  case focused(ring) {
    Ok(current_id) -> current_id == id
    Error(_) -> False
  }
}

/// 0-based index of the currently focused slot.
pub fn current_index(ring: FocusRing) -> Int {
  ring.current
}

/// Total number of slots in the ring.
pub fn size(ring: FocusRing) -> Int {
  list.length(ring.ids)
}

// ─────────────────────────────────────────────────────────────────
// Navigation

/// Move focus to the next slot (wraps around).
pub fn focus_next(ring: FocusRing) -> FocusRing {
  let n = list.length(ring.ids)
  case n {
    0 -> ring
    _ ->
      FocusRing(
        ..ring,
        current: { ring.current + 1 } |> int.modulo(n) |> unwrap_zero,
      )
  }
}

/// Move focus to the previous slot (wraps around).
pub fn focus_prev(ring: FocusRing) -> FocusRing {
  let n = list.length(ring.ids)
  case n {
    0 -> ring
    _ ->
      FocusRing(
        ..ring,
        current: { ring.current + n - 1 } |> int.modulo(n) |> unwrap_zero,
      )
  }
}

/// Move focus to the slot with the given `id`. No-op if `id` not found.
pub fn focus_id(ring: FocusRing, id: String) -> FocusRing {
  case index_of(ring.ids, id, 0) {
    Ok(i) -> FocusRing(..ring, current: i)
    Error(_) -> ring
  }
}

/// Move focus to the slot at the given index (clamped to valid range).
pub fn focus_index(ring: FocusRing, idx: Int) -> FocusRing {
  let n = list.length(ring.ids)
  case n {
    0 -> ring
    _ -> FocusRing(..ring, current: int.clamp(idx, 0, n - 1))
  }
}

// ─────────────────────────────────────────────────────────────────
// Internal helpers

fn get_at(lst: List(String), idx: Int) -> Result(String, Nil) {
  case idx {
    i if i < 0 -> Error(Nil)
    0 ->
      case lst {
        [h, ..] -> Ok(h)
        [] -> Error(Nil)
      }
    _ ->
      case lst {
        [] -> Error(Nil)
        [_, ..rest] -> get_at(rest, idx - 1)
      }
  }
}

fn index_of(lst: List(String), target: String, acc: Int) -> Result(Int, Nil) {
  case lst {
    [] -> Error(Nil)
    [h, ..rest] ->
      case h == target {
        True -> Ok(acc)
        False -> index_of(rest, target, acc + 1)
      }
  }
}

fn unwrap_zero(r: Result(Int, Nil)) -> Int {
  case r {
    Ok(n) -> n
    Error(_) -> 0
  }
}