Skip to content

Commit c1fb768

Browse files
committed
Added support to append to a style to pt mapping.
1 parent 93d99d9 commit c1fb768

3 files changed

Lines changed: 160 additions & 38 deletions

File tree

cmd2/styles.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ class Cmd2Style(StrEnum):
5353
"""
5454

5555
COMMAND_LINE = "cmd2.example" # Command line examples in help text
56-
COMPLETION_MENU = "cmd2.completion_menu" # Base style for the entire completion menu container (sets the background)
56+
COMPLETION_MENU = "cmd2.completion-menu" # Base style for the entire completion menu container (sets the background)
5757
COMPLETION_MENU_COMPLETION = "cmd2.completion-menu.completion" # Style for an individual, non-selected completion item
5858
COMPLETION_MENU_CURRENT = "cmd2.completion-menu.completion.current" # Style for the currently selected completion item
5959
COMPLETION_MENU_META = "cmd2.completion-menu.meta.completion" # Style for meta information shown alongside a completion

cmd2/theme.py

Lines changed: 65 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -44,15 +44,15 @@
4444
# This allows developers to use application-specific style names in set_theme()
4545
# while ensuring the underlying prompt-toolkit UI is styled correctly.
4646
# Use register_pt_mapping() to modify it.
47-
_PT_UI_MAP: dict[str, list[str]] = {
48-
Cmd2Style.COMPLETION_MENU: ["completion-menu"],
49-
Cmd2Style.COMPLETION_MENU_COMPLETION: ["completion-menu.completion"],
50-
Cmd2Style.COMPLETION_MENU_CURRENT: ["completion-menu.completion.current"],
51-
Cmd2Style.COMPLETION_MENU_META: ["completion-menu.meta.completion"],
52-
Cmd2Style.COMPLETION_MENU_META_CURRENT: [
47+
_PT_UI_MAP: dict[str, set[str]] = {
48+
Cmd2Style.COMPLETION_MENU: {"completion-menu"},
49+
Cmd2Style.COMPLETION_MENU_COMPLETION: {"completion-menu.completion"},
50+
Cmd2Style.COMPLETION_MENU_CURRENT: {"completion-menu.completion.current"},
51+
Cmd2Style.COMPLETION_MENU_META: {"completion-menu.meta.completion"},
52+
Cmd2Style.COMPLETION_MENU_META_CURRENT: {
5353
"completion-menu.meta.completion.current",
5454
"completion-menu.multi-column-meta",
55-
],
55+
},
5656
}
5757

5858
# Only Rich styles starting with one of these prefixes are synchronized to
@@ -154,23 +154,60 @@ def register_pt_mapping(style_name: str, pt_ui_names: str | list[str]) -> None:
154154
"""Map a Rich theme style name to one or more prompt-toolkit UI components.
155155
156156
This enables styling of prompt-toolkit's internal elements (such as the
157-
completion menu ) using styles in the application's Rich theme.
157+
completion menu) using styles in the application's Rich theme.
158158
159159
:param style_name: The style name used in the Rich theme.
160160
:param pt_ui_names: One or more prompt-toolkit UI component names (e.g., 'completion-menu').
161161
"""
162162
if isinstance(pt_ui_names, str):
163163
pt_ui_names = [pt_ui_names]
164164

165-
# Filter out UI names identical to the style name to avoid redundant registration.
165+
if style_name not in _PT_UI_MAP:
166+
_PT_UI_MAP[style_name] = set()
167+
168+
# Only add UI names that differ from the style name to avoid redundant rules in PtStyle
166169
unique_names = [n for n in pt_ui_names if n != style_name]
167-
_PT_UI_MAP[style_name] = unique_names
170+
_PT_UI_MAP[style_name].update(unique_names)
168171

169172
# Trigger a re-sync if the theme is already initialized
170173
if _PT_THEME is not None:
171174
_sync_pt_theme()
172175

173176

177+
def unregister_pt_mapping(style_name: str, pt_ui_names: str | list[str] | None = None) -> None:
178+
"""Remove one or more prompt-toolkit UI component mappings.
179+
180+
If pt_ui_names is None, all mappings for the given style_name are removed.
181+
182+
:param style_name: The style name used in the Rich theme.
183+
:param pt_ui_names: Specific UI component(s) to unmap, or None to clear all.
184+
"""
185+
if style_name not in _PT_UI_MAP:
186+
return
187+
188+
changed = False
189+
190+
if pt_ui_names is None:
191+
del _PT_UI_MAP[style_name]
192+
changed = True
193+
else:
194+
if isinstance(pt_ui_names, str):
195+
pt_ui_names = [pt_ui_names]
196+
197+
for name in pt_ui_names:
198+
_PT_UI_MAP[style_name].discard(name)
199+
changed = True
200+
201+
# Clean up the key if no mappings remain
202+
if not _PT_UI_MAP[style_name]:
203+
del _PT_UI_MAP[style_name]
204+
changed = True
205+
206+
# Trigger a re-sync if the theme is already initialized
207+
if changed and _PT_THEME is not None:
208+
_sync_pt_theme()
209+
210+
174211
def register_synchronized_prefix(prefix: str) -> None:
175212
"""Register a prefix whose styles will be synchronized to the prompt-toolkit theme.
176213
@@ -182,8 +219,22 @@ def register_synchronized_prefix(prefix: str) -> None:
182219
if not prefix:
183220
raise ValueError("Prefix cannot be empty.")
184221

