diff --git a/CHANGELOG.md b/CHANGELOG.md index 462f6f035..b45ffd8a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -94,9 +94,9 @@ prompt is displayed. - `RawDescriptionCmd2HelpFormatter` - `RawTextCmd2HelpFormatter` - `TextGroup` - - Replaced the global `APP_THEME` constant in `rich_utils.py` with `get_theme()` and - `set_theme()` functions in `theme.py` to support lazy initialization and safer in-place - updates of the theme. + - Replaced the global `APP_THEME` constant in `rich_utils.py` with `get_theme()`, + `reset_theme()`, and `update_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/__init__.py b/cmd2/__init__.py index 059707711..f706c0560 100644 --- a/cmd2/__init__.py +++ b/cmd2/__init__.py @@ -56,7 +56,8 @@ from .styles import Cmd2Style from .theme import ( get_theme, - set_theme, + reset_theme, + update_theme, ) from .utils import ( CustomCompletionSettings, @@ -112,7 +113,8 @@ "Cmd2Style", # Theme "get_theme", - "set_theme", + "reset_theme", + "update_theme", # Utilities "categorize", "CustomCompletionSettings", diff --git a/cmd2/rich_utils.py b/cmd2/rich_utils.py index a8d757ff0..05430ef7a 100644 --- a/cmd2/rich_utils.py +++ b/cmd2/rich_utils.py @@ -107,8 +107,8 @@ def __repr__(self) -> str: class Cmd2HelpFormatter(RichHelpFormatter): """Custom help formatter to configure ordering of help text.""" - # Have our own copy of the styles so set_theme() can synchronize them with - # the cmd2 application theme without overwriting RichHelpFormatter's defaults. + # Create our own copy of the styles so cmd2 can synchronize them with + # the application theme without overwriting RichHelpFormatter's defaults. styles: ClassVar[dict[str, StyleType]] = DEFAULT_ARGPARSE_STYLES.copy() # Disable automatic highlighting in the help text. @@ -341,10 +341,10 @@ def __init__( "Passing 'force_interactive' is not allowed. Its behavior is controlled by the 'ALLOW_STYLE' setting." ) - # Don't allow a theme to be passed in, as it is controlled by get_theme() and set_theme(). - # Use set_theme() to set the global theme or use a temporary theme with console.use_theme(). + # Don't allow a theme to be passed in. Use update_theme() to modify 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().") + raise TypeError("Passing 'theme' is not allowed. Modify the global theme with update_theme().") # Store the configuration key used by cmd2 to cache this console. self._config_key = self._build_config_key(file=file, **kwargs) diff --git a/cmd2/styles.py b/cmd2/styles.py index 4590ab325..ea29a6e0a 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.theme.set_theme()` function +styles from Rich and rich-argparse. The `cmd2.theme.update_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/cmd2/theme.py b/cmd2/theme.py index 4b0efe210..d46660d7c 100644 --- a/cmd2/theme.py +++ b/cmd2/theme.py @@ -23,7 +23,10 @@ from typing import cast from prompt_toolkit.styles import Style as PtStyle -from rich.style import StyleType +from rich.style import ( + Style, + StyleType, +) from rich.theme import Theme from .pt_utils import rich_to_pt_style @@ -35,16 +38,16 @@ ) # The application-wide theme, defined using Rich's styling system. -# Use get_theme() to access it and set_theme() to modify it. +# Use get_theme() to access it. +# Use reset_theme() and update_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. +# Use get_pt_theme() to access it. _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() +# This allows developers to use application-specific style names in update_theme() # while ensuring the underlying prompt-toolkit UI is styled correctly. # Use register_pt_mapping() and unregister_pt_mapping() to manage these mappings. # @@ -72,52 +75,73 @@ def get_theme() -> Theme: """Get the application-wide Rich theme. Initializes it on the first call.""" if _THEME is None: - set_theme() + reset_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() + reset_theme() return cast(PtStyle, _PT_THEME) -def set_theme(styles: Mapping[str, StyleType] | None = None) -> None: - """Set the application-wide theme. +def reset_theme() -> None: + """Reset the application-wide theme to its initial state. - This function performs an in-place update of the existing Rich theme's + This function performs an in-place reset 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 + Changes are automatically propagated to all synchronized components. """ global _THEME # noqa: PLW0603 + + # Include default styles from cmd2, rich-argparse, and Rich. + styles = DEFAULT_CMD2_STYLES.copy() + styles.update(DEFAULT_ARGPARSE_STYLES) + default_theme = Theme(styles, inherit=True) + if _THEME is None: - _THEME = Theme() + # Initial assignment + _THEME = default_theme + else: + # Perform in-place reset to preserve existing references + _THEME.styles.clear() + _THEME.styles.update(default_theme.styles) + + _sync_all() + + +def update_theme(styles: Mapping[str, StyleType]) -> None: + """Update the existing 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. + + Changes are automatically propagated to all synchronized components. - # Start with a fresh copy of the default styles. - unparsed_styles: dict[str, StyleType] = {} - unparsed_styles.update(_create_default_theme().styles) + :param styles: mapping of style names to styles + """ + # Convert any string styles to Style objects + parsed_styles = {name: style if isinstance(style, Style) else Style.parse(style) for name, style in styles.items()} + + # Perform in-place update to preserve existing references + get_theme().styles.update(parsed_styles) - # Add the custom styles, which may contain unparsed strings - if styles is not None: - unparsed_styles.update(styles) + _sync_all() - # 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) +def _sync_all() -> None: + """Propagate the global theme to rich-argparse and prompt-toolkit. + + If the theme hasn't been initialized yet, this is a no-op. + """ + if _THEME is None: + return - # Synchronize rich-argparse styles with the main application theme. + # Synchronize rich-argparse styles for name in Cmd2HelpFormatter.styles.keys() & _THEME.styles.keys(): Cmd2HelpFormatter.styles[name] = _THEME.styles[name] @@ -126,11 +150,16 @@ def set_theme(styles: Mapping[str, StyleType] | None = None) -> None: def _sync_pt_theme() -> None: - """Build a new global PT style object based on the current Rich theme.""" - theme = get_theme() + """Build a new global prompt-toolkit style object based on the current Rich theme. + + If the theme hasn't been initialized yet, this is a no-op. + """ + if _THEME is None: + return + style_rules: list[tuple[str, str]] = [] - for name, rich_style in theme.styles.items(): + 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 @@ -149,16 +178,6 @@ def _sync_pt_theme() -> None: _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 | Iterable[str]) -> None: """Map a Rich theme style name to one or more prompt-toolkit UI components. diff --git a/docs/features/generating_output.md b/docs/features/generating_output.md index e69c28bbd..0cb791dc3 100644 --- a/docs/features/generating_output.md +++ b/docs/features/generating_output.md @@ -131,7 +131,7 @@ all colors available to your `cmd2` application. `cmd2` uses a `rich` [Theme](https://rich.readthedocs.io/en/stable/reference/theme.html) object to define styles for various UI elements. You can define your own custom theme using -[cmd2.rich_utils.set_theme][]. See the +[cmd2.theme.update_theme][]. See the [rich_theme.py](https://github.com/python-cmd2/cmd2/blob/main/examples/rich_theme.py) example for more information. diff --git a/docs/features/theme.md b/docs/features/theme.md index 87b9db819..5d10f18fd 100644 --- a/docs/features/theme.md +++ b/docs/features/theme.md @@ -1,7 +1,7 @@ # Theme `cmd2` provides the ability to configure an overall theme for your application using the -[cmd2.rich_utils.set_theme][] function. This is based on the +[cmd2.theme.update_theme][] function. This is based on the [rich.theme](https://rich.readthedocs.io/en/stable/reference/theme.html) container for style information. You can use this to brand your application and set an overall consistent look and feel that is appealing to your user base. diff --git a/docs/upgrades.md b/docs/upgrades.md index 7f7ae30ea..f6a247760 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.theme.set_theme()`. See + `Cmd2Style.COMPLETION_MENU_META` styles using `cmd2.theme.update_theme()`. See [Customizing Completion Menu Colors](features/theme.md#customizing-completion-menu-colors) for more details. @@ -136,7 +136,7 @@ The new [cmd2.rich_utils][] module provides common utility classes and functions use of `rich` within `cmd2` applications. Most of what is here is not intended to be user-facing. The one thing many `cmd2` application developers will likely be interested in using is the -[cmd2.rich_utils.set_theme][] function. See the +[cmd2.theme.update_theme][] function. See the [rich_theme.py](https://github.com/python-cmd2/cmd2/blob/main/examples/rich_theme.py) example for a demonstration for how to set a theme (color scheme) for your app. diff --git a/examples/rich_theme.py b/examples/rich_theme.py index ef45c12d3..314e78972 100755 --- a/examples/rich_theme.py +++ b/examples/rich_theme.py @@ -4,7 +4,7 @@ from rich.style import Style import cmd2 -from cmd2 import Cmd2Style, Color, set_theme +from cmd2 import Cmd2Style, Color, update_theme class ThemedApp(cmd2.Cmd): @@ -41,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), } - set_theme(custom_theme) + update_theme(custom_theme) @cmd2.with_category("Theme Commands") def do_theme_show(self, _: cmd2.Statement): diff --git a/tests/test_theme.py b/tests/test_theme.py index 2ce453cac..83cf115bb 100644 --- a/tests/test_theme.py +++ b/tests/test_theme.py @@ -10,21 +10,57 @@ Color, ) from cmd2 import rich_utils as ru +from cmd2.rich_utils import Cmd2HelpFormatter from cmd2.theme import ( get_pt_theme, get_theme, register_pt_mapping, register_synchronized_prefix, register_synchronized_style, - set_theme, + reset_theme, unregister_pt_mapping, unregister_synchronized_prefix, unregister_synchronized_style, + update_theme, ) -def test_set_theme() -> None: - # Save a cmd2, rich-argparse, rich-specific style, +def test_reset_theme() -> None: + # Save values for the initial theme + orig_cmd2_styles = dict(get_theme().styles) + orig_rich_argparse_styles = Cmd2HelpFormatter.styles.copy() + orig_pt_styles = get_pt_theme().style_rules + + # Overwrite values for 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 + + 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), + } + update_theme(new_styles) + + # Verify the theme has been updated + assert orig_cmd2_styles != get_theme().styles + assert orig_rich_argparse_styles != Cmd2HelpFormatter.styles + assert orig_pt_styles != get_pt_theme().style_rules + + # Verify that we can reset all values + reset_theme() + + assert orig_cmd2_styles == get_theme().styles + assert orig_rich_argparse_styles == Cmd2HelpFormatter.styles + assert orig_pt_styles == get_pt_theme().style_rules + + +def test_update_theme() -> None: + # Update values for 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" @@ -49,7 +85,7 @@ def test_set_theme() -> None: rich_style_key: Style(color=Color.DARK_GOLDENROD, bold=True), pt_mapped_key: Style(color=Color.BLUE), } - set_theme(new_styles) + update_theme(new_styles) # Verify theme styles have changed to our custom values. assert theme.styles[cmd2_style_key] != orig_cmd2_style @@ -81,6 +117,12 @@ def test_set_theme() -> None: assert expected == value + # Verify incremental update by checking that a previously set custom style is preserved + update_theme({"custom_style": "bold red"}) + update_theme({"another_style": "blue"}) + assert theme.styles["custom_style"] == Style(color=Color.RED, bold=True) + assert theme.styles["another_style"] == Style(color="blue") + def test_theme_is_none() -> None: """Test that get_theme() creates the theme when it's None.""" @@ -100,6 +142,32 @@ def test_pt_theme_is_none() -> None: assert get_pt_theme() is not None +def test_sync_all_theme_is_none() -> None: + """Test that calling _sync_all() when _THEME is None is a no-op.""" + from cmd2 import theme + + theme._THEME = None + theme._PT_THEME = None + + theme._sync_all() + + assert theme._THEME is None + assert theme._PT_THEME is None + + +def test_sync_pt_theme_theme_is_none() -> None: + """Test that calling _sync_pt_theme() when _THEME is None is a no-op.""" + from cmd2 import theme + + theme._THEME = None + theme._PT_THEME = None + + theme._sync_pt_theme() + + assert theme._THEME is None + assert theme._PT_THEME is None + + def test_register_pt_mapping() -> None: """Test style registration with UI mapping.""" style_name = "my_custom_scrollbar" @@ -107,7 +175,7 @@ def test_register_pt_mapping() -> None: register_pt_mapping(style_name, ui_name) - set_theme({style_name: Style(color=Color.BLUE)}) + update_theme({style_name: Style(color=Color.BLUE)}) pt_theme = get_pt_theme() @@ -156,7 +224,7 @@ def test_unregister_pt_mapping() -> None: ui_names = ["scroll1", "scroll2"] register_pt_mapping(style_name, ui_names) - set_theme({style_name: Style(color=Color.RED)}) + update_theme({style_name: Style(color=Color.RED)}) pt_theme = get_pt_theme() assert pt_theme.get_attrs_for_style_str("class:scroll1").color == "ansired" @@ -203,7 +271,7 @@ def test_register_synchronized_style() -> None: style_name = "simple_style" register_synchronized_style(style_name) - set_theme({style_name: Style(color=Color.RED)}) + update_theme({style_name: Style(color=Color.RED)}) # It should be available as a class:name pt_theme = get_pt_theme() @@ -230,7 +298,7 @@ def test_register_synchronized_prefix() -> None: prefix = "myapp." style_name = f"{prefix}prompt" - set_theme({style_name: Style(color=Color.GREEN)}) + update_theme({style_name: Style(color=Color.GREEN)}) # Initially the style is only in the Rich theme rich_theme = get_theme() @@ -258,7 +326,7 @@ def test_unregister_synchronized_prefix() -> None: prefix = "unregister." style_name = f"{prefix}prompt" - set_theme({style_name: Style(color=Color.GREEN)}) + update_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)