Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions cmd2/cmd2.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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()
Expand Down
107 changes: 54 additions & 53 deletions cmd2/rich_utils.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -17,7 +17,6 @@
RichCast,
)
from rich.style import (
Style,
StyleType,
)
from rich.text import Text
Expand Down Expand Up @@ -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):
Expand All @@ -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.
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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.

Expand All @@ -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

Expand Down
14 changes: 7 additions & 7 deletions cmd2/styles.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
85 changes: 85 additions & 0 deletions tests/test_rich_utils.py
Original file line number Diff line number Diff line change
@@ -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]
Loading