185-
_SYNCHRONIZED_PREFIXES.add(prefix)
222+
if prefix not in _SYNCHRONIZED_PREFIXES:
223+
_SYNCHRONIZED_PREFIXES.add(prefix)
186224

187-
# Trigger a re-sync if the theme is already initialized
188-
if _PT_THEME is not None:
189-
_sync_pt_theme()
225+
# Trigger a re-sync if the theme is already initialized
226+
if _PT_THEME is not None:
227+
_sync_pt_theme()
228+
229+
230+
def unregister_synchronized_prefix(prefix: str) -> None:
231+
"""Stop synchronizing styles starting with the given prefix.
232+
233+
:param prefix: The prefix string to remove.
234+
"""
235+
if prefix in _SYNCHRONIZED_PREFIXES:
236+
_SYNCHRONIZED_PREFIXES.remove(prefix)
237+
238+
# Trigger a re-sync if the theme is already initialized
239+
if _PT_THEME is not None:
240+
_sync_pt_theme()

tests/test_theme.py

Lines changed: 94 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@
1616
register_pt_mapping,
1717
register_synchronized_prefix,
1818
set_theme,
19+
unregister_pt_mapping,
20+
unregister_synchronized_prefix,
1921
)
2022

2123

@@ -96,8 +98,83 @@ def test_pt_theme_is_none() -> None:
9698
assert get_pt_theme() is not None
9799

98100

