summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--CHANGELOG.md3
-rw-r--r--cmd2/ansi.py28
-rw-r--r--cmd2/argparse_completer.py23
-rw-r--r--cmd2/cmd2.py130
-rw-r--r--cmd2/table_creator.py6
-rw-r--r--tests/test_ansi.py15
-rw-r--r--tests/test_argparse_completer.py23
7 files changed, 139 insertions, 89 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1e049bc9..a25e0930 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -14,11 +14,14 @@
* Removed `--silent` flag from `alias/macro create` since startup scripts can be run silently.
* Removed `--with_silent` flag from `alias/macro list` since startup scripts can be run silently.
* Removed `with_argparser_and_unknown_args` since it was deprecated in 1.3.0.
+ * Replaced `cmd2.Cmd.completion_header` with `cmd2.Cmd.formatted_completions`. See Enhancements
+ for description of this new class member.
* Enhancements
* Added support for custom tab completion and up-arrow input history to `cmd2.Cmd2.read_input`.
See [read_input.py](https://github.com/python-cmd2/cmd2/blob/master/examples/read_input.py)
for an example.
* Added `cmd2.exceptions.PassThroughException` to raise unhandled command exceptions instead of printing them.
+ * Added support for ANSI styles and newlines in tab completion results using `cmd2.Cmd.formatted_completions`.
## 1.5.0 (January 31, 2021)
* Bug Fixes
diff --git a/cmd2/ansi.py b/cmd2/ansi.py
index 59e25483..d0159629 100644
--- a/cmd2/ansi.py
+++ b/cmd2/ansi.py
@@ -184,15 +184,39 @@ def strip_style(text: str) -> str:
def style_aware_wcswidth(text: str) -> int:
"""
- Wrap wcswidth to make it compatible with strings that contains ANSI style sequences
+ Wrap wcswidth to make it compatible with strings that contains ANSI style sequences.
+ This is intended for single line strings. If text contains a newline, this
+ function will return -1. For multiline strings, call widest_line() instead.
:param text: the string being measured
- :return: the width of the string when printed to the terminal
+ :return: The width of the string when printed to the terminal if no errors occur.
+ If text contains characters with no absolute width (i.e. tabs),
+ then this function returns -1. Replace tabs with spaces before calling this.
"""
# Strip ANSI style sequences since they cause wcswidth to return -1
return wcswidth(strip_style(text))
+def widest_line(text: str) -> int:
+ """
+ Return the width of the widest line in a multiline string. This wraps style_aware_wcswidth()
+ so it handles ANSI style sequences and has the same restrictions on non-printable characters.
+
+ :param text: the string being measured
+ :return: The width of the string when printed to the terminal if no errors occur.
+ If text contains characters with no absolute width (i.e. tabs),
+ then this function returns -1. Replace tabs with spaces before calling this.
+ """
+ if not text:
+ return 0
+
+ lines_widths = [style_aware_wcswidth(line) for line in text.splitlines()]
+ if -1 in lines_widths:
+ return -1
+
+ return max(lines_widths)
+
+
def style_aware_write(fileobj: IO, msg: str) -> None:
"""
Write a string to a fileobject and strip its ANSI style sequences if required by allow_style setting
diff --git a/cmd2/argparse_completer.py b/cmd2/argparse_completer.py
index fbaa6107..a40a9e2e 100644
--- a/cmd2/argparse_completer.py
+++ b/cmd2/argparse_completer.py
@@ -9,7 +9,6 @@ See the header of argparse_custom.py for instructions on how to use these featur
import argparse
import inspect
import numbers
-import shutil
from collections import (
deque,
)
@@ -527,6 +526,7 @@ class ArgparseCompleter:
def _format_completions(self, arg_state: _ArgumentState, completions: List[Union[str, CompletionItem]]) -> List[str]:
# Check if the results are CompletionItems and that there aren't too many to display
if 1 < len(completions) <= self._cmd2_app.max_completion_items and isinstance(completions[0], CompletionItem):
+ four_spaces = ' '
# If the user has not already sorted the CompletionItems, then sort them before appending the descriptions
if not self._cmd2_app.matches_sorted:
@@ -549,30 +549,27 @@ class ArgparseCompleter:
if desc_header is None:
desc_header = DEFAULT_DESCRIPTIVE_HEADER
+ # Replace tabs with 4 spaces so we can calculate width
+ desc_header = desc_header.replace('\t', four_spaces)
+
# Calculate needed widths for the token and description columns of the table
token_width = ansi.style_aware_wcswidth(destination)
- desc_width = ansi.style_aware_wcswidth(desc_header)
+ desc_width = ansi.widest_line(desc_header)
for item in completions:
token_width = max(ansi.style_aware_wcswidth(item), token_width)
- desc_width = max(ansi.style_aware_wcswidth(item.description), desc_width)
-
- # Create a table that's over half the width of the terminal.
- # This will force readline to place each entry on its own line.
- min_width = int(shutil.get_terminal_size().columns * 0.6)
- base_width = SimpleTable.base_width(2)
- initial_width = base_width + token_width + desc_width
- if initial_width < min_width:
- desc_width += min_width - initial_width
+ # Replace tabs with 4 spaces so we can calculate width
+ item.description = item.description.replace('\t', four_spaces)
+ desc_width = max(ansi.widest_line(item.description), desc_width)
cols = list()
cols.append(Column(destination.upper(), width=token_width))
cols.append(Column(desc_header, width=desc_width))
hint_table = SimpleTable(cols, divider_char=None)
- self._cmd2_app.completion_header = hint_table.generate_header()
- self._cmd2_app.display_matches = [hint_table.generate_data_row([item, item.description]) for item in completions]
+ table_data = [[item, item.description] for item in completions]
+ self._cmd2_app.formatted_completions = hint_table.generate_table(table_data, row_spacing=0)
return completions
diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py
index d01dfaa0..1292eb93 100644
--- a/cmd2/cmd2.py
+++ b/cmd2/cmd2.py
@@ -467,8 +467,10 @@ class Cmd(cmd.Cmd):
# An optional hint which prints above tab completion suggestions
self.completion_hint = ''
- # Header which prints above CompletionItem tables
- self.completion_header = ''
+ # Already formatted completion results. If this is populated, then cmd2 will print it instead
+ # of using readline's columnized results. ANSI style sequences and newlines in tab completion
+ # results are supported by this member. ArgparseCompleter uses this to print tab completion tables.
+ self.formatted_completions = ''
# Used by complete() for readline tab completion
self.completion_matches = []
@@ -1118,7 +1120,7 @@ class Cmd(cmd.Cmd):
self.allow_appended_space = True
self.allow_closing_quote = True
self.completion_hint = ''
- self.completion_header = ''
+ self.formatted_completions = ''
self.completion_matches = []
self.display_matches = []
self.matches_delimited = False
@@ -1645,22 +1647,6 @@ class Cmd(cmd.Cmd):
return [cur_match + padding for cur_match in matches_to_display], len(padding)
- def _build_completion_metadata_string(self) -> str: # pragma: no cover
- """Build completion metadata string which can contain a hint and CompletionItem table header"""
- metadata = ''
-
- # Add hint if one exists and we are supposed to display it
- if self.always_show_hint and self.completion_hint:
- metadata += '\n' + self.completion_hint
-
- # Add table header if one exists
- if self.completion_header:
- if not metadata:
- metadata += '\n'
- metadata += '\n' + self.completion_header
-
- return metadata
-
def _display_matches_gnu_readline(
self, substitution: str, matches: List[str], longest_match_length: int
) -> None: # pragma: no cover
@@ -1673,45 +1659,55 @@ class Cmd(cmd.Cmd):
"""
if rl_type == RlType.GNU:
- # Check if we should show display_matches
- if self.display_matches:
- matches_to_display = self.display_matches
+ # Print hint if one exists and we are supposed to display it
+ hint_printed = False
+ if self.always_show_hint and self.completion_hint:
+ hint_printed = True
+ sys.stdout.write('\n' + self.completion_hint)
- # Recalculate longest_match_length for display_matches
- longest_match_length = 0
+ # Check if we already have formatted results to print
+ if self.formatted_completions:
+ if not hint_printed:
+ sys.stdout.write('\n')
+ sys.stdout.write('\n' + self.formatted_completions + '\n\n')
- for cur_match in matches_to_display:
- cur_length = ansi.style_aware_wcswidth(cur_match)
- if cur_length > longest_match_length:
- longest_match_length = cur_length
+ # Otherwise use readline's formatter
else:
- matches_to_display = matches
+ # Check if we should show display_matches
+ if self.display_matches:
+ matches_to_display = self.display_matches
- # Add padding for visual appeal
- matches_to_display, padding_length = self._pad_matches_to_display(matches_to_display)
- longest_match_length += padding_length
+ # Recalculate longest_match_length for display_matches
+ longest_match_length = 0
- # We will use readline's display function (rl_display_match_list()), so we
- # need to encode our string as bytes to place in a C array.
- encoded_substitution = bytes(substitution, encoding='utf-8')
- encoded_matches = [bytes(cur_match, encoding='utf-8') for cur_match in matches_to_display]
+ for cur_match in matches_to_display:
+ cur_length = ansi.style_aware_wcswidth(cur_match)
+ if cur_length > longest_match_length:
+ longest_match_length = cur_length
+ else:
+ matches_to_display = matches
+
+ # Add padding for visual appeal
+ matches_to_display, padding_length = self._pad_matches_to_display(matches_to_display)
+ longest_match_length += padding_length
- # rl_display_match_list() expects matches to be in argv format where
- # substitution is the first element, followed by the matches, and then a NULL.
- # noinspection PyCallingNonCallable,PyTypeChecker
- strings_array = (ctypes.c_char_p * (1 + len(encoded_matches) + 1))()
+ # We will use readline's display function (rl_display_match_list()), so we
+ # need to encode our string as bytes to place in a C array.
+ encoded_substitution = bytes(substitution, encoding='utf-8')
+ encoded_matches = [bytes(cur_match, encoding='utf-8') for cur_match in matches_to_display]
- # Copy in the encoded strings and add a NULL to the end
- strings_array[0] = encoded_substitution
- strings_array[1:-1] = encoded_matches
- strings_array[-1] = None
+ # rl_display_match_list() expects matches to be in argv format where
+ # substitution is the first element, followed by the matches, and then a NULL.
+ # noinspection PyCallingNonCallable,PyTypeChecker
+ strings_array = (ctypes.c_char_p * (1 + len(encoded_matches) + 1))()
- # Print any metadata like a hint or table header
- sys.stdout.write(self._build_completion_metadata_string())
+ # Copy in the encoded strings and add a NULL to the end
+ strings_array[0] = encoded_substitution
+ strings_array[1:-1] = encoded_matches
+ strings_array[-1] = None
- # Call readline's display function
- # rl_display_match_list(strings_array, number of completion matches, longest match length)
- readline_lib.rl_display_match_list(strings_array, len(encoded_matches), longest_match_length)
+ # rl_display_match_list(strings_array, number of completion matches, longest match length)
+ readline_lib.rl_display_match_list(strings_array, len(encoded_matches), longest_match_length)
# Redraw prompt and input line
rl_force_redisplay()
@@ -1724,20 +1720,34 @@ class Cmd(cmd.Cmd):
"""
if rl_type == RlType.PYREADLINE:
- # Check if we should show display_matches
- if self.display_matches:
- matches_to_display = self.display_matches
- else:
- matches_to_display = matches
+ # Print hint if one exists and we are supposed to display it
+ hint_printed = False
+ if self.always_show_hint and self.completion_hint:
+ hint_printed = True
+ readline.rl.mode.console.write('\n' + self.completion_hint)
- # Add padding for visual appeal
- matches_to_display, _ = self._pad_matches_to_display(matches_to_display)
+ # Check if we already have formatted results to print
+ if self.formatted_completions:
+ if not hint_printed:
+ readline.rl.mode.console.write('\n')
+ readline.rl.mode.console.write('\n' + self.formatted_completions + '\n\n')
+
+ # Redraw the prompt and input lines
+ rl_force_redisplay()
+
+ # Otherwise use pyreadline's formatter
+ else:
+ # Check if we should show display_matches
+ if self.display_matches:
+ matches_to_display = self.display_matches
+ else:
+ matches_to_display = matches
- # Print any metadata like a hint or table header
- readline.rl.mode.console.write(self._build_completion_metadata_string())
+ # Add padding for visual appeal
+ matches_to_display, _ = self._pad_matches_to_display(matches_to_display)
- # Display matches using actual display function. This also redraws the prompt and line.
- orig_pyreadline_display(matches_to_display)
+ # Display matches using actual display function. This also redraws the prompt and input lines.
+ orig_pyreadline_display(matches_to_display)
def _perform_completion(
self, text: str, line: str, begidx: int, endidx: int, custom_settings: Optional[utils.CustomCompletionSettings] = None
diff --git a/cmd2/table_creator.py b/cmd2/table_creator.py
index 3b0db7b3..d9377bc4 100644
--- a/cmd2/table_creator.py
+++ b/cmd2/table_creator.py
@@ -139,9 +139,7 @@ class TableCreator:
# For headers with the width not yet set, use the width of the
# widest line in the header or 1 if the header has no width
if col.width is None:
- line_widths = [ansi.style_aware_wcswidth(line) for line in col.header.splitlines()]
- line_widths.append(1)
- col.width = max(line_widths)
+ col.width = max(1, ansi.widest_line(col.header))
@staticmethod
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 @@ class TableCreator:
aligned_text = utils.align_text(wrapped_text, fill_char=fill_char, width=col.width, alignment=text_alignment)
lines = deque(aligned_text.splitlines())
- cell_width = max([ansi.style_aware_wcswidth(line) for line in lines])
+ cell_width = ansi.widest_line(aligned_text)
return lines, cell_width
def generate_row(
diff --git a/tests/test_ansi.py b/tests/test_ansi.py
index 7ebda497..1797a047 100644
--- a/tests/test_ansi.py
+++ b/tests/test_ansi.py
@@ -20,7 +20,20 @@ def test_strip_style():
def test_style_aware_wcswidth():
base_str = HELLO_WORLD
ansi_str = ansi.style(base_str, fg='green')
- assert ansi.style_aware_wcswidth(ansi_str) != len(ansi_str)
+ assert ansi.style_aware_wcswidth(HELLO_WORLD) == ansi.style_aware_wcswidth(ansi_str)
+
+ assert ansi.style_aware_wcswidth('i have a tab\t') == -1
+ assert ansi.style_aware_wcswidth('i have a newline\n') == -1
+
+
+def test_widest_line():
+ text = ansi.style('i have\n3 lines\nThis is the longest one', fg='green')
+ assert ansi.widest_line(text) == ansi.style_aware_wcswidth("This is the longest one")
+
+ text = "I'm just one line"
+ assert ansi.widest_line(text) == ansi.style_aware_wcswidth(text)
+
+ assert ansi.widest_line('i have a tab\t') == -1
def test_style_none():
diff --git a/tests/test_argparse_completer.py b/tests/test_argparse_completer.py
index efda7660..75f24b3e 100644
--- a/tests/test_argparse_completer.py
+++ b/tests/test_argparse_completer.py
@@ -24,6 +24,7 @@ from cmd2.utils import (
from .conftest import (
complete_tester,
+ normalize,
run_cmd,
)
@@ -720,8 +721,12 @@ def test_completion_items(ac_app, num_aliases, show_description):
assert len(ac_app.completion_matches) == num_aliases
assert len(ac_app.display_matches) == num_aliases
- # If show_description is True, the alias's value will be in the display text
- assert ('help' in ac_app.display_matches[0]) == show_description
+ assert bool(ac_app.formatted_completions) == show_description
+ if show_description:
+ # If show_description is True, the table will show both the alias name and result
+ first_result_line = normalize(ac_app.formatted_completions)[1]
+ assert 'fake_alias0' in first_result_line
+ assert 'help' in first_result_line
@pytest.mark.parametrize(
@@ -842,7 +847,7 @@ def test_completion_items_arg_header(ac_app):
begidx = endidx - len(text)
complete_tester(text, line, begidx, endidx, ac_app)
- assert "DESC_HEADER" in ac_app.completion_header
+ assert "DESC_HEADER" in normalize(ac_app.formatted_completions)[0]
# Test when metavar is a string
text = ''
@@ -851,7 +856,7 @@ def test_completion_items_arg_header(ac_app):
begidx = endidx - len(text)
complete_tester(text, line, begidx, endidx, ac_app)
- assert ac_app.STR_METAVAR in ac_app.completion_header
+ assert ac_app.STR_METAVAR in normalize(ac_app.formatted_completions)[0]
# Test when metavar is a tuple
text = ''
@@ -861,7 +866,7 @@ def test_completion_items_arg_header(ac_app):
# We are completing the first argument of this flag. The first element in the tuple should be the column header.
complete_tester(text, line, begidx, endidx, ac_app)
- assert ac_app.TUPLE_METAVAR[0].upper() in ac_app.completion_header
+ assert ac_app.TUPLE_METAVAR[0].upper() in normalize(ac_app.formatted_completions)[0]
text = ''
line = 'choices --tuple_metavar token_1 {}'.format(text)
@@ -870,7 +875,7 @@ def test_completion_items_arg_header(ac_app):
# We are completing the second argument of this flag. The second element in the tuple should be the column header.
complete_tester(text, line, begidx, endidx, ac_app)
- assert ac_app.TUPLE_METAVAR[1].upper() in ac_app.completion_header
+ assert ac_app.TUPLE_METAVAR[1].upper() in normalize(ac_app.formatted_completions)[0]
text = ''
line = 'choices --tuple_metavar token_1 token_2 {}'.format(text)
@@ -880,7 +885,7 @@ def test_completion_items_arg_header(ac_app):
# We are completing the third argument of this flag. It should still be the second tuple element
# in the column header since the tuple only has two strings in it.
complete_tester(text, line, begidx, endidx, ac_app)
- assert ac_app.TUPLE_METAVAR[1].upper() in ac_app.completion_header
+ assert ac_app.TUPLE_METAVAR[1].upper() in normalize(ac_app.formatted_completions)[0]
def test_completion_items_descriptive_header(ac_app):
@@ -895,7 +900,7 @@ def test_completion_items_descriptive_header(ac_app):
begidx = endidx - len(text)
complete_tester(text, line, begidx, endidx, ac_app)
- assert ac_app.CUSTOM_DESC_HEADER in ac_app.completion_header
+ assert ac_app.CUSTOM_DESC_HEADER in normalize(ac_app.formatted_completions)[0]
# This argument did not provide a descriptive header, so it should be DEFAULT_DESCRIPTIVE_HEADER
text = ''
@@ -904,7 +909,7 @@ def test_completion_items_descriptive_header(ac_app):
begidx = endidx - len(text)
complete_tester(text, line, begidx, endidx, ac_app)
- assert DEFAULT_DESCRIPTIVE_HEADER in ac_app.completion_header
+ assert DEFAULT_DESCRIPTIVE_HEADER in normalize(ac_app.formatted_completions)[0]
@pytest.mark.parametrize(