diff options
author | Kevin Van Brunt <kmvanbrunt@gmail.com> | 2022-02-22 19:30:19 -0500 |
---|---|---|
committer | Kevin Van Brunt <kmvanbrunt@gmail.com> | 2022-02-22 19:52:26 -0500 |
commit | e76dbad13cb6363d315cf851a5da3541e2138148 (patch) | |
tree | 291b5594de6cd81695e18e36be3d9ae6e992ab39 | |
parent | 4621b0501acdaea7230eef97ffa526d14820af80 (diff) | |
download | cmd2-git-e76dbad13cb6363d315cf851a5da3541e2138148.tar.gz |
Reduced amount of style characters carried over from previous lines when aligning text.
Also reduced amount of style characters appended to truncated text.
These changes were made to reduce memory usage in certain use cases of tables (e.g. nested colored tables).
-rw-r--r-- | CHANGELOG.md | 3 | ||||
-rw-r--r-- | cmd2/ansi.py | 36 | ||||
-rw-r--r-- | cmd2/decorators.py | 4 | ||||
-rw-r--r-- | cmd2/table_creator.py | 22 | ||||
-rw-r--r-- | cmd2/utils.py | 125 | ||||
-rw-r--r-- | docs/features/argument_processing.rst | 5 | ||||
-rw-r--r-- | tests/test_ansi.py | 44 | ||||
-rw-r--r-- | tests/test_utils.py | 157 |
8 files changed, 335 insertions, 61 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md index 7459ab5a..0e4ace20 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,4 @@ -## 2.4.0 (TBD, 2021) +## 2.4.0 (TBD, 2022) * Bug Fixes * Fixed issue in `ansi.async_alert_str()` which would raise `IndexError` if prompt was blank. * Fixed issue where tab completion was quoting argparse flags in some cases. @@ -6,6 +6,7 @@ * Added broader exception handling when enabling clipboard functionality via `pyperclip`. * Added `PassThroughException` to `__init__.py` imports. * cmd2 now uses pyreadline3 when running any version of Python on Windows + * Improved memory usage in certain use cases of tables (e.g. nested colored tables) * Deletions (potentially breaking changes) * Deleted `cmd2.fg` and `cmd2.bg` which were deprecated in 2.3.0. Use `cmd2.Fg` and `cmd2.Bg` instead. diff --git a/cmd2/ansi.py b/cmd2/ansi.py index badd309f..d66c3efc 100644 --- a/cmd2/ansi.py +++ b/cmd2/ansi.py @@ -23,8 +23,9 @@ from wcwidth import ( # type: ignore[import] ####################################################### # Common ANSI escape sequence constants ####################################################### -CSI = '\033[' -OSC = '\033]' +ESC = '\x1b' +CSI = f'{ESC}[' +OSC = f'{ESC}]' BEL = '\a' @@ -60,8 +61,26 @@ to control how ANSI style sequences are handled by ``style_aware_write()``. The default is ``AllowStyle.TERMINAL``. """ -# Regular expression to match ANSI style sequences (including 8-bit and 24-bit colors) -ANSI_STYLE_RE = re.compile(r'\x1b\[[^m]*m') +# Regular expression to match ANSI style sequence +ANSI_STYLE_RE = re.compile(fr'{ESC}\[[^m]*m') + +# Matches standard foreground colors: CSI(30-37|90-97|39)m +STD_FG_RE = re.compile(fr'{ESC}\[(?:[39][0-7]|39)m') + +# Matches standard background colors: CSI(40-47|100-107|49)m +STD_BG_RE = re.compile(fr'{ESC}\[(?:(?:4|10)[0-7]|49)m') + +# Matches eight-bit foreground colors: CSI38;5;(0-255)m +EIGHT_BIT_FG_RE = re.compile(fr'{ESC}\[38;5;(?:1?[0-9]?[0-9]?|2[0-4][0-9]|25[0-5])m') + +# Matches eight-bit background colors: CSI48;5;(0-255)m +EIGHT_BIT_BG_RE = re.compile(fr'{ESC}\[48;5;(?:1?[0-9]?[0-9]?|2[0-4][0-9]|25[0-5])m') + +# Matches RGB foreground colors: CSI38;2;(0-255);(0-255);(0-255)m +RGB_FG_RE = re.compile(fr'{ESC}\[38;2(?:;(?:1?[0-9]?[0-9]?|2[0-4][0-9]|25[0-5])){{3}}m') + +# Matches RGB background colors: CSI48;2;(0-255);(0-255);(0-255)m +RGB_BG_RE = re.compile(fr'{ESC}\[48;2(?:;(?:1?[0-9]?[0-9]?|2[0-4][0-9]|25[0-5])){{3}}m') def strip_style(text: str) -> str: @@ -240,6 +259,7 @@ class TextStyle(AnsiSequence, Enum): # Resets all styles and colors of text RESET_ALL = 0 + ALT_RESET_ALL = '' INTENSITY_BOLD = 1 INTENSITY_DIM = 2 @@ -606,7 +626,7 @@ class EightBitFg(FgColor, Enum): This is helpful when using an EightBitFg in an f-string or format() call e.g. my_str = f"{EightBitFg.SLATE_BLUE_1}hello{Fg.RESET}" """ - return f"{CSI}{38};5;{self.value}m" + return f"{CSI}38;5;{self.value}m" class EightBitBg(BgColor, Enum): @@ -879,7 +899,7 @@ class EightBitBg(BgColor, Enum): This is helpful when using an EightBitBg in an f-string or format() call e.g. my_str = f"{EightBitBg.KHAKI_3}hello{Bg.RESET}" """ - return f"{CSI}{48};5;{self.value}m" + return f"{CSI}48;5;{self.value}m" class RgbFg(FgColor): @@ -900,7 +920,7 @@ class RgbFg(FgColor): if any(c < 0 or c > 255 for c in [r, g, b]): raise ValueError("RGB values must be integers in the range of 0 to 255") - self._sequence = f"{CSI}{38};2;{r};{g};{b}m" + self._sequence = f"{CSI}38;2;{r};{g};{b}m" def __str__(self) -> str: """ @@ -929,7 +949,7 @@ class RgbBg(BgColor): if any(c < 0 or c > 255 for c in [r, g, b]): raise ValueError("RGB values must be integers in the range of 0 to 255") - self._sequence = f"{CSI}{48};2;{r};{g};{b}m" + self._sequence = f"{CSI}48;2;{r};{g};{b}m" def __str__(self) -> str: """ diff --git a/cmd2/decorators.py b/cmd2/decorators.py index c06142fb..e1aac3cf 100644 --- a/cmd2/decorators.py +++ b/cmd2/decorators.py @@ -238,13 +238,13 @@ def _set_parser_prog(parser: argparse.ArgumentParser, prog: str) -> None: break -#: Function signature for an Command Function that uses an argparse.ArgumentParser to process user input +#: Function signature for a Command Function that uses an argparse.ArgumentParser to process user input #: and optionally returns a boolean ArgparseCommandFuncOptionalBoolReturn = Union[ Callable[['cmd2.Cmd', argparse.Namespace], Optional[bool]], Callable[[CommandSet, argparse.Namespace], Optional[bool]], ] -#: Function signature for an Command Function that uses an argparse.ArgumentParser to process user input +#: Function signature for a Command Function that uses an argparse.ArgumentParser to process user input #: and returns a boolean ArgparseCommandFuncBoolReturn = Union[ Callable[['cmd2.Cmd', argparse.Namespace], bool], diff --git a/cmd2/table_creator.py b/cmd2/table_creator.py index bb8504ba..c7c27756 100644 --- a/cmd2/table_creator.py +++ b/cmd2/table_creator.py @@ -165,7 +165,7 @@ class TableCreator: :param is_last_word: True if this is the last word of the total text being wrapped :return: Tuple(wrapped text, lines used, display width of last line) """ - styles = utils.get_styles_in_text(word) + styles_dict = utils.get_styles_dict(word) wrapped_buf = io.StringIO() # How many lines we've used @@ -190,9 +190,9 @@ class TableCreator: break # Check if we're at a style sequence. These don't count toward display width. - if char_index in styles: - wrapped_buf.write(styles[char_index]) - char_index += len(styles[char_index]) + if char_index in styles_dict: + wrapped_buf.write(styles_dict[char_index]) + char_index += len(styles_dict[char_index]) continue cur_char = word[char_index] @@ -330,7 +330,7 @@ class TableCreator: break # Locate the styles in this line - styles = utils.get_styles_in_text(data_line) + styles_dict = utils.get_styles_dict(data_line) # Display width of the current line we are building cur_line_width = 0 @@ -344,9 +344,9 @@ class TableCreator: break # Check if we're at a style sequence. These don't count toward display width. - if char_index in styles: - cur_word_buf.write(styles[char_index]) - char_index += len(styles[char_index]) + if char_index in styles_dict: + cur_word_buf.write(styles_dict[char_index]) + char_index += len(styles_dict[char_index]) continue cur_char = data_line[char_index] @@ -391,7 +391,7 @@ class TableCreator: :param col: Column definition for this cell :param fill_char: character that fills remaining space in a cell. If your text has a background color, then give fill_char the same background color. (Cannot be a line breaking character) - :return: Tuple of cell lines deque and the display width of the cell + :return: Tuple(deque of cell lines, display width of the cell) """ # Convert data to string and replace tabs with spaces data_str = str(cell_data).replace('\t', SPACE * self.tab_width) @@ -411,8 +411,10 @@ class TableCreator: aligned_text = utils.align_text(wrapped_text, fill_char=fill_char, width=col.width, alignment=text_alignment) - lines = deque(aligned_text.splitlines()) + # Calculate cell_width first to avoid having 2 copies of aligned_text.splitlines() in memory cell_width = ansi.widest_line(aligned_text) + lines = deque(aligned_text.splitlines()) + return lines, cell_width def generate_row( diff --git a/cmd2/utils.py b/cmd2/utils.py index 5f2ceaf4..855ad23e 100644 --- a/cmd2/utils.py +++ b/cmd2/utils.py @@ -737,6 +737,87 @@ class RedirectionSavedState: self.saved_redirecting = saved_redirecting +def _remove_overridden_styles(styles_to_parse: List[str]) -> List[str]: + """ + Utility function for align_text() / truncate_line() which filters a style list down + to only those which would still be in effect if all were processed in order. + + This is mainly used to reduce how many style strings are stored in memory when + building large multiline strings with ANSI styles. We only need to carry over + styles from previous lines that are still in effect. + + :param styles_to_parse: list of styles to evaluate. + :return: list of styles that are still in effect. + """ + from . import ( + ansi, + ) + + class StyleState: + """Keeps track of what text styles are enabled""" + + def __init__(self) -> None: + # Contains styles still in effect, keyed by their index in styles_to_parse + self.style_dict: Dict[int, str] = dict() + + # Indexes into style_dict + self.reset_all: Optional[int] = None + self.fg: Optional[int] = None + self.bg: Optional[int] = None + self.intensity: Optional[int] = None + self.italic: Optional[int] = None + self.overline: Optional[int] = None + self.strikethrough: Optional[int] = None + self.underline: Optional[int] = None + + # Read the previous styles in order and keep track of their states + style_state = StyleState() + + for index, style in enumerate(styles_to_parse): + # For styles types that we recognize, only keep their latest value from styles_to_parse. + # All unrecognized style types will be retained and their order preserved. + if style in (str(ansi.TextStyle.RESET_ALL), str(ansi.TextStyle.ALT_RESET_ALL)): + style_state = StyleState() + style_state.reset_all = index + elif ansi.STD_FG_RE.match(style) or ansi.EIGHT_BIT_FG_RE.match(style) or ansi.RGB_FG_RE.match(style): + if style_state.fg is not None: + style_state.style_dict.pop(style_state.fg) + style_state.fg = index + elif ansi.STD_BG_RE.match(style) or ansi.EIGHT_BIT_BG_RE.match(style) or ansi.RGB_BG_RE.match(style): + if style_state.bg is not None: + style_state.style_dict.pop(style_state.bg) + style_state.bg = index + elif style in ( + str(ansi.TextStyle.INTENSITY_BOLD), + str(ansi.TextStyle.INTENSITY_DIM), + str(ansi.TextStyle.INTENSITY_NORMAL), + ): + if style_state.intensity is not None: + style_state.style_dict.pop(style_state.intensity) + style_state.intensity = index + elif style in (str(ansi.TextStyle.ITALIC_ENABLE), str(ansi.TextStyle.ITALIC_DISABLE)): + if style_state.italic is not None: + style_state.style_dict.pop(style_state.italic) + style_state.italic = index + elif style in (str(ansi.TextStyle.OVERLINE_ENABLE), str(ansi.TextStyle.OVERLINE_DISABLE)): + if style_state.overline is not None: + style_state.style_dict.pop(style_state.overline) + style_state.overline = index + elif style in (str(ansi.TextStyle.STRIKETHROUGH_ENABLE), str(ansi.TextStyle.STRIKETHROUGH_DISABLE)): + if style_state.strikethrough is not None: + style_state.style_dict.pop(style_state.strikethrough) + style_state.strikethrough = index + elif style in (str(ansi.TextStyle.UNDERLINE_ENABLE), str(ansi.TextStyle.UNDERLINE_DISABLE)): + if style_state.underline is not None: + style_state.style_dict.pop(style_state.underline) + style_state.underline = index + + # Store this style and its location in the dictionary + style_state.style_dict[index] = style + + return list(style_state.style_dict.values()) + + class TextAlignment(Enum): """Horizontal text alignment""" @@ -801,7 +882,7 @@ def align_text( raise (ValueError("Fill character is an unprintable character")) # Isolate the style chars before and after the fill character. We will use them when building sequences of - # of fill characters. Instead of repeating the style characters for each fill character, we'll wrap each sequence. + # fill characters. Instead of repeating the style characters for each fill character, we'll wrap each sequence. fill_char_style_begin, fill_char_style_end = fill_char.split(stripped_fill_char) if text: @@ -811,10 +892,10 @@ def align_text( text_buf = io.StringIO() - # ANSI style sequences that may affect future lines will be cancelled by the fill_char's style. - # To avoid this, we save the state of a line's style so we can restore it when beginning the next line. - # This also allows the lines to be used independently and still have their style. TableCreator does this. - aggregate_styles = '' + # ANSI style sequences that may affect subsequent lines will be cancelled by the fill_char's style. + # To avoid this, we save styles which are still in effect so we can restore them when beginning the next line. + # This also allows lines to be used independently and still have their style. TableCreator does this. + previous_styles: List[str] = [] for index, line in enumerate(lines): if index > 0: @@ -827,8 +908,8 @@ def align_text( if line_width == -1: raise (ValueError("Text to align contains an unprintable character")) - # Get the styles in this line - line_styles = get_styles_in_text(line) + # Get list of styles in this line + line_styles = list(get_styles_dict(line).values()) # Calculate how wide each side of filling needs to be if line_width >= width: @@ -858,7 +939,7 @@ def align_text( right_fill += ' ' * (right_fill_width - ansi.style_aware_wcswidth(right_fill)) # Don't allow styles in fill characters and text to affect one another - if fill_char_style_begin or fill_char_style_end or aggregate_styles or line_styles: + if fill_char_style_begin or fill_char_style_end or previous_styles or line_styles: if left_fill: left_fill = ansi.TextStyle.RESET_ALL + fill_char_style_begin + left_fill + fill_char_style_end left_fill += ansi.TextStyle.RESET_ALL @@ -867,11 +948,12 @@ def align_text( right_fill = ansi.TextStyle.RESET_ALL + fill_char_style_begin + right_fill + fill_char_style_end right_fill += ansi.TextStyle.RESET_ALL - # Write the line and restore any styles from previous lines - text_buf.write(left_fill + aggregate_styles + line + right_fill) + # Write the line and restore styles from previous lines which are still in effect + text_buf.write(left_fill + ''.join(previous_styles) + line + right_fill) - # Update the aggregate with styles in this line - aggregate_styles += ''.join(line_styles.values()) + # Update list of styles that are still in effect for the next line + previous_styles.extend(line_styles) + previous_styles = _remove_overridden_styles(previous_styles) return text_buf.getvalue() @@ -985,7 +1067,7 @@ def truncate_line(line: str, max_width: int, *, tab_width: int = 4) -> str: return line # Find all style sequences in the line - styles = get_styles_in_text(line) + styles_dict = get_styles_dict(line) # Add characters one by one and preserve all style sequences done = False @@ -995,10 +1077,10 @@ def truncate_line(line: str, max_width: int, *, tab_width: int = 4) -> str: while not done: # Check if a style sequence is at this index. These don't count toward display width. - if index in styles: - truncated_buf.write(styles[index]) - style_len = len(styles[index]) - styles.pop(index) + if index in styles_dict: + truncated_buf.write(styles_dict[index]) + style_len = len(styles_dict[index]) + styles_dict.pop(index) index += style_len continue @@ -1015,13 +1097,16 @@ def truncate_line(line: str, max_width: int, *, tab_width: int = 4) -> str: truncated_buf.write(char) index += 1 - # Append remaining style sequences from original string - truncated_buf.write(''.join(styles.values())) + # Filter out overridden styles from the remaining ones + remaining_styles = _remove_overridden_styles(list(styles_dict.values())) + + # Append the remaining styles to the truncated text + truncated_buf.write(''.join(remaining_styles)) return truncated_buf.getvalue() -def get_styles_in_text(text: str) -> Dict[int, str]: +def get_styles_dict(text: str) -> Dict[int, str]: """ Return an OrderedDict containing all ANSI style sequences found in a string diff --git a/docs/features/argument_processing.rst b/docs/features/argument_processing.rst index 6ece2a81..67b94878 100644 --- a/docs/features/argument_processing.rst +++ b/docs/features/argument_processing.rst @@ -82,9 +82,6 @@ Here's what it looks like:: to bugs in CPython prior to Python 3.7 which make it impossible to make a deep copy of an instance of a ``argparse.ArgumentParser``. - See the table_display_ example for a work-around that demonstrates how to - create a function which returns a unique instance of the parser you want. - .. note:: @@ -92,8 +89,6 @@ Here's what it looks like:: parser based on the name of the method it is decorating. This will override anything you specify in ``prog`` variable when creating the argument parser. -.. _table_display: https://github.com/python-cmd2/cmd2/blob/master/examples/table_display.py - Help Messages ------------- diff --git a/tests/test_ansi.py b/tests/test_ansi.py index f769d718..6ccdbb13 100644 --- a/tests/test_ansi.py +++ b/tests/test_ansi.py @@ -253,3 +253,47 @@ def test_rgb_bounds(r, g, b, valid): ansi.RgbFg(r, g, b) with pytest.raises(ValueError): ansi.RgbBg(r, g, b) + + +def test_std_color_re(): + """Test regular expressions for matching standard foreground and background colors""" + for color in ansi.Fg: + assert ansi.STD_FG_RE.match(str(color)) + assert not ansi.STD_BG_RE.match(str(color)) + for color in ansi.Bg: + assert ansi.STD_BG_RE.match(str(color)) + assert not ansi.STD_FG_RE.match(str(color)) + + # Test an invalid color code + assert not ansi.STD_FG_RE.match(f'{ansi.CSI}38m') + assert not ansi.STD_BG_RE.match(f'{ansi.CSI}48m') + + +def test_eight_bit_color_re(): + """Test regular expressions for matching eight-bit foreground and background colors""" + for color in ansi.EightBitFg: + assert ansi.EIGHT_BIT_FG_RE.match(str(color)) + assert not ansi.EIGHT_BIT_BG_RE.match(str(color)) + for color in ansi.EightBitBg: + assert ansi.EIGHT_BIT_BG_RE.match(str(color)) + assert not ansi.EIGHT_BIT_FG_RE.match(str(color)) + + # Test invalid eight-bit value (256) + assert not ansi.EIGHT_BIT_FG_RE.match(f'{ansi.CSI}38;5;256m') + assert not ansi.EIGHT_BIT_BG_RE.match(f'{ansi.CSI}48;5;256m') + + +def test_rgb_color_re(): + """Test regular expressions for matching RGB foreground and background colors""" + for i in range(256): + fg_color = ansi.RgbFg(i, i, i) + assert ansi.RGB_FG_RE.match(str(fg_color)) + assert not ansi.RGB_BG_RE.match(str(fg_color)) + + bg_color = ansi.RgbBg(i, i, i) + assert ansi.RGB_BG_RE.match(str(bg_color)) + assert not ansi.RGB_FG_RE.match(str(bg_color)) + + # Test invalid RGB value (256) + assert not ansi.RGB_FG_RE.match(f'{ansi.CSI}38;2;256;256;256m') + assert not ansi.RGB_BG_RE.match(f'{ansi.CSI}48;2;256;256;256m') diff --git a/tests/test_utils.py b/tests/test_utils.py index 9c161774..72d9176e 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -11,6 +11,9 @@ import time import pytest import cmd2.utils as cu +from cmd2 import ( + ansi, +) from cmd2.constants import ( HORIZONTAL_ELLIPSIS, ) @@ -341,6 +344,79 @@ def test_context_flag_exit_err(context_flag): context_flag.__exit__() +def test_remove_overridden_styles(): + from typing import ( + List, + ) + + from cmd2 import ( + Bg, + EightBitBg, + EightBitFg, + Fg, + RgbBg, + RgbFg, + TextStyle, + ) + + def make_strs(styles_list: List[ansi.AnsiSequence]) -> List[str]: + return [str(s) for s in styles_list] + + # Test Reset All + styles_to_parse = make_strs([Fg.BLUE, TextStyle.UNDERLINE_DISABLE, TextStyle.INTENSITY_DIM, TextStyle.RESET_ALL]) + expected = make_strs([TextStyle.RESET_ALL]) + assert cu._remove_overridden_styles(styles_to_parse) == expected + + styles_to_parse = make_strs([Fg.BLUE, TextStyle.UNDERLINE_DISABLE, TextStyle.INTENSITY_DIM, TextStyle.ALT_RESET_ALL]) + expected = make_strs([TextStyle.ALT_RESET_ALL]) + assert cu._remove_overridden_styles(styles_to_parse) == expected + + # Test colors + styles_to_parse = make_strs([Fg.BLUE, Fg.RED, Fg.GREEN, Bg.BLUE, Bg.RED, Bg.GREEN]) + expected = make_strs([Fg.GREEN, Bg.GREEN]) + assert cu._remove_overridden_styles(styles_to_parse) == expected + + styles_to_parse = make_strs([EightBitFg.BLUE, EightBitFg.RED, EightBitBg.BLUE, EightBitBg.RED]) + expected = make_strs([EightBitFg.RED, EightBitBg.RED]) + assert cu._remove_overridden_styles(styles_to_parse) == expected + + styles_to_parse = make_strs([RgbFg(0, 3, 4), RgbFg(5, 6, 7), RgbBg(8, 9, 10), RgbBg(11, 12, 13)]) + expected = make_strs([RgbFg(5, 6, 7), RgbBg(11, 12, 13)]) + assert cu._remove_overridden_styles(styles_to_parse) == expected + + # Test text styles + styles_to_parse = make_strs([TextStyle.INTENSITY_DIM, TextStyle.INTENSITY_NORMAL, TextStyle.ITALIC_ENABLE]) + expected = make_strs([TextStyle.INTENSITY_NORMAL, TextStyle.ITALIC_ENABLE]) + assert cu._remove_overridden_styles(styles_to_parse) == expected + + styles_to_parse = make_strs([TextStyle.INTENSITY_DIM, TextStyle.ITALIC_ENABLE, TextStyle.ITALIC_DISABLE]) + expected = make_strs([TextStyle.INTENSITY_DIM, TextStyle.ITALIC_DISABLE]) + assert cu._remove_overridden_styles(styles_to_parse) == expected + + styles_to_parse = make_strs([TextStyle.INTENSITY_BOLD, TextStyle.OVERLINE_DISABLE, TextStyle.OVERLINE_ENABLE]) + expected = make_strs([TextStyle.INTENSITY_BOLD, TextStyle.OVERLINE_ENABLE]) + assert cu._remove_overridden_styles(styles_to_parse) == expected + + styles_to_parse = make_strs([TextStyle.OVERLINE_DISABLE, TextStyle.STRIKETHROUGH_DISABLE, TextStyle.STRIKETHROUGH_ENABLE]) + expected = make_strs([TextStyle.OVERLINE_DISABLE, TextStyle.STRIKETHROUGH_ENABLE]) + assert cu._remove_overridden_styles(styles_to_parse) == expected + + styles_to_parse = make_strs([TextStyle.STRIKETHROUGH_DISABLE, TextStyle.UNDERLINE_DISABLE, TextStyle.UNDERLINE_ENABLE]) + expected = make_strs([TextStyle.STRIKETHROUGH_DISABLE, TextStyle.UNDERLINE_ENABLE]) + assert cu._remove_overridden_styles(styles_to_parse) == expected + + styles_to_parse = make_strs([TextStyle.UNDERLINE_DISABLE]) + expected = make_strs([TextStyle.UNDERLINE_DISABLE]) + assert cu._remove_overridden_styles(styles_to_parse) == expected + + # Test unrecognized styles + slow_blink = ansi.CSI + str(5) + rapid_blink = ansi.CSI + str(6) + styles_to_parse = [slow_blink, rapid_blink] + expected = styles_to_parse + assert cu._remove_overridden_styles(styles_to_parse) == expected + + def test_truncate_line(): line = 'long' max_width = 3 @@ -397,26 +473,30 @@ def test_truncate_with_style(): TextStyle, ) - before_style = Fg.BLUE + TextStyle.UNDERLINE_ENABLE - after_style = Fg.RESET + TextStyle.UNDERLINE_DISABLE + before_text = Fg.BLUE + TextStyle.UNDERLINE_ENABLE + after_text = Fg.RESET + TextStyle.UNDERLINE_DISABLE + TextStyle.ITALIC_ENABLE + TextStyle.ITALIC_DISABLE + + # This is what the styles after the truncated text should look like since they will be + # filtered by _remove_overridden_styles. + filtered_after_text = Fg.RESET + TextStyle.UNDERLINE_DISABLE + TextStyle.ITALIC_DISABLE # Style only before truncated text - line = before_style + 'long' + line = before_text + 'long' max_width = 3 truncated = cu.truncate_line(line, max_width) - assert truncated == before_style + 'lo' + HORIZONTAL_ELLIPSIS + assert truncated == before_text + 'lo' + HORIZONTAL_ELLIPSIS # Style before and after truncated text - line = before_style + 'long' + after_style + line = before_text + 'long' + after_text max_width = 3 truncated = cu.truncate_line(line, max_width) - assert truncated == before_style + 'lo' + HORIZONTAL_ELLIPSIS + after_style + assert truncated == before_text + 'lo' + HORIZONTAL_ELLIPSIS + filtered_after_text # Style only after truncated text - line = 'long' + after_style + line = 'long' + after_text max_width = 3 truncated = cu.truncate_line(line, max_width) - assert truncated == 'lo' + HORIZONTAL_ELLIPSIS + after_style + assert truncated == 'lo' + HORIZONTAL_ELLIPSIS + filtered_after_text def test_align_text_fill_char_is_tab(): @@ -551,10 +631,6 @@ def test_align_text_has_unprintable(): def test_align_text_term_width(): import shutil - from cmd2 import ( - ansi, - ) - text = 'foo' fill_char = ' ' @@ -574,11 +650,28 @@ def test_align_left(): def test_align_left_multiline(): + # Without style text = "foo\nshoes" fill_char = '-' width = 7 aligned = cu.align_left(text, fill_char=fill_char, width=width) - assert aligned == ('foo----\n' 'shoes--') + assert aligned == 'foo----\nshoes--' + + # With style + reset_all = str(ansi.TextStyle.RESET_ALL) + blue = str(ansi.Fg.BLUE) + red = str(ansi.Fg.RED) + green = str(ansi.Fg.GREEN) + fg_reset = str(ansi.Fg.RESET) + + text = f"{blue}foo{red}moo\nshoes{fg_reset}" + fill_char = f"{green}-{fg_reset}" + width = 7 + aligned = cu.align_left(text, fill_char=fill_char, width=width) + + expected = f"{reset_all}{blue}foo{red}moo{reset_all}{green}-{fg_reset}{reset_all}\n" + expected += f"{reset_all}{red}shoes{fg_reset}{reset_all}{green}--{fg_reset}{reset_all}" + assert aligned == expected def test_align_left_wide_text(): @@ -615,11 +708,28 @@ def test_align_center(): def test_align_center_multiline(): + # Without style text = "foo\nshoes" fill_char = '-' width = 7 aligned = cu.align_center(text, fill_char=fill_char, width=width) - assert aligned == ('--foo--\n' '-shoes-') + assert aligned == '--foo--\n-shoes-' + + # With style + reset_all = str(ansi.TextStyle.RESET_ALL) + blue = str(ansi.Fg.BLUE) + red = str(ansi.Fg.RED) + green = str(ansi.Fg.GREEN) + fg_reset = str(ansi.Fg.RESET) + + text = f"{blue}foo{red}moo\nshoes{fg_reset}" + fill_char = f"{green}-{fg_reset}" + width = 10 + aligned = cu.align_center(text, fill_char=fill_char, width=width) + + expected = f"{reset_all}{green}--{fg_reset}{reset_all}{blue}foo{red}moo{reset_all}{green}--{fg_reset}{reset_all}\n" + expected += f"{reset_all}{green}--{fg_reset}{reset_all}{red}shoes{fg_reset}{reset_all}{green}---{fg_reset}{reset_all}" + assert aligned == expected def test_align_center_wide_text(): @@ -665,11 +775,28 @@ def test_align_right(): def test_align_right_multiline(): + # Without style text = "foo\nshoes" fill_char = '-' width = 7 aligned = cu.align_right(text, fill_char=fill_char, width=width) - assert aligned == ('----foo\n' '--shoes') + assert aligned == '----foo\n--shoes' + + # With style + reset_all = str(ansi.TextStyle.RESET_ALL) + blue = str(ansi.Fg.BLUE) + red = str(ansi.Fg.RED) + green = str(ansi.Fg.GREEN) + fg_reset = str(ansi.Fg.RESET) + + text = f"{blue}foo{red}moo\nshoes{fg_reset}" + fill_char = f"{green}-{fg_reset}" + width = 7 + aligned = cu.align_right(text, fill_char=fill_char, width=width) + + expected = f"{reset_all}{green}-{fg_reset}{reset_all}{blue}foo{red}moo{reset_all}\n" + expected += f"{reset_all}{green}--{fg_reset}{reset_all}{red}shoes{fg_reset}{reset_all}" + assert aligned == expected def test_align_right_wide_text(): |