Repository: python-cmd2/cmd2 (python)

Issue:
utils.center_text() is not ansi aware
The `utils.center_text()` function will produce incorrect results if the text to be centered contains ANSI escape sequences. Advise modifying the method to calculate the padding using `ansi.ansi_safe_wcswidth()`.

Pull request description:
Added text alignment functions
Fixes #823 

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1ac01813..79b50d49 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,9 +1,12 @@
-## 0.9.22 (TBD, 2019)
+## 0.9.22 (December 9, 2019)
 * Bug Fixes
     * Fixed bug where a redefined `ansi.style_error` was not being used in all `cmd2` files
 * Enhancements
     * Enabled line buffering when redirecting output to a file
-    
+    * Added `align_left()`, `align_center()`, and `align_right()` to utils.py. All 3 of these functions support
+    ANSI escape sequences and characters with display widths greater than 1. They wrap `align_text()` which
+    is also in utils.py.
+
 ## 0.9.21 (November 26, 2019)
 * Bug Fixes
     * Fixed bug where pipe processes were not being stopped by Ctrl-C
diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py
index bd581919..28a9dedb 100644
--- a/cmd2/cmd2.py
+++ b/cmd2/cmd2.py
@@ -2718,6 +2718,7 @@ class Cmd(cmd.Cmd):
                 self.stdout.write("\n")
 
     shortcuts_parser = DEFAULT_ARGUMENT_PARSER(description="List available shortcuts")
+
     @with_argparser(shortcuts_parser)
     def do_shortcuts(self, _: argparse.Namespace) -> None:
         """List available shortcuts"""
@@ -2727,6 +2728,7 @@ class Cmd(cmd.Cmd):
         self.poutput("Shortcuts for other commands:\n{}".format(result))
 
     eof_parser = DEFAULT_ARGUMENT_PARSER(description="Called when <Ctrl>-D is pressed", epilog=INTERNAL_COMMAND_EPILOG)
+
     @with_argparser(eof_parser)
     def do_eof(self, _: argparse.Namespace) -> bool:
         """Called when <Ctrl>-D is pressed"""
@@ -2734,6 +2736,7 @@ class Cmd(cmd.Cmd):
         return True
 
     quit_parser = DEFAULT_ARGUMENT_PARSER(description="Exit this application")
+
     @with_argparser(quit_parser)
     def do_quit(self, _: argparse.Namespace) -> bool:
         """Exit this application"""
@@ -3215,6 +3218,7 @@ class Cmd(cmd.Cmd):
     # Only include the do_ipy() method if IPython is available on the system
     if ipython_available:  # pragma: no cover
         ipython_parser = DEFAULT_ARGUMENT_PARSER(description="Enter an interactive IPython shell")
+
         @with_argparser(ipython_parser)
         def do_ipy(self, _: argparse.Namespace) -> None:
             """Enter an interactive IPython shell"""
@@ -3223,6 +3227,7 @@ class Cmd(cmd.Cmd):
                       'Run Python code from external files with: run filename.py\n')
             exit_msg = 'Leaving IPython, back to {}'.format(sys.argv[0])
 
+            # noinspection PyUnusedLocal
             def load_ipy(cmd2_app: Cmd, py_bridge: PyBridge):
                 """
                 Embed an IPython shell in an environment that is restricted to only the variables in this function
@@ -3715,7 +3720,7 @@ class Cmd(cmd.Cmd):
         verinfo = ".".join(map(str, sys.version_info[:3]))
         num_transcripts = len(transcripts_expanded)
         plural = '' if len(transcripts_expanded) == 1 else 's'
-        self.poutput(ansi.style(utils.center_text('cmd2 transcript test', pad='='), bold=True))
+        self.poutput(ansi.style(utils.align_center(' cmd2 transcript test ', fill_char='='), bold=True))
         self.poutput('platform {} -- Python {}, cmd2-{}, readline-{}'.format(sys.platform, verinfo, cmd2.__version__,
                                                                              rl_type))
         self.poutput('cwd: {}'.format(os.getcwd()))
@@ -3733,8 +3738,8 @@ class Cmd(cmd.Cmd):
         execution_time = time.time() - start_time
         if test_results.wasSuccessful():
             ansi.ansi_aware_write(sys.stderr, stream.read())
-            finish_msg = '{0} transcript{1} passed in {2:.3f} seconds'.format(num_transcripts, plural, execution_time)
-            finish_msg = ansi.style_success(utils.center_text(finish_msg, pad='='))
+            finish_msg = ' {0} transcript{1} passed in {2:.3f} seconds '.format(num_transcripts, plural, execution_time)
+            finish_msg = ansi.style_success(utils.align_center(finish_msg, fill_char='='))
             self.poutput(finish_msg)
         else:
             # Strip off the initial traceback which isn't particularly useful for end users
diff --git a/cmd2/parsing.py b/cmd2/parsing.py
index 4e690b0b..cef0b088 100755
--- a/cmd2/parsing.py
+++ b/cmd2/parsing.py
@@ -13,9 +13,10 @@ from . import utils
 
 
 def shlex_split(str_to_split: str) -> List[str]:
-    """A wrapper around shlex.split() that uses cmd2's preferred arguments.
+    """
+    A wrapper around shlex.split() that uses cmd2's preferred arguments.
+    This allows other classes to easily call split() the same way StatementParser does.
 
-    This allows other classes to easily call split() the same way StatementParser does
     :param str_to_split: the string being split
     :return: A list of tokens
     """
