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