diff options
author | Kevin Van Brunt <kmvanbrunt@gmail.com> | 2019-12-09 21:08:15 -0500 |
---|---|---|
committer | GitHub <noreply@github.com> | 2019-12-09 21:08:15 -0500 |
commit | bc99c90b4b4a8fd667b0ad77c9442d1393611f5f (patch) | |
tree | 7e612c633b9e54e35220b3690b8a5c2cd69000c3 | |
parent | 0aac6cee56a92bb6358106329f4f0c20e85bb7bc (diff) | |
parent | a4427a3a905d9e926e1ab9c57716235229247912 (diff) | |
download | cmd2-git-bc99c90b4b4a8fd667b0ad77c9442d1393611f5f.tar.gz |
Merge pull request #831 from python-cmd2/align_text0.9.22
Added text alignment functions
-rw-r--r-- | CHANGELOG.md | 7 | ||||
-rw-r--r-- | cmd2/cmd2.py | 11 | ||||
-rwxr-xr-x | cmd2/parsing.py | 9 | ||||
-rw-r--r-- | cmd2/utils.py | 165 | ||||
-rw-r--r-- | docs/api/utility_functions.rst | 8 | ||||
-rw-r--r-- | tests/test_utils.py | 198 |
6 files changed, 352 insertions, 46 deletions
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 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 |