@@ -26,8 +27,8 @@ def shlex_split(str_to_split: str) -> List[str]:
 class MacroArg:
     """
     Information used to replace or unescape arguments in a macro value when the macro is resolved
-    Normal argument syntax  : {5}
-    Escaped argument syntax: {{5}}
+    Normal argument syntax:    {5}
+    Escaped argument syntax:  {{5}}
     """
     # The starting index of this argument in the macro value
     start_index = attr.ib(validator=attr.validators.instance_of(int))
diff --git a/cmd2/utils.py b/cmd2/utils.py
index a1a0d377..9dd7a30b 100644
--- a/cmd2/utils.py
+++ b/cmd2/utils.py
@@ -5,11 +5,11 @@ import collections
 import glob
 import os
 import re
-import shutil
 import subprocess
 import sys
 import threading
 import unicodedata
+from enum import Enum
 from typing import Any, Iterable, List, Optional, TextIO, Union
 
 from . import constants
@@ -363,21 +363,6 @@ def get_exes_in_path(starts_with: str) -> List[str]:
     return list(exes_set)
 
 
-def center_text(msg: str, *, pad: str = ' ') -> str:
-    """Centers text horizontally for display within the current terminal, optionally padding both sides.
-
-    :param msg: message to display in the center
-    :param pad: if provided, the first character will be used to pad both sides of the message
-    :return: centered message, optionally padded on both sides with pad_char
-    """
-    term_width = shutil.get_terminal_size().columns
-    surrounded_msg = ' {} '.format(msg)
-    if not pad:
-        pad = ' '
-    fill_char = pad[:1]
-    return surrounded_msg.center(term_width, fill_char)
-
-
 class StdSim(object):
     """
     Class to simulate behavior of sys.stdout or sys.stderr.
@@ -644,3 +629,151 @@ def basic_complete(text: str, line: str, begidx: int, endidx: int, match_against
     :return: a list of possible tab completions
     """
     return [cur_match for cur_match in match_against if cur_match.startswith(text)]
