Skip to content

Commit c2d29d4

Browse files
authored
Enabled styled text for completion display and display_meta values. (#1577)
1 parent 054f172 commit c2d29d4

File tree

9 files changed

+205
-35
lines changed

9 files changed

+205
-35
lines changed

cmd2/argparse_completer.py

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@
1818
cast,
1919
)
2020

21+
from rich.text import Text
22+
2123
from .constants import INFINITY
2224
from .rich_utils import Cmd2GeneralConsole
2325

@@ -587,7 +589,7 @@ def _complete_flags(self, text: str, line: str, begidx: int, endidx: int, used_f
587589
return Completions(items)
588590

589591
def _format_completions(self, arg_state: _ArgumentState, completions: Completions) -> Completions:
590-
"""Format CompletionItems into hint table."""
592+
"""Format CompletionItems into completion table."""
591593
# Skip table generation for single results or if the list exceeds the
592594
# user-defined threshold for table display.
593595
if len(completions) < 2 or len(completions) > self._cmd2_app.max_completion_table_items:
@@ -611,7 +613,7 @@ def _format_completions(self, arg_state: _ArgumentState, completions: Completion
611613
# Determine if all display values are numeric so we can right-align them
612614
all_nums = all_display_numeric(completions.items)
613615

614-
# Build header row for the hint table
616+
# Build header row
615617
rich_columns: list[Column] = []
616618
rich_columns.append(Column(destination.upper(), justify="right" if all_nums else "left", no_wrap=True))
617619
table_header = cast(Sequence[str | Column] | None, arg_state.action.get_table_header()) # type: ignore[attr-defined]
@@ -621,12 +623,12 @@ def _format_completions(self, arg_state: _ArgumentState, completions: Completion
621623
column if isinstance(column, Column) else Column(column, overflow="fold") for column in table_header
622624
)
623625

624-
# Build the hint table
626+
# Add the data rows
625627
hint_table = Table(*rich_columns, box=SIMPLE_HEAD, show_edge=False, border_style=Cmd2Style.TABLE_BORDER)
626628
for item in completions:
627-
hint_table.add_row(item.display, *item.table_row)
629+
hint_table.add_row(Text.from_ansi(item.display), *item.table_row)
628630

629-
# Generate the hint table string
631+
# Generate the table string
630632
console = Cmd2GeneralConsole()
631633
with console.capture() as capture:
632634
console.print(hint_table, end="", soft_wrap=False)

cmd2/cmd2.py

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1189,12 +1189,20 @@ def allow_style_type(value: str) -> ru.AllowStyle:
11891189
f"must be {ru.AllowStyle.ALWAYS}, {ru.AllowStyle.NEVER}, or {ru.AllowStyle.TERMINAL} (case-insensitive)"
11901190
) from ex
11911191

1192+
settable_description = Text.assemble(
1193+
'Allow styled text in output (Options: ',
1194+
(str(ru.AllowStyle.ALWAYS), Style(bold=True)),
1195+
", ",
1196+
(str(ru.AllowStyle.NEVER), Style(bold=True)),
1197+
", ",
1198+
(str(ru.AllowStyle.TERMINAL), Style(bold=True)),
1199+
")",
1200+
)
11921201
self.add_settable(
11931202
Settable(
11941203
'allow_style',
11951204
allow_style_type,
1196-
'Allow ANSI text style sequences in output (valid values: '
1197-
f'{ru.AllowStyle.ALWAYS}, {ru.AllowStyle.NEVER}, {ru.AllowStyle.TERMINAL})',
1205+
ru.rich_text_to_string(settable_description),
11981206
self,
11991207
choices_provider=get_allow_style_choices,
12001208
)
@@ -1211,15 +1219,15 @@ def allow_style_type(value: str) -> ru.AllowStyle:
12111219
Settable(
12121220
'max_completion_table_items',
12131221
int,
1214-
"Maximum number of completion results allowed for a completion table to appear",
1222+
"Max results allowed to display a table",
12151223
self,
12161224
)
12171225
)
12181226
self.add_settable(
12191227
Settable(
12201228
'max_column_completion_results',
12211229
int,
1222-
"Maximum number of completion results to display in a single column",
1230+
"Max results to display in a single column",
12231231
self,
12241232
)
12251233
)
@@ -2496,11 +2504,13 @@ def _get_settable_choices(self) -> Choices:
24962504
items: list[CompletionItem] = []
24972505

24982506
for name, settable in self.settables.items():
2507+
value_str = str(settable.value)
24992508
table_row = [
2500-
str(settable.value),
2509+
value_str,
25012510
settable.description,
25022511
]
2503-
items.append(CompletionItem(name, display_meta=str(settable.value), table_row=table_row))
2512+
display_meta = f"[Current: {su.stylize(value_str, Style(bold=True))}] {settable.description}"
2513+
items.append(CompletionItem(name, display_meta=display_meta, table_row=table_row))
25042514

25052515
return Choices(items=items)
25062516

@@ -4414,7 +4424,7 @@ def do_set(self, args: argparse.Namespace) -> None:
44144424
settable_table.add_row(
44154425
param,
44164426
str(settable.value),
4417-
settable.description,
4427+
Text.from_ansi(settable.description),
44184428
)
44194429
self.last_result[param] = settable.value
44204430

cmd2/completion.py

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@
2121
overload,
2222
)
2323

