diff --git a/examples/argument_examples.py b/examples/argument_examples.py index faf2ae7..2af9889 100644 --- a/examples/argument_examples.py +++ b/examples/argument_examples.py @@ -77,7 +77,12 @@ def default_config(parser: CommandArgumentParser) -> None: ) -flx = Falyx("Argument Examples", program="argument_examples.py") +flx = Falyx( + "Argument Examples", + program="argument_examples.py", + hide_menu_table=True, + show_placeholder_menu=True, +) flx.add_command( key="T", @@ -88,7 +93,7 @@ flx.add_command( name="test_args", action=test_args, ), - style="bold blue", + style="bold #B3EBF2", argument_config=default_config, ) diff --git a/examples/file_select.py b/examples/file_select.py index d2289e5..d984704 100644 --- a/examples/file_select.py +++ b/examples/file_select.py @@ -19,6 +19,8 @@ flx = Falyx( description="This example demonstrates how to select files using Falyx.", version="1.0.0", program="file_select.py", + hide_menu_table=True, + show_placeholder_menu=True, ) flx.add_command( diff --git a/falyx/falyx.py b/falyx/falyx.py index 9424e7e..a718743 100644 --- a/falyx/falyx.py +++ b/falyx/falyx.py @@ -33,7 +33,7 @@ from prompt_toolkit import PromptSession from prompt_toolkit.formatted_text import StyleAndTextTuples from prompt_toolkit.key_binding import KeyBindings from prompt_toolkit.patch_stdout import patch_stdout -from prompt_toolkit.validation import ValidationError, Validator +from prompt_toolkit.validation import ValidationError from rich import box from rich.console import Console from rich.markdown import Markdown @@ -66,41 +66,10 @@ from falyx.retry import RetryPolicy from falyx.signals import BackSignal, CancelSignal, HelpSignal, QuitSignal from falyx.themes import OneColors from falyx.utils import CaseInsensitiveDict, _noop, chunks, ensure_async +from falyx.validators import CommandValidator from falyx.version import __version__ -class CommandValidator(Validator): - """Validator to check if the input is a valid command.""" - - def __init__(self, falyx: Falyx, error_message: str) -> None: - super().__init__() - self.falyx = falyx - self.error_message = error_message - - def validate(self, document) -> None: - if not document.text: - raise ValidationError( - message=self.error_message, - cursor_position=len(document.text), - ) - - async def validate_async(self, document) -> None: - text = document.text - if not text: - raise ValidationError( - message=self.error_message, - cursor_position=len(text), - ) - is_preview, choice, _, __ = await self.falyx.get_command(text, from_validate=True) - if is_preview: - return None - if not choice: - raise ValidationError( - message=self.error_message, - cursor_position=len(text), - ) - - class Falyx: """ Main menu controller for Falyx CLI applications. @@ -176,6 +145,7 @@ class Falyx: render_menu: Callable[[Falyx], None] | None = None, custom_table: Callable[[Falyx], Table] | Table | None = None, hide_menu_table: bool = False, + show_placeholder_menu: bool = False, ) -> None: """Initializes the Falyx object.""" self.title: str | Markdown = title @@ -208,6 +178,7 @@ class Falyx: self.render_menu: Callable[[Falyx], None] | None = render_menu self.custom_table: Callable[[Falyx], Table] | Table | None = custom_table self._hide_menu_table: bool = hide_menu_table + self.show_placeholder_menu: bool = show_placeholder_menu self.validate_options(cli_args, options) self._prompt_session: PromptSession | None = None self.options.set("mode", FalyxMode.MENU) @@ -492,6 +463,7 @@ class Falyx: def prompt_session(self) -> PromptSession: """Returns the prompt session for the menu.""" if self._prompt_session is None: + placeholder = self.build_placeholder_menu() self._prompt_session = PromptSession( message=self.prompt, multiline=False, @@ -502,6 +474,7 @@ class Falyx: validate_while_typing=True, interrupt_exception=QuitSignal, eof_exception=QuitSignal, + placeholder=placeholder if self.show_placeholder_menu else None, ) return self._prompt_session @@ -724,16 +697,16 @@ class Falyx: if self.help_command: bottom_row.append( f"[{self.help_command.key}] [{self.help_command.style}]" - f"{self.help_command.description}" + f"{self.help_command.description}[/]" ) if self.history_command: bottom_row.append( f"[{self.history_command.key}] [{self.history_command.style}]" - f"{self.history_command.description}" + f"{self.history_command.description}[/]" ) bottom_row.append( f"[{self.exit_command.key}] [{self.exit_command.style}]" - f"{self.exit_command.description}" + f"{self.exit_command.description}[/]" ) return bottom_row @@ -754,6 +727,22 @@ class Falyx: table.add_row(*row) return table + def build_placeholder_menu(self) -> StyleAndTextTuples: + """ + Builds a menu placeholder for prompt_menu mode. + """ + visible_commands = [item for item in self.commands.items() if not item[1].hidden] + if not visible_commands: + return [("", "")] + + placeholder: list[str] = [] + for key, command in visible_commands: + placeholder.append(f"[{key}] [{command.style}]{command.description}[/]") + for command_str in self.get_bottom_row(): + placeholder.append(command_str) + + return rich_text_to_prompt_text(" ".join(placeholder)) + @property def table(self) -> Table: """Creates or returns a custom table to display the menu commands.""" diff --git a/falyx/parser/command_argument_parser.py b/falyx/parser/command_argument_parser.py index 18c548b..4bf8da5 100644 --- a/falyx/parser/command_argument_parser.py +++ b/falyx/parser/command_argument_parser.py @@ -101,6 +101,8 @@ class CommandArgumentParser: - Render Help using Rich library. """ + RESERVED_DESTS = frozenset(("help", "tldr")) + def __init__( self, command_key: str = "", @@ -138,13 +140,13 @@ class CommandArgumentParser: def _add_help(self): """Add help argument to the parser.""" - self.add_argument( - "-h", - "--help", + help = Argument( + flags=("--help", "-h"), action=ArgumentAction.HELP, help="Show this help message.", dest="help", ) + self._register_argument(help) def add_tldr_examples(self, examples: list[tuple[str, str]]) -> None: """ @@ -163,12 +165,13 @@ class CommandArgumentParser: self._tldr_examples.append(TLDRExample(usage=usage, description=description)) if "tldr" not in self._dest_set: - self.add_argument( - "--tldr", + tldr = Argument( + ("--tldr",), action=ArgumentAction.TLDR, help="Show quick usage examples and exit.", dest="tldr", ) + self._register_argument(tldr) def _is_positional(self, flags: tuple[str, ...]) -> bool: """Check if the flags are positional.""" @@ -529,6 +532,10 @@ class CommandArgumentParser: "Merging multiple arguments into the same dest (e.g. positional + flagged) " "is not supported. Define a unique 'dest' for each argument." ) + if dest in self.RESERVED_DESTS: + raise CommandArgumentError( + f"Destination '{dest}' is reserved and cannot be used." + ) action = self._validate_action(action, positional) resolver = self._validate_resolver(action, resolver) @@ -1050,6 +1057,7 @@ class CommandArgumentParser: ) result.pop("help", None) + result.pop("tldr", None) return result async def parse_args_split( @@ -1067,7 +1075,7 @@ class CommandArgumentParser: args_list = [] kwargs_dict = {} for arg in self._arguments: - if arg.dest == "help": + if arg.dest in ("help", "tldr"): continue if arg.positional: args_list.append(parsed[arg.dest]) diff --git a/falyx/validators.py b/falyx/validators.py index 9020173..60da09a 100644 --- a/falyx/validators.py +++ b/falyx/validators.py @@ -7,6 +7,7 @@ user input during prompts—especially for selection actions, confirmations, and argument parsing. Included Validators: +- CommandValidator: Validates if the input matches a known command. - int_range_validator: Enforces numeric input within a range. - key_validator: Ensures the entered value matches a valid selection key. - yes_no_validator: Restricts input to 'Y' or 'N'. @@ -17,10 +18,45 @@ Included Validators: These validators integrate directly into `PromptSession.prompt_async()` to enforce correctness and provide helpful error messages. """ -from typing import KeysView, Sequence +from typing import TYPE_CHECKING, KeysView, Sequence from prompt_toolkit.validation import ValidationError, Validator +if TYPE_CHECKING: + from falyx.falyx import Falyx + + +class CommandValidator(Validator): + """Validator to check if the input is a valid command.""" + + def __init__(self, falyx: "Falyx", error_message: str) -> None: + super().__init__() + self.falyx = falyx + self.error_message = error_message + + def validate(self, document) -> None: + if not document.text: + raise ValidationError( + message=self.error_message, + cursor_position=len(document.text), + ) + + async def validate_async(self, document) -> None: + text = document.text + if not text: + raise ValidationError( + message=self.error_message, + cursor_position=len(text), + ) + is_preview, choice, _, __ = await self.falyx.get_command(text, from_validate=True) + if is_preview: + return None + if not choice: + raise ValidationError( + message=self.error_message, + cursor_position=len(text), + ) + def int_range_validator(minimum: int, maximum: int) -> Validator: """Validator for integer ranges.""" diff --git a/falyx/version.py b/falyx/version.py index da1fe8f..fe4c664 100644 --- a/falyx/version.py +++ b/falyx/version.py @@ -1 +1 @@ -__version__ = "0.1.70" +__version__ = "0.1.71" diff --git a/pyproject.toml b/pyproject.toml index 5aa7c2e..a817b9d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "falyx" -version = "0.1.70" +version = "0.1.71" description = "Reliable and introspectable async CLI action framework." authors = ["Roland Thomas Jr "] license = "MIT"