Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
dfedeba
Add styles to theme for completion item and meta foreground and backg…
tleonhardt Apr 24, 2026
1a73c22
Moved to_pt_style function to pt_utils.py and added unit tests for it
tleonhardt Apr 25, 2026
0b5e443
Add a unit test for _get_pt_style method
tleonhardt Apr 25, 2026
43d5ee1
Merge branch 'main' into pt-colors
tleonhardt Apr 26, 2026
a6927c3
Change metadata default color to BRIGHT_GREEN instead of LIGHT_GREEN
tleonhardt Apr 26, 2026
339b73d
Added `rich_to_pt_color` conversion helper function
tleonhardt Apr 26, 2026
2bcda9a
Move ansi_names list to ANSI_NAMES tuple constant to cleanup function
tleonhardt Apr 26, 2026
f10f42b
Change rich_to_pt_style to take a StyleType instead of Style
tleonhardt Apr 26, 2026
3bc0070
Add unit test for case when a str is passed to rich-to_pt_style
tleonhardt Apr 26, 2026
77fea2f
Merge branch 'main' into pt-colors
tleonhardt Apr 29, 2026
a02e688
Cmd2Lexer colors are now part of the cmd2 rich theme
tleonhardt Apr 30, 2026
d580970
Merge branch 'main' into pt-colors
tleonhardt Apr 30, 2026
ae78cb4
Lexer colors now change if a user changes the theme during runtime
tleonhardt May 5, 2026
252ad08
rich_to_pt_style now also supports blink, reverse, and conceal/hidden…
tleonhardt May 5, 2026
812ccc5
Removed pointless try/except
tleonhardt May 5, 2026
e6b33f3
Apparently I forgot to hit save after changing this previously
tleonhardt May 5, 2026
0a0b541
Fix tests after removing noreverse for color conversion initializaiton
tleonhardt May 5, 2026
60ca50d
Enable true color support for both main and temporary prompt-toolkit …
tleonhardt May 5, 2026
91331c1
Added additional prompt-toolkit completion styles to cmd2's rich theme
tleonhardt May 5, 2026
55785f5
Temporary prompt-toolkit sessions now get style from main session
tleonhardt May 5, 2026
128f050
Add register_theme_update_callback function to rich_utils.py
tleonhardt May 5, 2026
633511d
Updated changelog with info on register_theme_update_callback
tleonhardt May 5, 2026
bbf8787
cmd2.Cmd now updates pt_style based on callback method registation
tleonhardt May 6, 2026
8563dc8
Also set color_depth in the secret prompt
tleonhardt May 6, 2026
da3ca4f
Use consistent terminology in adjacent comments
tleonhardt May 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,18 @@ prompt is displayed.
- Added `Cmd2ArgumentParser.output_to()` context manager to temporarily set the output stream
during `argparse` operations. This is helpful for directing output for functions like
`parse_args()`, which default to `sys.stdout` and lack a `file` argument.
- Added `cmd2.rich_utils.register_theme_update_callback` function to register callback functions
to get called whenever `cmd2.rich_utils.set_theme` is called
- Added ability to customize `prompt-toolkit` completion menu colors by overriding the following
fields in the `cmd2` theme:
- `Cmd2Style.COMPLETION_MENU` - Base style for the entire completion menu container (sets
the background)
- `Cmd2Style.COMPLETION_MENU_COMPLETION` -Style for an individual, non-selected completion
item
- `Cmd2Style.COMPLETION_MENU_CURRENT` - Style for the currently selected completion item
- `Cmd2Style.COMPLETION_MENU_META` - Style for "meta" information shown alongside a
completion
- `Cmd2Style.COMPLETION_MENU_META_CURRENT`- Style for meta info of current item

## 3.5.1 (April 24, 2026)

