- Introduce `ArgumentAction.TLDR` for showing concise usage examples - Add `rich_text_to_prompt_text()` to support Rich-style markup in all prompt_toolkit inputs - Migrate all prompt-based Actions to use `prompt_message` with Rich styling support - Standardize `CancelSignal` as the default interrupt behavior for prompt-driven Actions
503 lines
15 KiB
Python
503 lines
15 KiB
Python
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
|
"""
|
|
Provides interactive selection utilities for Falyx CLI actions.
|
|
|
|
This module defines `SelectionOption` objects, selection maps, and rich-powered
|
|
rendering functions to build interactive selection prompts using `prompt_toolkit`.
|
|
It supports:
|
|
- Grid-based and dictionary-based selection menus
|
|
- Index- or key-driven multi-select prompts
|
|
- Formatted Rich tables for CLI visual menus
|
|
- Cancel keys, defaults, and duplication control
|
|
|
|
Used by `SelectionAction` and other prompt-driven workflows within Falyx.
|
|
"""
|
|
from dataclasses import dataclass
|
|
from typing import Any, Callable, KeysView, Sequence
|
|
|
|
from prompt_toolkit import PromptSession
|
|
from rich import box
|
|
from rich.markup import escape
|
|
from rich.table import Table
|
|
|
|
from falyx.console import console
|
|
from falyx.prompt_utils import rich_text_to_prompt_text
|
|
from falyx.themes import OneColors
|
|
from falyx.utils import CaseInsensitiveDict, chunks
|
|
from falyx.validators import MultiIndexValidator, MultiKeyValidator
|
|
|
|
|
|
@dataclass
|
|
class SelectionOption:
|
|
"""Represents a single selection option with a description and a value."""
|
|
|
|
description: str
|
|
value: Any
|
|
style: str = OneColors.WHITE
|
|
|
|
def __post_init__(self):
|
|
if not isinstance(self.description, str):
|
|
raise TypeError("SelectionOption description must be a string.")
|
|
|
|
def render(self, key: str) -> str:
|
|
"""Render the selection option for display."""
|
|
key = escape(f"[{key}]")
|
|
return f"[{OneColors.WHITE}]{key}[/] [{self.style}]{self.description}[/]"
|
|
|
|
|
|
class SelectionOptionMap(CaseInsensitiveDict):
|
|
"""
|
|
Manages selection options including validation and reserved key protection.
|
|
"""
|
|
|
|
RESERVED_KEYS: set[str] = set()
|
|
|
|
def __init__(
|
|
self,
|
|
options: dict[str, SelectionOption] | None = None,
|
|
allow_reserved: bool = False,
|
|
):
|
|
super().__init__()
|
|
self.allow_reserved = allow_reserved
|
|
if options:
|
|
self.update(options)
|
|
|
|
def _add_reserved(self, key: str, option: SelectionOption) -> None:
|
|
"""Add a reserved key, bypassing validation."""
|
|
norm_key = key.upper()
|
|
super().__setitem__(norm_key, option)
|
|
|
|
def __setitem__(self, key: str, option: SelectionOption) -> None:
|
|
if not isinstance(option, SelectionOption):
|
|
raise TypeError(f"Value for key '{key}' must be a SelectionOption.")
|
|
norm_key = key.upper()
|
|
if norm_key in self.RESERVED_KEYS and not self.allow_reserved:
|
|
raise ValueError(
|
|
f"Key '{key}' is reserved and cannot be used in SelectionOptionMap."
|
|
)
|
|
super().__setitem__(norm_key, option)
|
|
|
|
def __delitem__(self, key: str) -> None:
|
|
if key.upper() in self.RESERVED_KEYS and not self.allow_reserved:
|
|
raise ValueError(f"Cannot delete reserved option '{key}'.")
|
|
super().__delitem__(key)
|
|
|
|
def update(self, other=None, **kwargs):
|
|
"""Update the selection options with another dictionary."""
|
|
if other:
|
|
for key, option in other.items():
|
|
if not isinstance(option, SelectionOption):
|
|
raise TypeError(f"Value for key '{key}' must be a SelectionOption.")
|
|
self[key] = option
|
|
for key, option in kwargs.items():
|
|
if not isinstance(option, SelectionOption):
|
|
raise TypeError(f"Value for key '{key}' must be a SelectionOption.")
|
|
self[key] = option
|
|
|
|
def items(self, include_reserved: bool = True):
|
|
for k, v in super().items():
|
|
if not include_reserved and k in self.RESERVED_KEYS:
|
|
continue
|
|
yield k, v
|
|
|
|
|
|
def render_table_base(
|
|
title: str,
|
|
*,
|
|
caption: str = "",
|
|
columns: int = 4,
|
|
box_style: box.Box = box.SIMPLE,
|
|
show_lines: bool = False,
|
|
show_header: bool = False,
|
|
show_footer: bool = False,
|
|
style: str = "",
|
|
header_style: str = "",
|
|
footer_style: str = "",
|
|
title_style: str = "",
|
|
caption_style: str = "",
|
|
highlight: bool = True,
|
|
column_names: Sequence[str] | None = None,
|
|
) -> Table:
|
|
table = Table(
|
|
title=title,
|
|
caption=caption,
|
|
box=box_style,
|
|
show_lines=show_lines,
|
|
show_header=show_header,
|
|
show_footer=show_footer,
|
|
style=style,
|
|
header_style=header_style,
|
|
footer_style=footer_style,
|
|
title_style=title_style,
|
|
caption_style=caption_style,
|
|
highlight=highlight,
|
|
)
|
|
if column_names:
|
|
for column_name in column_names:
|
|
table.add_column(column_name)
|
|
else:
|
|
for _ in range(columns):
|
|
table.add_column()
|
|
return table
|
|
|
|
|
|
def render_selection_grid(
|
|
title: str,
|
|
selections: Sequence[str],
|
|
*,
|
|
columns: int = 4,
|
|
caption: str = "",
|
|
box_style: box.Box = box.SIMPLE,
|
|
show_lines: bool = False,
|
|
show_header: bool = False,
|
|
show_footer: bool = False,
|
|
style: str = "",
|
|
header_style: str = "",
|
|
footer_style: str = "",
|
|
title_style: str = "",
|
|
caption_style: str = "",
|
|
highlight: bool = False,
|
|
) -> Table:
|
|
"""Create a selection table with the given parameters."""
|
|
table = render_table_base(
|
|
title=title,
|
|
caption=caption,
|
|
columns=columns,
|
|
box_style=box_style,
|
|
show_lines=show_lines,
|
|
show_header=show_header,
|
|
show_footer=show_footer,
|
|
style=style,
|
|
header_style=header_style,
|
|
footer_style=footer_style,
|
|
title_style=title_style,
|
|
caption_style=caption_style,
|
|
highlight=highlight,
|
|
)
|
|
|
|
for chunk in chunks(selections, columns):
|
|
table.add_row(*chunk)
|
|
|
|
return table
|
|
|
|
|
|
def render_selection_indexed_table(
|
|
title: str,
|
|
selections: Sequence[str],
|
|
*,
|
|
columns: int = 4,
|
|
caption: str = "",
|
|
box_style: box.Box = box.SIMPLE,
|
|
show_lines: bool = False,
|
|
show_header: bool = False,
|
|
show_footer: bool = False,
|
|
style: str = "",
|
|
header_style: str = "",
|
|
footer_style: str = "",
|
|
title_style: str = "",
|
|
caption_style: str = "",
|
|
highlight: bool = False,
|
|
formatter: Callable[[int, str], str] | None = None,
|
|
) -> Table:
|
|
"""Create a selection table with the given parameters."""
|
|
table = render_table_base(
|
|
title=title,
|
|
caption=caption,
|
|
columns=columns,
|
|
box_style=box_style,
|
|
show_lines=show_lines,
|
|
show_header=show_header,
|
|
show_footer=show_footer,
|
|
style=style,
|
|
header_style=header_style,
|
|
footer_style=footer_style,
|
|
title_style=title_style,
|
|
caption_style=caption_style,
|
|
highlight=highlight,
|
|
)
|
|
|
|
for indexes, chunk in zip(
|
|
chunks(range(len(selections)), columns), chunks(selections, columns)
|
|
):
|
|
row = [
|
|
formatter(index, selection) if formatter else f"[{index}] {selection}"
|
|
for index, selection in zip(indexes, chunk)
|
|
]
|
|
table.add_row(*row)
|
|
|
|
return table
|
|
|
|
|
|
def render_selection_dict_table(
|
|
title: str,
|
|
selections: dict[str, SelectionOption],
|
|
*,
|
|
columns: int = 2,
|
|
caption: str = "",
|
|
box_style: box.Box = box.SIMPLE,
|
|
show_lines: bool = False,
|
|
show_header: bool = False,
|
|
show_footer: bool = False,
|
|
style: str = "",
|
|
header_style: str = "",
|
|
footer_style: str = "",
|
|
title_style: str = "",
|
|
caption_style: str = "",
|
|
highlight: bool = False,
|
|
) -> Table:
|
|
"""Create a selection table with the given parameters."""
|
|
table = render_table_base(
|
|
title=title,
|
|
caption=caption,
|
|
columns=columns,
|
|
box_style=box_style,
|
|
show_lines=show_lines,
|
|
show_header=show_header,
|
|
show_footer=show_footer,
|
|
style=style,
|
|
header_style=header_style,
|
|
footer_style=footer_style,
|
|
title_style=title_style,
|
|
caption_style=caption_style,
|
|
highlight=highlight,
|
|
)
|
|
|
|
for chunk in chunks(selections.items(), columns):
|
|
row = []
|
|
for key, option in chunk:
|
|
row.append(
|
|
f"[{OneColors.WHITE}][{key.upper()}] "
|
|
f"[{option.style}]{option.description}[/]"
|
|
)
|
|
table.add_row(*row)
|
|
|
|
return table
|
|
|
|
|
|
async def prompt_for_index(
|
|
max_index: int,
|
|
table: Table,
|
|
*,
|
|
min_index: int = 0,
|
|
default_selection: str = "",
|
|
prompt_session: PromptSession | None = None,
|
|
prompt_message: str = "Select an option > ",
|
|
show_table: bool = True,
|
|
number_selections: int | str = 1,
|
|
separator: str = ",",
|
|
allow_duplicates: bool = False,
|
|
cancel_key: str = "",
|
|
) -> int | list[int]:
|
|
prompt_session = prompt_session or PromptSession()
|
|
|
|
if show_table:
|
|
console.print(table, justify="center")
|
|
|
|
selection = await prompt_session.prompt_async(
|
|
message=rich_text_to_prompt_text(prompt_message),
|
|
validator=MultiIndexValidator(
|
|
min_index,
|
|
max_index,
|
|
number_selections,
|
|
separator,
|
|
allow_duplicates,
|
|
cancel_key,
|
|
),
|
|
default=default_selection,
|
|
)
|
|
|
|
if selection.strip() == cancel_key:
|
|
return int(cancel_key)
|
|
if isinstance(number_selections, int) and number_selections == 1:
|
|
return int(selection.strip())
|
|
return [int(index.strip()) for index in selection.strip().split(separator)]
|
|
|
|
|
|
async def prompt_for_selection(
|
|
keys: Sequence[str] | KeysView[str],
|
|
table: Table,
|
|
*,
|
|
default_selection: str = "",
|
|
prompt_session: PromptSession | None = None,
|
|
prompt_message: str = "Select an option > ",
|
|
show_table: bool = True,
|
|
number_selections: int | str = 1,
|
|
separator: str = ",",
|
|
allow_duplicates: bool = False,
|
|
cancel_key: str = "",
|
|
) -> str | list[str]:
|
|
"""Prompt the user to select a key from a set of options. Return the selected key."""
|
|
prompt_session = prompt_session or PromptSession()
|
|
|
|
if show_table:
|
|
console.print(table, justify="center")
|
|
|
|
selected = await prompt_session.prompt_async(
|
|
message=rich_text_to_prompt_text(prompt_message),
|
|
validator=MultiKeyValidator(
|
|
keys, number_selections, separator, allow_duplicates, cancel_key
|
|
),
|
|
default=default_selection,
|
|
)
|
|
|
|
if selected.strip() == cancel_key:
|
|
return cancel_key
|
|
if isinstance(number_selections, int) and number_selections == 1:
|
|
return selected.strip()
|
|
return [key.strip() for key in selected.strip().split(separator)]
|
|
|
|
|
|
async def select_value_from_list(
|
|
title: str,
|
|
selections: Sequence[str],
|
|
*,
|
|
prompt_session: PromptSession | None = None,
|
|
prompt_message: str = "Select an option > ",
|
|
default_selection: str = "",
|
|
number_selections: int | str = 1,
|
|
separator: str = ",",
|
|
allow_duplicates: bool = False,
|
|
cancel_key: str = "",
|
|
columns: int = 4,
|
|
caption: str = "",
|
|
box_style: box.Box = box.SIMPLE,
|
|
show_lines: bool = False,
|
|
show_header: bool = False,
|
|
show_footer: bool = False,
|
|
style: str = "",
|
|
header_style: str = "",
|
|
footer_style: str = "",
|
|
title_style: str = "",
|
|
caption_style: str = "",
|
|
highlight: bool = False,
|
|
) -> str | list[str]:
|
|
"""Prompt for a selection. Return the selected item."""
|
|
table = render_selection_indexed_table(
|
|
title=title,
|
|
selections=selections,
|
|
columns=columns,
|
|
caption=caption,
|
|
box_style=box_style,
|
|
show_lines=show_lines,
|
|
show_header=show_header,
|
|
show_footer=show_footer,
|
|
style=style,
|
|
header_style=header_style,
|
|
footer_style=footer_style,
|
|
title_style=title_style,
|
|
caption_style=caption_style,
|
|
highlight=highlight,
|
|
)
|
|
prompt_session = prompt_session or PromptSession()
|
|
|
|
selection_index = await prompt_for_index(
|
|
len(selections) - 1,
|
|
table,
|
|
default_selection=default_selection,
|
|
prompt_session=prompt_session,
|
|
prompt_message=prompt_message,
|
|
number_selections=number_selections,
|
|
separator=separator,
|
|
allow_duplicates=allow_duplicates,
|
|
cancel_key=cancel_key,
|
|
)
|
|
|
|
if isinstance(selection_index, list):
|
|
return [selections[i] for i in selection_index]
|
|
return selections[selection_index]
|
|
|
|
|
|
async def select_key_from_dict(
|
|
selections: dict[str, SelectionOption],
|
|
table: Table,
|
|
*,
|
|
prompt_session: PromptSession | None = None,
|
|
prompt_message: str = "Select an option > ",
|
|
default_selection: str = "",
|
|
number_selections: int | str = 1,
|
|
separator: str = ",",
|
|
allow_duplicates: bool = False,
|
|
cancel_key: str = "",
|
|
) -> str | list[str]:
|
|
"""Prompt for a key from a dict, returns the key."""
|
|
prompt_session = prompt_session or PromptSession()
|
|
|
|
console.print(table, justify="center")
|
|
|
|
return await prompt_for_selection(
|
|
selections.keys(),
|
|
table,
|
|
default_selection=default_selection,
|
|
prompt_session=prompt_session,
|
|
prompt_message=prompt_message,
|
|
number_selections=number_selections,
|
|
separator=separator,
|
|
allow_duplicates=allow_duplicates,
|
|
cancel_key=cancel_key,
|
|
)
|
|
|
|
|
|
async def select_value_from_dict(
|
|
selections: dict[str, SelectionOption],
|
|
table: Table,
|
|
*,
|
|
prompt_session: PromptSession | None = None,
|
|
prompt_message: str = "Select an option > ",
|
|
default_selection: str = "",
|
|
number_selections: int | str = 1,
|
|
separator: str = ",",
|
|
allow_duplicates: bool = False,
|
|
cancel_key: str = "",
|
|
) -> Any | list[Any]:
|
|
"""Prompt for a key from a dict, but return the value."""
|
|
prompt_session = prompt_session or PromptSession()
|
|
|
|
console.print(table, justify="center")
|
|
|
|
selection_key = await prompt_for_selection(
|
|
selections.keys(),
|
|
table,
|
|
default_selection=default_selection,
|
|
prompt_session=prompt_session,
|
|
prompt_message=prompt_message,
|
|
number_selections=number_selections,
|
|
separator=separator,
|
|
allow_duplicates=allow_duplicates,
|
|
cancel_key=cancel_key,
|
|
)
|
|
|
|
if isinstance(selection_key, list):
|
|
return [selections[key].value for key in selection_key]
|
|
return selections[selection_key].value
|
|
|
|
|
|
async def get_selection_from_dict_menu(
|
|
title: str,
|
|
selections: dict[str, SelectionOption],
|
|
*,
|
|
prompt_session: PromptSession | None = None,
|
|
prompt_message: str = "Select an option > ",
|
|
default_selection: str = "",
|
|
number_selections: int | str = 1,
|
|
separator: str = ",",
|
|
allow_duplicates: bool = False,
|
|
cancel_key: str = "",
|
|
) -> Any | list[Any]:
|
|
"""Prompt for a key from a dict, but return the value."""
|
|
table = render_selection_dict_table(
|
|
title,
|
|
selections,
|
|
)
|
|
|
|
return await select_value_from_dict(
|
|
selections=selections,
|
|
table=table,
|
|
prompt_session=prompt_session,
|
|
prompt_message=prompt_message,
|
|
default_selection=default_selection,
|
|
number_selections=number_selections,
|
|
separator=separator,
|
|
allow_duplicates=allow_duplicates,
|
|
cancel_key=cancel_key,
|
|
)
|