From dfedeba916d517ad735ab889bd5569d6d765ad72 Mon Sep 17 00:00:00 2001 From: Todd Leonhardt Date: Fri, 24 Apr 2026 19:17:41 -0400 Subject: [PATCH 01/22] Add styles to theme for completion item and meta foreground and background color This is an attempt to provide higher-contrast color options and to make them configurable as part of a theme. --- CHANGELOG.md | 2 ++ cmd2/cmd2.py | 45 +++++++++++++++++++++++++++++++++++++ cmd2/styles.py | 4 ++++ docs/features/completion.md | 7 ++++++ docs/features/theme.md | 14 ++++++++++++ docs/upgrades.md | 10 +++++++++ 6 files changed, 82 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b5e563151..f4f52e6f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -135,6 +135,8 @@ prompt is displayed. full type hints and IDE autocompletion for `self._cmd` without needing to override and cast the property. - Added `traceback_kwargs` attribute to allow customization of Rich-based tracebacks. + - Added ability to customize `prompt-toolkit` completion menu colors by overriding + `Cmd2Style.COMPLETION_MENU_ITEM` and `Cmd2Style.COMPLETION_MENU_META` in the `cmd2` theme. ## 3.5.1 (April 24, 2026) diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index f2ab98944..dba825041 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -78,6 +78,8 @@ from prompt_toolkit.output import DummyOutput, create_output from prompt_toolkit.patch_stdout import patch_stdout from prompt_toolkit.shortcuts import CompleteStyle, PromptSession, choice, set_title +from prompt_toolkit.styles import DynamicStyle +from prompt_toolkit.styles import Style as PtStyle from rich.console import ( Group, JustifyMethod, @@ -714,6 +716,46 @@ def _should_continue_multiline(self) -> bool: # No macro found or already processed. The statement is complete. return False + def _get_pt_style(self) -> "PtStyle": + """Return the prompt_toolkit style for the completion menu.""" + + def to_pt_style(rich_style: Style | None) -> str: + """Convert a rich Style object to a prompt_toolkit style string.""" + if not rich_style: + return "" + parts = ["noreverse"] + if rich_style.color: + c = rich_style.color.get_truecolor() + parts.append(f"fg:#{c.red:02x}{c.green:02x}{c.blue:02x}") + else: + parts.append("fg:default") + + if rich_style.bgcolor: + c = rich_style.bgcolor.get_truecolor() + parts.append(f"bg:#{c.red:02x}{c.green:02x}{c.blue:02x}") + else: + parts.append("bg:default") + + if rich_style.bold is not None: + parts.append("bold" if rich_style.bold else "nobold") + if rich_style.italic is not None: + parts.append("italic" if rich_style.italic else "noitalic") + if rich_style.underline is not None: + parts.append("underline" if rich_style.underline else "nounderline") + return " ".join(parts) + + theme = ru.get_theme() + item_style = to_pt_style(theme.styles.get(Cmd2Style.COMPLETION_MENU_ITEM)) + meta_style = to_pt_style(theme.styles.get(Cmd2Style.COMPLETION_MENU_META)) + + return PtStyle.from_dict( + { + "completion-menu.completion.current": item_style, + "completion-menu.meta.completion.current": meta_style, + "completion-menu.multi-column-meta": meta_style, + } + ) + def _create_main_session(self, auto_suggest: bool, completekey: str) -> PromptSession[str]: """Create and return the main PromptSession for the application. @@ -755,6 +797,7 @@ def _(event: Any) -> None: # pragma: no cover "multiline": filters.Condition(self._should_continue_multiline), "prompt_continuation": self.continuation_prompt, "rprompt": self.get_rprompt, + "style": DynamicStyle(self._get_pt_style), } if self.stdin.isatty() and self.stdout.isatty(): @@ -3578,6 +3621,7 @@ def read_input( key_bindings=self.main_session.key_bindings, input=self.main_session.input, output=self.main_session.output, + style=DynamicStyle(self._get_pt_style), ) return self._read_raw_input(prompt, temp_session) @@ -3596,6 +3640,7 @@ def read_secret( temp_session: PromptSession[str] = PromptSession( input=self.main_session.input, output=self.main_session.output, + style=DynamicStyle(self._get_pt_style), ) return self._read_raw_input(prompt, temp_session, is_password=True) diff --git a/cmd2/styles.py b/cmd2/styles.py index 15489d46e..4d015d30b 100644 --- a/cmd2/styles.py +++ b/cmd2/styles.py @@ -51,6 +51,8 @@ class Cmd2Style(StrEnum): """ COMMAND_LINE = "cmd2.example" # Command line examples in help text + COMPLETION_MENU_ITEM = "cmd2.completion_menu.item" # Selected completion item + COMPLETION_MENU_META = "cmd2.completion_menu.meta" # Selected completion help/meta text ERROR = "cmd2.error" # Error text (used by perror()) HELP_HEADER = "cmd2.help.header" # Help table header text HELP_LEADER = "cmd2.help.leader" # Text right before the help tables are listed @@ -63,6 +65,8 @@ class Cmd2Style(StrEnum): # Tightly coupled with the Cmd2Style enum. DEFAULT_CMD2_STYLES: dict[str, StyleType] = { Cmd2Style.COMMAND_LINE: Style(color=Color.CYAN, bold=True), + Cmd2Style.COMPLETION_MENU_ITEM: Style(color=Color.BLACK, bgcolor=Color.GREEN), + Cmd2Style.COMPLETION_MENU_META: Style(color=Color.BLACK, bgcolor=Color.LIGHT_GREEN), Cmd2Style.ERROR: Style(color=Color.BRIGHT_RED), Cmd2Style.HELP_HEADER: Style(color=Color.BRIGHT_GREEN), Cmd2Style.HELP_LEADER: Style(color=Color.CYAN), diff --git a/docs/features/completion.md b/docs/features/completion.md index 85143650b..1def16f23 100644 --- a/docs/features/completion.md +++ b/docs/features/completion.md @@ -116,6 +116,13 @@ demonstration of how this is used. [read_input](https://github.com/python-cmd2/cmd2/blob/main/examples/read_input.py) example for a demonstration. +## Custom Completion Menu Colors + +`cmd2` provides the ability to customize the foreground and background colors of the completion menu +items and their associated help text. See +[Customizing Completion Menu Colors](./theme.md#customizing-completion-menu-colors) in the Theme +documentation for more details. + ## For More Information See [cmd2's argparse_utils API](../api/argparse_utils.md) for a more detailed discussion of argparse diff --git a/docs/features/theme.md b/docs/features/theme.md index 064178fa7..cd348f061 100644 --- a/docs/features/theme.md +++ b/docs/features/theme.md @@ -6,5 +6,19 @@ information. You can use this to brand your application and set an overall consistent look and feel that is appealing to your user base. +## Customizing Completion Menu Colors + +`cmd2` leverages `prompt-toolkit` for its tab completion menu. You can customize the colors of the +completion menu by overriding the following styles in your `cmd2` theme: + +- `Cmd2Style.COMPLETION_MENU_ITEM`: The background and foreground color of the selected completion + item. +- `Cmd2Style.COMPLETION_MENU_META`: The background and foreground color of the selected completion + item's help/meta text. + +By default, these are styled with black text on a green background to provide contrast. + +## Example + See [rich_theme.py](https://github.com/python-cmd2/cmd2/blob/main/examples/rich_theme.py) for a simple example of configuring a custom theme for your `cmd2` application. diff --git a/docs/upgrades.md b/docs/upgrades.md index a89e248f2..2d10a7a44 100644 --- a/docs/upgrades.md +++ b/docs/upgrades.md @@ -46,6 +46,16 @@ See the example for a demonstration of how to implement a background thread that refreshes the toolbar periodically. +### Custom Completion Menu Colors + +`cmd2` now leverages `prompt-toolkit` for its tab completion menu and provides the ability to +customize its appearance using the `cmd2` theme. + +- **Customization**: Override the `Cmd2Style.COMPLETION_MENU_ITEM` and + `Cmd2Style.COMPLETION_MENU_META` styles using `cmd2.rich_utils.set_theme()`. See + [Customizing Completion Menu Colors](features/theme.md#customizing-completion-menu-colors) for + more details. + ### Deleted Modules Removed `rl_utils.py` and `terminal_utils.py` since `prompt-toolkit` provides this functionality. From 1a73c22b29c454c55924ff2830b02d014e7d2657 Mon Sep 17 00:00:00 2001 From: Todd Leonhardt Date: Sat, 25 Apr 2026 14:54:20 -0400 Subject: [PATCH 02/22] Moved to_pt_style function to pt_utils.py and added unit tests for it Also: - Cache prompt-toolkit completion menu styles for efficiency - Fixed color check bug related to default terminal style --- cmd2/cmd2.py | 45 ++++++++++---------------- cmd2/pt_utils.py | 27 ++++++++++++++++ tests/test_pt_utils.py | 73 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 117 insertions(+), 28 deletions(-) diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index dba825041..0ab5ad430 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -192,6 +192,7 @@ def __init__(self, msg: str = "") -> None: Cmd2History, Cmd2Lexer, pt_filter_style, + to_pt_style, ) from .utils import ( Settable, @@ -523,6 +524,10 @@ def __init__( self._persistent_history_length = persistent_history_length self._initialize_history(persistent_history_file) + # Cache for prompt_toolkit completion menu styles + self._cached_pt_style: PtStyle | None = None + self._cached_pt_style_params: tuple[Style | None, Style | None] | None = None + # Create the main PromptSession self.bottom_toolbar = bottom_toolbar self.main_session = self._create_main_session(auto_suggest, completekey) @@ -718,37 +723,19 @@ def _should_continue_multiline(self) -> bool: def _get_pt_style(self) -> "PtStyle": """Return the prompt_toolkit style for the completion menu.""" + theme = ru.get_theme() + rich_item_style = theme.styles.get(Cmd2Style.COMPLETION_MENU_ITEM) + rich_meta_style = theme.styles.get(Cmd2Style.COMPLETION_MENU_META) - def to_pt_style(rich_style: Style | None) -> str: - """Convert a rich Style object to a prompt_toolkit style string.""" - if not rich_style: - return "" - parts = ["noreverse"] - if rich_style.color: - c = rich_style.color.get_truecolor() - parts.append(f"fg:#{c.red:02x}{c.green:02x}{c.blue:02x}") - else: - parts.append("fg:default") - - if rich_style.bgcolor: - c = rich_style.bgcolor.get_truecolor() - parts.append(f"bg:#{c.red:02x}{c.green:02x}{c.blue:02x}") - else: - parts.append("bg:default") - - if rich_style.bold is not None: - parts.append("bold" if rich_style.bold else "nobold") - if rich_style.italic is not None: - parts.append("italic" if rich_style.italic else "noitalic") - if rich_style.underline is not None: - parts.append("underline" if rich_style.underline else "nounderline") - return " ".join(parts) + current_params = (rich_item_style, rich_meta_style) + if self._cached_pt_style is not None and self._cached_pt_style_params == current_params: + return self._cached_pt_style - theme = ru.get_theme() - item_style = to_pt_style(theme.styles.get(Cmd2Style.COMPLETION_MENU_ITEM)) - meta_style = to_pt_style(theme.styles.get(Cmd2Style.COMPLETION_MENU_META)) + item_style = to_pt_style(rich_item_style) + meta_style = to_pt_style(rich_meta_style) - return PtStyle.from_dict( + self._cached_pt_style_params = current_params + self._cached_pt_style = PtStyle.from_dict( { "completion-menu.completion.current": item_style, "completion-menu.meta.completion.current": meta_style, @@ -756,6 +743,8 @@ def to_pt_style(rich_style: Style | None) -> str: } ) + return self._cached_pt_style + def _create_main_session(self, auto_suggest: bool, completekey: str) -> PromptSession[str]: """Create and return the main PromptSession for the application. diff --git a/cmd2/pt_utils.py b/cmd2/pt_utils.py index 8ebdb9f3e..62ffe7f72 100644 --- a/cmd2/pt_utils.py +++ b/cmd2/pt_utils.py @@ -20,6 +20,7 @@ from prompt_toolkit.formatted_text import ANSI from prompt_toolkit.history import History from prompt_toolkit.lexers import Lexer +from rich.style import Style from . import ( constants, @@ -50,6 +51,32 @@ def pt_filter_style(text: str | ANSI) -> str | ANSI: return text if isinstance(text, ANSI) else ANSI(text) +def to_pt_style(rich_style: Style | None) -> str: + """Convert a rich Style object to a prompt_toolkit style string.""" + if not rich_style: + return "" + parts = ["noreverse"] + if rich_style.color and not rich_style.color.is_default: + c = rich_style.color.get_truecolor() + parts.append(f"fg:#{c.red:02x}{c.green:02x}{c.blue:02x}") + else: + parts.append("fg:default") + + if rich_style.bgcolor and not rich_style.bgcolor.is_default: + c = rich_style.bgcolor.get_truecolor() + parts.append(f"bg:#{c.red:02x}{c.green:02x}{c.blue:02x}") + else: + parts.append("bg:default") + + if rich_style.bold is not None: + parts.append("bold" if rich_style.bold else "nobold") + if rich_style.italic is not None: + parts.append("italic" if rich_style.italic else "noitalic") + if rich_style.underline is not None: + parts.append("underline" if rich_style.underline else "nounderline") + return " ".join(parts) + + class Cmd2Completer(Completer): """Completer that delegates to cmd2's completion logic.""" diff --git a/tests/test_pt_utils.py b/tests/test_pt_utils.py index 146ab81c8..54ffe5957 100644 --- a/tests/test_pt_utils.py +++ b/tests/test_pt_utils.py @@ -624,3 +624,76 @@ def test_clear(self): history.clear() assert not history.get_strings() + + +class TestToPtStyle: + def test_to_pt_style_none(self): + assert pt_utils.to_pt_style(None) == "" + + def test_to_pt_style_color(self): + from rich.style import Style + + style = Style(color="#123456") + pt_style = pt_utils.to_pt_style(style) + assert "fg:#123456" in pt_style + assert "bg:default" in pt_style + assert "noreverse" in pt_style + + def test_to_pt_style_bgcolor(self): + from rich.style import Style + + style = Style(bgcolor="#654321") + pt_style = pt_utils.to_pt_style(style) + assert "fg:default" in pt_style + assert "bg:#654321" in pt_style + + def test_to_pt_style_default_color(self): + from rich.style import Style + + style = Style(color="default", bgcolor="default") + pt_style = pt_utils.to_pt_style(style) + assert "fg:default" in pt_style + assert "bg:default" in pt_style + + def test_to_pt_style_bold(self): + from rich.style import Style + + style = Style(bold=True) + pt_style = pt_utils.to_pt_style(style) + assert "bold" in pt_style + assert "nobold" not in pt_style + + def test_to_pt_style_nobold(self): + from rich.style import Style + + style = Style(bold=False) + pt_style = pt_utils.to_pt_style(style) + assert "nobold" in pt_style + + def test_to_pt_style_italic(self): + from rich.style import Style + + style = Style(italic=True) + pt_style = pt_utils.to_pt_style(style) + assert "italic" in pt_style + + def test_to_pt_style_noitalic(self): + from rich.style import Style + + style = Style(italic=False) + pt_style = pt_utils.to_pt_style(style) + assert "noitalic" in pt_style + + def test_to_pt_style_underline(self): + from rich.style import Style + + style = Style(underline=True) + pt_style = pt_utils.to_pt_style(style) + assert "underline" in pt_style + + def test_to_pt_style_nounderline(self): + from rich.style import Style + + style = Style(underline=False) + pt_style = pt_utils.to_pt_style(style) + assert "nounderline" in pt_style From 0b5e443ee2cf4cefc53ef039c348cffd6d83d137 Mon Sep 17 00:00:00 2001 From: Todd Leonhardt Date: Sat, 25 Apr 2026 15:07:21 -0400 Subject: [PATCH 03/22] Add a unit test for _get_pt_style method --- tests/test_cmd2.py | 42 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/tests/test_cmd2.py b/tests/test_cmd2.py index d8093eed1..7ff54ec27 100644 --- a/tests/test_cmd2.py +++ b/tests/test_cmd2.py @@ -1889,6 +1889,48 @@ def do_orate(self, opts, arg) -> None: self.stdout.write(arg + "\n") +def test_get_pt_style_caching(base_app) -> None: + # Get the initial style (populates the cache) + style1 = base_app._get_pt_style() + + # Getting it again should return the exact same object from the cache + style2 = base_app._get_pt_style() + assert style1 is style2 + + # Change the theme which should invalidate the cache + from rich.style import Style + + import cmd2.rich_utils as ru + from cmd2.styles import Cmd2Style + + # Save the original theme to restore later + orig_theme = ru.get_theme() + + try: + ru.set_theme({Cmd2Style.COMPLETION_MENU_ITEM: Style(color="red")}) + + # Getting the style now should return a new object + style3 = base_app._get_pt_style() + assert style3 is not style1 + + # Getting it again should return the new cached object + style4 = base_app._get_pt_style() + assert style4 is style3 + + # Verify the style reflects the change + # In prompt_toolkit 3, styles are accessed differently + attrs = style3.class_names_and_attrs + found = False + for classes, attr in attrs: + if "completion-menu.completion.current" in classes and attr.color in ("800000", "darkred", "ff0000", "#800000"): + found = True + break + assert found, "Color change not found in cached style" + + finally: + ru._APP_THEME = orig_theme + + @pytest.fixture def multiline_app(): return MultilineApp() From a6927c3ba889aaaa40cc63d6523c58a6911cd3a3 Mon Sep 17 00:00:00 2001 From: Todd Leonhardt Date: Sun, 26 Apr 2026 14:32:35 -0400 Subject: [PATCH 04/22] Change metadata default color to BRIGHT_GREEN instead of LIGHT_GREEN BRIGHT_GREEN is one of the standard 16 colors typically configured by a user's terminal theme. LIGHT_GREEN is from the extended 256-color range. --- cmd2/styles.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd2/styles.py b/cmd2/styles.py index 4d015d30b..ff08bbf3a 100644 --- a/cmd2/styles.py +++ b/cmd2/styles.py @@ -66,7 +66,7 @@ class Cmd2Style(StrEnum): DEFAULT_CMD2_STYLES: dict[str, StyleType] = { Cmd2Style.COMMAND_LINE: Style(color=Color.CYAN, bold=True), Cmd2Style.COMPLETION_MENU_ITEM: Style(color=Color.BLACK, bgcolor=Color.GREEN), - Cmd2Style.COMPLETION_MENU_META: Style(color=Color.BLACK, bgcolor=Color.LIGHT_GREEN), + Cmd2Style.COMPLETION_MENU_META: Style(color=Color.BLACK, bgcolor=Color.BRIGHT_GREEN), Cmd2Style.ERROR: Style(color=Color.BRIGHT_RED), Cmd2Style.HELP_HEADER: Style(color=Color.BRIGHT_GREEN), Cmd2Style.HELP_LEADER: Style(color=Color.CYAN), From 339b73db7cbed449d6d3ea9b9473af08c1246161 Mon Sep 17 00:00:00 2001 From: Todd Leonhardt Date: Sun, 26 Apr 2026 14:59:00 -0400 Subject: [PATCH 05/22] Added `rich_to_pt_color` conversion helper function Created a new converter function to safely extract the correct string representation for prompt-toolkit: - For `ColorSystem.STANDARD` colors (0-15), it maps directly to `prompt-toolkit`'s `ansicolor` names (e.g. `ansired`, `ansibrightred`). This ensures that `prompt-toolkit` delegates to the terminal's theme for the basic 16 colors. - For `ColorSystem.EIGHT_BIT` and `ColorSystem.TRUECOLOR`, it falls back to parsing the RGB hex code (e.g., `#ff0000`), which `prompt-toolkit` supports natively. Also: - Renamed `to_pt_style` to `rich_to_pt_style` to make its intent more obvious - Added tests for the new `rich_to_pt_color` conversion function --- cmd2/cmd2.py | 6 ++-- cmd2/pt_utils.py | 55 ++++++++++++++++++++++------- tests/test_cmd2.py | 8 ++++- tests/test_pt_utils.py | 78 ++++++++++++++++++++++++++++++------------ 4 files changed, 110 insertions(+), 37 deletions(-) diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 030acd8be..e82569c5b 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -192,7 +192,7 @@ def __init__(self, msg: str = "") -> None: Cmd2History, Cmd2Lexer, pt_filter_style, - to_pt_style, + rich_to_pt_style, ) from .utils import ( Settable, @@ -731,8 +731,8 @@ def _get_pt_style(self) -> "PtStyle": if self._cached_pt_style is not None and self._cached_pt_style_params == current_params: return self._cached_pt_style - item_style = to_pt_style(rich_item_style) - meta_style = to_pt_style(rich_meta_style) + item_style = rich_to_pt_style(rich_item_style) + meta_style = rich_to_pt_style(rich_meta_style) self._cached_pt_style_params = current_params self._cached_pt_style = PtStyle.from_dict( diff --git a/cmd2/pt_utils.py b/cmd2/pt_utils.py index a898effba..d43db62c6 100644 --- a/cmd2/pt_utils.py +++ b/cmd2/pt_utils.py @@ -30,6 +30,8 @@ from . import string_utils as su if TYPE_CHECKING: # pragma: no cover + from rich.color import Color + from .cmd2 import Cmd @@ -51,22 +53,51 @@ def pt_filter_style(text: str | ANSI) -> str | ANSI: return text if isinstance(text, ANSI) else ANSI(text) -def to_pt_style(rich_style: Style | None) -> str: +def rich_to_pt_color(color: "Color | None") -> str: + """Convert a rich Color object to a prompt_toolkit color string.""" + if not color or color.is_default: + return "default" + + # Use prompt_toolkit's 16 standard ansi color names if applicable. + # This prevents overriding terminal themes with absolute RGB values. + if color.number is not None and 0 <= color.number <= 15: + # prompt_toolkit accepts these standard names directly + ansi_names = [ + "ansiblack", + "ansired", + "ansigreen", + "ansiyellow", + "ansiblue", + "ansimagenta", + "ansicyan", + "ansiwhite", + "ansibrightblack", + "ansibrightred", + "ansibrightgreen", + "ansibrightyellow", + "ansibrightblue", + "ansibrightmagenta", + "ansibrightcyan", + "ansibrightwhite", + ] + return ansi_names[color.number] + + # For 8-bit and truecolor, we fallback to hex RGB strings which prompt-toolkit supports natively + c = color.get_truecolor() + return f"#{c.red:02x}{c.green:02x}{c.blue:02x}" + + +def rich_to_pt_style(rich_style: Style | None) -> str: """Convert a rich Style object to a prompt_toolkit style string.""" if not rich_style: return "" parts = ["noreverse"] - if rich_style.color and not rich_style.color.is_default: - c = rich_style.color.get_truecolor() - parts.append(f"fg:#{c.red:02x}{c.green:02x}{c.blue:02x}") - else: - parts.append("fg:default") - - if rich_style.bgcolor and not rich_style.bgcolor.is_default: - c = rich_style.bgcolor.get_truecolor() - parts.append(f"bg:#{c.red:02x}{c.green:02x}{c.blue:02x}") - else: - parts.append("bg:default") + + fg_color = rich_to_pt_color(rich_style.color) + parts.append(f"fg:{fg_color}") + + bg_color = rich_to_pt_color(rich_style.bgcolor) + parts.append(f"bg:{bg_color}") if rich_style.bold is not None: parts.append("bold" if rich_style.bold else "nobold") diff --git a/tests/test_cmd2.py b/tests/test_cmd2.py index 155927b09..10e46e726 100644 --- a/tests/test_cmd2.py +++ b/tests/test_cmd2.py @@ -1922,7 +1922,13 @@ def test_get_pt_style_caching(base_app) -> None: attrs = style3.class_names_and_attrs found = False for classes, attr in attrs: - if "completion-menu.completion.current" in classes and attr.color in ("800000", "darkred", "ff0000", "#800000"): + if "completion-menu.completion.current" in classes and attr.color in ( + "800000", + "darkred", + "ff0000", + "#800000", + "ansired", + ): found = True break assert found, "Color change not found in cached style" diff --git a/tests/test_pt_utils.py b/tests/test_pt_utils.py index 486bd70b2..a044fc693 100644 --- a/tests/test_pt_utils.py +++ b/tests/test_pt_utils.py @@ -602,74 +602,110 @@ def test_clear(self): assert not history.get_strings() -class TestToPtStyle: - def test_to_pt_style_none(self): - assert pt_utils.to_pt_style(None) == "" +class TestRichToPtColor: + def test_rich_to_pt_color_none(self): + assert pt_utils.rich_to_pt_color(None) == "default" - def test_to_pt_style_color(self): + def test_rich_to_pt_color_default(self): + from rich.color import Color + + c = Color.parse("default") + assert pt_utils.rich_to_pt_color(c) == "default" + + def test_rich_to_pt_color_standard(self): + from rich.color import Color + + c = Color.parse("red") + assert pt_utils.rich_to_pt_color(c) == "ansired" + c = Color.parse("bright_red") + assert pt_utils.rich_to_pt_color(c) == "ansibrightred" + # Test a standard color initialized by number + c = Color.from_ansi(2) + assert pt_utils.rich_to_pt_color(c) == "ansigreen" + + def test_rich_to_pt_color_eight_bit(self): + from rich.color import Color + + # 155 is an 8-bit color + c = Color.from_ansi(155) + # Should convert to truecolor hex equivalent #afff5f + assert pt_utils.rich_to_pt_color(c) == "#afff5f" + + def test_rich_to_pt_color_truecolor(self): + from rich.color import Color + + c = Color.parse("#123456") + assert pt_utils.rich_to_pt_color(c) == "#123456" + + +class TestRichToPtStyle: + def test_rich_to_pt_style_none(self): + assert pt_utils.rich_to_pt_style(None) == "" + + def test_rich_to_pt_style_color(self): from rich.style import Style style = Style(color="#123456") - pt_style = pt_utils.to_pt_style(style) + pt_style = pt_utils.rich_to_pt_style(style) assert "fg:#123456" in pt_style assert "bg:default" in pt_style assert "noreverse" in pt_style - def test_to_pt_style_bgcolor(self): + def test_rich_to_pt_style_bgcolor(self): from rich.style import Style style = Style(bgcolor="#654321") - pt_style = pt_utils.to_pt_style(style) + pt_style = pt_utils.rich_to_pt_style(style) assert "fg:default" in pt_style assert "bg:#654321" in pt_style - def test_to_pt_style_default_color(self): + def test_rich_to_pt_style_default_color(self): from rich.style import Style style = Style(color="default", bgcolor="default") - pt_style = pt_utils.to_pt_style(style) + pt_style = pt_utils.rich_to_pt_style(style) assert "fg:default" in pt_style assert "bg:default" in pt_style - def test_to_pt_style_bold(self): + def test_rich_to_pt_style_bold(self): from rich.style import Style style = Style(bold=True) - pt_style = pt_utils.to_pt_style(style) + pt_style = pt_utils.rich_to_pt_style(style) assert "bold" in pt_style assert "nobold" not in pt_style - def test_to_pt_style_nobold(self): + def test_rich_to_pt_style_nobold(self): from rich.style import Style style = Style(bold=False) - pt_style = pt_utils.to_pt_style(style) + pt_style = pt_utils.rich_to_pt_style(style) assert "nobold" in pt_style - def test_to_pt_style_italic(self): + def test_rich_to_pt_style_italic(self): from rich.style import Style style = Style(italic=True) - pt_style = pt_utils.to_pt_style(style) + pt_style = pt_utils.rich_to_pt_style(style) assert "italic" in pt_style - def test_to_pt_style_noitalic(self): + def test_rich_to_pt_style_noitalic(self): from rich.style import Style style = Style(italic=False) - pt_style = pt_utils.to_pt_style(style) + pt_style = pt_utils.rich_to_pt_style(style) assert "noitalic" in pt_style - def test_to_pt_style_underline(self): + def test_rich_to_pt_style_underline(self): from rich.style import Style style = Style(underline=True) - pt_style = pt_utils.to_pt_style(style) + pt_style = pt_utils.rich_to_pt_style(style) assert "underline" in pt_style - def test_to_pt_style_nounderline(self): + def test_rich_to_pt_style_nounderline(self): from rich.style import Style style = Style(underline=False) - pt_style = pt_utils.to_pt_style(style) + pt_style = pt_utils.rich_to_pt_style(style) assert "nounderline" in pt_style From 2bcda9a7bb63e2f888f9a8f6b66fe8e38bd96d89 Mon Sep 17 00:00:00 2001 From: Todd Leonhardt Date: Sun, 26 Apr 2026 15:06:37 -0400 Subject: [PATCH 06/22] Move ansi_names list to ANSI_NAMES tuple constant to cleanup function --- cmd2/pt_utils.py | 41 +++++++++++++++++++++-------------------- 1 file changed, 21 insertions(+), 20 deletions(-) diff --git a/cmd2/pt_utils.py b/cmd2/pt_utils.py index d43db62c6..11362dd81 100644 --- a/cmd2/pt_utils.py +++ b/cmd2/pt_utils.py @@ -37,6 +37,26 @@ BASE_DELIMITERS = " \t\n" + "".join(constants.QUOTES) + "".join(constants.REDIRECTION_CHARS) +# prompt_toolkit accepts these standard ANSI color names directly +ANSI_NAMES = ( + "ansiblack", + "ansired", + "ansigreen", + "ansiyellow", + "ansiblue", + "ansimagenta", + "ansicyan", + "ansiwhite", + "ansibrightblack", + "ansibrightred", + "ansibrightgreen", + "ansibrightyellow", + "ansibrightblue", + "ansibrightmagenta", + "ansibrightcyan", + "ansibrightwhite", +) + def pt_filter_style(text: str | ANSI) -> str | ANSI: """Strip styles if disallowed by ru.ALLOW_STYLE. Otherwise return an ANSI object. @@ -61,26 +81,7 @@ def rich_to_pt_color(color: "Color | None") -> str: # Use prompt_toolkit's 16 standard ansi color names if applicable. # This prevents overriding terminal themes with absolute RGB values. if color.number is not None and 0 <= color.number <= 15: - # prompt_toolkit accepts these standard names directly - ansi_names = [ - "ansiblack", - "ansired", - "ansigreen", - "ansiyellow", - "ansiblue", - "ansimagenta", - "ansicyan", - "ansiwhite", - "ansibrightblack", - "ansibrightred", - "ansibrightgreen", - "ansibrightyellow", - "ansibrightblue", - "ansibrightmagenta", - "ansibrightcyan", - "ansibrightwhite", - ] - return ansi_names[color.number] + return ANSI_NAMES[color.number] # For 8-bit and truecolor, we fallback to hex RGB strings which prompt-toolkit supports natively c = color.get_truecolor() From f10f42b72ca7d6b162c98acf1875331a507fbac7 Mon Sep 17 00:00:00 2001 From: Todd Leonhardt Date: Sun, 26 Apr 2026 15:38:41 -0400 Subject: [PATCH 07/22] Change rich_to_pt_style to take a StyleType instead of Style where StyleType = Style | str --- cmd2/cmd2.py | 6 +++--- cmd2/pt_utils.py | 8 ++++++-- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index e82569c5b..c67779a2e 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -526,7 +526,7 @@ def __init__( # Cache for prompt_toolkit completion menu styles self._cached_pt_style: PtStyle | None = None - self._cached_pt_style_params: tuple[Style | None, Style | None] | None = None + self._cached_pt_style_params: tuple[StyleType, StyleType] | None = None # Create the main PromptSession self.bottom_toolbar = bottom_toolbar @@ -724,8 +724,8 @@ def _should_continue_multiline(self) -> bool: def _get_pt_style(self) -> "PtStyle": """Return the prompt_toolkit style for the completion menu.""" theme = ru.get_theme() - rich_item_style = theme.styles.get(Cmd2Style.COMPLETION_MENU_ITEM) - rich_meta_style = theme.styles.get(Cmd2Style.COMPLETION_MENU_META) + rich_item_style = theme.styles.get(Cmd2Style.COMPLETION_MENU_ITEM, "") + rich_meta_style = theme.styles.get(Cmd2Style.COMPLETION_MENU_META, "") current_params = (rich_item_style, rich_meta_style) if self._cached_pt_style is not None and self._cached_pt_style_params == current_params: diff --git a/cmd2/pt_utils.py b/cmd2/pt_utils.py index 11362dd81..d71c84c28 100644 --- a/cmd2/pt_utils.py +++ b/cmd2/pt_utils.py @@ -20,7 +20,7 @@ from prompt_toolkit.formatted_text import ANSI from prompt_toolkit.history import History from prompt_toolkit.lexers import Lexer -from rich.style import Style +from rich.style import Style, StyleType from . import ( constants, @@ -88,10 +88,14 @@ def rich_to_pt_color(color: "Color | None") -> str: return f"#{c.red:02x}{c.green:02x}{c.blue:02x}" -def rich_to_pt_style(rich_style: Style | None) -> str: +def rich_to_pt_style(rich_style: StyleType) -> str: """Convert a rich Style object to a prompt_toolkit style string.""" if not rich_style: return "" + + if isinstance(rich_style, str): + rich_style = Style.parse(rich_style) + parts = ["noreverse"] fg_color = rich_to_pt_color(rich_style.color) From 3bc0070a720f0a6ad2b205907dcb410f06f84f21 Mon Sep 17 00:00:00 2001 From: Todd Leonhardt Date: Sun, 26 Apr 2026 15:52:42 -0400 Subject: [PATCH 08/22] Add unit test for case when a str is passed to rich-to_pt_style --- tests/test_pt_utils.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/test_pt_utils.py b/tests/test_pt_utils.py index a044fc693..9b5bdfe3a 100644 --- a/tests/test_pt_utils.py +++ b/tests/test_pt_utils.py @@ -642,6 +642,13 @@ class TestRichToPtStyle: def test_rich_to_pt_style_none(self): assert pt_utils.rich_to_pt_style(None) == "" + def test_rich_to_pt_style_string(self): + pt_style = pt_utils.rich_to_pt_style("bold red on blue") + assert "fg:ansired" in pt_style + assert "bg:ansiblue" in pt_style + assert "bold" in pt_style + assert "nobold" not in pt_style + def test_rich_to_pt_style_color(self): from rich.style import Style From a02e6881dfd251e4b2ccd73e5590d7bcb492bcb9 Mon Sep 17 00:00:00 2001 From: Todd Leonhardt Date: Thu, 30 Apr 2026 18:13:59 -0400 Subject: [PATCH 09/22] Cmd2Lexer colors are now part of the cmd2 rich theme --- cmd2/pt_utils.py | 24 ++++++++------------- cmd2/styles.py | 10 +++++++++ examples/rich_theme.py | 7 +++++++ tests/test_pt_utils.py | 47 ++++++++++++++++++++++++++++-------------- 4 files changed, 58 insertions(+), 30 deletions(-) diff --git a/cmd2/pt_utils.py b/cmd2/pt_utils.py index d71c84c28..cc954c08d 100644 --- a/cmd2/pt_utils.py +++ b/cmd2/pt_utils.py @@ -28,6 +28,7 @@ ) from . import rich_utils as ru from . import string_utils as su +from .styles import Cmd2Style if TYPE_CHECKING: # pragma: no cover from rich.color import Color @@ -259,28 +260,21 @@ class Cmd2Lexer(Lexer): def __init__( self, cmd_app: "Cmd", - command_color: str = "ansigreen", - alias_color: str = "ansicyan", - macro_color: str = "ansimagenta", - flag_color: str = "ansired", - argument_color: str = "ansiyellow", ) -> None: """Initialize the Lexer. :param cmd_app: cmd2.Cmd instance - :param command_color: color to use for commands, defaults to 'ansigreen' - :param alias_color: color to use for aliases, defaults to 'ansicyan' - :param macro_color: color to use for macros, defaults to 'ansimagenta' - :param flag_color: color to use for flags, defaults to 'ansired' - :param argument_color: color to use for arguments, defaults to 'ansiyellow' """ super().__init__() self.cmd_app = cmd_app - self.command_color = command_color - self.alias_color = alias_color - self.macro_color = macro_color - self.flag_color = flag_color - self.argument_color = argument_color + + # Retrieve styles dynamically from the current theme + theme = ru.get_theme() + self.command_color = rich_to_pt_style(theme.styles.get(Cmd2Style.LEXER_COMMAND, "")) + self.alias_color = rich_to_pt_style(theme.styles.get(Cmd2Style.LEXER_ALIAS, "")) + self.macro_color = rich_to_pt_style(theme.styles.get(Cmd2Style.LEXER_MACRO, "")) + self.flag_color = rich_to_pt_style(theme.styles.get(Cmd2Style.LEXER_FLAG, "")) + self.argument_color = rich_to_pt_style(theme.styles.get(Cmd2Style.LEXER_ARGUMENT, "")) def lex_document(self, document: Document) -> Callable[[int], Any]: """Lex the document.""" diff --git a/cmd2/styles.py b/cmd2/styles.py index ff08bbf3a..995979453 100644 --- a/cmd2/styles.py +++ b/cmd2/styles.py @@ -56,6 +56,11 @@ class Cmd2Style(StrEnum): ERROR = "cmd2.error" # Error text (used by perror()) HELP_HEADER = "cmd2.help.header" # Help table header text HELP_LEADER = "cmd2.help.leader" # Text right before the help tables are listed + LEXER_COMMAND = "cmd2.lexer.command" # Lexer color for commands + LEXER_ALIAS = "cmd2.lexer.alias" # Lexer color for aliases + LEXER_MACRO = "cmd2.lexer.macro" # Lexer color for macros + LEXER_FLAG = "cmd2.lexer.flag" # Lexer color for flags + LEXER_ARGUMENT = "cmd2.lexer.argument" # Lexer color for arguments SUCCESS = "cmd2.success" # Success text (used by psuccess()) TABLE_BORDER = "cmd2.table_border" # Applied to cmd2's table borders WARNING = "cmd2.warning" # Warning text (used by pwarning()) @@ -70,6 +75,11 @@ class Cmd2Style(StrEnum): Cmd2Style.ERROR: Style(color=Color.BRIGHT_RED), Cmd2Style.HELP_HEADER: Style(color=Color.BRIGHT_GREEN), Cmd2Style.HELP_LEADER: Style(color=Color.CYAN), + Cmd2Style.LEXER_COMMAND: Style(color=Color.GREEN), + Cmd2Style.LEXER_ALIAS: Style(color=Color.CYAN), + Cmd2Style.LEXER_MACRO: Style(color=Color.MAGENTA), + Cmd2Style.LEXER_FLAG: Style(color=Color.RED), + Cmd2Style.LEXER_ARGUMENT: Style(color=Color.YELLOW), Cmd2Style.SUCCESS: Style(color=Color.GREEN), Cmd2Style.TABLE_BORDER: Style(color=Color.BRIGHT_GREEN), Cmd2Style.WARNING: Style(color=Color.BRIGHT_YELLOW), diff --git a/examples/rich_theme.py b/examples/rich_theme.py index 67914e33f..7fb367a2f 100755 --- a/examples/rich_theme.py +++ b/examples/rich_theme.py @@ -29,6 +29,13 @@ def __init__(self, *args, **kwargs): Cmd2Style.HELP_HEADER: Style(color=Color.CYAN, bgcolor="#44475a"), Cmd2Style.HELP_LEADER: Style(color="#f8f8f2", bgcolor="#282a36"), # use RGB hex colors Cmd2Style.TABLE_BORDER: Style(color="turquoise2"), # use a rich standard color + Cmd2Style.LEXER_COMMAND: Style(color=Color.LIGHT_GREEN), + Cmd2Style.LEXER_ALIAS: Style(color=Color.LIGHT_CYAN1), + Cmd2Style.LEXER_MACRO: Style(color=Color.LIGHT_CORAL), + Cmd2Style.LEXER_FLAG: Style(color=Color.LIGHT_PINK3), + Cmd2Style.LEXER_ARGUMENT: Style(color=Color.LIGHT_GOLDENROD1), + Cmd2Style.COMPLETION_MENU_ITEM: Style(color=Color.WHITE, bgcolor=Color.NAVY_BLUE), + Cmd2Style.COMPLETION_MENU_META: Style(color=Color.WHITE, bgcolor=Color.DARK_SLATE_GRAY2), "traceback.exc_type": Style(color=Color.RED, bgcolor=Color.LIGHT_YELLOW3, bold=True), "argparse.args": Style(color=Color.AQUAMARINE3, underline=True), } diff --git a/tests/test_pt_utils.py b/tests/test_pt_utils.py index 9b5bdfe3a..2cb52f827 100644 --- a/tests/test_pt_utils.py +++ b/tests/test_pt_utils.py @@ -116,7 +116,11 @@ def test_lex_document_command(self, mock_cmd_app): get_line = lexer.lex_document(document) tokens = get_line(0) - assert tokens == [("ansigreen", "help"), ("", " "), ("ansiyellow", "something")] + assert tokens == [ + ("noreverse fg:ansigreen bg:default", "help"), + ("", " "), + ("noreverse fg:ansiyellow bg:default", "something"), + ] def test_lex_document_alias(self, mock_cmd_app): """Test lexing an alias.""" @@ -128,7 +132,7 @@ def test_lex_document_alias(self, mock_cmd_app): get_line = lexer.lex_document(document) tokens = get_line(0) - assert tokens == [("ansicyan", "ls"), ("", " "), ("ansired", "-l")] + assert tokens == [("noreverse fg:ansicyan bg:default", "ls"), ("", " "), ("noreverse fg:ansired bg:default", "-l")] def test_lex_document_macro(self, mock_cmd_app): """Test lexing a macro.""" @@ -140,7 +144,11 @@ def test_lex_document_macro(self, mock_cmd_app): get_line = lexer.lex_document(document) tokens = get_line(0) - assert tokens == [("ansimagenta", "my_macro"), ("", " "), ("ansiyellow", "arg1")] + assert tokens == [ + ("noreverse fg:ansimagenta bg:default", "my_macro"), + ("", " "), + ("noreverse fg:ansiyellow bg:default", "arg1"), + ] def test_lex_document_leading_whitespace(self, mock_cmd_app): """Test lexing with leading whitespace.""" @@ -152,7 +160,12 @@ def test_lex_document_leading_whitespace(self, mock_cmd_app): get_line = lexer.lex_document(document) tokens = get_line(0) - assert tokens == [("", " "), ("ansigreen", "help"), ("", " "), ("ansiyellow", "something")] + assert tokens == [ + ("", " "), + ("noreverse fg:ansigreen bg:default", "help"), + ("", " "), + ("noreverse fg:ansiyellow bg:default", "something"), + ] def test_lex_document_unknown_command(self, mock_cmd_app): """Test lexing an unknown command.""" @@ -163,7 +176,7 @@ def test_lex_document_unknown_command(self, mock_cmd_app): get_line = lexer.lex_document(document) tokens = get_line(0) - assert tokens == [("", "unknown"), ("", " "), ("ansiyellow", "command")] + assert tokens == [("", "unknown"), ("", " "), ("noreverse fg:ansiyellow bg:default", "command")] def test_lex_document_no_command(self, mock_cmd_app): """Test lexing an empty line or line with only whitespace.""" @@ -200,17 +213,17 @@ def test_lex_document_arguments(self, mock_cmd_app): tokens = get_line(0) assert tokens == [ - ("ansigreen", "help"), + ("noreverse fg:ansigreen bg:default", "help"), ("", " "), - ("ansired", "-v"), + ("noreverse fg:ansired bg:default", "-v"), ("", " "), - ("ansired", "--name"), + ("noreverse fg:ansired bg:default", "--name"), ("", " "), - ("ansiyellow", '"John Doe"'), + ("noreverse fg:ansiyellow bg:default", '"John Doe"'), ("", " "), ("", ">"), ("", " "), - ("ansiyellow", "out.txt"), + ("noreverse fg:ansiyellow bg:default", "out.txt"), ] def test_lex_document_unclosed_quote(self, mock_cmd_app): @@ -223,7 +236,11 @@ def test_lex_document_unclosed_quote(self, mock_cmd_app): get_line = lexer.lex_document(document) tokens = get_line(0) - assert tokens == [("ansigreen", "echo"), ("", " "), ("ansiyellow", '"hello')] + assert tokens == [ + ("noreverse fg:ansigreen bg:default", "echo"), + ("", " "), + ("noreverse fg:ansiyellow bg:default", '"hello'), + ] def test_lex_document_shortcut(self, mock_cmd_app): """Test lexing a shortcut.""" @@ -235,13 +252,13 @@ def test_lex_document_shortcut(self, mock_cmd_app): document = Document(line) get_line = lexer.lex_document(document) tokens = get_line(0) - assert tokens == [("ansigreen", "!"), ("ansiyellow", "ls")] + assert tokens == [("noreverse fg:ansigreen bg:default", "!"), ("noreverse fg:ansiyellow bg:default", "ls")] line = "! ls" document = Document(line) get_line = lexer.lex_document(document) tokens = get_line(0) - assert tokens == [("ansigreen", "!"), ("", " "), ("ansiyellow", "ls")] + assert tokens == [("noreverse fg:ansigreen bg:default", "!"), ("", " "), ("noreverse fg:ansiyellow bg:default", "ls")] def test_lex_document_multiline(self, mock_cmd_app): """Test lexing a multiline command.""" @@ -255,11 +272,11 @@ def test_lex_document_multiline(self, mock_cmd_app): # First line should have command tokens0 = get_line(0) - assert tokens0 == [("ansigreen", "orate")] + assert tokens0 == [("noreverse fg:ansigreen bg:default", "orate")] # Second line should have argument (not command) tokens1 = get_line(1) - assert tokens1 == [("ansiyellow", "help")] + assert tokens1 == [("noreverse fg:ansiyellow bg:default", "help")] class TestCmd2Completer: From ae78cb421a9f2144ef8255004cb3abc40fbdb87d Mon Sep 17 00:00:00 2001 From: Todd Leonhardt Date: Tue, 5 May 2026 10:28:42 -0400 Subject: [PATCH 10/22] Lexer colors now change if a user changes the theme during runtime Since prommpt-toolkit calls the lex_document method of the Cmd2Lexer instance quite frequnetly, we don't want to dynamically fetech the colors to use on every call. Instead I extracted the code for retrieving the lexer colors from the rich theme into a set_colors helper method and the set_theme method will call this for any Cmd2Lexer instances. --- cmd2/pt_utils.py | 9 +++++++++ cmd2/rich_utils.py | 9 +++++++++ tests/test_pt_utils.py | 25 +++++++++++++++++++++++++ 3 files changed, 43 insertions(+) diff --git a/cmd2/pt_utils.py b/cmd2/pt_utils.py index cc954c08d..12e518bcb 100644 --- a/cmd2/pt_utils.py +++ b/cmd2/pt_utils.py @@ -1,6 +1,7 @@ """Utilities for integrating prompt_toolkit with cmd2.""" import re +import weakref from collections.abc import ( Callable, Iterable, @@ -254,6 +255,9 @@ def clear(self) -> None: self._loaded_strings.clear() +_lexers: "weakref.WeakSet[Cmd2Lexer]" = weakref.WeakSet() + + class Cmd2Lexer(Lexer): """Lexer that highlights cmd2 command names, aliases, and macros.""" @@ -268,6 +272,11 @@ def __init__( super().__init__() self.cmd_app = cmd_app + _lexers.add(self) + self.set_colors() + + def set_colors(self) -> None: + """Update colors from the current rich theme.""" # Retrieve styles dynamically from the current theme theme = ru.get_theme() self.command_color = rich_to_pt_style(theme.styles.get(Cmd2Style.LEXER_COMMAND, "")) diff --git a/cmd2/rich_utils.py b/cmd2/rich_utils.py index 106901162..e5894a40d 100644 --- a/cmd2/rich_utils.py +++ b/cmd2/rich_utils.py @@ -351,6 +351,15 @@ def set_theme(styles: Mapping[str, StyleType] | None = None) -> None: for name in Cmd2HelpFormatter.styles.keys() & theme.styles.keys(): Cmd2HelpFormatter.styles[name] = theme.styles[name] + # Update colors in active prompt-toolkit lexers + try: + from . import pt_utils + + for lexer in pt_utils._lexers: + lexer.set_colors() + except ImportError: + pass + def _create_default_theme() -> Theme: """Create a default theme for the application. diff --git a/tests/test_pt_utils.py b/tests/test_pt_utils.py index 2cb52f827..9d583cb90 100644 --- a/tests/test_pt_utils.py +++ b/tests/test_pt_utils.py @@ -278,6 +278,31 @@ def test_lex_document_multiline(self, mock_cmd_app): tokens1 = get_line(1) assert tokens1 == [("noreverse fg:ansiyellow bg:default", "help")] + def test_lexer_set_theme_runtime_update(self, mock_cmd_app): + """Test that changing the theme updates active lexers.""" + lexer = pt_utils.Cmd2Lexer(cast(Any, mock_cmd_app)) + + # Get the old color for command + old_color = lexer.command_color + + # Change the theme dynamically + from rich.style import Style + + from cmd2.styles import Cmd2Style + + new_styles = {Cmd2Style.LEXER_COMMAND: Style(color="red", bgcolor="black")} + + try: + ru.set_theme(new_styles) + + # Now verify the lexer's color was updated + assert lexer.command_color != old_color + assert "ansired" in lexer.command_color + assert "ansiblack" in lexer.command_color + + finally: + ru.set_theme() # Reset to default + class TestCmd2Completer: def test_get_completions(self, mock_cmd_app: MockCmd, monkeypatch) -> None: From 252ad089d7d10dd55ca6f5fec4ee060b1b73aae7 Mon Sep 17 00:00:00 2001 From: Todd Leonhardt Date: Tue, 5 May 2026 10:50:52 -0400 Subject: [PATCH 11/22] rich_to_pt_style now also supports blink, reverse, and conceal/hidden styles rich_to_pt_style should now support all styles in common between rich and prompt-toolkit --- cmd2/pt_utils.py | 8 ++++++++ tests/test_pt_utils.py | 46 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/cmd2/pt_utils.py b/cmd2/pt_utils.py index 12e518bcb..28d90aa6a 100644 --- a/cmd2/pt_utils.py +++ b/cmd2/pt_utils.py @@ -112,6 +112,14 @@ def rich_to_pt_style(rich_style: StyleType) -> str: parts.append("italic" if rich_style.italic else "noitalic") if rich_style.underline is not None: parts.append("underline" if rich_style.underline else "nounderline") + if rich_style.blink is not None: + parts.append("blink" if rich_style.blink else "noblink") + if rich_style.reverse is not None: + # prompt-toolkit uses 'reverse' + parts.append("reverse" if rich_style.reverse else "noreverse") + if rich_style.conceal is not None: + # prompt-toolkit uses 'hidden' for Rich's 'conceal' + parts.append("hidden" if rich_style.conceal else "nohidden") return " ".join(parts) diff --git a/tests/test_pt_utils.py b/tests/test_pt_utils.py index 9d583cb90..98e7b4c8d 100644 --- a/tests/test_pt_utils.py +++ b/tests/test_pt_utils.py @@ -758,3 +758,49 @@ def test_rich_to_pt_style_nounderline(self): style = Style(underline=False) pt_style = pt_utils.rich_to_pt_style(style) assert "nounderline" in pt_style + + def test_rich_to_pt_style_blink(self): + from rich.style import Style + + style = Style(blink=True) + pt_style = pt_utils.rich_to_pt_style(style) + assert "blink" in pt_style + + def test_rich_to_pt_style_noblink(self): + from rich.style import Style + + style = Style(blink=False) + pt_style = pt_utils.rich_to_pt_style(style) + assert "noblink" in pt_style + + def test_rich_to_pt_style_reverse(self): + from rich.style import Style + + style = Style(reverse=True) + pt_style = pt_utils.rich_to_pt_style(style) + # Note: reverse replaces the default 'noreverse' that is added at the start of parts + # wait, we'll check how it works exactly. It will append "reverse". So we just assert "reverse" in pt_style + # actually, if reverse=True, "reverse" will be appended to the list, while "noreverse" is also at index 0. + # Let's just check the last appended one. + assert "reverse" in pt_style.split() + + def test_rich_to_pt_style_noreverse(self): + from rich.style import Style + + style = Style(reverse=False) + pt_style = pt_utils.rich_to_pt_style(style) + assert "noreverse" in pt_style + + def test_rich_to_pt_style_hidden_conceal(self): + from rich.style import Style + + style = Style(conceal=True) + pt_style = pt_utils.rich_to_pt_style(style) + assert "hidden" in pt_style + + def test_rich_to_pt_style_nohidden_conceal(self): + from rich.style import Style + + style = Style(conceal=False) + pt_style = pt_utils.rich_to_pt_style(style) + assert "nohidden" in pt_style From 812ccc5f04f265d696526bd2f601c5577b2e5ad9 Mon Sep 17 00:00:00 2001 From: Todd Leonhardt Date: Tue, 5 May 2026 11:28:39 -0400 Subject: [PATCH 12/22] Removed pointless try/except --- cmd2/rich_utils.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/cmd2/rich_utils.py b/cmd2/rich_utils.py index e5894a40d..cfa27b72b 100644 --- a/cmd2/rich_utils.py +++ b/cmd2/rich_utils.py @@ -352,13 +352,10 @@ def set_theme(styles: Mapping[str, StyleType] | None = None) -> None: Cmd2HelpFormatter.styles[name] = theme.styles[name] # Update colors in active prompt-toolkit lexers - try: - from . import pt_utils + from . import pt_utils - for lexer in pt_utils._lexers: - lexer.set_colors() - except ImportError: - pass + for lexer in pt_utils._lexers: + lexer.set_colors() def _create_default_theme() -> Theme: From e6b33f3331d119c6ac388f6e35b78caf7b9097d3 Mon Sep 17 00:00:00 2001 From: Todd Leonhardt Date: Tue, 5 May 2026 11:32:51 -0400 Subject: [PATCH 13/22] Apparently I forgot to hit save after changing this previously --- cmd2/pt_utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd2/pt_utils.py b/cmd2/pt_utils.py index 28d90aa6a..a373f40af 100644 --- a/cmd2/pt_utils.py +++ b/cmd2/pt_utils.py @@ -98,7 +98,7 @@ def rich_to_pt_style(rich_style: StyleType) -> str: if isinstance(rich_style, str): rich_style = Style.parse(rich_style) - parts = ["noreverse"] + parts = [] fg_color = rich_to_pt_color(rich_style.color) parts.append(f"fg:{fg_color}") From 0a0b5410ae63f6229107c8cfc86256974f7f1623 Mon Sep 17 00:00:00 2001 From: Todd Leonhardt Date: Tue, 5 May 2026 11:57:12 -0400 Subject: [PATCH 14/22] Fix tests after removing noreverse for color conversion initializaiton --- tests/test_pt_utils.py | 39 +++++++++++++++++++-------------------- 1 file changed, 19 insertions(+), 20 deletions(-) diff --git a/tests/test_pt_utils.py b/tests/test_pt_utils.py index 98e7b4c8d..d9813bdd3 100644 --- a/tests/test_pt_utils.py +++ b/tests/test_pt_utils.py @@ -117,9 +117,9 @@ def test_lex_document_command(self, mock_cmd_app): tokens = get_line(0) assert tokens == [ - ("noreverse fg:ansigreen bg:default", "help"), + ("fg:ansigreen bg:default", "help"), ("", " "), - ("noreverse fg:ansiyellow bg:default", "something"), + ("fg:ansiyellow bg:default", "something"), ] def test_lex_document_alias(self, mock_cmd_app): @@ -132,7 +132,7 @@ def test_lex_document_alias(self, mock_cmd_app): get_line = lexer.lex_document(document) tokens = get_line(0) - assert tokens == [("noreverse fg:ansicyan bg:default", "ls"), ("", " "), ("noreverse fg:ansired bg:default", "-l")] + assert tokens == [("fg:ansicyan bg:default", "ls"), ("", " "), ("fg:ansired bg:default", "-l")] def test_lex_document_macro(self, mock_cmd_app): """Test lexing a macro.""" @@ -145,9 +145,9 @@ def test_lex_document_macro(self, mock_cmd_app): tokens = get_line(0) assert tokens == [ - ("noreverse fg:ansimagenta bg:default", "my_macro"), + ("fg:ansimagenta bg:default", "my_macro"), ("", " "), - ("noreverse fg:ansiyellow bg:default", "arg1"), + ("fg:ansiyellow bg:default", "arg1"), ] def test_lex_document_leading_whitespace(self, mock_cmd_app): @@ -162,9 +162,9 @@ def test_lex_document_leading_whitespace(self, mock_cmd_app): assert tokens == [ ("", " "), - ("noreverse fg:ansigreen bg:default", "help"), + ("fg:ansigreen bg:default", "help"), ("", " "), - ("noreverse fg:ansiyellow bg:default", "something"), + ("fg:ansiyellow bg:default", "something"), ] def test_lex_document_unknown_command(self, mock_cmd_app): @@ -176,7 +176,7 @@ def test_lex_document_unknown_command(self, mock_cmd_app): get_line = lexer.lex_document(document) tokens = get_line(0) - assert tokens == [("", "unknown"), ("", " "), ("noreverse fg:ansiyellow bg:default", "command")] + assert tokens == [("", "unknown"), ("", " "), ("fg:ansiyellow bg:default", "command")] def test_lex_document_no_command(self, mock_cmd_app): """Test lexing an empty line or line with only whitespace.""" @@ -213,17 +213,17 @@ def test_lex_document_arguments(self, mock_cmd_app): tokens = get_line(0) assert tokens == [ - ("noreverse fg:ansigreen bg:default", "help"), + ("fg:ansigreen bg:default", "help"), ("", " "), - ("noreverse fg:ansired bg:default", "-v"), + ("fg:ansired bg:default", "-v"), ("", " "), - ("noreverse fg:ansired bg:default", "--name"), + ("fg:ansired bg:default", "--name"), ("", " "), - ("noreverse fg:ansiyellow bg:default", '"John Doe"'), + ("fg:ansiyellow bg:default", '"John Doe"'), ("", " "), ("", ">"), ("", " "), - ("noreverse fg:ansiyellow bg:default", "out.txt"), + ("fg:ansiyellow bg:default", "out.txt"), ] def test_lex_document_unclosed_quote(self, mock_cmd_app): @@ -237,9 +237,9 @@ def test_lex_document_unclosed_quote(self, mock_cmd_app): tokens = get_line(0) assert tokens == [ - ("noreverse fg:ansigreen bg:default", "echo"), + ("fg:ansigreen bg:default", "echo"), ("", " "), - ("noreverse fg:ansiyellow bg:default", '"hello'), + ("fg:ansiyellow bg:default", '"hello'), ] def test_lex_document_shortcut(self, mock_cmd_app): @@ -252,13 +252,13 @@ def test_lex_document_shortcut(self, mock_cmd_app): document = Document(line) get_line = lexer.lex_document(document) tokens = get_line(0) - assert tokens == [("noreverse fg:ansigreen bg:default", "!"), ("noreverse fg:ansiyellow bg:default", "ls")] + assert tokens == [("fg:ansigreen bg:default", "!"), ("fg:ansiyellow bg:default", "ls")] line = "! ls" document = Document(line) get_line = lexer.lex_document(document) tokens = get_line(0) - assert tokens == [("noreverse fg:ansigreen bg:default", "!"), ("", " "), ("noreverse fg:ansiyellow bg:default", "ls")] + assert tokens == [("fg:ansigreen bg:default", "!"), ("", " "), ("fg:ansiyellow bg:default", "ls")] def test_lex_document_multiline(self, mock_cmd_app): """Test lexing a multiline command.""" @@ -272,11 +272,11 @@ def test_lex_document_multiline(self, mock_cmd_app): # First line should have command tokens0 = get_line(0) - assert tokens0 == [("noreverse fg:ansigreen bg:default", "orate")] + assert tokens0 == [("fg:ansigreen bg:default", "orate")] # Second line should have argument (not command) tokens1 = get_line(1) - assert tokens1 == [("noreverse fg:ansiyellow bg:default", "help")] + assert tokens1 == [("fg:ansiyellow bg:default", "help")] def test_lexer_set_theme_runtime_update(self, mock_cmd_app): """Test that changing the theme updates active lexers.""" @@ -698,7 +698,6 @@ def test_rich_to_pt_style_color(self): pt_style = pt_utils.rich_to_pt_style(style) assert "fg:#123456" in pt_style assert "bg:default" in pt_style - assert "noreverse" in pt_style def test_rich_to_pt_style_bgcolor(self): from rich.style import Style From 60ca50dc2c8735f4151c5696ed33c17579581461 Mon Sep 17 00:00:00 2001 From: Todd Leonhardt Date: Tue, 5 May 2026 16:37:06 -0400 Subject: [PATCH 15/22] Enable true color support for both main and temporary prompt-toolkit sessions --- cmd2/cmd2.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 873d831af..6870dde44 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -78,6 +78,7 @@ from prompt_toolkit.input import DummyInput, create_input from prompt_toolkit.key_binding import KeyBindings from prompt_toolkit.output import DummyOutput, create_output +from prompt_toolkit.output.color_depth import ColorDepth from prompt_toolkit.patch_stdout import patch_stdout from prompt_toolkit.shortcuts import CompleteStyle, PromptSession, choice, set_title from prompt_toolkit.styles import DynamicStyle @@ -778,6 +779,7 @@ def _(event: Any) -> None: # pragma: no cover kwargs: dict[str, Any] = { "auto_suggest": AutoSuggestFromHistory() if auto_suggest else None, "bottom_toolbar": self.get_bottom_toolbar if self.bottom_toolbar else None, + "color_depth": ColorDepth.TRUE_COLOR, "complete_style": CompleteStyle.MULTI_COLUMN, "complete_in_thread": True, "complete_while_typing": False, @@ -3593,6 +3595,7 @@ def read_input( temp_session: PromptSession[str] = PromptSession( auto_suggest=self.main_session.auto_suggest, + color_depth=self.main_session.color_depth, complete_style=self.main_session.complete_style, complete_in_thread=self.main_session.complete_in_thread, complete_while_typing=self.main_session.complete_while_typing, From 91331c1cd1ecb4ef2a088459bba1c6f5d47378c7 Mon Sep 17 00:00:00 2001 From: Todd Leonhardt Date: Tue, 5 May 2026 18:02:04 -0400 Subject: [PATCH 16/22] Added additional prompt-toolkit completion styles to cmd2's rich theme Also: - Updated outdated docstring that was insufficiently broad - Updated rich_theme.py example to use new completion styles - Updated CHANGELOG and documentation --- CHANGELOG.md | 12 ++++++++++-- cmd2/cmd2.py | 25 +++++++++++++++++-------- cmd2/styles.py | 16 ++++++++++++---- docs/features/theme.md | 15 ++++++++++----- docs/upgrades.md | 2 +- examples/rich_theme.py | 7 +++++-- tests/test_cmd2.py | 2 +- 7 files changed, 56 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eb453c8d4..cb1f43f4e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -151,8 +151,16 @@ prompt is displayed. - Added `Cmd2ArgumentParser.output_to()` context manager to temporarily set the output stream during `argparse` operations. This is helpful for directing output for functions like `parse_args()`, which default to `sys.stdout` and lack a `file` argument. - - Added ability to customize `prompt-toolkit` completion menu colors by overriding - `Cmd2Style.COMPLETION_MENU_ITEM` and `Cmd2Style.COMPLETION_MENU_META` in the `cmd2` theme. + - Added ability to customize `prompt-toolkit` completion menu colors by overriding the following + fields in the `cmd2` theme: + - `Cmd2Style.COMPLETION_MENU` - Base style for the entire completion menu container (sets + the background) + - `Cmd2Style.COMPLETION_MENU_COMPLETION` -Style for an individual, non-selected completion + item + - `Cmd2Style.COMPLETION_MENU_CURRENT` - Style for the currently selected completion item + - `Cmd2Style.COMPLETION_MENU_META` - Style for "meta" information shown alongside a + completion + - `Cmd2Style.COMPLETION_MENU_META_CURRENT`- Style for meta info of current item ## 3.5.1 (April 24, 2026) diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 6870dde44..f68c8e161 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -529,7 +529,7 @@ def __init__( # Cache for prompt_toolkit completion menu styles self._cached_pt_style: PtStyle | None = None - self._cached_pt_style_params: tuple[StyleType, StyleType] | None = None + self._cached_pt_style_params: tuple[StyleType, StyleType, StyleType, StyleType, StyleType] | None = None # Create the main PromptSession self.bottom_toolbar = bottom_toolbar @@ -725,24 +725,33 @@ def _should_continue_multiline(self) -> bool: return False def _get_pt_style(self) -> "PtStyle": - """Return the prompt_toolkit style for the completion menu.""" + """Return the cached prompt_toolkit style.""" theme = ru.get_theme() - rich_item_style = theme.styles.get(Cmd2Style.COMPLETION_MENU_ITEM, "") + rich_menu_style = theme.styles.get(Cmd2Style.COMPLETION_MENU, "") + rich_completion_style = theme.styles.get(Cmd2Style.COMPLETION_MENU_COMPLETION, "") + rich_current_style = theme.styles.get(Cmd2Style.COMPLETION_MENU_CURRENT, "") rich_meta_style = theme.styles.get(Cmd2Style.COMPLETION_MENU_META, "") + rich_meta_current_style = theme.styles.get(Cmd2Style.COMPLETION_MENU_META_CURRENT, "") - current_params = (rich_item_style, rich_meta_style) + current_params = (rich_menu_style, rich_completion_style, rich_current_style, rich_meta_style, rich_meta_current_style) if self._cached_pt_style is not None and self._cached_pt_style_params == current_params: return self._cached_pt_style - item_style = rich_to_pt_style(rich_item_style) + menu_style = rich_to_pt_style(rich_menu_style) + completion_style = rich_to_pt_style(rich_completion_style) + current_style = rich_to_pt_style(rich_current_style) meta_style = rich_to_pt_style(rich_meta_style) + meta_current_style = rich_to_pt_style(rich_meta_current_style) self._cached_pt_style_params = current_params self._cached_pt_style = PtStyle.from_dict( { - "completion-menu.completion.current": item_style, - "completion-menu.meta.completion.current": meta_style, - "completion-menu.multi-column-meta": meta_style, + "completion-menu": menu_style, + "completion-menu.completion": completion_style, + "completion-menu.completion.current": current_style, + "completion-menu.meta.completion": meta_style, + "completion-menu.meta.completion.current": meta_current_style, + "completion-menu.multi-column-meta": meta_current_style, } ) diff --git a/cmd2/styles.py b/cmd2/styles.py index 995979453..fd6e736d1 100644 --- a/cmd2/styles.py +++ b/cmd2/styles.py @@ -22,6 +22,8 @@ For rich-argparse, the style names are defined in the `rich_argparse.RichHelpFormatter.styles` dictionary. +For prompt-toolkit default styles, see: +https://github.com/prompt-toolkit/python-prompt-toolkit/blob/main/src/prompt_toolkit/styles/defaults.py """ import sys @@ -51,8 +53,11 @@ class Cmd2Style(StrEnum): """ COMMAND_LINE = "cmd2.example" # Command line examples in help text - COMPLETION_MENU_ITEM = "cmd2.completion_menu.item" # Selected completion item - COMPLETION_MENU_META = "cmd2.completion_menu.meta" # Selected completion help/meta text + COMPLETION_MENU = "cmd2.completion_menu" # Base style for the entire completion menu container (sets the background) + COMPLETION_MENU_COMPLETION = "cmd2.completion-menu.completion" # Style for an individual, non-selected completion item + COMPLETION_MENU_CURRENT = "cmd2.completion-menu.completion.current" # Style for the currently selected completion item + COMPLETION_MENU_META = "cmd2.completion-menu.meta.completion" # Style for "meta" information shown alongside a completion + COMPLETION_MENU_META_CURRENT = "cmd2.completion-menu.meta.completion.current" # Style for meta info of current item ERROR = "cmd2.error" # Error text (used by perror()) HELP_HEADER = "cmd2.help.header" # Help table header text HELP_LEADER = "cmd2.help.leader" # Text right before the help tables are listed @@ -70,8 +75,11 @@ class Cmd2Style(StrEnum): # Tightly coupled with the Cmd2Style enum. DEFAULT_CMD2_STYLES: dict[str, StyleType] = { Cmd2Style.COMMAND_LINE: Style(color=Color.CYAN, bold=True), - Cmd2Style.COMPLETION_MENU_ITEM: Style(color=Color.BLACK, bgcolor=Color.GREEN), - Cmd2Style.COMPLETION_MENU_META: Style(color=Color.BLACK, bgcolor=Color.BRIGHT_GREEN), + Cmd2Style.COMPLETION_MENU: Style(color="#000000", bgcolor="#bbbbbb"), # prompt-toolkit default + Cmd2Style.COMPLETION_MENU_COMPLETION: Style(), # prompt-toolkit default + Cmd2Style.COMPLETION_MENU_CURRENT: Style(color=Color.GREEN, bgcolor=Color.BLACK), # This style swaps FG and BG colors + Cmd2Style.COMPLETION_MENU_META: Style(color="#000000", bgcolor="#bbbbbb"), # prompt-toolkit default + Cmd2Style.COMPLETION_MENU_META_CURRENT: Style(color=Color.BLACK, bgcolor=Color.BRIGHT_GREEN), Cmd2Style.ERROR: Style(color=Color.BRIGHT_RED), Cmd2Style.HELP_HEADER: Style(color=Color.BRIGHT_GREEN), Cmd2Style.HELP_LEADER: Style(color=Color.CYAN), diff --git a/docs/features/theme.md b/docs/features/theme.md index cd348f061..87b9db819 100644 --- a/docs/features/theme.md +++ b/docs/features/theme.md @@ -11,12 +11,17 @@ that is appealing to your user base. `cmd2` leverages `prompt-toolkit` for its tab completion menu. You can customize the colors of the completion menu by overriding the following styles in your `cmd2` theme: -- `Cmd2Style.COMPLETION_MENU_ITEM`: The background and foreground color of the selected completion - item. -- `Cmd2Style.COMPLETION_MENU_META`: The background and foreground color of the selected completion - item's help/meta text. +- `Cmd2Style.COMPLETION_MENU` - Base style for the entire completion menu container (sets the + background) +- `Cmd2Style.COMPLETION_MENU_COMPLETION` -Style for an individual, non-selected completion item +- `Cmd2Style.COMPLETION_MENU_CURRENT` - Style for the currently selected completion item +- `Cmd2Style.COMPLETION_MENU_META` - Style for "meta" information shown alongside a completion +- `Cmd2Style.COMPLETION_MENU_META_CURRENT`- Style for meta info of current item -By default, these are styled with black text on a green background to provide contrast. +By default, the currently selected completion item and metadata are styled with black text on a +green background to provide contrast. All others are left at `prompt-toolkit` defaults by default. +However, `cmd2` application authors are free to customimze these as they see fit in order to match a +desired visual style and/or branding. ## Example diff --git a/docs/upgrades.md b/docs/upgrades.md index 2d10a7a44..2aa90181e 100644 --- a/docs/upgrades.md +++ b/docs/upgrades.md @@ -51,7 +51,7 @@ periodically. `cmd2` now leverages `prompt-toolkit` for its tab completion menu and provides the ability to customize its appearance using the `cmd2` theme. -- **Customization**: Override the `Cmd2Style.COMPLETION_MENU_ITEM` and +- **Customization**: Override the `Cmd2Style.COMPLETION_MENU_CURRENT` and `Cmd2Style.COMPLETION_MENU_META` styles using `cmd2.rich_utils.set_theme()`. See [Customizing Completion Menu Colors](features/theme.md#customizing-completion-menu-colors) for more details. diff --git a/examples/rich_theme.py b/examples/rich_theme.py index 7fb367a2f..d437506b4 100755 --- a/examples/rich_theme.py +++ b/examples/rich_theme.py @@ -34,8 +34,11 @@ def __init__(self, *args, **kwargs): Cmd2Style.LEXER_MACRO: Style(color=Color.LIGHT_CORAL), Cmd2Style.LEXER_FLAG: Style(color=Color.LIGHT_PINK3), Cmd2Style.LEXER_ARGUMENT: Style(color=Color.LIGHT_GOLDENROD1), - Cmd2Style.COMPLETION_MENU_ITEM: Style(color=Color.WHITE, bgcolor=Color.NAVY_BLUE), - Cmd2Style.COMPLETION_MENU_META: Style(color=Color.WHITE, bgcolor=Color.DARK_SLATE_GRAY2), + Cmd2Style.COMPLETION_MENU: Style(color="#000000", bgcolor=Color.SKY_BLUE1), + Cmd2Style.COMPLETION_MENU_COMPLETION: Style(color=Color.MAGENTA), + Cmd2Style.COMPLETION_MENU_CURRENT: Style(color=Color.WHITE, bgcolor=Color.NAVY_BLUE), + Cmd2Style.COMPLETION_MENU_META: Style(color="#000000", bgcolor=Color.CYAN), + Cmd2Style.COMPLETION_MENU_META_CURRENT: Style(color=Color.WHITE, bgcolor=Color.DARK_SLATE_GRAY2), "traceback.exc_type": Style(color=Color.RED, bgcolor=Color.LIGHT_YELLOW3, bold=True), "argparse.args": Style(color=Color.AQUAMARINE3, underline=True), } diff --git a/tests/test_cmd2.py b/tests/test_cmd2.py index 1423b2bf2..0005c77f8 100644 --- a/tests/test_cmd2.py +++ b/tests/test_cmd2.py @@ -1889,7 +1889,7 @@ def test_get_pt_style_caching(base_app) -> None: orig_theme = ru.get_theme() try: - ru.set_theme({Cmd2Style.COMPLETION_MENU_ITEM: Style(color="red")}) + ru.set_theme({Cmd2Style.COMPLETION_MENU_CURRENT: Style(color="red")}) # Getting the style now should return a new object style3 = base_app._get_pt_style() From 55785f51afa750b2cfdacbe36e6030e4b0ab1770 Mon Sep 17 00:00:00 2001 From: Todd Leonhardt Date: Tue, 5 May 2026 18:06:29 -0400 Subject: [PATCH 17/22] Temporary prompt-toolkit sessions now get style from main session --- cmd2/cmd2.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index f68c8e161..c8d752115 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -3613,7 +3613,7 @@ def read_input( key_bindings=self.main_session.key_bindings, input=self.main_session.input, output=self.main_session.output, - style=DynamicStyle(self._get_pt_style), + style=self.main_session.style, ) return self._read_raw_input(prompt, temp_session) @@ -3632,7 +3632,7 @@ def read_secret( temp_session: PromptSession[str] = PromptSession( input=self.main_session.input, output=self.main_session.output, - style=DynamicStyle(self._get_pt_style), + style=self.main_session.style, ) return self._read_raw_input(prompt, temp_session, is_password=True) From 128f050e60839506d6093006d9f26f2f6ef08dbf Mon Sep 17 00:00:00 2001 From: Todd Leonhardt Date: Tue, 5 May 2026 19:16:52 -0400 Subject: [PATCH 18/22] Add register_theme_update_callback function to rich_utils.py Also: - Moved code for updating lexers when theme is updated to pt_utils.py and this now registers with the above callback - Update unit tests to cover new/modified code --- cmd2/pt_utils.py | 9 +++++++++ cmd2/rich_utils.py | 18 +++++++++++++----- tests/test_pt_utils.py | 9 +++++++++ tests/test_rich_utils.py | 23 +++++++++++++++++++++++ 4 files changed, 54 insertions(+), 5 deletions(-) diff --git a/cmd2/pt_utils.py b/cmd2/pt_utils.py index a373f40af..bd287d7f2 100644 --- a/cmd2/pt_utils.py +++ b/cmd2/pt_utils.py @@ -266,6 +266,15 @@ def clear(self) -> None: _lexers: "weakref.WeakSet[Cmd2Lexer]" = weakref.WeakSet() +def _update_lexer_colors() -> None: + """Update colors for all active lexers.""" + for lexer in _lexers: + lexer.set_colors() + + +ru.register_theme_update_callback(_update_lexer_colors) + + class Cmd2Lexer(Lexer): """Lexer that highlights cmd2 command names, aliases, and macros.""" diff --git a/cmd2/rich_utils.py b/cmd2/rich_utils.py index cfa27b72b..19a7a3297 100644 --- a/cmd2/rich_utils.py +++ b/cmd2/rich_utils.py @@ -4,6 +4,7 @@ import re import sys from collections.abc import ( + Callable, Iterator, Mapping, ) @@ -309,6 +310,15 @@ def __cmd2_argparse_help__(self, formatter: Cmd2HelpFormatter) -> Group: # The application-wide theme. Use get_theme() and set_theme() to access it. _APP_THEME: Theme | None = None +# Callbacks to be executed when the theme is updated +_theme_update_callbacks: list[Callable[[], None]] = [] + + +def register_theme_update_callback(callback: Callable[[], None]) -> None: + """Register a callback to be executed when the theme is updated.""" + if callback not in _theme_update_callbacks: + _theme_update_callbacks.append(callback) + def get_theme() -> Theme: """Get the application-wide theme. Initializes it on the first call.""" @@ -351,11 +361,9 @@ def set_theme(styles: Mapping[str, StyleType] | None = None) -> None: for name in Cmd2HelpFormatter.styles.keys() & theme.styles.keys(): Cmd2HelpFormatter.styles[name] = theme.styles[name] - # Update colors in active prompt-toolkit lexers - from . import pt_utils - - for lexer in pt_utils._lexers: - lexer.set_colors() + # Notify callbacks that the theme has been updated + for callback in _theme_update_callbacks: + callback() def _create_default_theme() -> Theme: diff --git a/tests/test_pt_utils.py b/tests/test_pt_utils.py index d9813bdd3..13d89c47e 100644 --- a/tests/test_pt_utils.py +++ b/tests/test_pt_utils.py @@ -803,3 +803,12 @@ def test_rich_to_pt_style_nohidden_conceal(self): style = Style(conceal=False) pt_style = pt_utils.rich_to_pt_style(style) assert "nohidden" in pt_style + + +def test_update_lexer_colors() -> None: + mock_lexer = Mock() + pt_utils._lexers.add(mock_lexer) + + pt_utils._update_lexer_colors() + + mock_lexer.set_colors.assert_called_once() diff --git a/tests/test_rich_utils.py b/tests/test_rich_utils.py index 20229630f..9a8cc1173 100644 --- a/tests/test_rich_utils.py +++ b/tests/test_rich_utils.py @@ -349,3 +349,26 @@ def side_effect(color: bool, **kwargs: Any) -> None: assert mock_set_color.call_count == 2 mock_set_color.assert_any_call(True, file=sys.stdout) mock_set_color.assert_any_call(True) + + +def test_register_theme_update_callback() -> None: + # Clear callbacks for a clean state + ru._theme_update_callbacks.clear() + + # Define a dummy callback + def my_callback() -> None: + pass + + ru.register_theme_update_callback(my_callback) + assert my_callback in ru._theme_update_callbacks + + # Test that registering the same callback again doesn't duplicate it + ru.register_theme_update_callback(my_callback) + assert len(ru._theme_update_callbacks) == 1 + + # Test that set_theme calls the callback + mock_callback = mock.Mock() + ru.register_theme_update_callback(mock_callback) + + ru.set_theme() + mock_callback.assert_called_once() From 633511d5786021c4af4c8ed864f820ea61130c47 Mon Sep 17 00:00:00 2001 From: Todd Leonhardt Date: Tue, 5 May 2026 19:26:13 -0400 Subject: [PATCH 19/22] Updated changelog with info on register_theme_update_callback --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index cb1f43f4e..6837cbd3e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -151,6 +151,8 @@ prompt is displayed. - Added `Cmd2ArgumentParser.output_to()` context manager to temporarily set the output stream during `argparse` operations. This is helpful for directing output for functions like `parse_args()`, which default to `sys.stdout` and lack a `file` argument. + - Added `cmd2.rich_utils.register_theme_update_callback` function to register callback functions + to get called whenever `cmd2.rich_utils.set_theme` is called - Added ability to customize `prompt-toolkit` completion menu colors by overriding the following fields in the `cmd2` theme: - `Cmd2Style.COMPLETION_MENU` - Base style for the entire completion menu container (sets From bbf87870299c06ea10b667203518fff0a49dc20a Mon Sep 17 00:00:00 2001 From: Todd Leonhardt Date: Tue, 5 May 2026 20:59:27 -0400 Subject: [PATCH 20/22] cmd2.Cmd now updates pt_style based on callback method registation --- cmd2/cmd2.py | 20 +++++++++----------- tests/test_cmd2.py | 3 ++- 2 files changed, 11 insertions(+), 12 deletions(-) diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index c8d752115..20d6c44a2 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -528,8 +528,9 @@ def __init__( self._initialize_history(persistent_history_file) # Cache for prompt_toolkit completion menu styles - self._cached_pt_style: PtStyle | None = None - self._cached_pt_style_params: tuple[StyleType, StyleType, StyleType, StyleType, StyleType] | None = None + self.pt_style: PtStyle + self.update_pt_style() + ru.register_theme_update_callback(self.update_pt_style) # Create the main PromptSession self.bottom_toolbar = bottom_toolbar @@ -724,8 +725,8 @@ def _should_continue_multiline(self) -> bool: # No macro found or already processed. The statement is complete. return False - def _get_pt_style(self) -> "PtStyle": - """Return the cached prompt_toolkit style.""" + def update_pt_style(self) -> None: + """Update the cached prompt_toolkit style.""" theme = ru.get_theme() rich_menu_style = theme.styles.get(Cmd2Style.COMPLETION_MENU, "") rich_completion_style = theme.styles.get(Cmd2Style.COMPLETION_MENU_COMPLETION, "") @@ -733,18 +734,13 @@ def _get_pt_style(self) -> "PtStyle": rich_meta_style = theme.styles.get(Cmd2Style.COMPLETION_MENU_META, "") rich_meta_current_style = theme.styles.get(Cmd2Style.COMPLETION_MENU_META_CURRENT, "") - current_params = (rich_menu_style, rich_completion_style, rich_current_style, rich_meta_style, rich_meta_current_style) - if self._cached_pt_style is not None and self._cached_pt_style_params == current_params: - return self._cached_pt_style - menu_style = rich_to_pt_style(rich_menu_style) completion_style = rich_to_pt_style(rich_completion_style) current_style = rich_to_pt_style(rich_current_style) meta_style = rich_to_pt_style(rich_meta_style) meta_current_style = rich_to_pt_style(rich_meta_current_style) - self._cached_pt_style_params = current_params - self._cached_pt_style = PtStyle.from_dict( + self.pt_style = PtStyle.from_dict( { "completion-menu": menu_style, "completion-menu.completion": completion_style, @@ -755,7 +751,9 @@ def _get_pt_style(self) -> "PtStyle": } ) - return self._cached_pt_style + def _get_pt_style(self) -> "PtStyle": + """Return the cached prompt_toolkit style.""" + return self.pt_style def _create_main_session(self, auto_suggest: bool, completekey: str) -> PromptSession[str]: """Create and return the main PromptSession for the application. diff --git a/tests/test_cmd2.py b/tests/test_cmd2.py index 0005c77f8..8a47a079f 100644 --- a/tests/test_cmd2.py +++ b/tests/test_cmd2.py @@ -1871,7 +1871,7 @@ def do_orate(self, opts, arg) -> None: self.stdout.write(arg + "\n") -def test_get_pt_style_caching(base_app) -> None: +def test_update_pt_style_caching(base_app) -> None: # Get the initial style (populates the cache) style1 = base_app._get_pt_style() @@ -1917,6 +1917,7 @@ def test_get_pt_style_caching(base_app) -> None: finally: ru._APP_THEME = orig_theme + ru.set_theme() @pytest.fixture From 8563dc819900800f0bfc76d177b148efc278cc3e Mon Sep 17 00:00:00 2001 From: Todd Leonhardt Date: Tue, 5 May 2026 22:33:05 -0400 Subject: [PATCH 21/22] Also set color_depth in the secret prompt --- cmd2/cmd2.py | 1 + 1 file changed, 1 insertion(+) diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 20d6c44a2..8c60d04d5 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -3628,6 +3628,7 @@ def read_secret( :raises Exception: any other exceptions raised by prompt() """ temp_session: PromptSession[str] = PromptSession( + color_depth=self.main_session.color_depth, input=self.main_session.input, output=self.main_session.output, style=self.main_session.style, From da3ca4f71fb153b21581aa77f455c91678e4b1cb Mon Sep 17 00:00:00 2001 From: Todd Leonhardt Date: Tue, 5 May 2026 22:43:17 -0400 Subject: [PATCH 22/22] Use consistent terminology in adjacent comments --- cmd2/styles.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cmd2/styles.py b/cmd2/styles.py index fd6e736d1..5ab7d9dac 100644 --- a/cmd2/styles.py +++ b/cmd2/styles.py @@ -56,8 +56,8 @@ class Cmd2Style(StrEnum): COMPLETION_MENU = "cmd2.completion_menu" # Base style for the entire completion menu container (sets the background) COMPLETION_MENU_COMPLETION = "cmd2.completion-menu.completion" # Style for an individual, non-selected completion item COMPLETION_MENU_CURRENT = "cmd2.completion-menu.completion.current" # Style for the currently selected completion item - COMPLETION_MENU_META = "cmd2.completion-menu.meta.completion" # Style for "meta" information shown alongside a completion - COMPLETION_MENU_META_CURRENT = "cmd2.completion-menu.meta.completion.current" # Style for meta info of current item + COMPLETION_MENU_META = "cmd2.completion-menu.meta.completion" # Style for meta information shown alongside a completion + COMPLETION_MENU_META_CURRENT = "cmd2.completion-menu.meta.completion.current" # Style for meta information of current item ERROR = "cmd2.error" # Error text (used by perror()) HELP_HEADER = "cmd2.help.header" # Help table header text HELP_LEADER = "cmd2.help.leader" # Text right before the help tables are listed