Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@
* Enhancements
* Flushing stderr when setting the window title and printing alerts for better responsiveness in cases where
stderr is not unbuffered.
* Added function to truncate a single line to fit within a given display width. `cmd2.utils.truncate_line`
supports characters with display widths greater than 1 and ANSI style sequences.
* Added line truncation support to `cmd2.utils` text alignment functions.

## 0.9.23 (January 9, 2020)
* Bug Fixes
Expand Down
85 changes: 71 additions & 14 deletions cmd2/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -638,7 +638,7 @@ class TextAlignment(Enum):


def align_text(text: str, alignment: TextAlignment, *, fill_char: str = ' ',
width: Optional[int] = None, tab_width: int = 4) -> str:
width: Optional[int] = None, tab_width: int = 4, truncate: bool = False) -> str:
"""
Align text for display within a given width. Supports characters with display widths greater than 1.
ANSI style sequences are safely ignored and do not count toward the display width. This means colored text is
Expand All @@ -652,15 +652,24 @@ def align_text(text: str, alignment: TextAlignment, *, fill_char: str = ' ',
:param width: display width of the aligned text. Defaults to width of the terminal.
:param tab_width: any tabs in the text will be replaced with this many spaces. if fill_char is a tab, then it will
be converted to a space.
:param truncate: if True, then each line will be shortened to fit within the display width. The truncated
portions are replaced by a '…' character. Defaults to False.
:return: aligned text
:raises: TypeError if fill_char is more than one character
ValueError if text or fill_char contains an unprintable character
ValueError if width is less than 1
"""
import io
import shutil

from . import ansi

if width is None:
width = shutil.get_terminal_size().columns

if width < 1:
raise ValueError("width must be at least 1")

