Packages

Declarative terminal styling for Elixir. Colors, borders, padding, layout, tables, trees, spinners, and interactive select menus.

Current section

Files

Jump to
esc lib esc multi_select_table.ex
Raw

lib/esc/multi_select_table.ex

defmodule Esc.MultiSelectTable do
@moduledoc """
Interactive table-based multi-selection for terminal applications.
MultiSelectTable displays items in a grid layout and allows users to navigate
with arrow keys (or h/j/k/l), toggle selections with Space, and confirm with Enter.
## Example
alias Esc.MultiSelectTable
tags = ~w(elixir phoenix ecto liveview tailwind alpine docker kubernetes)
case MultiSelectTable.new(tags) |> MultiSelectTable.run() do
{:ok, selected} -> IO.puts("Selected: \#{Enum.join(selected, ", ")}")
:cancelled -> IO.puts("Cancelled")
end
## Keyboard Controls
- `Left` / `h` / `Shift+Tab` - Move cursor left
- `Down` / `j` - Move cursor down
- `Up` / `k` - Move cursor up
- `Right` / `l` / `Tab` - Move cursor right
- `Space` - Toggle selection on current item
- `Enter` - Confirm selections (if minimum met)
- `Escape` / `q` - Cancel selection
- `Home` / `g` - Jump to first item
- `End` / `G` - Jump to last item
- `a` - Select all items
- `n` - Clear all selections
## Theme Integration
When a global theme is set and `use_theme` is enabled (default),
the table automatically uses theme colors for cursor, selections, and borders.
"""
defstruct items: [],
cursor_index: 0,
selected_indices: MapSet.new(),
columns: :auto,
cursor_style: nil,
item_style: nil,
selected_style: nil,
selected_marker: "*",
border: :rounded,
use_theme: true,
min_selections: 0,
max_selections: nil,
show_help: true,
help_style: nil
@type item :: String.t() | {String.t(), term()}
@type t :: %__MODULE__{
items: [item()],
cursor_index: non_neg_integer(),
selected_indices: MapSet.t(non_neg_integer()),
columns: :auto | pos_integer(),
cursor_style: Esc.Style.t() | nil,
item_style: Esc.Style.t() | nil,
selected_style: Esc.Style.t() | nil,
selected_marker: String.t(),
border: atom() | nil,
use_theme: boolean(),
min_selections: non_neg_integer(),
max_selections: non_neg_integer() | nil,
show_help: boolean(),
help_style: Esc.Style.t() | nil
}
# Cursor adds [] around text = 2 chars, marker = 1 char, plus padding = 5 total extra
@cursor_overhead 5
# ===========================================================================
# Core Functions
# ===========================================================================
@doc """
Creates a new multi-select table with the given items.
Items can be strings or `{display_text, return_value}` tuples.
"""
@spec new([item()]) :: t()
def new(items \\ []) when is_list(items) do
%__MODULE__{items: items}
end
@doc """
Adds an item to the multi-select table.
"""
@spec item(t(), item()) :: t()
def item(%__MODULE__{} = table, item) do
%{table | items: table.items ++ [item]}
end
@doc """
Pre-selects items by index or value.
"""
@spec preselect(t(), [non_neg_integer()] | [term()]) :: t()
def preselect(%__MODULE__{} = table, selections) when is_list(selections) do
indices =
selections
|> Enum.flat_map(fn selection ->
cond do
is_integer(selection) and selection >= 0 ->
[selection]
true ->
table.items
|> Enum.with_index()
|> Enum.find_value([], fn {item, idx} ->
if get_return_value_for_item(item) == selection, do: [idx], else: nil
end)
end
end)
|> Enum.filter(&(&1 < length(table.items)))
|> MapSet.new()
%{table | selected_indices: MapSet.union(table.selected_indices, indices)}
end
@doc """
Sets the number of columns.
Use `:auto` (default) to calculate based on terminal width and item widths.
"""
@spec columns(t(), :auto | pos_integer()) :: t()
def columns(%__MODULE__{} = table, :auto), do: %{table | columns: :auto}
def columns(%__MODULE__{} = table, cols) when is_integer(cols) and cols > 0 do
%{table | columns: cols}
end
# ===========================================================================
# Styling Functions
# ===========================================================================
@doc """
Sets the style for the currently focused cell.
"""
@spec cursor_style(t(), Esc.Style.t()) :: t()
def cursor_style(%__MODULE__{} = table, style) do
%{table | cursor_style: style}
end
@doc """
Sets the style for non-focused, non-selected cells.
"""
@spec item_style(t(), Esc.Style.t()) :: t()
def item_style(%__MODULE__{} = table, style) do
%{table | item_style: style}
end
@doc """
Sets the style for selected cells (not currently focused).
"""
@spec selected_style(t(), Esc.Style.t()) :: t()
def selected_style(%__MODULE__{} = table, style) do
%{table | selected_style: style}
end
@doc """
Sets the marker shown in selected cells.
Default is `"*"`.
"""
@spec selected_marker(t(), String.t()) :: t()
def selected_marker(%__MODULE__{} = table, marker) when is_binary(marker) do
%{table | selected_marker: marker}
end
@doc """
Sets the table border style.
"""
@spec border(t(), atom() | nil) :: t()
def border(%__MODULE__{} = table, style) do
%{table | border: style}
end
# ===========================================================================
# Selection Constraints
# ===========================================================================
@doc """
Sets minimum required selections.
"""
@spec min_selections(t(), non_neg_integer()) :: t()
def min_selections(%__MODULE__{} = table, min) when is_integer(min) and min >= 0 do
%{table | min_selections: min}
end
@doc """
Sets maximum allowed selections.
"""
@spec max_selections(t(), non_neg_integer() | nil) :: t()
def max_selections(%__MODULE__{} = table, max)
when is_nil(max) or (is_integer(max) and max >= 0) do
%{table | max_selections: max}
end
# ===========================================================================
# Help & Theme
# ===========================================================================
@doc """
Shows or hides the help text.
"""
@spec show_help(t(), boolean()) :: t()
def show_help(%__MODULE__{} = table, enabled) when is_boolean(enabled) do
%{table | show_help: enabled}
end
@doc """
Sets the style for help text.
"""
@spec help_style(t(), Esc.Style.t()) :: t()
def help_style(%__MODULE__{} = table, style) do
%{table | help_style: style}
end
@doc """
Enables or disables automatic theme colors.
"""
@spec use_theme(t(), boolean()) :: t()
def use_theme(%__MODULE__{} = table, enabled) when is_boolean(enabled) do
%{table | use_theme: enabled}
end
# ===========================================================================
# Rendering
# ===========================================================================
@doc """
Renders the multi-select table at its current state (non-interactive).
"""
@spec render(t()) :: String.t()
def render(%__MODULE__{items: []}), do: ""
def render(%__MODULE__{} = table) do
{col_count, cell_width} = calculate_grid_dimensions(table)
marker = table.selected_marker
cursor_style = get_effective_cursor_style(table)
selected_style = get_effective_selected_style(table)
item_style = table.item_style
border_style = get_effective_border_style(table)
# Build grid rows with styled cells
cell_rows =
table.items
|> Enum.with_index()
|> Enum.chunk_every(col_count)
|> Enum.map(fn chunk ->
# Pad row to full width
padded = chunk ++ List.duplicate(nil, col_count - length(chunk))
Enum.map(padded, fn
nil ->
# Empty cell
String.duplicate(" ", cell_width)
{item, idx} ->
display_text = get_display_text(item)
is_focused = idx == table.cursor_index
is_selected = MapSet.member?(table.selected_indices, idx)
# Build cell content with visual indicators
content =
cond do
is_focused and is_selected ->
"[#{marker}#{display_text}]"
is_focused ->
"[ #{display_text}]"
is_selected ->
" #{marker}#{display_text} "
true ->
" #{display_text} "
end
# Pad to cell width first
padded_content = pad_to_width(content, cell_width)
# Apply ANSI style based on state
style =
cond do
is_focused -> cursor_style
is_selected -> selected_style
true -> item_style
end
Esc.Grid.apply_style(padded_content, style)
end)
end)
# Use shared grid renderer
table_output = Esc.Grid.render(cell_rows, cell_width, table.border, border_style)
if table.show_help do
help_text = build_help_text(table)
help_style = get_effective_help_style(table)
styled_help = if help_style, do: Esc.render(help_style, help_text), else: help_text
table_output <> "\n" <> styled_help
else
table_output
end
end
@doc """
Runs the interactive multi-selection loop.
Returns `{:ok, selected_values}` when the user confirms,
or `:cancelled` if the user presses Escape or q.
"""
@spec run(t()) :: {:ok, [term()]} | :cancelled
def run(%__MODULE__{items: []}), do: :cancelled
def run(%__MODULE__{} = table) do
:shell.start_interactive({:noshell, :raw})
IO.write(hide_cursor())
try do
loop(table)
after
IO.write(show_cursor())
:shell.start_interactive({:noshell, :cooked})
end
end
# ===========================================================================
# Private - Grid Calculation
# ===========================================================================
defp calculate_grid_dimensions(%__MODULE__{items: []}), do: {1, 10}
defp calculate_grid_dimensions(%__MODULE__{columns: cols, items: items}) when is_integer(cols) do
max_item_len = items |> Enum.map(&(get_display_text(&1) |> Esc.Grid.display_width())) |> Enum.max()
cell_width = max_item_len + @cursor_overhead
{cols, cell_width}
end
defp calculate_grid_dimensions(%__MODULE__{columns: :auto, items: items, border: border}) do
terminal_width = Esc.Table.get_terminal_width()
# Use display_width for emoji/CJK support
max_item_len = items |> Enum.map(&(get_display_text(&1) |> Esc.Grid.display_width())) |> Enum.max()
cell_width = max_item_len + @cursor_overhead
# Calculate how many columns fit (with 2-char safety margin to prevent wrapping)
col_count =
if border do
max(1, div(terminal_width - 3, cell_width + 3))
else
max(1, div(terminal_width - 2, cell_width + 2))
end
{col_count, cell_width}
end
defp pad_to_width(string, width) do
len = Esc.Grid.display_width(string)
if len >= width do
string
else
string <> String.duplicate(" ", width - len)
end
end
# ===========================================================================
# Private - Interactive Loop
# ===========================================================================
defp loop(table) do
output = render(table)
# Simple line count - lines should never wrap with proper grid sizing
line_count = output |> String.split("\n") |> length()
IO.write(String.replace(output, "\n", "\r\n"))
{col_count, _} = calculate_grid_dimensions(table)
case read_key() do
:left ->
move_and_redraw(table, line_count, &move_left(&1, col_count))
:right ->
move_and_redraw(table, line_count, &move_right(&1, col_count))
:up ->
move_and_redraw(table, line_count, &move_up(&1, col_count))
:down ->
move_and_redraw(table, line_count, &move_down(&1, col_count))
:home ->
move_and_redraw(table, line_count, &move_home/1)
:end_key ->
move_and_redraw(table, line_count, &move_end/1)
:toggle ->
move_and_redraw(table, line_count, &toggle_selection/1)
:select_all ->
move_and_redraw(table, line_count, &select_all/1)
:select_none ->
move_and_redraw(table, line_count, &select_none/1)
:enter ->
if can_submit?(table) do
IO.write("\r\n")
{:ok, get_selected_values(table)}
else
move_and_redraw(table, line_count, & &1)
end
:cancel ->
clear_lines(line_count)
:cancelled
_ ->
move_and_redraw(table, line_count, & &1)
end
end
defp move_and_redraw(table, line_count, move_fn) do
clear_lines(line_count)
loop(move_fn.(table))
end
defp move_left(table, _col_count) do
new_index = max(0, table.cursor_index - 1)
%{table | cursor_index: new_index}
end
defp move_right(table, _col_count) do
max_index = length(table.items) - 1
new_index = min(max_index, table.cursor_index + 1)
%{table | cursor_index: new_index}
end
defp move_up(table, col_count) do
new_index = table.cursor_index - col_count
if new_index >= 0 do
%{table | cursor_index: new_index}
else
row_count = ceil(length(table.items) / col_count)
current_col = rem(table.cursor_index, col_count)
wrapped_index = (row_count - 1) * col_count + current_col
%{table | cursor_index: min(wrapped_index, length(table.items) - 1)}
end
end
defp move_down(table, col_count) do
new_index = table.cursor_index + col_count
max_index = length(table.items) - 1
if new_index <= max_index do
%{table | cursor_index: new_index}
else
current_col = rem(table.cursor_index, col_count)
%{table | cursor_index: current_col}
end
end
defp move_home(table), do: %{table | cursor_index: 0}
defp move_end(table), do: %{table | cursor_index: length(table.items) - 1}
defp toggle_selection(table) do
idx = table.cursor_index
currently_selected = MapSet.member?(table.selected_indices, idx)
cond do
currently_selected ->
%{table | selected_indices: MapSet.delete(table.selected_indices, idx)}
at_max_selections?(table) ->
table
true ->
%{table | selected_indices: MapSet.put(table.selected_indices, idx)}
end
end
defp select_all(table) do
max = table.max_selections
count = length(table.items)
indices =
if is_nil(max) or max >= count do
0..(count - 1) |> MapSet.new()
else
current = table.selected_indices
remaining = max - MapSet.size(current)
additional =
0..(count - 1)
|> Enum.reject(&MapSet.member?(current, &1))
|> Enum.take(remaining)
|> MapSet.new()
MapSet.union(current, additional)
end
%{table | selected_indices: indices}
end
defp select_none(table), do: %{table | selected_indices: MapSet.new()}
defp at_max_selections?(%__MODULE__{max_selections: nil}), do: false
defp at_max_selections?(table) do
MapSet.size(table.selected_indices) >= table.max_selections
end
defp can_submit?(table) do
MapSet.size(table.selected_indices) >= table.min_selections
end
# ===========================================================================
# Private - Key Reading
# ===========================================================================
defp read_key do
case read_char() do
"\e" -> read_escape_sequence()
"\r" -> :enter
"\n" -> :enter
" " -> :toggle
"\t" -> :right
"h" -> :left
"j" -> :down
"k" -> :up
"l" -> :right
"g" -> :home
"G" -> :end_key
"a" -> :select_all
"n" -> :select_none
"q" -> :cancel
<<3>> -> :cancel
:eof -> :cancel
_ -> :unknown
end
end
defp read_char, do: IO.getn("", 1)
defp read_escape_sequence do
case read_char() do
"[" ->
case read_char() do
"A" -> :up
"B" -> :down
"C" -> :right
"D" -> :left
"Z" -> :left
"H" -> :home
"F" -> :end_key
"1" ->
case read_char() do
"~" -> :home
_ -> :unknown
end
"4" ->
case read_char() do
"~" -> :end_key
_ -> :unknown
end
_ -> :unknown
end
_ -> :cancel
end
end
# ===========================================================================
# Private - ANSI
# ===========================================================================
defp hide_cursor, do: "\e[?25l"
defp show_cursor, do: "\e[?25h"
defp clear_lines(count) when count > 1 do
IO.write("\r\e[#{count - 1}A\e[J")
end
defp clear_lines(_count), do: IO.write("\r\e[J")
# ===========================================================================
# Private - Helpers
# ===========================================================================
defp get_display_text({text, _value}) when is_binary(text), do: text
defp get_display_text(text) when is_binary(text), do: text
defp get_return_value_for_item({_text, value}), do: value
defp get_return_value_for_item(text) when is_binary(text), do: text
defp get_selected_values(table) do
table.selected_indices
|> MapSet.to_list()
|> Enum.sort()
|> Enum.map(fn idx ->
item = Enum.at(table.items, idx)
get_return_value_for_item(item)
end)
end
defp build_help_text(table) do
selected_count = MapSet.size(table.selected_indices)
min = table.min_selections
toggle_text =
if at_max_selections?(table) do
"Space: toggle (max)"
else
"Space: toggle"
end
confirm_text =
cond do
selected_count < min ->
needed = min - selected_count
"Enter: confirm (#{needed} more)"
true ->
"Enter: confirm (#{selected_count})"
end
"hjkl/arrows: nav | #{toggle_text} | #{confirm_text} | a/n: all/none | q: cancel"
end
# ===========================================================================
# Private - Theme & Styling
# ===========================================================================
defp get_effective_cursor_style(table) do
case {table.cursor_style, table.use_theme, Esc.get_theme()} do
{style, _, _} when not is_nil(style) ->
style
{nil, true, theme} when not is_nil(theme) ->
Esc.style()
|> Esc.foreground(Esc.Theme.color(theme, :header))
|> Esc.bold()
_ ->
Esc.style() |> Esc.bold()
end
end
defp get_effective_selected_style(table) do
case {table.selected_style, table.use_theme, Esc.get_theme()} do
{style, _, _} when not is_nil(style) ->
style
{nil, true, theme} when not is_nil(theme) ->
Esc.style() |> Esc.foreground(Esc.Theme.color(theme, :success))
_ ->
nil
end
end
defp get_effective_border_style(table) do
case {table.use_theme, Esc.get_theme()} do
{true, theme} when not is_nil(theme) ->
Esc.style() |> Esc.foreground(Esc.Theme.color(theme, :muted))
_ ->
nil
end
end
defp get_effective_help_style(table) do
case {table.help_style, table.use_theme, Esc.get_theme()} do
{style, _, _} when not is_nil(style) ->
style
{nil, true, theme} when not is_nil(theme) ->
Esc.style() |> Esc.foreground(Esc.Theme.color(theme, :muted))
_ ->
nil
end
end
end