Skip to content

Commit 390e795

Browse files
committed
Sync prompt-toolkit styles on demand using caching and LRU-backed lookups instead of callbacks
1 parent 5e2b4ac commit 390e795

6 files changed

Lines changed: 70 additions & 103 deletions

File tree

CHANGELOG.md

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -153,8 +153,6 @@ 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
158156
- Added ability to customize `prompt-toolkit` completion menu colors by overriding the following
159157
fields in the `cmd2` theme:
160158
- `Cmd2Style.COMPLETION_MENU` - Base style for the entire completion menu container (sets

cmd2/cmd2.py

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

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)
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, ...] = ()
533536

534537
# Create the main PromptSession
535538
self.bottom_toolbar = bottom_toolbar
@@ -724,35 +727,39 @@ def _should_continue_multiline(self) -> bool:
724727
# No macro found or already processed. The statement is complete.
725728
return False
726729

727-
def update_pt_style(self) -> None:
728-
"""Update the cached prompt_toolkit style."""
730+
def _get_pt_style(self) -> PtStyle:
731+
"""Return the prompt_toolkit style synchronized with the application theme."""
729732
theme = ru.get_theme()
730-
rich_menu_style = theme.styles.get(Cmd2Style.COMPLETION_MENU, "")
731-
rich_completion_style = theme.styles.get(Cmd2Style.COMPLETION_MENU_COMPLETION, "")
732-
rich_current_style = theme.styles.get(Cmd2Style.COMPLETION_MENU_CURRENT, "")
733-
rich_meta_style = theme.styles.get(Cmd2Style.COMPLETION_MENU_META, "")
734-
rich_meta_current_style = theme.styles.get(Cmd2Style.COMPLETION_MENU_META_CURRENT, "")
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-
}
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,
751746
)
752747

753-
def _get_pt_style(self) -> "PtStyle":
754-
"""Return the cached prompt_toolkit style."""
755-
return self.pt_style
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
756763

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

cmd2/pt_utils.py

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

33
import re
4-
import weakref
54
from collections.abc import (
65
Callable,
76
Iterable,
87
)
8+
from functools import lru_cache
99
from typing import (
1010
TYPE_CHECKING,
1111
Any,
@@ -75,6 +75,7 @@ def pt_filter_style(text: str | ANSI) -> str | ANSI:
7575
return text if isinstance(text, ANSI) else ANSI(text)
7676

7777

78+
@lru_cache(maxsize=256)
7879
def rich_to_pt_color(color: "Color | None") -> str:
7980
"""Convert a rich Color object to a prompt_toolkit color string."""
8081
if not color or color.is_default:
@@ -90,6 +91,7 @@ def rich_to_pt_color(color: "Color | None") -> str:
9091
return f"#{c.red:02x}{c.green:02x}{c.blue:02x}"
9192

9293

94+
@lru_cache(maxsize=1024)
9395
def rich_to_pt_style(rich_style: StyleType) -> str:
9496
"""Convert a rich Style object to a prompt_toolkit style string."""
9597
if not rich_style:
@@ -115,10 +117,8 @@ def rich_to_pt_style(rich_style: StyleType) -> str:
115117
if rich_style.blink is not None:
116118
parts.append("blink" if rich_style.blink else "noblink")
117119
if rich_style.reverse is not None:
118-
# prompt-toolkit uses 'reverse'
119120
parts.append("reverse" if rich_style.reverse else "noreverse")
120121
if rich_style.conceal is not None:
121-
# prompt-toolkit uses 'hidden' for Rich's 'conceal'
122122
parts.append("hidden" if rich_style.conceal else "nohidden")
123123
return " ".join(parts)
124124

@@ -263,18 +263,6 @@ def clear(self) -> None:
263263
self._loaded_strings.clear()
264264

265265

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

@@ -289,18 +277,29 @@ def __init__(
289277
super().__init__()
290278
self.cmd_app = cmd_app
291279

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

295284
def set_colors(self) -> None:
296-
"""Update colors from the current rich theme."""
297-
# Retrieve styles dynamically from the current theme
285+
"""Synchronize lexer colors with the application theme."""
298286
theme = ru.get_theme()
299-
self.command_color = rich_to_pt_style(theme.styles.get(Cmd2Style.LEXER_COMMAND, ""))
300-
self.alias_color = rich_to_pt_style(theme.styles.get(Cmd2Style.LEXER_ALIAS, ""))
301-
self.macro_color = rich_to_pt_style(theme.styles.get(Cmd2Style.LEXER_MACRO, ""))
302-
self.flag_color = rich_to_pt_style(theme.styles.get(Cmd2Style.LEXER_FLAG, ""))
303-
self.argument_color = rich_to_pt_style(theme.styles.get(Cmd2Style.LEXER_ARGUMENT, ""))
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)
304303

305304
def lex_document(self, document: Document) -> Callable[[int], Any]:
306305
"""Lex the document."""
@@ -309,6 +308,8 @@ def lex_document(self, document: Document) -> Callable[[int], Any]:
309308
exclude_tokens.update(self.cmd_app.statement_parser.terminators)
310309
arg_pattern = re.compile(r'(\s+)|(--?[^\s\'"]+)|("[^"]*"?|\'[^\']*\'?)|([^\s\'"]+)')
311310

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

cmd2/rich_utils.py

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

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-
322312

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

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

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

tests/test_pt_utils.py

Lines changed: 9 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -279,7 +279,8 @@ 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 changing the theme updates active lexers."""
282+
"""Test that the lexer uses current theme values."""
283+
mock_cmd_app.all_commands = ["help"]
283284
lexer = pt_utils.Cmd2Lexer(cast(Any, mock_cmd_app))
284285

285286
# Get the old color for command
@@ -295,7 +296,13 @@ def test_lexer_set_theme_runtime_update(self, mock_cmd_app):
295296
try:
296297
ru.set_theme(new_styles)
297298

298-
# Now verify the lexer's color was updated
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")]
299306
assert lexer.command_color != old_color
300307
assert "ansired" in lexer.command_color
301308
assert "ansiblack" in lexer.command_color
@@ -803,12 +810,3 @@ def test_rich_to_pt_style_nohidden_conceal(self):
803810
style = Style(conceal=False)
804811
pt_style = pt_utils.rich_to_pt_style(style)
805812
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: 0 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -349,26 +349,3 @@ 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)