Skip to content

Commit e68b481

Browse files
committed
Merge branch 'main' into 1627-silence-settable
2 parents ba6003b + 715b57d commit e68b481

12 files changed

Lines changed: 536 additions & 30 deletions

File tree

CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,18 @@ prompt is displayed.
151151
- Added `Cmd2ArgumentParser.output_to()` context manager to temporarily set the output stream
152152
during `argparse` operations. This is helpful for directing output for functions like
153153
`parse_args()`, which default to `sys.stdout` and lack a `file` argument.
154+
- Added `cmd2.rich_utils.register_theme_update_callback` function to register callback functions
155+
to get called whenever `cmd2.rich_utils.set_theme` is called
156+
- Added ability to customize `prompt-toolkit` completion menu colors by overriding the following
157+
fields in the `cmd2` theme:
158+
- `Cmd2Style.COMPLETION_MENU` - Base style for the entire completion menu container (sets
159+
the background)
160+
- `Cmd2Style.COMPLETION_MENU_COMPLETION` -Style for an individual, non-selected completion
161+
item
162+
- `Cmd2Style.COMPLETION_MENU_CURRENT` - Style for the currently selected completion item
163+
- `Cmd2Style.COMPLETION_MENU_META` - Style for "meta" information shown alongside a
164+
completion
165+
- `Cmd2Style.COMPLETION_MENU_META_CURRENT`- Style for meta info of current item
154166
- Updated `set` command to consolidate its confirmation output into a single, colorized line.
155167
The confirmation now uses `pfeedback()`, allowing it to be silenced when the `quiet` settable
156168
is enabled.