24+
from . import string_utils as su
25+
2426
if TYPE_CHECKING: # pragma: no cover
2527
from .cmd2 import Cmd
2628
from .command_definition import CommandSet
@@ -64,15 +66,22 @@ class CompletionItem:
6466
text: str = ""
6567

6668
# Optional string for displaying the completion differently in the completion menu.
69+
# This can contain ANSI style sequences. A plain version is stored in display_plain.
6770
display: str = ""
6871

6972
# Optional meta information about completion which displays in the completion menu.
73+
# This can contain ANSI style sequences. A plain version is stored in display_meta_plain.
7074
display_meta: str = ""
7175

7276
# Optional row data for completion tables. Length must match the associated argparse
7377
# argument's table_header. This is stored internally as a tuple.
7478
table_row: Sequence[Any] = field(default_factory=tuple)
7579

80+
# Plain text versions of display fields (stripped of ANSI) for sorting/filtering.
81+
# These are set in __post_init__().
82+
display_plain: str = field(init=False)
83+
display_meta_plain: str = field(init=False)
84+
7685
def __post_init__(self) -> None:
7786
"""Finalize the object after initialization."""
7887
# Derive text from value if it wasn't explicitly provided
@@ -83,6 +92,11 @@ def __post_init__(self) -> None:
8392
if not self.display:
8493
object.__setattr__(self, "display", self.text)
8594

95+
# Pre-calculate plain text versions by stripping ANSI sequences.
96+
# These are stored as attributes for fast access during sorting/filtering.
97+
object.__setattr__(self, "display_plain", su.strip_style(self.display))
98+
object.__setattr__(self, "display_meta_plain", su.strip_style(self.display_meta))
99+
86100
# Make sure all table row objects are renderable by a Rich table.
87101
renderable_data = [obj if is_renderable(obj) else str(obj) for obj in self.table_row]
88102

@@ -140,10 +154,10 @@ def __post_init__(self) -> None:
140154
if not self.is_sorted:
141155
if all_display_numeric(unique_items):
142156
# Sort numerically
143-
unique_items.sort(key=lambda item: float(item.display))
157+
unique_items.sort(key=lambda item: float(item.display_plain))
144158
else:
145159
# Standard string sort
146-
unique_items.sort(key=lambda item: utils.DEFAULT_STR_SORT_KEY(item.display))
160+
unique_items.sort(key=lambda item: utils.DEFAULT_STR_SORT_KEY(item.display_plain))
147161

148162
object.__setattr__(self, "is_sorted", True)
149163

@@ -247,8 +261,8 @@ class Completions(CompletionResultsBase):
247261

248262

249263
def all_display_numeric(items: Collection[CompletionItem]) -> bool:
250-
"""Return True if items is non-empty and every item.display is a numeric string."""
251-
return bool(items) and all(NUMERIC_RE.match(item.display) for item in items)
264+
"""Return True if items is non-empty and every item.display_plain value is a numeric string."""
265+
return bool(items) and all(NUMERIC_RE.match(item.display_plain) for item in items)
252266

253267

254268
#############################################

cmd2/pt_utils.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
constants,
2525
utils,
2626
)
27+
from . import rich_utils as ru
2728

2829
if TYPE_CHECKING: # pragma: no cover
2930
from .cmd2 import Cmd
@@ -101,6 +102,9 @@ def get_completions(self, document: Document, _complete_event: object) -> Iterab
101102
buffer.cursor_right(search_text_length)
102103
return
103104

