Skip to content

Commit cc9d96a

Browse files
authored
Merge pull request #1068 from python-cmd2/formatted_completions
Formatted completions
2 parents a9068de + 5bca14f commit cc9d96a

File tree

8 files changed

+144
-92
lines changed

8 files changed

+144
-92
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,15 @@
1515
* Removed `--silent` flag from `alias/macro create` since startup scripts can be run silently.
1616
* Removed `--with_silent` flag from `alias/macro list` since startup scripts can be run silently.
1717
* Removed `with_argparser_and_unknown_args` since it was deprecated in 1.3.0.
18+
* Replaced `cmd2.Cmd.completion_header` with `cmd2.Cmd.formatted_completions`. See Enhancements
19+
for description of this new class member.
1820
* Enhancements
1921
* Added support for custom tab completion and up-arrow input history to `cmd2.Cmd2.read_input`.
2022
See [read_input.py](https://github.com/python-cmd2/cmd2/blob/master/examples/read_input.py)
2123
for an example.
2224
* Added `cmd2.exceptions.PassThroughException` to raise unhandled command exceptions instead of printing them.
25+
* Added support for ANSI styles and newlines in tab completion results using `cmd2.Cmd.formatted_completions`.
26+
`cmd2` provides this capability automatically if you return argparse completion matches as `CompletionItems`.
2327

2428
## 1.5.0 (January 31, 2021)
2529
* Bug Fixes

cmd2/ansi.py

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -184,15 +184,39 @@ def strip_style(text: str) -> str:
184184

185185
def style_aware_wcswidth(text: str) -> int:
186186
"""
187-
Wrap wcswidth to make it compatible with strings that contains ANSI style sequences
187+
Wrap wcswidth to make it compatible with strings that contain ANSI style sequences.
188+
This is intended for single line strings. If text contains a newline, this
189+
function will return -1. For multiline strings, call widest_line() instead.
188190
189191
:param text: the string being measured
190-
:return: the width of the string when printed to the terminal
192+
:return: The width of the string when printed to the terminal if no errors occur.
193+
If text contains characters with no absolute width (i.e. tabs),
194+
then this function returns -1. Replace tabs with spaces before calling this.
191195
"""
192196
# Strip ANSI style sequences since they cause wcswidth to return -1
193197
return wcswidth(strip_style(text))
194198

195199

200+
def widest_line(text: str) -> int:
201+
"""
202+
Return the width of the widest line in a multiline string. This wraps style_aware_wcswidth()
203+
so it handles ANSI style sequences and has the same restrictions on non-printable characters.
204+
205+
:param text: the string being measured
206+
:return: The width of the string when printed to the terminal if no errors occur.
207+
If text contains characters with no absolute width (i.e. tabs),
208+
then this function returns -1. Replace tabs with spaces before calling this.
209+
"""
210+
if not text:
211+
return 0
212+
213+
lines_widths = [style_aware_wcswidth(line) for line in text.splitlines()]
214+
if -1 in lines_widths:
215+
return -1
216+
217+
return max(lines_widths)
218+
219+
196220
def style_aware_write(fileobj: IO, msg: str) -> None:
197221
"""
198222
Write a string to a fileobject and strip its ANSI style sequences if required by allow_style setting

cmd2/argparse_completer.py

Lines changed: 10 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@
99
import argparse
1010
import inspect
1111
import numbers
12-
import shutil
1312
from collections import (
1413
deque,
1514
)
@@ -527,6 +526,7 @@ def _complete_flags(self, text: str, line: str, begidx: int, endidx: int, matche
527526
def _format_completions(self, arg_state: _ArgumentState, completions: List[Union[str, CompletionItem]]) -> List[str]:
528527
# Check if the results are CompletionItems and that there aren't too many to display
529528
if 1 < len(completions) <= self._cmd2_app.max_completion_items and isinstance(completions[0], CompletionItem):
529+
four_spaces = 4 * ' '
530530

531531
# If the user has not already sorted the CompletionItems, then sort them before appending the descriptions
532532
if not self._cmd2_app.matches_sorted:
@@ -549,30 +549,27 @@ def _format_completions(self, arg_state: _ArgumentState, completions: List[Union
549549
if desc_header is None:
550550
desc_header = DEFAULT_DESCRIPTIVE_HEADER
551551

552+
# Replace tabs with 4 spaces so we can calculate width
553+
desc_header = desc_header.replace('\t', four_spaces)
554+
552555
# Calculate needed widths for the token and description columns of the table
553556
token_width = ansi.style_aware_wcswidth(destination)
554-
desc_width = ansi.style_aware_wcswidth(desc_header)
557+
desc_width = ansi.widest_line(desc_header)
555558

556559
for item in completions:
557560
token_width = max(ansi.style_aware_wcswidth(item), token_width)
558-
desc_width = max(ansi.style_aware_wcswidth(item.description), desc_width)
559-
560-
# Create a table that's over half the width of the terminal.
561-
# This will force readline to place each entry on its own line.
562-
min_width = int(shutil.get_terminal_size().columns * 0.6)
563-
base_width = SimpleTable.base_width(2)
564-
initial_width = base_width + token_width + desc_width
565561

566-
if initial_width < min_width:
567-
desc_width += min_width - initial_width
562+
# Replace tabs with 4 spaces so we can calculate width
563+
item.description = item.description.replace('\t', four_spaces)
564+
desc_width = max(ansi.widest_line(item.description), desc_width)
568565

569566
cols = list()
570567
cols.append(Column(destination.upper(), width=token_width))
571568
cols.append(Column(desc_header, width=desc_width))
572569

573570
hint_table = SimpleTable(cols, divider_char=None)
574-
self._cmd2_app.completion_header = hint_table.generate_header()
575-
self._cmd2_app.display_matches = [hint_table.generate_data_row([item, item.description]) for item in completions]
571+
table_data = [[item, item.description] for item in completions]
572+
self._cmd2_app.formatted_completions = hint_table.generate_table(table_data, row_spacing=0)
576573

577574
return completions
578575

cmd2/cmd2.py

Lines changed: 70 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -467,8 +467,10 @@ def __init__(
467467
# An optional hint which prints above tab completion suggestions
468468
self.completion_hint = ''
469469

470-
# Header which prints above CompletionItem tables
471-
self.completion_header = ''
470+
# Already formatted completion results. If this is populated, then cmd2 will print it instead
471+
# of using readline's columnized results. ANSI style sequences and newlines in tab completion
472+
# results are supported by this member. ArgparseCompleter uses this to print tab completion tables.
473+
self.formatted_completions = ''
472474

473475
# Used by complete() for readline tab completion
474476
self.completion_matches = []
@@ -1118,7 +1120,7 @@ def _reset_completion_defaults(self) -> None:
11181120
self.allow_appended_space = True
11191121
self.allow_closing_quote = True
11201122
self.completion_hint = ''
1121-
self.completion_header = ''
1123+
self.formatted_completions = ''
11221124
self.completion_matches = []
11231125
self.display_matches = []
11241126
self.matches_delimited = False
@@ -1645,22 +1647,6 @@ def _pad_matches_to_display(matches_to_display: List[str]) -> Tuple[List[str], i
16451647

16461648
return [cur_match + padding for cur_match in matches_to_display], len(padding)
16471649

1648-
def _build_completion_metadata_string(self) -> str: # pragma: no cover
1649-
"""Build completion metadata string which can contain a hint and CompletionItem table header"""
1650-
metadata = ''
1651-
1652-
# Add hint if one exists and we are supposed to display it
1653-
if self.always_show_hint and self.completion_hint:
1654-
metadata += '\n' + self.completion_hint
1655-
1656-
# Add table header if one exists
1657-
if self.completion_header:
1658-
if not metadata:
1659-
metadata += '\n'
1660-
metadata += '\n' + self.completion_header
1661-
1662-
return metadata
1663-
16641650
def _display_matches_gnu_readline(
16651651
self, substitution: str, matches: List[str], longest_match_length: int
16661652
) -> None: # pragma: no cover
@@ -1673,45 +1659,55 @@ def _display_matches_gnu_readline(
16731659
"""
16741660
if rl_type == RlType.GNU:
16751661

1676-
# Check if we should show display_matches
1677-
if self.display_matches:
1678-
matches_to_display = self.display_matches
1662+
# Print hint if one exists and we are supposed to display it
1663+
hint_printed = False
1664+
if self.always_show_hint and self.completion_hint:
1665+
hint_printed = True
1666+
sys.stdout.write('\n' + self.completion_hint)
16791667

1680-
# Recalculate longest_match_length for display_matches
1681-
longest_match_length = 0
1668+
# Check if we already have formatted results to print
1669+
if self.formatted_completions:
1670+
if not hint_printed:
1671+
sys.stdout.write('\n')
1672+
sys.stdout.write('\n' + self.formatted_completions + '\n\n')
16821673

1683-
for cur_match in matches_to_display:
1684-
cur_length = ansi.style_aware_wcswidth(cur_match)
1685-
if cur_length > longest_match_length:
1686-
longest_match_length = cur_length
1674+
# Otherwise use readline's formatter
16871675
else:
1688-
matches_to_display = matches
1676+
# Check if we should show display_matches
1677+
if self.display_matches:
1678+
matches_to_display = self.display_matches
16891679

1690-
# Add padding for visual appeal
1691-
matches_to_display, padding_length = self._pad_matches_to_display(matches_to_display)
1692-
longest_match_length += padding_length
1680+
# Recalculate longest_match_length for display_matches
1681+
longest_match_length = 0
16931682

1694-
# We will use readline's display function (rl_display_match_list()), so we
1695-
# need to encode our string as bytes to place in a C array.
1696-
encoded_substitution = bytes(substitution, encoding='utf-8')
1697-
encoded_matches = [bytes(cur_match, encoding='utf-8') for cur_match in matches_to_display]
1683+
for cur_match in matches_to_display:
1684+
cur_length = ansi.style_aware_wcswidth(cur_match)
1685+
if cur_length > longest_match_length:
1686+
longest_match_length = cur_length
1687+
else:
1688+
matches_to_display = matches
1689+
1690+
# Add padding for visual appeal
1691+
matches_to_display, padding_length = self._pad_matches_to_display(matches_to_display)
1692+
longest_match_length += padding_length
16981693

1699-
# rl_display_match_list() expects matches to be in argv format where
1700-
# substitution is the first element, followed by the matches, and then a NULL.
1701-
# noinspection PyCallingNonCallable,PyTypeChecker
1702-
strings_array = (ctypes.c_char_p * (1 + len(encoded_matches) + 1))()
1694+
# We will use readline's display function (rl_display_match_list()), so we
1695+
# need to encode our string as bytes to place in a C array.
1696+
encoded_substitution = bytes(substitution, encoding='utf-8')
1697+
encoded_matches = [bytes(cur_match, encoding='utf-8') for cur_match in matches_to_display]
17031698

1704-
# Copy in the encoded strings and add a NULL to the end
1705-
strings_array[0] = encoded_substitution
1706-
strings_array[1:-1] = encoded_matches
1707-
strings_array[-1] = None
1699+
# rl_display_match_list() expects matches to be in argv format where
1700+
# substitution is the first element, followed by the matches, and then a NULL.
1701+
# noinspection PyCallingNonCallable,PyTypeChecker
1702+
strings_array = (ctypes.c_char_p * (1 + len(encoded_matches) + 1))()
17081703

1709-
# Print any metadata like a hint or table header
1710-
sys.stdout.write(self._build_completion_metadata_string())
1704+
# Copy in the encoded strings and add a NULL to the end
1705+
strings_array[0] = encoded_substitution
1706+
strings_array[1:-1] = encoded_matches
1707+
strings_array[-1] = None
17111708

1712-
# Call readline's display function
1713-
# rl_display_match_list(strings_array, number of completion matches, longest match length)
1714-
readline_lib.rl_display_match_list(strings_array, len(encoded_matches), longest_match_length)
1709+
# rl_display_match_list(strings_array, number of completion matches, longest match length)
1710+
readline_lib.rl_display_match_list(strings_array, len(encoded_matches), longest_match_length)
17151711

17161712
# Redraw prompt and input line
17171713
rl_force_redisplay()
@@ -1724,20 +1720,34 @@ def _display_matches_pyreadline(self, matches: List[str]) -> None: # pragma: no
17241720
"""
17251721
if rl_type == RlType.PYREADLINE:
17261722

1727-
# Check if we should show display_matches
1728-
if self.display_matches:
1729-
matches_to_display = self.display_matches
1730-
else:
1731-
matches_to_display = matches
1723+
# Print hint if one exists and we are supposed to display it
1724+
hint_printed = False
1725+
if self.always_show_hint and self.completion_hint:
1726+
hint_printed = True
1727+
readline.rl.mode.console.write('\n' + self.completion_hint)
17321728

1733-
# Add padding for visual appeal
1734-
matches_to_display, _ = self._pad_matches_to_display(matches_to_display)
1729+
# Check if we already have formatted results to print
1730+
if self.formatted_completions:
1731+
if not hint_printed:
1732+
readline.rl.mode.console.write('\n')
1733+
readline.rl.mode.console.write('\n' + self.formatted_completions + '\n\n')
1734+
1735+
# Redraw the prompt and input lines
1736+
rl_force_redisplay()
1737+
1738+
# Otherwise use pyreadline's formatter
1739+
else:
1740+
# Check if we should show display_matches
1741+
if self.display_matches:
1742+
matches_to_display = self.display_matches
1743+
else:
1744+
matches_to_display = matches
17351745

1736-
# Print any metadata like a hint or table header
1737-
readline.rl.mode.console.write(self._build_completion_metadata_string())
1746+
# Add padding for visual appeal
1747+
matches_to_display, _ = self._pad_matches_to_display(matches_to_display)
17381748

1739-
# Display matches using actual display function. This also redraws the prompt and line.
1740-
orig_pyreadline_display(matches_to_display)
1749+
# Display matches using actual display function. This also redraws the prompt and input lines.
1750+
orig_pyreadline_display(matches_to_display)
17411751

17421752
def _perform_completion(
17431753
self, text: str, line: str, begidx: int, endidx: int, custom_settings: Optional[utils.CustomCompletionSettings] = None

cmd2/table_creator.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -139,9 +139,7 @@ def __init__(self, cols: Sequence[Column], *, tab_width: int = 4) -> None:
139139
# For headers with the width not yet set, use the width of the
140140
# widest line in the header or 1 if the header has no width
141141
if col.width is None:
142-
line_widths = [ansi.style_aware_wcswidth(line) for line in col.header.splitlines()]
143-
line_widths.append(1)
144-
col.width = max(line_widths)
142+
col.width = max(1, ansi.widest_line(col.header))
145143

146144
@staticmethod
147145
def _wrap_long_word(word: str, max_width: int, max_lines: Union[int, float], is_last_word: bool) -> Tuple[str, int, int]:
@@ -396,7 +394,7 @@ def _generate_cell_lines(self, cell_data: Any, is_header: bool, col: Column, fil
396394
aligned_text = utils.align_text(wrapped_text, fill_char=fill_char, width=col.width, alignment=text_alignment)
397395

398396
lines = deque(aligned_text.splitlines())
399-
cell_width = max([ansi.style_aware_wcswidth(line) for line in lines])
397+
cell_width = ansi.widest_line(aligned_text)
400398
return lines, cell_width
401399

402400
def generate_row(

examples/argparse_completion.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
Cmd2ArgumentParser,
1515
CompletionError,
1616
CompletionItem,
17+
ansi,
1718
with_argparser,
1819
)
1920

@@ -45,7 +46,9 @@ def choices_completion_error(self) -> List[str]:
4546
# noinspection PyMethodMayBeStatic
4647
def choices_completion_item(self) -> List[CompletionItem]:
4748
"""Return CompletionItem instead of strings. These give more context to what's being tab completed."""
48-
items = {1: "My item", 2: "Another item", 3: "Yet another item"}
49+
fancy_item = "These things can\ncontain newlines and\n"
50+
fancy_item += ansi.style("styled text!!", fg=ansi.fg.bright_yellow, underline=True)
51+
items = {1: "My item", 2: "Another item", 3: "Yet another item", 4: fancy_item}
4952
return [CompletionItem(item_id, description) for item_id, description in items.items()]
5053

5154
# noinspection PyMethodMayBeStatic
@@ -73,8 +76,6 @@ def choices_arg_tokens(self, arg_tokens: Dict[str, List[str]]) -> List[str]:
7376
# want the entire choices list showing in the usage text for this command.
7477
example_parser.add_argument('--choices', choices=food_item_strs, metavar="CHOICE", help="tab complete using choices")
7578

76-
example_parser.add_argument('--choices', choices=food_item_strs, metavar="CHOICE", help="tab complete using choices")
77-
7879
# Tab complete from choices provided by a choices_provider
7980
example_parser.add_argument(
8081
'--choices_provider', choices_provider=choices_provider, help="tab complete using a choices_provider"

tests/test_ansi.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,20 @@ def test_strip_style():
2020
def test_style_aware_wcswidth():
2121
base_str = HELLO_WORLD
2222
ansi_str = ansi.style(base_str, fg='green')
23-
assert ansi.style_aware_wcswidth(ansi_str) != len(ansi_str)
23+
assert ansi.style_aware_wcswidth(HELLO_WORLD) == ansi.style_aware_wcswidth(ansi_str)
24+
25+
assert ansi.style_aware_wcswidth('i have a tab\t') == -1
26+
assert ansi.style_aware_wcswidth('i have a newline\n') == -1
27+
28+
29+
def test_widest_line():
30+
text = ansi.style('i have\n3 lines\nThis is the longest one', fg='green')
31+
assert ansi.widest_line(text) == ansi.style_aware_wcswidth("This is the longest one")
32+
33+
text = "I'm just one line"
34+
assert ansi.widest_line(text) == ansi.style_aware_wcswidth(text)
35+
36+
assert ansi.widest_line('i have a tab\t') == -1
2437

2538

2639
def test_style_none():

0 commit comments

Comments
 (0)