101+
def test_register_pt_mapping() -> None:
102+
"""Test style to UI mapping."""
103+
style_name = "my_custom_scrollbar"
104+
ui_name = "scrollbar"
105+
106+
register_pt_mapping(style_name, ui_name)
107+
108+
set_theme({style_name: Style(color=Color.BLUE)})
109+
110+
pt_theme = get_pt_theme()
111+
112+
# Check that both the main style name and the UI name are mapped
113+
attrs_main = pt_theme.get_attrs_for_style_str(f"class:{style_name}")
114+
attrs_ui = pt_theme.get_attrs_for_style_str(f"class:{ui_name}")
115+
116+
assert attrs_main.color == "ansiblue"
117+
assert attrs_ui.color == "ansiblue"
118+
119+
120+
def test_register_pt_mapping_redundant() -> None:
121+
"""Test that redundant mappings are filtered out."""
122+
from cmd2 import theme
123+
124+
style_name = "redundant"
125+
register_pt_mapping(style_name, [style_name, "other"])
126+
127+
assert style_name not in theme._PT_UI_MAP[style_name]
128+
assert "other" in theme._PT_UI_MAP[style_name]
129+
130+
131+
def test_unregister_pt_mapping() -> None:
132+
"""Test unmapping styles from UI components."""
133+
from prompt_toolkit.styles import DEFAULT_ATTRS
134+
135+
style_name = "custom_scroll"
136+
ui_names = ["scroll1", "scroll2"]
137+
138+
register_pt_mapping(style_name, ui_names)
139+
set_theme({style_name: Style(color=Color.RED)})
140+
141+
pt_theme = get_pt_theme()
142+
assert pt_theme.get_attrs_for_style_str("class:scroll1").color == "ansired"
143+
assert pt_theme.get_attrs_for_style_str("class:scroll2").color == "ansired"
144+
145+
# Unregister one UI component
146+
unregister_pt_mapping(style_name, "scroll1")
147+
pt_theme = get_pt_theme()
148+
assert pt_theme.get_attrs_for_style_str("class:scroll1") == DEFAULT_ATTRS
149+
assert pt_theme.get_attrs_for_style_str("class:scroll2").color == "ansired"
150+
151+
# Unregister the entire style mapping
152+
unregister_pt_mapping(style_name)
153+
pt_theme = get_pt_theme()
154+
assert pt_theme.get_attrs_for_style_str("class:scroll2") == DEFAULT_ATTRS
155+
156+
157+
def test_unregister_pt_mapping_nonexistent() -> None:
158+
"""Test unregistering a mapping that doesn't exist."""
159+
unregister_pt_mapping("nonexistent_style")
160+
161+
162+
def test_unregister_pt_mapping_cleans_up_key() -> None:
163+
"""Test that unregistering the last UI component removes the style key."""
164+
style_name = "cleanup_style"
165+
ui_name = "cleanup_ui"
166+
register_pt_mapping(style_name, ui_name)
167+
168+
from cmd2 import theme
169+
170+
assert style_name in theme._PT_UI_MAP
171+
172+
unregister_pt_mapping(style_name, ui_name)
173+
assert style_name not in theme._PT_UI_MAP
174+
175+
99176
def test_register_synchronized_prefix() -> None:
100-
"""Test registering a custom synchronization prefix."""
177+
"""Test registering a custom synchronized prefix."""
101178
from prompt_toolkit.styles import DEFAULT_ATTRS
102179

103180
prefix = "myapp."
@@ -124,31 +201,25 @@ def test_register_synchronized_prefix_empty() -> None:
124201
register_synchronized_prefix("")
125202

126203

127-
def test_register_pt_mapping() -> None:
128-
"""Test style to UI mapping."""
129-
style_name = "my_custom_scrollbar"
130-
ui_name = "scrollbar"
131-
132-
register_pt_mapping(style_name, ui_name)
204+
def test_unregister_synchronized_prefix() -> None:
205+
"""Test unregistering a custom synchronized prefix."""
206+
from prompt_toolkit.styles import DEFAULT_ATTRS
133207

134-
set_theme({style_name: Style(color=Color.BLUE)})
208+
prefix = "unregister."
209+
style_name = f"{prefix}prompt"
210+
set_theme({style_name: Style(color=Color.GREEN)})
135211

212+
# Register the prefix and make sure the style has been synced to the pt theme
213+
register_synchronized_prefix(prefix)
136214
pt_theme = get_pt_theme()
215+
assert pt_theme.get_attrs_for_style_str(f"class:{style_name}").color == "ansigreen"
137216

138-
# Check that both the main style name and the UI name are mapped
139-
attrs_main = pt_theme.get_attrs_for_style_str(f"class:{style_name}")
140-
attrs_ui = pt_theme.get_attrs_for_style_str(f"class:{ui_name}")
141-
142-
assert attrs_main.color == "ansiblue"
143-
assert attrs_ui.color == "ansiblue"
144-
217+
# Unregister the prefix and make sure the style is no longer synced
218+
unregister_synchronized_prefix(prefix)
219+
new_pt_theme = get_pt_theme()
220+
assert new_pt_theme.get_attrs_for_style_str(f"class:{style_name}") == DEFAULT_ATTRS
145221

146-
def test_register_pt_mapping_redundant() -> None:
147-
"""Test that redundant mappings are filtered out."""
148-
from cmd2 import theme
149222

150-
style_name = "redundant"
151-
register_pt_mapping(style_name, [style_name, "other"])
152-
153-
assert style_name not in theme._PT_UI_MAP[style_name]
154-
assert "other" in theme._PT_UI_MAP[style_name]
223+
def test_unregister_synchronized_prefix_nonexistent() -> None:
224+
"""Test unregistering a prefix that doesn't exist."""
225+
unregister_synchronized_prefix("nonexistent_prefix.")

0 commit comments

Comments
 (0)