diff --git a/CHANGELOG.md b/CHANGELOG.md index 185b9fb4f..94dc16788 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -63,6 +63,7 @@ prompt is displayed. `add_alert()`. This new function is thread-safe and does not require you to acquire a mutex before calling it like the previous functions did. - Removed `Cmd.default_to_shell`. + - Removed `Cmd.ruler` since `cmd2` no longer uses it. - Enhancements - New `cmd2.Cmd` parameters - **auto_suggest**: (boolean) if `True`, provide fish shell style auto-suggestions. These diff --git a/cmd2/argparse_completer.py b/cmd2/argparse_completer.py index 8047f9c79..0b2c3b3f9 100644 --- a/cmd2/argparse_completer.py +++ b/cmd2/argparse_completer.py @@ -25,15 +25,12 @@ from rich.text import Text from .constants import INFINITY +from .rich_utils import Cmd2SimpleTable if TYPE_CHECKING: # pragma: no cover from .cmd2 import Cmd -from rich.box import SIMPLE_HEAD -from rich.table import ( - Column, - Table, -) +from rich.table import Column from .argparse_custom import ( ChoicesCallable, @@ -46,7 +43,6 @@ all_display_numeric, ) from .exceptions import CompletionError -from .styles import Cmd2Style # Name of the choice/completer function argument that, if present, will be passed a dictionary of # command line tokens up through the token being completed mapped to their argparse destination name. @@ -658,7 +654,7 @@ def _build_completion_table(self, arg_state: _ArgumentState, completions: Comple ) # Build the table - table = Table(*rich_columns, box=SIMPLE_HEAD, show_edge=False, border_style=Cmd2Style.TABLE_BORDER) + table = Cmd2SimpleTable(*rich_columns) for item in completions: table.add_row(Text.from_ansi(item.display), *item.table_data) diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 8cb373a3c..b3b1c86b4 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -68,7 +68,6 @@ cast, ) -import rich.box from prompt_toolkit import ( filters, print_formatted_text, @@ -160,6 +159,7 @@ Cmd2BaseConsole, Cmd2ExceptionConsole, Cmd2GeneralConsole, + Cmd2SimpleTable, RichPrintKwargs, ) from .styles import Cmd2Style @@ -517,9 +517,6 @@ def __init__( # Used to keep track of whether we are redirecting or piping output self._redirecting = False - # Characters used to draw a horizontal rule. Should not be blank. - self.ruler = "─" - # Set text which prints right before all of the help tables are listed. self.doc_leader = "" @@ -4185,6 +4182,15 @@ def do_help(self, args: argparse.Namespace) -> None: self.perror(err_msg, style=None) self.last_result = False + def _create_help_grid(self, title: str, *content: RenderableType) -> Table: + """Create a titled grid for help headers with a ruler and optional content.""" + grid = Table.grid() + grid.add_row(Text(title, style=Cmd2Style.HELP_HEADER)) + grid.add_row(Rule(style=Cmd2Style.TABLE_BORDER)) + for item in content: + grid.add_row(item) + return grid + def print_topics(self, header: str, cmds: Sequence[str] | None, cmdlen: int, maxcol: int) -> None: # noqa: ARG002 """Print groups of commands and topics in columns and an optional header. @@ -4198,12 +4204,11 @@ def print_topics(self, header: str, cmds: Sequence[str] | None, cmdlen: int, max if not cmds: return - # Print a row that looks like a table header. if header: - header_grid = Table.grid() - header_grid.add_row(Text(header, style=Cmd2Style.HELP_HEADER)) - header_grid.add_row(Rule(characters=self.ruler, style=Cmd2Style.TABLE_BORDER)) - self.poutput(header_grid, soft_wrap=False) + self.poutput( + self._create_help_grid(header), + soft_wrap=False, + ) # Subtract 1 from maxcol to account for a one-space right margin. maxcol = min(maxcol, ru.console_width()) - 1 @@ -4221,17 +4226,9 @@ def _print_documented_command_topics(self, header: str, cmds: Sequence[str], ver self.print_topics(header, cmds, 15, 80) return - # Create a grid to hold the header and the topics table - category_grid = Table.grid() - category_grid.add_row(Text(header, style=Cmd2Style.HELP_HEADER)) - category_grid.add_row(Rule(characters=self.ruler, style=Cmd2Style.TABLE_BORDER)) - - topics_table = Table( + topic_table = Cmd2SimpleTable( Column("Name", no_wrap=True), Column("Description", overflow="fold"), - box=rich.box.SIMPLE_HEAD, - show_edge=False, - border_style=Cmd2Style.TABLE_BORDER, ) # Try to get the documentation string for each command @@ -4268,10 +4265,12 @@ def _print_documented_command_topics(self, header: str, cmds: Sequence[str], ver cmd_desc = strip_doc_annotations(doc) if doc else '' # Add this command to the table - topics_table.add_row(command, cmd_desc) + topic_table.add_row(command, cmd_desc) - category_grid.add_row(topics_table) - self.poutput(category_grid, soft_wrap=False) + self.poutput( + self._create_help_grid(header, topic_table), + soft_wrap=False, + ) self.poutput() def render_columns(self, str_list: Sequence[str] | None, display_width: int = 80) -> str: @@ -4560,14 +4559,10 @@ def do_set(self, args: argparse.Namespace) -> None: # Show all settables to_show = list(self.settables.keys()) - # Define the table structure - settable_table = Table( + settable_table = Cmd2SimpleTable( Column("Name", no_wrap=True), Column("Value", overflow="fold"), Column("Description", overflow="fold"), - box=rich.box.SIMPLE_HEAD, - show_edge=False, - border_style=Cmd2Style.TABLE_BORDER, ) # Build the table and populate self.last_result diff --git a/cmd2/rich_utils.py b/cmd2/rich_utils.py index cc96e4bdc..320959889 100644 --- a/cmd2/rich_utils.py +++ b/cmd2/rich_utils.py @@ -10,6 +10,7 @@ TypedDict, ) +from rich.box import SIMPLE_HEAD from rich.console import ( Console, ConsoleRenderable, @@ -29,7 +30,10 @@ from rich.theme import Theme from rich_argparse import RichHelpFormatter -from .styles import DEFAULT_CMD2_STYLES +from .styles import ( + DEFAULT_CMD2_STYLES, + Cmd2Style, +) # Matches ANSI SGR (Select Graphic Rendition) sequences for text styling. # \x1b[ - the CSI (Control Sequence Introducer) @@ -395,6 +399,19 @@ def __init__(self, *, file: IO[str] | None = None) -> None: ) +class Cmd2SimpleTable(Table): + """A clean, lightweight Rich Table tailored for cmd2's internal use.""" + + def __init__(self, *headers: Column | str) -> None: + """Cmd2SimpleTable initializer.""" + super().__init__( + *headers, + box=SIMPLE_HEAD, + show_edge=False, + border_style=Cmd2Style.TABLE_BORDER, + ) + + def console_width() -> int: """Return the width of the console.""" return Console().width @@ -409,7 +426,13 @@ def rich_text_to_string(text: Text) -> str: :param text: the text object to convert :return: the resulting string with ANSI styles preserved. + :raises TypeError: if text is not a rich.text.Text object """ + # Strictly enforce Text type. While console.print() can render any object, + # this function is specifically tailored to convert Text instances to strings. + if not isinstance(text, Text): + raise TypeError(f"rich_text_to_string() expected a rich.text.Text object, but got {type(text).__name__}") + console = Console( force_terminal=True, soft_wrap=True, diff --git a/tests/test_rich_utils.py b/tests/test_rich_utils.py index ea7eb9e8c..c853c5e50 100644 --- a/tests/test_rich_utils.py +++ b/tests/test_rich_utils.py @@ -81,6 +81,12 @@ def test_rich_text_to_string(rich_text: Text, string: str) -> None: assert ru.rich_text_to_string(rich_text) == string +def test_rich_text_to_string_type_error() -> None: + with pytest.raises(TypeError) as excinfo: + ru.rich_text_to_string("not a Text object") # type: ignore[arg-type] + assert "rich_text_to_string() expected a rich.text.Text object, but got str" in str(excinfo.value) + + def test_set_theme() -> None: # Save a cmd2, rich-argparse, and rich-specific style. cmd2_style_key = Cmd2Style.ERROR