# Handle tabs
text = text.replace('\t', ' ' * tab_width)
if fill_char == '\t':
Expand All @@ -678,23 +687,21 @@ def align_text(text: str, alignment: TextAlignment, *, fill_char: str = ' ',
else:
lines = ['']

if width is None:
width = shutil.get_terminal_size().columns

text_buf = io.StringIO()

for index, line in enumerate(lines):
if index > 0:
text_buf.write('\n')

# Use style_aware_wcswidth to support characters with display widths
# greater than 1 as well as ANSI style sequences
if truncate:
line = truncate_line(line, width)

line_width = ansi.style_aware_wcswidth(line)
if line_width == -1:
raise(ValueError("Text to align contains an unprintable character"))

# Check if line is wider than the desired final width
if width <= line_width:
elif line_width >= width:
# No need to add fill characters
text_buf.write(line)
continue

Expand Down Expand Up @@ -725,7 +732,8 @@ def align_text(text: str, alignment: TextAlignment, *, fill_char: str = ' ',
return text_buf.getvalue()


def align_left(text: str, *, fill_char: str = ' ', width: Optional[int] = None, tab_width: int = 4) -> str:
def align_left(text: str, *, fill_char: str = ' ', width: Optional[int] = None,
tab_width: int = 4, truncate: bool = False) -> str:
"""
Left align text for display within a given width. Supports characters with display widths greater than 1.
ANSI style sequences are safely ignored and do not count toward the display width. This means colored text is
Expand All @@ -736,14 +744,19 @@ def align_left(text: str, *, fill_char: str = ' ', width: Optional[int] = None,
:param width: display width of the aligned text. Defaults to width of the terminal.
:param tab_width: any tabs in the text will be replaced with this many spaces. if fill_char is a tab, then it will
be converted to a space.
:param truncate: if True, then text will be shortened to fit within the display width. The truncated portion is
replaced by a '…' character. Defaults to False.
:return: left-aligned text
:raises: TypeError if fill_char is more than one character
ValueError if text or fill_char contains an unprintable character
ValueError if width is less than 1
"""
return align_text(text, TextAlignment.LEFT, fill_char=fill_char, width=width, tab_width=tab_width)
return align_text(text, TextAlignment.LEFT, fill_char=fill_char, width=width,
tab_width=tab_width, truncate=truncate)


def align_center(text: str, *, fill_char: str = ' ', width: Optional[int] = None, tab_width: int = 4) -> str:
def align_center(text: str, *, fill_char: str = ' ', width: Optional[int] = None,
tab_width: int = 4, truncate: bool = False) -> str:
"""
Center text for display within a given width. Supports characters with display widths greater than 1.
ANSI style sequences are safely ignored and do not count toward the display width. This means colored text is
Expand All @@ -754,14 +767,19 @@ def align_center(text: str, *, fill_char: str = ' ', width: Optional[int] = None
:param width: display width of the aligned text. Defaults to width of the terminal.
:param tab_width: any tabs in the text will be replaced with this many spaces. if fill_char is a tab, then it will
be converted to a space.
:param truncate: if True, then text will be shortened to fit within the display width. The truncated portion is
replaced by a '…' character. Defaults to False.
:return: centered text
:raises: TypeError if fill_char is more than one character
ValueError if text or fill_char contains an unprintable character
ValueError if width is less than 1
"""
return align_text(text, TextAlignment.CENTER, fill_char=fill_char, width=width, tab_width=tab_width)
return align_text(text, TextAlignment.CENTER, fill_char=fill_char, width=width,
tab_width=tab_width, truncate=truncate)


def align_right(text: str, *, fill_char: str = ' ', width: Optional[int] = None, tab_width: int = 4) -> str:
def align_right(text: str, *, fill_char: str = ' ', width: Optional[int] = None,
tab_width: int = 4, truncate: bool = False) -> str:
"""
Right align text for display within a given width. Supports characters with display widths greater than 1.
ANSI style sequences are safely ignored and do not count toward the display width. This means colored text is
Expand All @@ -772,8 +790,47 @@ def align_right(text: str, *, fill_char: str = ' ', width: Optional[int] = None,
:param width: display width of the aligned text. Defaults to width of the terminal.
:param tab_width: any tabs in the text will be replaced with this many spaces. if fill_char is a tab, then it will
be converted to a space.
:param truncate: if True, then text will be shortened to fit within the display width. The truncated portion is
replaced by a '…' character. Defaults to False.
:return: right-aligned text
:raises: TypeError if fill_char is more than one character
ValueError if text or fill_char contains an unprintable character
ValueError if width is less than 1
"""
return align_text(text, TextAlignment.RIGHT, fill_char=fill_char, width=width,
tab_width=tab_width, truncate=truncate)


def truncate_line(line: str, max_width: int, *, tab_width: int = 4) -> str:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks good to me

"""
return align_text(text, TextAlignment.RIGHT, fill_char=fill_char, width=width, tab_width=tab_width)
Truncate a single line to fit within a given display width. Any portion of the string that is truncated
is replaced by a '…' character. Supports characters with display widths greater than 1. ANSI style sequences are
safely ignored and do not count toward the display width. This means colored text is supported.

:param line: text to truncate
:param max_width: the maximum display width the resulting string is allowed to have
:param tab_width: any tabs in the text will be replaced with this many spaces
:return: line that has a display width less than or equal to width
:raises: ValueError if text contains an unprintable character like a new line
ValueError if max_width is less than 1
"""
from . import ansi

# Handle tabs
line = line.replace('\t', ' ' * tab_width)

if ansi.style_aware_wcswidth(line) == -1:
raise (ValueError("text contains an unprintable character"))

if max_width < 1:
raise ValueError("max_width must be at least 1")

if ansi.style_aware_wcswidth(line) > max_width:
# Remove characters until we fit. Leave room for the ellipsis.
line = line[:max_width - 1]
while ansi.style_aware_wcswidth(line) > max_width - 1:
line = line[:-1]

line += "\N{HORIZONTAL ELLIPSIS}"

return line
2 changes: 2 additions & 0 deletions docs/api/utility_functions.rst
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ Utility Functions

.. autofunction:: cmd2.utils.align_right

.. autofunction:: cmd2.utils.truncate_line

.. autofunction:: cmd2.utils.strip_quotes

.. autofunction:: cmd2.utils.namedtuple_with_defaults
Expand Down
18 changes: 10 additions & 8 deletions docs/features/generating_output.rst
Original file line number Diff line number Diff line change
Expand Up @@ -140,17 +140,19 @@ the terminal or not.
Aligning Text
--------------

If you would like to generate output which is left, center, or right aligned within a
specified width or the terminal width, the following functions can help:
If you would like to generate output which is left, center, or right aligned
within a specified width or the terminal width, the following functions can
help:

- :meth:`cmd2.utils.align_left`
- :meth:`cmd2.utils.align_center`
- :meth:`cmd2.utils.align_right`

These functions differ from Python's string justifying functions in that they support
characters with display widths greater than 1. Additionally, ANSI style sequences are safely
ignored and do not count toward the display width. This means colored text is supported. If
text has line breaks, then each line is aligned independently.
These functions differ from Python's string justifying functions in that they
support characters with display widths greater than 1. Additionally, ANSI style
sequences are safely ignored and do not count toward the display width. This
means colored text is supported. If text has line breaks, then each line is
aligned independently.



Expand All @@ -165,5 +167,5 @@ in the output to generate colors on the terminal.

The :meth:`cmd2.ansi.style_aware_wcswidth` function solves both of these
problems. Pass it a string, and regardless of which Unicode characters and ANSI
text style escape sequences it contains, it will tell you how many characters on the
screen that string will consume when printed.
text style escape sequences it contains, it will tell you how many characters
on the screen that string will consume when printed.
17 changes: 9 additions & 8 deletions docs/features/settings.rst
Original file line number Diff line number Diff line change
Expand Up @@ -70,8 +70,8 @@ echo
~~~~

If ``True``, each command the user issues will be repeated to the screen before
it is executed. This is particularly useful when running scripts. This behavior
does not occur when a running command at the prompt.
it is executed. This is particularly useful when running scripts. This
behavior does not occur when a running command at the prompt.


editor
Expand Down Expand Up @@ -105,13 +105,14 @@ Allow access to your application in one of the
max_completion_items
~~~~~~~~~~~~~~~~~~~~

Maximum number of CompletionItems to display during tab completion. A CompletionItem
is a special kind of tab-completion hint which displays both a value and description
and uses one line for each hint. Tab complete the ``set`` command for an example.
Maximum number of CompletionItems to display during tab completion. A
CompletionItem is a special kind of tab-completion hint which displays both a
value and description and uses one line for each hint. Tab complete the ``set``
command for an example.

If the number of tab-completion hints exceeds ``max_completion_items``, then they will
be displayed in the typical columnized format and will not include the description text
of the CompletionItem.
If the number of tab-completion hints exceeds ``max_completion_items``, then
they will be displayed in the typical columnized format and will not include
the description text of the CompletionItem.


prompt
Expand Down
79 changes: 69 additions & 10 deletions tests/test_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -293,54 +293,113 @@ def test_context_flag_exit_err(context_flag):
context_flag.__exit__()


def test_truncate_line():
line = 'long'
max_width = 3
truncated = cu.truncate_line(line, max_width)
assert truncated == 'lo\N{HORIZONTAL ELLIPSIS}'

def test_truncate_line_with_newline():
line = 'fo\no'
max_width = 2
with pytest.raises(ValueError):
cu.truncate_line(line, max_width)

def test_truncate_line_width_is_too_small():
line = 'foo'
max_width = 0
with pytest.raises(ValueError):
cu.truncate_line(line, max_width)

def test_truncate_line_wide_text():
line = '苹苹other'
max_width = 6
truncated = cu.truncate_line(line, max_width)
assert truncated == '苹苹o\N{HORIZONTAL ELLIPSIS}'

def test_truncate_line_split_wide_text():
"""Test when truncation results in a string which is shorter than max_width"""
line = '1苹2苹'
max_width = 3
truncated = cu.truncate_line(line, max_width)
assert truncated == '1\N{HORIZONTAL ELLIPSIS}'

def test_truncate_line_tabs():
line = 'has\ttab'
max_width = 9
truncated = cu.truncate_line(line, max_width)
assert truncated == 'has t\N{HORIZONTAL ELLIPSIS}'

def test_align_text_fill_char_is_tab():
text = 'foo'
fill_char = '\t'
width = 5
aligned = cu.align_text(text, fill_char=fill_char, width=width, alignment=cu.TextAlignment.LEFT)
aligned = cu.align_text(text, cu.TextAlignment.LEFT, fill_char=fill_char, width=width)
assert aligned == text + ' '

def test_align_text_width_is_too_small():
text = 'foo'
fill_char = '-'
width = 0
with pytest.raises(ValueError):
cu.align_text(text, cu.TextAlignment.LEFT, fill_char=fill_char, width=width)

def test_align_text_fill_char_is_too_long():
text = 'foo'
fill_char = 'fill'
width = 5
with pytest.raises(TypeError):
cu.align_text(text, fill_char=fill_char, width=width, alignment=cu.TextAlignment.LEFT)
cu.align_text(text, cu.TextAlignment.LEFT, fill_char=fill_char, width=width)

def test_align_text_fill_char_is_unprintable():
text = 'foo'
fill_char = '\n'
width = 5
with pytest.raises(ValueError):
cu.align_text(text, fill_char=fill_char, width=width, alignment=cu.TextAlignment.LEFT)
cu.align_text(text, cu.TextAlignment.LEFT, fill_char=fill_char, width=width)

def test_align_text_has_tabs():
text = '\t\tfoo'
fill_char = '-'
width = 10
aligned = cu.align_text(text, fill_char=fill_char, width=width, alignment=cu.TextAlignment.LEFT, tab_width=2)
aligned = cu.align_text(text, cu.TextAlignment.LEFT, fill_char=fill_char, width=width, tab_width=2)
assert aligned == ' ' + 'foo' + '---'

def test_align_text_blank():
text = ''
fill_char = '-'
width = 5
aligned = cu.align_text(text, fill_char=fill_char, width=width, alignment=cu.TextAlignment.LEFT)
aligned = cu.align_text(text, cu.TextAlignment.LEFT, fill_char=fill_char, width=width)
assert aligned == fill_char * width

def test_align_text_wider_than_width():
text = 'long'
text = 'long text field'
fill_char = '-'
width = 3
aligned = cu.align_text(text, fill_char=fill_char, width=width, alignment=cu.TextAlignment.LEFT)
width = 8
aligned = cu.align_text(text, cu.TextAlignment.LEFT, fill_char=fill_char, width=width)
assert aligned == text

def test_align_text_wider_than_width_truncate():
text = 'long text field'
fill_char = '-'
width = 8
aligned = cu.align_text(text, cu.TextAlignment.LEFT, fill_char=fill_char, width=width, truncate=True)
assert aligned == 'long te\N{HORIZONTAL ELLIPSIS}'

def test_align_text_wider_than_width_truncate_add_fill():
"""Test when truncation results in a string which is shorter than width and align_text adds filler"""
text = '1苹2苹'
fill_char = '-'
width = 3
aligned = cu.align_text(text, cu.TextAlignment.LEFT, fill_char=fill_char, width=width, truncate=True)
assert aligned == '1\N{HORIZONTAL ELLIPSIS}-'

def test_align_text_has_unprintable():
text = 'foo\x02'
fill_char = '-'
width = 5
with pytest.raises(ValueError):
cu.align_text(text, fill_char=fill_char, width=width, alignment=cu.TextAlignment.LEFT)
cu.align_text(text, cu.TextAlignment.LEFT, fill_char=fill_char, width=width)

def test_align_text_term_width():
import shutil
Expand All @@ -351,7 +410,7 @@ def test_align_text_term_width():
term_width = shutil.get_terminal_size().columns
expected_fill = (term_width - ansi.style_aware_wcswidth(text)) * fill_char

aligned = cu.align_text(text, fill_char=fill_char, alignment=cu.TextAlignment.LEFT)
aligned = cu.align_text(text, cu.TextAlignment.LEFT, fill_char=fill_char)
assert aligned == text + expected_fill

def test_align_left():
Expand Down