cmd2/cmd2.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,8 +78,11 @@
7878
from prompt_toolkit.input import DummyInput, create_input
7979
from prompt_toolkit.key_binding import KeyBindings
8080
from prompt_toolkit.output import DummyOutput, create_output
81+
from prompt_toolkit.output.color_depth import ColorDepth
8182
from prompt_toolkit.patch_stdout import patch_stdout
8283
from prompt_toolkit.shortcuts import CompleteStyle, PromptSession, choice, set_title
84+
from prompt_toolkit.styles import DynamicStyle
85+
from prompt_toolkit.styles import Style as PtStyle
8386
from rich.console import (
8487
Group,
8588
JustifyMethod,
@@ -192,6 +195,7 @@ def __init__(self, msg: str = "") -> None:
192195
Cmd2History,
193196
Cmd2Lexer,
194197
pt_filter_style,
198+
rich_to_pt_style,
195199
)
196200
from .utils import (
197201
Settable,
@@ -523,6 +527,11 @@ def __init__(
523527
self._persistent_history_length = persistent_history_length
524528
self._initialize_history(persistent_history_file)
525529

530+
# Cache for prompt_toolkit completion menu styles
531+
self.pt_style: PtStyle
532+
self.update_pt_style()
533+
ru.register_theme_update_callback(self.update_pt_style)
534+
526535
# Create the main PromptSession
527536
self.bottom_toolbar = bottom_toolbar
528537
self.main_session = self._create_main_session(auto_suggest, completekey)
@@ -716,6 +725,36 @@ def _should_continue_multiline(self) -> bool:
716725
# No macro found or already processed. The statement is complete.
717726
return False
718727

728+
def update_pt_style(self) -> None:
729+
"""Update the cached prompt_toolkit style."""
730+
theme = ru.get_theme()
731+
rich_menu_style = theme.styles.get(Cmd2Style.COMPLETION_MENU, "")
732+
rich_completion_style = theme.styles.get(Cmd2Style.COMPLETION_MENU_COMPLETION, "")
733+
rich_current_style = theme.styles.get(Cmd2Style.COMPLETION_MENU_CURRENT, "")
734+
rich_meta_style = theme.styles.get(Cmd2Style.COMPLETION_MENU_META, "")
735+
rich_meta_current_style = theme.styles.get(Cmd2Style.COMPLETION_MENU_META_CURRENT, "")
736+
737+
menu_style = rich_to_pt_style(rich_menu_style)
738+
completion_style = rich_to_pt_style(rich_completion_style)
739+
current_style = rich_to_pt_style(rich_current_style)
740+
meta_style = rich_to_pt_style(rich_meta_style)
741+
meta_current_style = rich_to_pt_style(rich_meta_current_style)
742+
743+
self.pt_style = PtStyle.from_dict(
744+
{
745+
"completion-menu": menu_style,
746+
"completion-menu.completion": completion_style,
747+
"completion-menu.completion.current": current_style,
748+
"completion-menu.meta.completion": meta_style,
749+
"completion-menu.meta.completion.current": meta_current_style,
750+
"completion-menu.multi-column-meta": meta_current_style,
751+
}
752+
)
753+
754+
def _get_pt_style(self) -> "PtStyle":
755+
"""Return the cached prompt_toolkit style."""
756+
return self.pt_style
757+
719758
def _create_main_session(self, auto_suggest: bool, completekey: str) -> PromptSession[str]:
720759
"""Create and return the main PromptSession for the application.
721760
@@ -747,6 +786,7 @@ def _(event: Any) -> None: # pragma: no cover
747786
kwargs: dict[str, Any] = {
748787
"auto_suggest": AutoSuggestFromHistory() if auto_suggest else None,
749788
"bottom_toolbar": self.get_bottom_toolbar if self.bottom_toolbar else None,
789+
"color_depth": ColorDepth.TRUE_COLOR,
750790
"complete_style": CompleteStyle.MULTI_COLUMN,
751791
"complete_in_thread": True,
752792
"complete_while_typing": False,
@@ -757,6 +797,7 @@ def _(event: Any) -> None: # pragma: no cover
757797
"multiline": filters.Condition(self._should_continue_multiline),
758798
"prompt_continuation": self.continuation_prompt,
759799
"rprompt": self.get_rprompt,
800+
"style": DynamicStyle(self._get_pt_style),
760801
}
761802

762803
if self.stdin.isatty() and self.stdout.isatty():
@@ -3561,6 +3602,7 @@ def read_input(
35613602

35623603
temp_session: PromptSession[str] = PromptSession(
35633604
auto_suggest=self.main_session.auto_suggest,
3605+
color_depth=self.main_session.color_depth,
35643606
complete_style=self.main_session.complete_style,
35653607
complete_in_thread=self.main_session.complete_in_thread,
35663608
complete_while_typing=self.main_session.complete_while_typing,
@@ -3569,6 +3611,7 @@ def read_input(
35693611
key_bindings=self.main_session.key_bindings,
35703612
input=self.main_session.input,
35713613
output=self.main_session.output,
3614+
style=self.main_session.style,
35723615
)
35733616

35743617
return self._read_raw_input(prompt, temp_session)
@@ -3585,8 +3628,10 @@ def read_secret(
35853628
:raises Exception: any other exceptions raised by prompt()
35863629
"""
35873630
temp_session: PromptSession[str] = PromptSession(
3631+
color_depth=self.main_session.color_depth,
35883632
input=self.main_session.input,
35893633
output=self.main_session.output,
3634+
style=self.main_session.style,
35903635
)
35913636

35923637
return self._read_raw_input(prompt, temp_session, is_password=True)

cmd2/pt_utils.py

Lines changed: 98 additions & 15 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,
@@ -20,20 +21,44 @@
2021
from prompt_toolkit.formatted_text import ANSI
2122
from prompt_toolkit.history import History
2223
from prompt_toolkit.lexers import Lexer
24+
from rich.style import Style, StyleType
2325

2426
from . import (
2527
constants,
2628
utils,
2729
)
2830
from . import rich_utils as ru
2931
from . import string_utils as su
32+
from .styles import Cmd2Style
3033

3134
if TYPE_CHECKING: # pragma: no cover
35+
from rich.color import Color
36+
3237
from .cmd2 import Cmd
3338

3439

3540
BASE_DELIMITERS = " \t\n" + "".join(constants.QUOTES) + "".join(constants.REDIRECTION_CHARS)
3641

42+
# prompt_toolkit accepts these standard ANSI color names directly
43+
ANSI_NAMES = (
44+
"ansiblack",
45+
"ansired",
46+
"ansigreen",
47+
"ansiyellow",
48+
"ansiblue",
49+
"ansimagenta",
50+
"ansicyan",
51+
"ansiwhite",
52+
"ansibrightblack",
53+
"ansibrightred",
54+
"ansibrightgreen",
55+
"ansibrightyellow",
56+
"ansibrightblue",
57+
"ansibrightmagenta",
58+
"ansibrightcyan",
59+
"ansibrightwhite",
60+
)
61+
3762

3863
def pt_filter_style(text: str | ANSI) -> str | ANSI:
3964
"""Strip styles if disallowed by ru.ALLOW_STYLE. Otherwise return an ANSI object.
@@ -50,6 +75,54 @@ def pt_filter_style(text: str | ANSI) -> str | ANSI:
5075
return text if isinstance(text, ANSI) else ANSI(text)
5176

5277

78+
def rich_to_pt_color(color: "Color | None") -> str:
79+
"""Convert a rich Color object to a prompt_toolkit color string."""
80+
if not color or color.is_default:
81+
return "default"
82+
83+
# Use prompt_toolkit's 16 standard ansi color names if applicable.
84+
# This prevents overriding terminal themes with absolute RGB values.
85+
if color.number is not None and 0 <= color.number <= 15:
86+
return ANSI_NAMES[color.number]
87+
88+
# For 8-bit and truecolor, we fallback to hex RGB strings which prompt-toolkit supports natively
89+
c = color.get_truecolor()
90+
return f"#{c.red:02x}{c.green:02x}{c.blue:02x}"
91+
92+
93+
def rich_to_pt_style(rich_style: StyleType) -> str:
94+
"""Convert a rich Style object to a prompt_toolkit style string."""
95+
if not rich_style:
96+
return ""
97+
98+
if isinstance(rich_style, str):
99+
rich_style = Style.parse(rich_style)
100+
101+
parts = []
102+
103+
fg_color = rich_to_pt_color(rich_style.color)
104+
parts.append(f"fg:{fg_color}")
105+
106+
bg_color = rich_to_pt_color(rich_style.bgcolor)
107+
parts.append(f"bg:{bg_color}")
108+
109+
if rich_style.bold is not None:
110+
parts.append("bold" if rich_style.bold else "nobold")
111+
if rich_style.italic is not None:
112+
parts.append("italic" if rich_style.italic else "noitalic")
113+
if rich_style.underline is not None:
114+
parts.append("underline" if rich_style.underline else "nounderline")
115+
if rich_style.blink is not None:
116+
parts.append("blink" if rich_style.blink else "noblink")
117+
if rich_style.reverse is not None:
118+
# prompt-toolkit uses 'reverse'
119+
parts.append("reverse" if rich_style.reverse else "noreverse")
120+
if rich_style.conceal is not None:
121+
# prompt-toolkit uses 'hidden' for Rich's 'conceal'
122+
parts.append("hidden" if rich_style.conceal else "nohidden")
123+
return " ".join(parts)
124+
125+
53126
class Cmd2Completer(Completer):
54127
"""Completer that delegates to cmd2's completion logic."""
55128

@@ -190,34 +263,44 @@ def clear(self) -> None:
190263
self._loaded_strings.clear()
191264

192265

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+
193278
class Cmd2Lexer(Lexer):
194279
"""Lexer that highlights cmd2 command names, aliases, and macros."""
195280

196281
def __init__(
197282
self,
198283
cmd_app: "Cmd",
199-
command_color: str = "ansigreen",
200-
alias_color: str = "ansicyan",
201-
macro_color: str = "ansimagenta",
202-
flag_color: str = "ansired",
203-
argument_color: str = "ansiyellow",
204284
) -> None:
205285
"""Initialize the Lexer.
206286
207287
:param cmd_app: cmd2.Cmd instance
208-
:param command_color: color to use for commands, defaults to 'ansigreen'
209-
:param alias_color: color to use for aliases, defaults to 'ansicyan'
210-
:param macro_color: color to use for macros, defaults to 'ansimagenta'
211-
:param flag_color: color to use for flags, defaults to 'ansired'
212-
:param argument_color: color to use for arguments, defaults to 'ansiyellow'
213288
"""
214289
super().__init__()
215290
self.cmd_app = cmd_app
216-
self.command_color = command_color
217-
self.alias_color = alias_color
218-
self.macro_color = macro_color
219-
self.flag_color = flag_color
220-
self.argument_color = argument_color
291+
292+
_lexers.add(self)
293+
self.set_colors()
294+
295+
def set_colors(self) -> None:
296+
"""Update colors from the current rich theme."""
297+
# Retrieve styles dynamically from the current theme
298+
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, ""))
221304