105+
# Determine if we should remove style from completion text
106+
remove_style = ru.ALLOW_STYLE == ru.AllowStyle.NEVER
107+
104108
# Return the completions
105109
for item in completions:
106110
# Set offset to the start of the current word to overwrite it with the completion
@@ -129,8 +133,8 @@ def get_completions(self, document: Document, _complete_event: object) -> Iterab
129133
yield Completion(
130134
match_text,
131135
start_position=start_position,
132-
display=item.display,
133-
display_meta=item.display_meta,
136+
display=item.display_plain if remove_style else ANSI(item.display),
137+
display_meta=item.display_meta_plain if remove_style else ANSI(item.display_meta),
134138
)
135139

136140

tests/scripts/postcmds.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
set allow_style Never
1+
set always_show_hint False

tests/scripts/precmds.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
set allow_style Always
1+
set always_show_hint True

tests/test_cmd2.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -465,11 +465,11 @@ def test_run_script_nested_run_scripts(base_app, request) -> None:
465465
expected = f"""
466466
{initial_run}
467467
_relative_run_script precmds.txt
468-
set allow_style Always
468+
set always_show_hint True
469469
help
470470
shortcuts
471471
_relative_run_script postcmds.txt
472-
set allow_style Never"""
472+
set always_show_hint False"""
473473
out, _err = run_cmd(base_app, 'history -s')
474474
assert out == normalize(expected)
475475

@@ -482,11 +482,11 @@ def test_runcmds_plus_hooks(base_app, request) -> None:
482482
base_app.runcmds_plus_hooks(['run_script ' + prefilepath, 'help', 'shortcuts', 'run_script ' + postfilepath])
483483
expected = f"""
484484
run_script {prefilepath}
485-
set allow_style Always
485+
set always_show_hint True
486486
help
487487
shortcuts
488488
run_script {postfilepath}
489-
set allow_style Never"""
489+
set always_show_hint False"""
490490

491491
out, _err = run_cmd(base_app, 'history -s')
492492
assert out == normalize(expected)
@@ -2349,9 +2349,9 @@ def test_get_settable_choices(base_app: cmd2.Cmd) -> None:
23492349
assert cur_settable is not None
23502350

23512351
str_value = str(cur_settable.value)
2352-
assert cur_choice.display_meta == str_value
2353-
assert cur_choice.table_row[0] == str_value
2354-
assert cur_choice.table_row[1] == cur_settable.description
2352+
assert str_value in cur_choice.display_meta
2353+
assert ru.rich_text_to_string(cur_choice.table_row[0]) == str_value
2354+
assert ru.rich_text_to_string(cur_choice.table_row[1]) == cur_settable.description
23552355

23562356

23572357
def test_completion_supported(base_app) -> None:

tests/test_completion.py

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
Completions,
2020
utils,
2121
)
22+
from cmd2.completion import all_display_numeric
2223