+
+
+class TextAlignment(Enum):
+    LEFT = 1
+    CENTER = 2
+    RIGHT = 3
+
+
+def align_text(text: str, alignment: TextAlignment, *, fill_char: str = ' ',
+               width: Optional[int] = None, tab_width: int = 4) -> str:
+    """
+    Align text for display within a given width. Supports characters with display widths greater than 1.
+    ANSI escape 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.
+
+    There are convenience wrappers around this function: align_left(), align_center(), and align_right()
+
+    :param text: text to align (can contain multiple lines)
+    :param alignment: how to align the text
+    :param fill_char: character that fills the alignment gap. Defaults to space. (Cannot be a line breaking character)
+    :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.
+    :return: aligned text
+    :raises: TypeError if fill_char is more than one character
+             ValueError if text or fill_char contains an unprintable character
+    """
+    import io
+    import shutil
+
+    from . import ansi
+
+    # Handle tabs
+    text = text.replace('\t', ' ' * tab_width)
+    if fill_char == '\t':
+        fill_char = ' '
+
+    if len(fill_char) != 1:
+        raise TypeError("Fill character must be exactly one character long")
+
+    fill_char_width = ansi.ansi_safe_wcswidth(fill_char)
+    if fill_char_width == -1:
+        raise (ValueError("Fill character is an unprintable character"))
+
+    if text:
+        lines = text.splitlines()
+    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 ansi_safe_wcswidth to support characters with display widths
+        # greater than 1 as well as ANSI escape sequences
+        line_width = ansi.ansi_safe_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:
+            text_buf.write(line)
+            continue
+
+        # Calculate how wide each side of filling needs to be
+        total_fill_width = width - line_width
+
+        if alignment == TextAlignment.LEFT:
+            left_fill_width = 0
+            right_fill_width = total_fill_width
+        elif alignment == TextAlignment.CENTER:
+            left_fill_width = total_fill_width // 2
+            right_fill_width = total_fill_width - left_fill_width
+        else:
+            left_fill_width = total_fill_width
+            right_fill_width = 0
+
+        # Determine how many fill characters are needed to cover the width
+        left_fill = (left_fill_width // fill_char_width) * fill_char
+        right_fill = (right_fill_width // fill_char_width) * fill_char
+
+        # In cases where the fill character display width didn't divide evenly into
+        # the gaps being filled, pad the remainder with spaces.
+        left_fill += ' ' * (left_fill_width - ansi.ansi_safe_wcswidth(left_fill))
+        right_fill += ' ' * (right_fill_width - ansi.ansi_safe_wcswidth(right_fill))
+
+        text_buf.write(left_fill + line + right_fill)
+
+    return text_buf.getvalue()
+
+
+def align_left(text: str, *, fill_char: str = ' ', width: Optional[int] = None, tab_width: int = 4) -> str:
+    """
+    Left align text for display within a given width. Supports characters with display widths greater than 1.
+    ANSI escape 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.
+
+    :param text: text to left align (can contain multiple lines)
+    :param fill_char: character that fills the alignment gap. Defaults to space. (Cannot be a line breaking character)
+    :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.
+    :return: left-aligned text
+    :raises: TypeError if fill_char is more than one character
+             ValueError if text or fill_char contains an unprintable character
+    """
+    return align_text(text, TextAlignment.LEFT, fill_char=fill_char, width=width, tab_width=tab_width)
+
+
+def align_center(text: str, *, fill_char: str = ' ', width: Optional[int] = None, tab_width: int = 4) -> str:
+    """
+    Center text for display within a given width. Supports characters with display widths greater than 1.
+    ANSI escape 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.
+
+    :param text: text to center (can contain multiple lines)
+    :param fill_char: character that fills the alignment gap. Defaults to space. (Cannot be a line breaking character)
+    :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.
+    :return: centered text
+    :raises: TypeError if fill_char is more than one character
+             ValueError if text or fill_char contains an unprintable character
+    """
+    return align_text(text, TextAlignment.CENTER, fill_char=fill_char, width=width, tab_width=tab_width)
+
+
+def align_right(text: str, *, fill_char: str = ' ', width: Optional[int] = None, tab_width: int = 4) -> str:
+    """
+    Right align text for display within a given width. Supports characters with display widths greater than 1.
+    ANSI escape 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.
+
+    :param text: text to right align (can contain multiple lines)
+    :param fill_char: character that fills the alignment gap. Defaults to space. (Cannot be a line breaking character)
+    :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.
+    :return: right-aligned text
+    :raises: TypeError if fill_char is more than one character
+             ValueError if text or fill_char contains an unprintable character
+    """
+    return align_text(text, TextAlignment.RIGHT, fill_char=fill_char, width=width, tab_width=tab_width)
diff --git a/docs/api/utility_functions.rst b/docs/api/utility_functions.rst
index 86fb656c..e083cafe 100644
--- a/docs/api/utility_functions.rst
+++ b/docs/api/utility_functions.rst
@@ -9,7 +9,13 @@ Utility Functions
 
 .. autofunction:: cmd2.decorators.categorize
 
-.. autofunction:: cmd2.utils.center_text
+.. autofunction:: cmd2.utils.align_text
+
+.. autofunction:: cmd2.utils.align_left
+
+.. autofunction:: cmd2.utils.align_center
+
+.. autofunction:: cmd2.utils.align_right
 
 .. autofunction:: cmd2.utils.strip_quotes
 


Test patch:
diff --git a/tests/test_utils.py b/tests/test_utils.py
index e4b9169c..27b0e3bb 100644
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -293,24 +293,182 @@ def test_context_flag_exit_err(context_flag):
         context_flag.__exit__()
 
 
-def test_center_text_pad_none():
-    msg = 'foo'
-    centered = cu.center_text(msg, pad=None)
-    expected_center = ' ' + msg + ' '
-    assert expected_center in centered
-    letters_in_centered = set(centered)
-    letters_in_msg = set(msg)
-    assert len(letters_in_centered) == len(letters_in_msg) + 1
-
-def test_center_text_pad_equals():
-    msg = 'foo'
-    pad = '='
-    centered = cu.center_text(msg, pad=pad)
-    expected_center = ' ' + msg + ' '
-    assert expected_center in centered
-    assert centered.startswith(pad)
-    assert centered.endswith(pad)
-    letters_in_centered = set(centered)
-    letters_in_msg = set(msg)
-    assert len(letters_in_centered) == len(letters_in_msg) + 2
+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)
+    assert aligned == text + '  '
+
+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)
 
