Skip to main content

lib/skua/setup/prompt.ex

defmodule Skua.Setup.Prompt do
  @moduledoc false

  # Interactive selection. On a real terminal it uses raw-mode arrow-key
  # navigation; everywhere else (CI, pipes, no /dev/tty) it degrades to a
  # numbered prompt so the same task still works non-interactively.
  #
  # The robust BEAM trick for raw keystrokes: read bytes straight from /dev/tty
  # (bypassing Erlang's line-editing group leader) while `stty` holds the
  # terminal in raw, no-echo mode. Menu output still goes to stdout. The pure
  # decode (Keys) and render (Menu) live in their own, unit-tested modules.

  alias Skua.Setup.{Keys, Menu}

  @doc "True when we can drive a real interactive terminal."
  def interactive? do
    match?({:ok, _}, :io.columns()) and File.exists?("/dev/tty")
  end

  @doc "Single-select; returns the chosen item. `default` is a 0-based index."
  def select(label, items, default \\ 0) when is_list(items) and items != [] do
    d = clamp(default, items)

    if interactive?() do
      # If raw-mode setup raises (exotic terminal, stty refused), degrade to the
      # numbered prompt. A user cancel uses `exit`, which `rescue` does not catch.
      try do
        with_raw(fn -> select_loop(label, items, d) end)
      rescue
        _ -> numbered_select(label, items, d)
      end
    else
      numbered_select(label, items, d)
    end
  end

  @doc "Multi-select; returns the list of chosen items. `defaults` is a list of items pre-checked."
  def multiselect(label, items, defaults \\ []) when is_list(items) do
    if interactive?() do
      try do
        with_raw(fn -> multiselect_loop(label, items, 0, default_set(items, defaults)) end)
      rescue
        _ -> numbered_multiselect(label, items, defaults)
      end
    else
      numbered_multiselect(label, items, defaults)
    end
  end

  # === raw-mode loops =======================================================
  defp select_loop(label, items, cursor) do
    n = length(items)
    redraw(Menu.render_select(label, items, cursor))

    case action() do
      :up -> select_loop(label, items, rem(cursor - 1 + n, n))
      :down -> select_loop(label, items, rem(cursor + 1, n))
      :enter -> Enum.at(items, cursor)
      :quit -> cancel()
      _ -> select_loop(label, items, cursor)
    end
  end

  defp multiselect_loop(label, items, cursor, selected) do
    n = length(items)
    redraw(Menu.render_multiselect(label, items, cursor, selected))

    case action() do
      :up -> multiselect_loop(label, items, rem(cursor - 1 + n, n), selected)
      :down -> multiselect_loop(label, items, rem(cursor + 1, n), selected)
      :space -> multiselect_loop(label, items, cursor, toggle(selected, cursor))
      :enter -> selected |> Enum.sort() |> Enum.map(&Enum.at(items, &1))
      :quit -> cancel()
      _ -> multiselect_loop(label, items, cursor, selected)
    end
  end

  # === raw-mode plumbing ====================================================
  defp with_raw(fun) do
    # `-isig` is the important one: it stops Ctrl-C from raising SIGINT (which
    # would drop into the BEAM break menu and bypass the `after` restore). With
    # it off, Ctrl-C arrives as byte 3 -> Keys.decode -> :quit -> clean cancel.
    {_, status} = stty("-echo -icanon -isig min 1 time 0")
    if status != 0, do: raise("could not put the terminal into raw mode")
    Process.put(:skua_menu_lines, 0)

    # The /dev/tty open is INSIDE the try, so if it fails the `after` still runs
    # `stty sane` — the terminal is never left in raw mode.
    try do
      {:ok, tty} = :file.open(~c"/dev/tty", [:read, :raw, :binary])
      Process.put(:skua_tty, tty)

      try do
        result = fun.()
        IO.write("\n")
        result
      after
        :file.close(tty)
      end
    after
      stty("sane")
      Process.delete(:skua_tty)
      Process.delete(:skua_menu_lines)
    end
  end

  defp stty(args), do: System.cmd("sh", ["-c", "stty #{args} < /dev/tty"], stderr_to_stdout: true)

  # Read one logical key (handles the 3-byte arrow escape sequences).
  defp action do
    case read1() do
      "\e" ->
        case read1() do
          "[" -> Keys.decode("\e[" <> to_string(read1()))
          "O" -> Keys.decode("\eO" <> to_string(read1()))
          _ -> :quit
        end

      :eof ->
        :quit

      byte ->
        Keys.decode(byte)
    end
  end

  defp read1 do
    case :file.read(Process.get(:skua_tty), 1) do
      {:ok, b} -> b
      _ -> :eof
    end
  end

  # Redraw in place: move up over the previous render, clear to end of screen.
  defp redraw(str) do
    prev = Process.get(:skua_menu_lines, 0)
    up = if prev > 0, do: "\e[#{prev}A", else: ""
    IO.write([up, "\e[0J", str])
    Process.put(:skua_menu_lines, count_lines(str))
  end

  defp count_lines(str), do: str |> :binary.matches("\n") |> length()

  # === numbered fallback (non-interactive) ==================================
  defp numbered_select(label, items, default) do
    IO.puts(["\n  ", label])

    Enum.each(Enum.with_index(items, 1), fn {it, i} ->
      IO.puts(["    ", to_string(i), ") ", it])
    end)

    case prompt("  choose [#{default + 1}]: ") do
      "" ->
        Enum.at(items, default)

      s ->
        case Integer.parse(s) do
          {i, _} when i >= 1 and i <= length(items) -> Enum.at(items, i - 1)
          _ -> numbered_select(label, items, default)
        end
    end
  end

  defp numbered_multiselect(label, items, defaults) do
    default_nums =
      defaults
      |> Enum.map(&(Enum.find_index(items, fn x -> x == &1 end) + 1))
      |> Enum.sort()
      |> Enum.join(",")

    IO.puts(["\n  ", label, " (comma-separated numbers to enable)"])

    Enum.each(Enum.with_index(items, 1), fn {it, i} ->
      IO.puts(["    ", to_string(i), ") ", it])
    end)

    case prompt("  enable [#{default_nums}]: ") do
      "" ->
        defaults

      s ->
        s
        |> String.split([",", " "], trim: true)
        |> Enum.flat_map(fn t ->
          case Integer.parse(t) do
            {i, _} when i >= 1 and i <= length(items) -> [Enum.at(items, i - 1)]
            _ -> []
          end
        end)
    end
  end

  # `:eof` (closed stdin) -> "" so a non-interactive caller falls through to the
  # default instead of crashing on String.trim(:eof).
  defp prompt(msg) do
    case Mix.shell().prompt(msg) do
      s when is_binary(s) -> String.trim(s)
      _ -> ""
    end
  end

  # === helpers ==============================================================
  defp toggle(set, i),
    do: if(MapSet.member?(set, i), do: MapSet.delete(set, i), else: MapSet.put(set, i))

  defp default_set(items, defaults) do
    defaults
    |> Enum.map(&Enum.find_index(items, fn x -> x == &1 end))
    |> Enum.reject(&is_nil/1)
    |> MapSet.new()
  end

  defp clamp(i, items) when i >= 0 and i < length(items), do: i
  defp clamp(_, _), do: 0

  defp cancel do
    Mix.shell().info("\n  Cancelled.")
    exit({:shutdown, 0})
  end
end