2324
from .conftest import (
2425
normalize,
@@ -877,6 +878,125 @@ def test_completions_iteration() -> None:
877878
assert list(reversed(completions)) == items[::-1]
878879

879880

881+
def test_numeric_sorting() -> None:
882+
"""Test that numbers and numeric strings are sorted numerically."""
883+
numbers = [5, 6, 4, 3, 7.2, 9.1]
884+
completions = Completions.from_values(numbers)
885+
assert [item.value for item in completions] == sorted(numbers)
886+
887+
number_strs = ["5", "6", "4", "3", "7.2", "9.1"]
888+
completions = Completions.from_values(number_strs)
889+
assert list(completions.to_strings()) == sorted(number_strs, key=float)
890+
891+
mixed = ["5", "6", "4", 3, "7.2", 9.1]
892+
completions = Completions.from_values(mixed)
893+
assert list(completions.to_strings()) == [str(v) for v in sorted(number_strs, key=float)]
894+
895+
896+
def test_is_sorted() -> None:
897+
"""Test that already sorted results are not re-sorted."""
898+
values = [5, 6, 4, 3]
899+
already_sorted = Completions.from_values(values, is_sorted=True)
900+
sorted_on_creation = Completions.from_values(values, is_sorted=False)
901+
902+
assert already_sorted.to_strings() != sorted_on_creation.to_strings()
903+
assert [item.value for item in already_sorted] == values
904+
905+
906+
@pytest.mark.parametrize(
907+
('values', 'all_nums'),
908+
[
909+
([2, 3], True),
910+
([2, 3.7], True),
911+
([2, "3"], True),
912+
([2.2, "3.4"], True),
913+
([2, "3g"], False),
914+
# The display_plain field strips off ANSI sequences
915+
(["\x1b[31m5\x1b[0m", "\x1b[32m9.2\x1b[0m"], True),
916+
(["\x1b[31mNOT_STRING\x1b[0m", "\x1b[32m9.2\x1b[0m"], False),
917+
],
918+
)
919+
def test_all_display_numeric(values: list[int | float | str], all_nums: bool) -> None:
920+
"""Test that all_display_numeric() evaluates the display_plain field."""
921+
922+
items = [CompletionItem(v) for v in values]
923+
assert all_display_numeric(items) == all_nums
924+
925+
926+
def test_remove_duplicates() -> None:
927+
"""Test that duplicate CompletionItems are removed."""
928+
929+
# Create items which alter the fields used in CompletionItem.__eq__().
930+
orig_item = CompletionItem(value="orig item", display="orig display", display_meta="orig meta")
931+
new_value = dataclasses.replace(orig_item, value="new value")
932+
new_text = dataclasses.replace(orig_item, text="new text")
933+
new_display = dataclasses.replace(orig_item, display="new display")
934+
new_meta = dataclasses.replace(orig_item, display_meta="new meta")
935+
936+
# Include each item twice.
937+
items = [orig_item, orig_item, new_value, new_value, new_text, new_text, new_display, new_display, new_meta, new_meta]
938+
completions = Completions(items)
939+
940+
# Make sure we have exactly 1 of each item.
941+
assert len(completions) == 5
942+
assert orig_item in completions
943+
assert new_value in completions
944+
assert new_text in completions
945+
assert new_display in completions
946+
assert new_meta in completions
947+
948+
949+
def test_plain_fields() -> None:
950+
"""Test the plain text fields in CompletionItem."""
951+
display = "\x1b[31mApple\x1b[0m"
952+
display_meta = "\x1b[32mA tasty apple\x1b[0m"
953+
954+
# Show that the plain fields remove the ANSI sequences.
955+
completion_item = CompletionItem("apple", display=display, display_meta=display_meta)
956+
assert completion_item.display == display
957+
assert completion_item.display_plain == "Apple"
958+
assert completion_item.display_meta == display_meta
959+
assert completion_item.display_meta_plain == "A tasty apple"
960+
961+
962+
def test_styled_completion_sort() -> None:
963+
"""Test that sorting is done with the display_plain field."""
964+
965+
# First sort with strings that include ANSI style sequences.
966+
red_apple = "\x1b[31mApple\x1b[0m"
967+
green_cherry = "\x1b[32mCherry\x1b[0m"
968+
blue_banana = "\x1b[34mBanana\x1b[0m"
969+
970+
# This sorts by ASCII: [31m (Red), [32m (Green), [34m (Blue)
971+
unsorted_strs = [blue_banana, red_apple, green_cherry]
972+
sorted_strs = sorted(unsorted_strs, key=utils.DEFAULT_STR_SORT_KEY)
973+
assert sorted_strs == [red_apple, green_cherry, blue_banana]
974+
975+
# Now create a Completions object with these values.
976+
unsorted_items = [
977+
CompletionItem("banana", display=blue_banana),
978+
CompletionItem("cherry", display=green_cherry),
979+
CompletionItem("apple", display=red_apple),
980+
]
981+
982+
completions = Completions(unsorted_items)
983+
984+
# Expected order: Apple (A), Banana (B), Cherry (C)
985+
expected_plain = ["Apple", "Banana", "Cherry"]
986+
expected_styled = [red_apple, blue_banana, green_cherry]
987+
988+
for index, item in enumerate(completions):
989+
# Prove the ANSI stripping worked correctly
990+
assert item.display_plain == expected_plain[index]
991+
992+
# Prove the sort order used the plain text, not the ANSI codes
993+
assert item.display == expected_styled[index]
994+
995+
# Prove the order of completions is not the same as the raw string sort order
996+
completion_displays = [item.display for item in completions]
997+
assert completion_displays != sorted_strs
998+
999+
8801000
# Used by redirect_complete tests
8811001
class RedirCompType(enum.Enum):
8821002
SHELL_CMD = (1,)

0 commit comments

Comments
 (0)