Repository: python-cmd2/cmd2
Pull request #850 (2020-01-20 20:04:32)

Truncate line


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e5ee988f..73aa5b23 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
diff --git a/cmd2/utils.py b/cmd2/utils.py
index ffbe5a64..3e154bd2 100644
--- a/cmd2/utils.py
+++ b/cmd2/utils.py
@@ -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
@@ -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':
@@ -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
 
@@ -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
@@ -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
@@ -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
@@ -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:
     """
-    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
diff --git a/docs/api/utility_functions.rst b/docs/api/utility_functions.rst
index e083cafe..e2d6f036 100644
--- a/docs/api/utility_functions.rst
+++ b/docs/api/utility_functions.rst
@@ -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
diff --git a/docs/features/generating_output.rst b/docs/features/generating_output.rst
index f3d2a7f4..d5224e86 100644
--- a/docs/features/generating_output.rst
+++ b/docs/features/generating_output.rst
@@ -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.
 
 
 
@@ -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.
diff --git a/docs/features/settings.rst b/docs/features/settings.rst
index 627f61a9..697c68a7 100644
--- a/docs/features/settings.rst
+++ b/docs/features/settings.rst
@@ -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
@@ -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


Test patch:
diff --git a/tests/test_utils.py b/tests/test_utils.py
index b5231172..9dd54ee2 100644
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -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
@@ -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():