diff options
-rw-r--r-- | cmd2/cmd2.py | 6 | ||||
-rwxr-xr-x | cmd2/parsing.py | 9 | ||||
-rw-r--r-- | cmd2/utils.py | 164 | ||||
-rw-r--r-- | tests/test_utils.py | 25 |
4 files changed, 167 insertions, 37 deletions
diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index bd581919..9c48b222 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -3715,7 +3715,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.center_text(' 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 +3733,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.center_text(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..c8ba5816 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,150 @@ 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, *, fill_char: str = ' ', width: Optional[int] = None, tab_width: int = 4, + alignment: TextAlignment) -> 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. Each line in text will be aligned independently. + + There are convenience wrappers around this function: ljustify_text(), center_text(), and rjustify_text() + + :param text: text to 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. + :param alignment: how to align the text + :return: aligned text + :raises: ValueError if text or fill_char contains an unprintable character + TypeError if fill_char is more than one character + + """ + import io + import shutil + + from . import ansi + + # Handle tabs + text.replace('\t', ' ' * tab_width) + if fill_char == '\t': + fill_char = ' ' + + if len(fill_char) != 1: + raise ValueError("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 = [''] + + 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: + # This can happen if text contains characters like newlines or tabs + raise(ValueError("Text to align contains an unprintable character")) + + if width is None: + width = shutil.get_terminal_size().columns + + # 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 ljustify_text(text: str, *, fill_char: str = ' ', width: Optional[int] = None, tab_width: int = 4) -> str: + """ + Left justify 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. Each line in text will be aligned independently. + + :param text: text to left justify (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-justified text + """ + return align_text(text, fill_char=fill_char, width=width, + tab_width=tab_width, alignment=TextAlignment.LEFT) + + +def center_text(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. Each line in text will be 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 + """ + return align_text(text, fill_char=fill_char, width=width, + tab_width=tab_width, alignment=TextAlignment.CENTER) + + +def rjustify_text(text: str, *, fill_char: str = ' ', width: Optional[int] = None, tab_width: int = 4) -> str: + """ + Right justify 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. Each line in text will be aligned independently. + + :param text: text to right justify (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-justified text + """ + return align_text(text, fill_char=fill_char, width=width, + tab_width=tab_width, alignment=TextAlignment.RIGHT) diff --git a/tests/test_utils.py b/tests/test_utils.py index e4b9169c..2c43371f 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -293,24 +293,21 @@ def test_context_flag_exit_err(context_flag): context_flag.__exit__() -def test_center_text_pad_none(): +def test_center_text_pad_equals(): msg = 'foo' - centered = cu.center_text(msg, pad=None) - expected_center = ' ' + msg + ' ' - assert expected_center in centered + fill_char = '=' + centered = cu.center_text(msg, fill_char=fill_char) + assert msg in centered + assert centered.startswith(fill_char) + assert centered.endswith(fill_char) 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(): + +def test_center_text_pad_blank(): 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 + fill_char = '' + with pytest.raises(ValueError): + cu.center_text(msg, fill_char=fill_char) |