Skip to content

Commit

Permalink
Merge pull request #941 from python-cmd2/hint_bug
Browse files Browse the repository at this point in the history
Hint bug
  • Loading branch information
tleonhardt authored Jun 2, 2020
2 parents 8d9405a + 52e70d0 commit f626f86
Show file tree
Hide file tree
Showing 6 changed files with 266 additions and 91 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
## 1.1.0 (TBD, 2020)
* Bug Fixes
* Fixed issue where subcommand usage text could contain a subcommand alias instead of the actual name
* Fixed bug in `ArgparseCompleter` where `fill_width` could become negative if `token_width` was large
relative to the terminal width.
* Enhancements
* Made `ipy` consistent with `py` in the following ways
* `ipy` returns whether any of the commands run in it returned True to stop command loop
Expand Down
41 changes: 24 additions & 17 deletions cmd2/argparse_completer.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
CompletionItem,
generate_range_error,
)
from .table_creator import Column, SimpleTable
from .utils import CompletionError, basic_complete

# If no descriptive header is supplied, then this will be used instead
Expand Down Expand Up @@ -467,29 +468,35 @@ def _format_completions(self, action, completions: List[Union[str, CompletionIte

# If a metavar was defined, use that instead of the dest field
destination = action.metavar if action.metavar else action.dest

desc_header = getattr(action, ATTR_DESCRIPTIVE_COMPLETION_HEADER, None)
if desc_header is None:
desc_header = DEFAULT_DESCRIPTIVE_HEADER

# Calculate needed widths for the token and description columns of the table
token_width = ansi.style_aware_wcswidth(destination)
completions_with_desc = []
desc_width = ansi.style_aware_wcswidth(desc_header)

for item in completions:
item_width = ansi.style_aware_wcswidth(item)
if item_width > token_width:
token_width = item_width
token_width = max(ansi.style_aware_wcswidth(item), token_width)
desc_width = max(ansi.style_aware_wcswidth(item.description), desc_width)

term_size = shutil.get_terminal_size()
fill_width = int(term_size.columns * .6) - (token_width + 2)
for item in completions:
entry = '{: <{token_width}}{: <{fill_width}}'.format(item, item.description,
token_width=token_width + 2,
fill_width=fill_width)
completions_with_desc.append(entry)
# 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

desc_header = getattr(action, ATTR_DESCRIPTIVE_COMPLETION_HEADER, None)
if desc_header is None:
desc_header = DEFAULT_DESCRIPTIVE_HEADER
header = '\n{: <{token_width}}{}'.format(destination.upper(), desc_header, token_width=token_width + 2)
if initial_width < min_width:
desc_width += (min_width - initial_width)

cols = list()
cols.append(Column(destination.upper(), width=token_width))
cols.append(Column(desc_header, width=desc_width))

self._cmd2_app.completion_header = header
self._cmd2_app.display_matches = completions_with_desc
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]

return completions

Expand Down
4 changes: 2 additions & 2 deletions cmd2/cmd2.py
Original file line number Diff line number Diff line change
Expand Up @@ -1147,7 +1147,7 @@ def _display_matches_gnu_readline(self, substitution: str, matches: List[str],

# Print the header if one exists
if self.completion_header:
sys.stdout.write('\n' + self.completion_header)
sys.stdout.write('\n\n' + self.completion_header)

# Call readline's display function
# rl_display_match_list(strings_array, number of completion matches, longest match length)
Expand Down Expand Up @@ -1176,7 +1176,7 @@ def _display_matches_pyreadline(self, matches: List[str]) -> None: # pragma: no
# Print the header if one exists
if self.completion_header:
# noinspection PyUnresolvedReferences
readline.rl.mode.console.write('\n' + self.completion_header)
readline.rl.mode.console.write('\n\n' + self.completion_header)

# Display matches using actual display function. This also redraws the prompt and line.
orig_pyreadline_display(matches_to_display)
Expand Down
128 changes: 102 additions & 26 deletions cmd2/table_creator.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
The general use case is to inherit from TableCreator to create a table class with custom formatting options.
There are already implemented and ready-to-use examples of this below TableCreator's code.
"""
import copy
import functools
import io
from collections import deque
Expand Down Expand Up @@ -103,7 +104,7 @@ def __init__(self, cols: Sequence[Column], *, tab_width: int = 4) -> None:
:param tab_width: all tabs will be replaced with this many spaces. If a row's fill_char is a tab,
then it will be converted to one space.
"""
self.cols = cols
self.cols = copy.copy(cols)
self.tab_width = tab_width

@staticmethod
Expand Down Expand Up @@ -465,8 +466,9 @@ def __init__(self) -> None:
if cell_index == len(self.cols) - 1:
row_buf.write(post_line)

# Add a newline if this is not the last row
row_buf.write('\n')
# Add a newline if this is not the last line
if line_index < total_lines - 1:
row_buf.write('\n')

return row_buf.getvalue()

Expand All @@ -480,39 +482,78 @@ class SimpleTable(TableCreator):
Implementation of TableCreator which generates a borderless table with an optional divider row after the header.
This class can be used to create the whole table at once or one row at a time.
"""
# Spaces between cells
INTER_CELL = 2 * SPACE

def __init__(self, cols: Sequence[Column], *, tab_width: int = 4, divider_char: Optional[str] = '-') -> None:
"""
SimpleTable initializer
:param cols: column definitions for this table
:param tab_width: all tabs will be replaced with this many spaces. If a row's fill_char is a tab,
then it will be converted to one space.
:param divider_char: optional character used to build the header divider row. If provided, its value must meet the
same requirements as fill_char in TableCreator.generate_row() or exceptions will be raised.
Set this to None if you don't want a divider row. (Defaults to dash)
:param divider_char: optional character used to build the header divider row. Set this to None if you don't
want a divider row. Defaults to dash. (Cannot be a line breaking character)
:raises: TypeError if fill_char is more than one character (not including ANSI style sequences)
:raises: ValueError if text or fill_char contains an unprintable character
"""
if divider_char is not None:
if len(ansi.strip_style(divider_char)) != 1:
raise TypeError("Divider character must be exactly one character long")

divider_char_width = ansi.style_aware_wcswidth(divider_char)
if divider_char_width == -1:
raise (ValueError("Divider character is an unprintable character"))

super().__init__(cols, tab_width=tab_width)
self.divider_char = divider_char
self.empty_data = [EMPTY for _ in self.cols]

def generate_header(self) -> str:
@classmethod
def base_width(cls, num_cols: int) -> int:
"""
Generate header with an optional divider row
Utility method to calculate the display width required for a table before data is added to it.
This is useful when determining how wide to make your columns to have a table be a specific width.
:param num_cols: how many columns the table will have
:return: base width
:raises: ValueError if num_cols is less than 1
"""
if num_cols < 1:
raise ValueError("Column count cannot be less than 1")

data_str = SPACE
data_width = ansi.style_aware_wcswidth(data_str) * num_cols

tbl = cls([Column(data_str)] * num_cols)
data_row = tbl.generate_data_row([data_str] * num_cols)

return ansi.style_aware_wcswidth(data_row) - data_width

def total_width(self) -> int:
"""Calculate the total display width of this table"""
base_width = self.base_width(len(self.cols))
data_width = sum(col.width for col in self.cols)
return base_width + data_width

def generate_header(self) -> str:
"""Generate table header with an optional divider row"""
header_buf = io.StringIO()

# Create the header labels
if self.divider_char is None:
inter_cell = 2 * SPACE
else:
inter_cell = SPACE * ansi.style_aware_wcswidth(2 * self.divider_char)
header = self.generate_row(inter_cell=inter_cell)
header = self.generate_row(inter_cell=self.INTER_CELL)
header_buf.write(header)

# Create the divider. Use empty strings for the row_data.
# Create the divider if necessary
if self.divider_char is not None:
divider = self.generate_row(row_data=self.empty_data, fill_char=self.divider_char,
inter_cell=(2 * self.divider_char))
total_width = self.total_width()
divider_char_width = ansi.style_aware_wcswidth(self.divider_char)

# Make divider as wide as table and use padding if width of
# divider_char does not divide evenly into table width.
divider = self.divider_char * (total_width // divider_char_width)
divider += SPACE * (total_width % divider_char_width)

header_buf.write('\n')
header_buf.write(divider)
return header_buf.getvalue()

Expand All @@ -523,11 +564,7 @@ def generate_data_row(self, row_data: Sequence[Any]) -> str:
:param row_data: data with an entry for each column in the row
:return: data row string
"""
if self.divider_char is None:
inter_cell = 2 * SPACE
else:
inter_cell = SPACE * ansi.style_aware_wcswidth(2 * self.divider_char)
return self.generate_row(row_data=row_data, inter_cell=inter_cell)
return self.generate_row(row_data=row_data, inter_cell=self.INTER_CELL)

def generate_table(self, table_data: Sequence[Sequence[Any]], *,
include_header: bool = True, row_spacing: int = 1) -> str:
Expand All @@ -548,13 +585,17 @@ def generate_table(self, table_data: Sequence[Sequence[Any]], *,
if include_header:
header = self.generate_header()
table_buf.write(header)
if len(table_data) > 0:
table_buf.write('\n')

for index, row_data in enumerate(table_data):
if index > 0 and row_spacing > 0:
table_buf.write(row_spacing * '\n')

row = self.generate_data_row(row_data)
table_buf.write(row)
if index < len(table_data) - 1:
table_buf.write('\n')

return table_buf.getvalue()

Expand Down Expand Up @@ -586,6 +627,35 @@ def __init__(self, cols: Sequence[Column], *, tab_width: int = 4,
raise ValueError("Padding cannot be less than 0")
self.padding = padding

@classmethod
def base_width(cls, num_cols: int, *, column_borders: bool = True, padding: int = 1) -> int:
"""
Utility method to calculate the display width required for a table before data is added to it.
This is useful when determining how wide to make your columns to have a table be a specific width.
:param num_cols: how many columns the table will have
:param column_borders: if True, borders between columns will be included in the calculation (Defaults to True)
:param padding: number of spaces between text and left/right borders of cell
:return: base width
:raises: ValueError if num_cols is less than 1
"""
if num_cols < 1:
raise ValueError("Column count cannot be less than 1")

data_str = SPACE
data_width = ansi.style_aware_wcswidth(data_str) * num_cols

tbl = cls([Column(data_str)] * num_cols, column_borders=column_borders, padding=padding)
data_row = tbl.generate_data_row([data_str] * num_cols)

return ansi.style_aware_wcswidth(data_row) - data_width

def total_width(self) -> int:
"""Calculate the total display width of this table"""
base_width = self.base_width(len(self.cols), column_borders=self.column_borders, padding=self.padding)
data_width = sum(col.width for col in self.cols)
return base_width + data_width

def generate_table_top_border(self):
"""Generate a border which appears at the top of the header and data section"""
pre_line = '╔' + self.padding * '═'
Expand Down Expand Up @@ -643,10 +713,7 @@ def generate_table_bottom_border(self):
inter_cell=inter_cell, post_line=post_line)

def generate_header(self) -> str:
"""
Generate header
:return: header string
"""
"""Generate table header"""
pre_line = '║' + self.padding * SPACE

inter_cell = self.padding * SPACE
Expand All @@ -659,7 +726,9 @@ def generate_header(self) -> str:
# Create the bordered header
header_buf = io.StringIO()
header_buf.write(self.generate_table_top_border())
header_buf.write('\n')
header_buf.write(self.generate_row(pre_line=pre_line, inter_cell=inter_cell, post_line=post_line))
header_buf.write('\n')
header_buf.write(self.generate_header_bottom_border())

return header_buf.getvalue()
Expand Down Expand Up @@ -699,13 +768,17 @@ def generate_table(self, table_data: Sequence[Sequence[Any]], *, include_header:
top_border = self.generate_table_top_border()
table_buf.write(top_border)

table_buf.write('\n')

for index, row_data in enumerate(table_data):
if index > 0:
row_bottom_border = self.generate_row_bottom_border()
table_buf.write(row_bottom_border)
table_buf.write('\n')

row = self.generate_data_row(row_data)
table_buf.write(row)
table_buf.write('\n')

table_buf.write(self.generate_table_bottom_border())
return table_buf.getvalue()
Expand Down Expand Up @@ -797,9 +870,12 @@ def generate_table(self, table_data: Sequence[Sequence[Any]], *, include_header:
top_border = self.generate_table_top_border()
table_buf.write(top_border)

table_buf.write('\n')

for row_data in table_data:
row = self.generate_data_row(row_data)
table_buf.write(row)
table_buf.write('\n')

table_buf.write(self.generate_table_bottom_border())
return table_buf.getvalue()
2 changes: 1 addition & 1 deletion examples/table_creation.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ def __str__(self) -> str:

def ansi_print(text):
"""Wraps style_aware_write so style can be stripped if needed"""
ansi.style_aware_write(sys.stdout, text + '\n')
ansi.style_aware_write(sys.stdout, text + '\n\n')


def main():
Expand Down
Loading

0 comments on commit f626f86

Please sign in to comment.