222305
def lex_document(self, document: Document) -> Callable[[int], Any]:
223306
"""Lex the document."""

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.

cmd2/styles.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@
2222
For rich-argparse, the style names are defined in the
2323
`rich_argparse.RichHelpFormatter.styles` dictionary.
2424
25+
For prompt-toolkit default styles, see:
26+
https://github.com/prompt-toolkit/python-prompt-toolkit/blob/main/src/prompt_toolkit/styles/defaults.py
2527
"""
2628

2729
import sys
@@ -51,9 +53,19 @@ class Cmd2Style(StrEnum):
5153
"""
5254

5355
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)
57+
COMPLETION_MENU_COMPLETION = "cmd2.completion-menu.completion" # Style for an individual, non-selected completion item
58+
COMPLETION_MENU_CURRENT = "cmd2.completion-menu.completion.current" # Style for the currently selected completion item
59+
COMPLETION_MENU_META = "cmd2.completion-menu.meta.completion" # Style for meta information shown alongside a completion
60+
COMPLETION_MENU_META_CURRENT = "cmd2.completion-menu.meta.completion.current" # Style for meta information of current item
5461
ERROR = "cmd2.error" # Error text (used by perror())
5562
HELP_HEADER = "cmd2.help.header" # Help table header text
5663
HELP_LEADER = "cmd2.help.leader" # Text right before the help tables are listed
64+
LEXER_COMMAND = "cmd2.lexer.command" # Lexer color for commands
65+
LEXER_ALIAS = "cmd2.lexer.alias" # Lexer color for aliases
66+
LEXER_MACRO = "cmd2.lexer.macro" # Lexer color for macros
67+
LEXER_FLAG = "cmd2.lexer.flag" # Lexer color for flags
68+
LEXER_ARGUMENT = "cmd2.lexer.argument" # Lexer color for arguments
5769
SUCCESS = "cmd2.success" # Success text (used by psuccess())
5870
TABLE_BORDER = "cmd2.table_border" # Applied to cmd2's table borders
5971
WARNING = "cmd2.warning" # Warning text (used by pwarning())
@@ -63,9 +75,19 @@ class Cmd2Style(StrEnum):
6375
# Tightly coupled with the Cmd2Style enum.
6476
DEFAULT_CMD2_STYLES: dict[str, StyleType] = {
6577
Cmd2Style.COMMAND_LINE: Style(color=Color.CYAN, bold=True),
78+
Cmd2Style.COMPLETION_MENU: Style(color="#000000", bgcolor="#bbbbbb"), # prompt-toolkit default
79+
Cmd2Style.COMPLETION_MENU_COMPLETION: Style(), # prompt-toolkit default
80+
Cmd2Style.COMPLETION_MENU_CURRENT: Style(color=Color.GREEN, bgcolor=Color.BLACK), # This style swaps FG and BG colors
81+
Cmd2Style.COMPLETION_MENU_META: Style(color="#000000", bgcolor="#bbbbbb"), # prompt-toolkit default
82+
Cmd2Style.COMPLETION_MENU_META_CURRENT: Style(color=Color.BLACK, bgcolor=Color.BRIGHT_GREEN),
6683
Cmd2Style.ERROR: Style(color=Color.BRIGHT_RED),
6784
Cmd2Style.HELP_HEADER: Style(color=Color.BRIGHT_GREEN),
6885
Cmd2Style.HELP_LEADER: Style(color=Color.CYAN),
86+
Cmd2Style.LEXER_COMMAND: Style(color=Color.GREEN),
87+
Cmd2Style.LEXER_ALIAS: Style(color=Color.CYAN),
88+
Cmd2Style.LEXER_MACRO: Style(color=Color.MAGENTA),
89+
Cmd2Style.LEXER_FLAG: Style(color=Color.RED),
90+
Cmd2Style.LEXER_ARGUMENT: Style(color=Color.YELLOW),
6991
Cmd2Style.SUCCESS: Style(color=Color.GREEN),
7092
Cmd2Style.TABLE_BORDER: Style(color=Color.BRIGHT_GREEN),
7193
Cmd2Style.WARNING: Style(color=Color.BRIGHT_YELLOW),

docs/features/completion.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,13 @@ demonstration of how this is used.
116116
[read_input](https://github.com/python-cmd2/cmd2/blob/main/examples/read_input.py) example for a
117117
demonstration.
118118

119+
## Custom Completion Menu Colors
120+
121+
`cmd2` provides the ability to customize the foreground and background colors of the completion menu
122+
items and their associated help text. See
123+
[Customizing Completion Menu Colors](./theme.md#customizing-completion-menu-colors) in the Theme
124+
documentation for more details.
125+
119126
## For More Information
120127

121128
See [cmd2's argparse_utils API](../api/argparse_utils.md) for a more detailed discussion of argparse

0 commit comments

Comments
 (0)