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