Expand Down
45 changes: 45 additions & 0 deletions cmd2/cmd2.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,8 +78,11 @@
from prompt_toolkit.input import DummyInput, create_input
from prompt_toolkit.key_binding import KeyBindings
from prompt_toolkit.output import DummyOutput, create_output
from prompt_toolkit.output.color_depth import ColorDepth
from prompt_toolkit.patch_stdout import patch_stdout
from prompt_toolkit.shortcuts import CompleteStyle, PromptSession, choice, set_title
from prompt_toolkit.styles import DynamicStyle
from prompt_toolkit.styles import Style as PtStyle
from rich.console import (
Group,
JustifyMethod,
Expand Down Expand Up @@ -192,6 +195,7 @@ def __init__(self, msg: str = "") -> None:
Cmd2History,
Cmd2Lexer,
pt_filter_style,
rich_to_pt_style,
)
from .utils import (
Settable,
Expand Down Expand Up @@ -523,6 +527,11 @@ def __init__(
self._persistent_history_length = persistent_history_length
self._initialize_history(persistent_history_file)

# Cache for prompt_toolkit completion menu styles
self.pt_style: PtStyle
self.update_pt_style()
ru.register_theme_update_callback(self.update_pt_style)

# Create the main PromptSession
self.bottom_toolbar = bottom_toolbar
self.main_session = self._create_main_session(auto_suggest, completekey)
Expand Down Expand Up @@ -716,6 +725,36 @@ def _should_continue_multiline(self) -> bool:
# No macro found or already processed. The statement is complete.
return False

def update_pt_style(self) -> None:
"""Update the cached prompt_toolkit style."""
theme = ru.get_theme()
rich_menu_style = theme.styles.get(Cmd2Style.COMPLETION_MENU, "")
rich_completion_style = theme.styles.get(Cmd2Style.COMPLETION_MENU_COMPLETION, "")
rich_current_style = theme.styles.get(Cmd2Style.COMPLETION_MENU_CURRENT, "")
rich_meta_style = theme.styles.get(Cmd2Style.COMPLETION_MENU_META, "")
rich_meta_current_style = theme.styles.get(Cmd2Style.COMPLETION_MENU_META_CURRENT, "")

menu_style = rich_to_pt_style(rich_menu_style)
completion_style = rich_to_pt_style(rich_completion_style)
current_style = rich_to_pt_style(rich_current_style)
meta_style = rich_to_pt_style(rich_meta_style)
meta_current_style = rich_to_pt_style(rich_meta_current_style)

self.pt_style = PtStyle.from_dict(
{
"completion-menu": menu_style,
"completion-menu.completion": completion_style,
"completion-menu.completion.current": current_style,
"completion-menu.meta.completion": meta_style,
"completion-menu.meta.completion.current": meta_current_style,
"completion-menu.multi-column-meta": meta_current_style,
}
)

def _get_pt_style(self) -> "PtStyle":
"""Return the cached prompt_toolkit style."""
return self.pt_style

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

Expand Down Expand Up @@ -747,6 +786,7 @@ def _(event: Any) -> None: # pragma: no cover
kwargs: dict[str, Any] = {
"auto_suggest": AutoSuggestFromHistory() if auto_suggest else None,
"bottom_toolbar": self.get_bottom_toolbar if self.bottom_toolbar else None,
"color_depth": ColorDepth.TRUE_COLOR,
"complete_style": CompleteStyle.MULTI_COLUMN,
"complete_in_thread": True,
"complete_while_typing": False,
Expand All @@ -757,6 +797,7 @@ def _(event: Any) -> None: # pragma: no cover
"multiline": filters.Condition(self._should_continue_multiline),
"prompt_continuation": self.continuation_prompt,
"rprompt": self.get_rprompt,
"style": DynamicStyle(self._get_pt_style),
}

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

temp_session: PromptSession[str] = PromptSession(
auto_suggest=self.main_session.auto_suggest,
color_depth=self.main_session.color_depth,
complete_style=self.main_session.complete_style,
complete_in_thread=self.main_session.complete_in_thread,
complete_while_typing=self.main_session.complete_while_typing,
Expand All @@ -3569,6 +3611,7 @@ def read_input(
key_bindings=self.main_session.key_bindings,
input=self.main_session.input,
output=self.main_session.output,
style=self.main_session.style,
)

return self._read_raw_input(prompt, temp_session)
Expand All @@ -3585,8 +3628,10 @@ def read_secret(
:raises Exception: any other exceptions raised by prompt()
"""
temp_session: PromptSession[str] = PromptSession(
color_depth=self.main_session.color_depth,
input=self.main_session.input,
output=self.main_session.output,
style=self.main_session.style,
)

return self._read_raw_input(prompt, temp_session, is_password=True)
Expand Down
113 changes: 98 additions & 15 deletions cmd2/pt_utils.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Utilities for integrating prompt_toolkit with cmd2."""

import re
import weakref
from collections.abc import (
Callable,
Iterable,
Expand All @@ -20,20 +21,44 @@
from prompt_toolkit.formatted_text import ANSI
from prompt_toolkit.history import History
from prompt_toolkit.lexers import Lexer
from rich.style import Style, StyleType

from . import (
constants,
utils,
)
from . import rich_utils as ru
from . import string_utils as su
from .styles import Cmd2Style

if TYPE_CHECKING: # pragma: no cover
from rich.color import Color

from .cmd2 import Cmd


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

# prompt_toolkit accepts these standard ANSI color names directly
ANSI_NAMES = (
"ansiblack",
"ansired",
"ansigreen",
"ansiyellow",
"ansiblue",
"ansimagenta",
"ansicyan",
"ansiwhite",
"ansibrightblack",
"ansibrightred",
"ansibrightgreen",
"ansibrightyellow",
"ansibrightblue",
"ansibrightmagenta",
"ansibrightcyan",
"ansibrightwhite",
)


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


def rich_to_pt_color(color: "Color | None") -> str:
"""Convert a rich Color object to a prompt_toolkit color string."""
if not color or color.is_default:
return "default"

# Use prompt_toolkit's 16 standard ansi color names if applicable.
# This prevents overriding terminal themes with absolute RGB values.
if color.number is not None and 0 <= color.number <= 15:
return ANSI_NAMES[color.number]
Comment thread
kmvanbrunt marked this conversation as resolved.

# For 8-bit and truecolor, we fallback to hex RGB strings which prompt-toolkit supports natively
c = color.get_truecolor()
return f"#{c.red:02x}{c.green:02x}{c.blue:02x}"
Comment thread
tleonhardt marked this conversation as resolved.


def rich_to_pt_style(rich_style: StyleType) -> str:
"""Convert a rich Style object to a prompt_toolkit style string."""
if not rich_style:
return ""

if isinstance(rich_style, str):
rich_style = Style.parse(rich_style)

parts = []

fg_color = rich_to_pt_color(rich_style.color)
parts.append(f"fg:{fg_color}")

bg_color = rich_to_pt_color(rich_style.bgcolor)
parts.append(f"bg:{bg_color}")

if rich_style.bold is not None:
Comment thread
tleonhardt marked this conversation as resolved.
parts.append("bold" if rich_style.bold else "nobold")
if rich_style.italic is not None:
parts.append("italic" if rich_style.italic else "noitalic")
if rich_style.underline is not None:
parts.append("underline" if rich_style.underline else "nounderline")
if rich_style.blink is not None:
parts.append("blink" if rich_style.blink else "noblink")
if rich_style.reverse is not None:
# prompt-toolkit uses 'reverse'
parts.append("reverse" if rich_style.reverse else "noreverse")
if rich_style.conceal is not None:
# prompt-toolkit uses 'hidden' for Rich's 'conceal'
parts.append("hidden" if rich_style.conceal else "nohidden")
return " ".join(parts)


class Cmd2Completer(Completer):
"""Completer that delegates to cmd2's completion logic."""

Expand Down Expand Up @@ -190,34 +263,44 @@ def clear(self) -> None:
self._loaded_strings.clear()


_lexers: "weakref.WeakSet[Cmd2Lexer]" = weakref.WeakSet()


def _update_lexer_colors() -> None:
"""Update colors for all active lexers."""
for lexer in _lexers:
lexer.set_colors()


ru.register_theme_update_callback(_update_lexer_colors)


class Cmd2Lexer(Lexer):
"""Lexer that highlights cmd2 command names, aliases, and macros."""

def __init__(
self,
cmd_app: "Cmd",
command_color: str = "ansigreen",
alias_color: str = "ansicyan",
macro_color: str = "ansimagenta",
flag_color: str = "ansired",
argument_color: str = "ansiyellow",
) -> None:
"""Initialize the Lexer.

:param cmd_app: cmd2.Cmd instance
:param command_color: color to use for commands, defaults to 'ansigreen'
:param alias_color: color to use for aliases, defaults to 'ansicyan'
:param macro_color: color to use for macros, defaults to 'ansimagenta'
:param flag_color: color to use for flags, defaults to 'ansired'
:param argument_color: color to use for arguments, defaults to 'ansiyellow'
"""
super().__init__()
self.cmd_app = cmd_app
self.command_color = command_color
self.alias_color = alias_color
self.macro_color = macro_color
self.flag_color = flag_color
self.argument_color = argument_color

_lexers.add(self)
self.set_colors()

def set_colors(self) -> None:
"""Update colors from the current rich theme."""
# Retrieve styles dynamically from the current theme
theme = ru.get_theme()
self.command_color = rich_to_pt_style(theme.styles.get(Cmd2Style.LEXER_COMMAND, ""))
Comment thread
tleonhardt marked this conversation as resolved.
self.alias_color = rich_to_pt_style(theme.styles.get(Cmd2Style.LEXER_ALIAS, ""))
self.macro_color = rich_to_pt_style(theme.styles.get(Cmd2Style.LEXER_MACRO, ""))
self.flag_color = rich_to_pt_style(theme.styles.get(Cmd2Style.LEXER_FLAG, ""))
self.argument_color = rich_to_pt_style(theme.styles.get(Cmd2Style.LEXER_ARGUMENT, ""))

def lex_document(self, document: Document) -> Callable[[int], Any]:
"""Lex the document."""
Expand Down
14 changes: 14 additions & 0 deletions cmd2/rich_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import re
import sys
from collections.abc import (
Callable,
Iterator,
Mapping,
)
Expand Down Expand Up @@ -309,6 +310,15 @@ def __cmd2_argparse_help__(self, formatter: Cmd2HelpFormatter) -> Group:
# The application-wide theme. Use get_theme() and set_theme() to access it.
_APP_THEME: Theme | None = None

# Callbacks to be executed when the theme is updated
_theme_update_callbacks: list[Callable[[], None]] = []


def register_theme_update_callback(callback: Callable[[], None]) -> None:
"""Register a callback to be executed when the theme is updated."""
if callback not in _theme_update_callbacks:
_theme_update_callbacks.append(callback)


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

# Notify callbacks that the theme has been updated
for callback in _theme_update_callbacks:
callback()


def _create_default_theme() -> Theme:
"""Create a default theme for the application.
Expand Down
22 changes: 22 additions & 0 deletions cmd2/styles.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@
For rich-argparse, the style names are defined in the
`rich_argparse.RichHelpFormatter.styles` dictionary.

For prompt-toolkit default styles, see:
https://github.com/prompt-toolkit/python-prompt-toolkit/blob/main/src/prompt_toolkit/styles/defaults.py
"""

import sys
Expand Down Expand Up @@ -51,9 +53,19 @@ class Cmd2Style(StrEnum):
"""

COMMAND_LINE = "cmd2.example" # Command line examples in help text
COMPLETION_MENU = "cmd2.completion_menu" # Base style for the entire completion menu container (sets the background)
COMPLETION_MENU_COMPLETION = "cmd2.completion-menu.completion" # Style for an individual, non-selected completion item
COMPLETION_MENU_CURRENT = "cmd2.completion-menu.completion.current" # Style for the currently selected completion item
COMPLETION_MENU_META = "cmd2.completion-menu.meta.completion" # Style for meta information shown alongside a completion
COMPLETION_MENU_META_CURRENT = "cmd2.completion-menu.meta.completion.current" # Style for meta information of current item
ERROR = "cmd2.error" # Error text (used by perror())
HELP_HEADER = "cmd2.help.header" # Help table header text
HELP_LEADER = "cmd2.help.leader" # Text right before the help tables are listed
LEXER_COMMAND = "cmd2.lexer.command" # Lexer color for commands
LEXER_ALIAS = "cmd2.lexer.alias" # Lexer color for aliases
LEXER_MACRO = "cmd2.lexer.macro" # Lexer color for macros
LEXER_FLAG = "cmd2.lexer.flag" # Lexer color for flags
LEXER_ARGUMENT = "cmd2.lexer.argument" # Lexer color for arguments
SUCCESS = "cmd2.success" # Success text (used by psuccess())
TABLE_BORDER = "cmd2.table_border" # Applied to cmd2's table borders
WARNING = "cmd2.warning" # Warning text (used by pwarning())
Expand All @@ -63,9 +75,19 @@ class Cmd2Style(StrEnum):
# Tightly coupled with the Cmd2Style enum.
DEFAULT_CMD2_STYLES: dict[str, StyleType] = {
Cmd2Style.COMMAND_LINE: Style(color=Color.CYAN, bold=True),
Cmd2Style.COMPLETION_MENU: Style(color="#000000", bgcolor="#bbbbbb"), # prompt-toolkit default
Cmd2Style.COMPLETION_MENU_COMPLETION: Style(), # prompt-toolkit default
Cmd2Style.COMPLETION_MENU_CURRENT: Style(color=Color.GREEN, bgcolor=Color.BLACK), # This style swaps FG and BG colors
Cmd2Style.COMPLETION_MENU_META: Style(color="#000000", bgcolor="#bbbbbb"), # prompt-toolkit default
Cmd2Style.COMPLETION_MENU_META_CURRENT: Style(color=Color.BLACK, bgcolor=Color.BRIGHT_GREEN),
Cmd2Style.ERROR: Style(color=Color.BRIGHT_RED),
Cmd2Style.HELP_HEADER: Style(color=Color.BRIGHT_GREEN),
Cmd2Style.HELP_LEADER: Style(color=Color.CYAN),
Cmd2Style.LEXER_COMMAND: Style(color=Color.GREEN),
Cmd2Style.LEXER_ALIAS: Style(color=Color.CYAN),
Cmd2Style.LEXER_MACRO: Style(color=Color.MAGENTA),
Cmd2Style.LEXER_FLAG: Style(color=Color.RED),
Cmd2Style.LEXER_ARGUMENT: Style(color=Color.YELLOW),
Cmd2Style.SUCCESS: Style(color=Color.GREEN),
Cmd2Style.TABLE_BORDER: Style(color=Color.BRIGHT_GREEN),
Cmd2Style.WARNING: Style(color=Color.BRIGHT_YELLOW),
Expand Down
7 changes: 7 additions & 0 deletions docs/features/completion.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,13 @@ demonstration of how this is used.
[read_input](https://github.com/python-cmd2/cmd2/blob/main/examples/read_input.py) example for a
demonstration.

## Custom Completion Menu Colors

`cmd2` provides the ability to customize the foreground and background colors of the completion menu
items and their associated help text. See
[Customizing Completion Menu Colors](./theme.md#customizing-completion-menu-colors) in the Theme
documentation for more details.

## For More Information

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