+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)
+
+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)
+    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)
+    assert aligned == fill_char * width
+
+def test_align_text_wider_than_width():
+    text = 'long'
+    fill_char = '-'
+    width = 3
+    aligned = cu.align_text(text, fill_char=fill_char, width=width, alignment=cu.TextAlignment.LEFT)
+    assert aligned == text
+
+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)
+
+def test_align_text_term_width():
+    import shutil
+    from cmd2 import ansi
+    text = 'foo'
+    fill_char = ' '
+
+    term_width = shutil.get_terminal_size().columns
+    expected_fill = (term_width - ansi.ansi_safe_wcswidth(text)) * fill_char
+
+    aligned = cu.align_text(text, fill_char=fill_char, alignment=cu.TextAlignment.LEFT)
+    assert aligned == text + expected_fill
+
+def test_align_left():
+    text = 'foo'
+    fill_char = '-'
+    width = 5
+    aligned = cu.align_left(text, fill_char=fill_char, width=width)
+    assert aligned == text + fill_char + fill_char
+
+def test_align_left_multiline():
+    text = "foo\nshoes"
+    fill_char = '-'
+    width = 7
+    aligned = cu.align_left(text, fill_char=fill_char, width=width)
+    assert aligned == ('foo----\n'
+                       'shoes--')
+
+def test_align_left_wide_text():
+    text = '苹'
+    fill_char = '-'
+    width = 4
+    aligned = cu.align_left(text, fill_char=fill_char, width=width)
+    assert aligned == text + fill_char + fill_char
+
+def test_align_left_wide_fill():
+    text = 'foo'
+    fill_char = '苹'
+    width = 5
+    aligned = cu.align_left(text, fill_char=fill_char, width=width)
+    assert aligned == text + fill_char
+
+def test_align_left_wide_fill_needs_padding():
+    """Test when fill_char's display width does not divide evenly into gap"""
+    text = 'foo'
+    fill_char = '苹'
+    width = 6
+    aligned = cu.align_left(text, fill_char=fill_char, width=width)
+    assert aligned == text + fill_char + ' '
+
+def test_align_center():
+    text = 'foo'
+    fill_char = '-'
+    width = 5
+    aligned = cu.align_center(text, fill_char=fill_char, width=width)
+    assert aligned == fill_char + text + fill_char
+
+def test_align_center_multiline():
+    text = "foo\nshoes"
+    fill_char = '-'
+    width = 7
+    aligned = cu.align_center(text, fill_char=fill_char, width=width)
+    assert aligned == ('--foo--\n'
+                       '-shoes-')
+
+def test_align_center_wide_text():
+    text = '苹'
+    fill_char = '-'
+    width = 4
+    aligned = cu.align_center(text, fill_char=fill_char, width=width)
+    assert aligned == fill_char + text + fill_char
+
+def test_align_center_wide_fill():
+    text = 'foo'
+    fill_char = '苹'
+    width = 7
+    aligned = cu.align_center(text, fill_char=fill_char, width=width)
+    assert aligned == fill_char + text + fill_char
+
+def test_align_center_wide_fill_needs_right_padding():
+    """Test when fill_char's display width does not divide evenly into right gap"""
+    text = 'foo'
+    fill_char = '苹'
+    width = 8
+    aligned = cu.align_center(text, fill_char=fill_char, width=width)
+    assert aligned == fill_char + text + fill_char + ' '
+
+def test_align_center_wide_fill_needs_left_and_right_padding():
+    """Test when fill_char's display width does not divide evenly into either gap"""
+    text = 'foo'
+    fill_char = '苹'
+    width = 9
+    aligned = cu.align_center(text, fill_char=fill_char, width=width)
+    assert aligned == fill_char + ' ' + text + fill_char + ' '
+
+def test_align_right():
+    text = 'foo'
+    fill_char = '-'
+    width = 5
+    aligned = cu.align_right(text, fill_char=fill_char, width=width)
+    assert aligned == fill_char + fill_char + text
+
+def test_align_right_multiline():
+    text = "foo\nshoes"
+    fill_char = '-'
+    width = 7
+    aligned = cu.align_right(text, fill_char=fill_char, width=width)
+    assert aligned == ('----foo\n'
+                       '--shoes')
+
+def test_align_right_wide_text():
+    text = '苹'
+    fill_char = '-'
+    width = 4
+    aligned = cu.align_right(text, fill_char=fill_char, width=width)
+    assert aligned == fill_char + fill_char + text
+
+def test_align_right_wide_fill():
+    text = 'foo'
+    fill_char = '苹'
+    width = 5
+    aligned = cu.align_right(text, fill_char=fill_char, width=width)
+    assert aligned == fill_char + text
+
+def test_align_right_wide_fill_needs_padding():
+    """Test when fill_char's display width does not divide evenly into gap"""
+    text = 'foo'
+    fill_char = '苹'
+    width = 6
+    aligned = cu.align_right(text, fill_char=fill_char, width=width)
+    assert aligned == fill_char + ' ' + text