Files
falyx/falyx/selection.py
Roland Thomas fa5e2a4c2c feat: add TLDR ArgumentAction and Rich-compatible prompt styling
- 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
2025-07-22 21:56:44 -04:00

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,
)