diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 7aa08dc25..52be358e5 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -292,7 +292,6 @@ class Cmd(cmd.Cmd): Line-oriented command interpreters are often useful for test harnesses, internal tools, and rapid prototypes. """ - ruler = "─" DEFAULT_EDITOR = utils.find_editor() # Sorting keys for strings @@ -475,7 +474,10 @@ def __init__( # The multiline command currently being typed which is used to tab complete multiline commands. self._multiline_in_progress = '' - # Set text which prints right before all of the help topics are listed. + # 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 = "" # Set header for table listing documented commands. @@ -4175,8 +4177,7 @@ def print_topics(self, header: str, cmds: list[str] | None, cmdlen: int, maxcol: header_grid = Table.grid() header_grid.add_row(header, style=Cmd2Style.HELP_HEADER) - if self.ruler: - header_grid.add_row(Rule(characters=self.ruler)) + header_grid.add_row(Rule(characters=self.ruler)) self.poutput(header_grid) self.columnize(cmds, maxcol - 1) self.poutput() @@ -5543,7 +5544,10 @@ class TestMyAppCase(Cmd2TestCase): verinfo = ".".join(map(str, sys.version_info[:3])) num_transcripts = len(transcripts_expanded) plural = '' if len(transcripts_expanded) == 1 else 's' - self.poutput(su.align_center(' cmd2 transcript test ', character=self.ruler), style=Style(bold=True)) + self.poutput( + Rule("cmd2 transcript test", style=Style.null()), + style=Style(bold=True), + ) self.poutput(f'platform {sys.platform} -- Python {verinfo}, cmd2-{cmd2.__version__}, readline-{rl_type}') self.poutput(f'cwd: {os.getcwd()}') self.poutput(f'cmd2 app: {sys.argv[0]}') @@ -5559,9 +5563,8 @@ class TestMyAppCase(Cmd2TestCase): execution_time = time.time() - start_time if test_results.wasSuccessful(): self.perror(stream.read(), end="", style=None) - finish_msg = f' {num_transcripts} transcript{plural} passed in {execution_time:.3f} seconds ' - finish_msg = su.align_center(finish_msg, character=self.ruler) - self.psuccess(finish_msg) + finish_msg = f'{num_transcripts} transcript{plural} passed in {execution_time:.3f} seconds' + self.psuccess(Rule(finish_msg, style=Style.null())) else: # Strip off the initial traceback which isn't particularly useful for end users error_str = stream.read() diff --git a/cmd2/rich_utils.py b/cmd2/rich_utils.py index 3e06dfaba..95cd431d0 100644 --- a/cmd2/rich_utils.py +++ b/cmd2/rich_utils.py @@ -1,4 +1,4 @@ -"""Provides common utilities to support Rich in cmd2 applications.""" +"""Provides common utilities to support Rich in cmd2-based applications.""" from collections.abc import Mapping from enum import Enum @@ -17,7 +17,6 @@ RichCast, ) from rich.style import ( - Style, StyleType, ) from rich.text import Text @@ -47,47 +46,44 @@ def __repr__(self) -> str: ALLOW_STYLE = AllowStyle.TERMINAL -class Cmd2Theme(Theme): - """Rich theme class used by cmd2.""" +def _create_default_theme() -> Theme: + """Create a default theme for cmd2-based applications. - def __init__(self, styles: Mapping[str, StyleType] | None = None) -> None: - """Cmd2Theme initializer. + This theme combines the default styles from cmd2, rich-argparse, and Rich. + """ + app_styles = DEFAULT_CMD2_STYLES.copy() + app_styles.update(RichHelpFormatter.styles.copy()) + return Theme(app_styles, inherit=True) - :param styles: optional mapping of style names on to styles. - Defaults to None for a theme with no styles. - """ - cmd2_styles = DEFAULT_CMD2_STYLES.copy() - # Include default styles from rich-argparse - cmd2_styles.update(RichHelpFormatter.styles.copy()) +def set_theme(styles: Mapping[str, StyleType] | None = None) -> None: + """Set the Rich theme used by cmd2. - if styles is not None: - cmd2_styles.update(styles) + Call set_theme() with no arguments to reset to the default theme. + This will clear any custom styles that were previously applied. - # Set inherit to True to include Rich's default styles - super().__init__(cmd2_styles, inherit=True) + :param styles: optional mapping of style names to styles + """ + global APP_THEME # noqa: PLW0603 + # Start with a fresh copy of the default styles. + app_styles: dict[str, StyleType] = {} + app_styles.update(_create_default_theme().styles) -# Current Rich theme used by Cmd2Console -THEME: Cmd2Theme = Cmd2Theme() + # Incorporate custom styles. + if styles is not None: + app_styles.update(styles) + APP_THEME = Theme(app_styles) -def set_theme(new_theme: Cmd2Theme) -> None: - """Set the Rich theme used by cmd2. + # Synchronize rich-argparse styles with the main application theme. + for name in RichHelpFormatter.styles.keys() & APP_THEME.styles.keys(): + RichHelpFormatter.styles[name] = APP_THEME.styles[name] - :param new_theme: new theme to use. - """ - global THEME # noqa: PLW0603 - THEME = new_theme - - # Make sure the new theme has all style names included in a Cmd2Theme. - missing_names = Cmd2Theme().styles.keys() - THEME.styles.keys() - for name in missing_names: - THEME.styles[name] = Style() - # Update rich-argparse styles - for name in RichHelpFormatter.styles.keys() & THEME.styles.keys(): - RichHelpFormatter.styles[name] = THEME.styles[name] +# The main theme for cmd2-based applications. +# You can change it with set_theme(). +APP_THEME = _create_default_theme() class RichPrintKwargs(TypedDict, total=False): @@ -113,7 +109,7 @@ class RichPrintKwargs(TypedDict, total=False): class Cmd2Console(Console): - """Rich console with characteristics appropriate for cmd2 applications.""" + """Rich console with characteristics appropriate for cmd2-based applications.""" def __init__(self, file: IO[str] | None = None) -> None: """Cmd2Console initializer. @@ -147,7 +143,7 @@ def __init__(self, file: IO[str] | None = None) -> None: markup=False, emoji=False, highlight=False, - theme=THEME, + theme=APP_THEME, ) def on_broken_pipe(self) -> None: @@ -178,13 +174,17 @@ def rich_text_to_string(text: Text) -> str: markup=False, emoji=False, highlight=False, - theme=THEME, + theme=APP_THEME, ) with console.capture() as capture: console.print(text, end="") return capture.get() +# If True, Rich still has the bug addressed in string_to_rich_text(). +_from_ansi_has_newline_bug = Text.from_ansi("\n").plain == "" + + def string_to_rich_text(text: str) -> Text: r"""Create a Text object from a string which can contain ANSI escape codes. @@ -199,24 +199,25 @@ def string_to_rich_text(text: str) -> Text: """ result = Text.from_ansi(text) - # If the original string ends with a recognized line break character, - # then restore the missing newline. We use "\n" because Text.from_ansi() - # converts all line breaks into newlines. - # Source: https://docs.python.org/3/library/stdtypes.html#str.splitlines - line_break_chars = { - "\n", # Line Feed - "\r", # Carriage Return - "\v", # Vertical Tab - "\f", # Form Feed - "\x1c", # File Separator - "\x1d", # Group Separator - "\x1e", # Record Separator - "\x85", # Next Line (NEL) - "\u2028", # Line Separator - "\u2029", # Paragraph Separator - } - if text and text[-1] in line_break_chars: - result.append("\n") + if _from_ansi_has_newline_bug: + # If the original string ends with a recognized line break character, + # then restore the missing newline. We use "\n" because Text.from_ansi() + # converts all line breaks into newlines. + # Source: https://docs.python.org/3/library/stdtypes.html#str.splitlines + line_break_chars = { + "\n", # Line Feed + "\r", # Carriage Return + "\v", # Vertical Tab + "\f", # Form Feed + "\x1c", # File Separator + "\x1d", # Group Separator + "\x1e", # Record Separator + "\x85", # Next Line (NEL) + "\u2028", # Line Separator + "\u2029", # Paragraph Separator + } + if text and text[-1] in line_break_chars: + result.append("\n") return result diff --git a/cmd2/styles.py b/cmd2/styles.py index 22cba9f93..57b786069 100644 --- a/cmd2/styles.py +++ b/cmd2/styles.py @@ -30,13 +30,13 @@ class Cmd2Style(StrEnum): added here must have a corresponding style definition there. """ - ERROR = "cmd2.error" - EXAMPLE = "cmd2.example" - HELP_HEADER = "cmd2.help.header" - HELP_LEADER = "cmd2.help.leader" - RULE_LINE = "cmd2.rule.line" - SUCCESS = "cmd2.success" - WARNING = "cmd2.warning" + ERROR = "cmd2.error" # Error text (used by perror()) + EXAMPLE = "cmd2.example" # Command line examples in help text + HELP_HEADER = "cmd2.help.header" # Help table header text + HELP_LEADER = "cmd2.help.leader" # Text right before the help tables are listed + RULE_LINE = "rule.line" # Rich style for horizontal rules + SUCCESS = "cmd2.success" # Success text (used by psuccess()) + WARNING = "cmd2.warning" # Warning text (used by pwarning()) # Default styles used by cmd2. Tightly coupled with the Cmd2Style enum. diff --git a/tests/test_rich_utils.py b/tests/test_rich_utils.py new file mode 100644 index 000000000..af6f4b914 --- /dev/null +++ b/tests/test_rich_utils.py @@ -0,0 +1,85 @@ +"""Unit testing for cmd2/rich_utils.py module""" + +import pytest +from rich.style import Style +from rich.text import Text + +from cmd2 import ( + Cmd2Style, + Color, +) +from cmd2 import rich_utils as ru +from cmd2 import string_utils as su + + +def test_string_to_rich_text() -> None: + # Line breaks recognized by str.splitlines(). + # Source: https://docs.python.org/3/library/stdtypes.html#str.splitlines + line_breaks = { + "\n", # Line Feed + "\r", # Carriage Return + "\r\n", # Carriage Return + Line Feed + "\v", # Vertical Tab + "\f", # Form Feed + "\x1c", # File Separator + "\x1d", # Group Separator + "\x1e", # Record Separator + "\x85", # Next Line (NEL) + "\u2028", # Line Separator + "\u2029", # Paragraph Separator + } + + # Test all line breaks + for lb in line_breaks: + input_string = f"Text{lb}" + expected_output = input_string.replace(lb, "\n") + assert ru.string_to_rich_text(input_string).plain == expected_output + + # Test string without trailing line break + input_string = "No trailing\nline break" + assert ru.string_to_rich_text(input_string).plain == input_string + + # Test empty string + input_string = "" + assert ru.string_to_rich_text(input_string).plain == input_string + + +@pytest.mark.parametrize( + ('rich_text', 'string'), + [ + (Text("Hello"), "Hello"), + (Text("Hello\n"), "Hello\n"), + (Text("Hello", style="blue"), su.stylize("Hello", style="blue")), + ], +) +def test_rich_text_to_string(rich_text: Text, string: str) -> None: + assert ru.rich_text_to_string(rich_text) == string + + +def test_set_style() -> None: + # Save a cmd2, rich-argparse, and rich-specific style. + cmd2_style_key = Cmd2Style.ERROR + argparse_style_key = "argparse.args" + rich_style_key = "inspect.attr" + + orig_cmd2_style = ru.APP_THEME.styles[cmd2_style_key] + orig_argparse_style = ru.APP_THEME.styles[argparse_style_key] + orig_rich_style = ru.APP_THEME.styles[rich_style_key] + + # Overwrite these styles by setting a new theme. + theme = { + cmd2_style_key: Style(color=Color.CYAN), + argparse_style_key: Style(color=Color.AQUAMARINE3, underline=True), + rich_style_key: Style(color=Color.DARK_GOLDENROD, bold=True), + } + ru.set_theme(theme) + + # Verify theme styles have changed to our custom values. + assert ru.APP_THEME.styles[cmd2_style_key] != orig_cmd2_style + assert ru.APP_THEME.styles[cmd2_style_key] == theme[cmd2_style_key] + + assert ru.APP_THEME.styles[argparse_style_key] != orig_argparse_style + assert ru.APP_THEME.styles[argparse_style_key] == theme[argparse_style_key] + + assert ru.APP_THEME.styles[rich_style_key] != orig_rich_style + assert ru.APP_THEME.styles[rich_style_key] == theme[rich_style_key]