From cda57dc1a1859408fb25d31178ad0f6e77ede902 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Mon, 9 Dec 2019 15:23:58 -0500 Subject: Updated center_text to support ansi escape sequences and characters with display widths greater than 1. Also added left and right justification functions. --- cmd2/utils.py | 164 ++++++++++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 148 insertions(+), 16 deletions(-) (limited to 'cmd2/utils.py') 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) -- cgit v1.2.1 From 6f16e671971a9fac4edfda9c0117ceaeb5e6487e Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Mon, 9 Dec 2019 16:24:54 -0500 Subject: Adding unit tests for text alignment functions --- cmd2/utils.py | 28 +++++++++++++--------------- 1 file changed, 13 insertions(+), 15 deletions(-) (limited to 'cmd2/utils.py') diff --git a/cmd2/utils.py b/cmd2/utils.py index c8ba5816..d7c9ff19 100644 --- a/cmd2/utils.py +++ b/cmd2/utils.py @@ -642,7 +642,7 @@ def align_text(text: str, *, fill_char: str = ' ', width: Optional[int] = None, """ 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. + supported. If text has line breaks, then each line is aligned independently. There are convenience wrappers around this function: ljustify_text(), center_text(), and rjustify_text() @@ -653,9 +653,8 @@ def align_text(text: str, *, fill_char: str = ' ', width: Optional[int] = None, 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 - + :raises: TypeError if fill_char is more than one character + ValueError if text or fill_char contains an unprintable character """ import io import shutil @@ -663,12 +662,12 @@ def align_text(text: str, *, fill_char: str = ' ', width: Optional[int] = None, from . import ansi # Handle tabs - text.replace('\t', ' ' * tab_width) + text = 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") + raise TypeError("Fill character must be exactly one character long") fill_char_width = ansi.ansi_safe_wcswidth(fill_char) if fill_char_width == -1: @@ -679,22 +678,21 @@ def align_text(text: str, *, fill_char: str = ' ', width: Optional[int] = None, 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 + # 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) @@ -731,7 +729,7 @@ def ljustify_text(text: str, *, fill_char: str = ' ', width: Optional[int] = Non """ 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. + supported. If text has line breaks, then each line is 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) @@ -748,7 +746,7 @@ def center_text(text: str, *, fill_char: str = ' ', width: Optional[int] = None, """ 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. + 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) @@ -765,7 +763,7 @@ def rjustify_text(text: str, *, fill_char: str = ' ', width: Optional[int] = Non """ 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. + supported. If text has line breaks, then each line is 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) -- cgit v1.2.1 From 5ffed1387ddc4e5725f9c6b5b632a611eca1e3ca Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Mon, 9 Dec 2019 16:34:07 -0500 Subject: Added more text alignment unit tests --- cmd2/utils.py | 6 ++++++ 1 file changed, 6 insertions(+) (limited to 'cmd2/utils.py') diff --git a/cmd2/utils.py b/cmd2/utils.py index d7c9ff19..4235db7d 100644 --- a/cmd2/utils.py +++ b/cmd2/utils.py @@ -737,6 +737,8 @@ def ljustify_text(text: str, *, fill_char: str = ' ', width: Optional[int] = Non :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 + :raises: TypeError if fill_char is more than one character + ValueError if text or fill_char contains an unprintable character """ return align_text(text, fill_char=fill_char, width=width, tab_width=tab_width, alignment=TextAlignment.LEFT) @@ -754,6 +756,8 @@ def center_text(text: str, *, fill_char: str = ' ', width: Optional[int] = None, :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, fill_char=fill_char, width=width, tab_width=tab_width, alignment=TextAlignment.CENTER) @@ -771,6 +775,8 @@ def rjustify_text(text: str, *, fill_char: str = ' ', width: Optional[int] = Non :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 + :raises: TypeError if fill_char is more than one character + ValueError if text or fill_char contains an unprintable character """ return align_text(text, fill_char=fill_char, width=width, tab_width=tab_width, alignment=TextAlignment.RIGHT) -- cgit v1.2.1 From 9e747677a803210ad5ef5c1d5fdf01ea9c1a5ee9 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Mon, 9 Dec 2019 20:53:37 -0500 Subject: Renamed functions based on code review comments. Fixed Python warnings. --- cmd2/utils.py | 39 ++++++++++++++++++--------------------- 1 file changed, 18 insertions(+), 21 deletions(-) (limited to 'cmd2/utils.py') diff --git a/cmd2/utils.py b/cmd2/utils.py index 4235db7d..9dd7a30b 100644 --- a/cmd2/utils.py +++ b/cmd2/utils.py @@ -637,21 +637,21 @@ class TextAlignment(Enum): RIGHT = 3 -def align_text(text: str, *, fill_char: str = ' ', width: Optional[int] = None, tab_width: int = 4, - alignment: TextAlignment) -> str: +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: ljustify_text(), center_text(), and rjustify_text() + There are convenience wrappers around this function: align_left(), align_center(), and align_right() - :param text: text to align (Can contain multiple lines) + :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. - :param alignment: how to align the text :return: aligned text :raises: TypeError if fill_char is more than one character ValueError if text or fill_char contains an unprintable character @@ -725,32 +725,31 @@ def align_text(text: str, *, fill_char: str = ' ', width: Optional[int] = None, return text_buf.getvalue() -def ljustify_text(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) -> str: """ - Left justify text for display within a given width. Supports characters with display widths greater than 1. + 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 justify (Can contain multiple lines) + :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-justified text + :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, fill_char=fill_char, width=width, - tab_width=tab_width, alignment=TextAlignment.LEFT) + return align_text(text, TextAlignment.LEFT, fill_char=fill_char, width=width, tab_width=tab_width) -def center_text(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) -> 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 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 @@ -759,24 +758,22 @@ def center_text(text: str, *, fill_char: str = ' ', width: Optional[int] = None, :raises: TypeError if fill_char is more than one character ValueError if text or fill_char contains an unprintable character """ - return align_text(text, fill_char=fill_char, width=width, - tab_width=tab_width, alignment=TextAlignment.CENTER) + return align_text(text, TextAlignment.CENTER, fill_char=fill_char, width=width, tab_width=tab_width) -def rjustify_text(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) -> str: """ - Right justify text for display within a given width. Supports characters with display widths greater than 1. + 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 justify (Can contain multiple lines) + :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-justified text + :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, fill_char=fill_char, width=width, - tab_width=tab_width, alignment=TextAlignment.RIGHT) + return align_text(text, TextAlignment.RIGHT, fill_char=fill_char, width=width, tab_width=tab_width) -- cgit v1.2.1 From 801bab847341fb9a35d10f1d0b4a629a4fc8f14c Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Thu, 19 Dec 2019 16:04:06 -0500 Subject: Changed allow_ansi to allow_style for accuracy in what types of ANSI escape sequences are handled --- cmd2/utils.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) (limited to 'cmd2/utils.py') diff --git a/cmd2/utils.py b/cmd2/utils.py index 9dd7a30b..ddb9f3b5 100644 --- a/cmd2/utils.py +++ b/cmd2/utils.py @@ -641,7 +641,7 @@ 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 + 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. There are convenience wrappers around this function: align_left(), align_center(), and align_right() @@ -688,7 +688,7 @@ def align_text(text: str, alignment: TextAlignment, *, fill_char: str = ' ', text_buf.write('\n') # Use ansi_safe_wcswidth to support characters with display widths - # greater than 1 as well as ANSI escape sequences + # greater than 1 as well as ANSI style sequences line_width = ansi.ansi_safe_wcswidth(line) if line_width == -1: raise(ValueError("Text to align contains an unprintable character")) @@ -728,7 +728,7 @@ def align_text(text: str, alignment: TextAlignment, *, fill_char: str = ' ', 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 + 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. :param text: text to left align (can contain multiple lines) @@ -746,7 +746,7 @@ def align_left(text: str, *, fill_char: str = ' ', width: Optional[int] = None, 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 + 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. :param text: text to center (can contain multiple lines) @@ -764,7 +764,7 @@ def align_center(text: str, *, fill_char: str = ' ', width: Optional[int] = None 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 + 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. :param text: text to right align (can contain multiple lines) -- cgit v1.2.1 From b0e5aabad9c902ee5d664bf58885245060114f61 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Thu, 19 Dec 2019 17:00:29 -0500 Subject: Renamed ansi_safe_wcswidth() to style_aware_wcswidth() Renamed ansi_aware_write() to style_aware_write() --- cmd2/utils.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) (limited to 'cmd2/utils.py') diff --git a/cmd2/utils.py b/cmd2/utils.py index ddb9f3b5..ffbe5a64 100644 --- a/cmd2/utils.py +++ b/cmd2/utils.py @@ -669,7 +669,7 @@ def align_text(text: str, alignment: TextAlignment, *, fill_char: str = ' ', if len(fill_char) != 1: raise TypeError("Fill character must be exactly one character long") - fill_char_width = ansi.ansi_safe_wcswidth(fill_char) + fill_char_width = ansi.style_aware_wcswidth(fill_char) if fill_char_width == -1: raise (ValueError("Fill character is an unprintable character")) @@ -687,9 +687,9 @@ def align_text(text: str, alignment: TextAlignment, *, fill_char: str = ' ', if index > 0: text_buf.write('\n') - # Use ansi_safe_wcswidth to support characters with display widths + # Use style_aware_wcswidth to support characters with display widths # greater than 1 as well as ANSI style sequences - line_width = ansi.ansi_safe_wcswidth(line) + line_width = ansi.style_aware_wcswidth(line) if line_width == -1: raise(ValueError("Text to align contains an unprintable character")) @@ -717,8 +717,8 @@ def align_text(text: str, alignment: TextAlignment, *, fill_char: str = ' ', # 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)) + left_fill += ' ' * (left_fill_width - ansi.style_aware_wcswidth(left_fill)) + right_fill += ' ' * (right_fill_width - ansi.style_aware_wcswidth(right_fill)) text_buf.write(left_fill + line + right_fill) -- cgit v1.2.1