From d786ae3fbc1338daafe84a578a966d4ef3e3e11c Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Mon, 11 May 2026 16:14:53 -0400 Subject: [PATCH 1/8] Added prompt-toolkit version of the app theme which remains synchronized when theme is updated. --- CHANGELOG.md | 2 - cmd2/__init__.py | 19 ++-- cmd2/cmd2.py | 40 +-------- cmd2/pt_utils.py | 55 +++++------- cmd2/rich_utils.py | 89 +++--------------- cmd2/theme.py | 188 +++++++++++++++++++++++++++++++++++++++ tests/test_cmd2.py | 59 ++---------- tests/test_pt_utils.py | 142 +++++++++++++++-------------- tests/test_rich_utils.py | 55 ------------ tests/test_theme.py | 154 ++++++++++++++++++++++++++++++++ 10 files changed, 470 insertions(+), 333 deletions(-) create mode 100644 cmd2/theme.py create mode 100644 tests/test_theme.py diff --git a/CHANGELOG.md b/CHANGELOG.md index e45fed587..fdb5e834f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -155,8 +155,6 @@ 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 diff --git a/cmd2/__init__.py b/cmd2/__init__.py index 9db123b48..0727f6f31 100644 --- a/cmd2/__init__.py +++ b/cmd2/__init__.py @@ -51,11 +51,16 @@ RawDescriptionCmd2HelpFormatter, RawTextCmd2HelpFormatter, TextGroup, - get_theme, - set_theme, ) from .string_utils import stylize from .styles import Cmd2Style +from .theme import ( + get_pt_theme, + get_theme, + register_pt_mapping, + register_synchronized_prefix, + set_theme, +) from .utils import ( CustomCompletionSettings, Settable, @@ -100,16 +105,20 @@ # Rich Utils "ArgumentDefaultsCmd2HelpFormatter", "Cmd2HelpFormatter", - "get_theme", "MetavarTypeCmd2HelpFormatter", "RawDescriptionCmd2HelpFormatter", "RawTextCmd2HelpFormatter", - "set_theme", "TextGroup", # String Utils "stylize", - # Styles, + # Styles "Cmd2Style", + # Theme + "get_pt_theme", + "get_theme", + "register_pt_mapping", + "register_synchronized_prefix", + "set_theme", # Utilities "categorize", "CustomCompletionSettings", diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 48e437a0e..2485448a6 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -82,7 +82,6 @@ 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, @@ -162,6 +161,7 @@ TextGroup, ) from .styles import Cmd2Style +from .theme import get_pt_theme from .types import ( BoundCommandFunc, BoundCompleter, @@ -195,7 +195,6 @@ def __init__(self, msg: str = "") -> None: Cmd2History, Cmd2Lexer, pt_filter_style, - rich_to_pt_style, ) from .utils import ( Settable, @@ -526,11 +525,6 @@ def __init__( self._persistent_history_length = persistent_history_length self._initialize_history(persistent_history_file) - # Cache for prompt_toolkit completion menu styles - 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 self.main_session = self._create_main_session(auto_suggest, completekey) @@ -724,36 +718,6 @@ def _should_continue_multiline(self) -> bool: # No macro found or already processed. The statement is complete. return False - 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, Style.null()) - rich_completion_style = theme.styles.get(Cmd2Style.COMPLETION_MENU_COMPLETION, Style.null()) - rich_current_style = theme.styles.get(Cmd2Style.COMPLETION_MENU_CURRENT, Style.null()) - rich_meta_style = theme.styles.get(Cmd2Style.COMPLETION_MENU_META, Style.null()) - rich_meta_current_style = theme.styles.get(Cmd2Style.COMPLETION_MENU_META_CURRENT, Style.null()) - - 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.pt_style = PtStyle.from_dict( - { - "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, - } - ) - - 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. @@ -796,7 +760,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), + "style": DynamicStyle(get_pt_theme), } if self.stdin.isatty() and self.stdout.isatty(): diff --git a/cmd2/pt_utils.py b/cmd2/pt_utils.py index a71e13204..909345bd4 100644 --- a/cmd2/pt_utils.py +++ b/cmd2/pt_utils.py @@ -1,7 +1,6 @@ """Utilities for integrating prompt_toolkit with cmd2.""" import re -import weakref from collections.abc import ( Callable, Iterable, @@ -111,16 +110,20 @@ def rich_to_pt_style(rich_style: StyleType) -> str: 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") + if rich_style.strike is not None: + parts.append("strike" if rich_style.strike else "nostrike") + if rich_style.italic is not None: + parts.append("italic" if rich_style.italic else "noitalic") if rich_style.blink is not None: parts.append("blink" if rich_style.blink else "noblink") if rich_style.reverse is not None: parts.append("reverse" if rich_style.reverse else "noreverse") if rich_style.conceal is not None: parts.append("hidden" if rich_style.conceal else "nohidden") + if rich_style.dim is not None: + parts.append("dim" if rich_style.dim else "nodim") return " ".join(parts) @@ -264,21 +267,16 @@ def clear(self) -> None: self._loaded_strings.clear() -_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.""" + # Use the 'class:' prefix to look up styles in the prompt-toolkit theme + COMMAND_STYLE = f"class:{Cmd2Style.LEXER_COMMAND}" + ALIAS_STYLE = f"class:{Cmd2Style.LEXER_ALIAS}" + MACRO_STYLE = f"class:{Cmd2Style.LEXER_MACRO}" + FLAG_STYLE = f"class:{Cmd2Style.LEXER_FLAG}" + ARGUMENT_STYLE = f"class:{Cmd2Style.LEXER_ARGUMENT}" + def __init__( self, cmd_app: "Cmd", @@ -290,19 +288,6 @@ 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, Style.null())) - self.alias_color = rich_to_pt_style(theme.styles.get(Cmd2Style.LEXER_ALIAS, Style.null())) - self.macro_color = rich_to_pt_style(theme.styles.get(Cmd2Style.LEXER_MACRO, Style.null())) - self.flag_color = rich_to_pt_style(theme.styles.get(Cmd2Style.LEXER_FLAG, Style.null())) - self.argument_color = rich_to_pt_style(theme.styles.get(Cmd2Style.LEXER_ARGUMENT, Style.null())) - def lex_document(self, document: Document) -> Callable[[int], Any]: """Lex the document.""" # Get redirection tokens and terminators to avoid highlighting them as values @@ -319,9 +304,9 @@ def highlight_args(text: str, tokens: list[tuple[str, str]]) -> None: if space: tokens.append(("", match_text)) elif flag: - tokens.append((self.flag_color, match_text)) + tokens.append((self.FLAG_STYLE, match_text)) elif (quoted or word) and match_text not in exclude_tokens: - tokens.append((self.argument_color, match_text)) + tokens.append((self.ARGUMENT_STYLE, match_text)) else: tokens.append(("", match_text)) @@ -355,11 +340,11 @@ def get_line(lineno: int) -> list[tuple[str, str]]: for shortcut, _ in self._cmd_app.statement_parser.shortcuts: if command.startswith(shortcut): # Add the shortcut with the command style - tokens.append((self.command_color, shortcut)) + tokens.append((self.COMMAND_STYLE, shortcut)) # If there's more in the command word, it's an argument if len(command) > len(shortcut): - tokens.append((self.argument_color, command[len(shortcut) :])) + tokens.append((self.ARGUMENT_STYLE, command[len(shortcut) :])) shortcut_found = True break @@ -367,11 +352,11 @@ def get_line(lineno: int) -> list[tuple[str, str]]: if not shortcut_found: style = "" if command in self._cmd_app.get_all_commands(): - style = self.command_color + style = self.COMMAND_STYLE elif command in self._cmd_app.aliases: - style = self.alias_color + style = self.ALIAS_STYLE elif command in self._cmd_app.macros: - style = self.macro_color + style = self.MACRO_STYLE # Add the command with the determined style tokens.append((style, command)) diff --git a/cmd2/rich_utils.py b/cmd2/rich_utils.py index 19a7a3297..f1d6fccb6 100644 --- a/cmd2/rich_utils.py +++ b/cmd2/rich_utils.py @@ -3,11 +3,7 @@ import argparse import re import sys -from collections.abc import ( - Callable, - Iterator, - Mapping, -) +from collections.abc import Iterator from enum import Enum from typing import ( IO, @@ -48,7 +44,6 @@ from . import constants from .styles import ( DEFAULT_ARGPARSE_STYLES, - DEFAULT_CMD2_STYLES, Cmd2Style, ) @@ -59,6 +54,13 @@ ANSI_STYLE_SEQUENCE_RE = re.compile(r"\x1b\[[0-9;]*m") +def _get_theme() -> Theme: + """Retrieve the global Rich theme while avoiding circular imports.""" + from .theme import get_theme + + return get_theme() + + @runtime_checkable class HelpFormatterRenderable(Protocol): """Protocol for objects that require a Cmd2HelpFormatter to render.""" @@ -307,75 +309,6 @@ def __cmd2_argparse_help__(self, formatter: Cmd2HelpFormatter) -> Group: return Group(styled_title, indented_text) -# 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.""" - global _APP_THEME # noqa: PLW0603 - if _APP_THEME is None: - _APP_THEME = _create_default_theme() - return _APP_THEME - - -def set_theme(styles: Mapping[str, StyleType] | None = None) -> None: - """Set the Rich theme used by cmd2. - - This function performs an in-place update of the existing theme's - styles. This ensures that any Console objects already using the theme - will reflect the changes immediately without needing to be recreated. - - Call set_theme() with no arguments to reset to the default theme. - This will clear any custom styles that were previously applied. - - :param styles: optional mapping of style names to styles - """ - theme = get_theme() - - # Start with a fresh copy of the default styles. - unparsed_styles: dict[str, StyleType] = {} - unparsed_styles.update(_create_default_theme().styles) - - # Add the custom styles, which may contain unparsed strings - if styles is not None: - unparsed_styles.update(styles) - - # Use Rich's Theme class to perform the parsing - parsed_styles = Theme(unparsed_styles).styles - - # Perform the in-place update with the results - theme.styles.clear() - theme.styles.update(parsed_styles) - - # Synchronize rich-argparse styles with the main application theme. - for name in Cmd2HelpFormatter.styles.keys() & theme.styles.keys(): - Cmd2HelpFormatter.styles[name] = theme.styles[name] - - # Notify callbacks that the theme has been updated - for callback in _theme_update_callbacks: - callback() - - -def _create_default_theme() -> Theme: - """Create a default theme for the application. - - This theme combines the default styles from cmd2, rich-argparse, and Rich. - """ - app_styles = DEFAULT_CMD2_STYLES.copy() - app_styles.update(DEFAULT_ARGPARSE_STYLES) - return Theme(app_styles, inherit=True) - - class Cmd2BaseConsole(Console): """Base class for all cmd2 Rich consoles. @@ -439,7 +372,7 @@ def __init__( color_system="truecolor" if allow_style else None, force_terminal=force_terminal, force_interactive=force_interactive, - theme=get_theme(), + theme=_get_theme(), **kwargs, ) @@ -460,7 +393,7 @@ def _build_config_key( return ( id(file), ALLOW_STYLE, - id(get_theme()), + id(_get_theme()), tuple(sorted(kwargs.items())), ) @@ -677,7 +610,7 @@ def rich_text_to_string(text: Text) -> str: color_system="truecolor", soft_wrap=True, no_color=False, - theme=get_theme(), + theme=_get_theme(), ) with console.capture() as capture: console.print(text, end="") diff --git a/cmd2/theme.py b/cmd2/theme.py new file mode 100644 index 000000000..32c4c95ef --- /dev/null +++ b/cmd2/theme.py @@ -0,0 +1,188 @@ +"""Provides a centralized theming system for cmd2. + +This module manages the global theme used for both Rich terminal output and +prompt-toolkit interactive components. It ensures that styling is consistent +and synchronized across the entire application when the theme is updated using +the following strategy. + +1. Rich consoles use a persistent Theme object that is updated in-place. +2. prompt-toolkit integration in cmd2 uses a DynamicStyle wrapper around a + callable that returns the current prompt-toolkit theme. + +To prevent Rich's built-in styles (like 'bold', or 'italic') from +polluting the prompt-toolkit namespace or colliding with its own internal +names, only styles starting with registered prefixes (e.g., 'cmd2.') or +those explicitly mapped to UI elements are synchronized to the +prompt-toolkit theme. +""" + +from collections.abc import Mapping +from typing import cast + +from prompt_toolkit.styles import Style as PtStyle +from rich.style import StyleType +from rich.theme import Theme + +from .pt_utils import rich_to_pt_style +from .rich_utils import Cmd2HelpFormatter +from .styles import ( + DEFAULT_ARGPARSE_STYLES, + DEFAULT_CMD2_STYLES, + Cmd2Style, +) + +# The application-wide theme, defined using Rich's styling system. +# Use get_theme() to access it and set_theme() to modify it. +_THEME: Theme | None = None + +# The prompt-toolkit version of the theme, synchronized from the Rich theme. +# Use get_pt_theme() to access it. This object is automatically updated whenever +# set_theme() is called. +_PT_THEME: PtStyle | None = None + +# Maps style names to internal UI component names used by prompt-toolkit. +# This allows developers to use application-specific style names in set_theme() +# while ensuring the underlying prompt-toolkit UI is styled correctly. +# Use register_pt_mapping() to modify it. +_PT_UI_MAP: dict[str, list[str]] = { + Cmd2Style.COMPLETION_MENU: ["completion-menu"], + Cmd2Style.COMPLETION_MENU_COMPLETION: ["completion-menu.completion"], + Cmd2Style.COMPLETION_MENU_CURRENT: ["completion-menu.completion.current"], + Cmd2Style.COMPLETION_MENU_META: ["completion-menu.meta.completion"], + Cmd2Style.COMPLETION_MENU_META_CURRENT: [ + "completion-menu.meta.completion.current", + "completion-menu.multi-column-meta", + ], +} + +# Only Rich styles starting with one of these prefixes are synchronized to +# the prompt-toolkit theme. Use register_synchronized_prefix() to modify it. +_SYNCHRONIZED_PREFIXES: set[str] = {"cmd2."} + + +def get_theme() -> Theme: + """Get the application-wide Rich theme. Initializes it on the first call.""" + if _THEME is None: + set_theme() + return cast(Theme, _THEME) + + +def get_pt_theme() -> PtStyle: + """Get the application-wide prompt-toolkit style. Initializes it on the first call.""" + if _PT_THEME is None: + set_theme() + return cast(PtStyle, _PT_THEME) + + +def set_theme(styles: Mapping[str, StyleType] | None = None) -> None: + """Set the application-wide theme. + + This function performs an in-place update of the existing Rich theme's + styles. This ensures that any Console objects already using the theme + will reflect the changes immediately without needing to be recreated. + + It also automatically synchronizes the prompt-toolkit theme for any + styles with registered prefixes or mapped UI components. + + Call set_theme() with no arguments to reset to the default theme. + This will clear any custom styles that were previously applied. + + :param styles: optional mapping of style names to styles + """ + global _THEME # noqa: PLW0603 + if _THEME is None: + _THEME = Theme() + + # Start with a fresh copy of the default styles. + unparsed_styles: dict[str, StyleType] = {} + unparsed_styles.update(_create_default_theme().styles) + + # Add the custom styles, which may contain unparsed strings + if styles is not None: + unparsed_styles.update(styles) + + # Use Rich's Theme class to perform the parsing + parsed_styles = Theme(unparsed_styles).styles + + # Perform the in-place update with the results + _THEME.styles.clear() + _THEME.styles.update(parsed_styles) + + # Synchronize rich-argparse styles with the main application theme. + for name in Cmd2HelpFormatter.styles.keys() & _THEME.styles.keys(): + Cmd2HelpFormatter.styles[name] = _THEME.styles[name] + + # Synchronize the prompt-toolkit theme + _sync_pt_theme() + + +def _sync_pt_theme() -> None: + """Build a new global PT style object based on the current Rich theme.""" + theme = get_theme() + style_rules: list[tuple[str, str]] = [] + + for name, rich_style in theme.styles.items(): + # Only synchronize if it has a registered prefix or mapped UI component. + is_framework_style = any(name.startswith(p) for p in _SYNCHRONIZED_PREFIXES) + is_mapped_style = name in _PT_UI_MAP + + if is_framework_style or is_mapped_style: + pt_style_str = rich_to_pt_style(rich_style) + + # Register the style name as a prompt-toolkit class (accessible via 'class:name') + style_rules.append((name, pt_style_str)) + + # Add any prompt-toolkit UI component aliases from the map (e.g., 'completion-menu') + if is_mapped_style: + style_rules.extend((pt_name, pt_style_str) for pt_name in _PT_UI_MAP[name]) + + global _PT_THEME # noqa: PLW0603 + _PT_THEME = PtStyle(style_rules) + + +def _create_default_theme() -> Theme: + """Create a default theme for the application. + + This theme combines the default styles from cmd2, rich-argparse, and Rich. + """ + app_styles = DEFAULT_CMD2_STYLES.copy() + app_styles.update(DEFAULT_ARGPARSE_STYLES) + return Theme(app_styles, inherit=True) + + +def register_pt_mapping(style_name: str, pt_ui_names: str | list[str]) -> None: + """Map a style name to one or more prompt-toolkit UI components. + + This allows plugins and applications to define their own themeable UI elements. + + :param style_name: The style name used in the Rich theme. + :param pt_ui_names: One or more prompt-toolkit UI component names (e.g., 'completion-menu'). + """ + if isinstance(pt_ui_names, str): + pt_ui_names = [pt_ui_names] + + # Filter out UI names identical to the style name to avoid redundant registration. + unique_names = [n for n in pt_ui_names if n != style_name] + _PT_UI_MAP[style_name] = unique_names + + # Trigger a re-sync if the theme is already initialized + if _PT_THEME is not None: + _sync_pt_theme() + + +def register_synchronized_prefix(prefix: str) -> None: + """Register a prefix whose styles will be synchronized to the prompt-toolkit theme. + + The prefix must include any desired delimiters (e.g., 'myapp.' or 'plugin-'). + + :param prefix: The prefix string. Must be at least 1 character. + :raises ValueError: If the prefix is empty. + """ + if not prefix: + raise ValueError("Prefix cannot be empty.") + + _SYNCHRONIZED_PREFIXES.add(prefix) + + # Trigger a re-sync if the theme is already initialized + if _PT_THEME is not None: + _sync_pt_theme() diff --git a/tests/test_cmd2.py b/tests/test_cmd2.py index eed345457..976801b7a 100644 --- a/tests/test_cmd2.py +++ b/tests/test_cmd2.py @@ -1844,55 +1844,6 @@ def do_orate(self, opts, arg) -> None: self.stdout.write(arg + "\n") -def test_update_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_CURRENT: 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", - "ansired", - ): - found = True - break - assert found, "Color change not found in cached style" - - finally: - ru._APP_THEME = orig_theme - ru.set_theme() - - @pytest.fixture def multiline_app(): return MultilineApp() @@ -2620,12 +2571,14 @@ def test_get_core_print_console_invalidation(base_app: cmd2.Cmd, stream: str) -> finally: ru.ALLOW_STYLE = orig_allow_style - # Changing the theme should create a new console + # Changing the theme object should create a new console from rich.theme import Theme - old_theme = ru.get_theme() + from cmd2 import theme + + old_theme = theme.get_theme() try: - ru._APP_THEME = Theme() + theme._THEME = Theme() console6 = base_app._get_core_print_console( file=file, emoji=False, @@ -2635,7 +2588,7 @@ def test_get_core_print_console_invalidation(base_app: cmd2.Cmd, stream: str) -> assert console6 is not console5 assert getattr(base_app._console_cache, stream) is console6 finally: - ru._APP_THEME = old_theme + theme._THEME = old_theme def test_get_core_print_console_non_cached(base_app: cmd2.Cmd) -> None: diff --git a/tests/test_pt_utils.py b/tests/test_pt_utils.py index 13d89c47e..c1e5135de 100644 --- a/tests/test_pt_utils.py +++ b/tests/test_pt_utils.py @@ -22,7 +22,10 @@ ) from cmd2 import rich_utils as ru from cmd2 import string_utils as su -from cmd2.pt_utils import pt_filter_style +from cmd2.pt_utils import ( + Cmd2Lexer, + pt_filter_style, +) from .conftest import with_ansi_style @@ -117,9 +120,9 @@ def test_lex_document_command(self, mock_cmd_app): tokens = get_line(0) assert tokens == [ - ("fg:ansigreen bg:default", "help"), + (Cmd2Lexer.COMMAND_STYLE, "help"), ("", " "), - ("fg:ansiyellow bg:default", "something"), + (Cmd2Lexer.ARGUMENT_STYLE, "something"), ] def test_lex_document_alias(self, mock_cmd_app): @@ -132,7 +135,11 @@ def test_lex_document_alias(self, mock_cmd_app): get_line = lexer.lex_document(document) tokens = get_line(0) - assert tokens == [("fg:ansicyan bg:default", "ls"), ("", " "), ("fg:ansired bg:default", "-l")] + assert tokens == [ + (Cmd2Lexer.ALIAS_STYLE, "ls"), + ("", " "), + (Cmd2Lexer.FLAG_STYLE, "-l"), + ] def test_lex_document_macro(self, mock_cmd_app): """Test lexing a macro.""" @@ -145,9 +152,9 @@ def test_lex_document_macro(self, mock_cmd_app): tokens = get_line(0) assert tokens == [ - ("fg:ansimagenta bg:default", "my_macro"), + (Cmd2Lexer.MACRO_STYLE, "my_macro"), ("", " "), - ("fg:ansiyellow bg:default", "arg1"), + (Cmd2Lexer.ARGUMENT_STYLE, "arg1"), ] def test_lex_document_leading_whitespace(self, mock_cmd_app): @@ -162,9 +169,9 @@ def test_lex_document_leading_whitespace(self, mock_cmd_app): assert tokens == [ ("", " "), - ("fg:ansigreen bg:default", "help"), + (Cmd2Lexer.COMMAND_STYLE, "help"), ("", " "), - ("fg:ansiyellow bg:default", "something"), + (Cmd2Lexer.ARGUMENT_STYLE, "something"), ] def test_lex_document_unknown_command(self, mock_cmd_app): @@ -176,7 +183,11 @@ def test_lex_document_unknown_command(self, mock_cmd_app): get_line = lexer.lex_document(document) tokens = get_line(0) - assert tokens == [("", "unknown"), ("", " "), ("fg:ansiyellow bg:default", "command")] + assert tokens == [ + ("", "unknown"), + ("", " "), + (Cmd2Lexer.ARGUMENT_STYLE, "command"), + ] def test_lex_document_no_command(self, mock_cmd_app): """Test lexing an empty line or line with only whitespace.""" @@ -213,17 +224,17 @@ def test_lex_document_arguments(self, mock_cmd_app): tokens = get_line(0) assert tokens == [ - ("fg:ansigreen bg:default", "help"), + (Cmd2Lexer.COMMAND_STYLE, "help"), ("", " "), - ("fg:ansired bg:default", "-v"), + (Cmd2Lexer.FLAG_STYLE, "-v"), ("", " "), - ("fg:ansired bg:default", "--name"), + (Cmd2Lexer.FLAG_STYLE, "--name"), ("", " "), - ("fg:ansiyellow bg:default", '"John Doe"'), + (Cmd2Lexer.ARGUMENT_STYLE, '"John Doe"'), ("", " "), ("", ">"), ("", " "), - ("fg:ansiyellow bg:default", "out.txt"), + (Cmd2Lexer.ARGUMENT_STYLE, "out.txt"), ] def test_lex_document_unclosed_quote(self, mock_cmd_app): @@ -237,9 +248,9 @@ def test_lex_document_unclosed_quote(self, mock_cmd_app): tokens = get_line(0) assert tokens == [ - ("fg:ansigreen bg:default", "echo"), + (Cmd2Lexer.COMMAND_STYLE, "echo"), ("", " "), - ("fg:ansiyellow bg:default", '"hello'), + (Cmd2Lexer.ARGUMENT_STYLE, '"hello'), ] def test_lex_document_shortcut(self, mock_cmd_app): @@ -252,13 +263,20 @@ def test_lex_document_shortcut(self, mock_cmd_app): document = Document(line) get_line = lexer.lex_document(document) tokens = get_line(0) - assert tokens == [("fg:ansigreen bg:default", "!"), ("fg:ansiyellow bg:default", "ls")] + assert tokens == [ + (Cmd2Lexer.COMMAND_STYLE, "!"), + (Cmd2Lexer.ARGUMENT_STYLE, "ls"), + ] line = "! ls" document = Document(line) get_line = lexer.lex_document(document) tokens = get_line(0) - assert tokens == [("fg:ansigreen bg:default", "!"), ("", " "), ("fg:ansiyellow bg:default", "ls")] + assert tokens == [ + (Cmd2Lexer.COMMAND_STYLE, "!"), + ("", " "), + (Cmd2Lexer.ARGUMENT_STYLE, "ls"), + ] def test_lex_document_multiline(self, mock_cmd_app): """Test lexing a multiline command.""" @@ -272,36 +290,11 @@ def test_lex_document_multiline(self, mock_cmd_app): # First line should have command tokens0 = get_line(0) - assert tokens0 == [("fg:ansigreen bg:default", "orate")] + assert tokens0 == [(Cmd2Lexer.COMMAND_STYLE, "orate")] # Second line should have argument (not command) tokens1 = get_line(1) - 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.""" - 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 + assert tokens1 == [(Cmd2Lexer.ARGUMENT_STYLE, "help")] class TestCmd2Completer: @@ -730,33 +723,47 @@ def test_rich_to_pt_style_nobold(self): pt_style = pt_utils.rich_to_pt_style(style) assert "nobold" in pt_style - def test_rich_to_pt_style_italic(self): + def test_rich_to_pt_style_underline(self): from rich.style import Style - style = Style(italic=True) + style = Style(underline=True) pt_style = pt_utils.rich_to_pt_style(style) - assert "italic" in pt_style + assert "underline" in pt_style - def test_rich_to_pt_style_noitalic(self): + def test_rich_to_pt_style_nounderline(self): from rich.style import Style - style = Style(italic=False) + style = Style(underline=False) pt_style = pt_utils.rich_to_pt_style(style) - assert "noitalic" in pt_style + assert "nounderline" in pt_style - def test_rich_to_pt_style_underline(self): + def test_rich_to_pt_style_strike(self): from rich.style import Style - style = Style(underline=True) + style = Style(strike=True) pt_style = pt_utils.rich_to_pt_style(style) - assert "underline" in pt_style + assert "strike" in pt_style - def test_rich_to_pt_style_nounderline(self): + def test_rich_to_pt_style_nostrike(self): from rich.style import Style - style = Style(underline=False) + style = Style(strike=False) pt_style = pt_utils.rich_to_pt_style(style) - assert "nounderline" in pt_style + assert "nostrike" in pt_style + + def test_rich_to_pt_style_italic(self): + from rich.style import Style + + style = Style(italic=True) + pt_style = pt_utils.rich_to_pt_style(style) + assert "italic" in pt_style + + def test_rich_to_pt_style_noitalic(self): + from rich.style import Style + + style = Style(italic=False) + pt_style = pt_utils.rich_to_pt_style(style) + assert "noitalic" in pt_style def test_rich_to_pt_style_blink(self): from rich.style import Style @@ -777,11 +784,7 @@ def test_rich_to_pt_style_reverse(self): 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() + assert "reverse" in pt_style def test_rich_to_pt_style_noreverse(self): from rich.style import Style @@ -804,11 +807,16 @@ def test_rich_to_pt_style_nohidden_conceal(self): pt_style = pt_utils.rich_to_pt_style(style) assert "nohidden" in pt_style + def test_rich_to_pt_style_dim(self): + from rich.style import Style -def test_update_lexer_colors() -> None: - mock_lexer = Mock() - pt_utils._lexers.add(mock_lexer) + style = Style(dim=True) + pt_style = pt_utils.rich_to_pt_style(style) + assert "dim" in pt_style - pt_utils._update_lexer_colors() + def test_rich_to_pt_style_nodim(self): + from rich.style import Style - mock_lexer.set_colors.assert_called_once() + style = Style(dim=False) + pt_style = pt_utils.rich_to_pt_style(style) + assert "nodim" in pt_style diff --git a/tests/test_rich_utils.py b/tests/test_rich_utils.py index 9a8cc1173..1899fa782 100644 --- a/tests/test_rich_utils.py +++ b/tests/test_rich_utils.py @@ -8,13 +8,11 @@ import rich.box from pytest_mock import MockerFixture from rich.console import Console -from rich.style import Style from rich.table import Table from rich.text import Text from cmd2 import ( Cmd2ArgumentParser, - Cmd2Style, Color, ) from cmd2 import rich_utils as ru @@ -102,36 +100,6 @@ def test_rich_text_to_string_type_error() -> None: assert "rich_text_to_string() expected a rich.text.Text object, but got str" in str(excinfo.value) -def test_set_theme() -> None: - # Save a cmd2, rich-argparse, and rich-specific style. - cmd2_style_key = Cmd2Style.ERROR - argparse_style_key = "argparse.args" - rich_style_key = "inspect.attr" - - theme = ru.get_theme() - orig_cmd2_style = theme.styles[cmd2_style_key] - orig_argparse_style = theme.styles[argparse_style_key] - orig_rich_style = theme.styles[rich_style_key] - - # Overwrite these styles by setting a new theme. - new_styles = { - 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(new_styles) - - # Verify theme styles have changed to our custom values. - assert theme.styles[cmd2_style_key] != orig_cmd2_style - assert theme.styles[cmd2_style_key] == new_styles[cmd2_style_key] - - assert theme.styles[argparse_style_key] != orig_argparse_style - assert theme.styles[argparse_style_key] == new_styles[argparse_style_key] - - assert theme.styles[rich_style_key] != orig_rich_style - assert theme.styles[rich_style_key] == new_styles[rich_style_key] - - def test_cmd2_base_console_print(mocker: MockerFixture) -> None: """Test that Cmd2BaseConsole.print() calls prepare_objects_for_rendering().""" # Mock prepare_objects_for_rendering to return a specific value @@ -349,26 +317,3 @@ 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() diff --git a/tests/test_theme.py b/tests/test_theme.py new file mode 100644 index 000000000..b7c36a91a --- /dev/null +++ b/tests/test_theme.py @@ -0,0 +1,154 @@ +"""Unit testing for cmd2/theme.py module""" + +from typing import Any + +import pytest +from rich.style import Style + +from cmd2 import ( + Cmd2Style, + Color, +) +from cmd2 import rich_utils as ru +from cmd2.theme import ( + get_pt_theme, + get_theme, + register_pt_mapping, + register_synchronized_prefix, + set_theme, +) + + +def test_set_theme() -> None: + # Save a cmd2, rich-argparse, rich-specific style, + # and one that maps to a prompt-toolkit UI element. + cmd2_style_key = Cmd2Style.ERROR + argparse_style_key = "argparse.args" + rich_style_key = "inspect.attr" + pt_mapped_key = Cmd2Style.COMPLETION_MENU + + theme = get_theme() + pt_theme = get_pt_theme() + + # Save the originals + orig_cmd2_style = theme.styles[cmd2_style_key] + orig_argparse_style = theme.styles[argparse_style_key] + orig_rich_argparse_style = ru.Cmd2HelpFormatter.styles[argparse_style_key] + orig_rich_style = theme.styles[rich_style_key] + orig_completion_menu = theme.styles[pt_mapped_key] + orig_pt_completion_menu = pt_theme.get_attrs_for_style_str("class:completion-menu") + + # Overwrite these styles by setting a new theme. + new_styles = { + 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), + pt_mapped_key: Style(color=Color.BLUE), + } + set_theme(new_styles) + + # Verify theme styles have changed to our custom values. + assert theme.styles[cmd2_style_key] != orig_cmd2_style + assert theme.styles[cmd2_style_key] == new_styles[cmd2_style_key] + + assert theme.styles[argparse_style_key] != orig_argparse_style + assert theme.styles[argparse_style_key] == new_styles[argparse_style_key] + assert ru.Cmd2HelpFormatter.styles[argparse_style_key] != orig_rich_argparse_style + assert ru.Cmd2HelpFormatter.styles[argparse_style_key] == new_styles[argparse_style_key] + + assert theme.styles[rich_style_key] != orig_rich_style + assert theme.styles[rich_style_key] == new_styles[rich_style_key] + + assert theme.styles[pt_mapped_key] != orig_completion_menu + assert theme.styles[pt_mapped_key] == new_styles[pt_mapped_key] + + # Verify the prompt-toolkit theme was updated + new_pt_theme = get_pt_theme() + new_pt_completion_menu = new_pt_theme.get_attrs_for_style_str("class:completion-menu") + assert orig_pt_completion_menu != new_pt_completion_menu + + for field, value in new_pt_completion_menu._asdict().items(): + if field == "color": + expected: Any = "ansiblue" + elif field == "bgcolor": + expected = "default" + else: + expected = False + + assert expected == value + + +def test_theme_is_none() -> None: + """Test that get_theme() creates the theme when it's None.""" + from cmd2 import theme + + theme._THEME = None + + assert get_theme() is not None + + +def test_pt_theme_is_none() -> None: + """Test that get_pt_theme() creates the pt theme when it's None.""" + from cmd2 import theme + + theme._PT_THEME = None + + assert get_pt_theme() is not None + + +def test_register_synchronized_prefix() -> None: + """Test registering a custom synchronization prefix.""" + from prompt_toolkit.styles import DEFAULT_ATTRS + + prefix = "myapp." + style_name = f"{prefix}prompt" + set_theme({style_name: Style(color=Color.GREEN)}) + + # Initially the style is only in the Rich theme + rich_theme = get_theme() + orig_pt_theme = get_pt_theme() + + assert style_name in rich_theme.styles + assert orig_pt_theme.get_attrs_for_style_str(f"class:{style_name}") == DEFAULT_ATTRS + + # Register the prefix and make sure the style has been synced to the pt theme + register_synchronized_prefix(prefix) + new_pt_theme = get_pt_theme() + style_attrs = new_pt_theme.get_attrs_for_style_str(f"class:{style_name}") + assert style_attrs.color == "ansigreen" + + +def test_register_synchronized_prefix_empty() -> None: + """Test that an empty prefix raises a ValueError.""" + with pytest.raises(ValueError, match="Prefix cannot be empty"): + register_synchronized_prefix("") + + +def test_register_pt_mapping() -> None: + """Test style to UI mapping.""" + style_name = "my_custom_scrollbar" + ui_name = "scrollbar" + + register_pt_mapping(style_name, ui_name) + + set_theme({style_name: Style(color=Color.BLUE)}) + + pt_theme = get_pt_theme() + + # Check that both the main style name and the UI name are mapped + attrs_main = pt_theme.get_attrs_for_style_str(f"class:{style_name}") + attrs_ui = pt_theme.get_attrs_for_style_str(f"class:{ui_name}") + + assert attrs_main.color == "ansiblue" + assert attrs_ui.color == "ansiblue" + + +def test_register_pt_mapping_redundant() -> None: + """Test that redundant mappings are filtered out.""" + from cmd2 import theme + + style_name = "redundant" + register_pt_mapping(style_name, [style_name, "other"]) + + assert style_name not in theme._PT_UI_MAP[style_name] + assert "other" in theme._PT_UI_MAP[style_name] From 93d99d97d76f887fa99afacf207e0c55d51ba634 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Mon, 11 May 2026 16:46:59 -0400 Subject: [PATCH 2/8] Updated comment. --- cmd2/theme.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/cmd2/theme.py b/cmd2/theme.py index 32c4c95ef..4d322eb33 100644 --- a/cmd2/theme.py +++ b/cmd2/theme.py @@ -151,9 +151,10 @@ def _create_default_theme() -> Theme: def register_pt_mapping(style_name: str, pt_ui_names: str | list[str]) -> None: - """Map a style name to one or more prompt-toolkit UI components. + """Map a Rich theme style name to one or more prompt-toolkit UI components. - This allows plugins and applications to define their own themeable UI elements. + This enables styling of prompt-toolkit's internal elements (such as the + completion menu ) using styles in the application's Rich theme. :param style_name: The style name used in the Rich theme. :param pt_ui_names: One or more prompt-toolkit UI component names (e.g., 'completion-menu'). From c1fb7687b5b59ef7300876111d0ee4d11427b8a5 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Mon, 11 May 2026 18:36:48 -0400 Subject: [PATCH 3/8] Added support to append to a style to pt mapping. --- cmd2/styles.py | 2 +- cmd2/theme.py | 79 ++++++++++++++++++++++++------ tests/test_theme.py | 117 +++++++++++++++++++++++++++++++++++--------- 3 files changed, 160 insertions(+), 38 deletions(-) diff --git a/cmd2/styles.py b/cmd2/styles.py index 5ab7d9dac..2dcbac804 100644 --- a/cmd2/styles.py +++ b/cmd2/styles.py @@ -53,7 +53,7 @@ class Cmd2Style(StrEnum): """ COMMAND_LINE = "cmd2.example" # Command line examples in help text - COMPLETION_MENU = "cmd2.completion_menu" # Base style for the entire completion menu container (sets the background) + 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 diff --git a/cmd2/theme.py b/cmd2/theme.py index 4d322eb33..f1ab5ad66 100644 --- a/cmd2/theme.py +++ b/cmd2/theme.py @@ -44,15 +44,15 @@ # This allows developers to use application-specific style names in set_theme() # while ensuring the underlying prompt-toolkit UI is styled correctly. # Use register_pt_mapping() to modify it. -_PT_UI_MAP: dict[str, list[str]] = { - Cmd2Style.COMPLETION_MENU: ["completion-menu"], - Cmd2Style.COMPLETION_MENU_COMPLETION: ["completion-menu.completion"], - Cmd2Style.COMPLETION_MENU_CURRENT: ["completion-menu.completion.current"], - Cmd2Style.COMPLETION_MENU_META: ["completion-menu.meta.completion"], - Cmd2Style.COMPLETION_MENU_META_CURRENT: [ +_PT_UI_MAP: dict[str, set[str]] = { + Cmd2Style.COMPLETION_MENU: {"completion-menu"}, + Cmd2Style.COMPLETION_MENU_COMPLETION: {"completion-menu.completion"}, + Cmd2Style.COMPLETION_MENU_CURRENT: {"completion-menu.completion.current"}, + Cmd2Style.COMPLETION_MENU_META: {"completion-menu.meta.completion"}, + Cmd2Style.COMPLETION_MENU_META_CURRENT: { "completion-menu.meta.completion.current", "completion-menu.multi-column-meta", - ], + }, } # Only Rich styles starting with one of these prefixes are synchronized to @@ -154,7 +154,7 @@ def register_pt_mapping(style_name: str, pt_ui_names: str | list[str]) -> None: """Map a Rich theme style name to one or more prompt-toolkit UI components. This enables styling of prompt-toolkit's internal elements (such as the - completion menu ) using styles in the application's Rich theme. + completion menu) using styles in the application's Rich theme. :param style_name: The style name used in the Rich theme. :param pt_ui_names: One or more prompt-toolkit UI component names (e.g., 'completion-menu'). @@ -162,15 +162,52 @@ def register_pt_mapping(style_name: str, pt_ui_names: str | list[str]) -> None: if isinstance(pt_ui_names, str): pt_ui_names = [pt_ui_names] - # Filter out UI names identical to the style name to avoid redundant registration. + if style_name not in _PT_UI_MAP: + _PT_UI_MAP[style_name] = set() + + # Only add UI names that differ from the style name to avoid redundant rules in PtStyle unique_names = [n for n in pt_ui_names if n != style_name] - _PT_UI_MAP[style_name] = unique_names + _PT_UI_MAP[style_name].update(unique_names) # Trigger a re-sync if the theme is already initialized if _PT_THEME is not None: _sync_pt_theme() +def unregister_pt_mapping(style_name: str, pt_ui_names: str | list[str] | None = None) -> None: + """Remove one or more prompt-toolkit UI component mappings. + + If pt_ui_names is None, all mappings for the given style_name are removed. + + :param style_name: The style name used in the Rich theme. + :param pt_ui_names: Specific UI component(s) to unmap, or None to clear all. + """ + if style_name not in _PT_UI_MAP: + return + + changed = False + + if pt_ui_names is None: + del _PT_UI_MAP[style_name] + changed = True + else: + if isinstance(pt_ui_names, str): + pt_ui_names = [pt_ui_names] + + for name in pt_ui_names: + _PT_UI_MAP[style_name].discard(name) + changed = True + + # Clean up the key if no mappings remain + if not _PT_UI_MAP[style_name]: + del _PT_UI_MAP[style_name] + changed = True + + # Trigger a re-sync if the theme is already initialized + if changed and _PT_THEME is not None: + _sync_pt_theme() + + def register_synchronized_prefix(prefix: str) -> None: """Register a prefix whose styles will be synchronized to the prompt-toolkit theme. @@ -182,8 +219,22 @@ def register_synchronized_prefix(prefix: str) -> None: if not prefix: raise ValueError("Prefix cannot be empty.") - _SYNCHRONIZED_PREFIXES.add(prefix) + if prefix not in _SYNCHRONIZED_PREFIXES: + _SYNCHRONIZED_PREFIXES.add(prefix) - # Trigger a re-sync if the theme is already initialized - if _PT_THEME is not None: - _sync_pt_theme() + # Trigger a re-sync if the theme is already initialized + if _PT_THEME is not None: + _sync_pt_theme() + + +def unregister_synchronized_prefix(prefix: str) -> None: + """Stop synchronizing styles starting with the given prefix. + + :param prefix: The prefix string to remove. + """ + if prefix in _SYNCHRONIZED_PREFIXES: + _SYNCHRONIZED_PREFIXES.remove(prefix) + + # Trigger a re-sync if the theme is already initialized + if _PT_THEME is not None: + _sync_pt_theme() diff --git a/tests/test_theme.py b/tests/test_theme.py index b7c36a91a..adf4f4a42 100644 --- a/tests/test_theme.py +++ b/tests/test_theme.py @@ -16,6 +16,8 @@ register_pt_mapping, register_synchronized_prefix, set_theme, + unregister_pt_mapping, + unregister_synchronized_prefix, ) @@ -96,8 +98,83 @@ def test_pt_theme_is_none() -> None: assert get_pt_theme() is not None +def test_register_pt_mapping() -> None: + """Test style to UI mapping.""" + style_name = "my_custom_scrollbar" + ui_name = "scrollbar" + + register_pt_mapping(style_name, ui_name) + + set_theme({style_name: Style(color=Color.BLUE)}) + + pt_theme = get_pt_theme() + + # Check that both the main style name and the UI name are mapped + attrs_main = pt_theme.get_attrs_for_style_str(f"class:{style_name}") + attrs_ui = pt_theme.get_attrs_for_style_str(f"class:{ui_name}") + + assert attrs_main.color == "ansiblue" + assert attrs_ui.color == "ansiblue" + + +def test_register_pt_mapping_redundant() -> None: + """Test that redundant mappings are filtered out.""" + from cmd2 import theme + + style_name = "redundant" + register_pt_mapping(style_name, [style_name, "other"]) + + assert style_name not in theme._PT_UI_MAP[style_name] + assert "other" in theme._PT_UI_MAP[style_name] + + +def test_unregister_pt_mapping() -> None: + """Test unmapping styles from UI components.""" + from prompt_toolkit.styles import DEFAULT_ATTRS + + style_name = "custom_scroll" + ui_names = ["scroll1", "scroll2"] + + register_pt_mapping(style_name, ui_names) + set_theme({style_name: Style(color=Color.RED)}) + + pt_theme = get_pt_theme() + assert pt_theme.get_attrs_for_style_str("class:scroll1").color == "ansired" + assert pt_theme.get_attrs_for_style_str("class:scroll2").color == "ansired" + + # Unregister one UI component + unregister_pt_mapping(style_name, "scroll1") + pt_theme = get_pt_theme() + assert pt_theme.get_attrs_for_style_str("class:scroll1") == DEFAULT_ATTRS + assert pt_theme.get_attrs_for_style_str("class:scroll2").color == "ansired" + + # Unregister the entire style mapping + unregister_pt_mapping(style_name) + pt_theme = get_pt_theme() + assert pt_theme.get_attrs_for_style_str("class:scroll2") == DEFAULT_ATTRS + + +def test_unregister_pt_mapping_nonexistent() -> None: + """Test unregistering a mapping that doesn't exist.""" + unregister_pt_mapping("nonexistent_style") + + +def test_unregister_pt_mapping_cleans_up_key() -> None: + """Test that unregistering the last UI component removes the style key.""" + style_name = "cleanup_style" + ui_name = "cleanup_ui" + register_pt_mapping(style_name, ui_name) + + from cmd2 import theme + + assert style_name in theme._PT_UI_MAP + + unregister_pt_mapping(style_name, ui_name) + assert style_name not in theme._PT_UI_MAP + + def test_register_synchronized_prefix() -> None: - """Test registering a custom synchronization prefix.""" + """Test registering a custom synchronized prefix.""" from prompt_toolkit.styles import DEFAULT_ATTRS prefix = "myapp." @@ -124,31 +201,25 @@ def test_register_synchronized_prefix_empty() -> None: register_synchronized_prefix("") -def test_register_pt_mapping() -> None: - """Test style to UI mapping.""" - style_name = "my_custom_scrollbar" - ui_name = "scrollbar" - - register_pt_mapping(style_name, ui_name) +def test_unregister_synchronized_prefix() -> None: + """Test unregistering a custom synchronized prefix.""" + from prompt_toolkit.styles import DEFAULT_ATTRS - set_theme({style_name: Style(color=Color.BLUE)}) + prefix = "unregister." + style_name = f"{prefix}prompt" + set_theme({style_name: Style(color=Color.GREEN)}) + # Register the prefix and make sure the style has been synced to the pt theme + register_synchronized_prefix(prefix) pt_theme = get_pt_theme() + assert pt_theme.get_attrs_for_style_str(f"class:{style_name}").color == "ansigreen" - # Check that both the main style name and the UI name are mapped - attrs_main = pt_theme.get_attrs_for_style_str(f"class:{style_name}") - attrs_ui = pt_theme.get_attrs_for_style_str(f"class:{ui_name}") - - assert attrs_main.color == "ansiblue" - assert attrs_ui.color == "ansiblue" - + # Unregister the prefix and make sure the style is no longer synced + unregister_synchronized_prefix(prefix) + new_pt_theme = get_pt_theme() + assert new_pt_theme.get_attrs_for_style_str(f"class:{style_name}") == DEFAULT_ATTRS -def test_register_pt_mapping_redundant() -> None: - """Test that redundant mappings are filtered out.""" - from cmd2 import theme - style_name = "redundant" - register_pt_mapping(style_name, [style_name, "other"]) - - assert style_name not in theme._PT_UI_MAP[style_name] - assert "other" in theme._PT_UI_MAP[style_name] +def test_unregister_synchronized_prefix_nonexistent() -> None: + """Test unregistering a prefix that doesn't exist.""" + unregister_synchronized_prefix("nonexistent_prefix.") From c1ecc624f2278f0c3af5b1c413473fd45859ef66 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Mon, 11 May 2026 19:34:56 -0400 Subject: [PATCH 4/8] Addressing code review comments. --- cmd2/__init__.py | 4 ++++ cmd2/theme.py | 30 ++++++++++++++++++++++-------- tests/test_theme.py | 18 ++++++++++++++++++ 3 files changed, 44 insertions(+), 8 deletions(-) diff --git a/cmd2/__init__.py b/cmd2/__init__.py index 0727f6f31..84ea37522 100644 --- a/cmd2/__init__.py +++ b/cmd2/__init__.py @@ -60,6 +60,8 @@ register_pt_mapping, register_synchronized_prefix, set_theme, + unregister_pt_mapping, + unregister_synchronized_prefix, ) from .utils import ( CustomCompletionSettings, @@ -119,6 +121,8 @@ "register_pt_mapping", "register_synchronized_prefix", "set_theme", + "unregister_pt_mapping", + "unregister_synchronized_prefix", # Utilities "categorize", "CustomCompletionSettings", diff --git a/cmd2/theme.py b/cmd2/theme.py index f1ab5ad66..9d85911d3 100644 --- a/cmd2/theme.py +++ b/cmd2/theme.py @@ -16,7 +16,10 @@ prompt-toolkit theme. """ -from collections.abc import Mapping +from collections.abc import ( + Iterable, + Mapping, +) from typing import cast from prompt_toolkit.styles import Style as PtStyle @@ -150,7 +153,7 @@ def _create_default_theme() -> Theme: return Theme(app_styles, inherit=True) -def register_pt_mapping(style_name: str, pt_ui_names: str | list[str]) -> None: +def register_pt_mapping(style_name: str, pt_ui_names: str | Iterable[str]) -> None: """Map a Rich theme style name to one or more prompt-toolkit UI components. This enables styling of prompt-toolkit's internal elements (such as the @@ -162,19 +165,28 @@ def register_pt_mapping(style_name: str, pt_ui_names: str | list[str]) -> None: if isinstance(pt_ui_names, str): pt_ui_names = [pt_ui_names] + # Register the style in the map. Presence in this map, even with an empty set, + # is the trigger that flags this style for synchronization to prompt-toolkit. + # This is helpful for styles which do not begin with a registered prefix. if style_name not in _PT_UI_MAP: _PT_UI_MAP[style_name] = set() + changed = True + else: + changed = False + + # Add UI aliases, excluding 'style_name' which the sync handles by default. + original_size = len(_PT_UI_MAP[style_name]) + _PT_UI_MAP[style_name].update(n for n in pt_ui_names if n != style_name) - # Only add UI names that differ from the style name to avoid redundant rules in PtStyle - unique_names = [n for n in pt_ui_names if n != style_name] - _PT_UI_MAP[style_name].update(unique_names) + if len(_PT_UI_MAP[style_name]) > original_size: + changed = True # Trigger a re-sync if the theme is already initialized - if _PT_THEME is not None: + if changed and _PT_THEME is not None: _sync_pt_theme() -def unregister_pt_mapping(style_name: str, pt_ui_names: str | list[str] | None = None) -> None: +def unregister_pt_mapping(style_name: str, pt_ui_names: str | Iterable[str] | None = None) -> None: """Remove one or more prompt-toolkit UI component mappings. If pt_ui_names is None, all mappings for the given style_name are removed. @@ -194,9 +206,11 @@ def unregister_pt_mapping(style_name: str, pt_ui_names: str | list[str] | None = if isinstance(pt_ui_names, str): pt_ui_names = [pt_ui_names] + original_size = len(_PT_UI_MAP[style_name]) for name in pt_ui_names: _PT_UI_MAP[style_name].discard(name) - changed = True + + changed = len(_PT_UI_MAP[style_name]) < original_size # Clean up the key if no mappings remain if not _PT_UI_MAP[style_name]: diff --git a/tests/test_theme.py b/tests/test_theme.py index adf4f4a42..a89479af0 100644 --- a/tests/test_theme.py +++ b/tests/test_theme.py @@ -128,6 +128,24 @@ def test_register_pt_mapping_redundant() -> None: assert "other" in theme._PT_UI_MAP[style_name] +def test_register_pt_mapping_existing_style() -> None: + """Test calling register_pt_mapping with an existing style name.""" + style_name = "existing_style" + ui_name = "ui_component" + + # First registration + register_pt_mapping(style_name, ui_name) + + # Second registration with the same name + register_pt_mapping(style_name, ui_name) + + # Verify contents of _PT_UI_MAP + from cmd2 import theme + + assert style_name in theme._PT_UI_MAP + assert ui_name in theme._PT_UI_MAP[style_name] + + def test_unregister_pt_mapping() -> None: """Test unmapping styles from UI components.""" from prompt_toolkit.styles import DEFAULT_ATTRS From 0f91f796b905dd2aaacfb949b0e26f30ef1948f9 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Mon, 11 May 2026 21:03:47 -0400 Subject: [PATCH 5/8] Added functions for registering synchronized styles. --- cmd2/__init__.py | 4 +++ cmd2/theme.py | 85 ++++++++++++++++++++++++++++++--------------- tests/test_theme.py | 53 ++++++++++++++++++++++------ 3 files changed, 104 insertions(+), 38 deletions(-) diff --git a/cmd2/__init__.py b/cmd2/__init__.py index 84ea37522..049da4749 100644 --- a/cmd2/__init__.py +++ b/cmd2/__init__.py @@ -59,9 +59,11 @@ get_theme, register_pt_mapping, register_synchronized_prefix, + register_synchronized_style, set_theme, unregister_pt_mapping, unregister_synchronized_prefix, + unregister_synchronized_style, ) from .utils import ( CustomCompletionSettings, @@ -120,9 +122,11 @@ "get_theme", "register_pt_mapping", "register_synchronized_prefix", + "register_synchronized_style", "set_theme", "unregister_pt_mapping", "unregister_synchronized_prefix", + "unregister_synchronized_style", # Utilities "categorize", "CustomCompletionSettings", diff --git a/cmd2/theme.py b/cmd2/theme.py index 9d85911d3..1f1c2a22d 100644 --- a/cmd2/theme.py +++ b/cmd2/theme.py @@ -46,7 +46,12 @@ # Maps style names to internal UI component names used by prompt-toolkit. # This allows developers to use application-specific style names in set_theme() # while ensuring the underlying prompt-toolkit UI is styled correctly. -# Use register_pt_mapping() to modify it. +# Use register_pt_mapping() and unregister_pt_mapping() to manage these mappings. +# +# Presence in this mapping, even with an empty set of UI component names, flags the +# style for synchronization to the prompt-toolkit theme. Use register_synchronized_style() +# and unregister_synchronized_style() to manage synchronization for styles that lack a +# registered prefix and do not require specific UI component mappings. _PT_UI_MAP: dict[str, set[str]] = { Cmd2Style.COMPLETION_MENU: {"completion-menu"}, Cmd2Style.COMPLETION_MENU_COMPLETION: {"completion-menu.completion"}, @@ -58,8 +63,9 @@ }, } -# Only Rich styles starting with one of these prefixes are synchronized to -# the prompt-toolkit theme. Use register_synchronized_prefix() to modify it. +# Rich styles that start with one of these prefixes are automatically +# synchronized to the prompt-toolkit theme. Use register_synchronized_prefix() +# and unregister_synchronized_prefix() to modify this set. _SYNCHRONIZED_PREFIXES: set[str] = {"cmd2."} @@ -135,7 +141,7 @@ def _sync_pt_theme() -> None: # Register the style name as a prompt-toolkit class (accessible via 'class:name') style_rules.append((name, pt_style_str)) - # Add any prompt-toolkit UI component aliases from the map (e.g., 'completion-menu') + # Add any prompt-toolkit UI component names from the map (e.g., 'completion-menu') if is_mapped_style: style_rules.extend((pt_name, pt_style_str) for pt_name in _PT_UI_MAP[name]) @@ -159,22 +165,23 @@ def register_pt_mapping(style_name: str, pt_ui_names: str | Iterable[str]) -> No This enables styling of prompt-toolkit's internal elements (such as the completion menu) using styles in the application's Rich theme. + Registering a mapping also flags the style for synchronization to the + prompt-toolkit theme, making it accessible via 'class:style_name'. + :param style_name: The style name used in the Rich theme. :param pt_ui_names: One or more prompt-toolkit UI component names (e.g., 'completion-menu'). """ if isinstance(pt_ui_names, str): pt_ui_names = [pt_ui_names] - # Register the style in the map. Presence in this map, even with an empty set, - # is the trigger that flags this style for synchronization to prompt-toolkit. - # This is helpful for styles which do not begin with a registered prefix. + # Register the style in the map. if style_name not in _PT_UI_MAP: _PT_UI_MAP[style_name] = set() changed = True else: changed = False - # Add UI aliases, excluding 'style_name' which the sync handles by default. + # Add UI mappings, excluding 'style_name' which the sync handles by default. original_size = len(_PT_UI_MAP[style_name]) _PT_UI_MAP[style_name].update(n for n in pt_ui_names if n != style_name) @@ -186,42 +193,64 @@ def register_pt_mapping(style_name: str, pt_ui_names: str | Iterable[str]) -> No _sync_pt_theme() -def unregister_pt_mapping(style_name: str, pt_ui_names: str | Iterable[str] | None = None) -> None: +def register_synchronized_style(style_name: str) -> None: + """Register a Rich theme style for synchronization with prompt-toolkit. + + This ensures that the style is synchronized to the prompt-toolkit theme + (accessible via 'class:style_name') even if it does not begin with a + registered prefix. + + :param style_name: The style name used in the Rich theme. + """ + register_pt_mapping(style_name, []) + + +def unregister_pt_mapping(style_name: str, pt_ui_names: str | Iterable[str]) -> None: """Remove one or more prompt-toolkit UI component mappings. - If pt_ui_names is None, all mappings for the given style_name are removed. + The style itself remains in the synchronization mapping (even if no + UI component mappings remain), ensuring it continues to be synchronized + to the prompt-toolkit theme. + + To completely remove a style from synchronization, use + unregister_synchronized_style(). :param style_name: The style name used in the Rich theme. - :param pt_ui_names: Specific UI component(s) to unmap, or None to clear all. + :param pt_ui_names: Specific UI component(s) to unmap. """ if style_name not in _PT_UI_MAP: return - changed = False - - if pt_ui_names is None: - del _PT_UI_MAP[style_name] - changed = True - else: - if isinstance(pt_ui_names, str): - pt_ui_names = [pt_ui_names] - - original_size = len(_PT_UI_MAP[style_name]) - for name in pt_ui_names: - _PT_UI_MAP[style_name].discard(name) + if isinstance(pt_ui_names, str): + pt_ui_names = [pt_ui_names] - changed = len(_PT_UI_MAP[style_name]) < original_size + original_size = len(_PT_UI_MAP[style_name]) + for name in pt_ui_names: + _PT_UI_MAP[style_name].discard(name) - # Clean up the key if no mappings remain - if not _PT_UI_MAP[style_name]: - del _PT_UI_MAP[style_name] - changed = True + changed = len(_PT_UI_MAP[style_name]) < original_size # Trigger a re-sync if the theme is already initialized if changed and _PT_THEME is not None: _sync_pt_theme() +def unregister_synchronized_style(style_name: str) -> None: + """Stop synchronizing a Rich theme style with prompt-toolkit. + + This removes the style and all its mappings entirely from the + prompt-toolkit theme synchronization. + + :param style_name: The style name to unregister. + """ + if style_name in _PT_UI_MAP: + del _PT_UI_MAP[style_name] + + # Trigger a re-sync if the theme is already initialized + if _PT_THEME is not None: + _sync_pt_theme() + + def register_synchronized_prefix(prefix: str) -> None: """Register a prefix whose styles will be synchronized to the prompt-toolkit theme. diff --git a/tests/test_theme.py b/tests/test_theme.py index a89479af0..2ce453cac 100644 --- a/tests/test_theme.py +++ b/tests/test_theme.py @@ -15,9 +15,11 @@ get_theme, register_pt_mapping, register_synchronized_prefix, + register_synchronized_style, set_theme, unregister_pt_mapping, unregister_synchronized_prefix, + unregister_synchronized_style, ) @@ -99,7 +101,7 @@ def test_pt_theme_is_none() -> None: def test_register_pt_mapping() -> None: - """Test style to UI mapping.""" + """Test style registration with UI mapping.""" style_name = "my_custom_scrollbar" ui_name = "scrollbar" @@ -147,7 +149,7 @@ def test_register_pt_mapping_existing_style() -> None: def test_unregister_pt_mapping() -> None: - """Test unmapping styles from UI components.""" + """Test unregistering UI mappings from styles.""" from prompt_toolkit.styles import DEFAULT_ATTRS style_name = "custom_scroll" @@ -166,28 +168,59 @@ def test_unregister_pt_mapping() -> None: assert pt_theme.get_attrs_for_style_str("class:scroll1") == DEFAULT_ATTRS assert pt_theme.get_attrs_for_style_str("class:scroll2").color == "ansired" - # Unregister the entire style mapping - unregister_pt_mapping(style_name) + # Unregister the other UI component + unregister_pt_mapping(style_name, "scroll2") pt_theme = get_pt_theme() assert pt_theme.get_attrs_for_style_str("class:scroll2") == DEFAULT_ATTRS def test_unregister_pt_mapping_nonexistent() -> None: - """Test unregistering a mapping that doesn't exist.""" - unregister_pt_mapping("nonexistent_style") + """Test unregistering a style that doesn't exist.""" + unregister_pt_mapping("nonexistent_style", "some_ui") -def test_unregister_pt_mapping_cleans_up_key() -> None: - """Test that unregistering the last UI component removes the style key.""" - style_name = "cleanup_style" - ui_name = "cleanup_ui" +def test_unregister_pt_mapping_preserves_key() -> None: + """Test that unregistering UI components preserves the style key for synchronization.""" + style_name = "preserved_style" + ui_name = "some_ui" register_pt_mapping(style_name, ui_name) from cmd2 import theme assert style_name in theme._PT_UI_MAP + assert ui_name in theme._PT_UI_MAP[style_name] + # Unregister just the UI component unregister_pt_mapping(style_name, ui_name) + + # The style key should still be in the map to trigger synchronization + assert style_name in theme._PT_UI_MAP + assert not theme._PT_UI_MAP[style_name] + + +def test_register_synchronized_style() -> None: + """Test that simple registration (no UI mapping) synchronizes to PT.""" + style_name = "simple_style" + register_synchronized_style(style_name) + + set_theme({style_name: Style(color=Color.RED)}) + + # It should be available as a class:name + pt_theme = get_pt_theme() + attrs = pt_theme.get_attrs_for_style_str(f"class:{style_name}") + assert attrs.color == "ansired" + + +def test_unregister_synchronized_style() -> None: + """Test that unregistering removes the style entirely.""" + style_name = "removal_style" + ui_name = "removal_ui" + register_pt_mapping(style_name, ui_name) + + from cmd2 import theme + + assert style_name in theme._PT_UI_MAP + unregister_synchronized_style(style_name) assert style_name not in theme._PT_UI_MAP From 4b8b02e420d940f374f724305133a73d1b0e294c Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Mon, 11 May 2026 21:49:22 -0400 Subject: [PATCH 6/8] Fixed references to get_theme() and set_theme(). --- CHANGELOG.md | 4 ++-- cmd2/rich_utils.py | 3 +-- cmd2/styles.py | 2 +- docs/upgrades.md | 2 +- examples/rich_theme.py | 5 ++--- 5 files changed, 7 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fdb5e834f..462f6f035 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -95,8 +95,8 @@ prompt is displayed. - `RawTextCmd2HelpFormatter` - `TextGroup` - Replaced the global `APP_THEME` constant in `rich_utils.py` with `get_theme()` and - `set_theme()` functions to support lazy initialization and safer in-place updates of the - theme. + `set_theme()` functions in `theme.py` to support lazy initialization and safer in-place + updates of the theme. - Renamed `Cmd._command_parsers` to `Cmd.command_parsers`. - Removed `RichPrintKwargs` `TypedDict` in favor of using `Mapping[str, Any]`, allowing for greater flexibility in passing keyword arguments to `console.print()` calls. diff --git a/cmd2/rich_utils.py b/cmd2/rich_utils.py index f1d6fccb6..a8d757ff0 100644 --- a/cmd2/rich_utils.py +++ b/cmd2/rich_utils.py @@ -342,8 +342,7 @@ def __init__( ) # Don't allow a theme to be passed in, as it is controlled by get_theme() and set_theme(). - # Use cmd2.rich_utils.set_theme() to set the global theme or use a temporary - # theme with console.use_theme(). + # Use set_theme() to set the global theme or use a temporary theme with console.use_theme(). if "theme" in kwargs: raise TypeError("Passing 'theme' is not allowed. Its behavior is controlled by get_theme() and set_theme().") diff --git a/cmd2/styles.py b/cmd2/styles.py index 2dcbac804..4590ab325 100644 --- a/cmd2/styles.py +++ b/cmd2/styles.py @@ -14,7 +14,7 @@ their own default styles. For a complete theming experience, you can create a custom theme that includes -styles from Rich and rich-argparse. The `cmd2.rich_utils.set_theme()` function +styles from Rich and rich-argparse. The `cmd2.theme.set_theme()` function automatically updates rich-argparse's styles with any custom styles provided in your theme dictionary, so you don't have to modify them directly. diff --git a/docs/upgrades.md b/docs/upgrades.md index 2aa90181e..7f7ae30ea 100644 --- a/docs/upgrades.md +++ b/docs/upgrades.md @@ -52,7 +52,7 @@ periodically. customize its appearance using the `cmd2` theme. - **Customization**: Override the `Cmd2Style.COMPLETION_MENU_CURRENT` and - `Cmd2Style.COMPLETION_MENU_META` styles using `cmd2.rich_utils.set_theme()`. See + `Cmd2Style.COMPLETION_MENU_META` styles using `cmd2.theme.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 d437506b4..ef45c12d3 100755 --- a/examples/rich_theme.py +++ b/examples/rich_theme.py @@ -4,8 +4,7 @@ from rich.style import Style import cmd2 -import cmd2.rich_utils as ru -from cmd2 import Cmd2Style, Color +from cmd2 import Cmd2Style, Color, set_theme class ThemedApp(cmd2.Cmd): @@ -42,7 +41,7 @@ def __init__(self, *args, **kwargs): "traceback.exc_type": Style(color=Color.RED, bgcolor=Color.LIGHT_YELLOW3, bold=True), "argparse.args": Style(color=Color.AQUAMARINE3, underline=True), } - ru.set_theme(custom_theme) + set_theme(custom_theme) @cmd2.with_category("Theme Commands") def do_theme_show(self, _: cmd2.Statement): From acb4a519f3a9f7dee45e1cd7fb04c343adf81bb7 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Mon, 11 May 2026 22:00:29 -0400 Subject: [PATCH 7/8] Changed logic check. --- cmd2/theme.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cmd2/theme.py b/cmd2/theme.py index 1f1c2a22d..4b0efe210 100644 --- a/cmd2/theme.py +++ b/cmd2/theme.py @@ -185,7 +185,7 @@ def register_pt_mapping(style_name: str, pt_ui_names: str | Iterable[str]) -> No original_size = len(_PT_UI_MAP[style_name]) _PT_UI_MAP[style_name].update(n for n in pt_ui_names if n != style_name) - if len(_PT_UI_MAP[style_name]) > original_size: + if len(_PT_UI_MAP[style_name]) != original_size: changed = True # Trigger a re-sync if the theme is already initialized @@ -228,7 +228,7 @@ def unregister_pt_mapping(style_name: str, pt_ui_names: str | Iterable[str]) -> for name in pt_ui_names: _PT_UI_MAP[style_name].discard(name) - changed = len(_PT_UI_MAP[style_name]) < original_size + changed = len(_PT_UI_MAP[style_name]) != original_size # Trigger a re-sync if the theme is already initialized if changed and _PT_THEME is not None: From c5b0dae7fd65e2dd1043244e1c03198ae1ad04ee Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Mon, 11 May 2026 22:43:41 -0400 Subject: [PATCH 8/8] Removed some theme functions from __init__.py. --- cmd2/__init__.py | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/cmd2/__init__.py b/cmd2/__init__.py index 049da4749..059707711 100644 --- a/cmd2/__init__.py +++ b/cmd2/__init__.py @@ -55,15 +55,8 @@ from .string_utils import stylize from .styles import Cmd2Style from .theme import ( - get_pt_theme, get_theme, - register_pt_mapping, - register_synchronized_prefix, - register_synchronized_style, set_theme, - unregister_pt_mapping, - unregister_synchronized_prefix, - unregister_synchronized_style, ) from .utils import ( CustomCompletionSettings, @@ -118,15 +111,8 @@ # Styles "Cmd2Style", # Theme - "get_pt_theme", "get_theme", - "register_pt_mapping", - "register_synchronized_prefix", - "register_synchronized_style", "set_theme", - "unregister_pt_mapping", - "unregister_synchronized_prefix", - "unregister_synchronized_style", # Utilities "categorize", "CustomCompletionSettings",