Skip to main content

src/etui/widgets/dialog.gleam

/// Modal dialog widget: message with Confirm / Cancel buttons.
///
/// Renders a centered popup with a message and two focusable buttons.
/// Drive it with `toggle`, `confirm`, `cancel` and read result via `is_confirmed`.
///
/// ```gleam
/// import etui/widgets/dialog
///
/// let d = dialog.dialog_new("Delete this file?")
/// let state = dialog.state_new()
///
/// // In on_event:
/// let state = case keys.match(k) {
///   keys.Tab    -> dialog.toggle(state)
///   keys.Enter  -> state  // handle below
///   keys.Escape -> dialog.cancel(state)
///   _           -> state
/// }
/// let confirmed = dialog.is_confirmed(state) && key == "enter"
///
/// // In render:
/// dialog.render(buf, screen_area, d, state)
/// ```
import etui/buffer
import etui/geometry
import etui/style
import etui/text
import etui/widgets/block

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

/// Dialog configuration.
pub type Dialog {
  Dialog(
    message: String,
    confirm_label: String,
    cancel_label: String,
    /// Dialog box width (0 = auto: max of message width + 4 and 30).
    width: Int,
    /// Total dialog height including border (0 = auto).
    height: Int,
    fg: style.Color,
    bg: style.Color,
    confirm_style: style.Style,
    cancel_style: style.Style,
    /// Style for the focused button.
    focused_style: style.Style,
    border: block.Border,
  )
}

/// Which button is currently focused.
pub type DialogButton {
  Confirm
  Cancel
}

/// Mutable dialog state.
pub type DialogState {
  DialogState(focused: DialogButton)
}

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

/// Dialog with default labels ("OK" / "Cancel") and a rounded border.
pub fn dialog_new(message: String) -> Dialog {
  Dialog(
    message: message,
    confirm_label: " OK ",
    cancel_label: " Cancel ",
    width: 0,
    height: 0,
    fg: style.Default,
    bg: style.Default,
    confirm_style: style.Style(
      fg: style.Default,
      bg: style.Default,
      modifier: style.none(),
    ),
    cancel_style: style.Style(
      fg: style.Default,
      bg: style.Default,
      modifier: style.none(),
    ),
    focused_style: style.Style(
      fg: style.Default,
      bg: style.Default,
      modifier: style.reverse(),
    ),
    border: block.Rounded,
  )
}

pub fn state_new() -> DialogState {
  DialogState(focused: Confirm)
}

// ─────────────────────────────────────────────────────────────────
// Builders

pub fn with_labels(d: Dialog, confirm: String, cancel: String) -> Dialog {
  Dialog(..d, confirm_label: confirm, cancel_label: cancel)
}

pub fn with_size(d: Dialog, width: Int, height: Int) -> Dialog {
  Dialog(..d, width: width, height: height)
}

pub fn with_colors(d: Dialog, fg: style.Color, bg: style.Color) -> Dialog {
  Dialog(..d, fg: fg, bg: bg)
}

pub fn with_style(d: Dialog, s: style.Style) -> Dialog {
  Dialog(..d, fg: s.fg, bg: s.bg)
}

pub fn with_focused_style(d: Dialog, s: style.Style) -> Dialog {
  Dialog(..d, focused_style: s)
}

pub fn with_border(d: Dialog, b: block.Border) -> Dialog {
  Dialog(..d, border: b)
}

// ─────────────────────────────────────────────────────────────────
// State operations

/// Toggle focus between Confirm and Cancel.
pub fn toggle(state: DialogState) -> DialogState {
  case state.focused {
    Confirm -> DialogState(focused: Cancel)
    Cancel -> DialogState(focused: Confirm)
  }
}

/// Focus the Confirm button.
pub fn focus_confirm(_state: DialogState) -> DialogState {
  DialogState(focused: Confirm)
}

/// Focus the Cancel button.
pub fn focus_cancel(_state: DialogState) -> DialogState {
  DialogState(focused: Cancel)
}

/// Convenience: focus Cancel (same as pressing Escape conceptually).
pub fn cancel(_state: DialogState) -> DialogState {
  DialogState(focused: Cancel)
}

/// `True` if the Confirm button is focused.
pub fn is_confirmed(state: DialogState) -> Bool {
  state.focused == Confirm
}

// ─────────────────────────────────────────────────────────────────
// Rendering

/// Render the dialog centered within `area`.
pub fn render(
  buf: buffer.Buffer,
  area: geometry.Rect,
  d: Dialog,
  state: DialogState,
) -> buffer.Buffer {
  let msg_w = text.cell_width(d.message)
  let btn_w =
    text.cell_width(d.confirm_label) + text.cell_width(d.cancel_label) + 3
  let content_w = case msg_w > btn_w {
    True -> msg_w
    False -> btn_w
  }
  let box_w = case d.width > 0 {
    True -> d.width
    False -> content_w + 4
  }
  let box_h = case d.height > 0 {
    True -> d.height
    False -> 6
  }
  let box_w = case box_w < 20 {
    True -> 20
    False -> box_w
  }
  let box_w = case box_w > area.size.width {
    True -> area.size.width
    False -> box_w
  }
  let box_h = case box_h > area.size.height {
    True -> area.size.height
    False -> box_h
  }

  let x = area.position.x + { area.size.width - box_w } / 2
  let y = area.position.y + { area.size.height - box_h } / 2
  let box_area =
    geometry.Rect(
      position: geometry.Position(x: x, y: y),
      size: geometry.Size(width: box_w, height: box_h),
    )

  let blk =
    block.block_new()
    |> block.with_border(d.border)
    |> block.with_style(d.fg, d.bg)
    |> block.with_bg_fill
  let buf1 = block.render(buf, box_area, blk)
  let inner = block.inner(box_area, blk)

  let msg_x =
    inner.position.x + { inner.size.width - text.cell_width(d.message) } / 2
  let msg_y = inner.position.y + { inner.size.height - 3 } / 2
  let buf2 = case msg_y >= inner.position.y && inner.size.height > 0 {
    False -> buf1
    True ->
      buffer.set_string(
        buf1,
        geometry.Position(x: msg_x, y: msg_y),
        text.truncate(d.message, inner.size.width, "…"),
        d.fg,
        d.bg,
        style.none(),
      )
  }

  let btn_y = inner.position.y + inner.size.height - 1
  case btn_y >= inner.position.y && inner.size.height >= 3 {
    False -> buf2
    True -> render_buttons(buf2, inner, d, state, btn_y)
  }
}

fn render_buttons(
  buf: buffer.Buffer,
  inner: geometry.Rect,
  d: Dialog,
  state: DialogState,
  btn_y: Int,
) -> buffer.Buffer {
  let conf_w = text.cell_width(d.confirm_label)
  let canc_w = text.cell_width(d.cancel_label)
  let total_btn_w = conf_w + 1 + canc_w
  let btn_x = inner.position.x + { inner.size.width - total_btn_w } / 2

  let #(conf_st, canc_st) = case state.focused {
    Confirm -> #(d.focused_style, d.cancel_style)
    Cancel -> #(d.confirm_style, d.focused_style)
  }

  let buf1 =
    buffer.set_string(
      buf,
      geometry.Position(x: btn_x, y: btn_y),
      d.confirm_label,
      conf_st.fg,
      conf_st.bg,
      conf_st.modifier,
    )
  let buf2 =
    buffer.set_string(
      buf1,
      geometry.Position(x: btn_x + conf_w + 1, y: btn_y),
      d.cancel_label,
      canc_st.fg,
      canc_st.bg,
      canc_st.modifier,
    )
  buf2
}