Skip to content

Commit 07b9a8c

Browse files
committed
Restored callback approach but retained LRU caching of pt styles.
1 parent 390e795 commit 07b9a8c

6 files changed

Lines changed: 101 additions & 67 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,8 @@ prompt is displayed.
153153
- Added `Cmd2ArgumentParser.output_to()` context manager to temporarily set the output stream
154154
during `argparse` operations. This is helpful for directing output for functions like
155155
`parse_args()`, which default to `sys.stdout` and lack a `file` argument.
156+
- Added `cmd2.rich_utils.register_theme_update_callback` function to register callback functions
157+
to get called whenever `cmd2.rich_utils.set_theme` is called
156158
- Added ability to customize `prompt-toolkit` completion menu colors by overriding the following
157159
fields in the `cmd2` theme:
158160
- `Cmd2Style.COMPLETION_MENU` - Base style for the entire completion menu container (sets

cmd2/cmd2.py

Lines changed: 30 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -526,13 +526,10 @@ def __init__(
526526
self._persistent_history_length = persistent_history_length
527527
self._initialize_history(persistent_history_file)
528528

529-
# Styles used in prompt_toolkit elements like completion menus.
530-
# This is initialized when first needed for rendering and is only
531-
# updated when the application theme changes. self._pt_style_key
532-
# is a tuple of the Rich styles used to build self._pt_style and
533-
# acts as a cache key to detect when the theme has changed.
534-
self._pt_style: PtStyle | None = None
535-
self._pt_style_key: tuple[Style, ...] = ()
529+
# Cache for prompt_toolkit completion menu styles
530+
self.pt_style: PtStyle
531+
self.update_pt_style()
532+
ru.register_theme_update_callback(self.update_pt_style)
536533

537534
# Create the main PromptSession
538535
self.bottom_toolbar = bottom_toolbar
@@ -727,39 +724,35 @@ def _should_continue_multiline(self) -> bool:
727724
# No macro found or already processed. The statement is complete.
728725
return False
729726

730-
def _get_pt_style(self) -> PtStyle:
731-
"""Return the prompt_toolkit style synchronized with the application theme."""
727+
def update_pt_style(self) -> None:
728+
"""Update the cached prompt_toolkit style."""
732729
theme = ru.get_theme()
733-
734-
completion_menu = theme.styles.get(Cmd2Style.COMPLETION_MENU, Style.null())
735-
completion_menu_completion = theme.styles.get(Cmd2Style.COMPLETION_MENU_COMPLETION, Style.null())
736-
completion_menu_current = theme.styles.get(Cmd2Style.COMPLETION_MENU_CURRENT, Style.null())
737-
completion_menu_meta = theme.styles.get(Cmd2Style.COMPLETION_MENU_META, Style.null())
738-
completion_menu_meta_current = theme.styles.get(Cmd2Style.COMPLETION_MENU_META_CURRENT, Style.null())
739-
740-
current_key = (
741-
completion_menu,
742-
completion_menu_completion,
743-
completion_menu_current,
744-
completion_menu_meta,
745-
completion_menu_meta_current,
730+
rich_menu_style = theme.styles.get(Cmd2Style.COMPLETION_MENU, Style.null())
731+
rich_completion_style = theme.styles.get(Cmd2Style.COMPLETION_MENU_COMPLETION, Style.null())
732+
rich_current_style = theme.styles.get(Cmd2Style.COMPLETION_MENU_CURRENT, Style.null())
733+
rich_meta_style = theme.styles.get(Cmd2Style.COMPLETION_MENU_META, Style.null())
734+
rich_meta_current_style = theme.styles.get(Cmd2Style.COMPLETION_MENU_META_CURRENT, Style.null())
735+
736+
menu_style = rich_to_pt_style(rich_menu_style)
737+
completion_style = rich_to_pt_style(rich_completion_style)
738+
current_style = rich_to_pt_style(rich_current_style)
739+
meta_style = rich_to_pt_style(rich_meta_style)
740+
meta_current_style = rich_to_pt_style(rich_meta_current_style)
741+
742+
self.pt_style = PtStyle.from_dict(
743+
{
744+
"completion-menu": menu_style,
745+
"completion-menu.completion": completion_style,
746+
"completion-menu.completion.current": current_style,
747+
"completion-menu.meta.completion": meta_style,
748+
"completion-menu.meta.completion.current": meta_current_style,
749+
"completion-menu.multi-column-meta": meta_current_style,
750+
}
746751
)
747752

748-
if self._pt_style is None or current_key != self._pt_style_key:
749-
self._pt_style_key = current_key
750-
751-
self._pt_style = PtStyle.from_dict(
752-
{
753-
"completion-menu": rich_to_pt_style(completion_menu),
754-
"completion-menu.completion": rich_to_pt_style(completion_menu_completion),
755-
"completion-menu.completion.current": rich_to_pt_style(completion_menu_current),
756-
"completion-menu.meta.completion": rich_to_pt_style(completion_menu_meta),
757-
"completion-menu.meta.completion.current": rich_to_pt_style(completion_menu_meta_current),
758-
"completion-menu.multi-column-meta": rich_to_pt_style(completion_menu_meta_current),
759-
}
760-
)
761-
762-
return self._pt_style
753+
def _get_pt_style(self) -> "PtStyle":
754+
"""Return the cached prompt_toolkit style."""
755+
return self.pt_style
763756

764757
def _create_main_session(self, auto_suggest: bool, completekey: str) -> PromptSession[str]:
765758
"""Create and return the main PromptSession for the application.

cmd2/pt_utils.py

Lines changed: 21 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""Utilities for integrating prompt_toolkit with cmd2."""
22

33
import re
4+
import weakref
45
from collections.abc import (
56
Callable,
67
Iterable,
@@ -263,6 +264,18 @@ def clear(self) -> None:
263264
self._loaded_strings.clear()
264265

265266

267+
_lexers: "weakref.WeakSet[Cmd2Lexer]" = weakref.WeakSet()
268+
269+
270+
def _update_lexer_colors() -> None:
271+
"""Update colors for all active lexers."""
272+
for lexer in _lexers:
273+
lexer.set_colors()
274+
275+
276+
ru.register_theme_update_callback(_update_lexer_colors)
277+
278+
266279
class Cmd2Lexer(Lexer):
267280
"""Lexer that highlights cmd2 command names, aliases, and macros."""
268281

@@ -277,29 +290,18 @@ def __init__(
277290
super().__init__()
278291
self.cmd_app = cmd_app
279292

280-
# Cache key used to detect when theme styles have changed
281-
self._style_key: tuple[Style, ...] = ()
293+
_lexers.add(self)
282294
self.set_colors()
283295

284296
def set_colors(self) -> None:
285-
"""Synchronize lexer colors with the application theme."""
297+
"""Update colors from the current rich theme."""
298+
# Retrieve styles dynamically from the current theme
286299
theme = ru.get_theme()
287-
288-
command_style = theme.styles.get(Cmd2Style.LEXER_COMMAND, Style.null())
289-
alias_style = theme.styles.get(Cmd2Style.LEXER_ALIAS, Style.null())
290-
macro_style = theme.styles.get(Cmd2Style.LEXER_MACRO, Style.null())
291-
flag_style = theme.styles.get(Cmd2Style.LEXER_FLAG, Style.null())
292-
argument_style = theme.styles.get(Cmd2Style.LEXER_ARGUMENT, Style.null())
293-
294-
current_key = (command_style, alias_style, macro_style, flag_style, argument_style)
295-
296-
if current_key != self._style_key:
297-
self._style_key = current_key
298-
self.command_color = rich_to_pt_style(command_style)
299-
self.alias_color = rich_to_pt_style(alias_style)
300-
self.macro_color = rich_to_pt_style(macro_style)
301-
self.flag_color = rich_to_pt_style(flag_style)
302-
self.argument_color = rich_to_pt_style(argument_style)
300+
self.command_color = rich_to_pt_style(theme.styles.get(Cmd2Style.LEXER_COMMAND, Style.null()))
301+
self.alias_color = rich_to_pt_style(theme.styles.get(Cmd2Style.LEXER_ALIAS, Style.null()))
302+
self.macro_color = rich_to_pt_style(theme.styles.get(Cmd2Style.LEXER_MACRO, Style.null()))
303+
self.flag_color = rich_to_pt_style(theme.styles.get(Cmd2Style.LEXER_FLAG, Style.null()))
304+
self.argument_color = rich_to_pt_style(theme.styles.get(Cmd2Style.LEXER_ARGUMENT, Style.null()))
303305

304306
def lex_document(self, document: Document) -> Callable[[int], Any]:
305307
"""Lex the document."""
@@ -308,8 +310,6 @@ def lex_document(self, document: Document) -> Callable[[int], Any]:
308310
exclude_tokens.update(self.cmd_app.statement_parser.terminators)
309311
arg_pattern = re.compile(r'(\s+)|(--?[^\s\'"]+)|("[^"]*"?|\'[^\']*\'?)|([^\s\'"]+)')
310312

311-
self.set_colors()
312-
313313
def highlight_args(text: str, tokens: list[tuple[str, str]]) -> None:
314314
"""Highlight arguments in a string."""
315315
for m in arg_pattern.finditer(text):

cmd2/rich_utils.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import re
55
import sys
66
from collections.abc import (
7+
Callable,
78
Iterator,
89
Mapping,
910
)
@@ -309,6 +310,15 @@ def __cmd2_argparse_help__(self, formatter: Cmd2HelpFormatter) -> Group:
309310
# The application-wide theme. Use get_theme() and set_theme() to access it.
310311
_APP_THEME: Theme | None = None
311312

313+
# Callbacks to be executed when the theme is updated
314+
_theme_update_callbacks: list[Callable[[], None]] = []
315+
316+
317+
def register_theme_update_callback(callback: Callable[[], None]) -> None:
318+
"""Register a callback to be executed when the theme is updated."""
319+
if callback not in _theme_update_callbacks:
320+
_theme_update_callbacks.append(callback)
321+
312322

313323
def get_theme() -> Theme:
314324
"""Get the application-wide theme. Initializes it on the first call."""
@@ -351,6 +361,10 @@ def set_theme(styles: Mapping[str, StyleType] | None = None) -> None:
351361
for name in Cmd2HelpFormatter.styles.keys() & theme.styles.keys():
352362
Cmd2HelpFormatter.styles[name] = theme.styles[name]
353363

364+
# Notify callbacks that the theme has been updated
365+
for callback in _theme_update_callbacks:
366+
callback()
367+
354368

355369
def _create_default_theme() -> Theme:
356370
"""Create a default theme for the application.

tests/test_pt_utils.py

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -279,8 +279,7 @@ def test_lex_document_multiline(self, mock_cmd_app):
279279
assert tokens1 == [("fg:ansiyellow bg:default", "help")]
280280

281281
def test_lexer_set_theme_runtime_update(self, mock_cmd_app):
282-
"""Test that the lexer uses current theme values."""
283-
mock_cmd_app.all_commands = ["help"]
282+
"""Test that changing the theme updates active lexers."""
284283
lexer = pt_utils.Cmd2Lexer(cast(Any, mock_cmd_app))
285284

286285
# Get the old color for command
@@ -296,13 +295,7 @@ def test_lexer_set_theme_runtime_update(self, mock_cmd_app):
296295
try:
297296
ru.set_theme(new_styles)
298297

299-
line = "help"
300-
document = Document(line)
301-
get_line = lexer.lex_document(document)
302-
tokens = get_line(0)
303-
304-
# Now verify the updated colors were used
305-
assert tokens == [("fg:ansired bg:ansiblack", "help")]
298+
# Now verify the lexer's color was updated
306299
assert lexer.command_color != old_color
307300
assert "ansired" in lexer.command_color
308301
assert "ansiblack" in lexer.command_color
@@ -810,3 +803,12 @@ def test_rich_to_pt_style_nohidden_conceal(self):
810803
style = Style(conceal=False)
811804
pt_style = pt_utils.rich_to_pt_style(style)
812805
assert "nohidden" in pt_style
806+
807+
808+
def test_update_lexer_colors() -> None:
809+
mock_lexer = Mock()
810+
pt_utils._lexers.add(mock_lexer)
811+
812+
pt_utils._update_lexer_colors()
813+
814+
mock_lexer.set_colors.assert_called_once()

tests/test_rich_utils.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -349,3 +349,26 @@ def side_effect(color: bool, **kwargs: Any) -> None:
349349
assert mock_set_color.call_count == 2
350350
mock_set_color.assert_any_call(True, file=sys.stdout)
351351
mock_set_color.assert_any_call(True)
352+
353+
354+
def test_register_theme_update_callback() -> None:
355+
# Clear callbacks for a clean state
356+
ru._theme_update_callbacks.clear()
357+
358+
# Define a dummy callback
359+
def my_callback() -> None:
360+
pass
361+
362+
ru.register_theme_update_callback(my_callback)
363+
assert my_callback in ru._theme_update_callbacks
364+
365+
# Test that registering the same callback again doesn't duplicate it
366+
ru.register_theme_update_callback(my_callback)
367+
assert len(ru._theme_update_callbacks) == 1
368+
369+
# Test that set_theme calls the callback
370+
mock_callback = mock.Mock()
371+
ru.register_theme_update_callback(mock_callback)
372+
373+
ru.set_theme()
374+
mock_callback.assert_called_once()

0 commit comments

Comments
 (0)