From 5074f2573b08f4f44ab3a41ac7a450c4844dcd3d Mon Sep 17 00:00:00 2001 From: Todd Leonhardt Date: Sun, 30 Jun 2019 17:59:38 -0400 Subject: Initial changes for encapsulation of colorama usage within ansi.py Changes include: - ansi.py is now the only cmd2 code which depends on colorama - cmd2 tests no longer rely on colorama - Added new functions to ansi.py: - async_alert_str() - set_title_str - Added unit test for set_title_str TODO: - Refactor async_alert_str so cursor position is passed in and import from rl_utils is no longer required - Add unit test(s) for async_alert_str - Refactor FG_COLORS and BG_COLORS in ansi.py to not use constants from colorama --- cmd2/ansi.py | 74 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ cmd2/cmd2.py | 58 +++--------------------------------------- tests/test_ansi.py | 38 ++++++++++++++++++---------- 3 files changed, 103 insertions(+), 67 deletions(-) diff --git a/cmd2/ansi.py b/cmd2/ansi.py index 035991bb..59dcb43c 100644 --- a/cmd2/ansi.py +++ b/cmd2/ansi.py @@ -8,6 +8,9 @@ import colorama from colorama import Fore, Back, Style from wcwidth import wcswidth +# On Windows, filter ANSI escape codes out of text sent to stdout/stderr, and replace them with equivalent Win32 calls +colorama.init(strip=False) + # Values for allow_ansi setting ANSI_NEVER = 'Never' ANSI_TERMINAL = 'Terminal' @@ -65,6 +68,9 @@ FG_RESET = FG_COLORS['reset'] BG_RESET = BG_COLORS['reset'] RESET_ALL = Style.RESET_ALL +BRIGHT = Style.BRIGHT +NORMAL = Style.NORMAL + # ANSI escape sequences not provided by colorama UNDERLINE_ENABLE = colorama.ansi.code_to_chars(4) UNDERLINE_DISABLE = colorama.ansi.code_to_chars(24) @@ -180,3 +186,71 @@ def style(text: Any, *, fg: str = '', bg: str = '', bold: bool = False, underlin style_success = functools.partial(style, fg='green', bold=True) style_warning = functools.partial(style, fg='bright_yellow') style_error = functools.partial(style, fg='bright_red') + + +def async_alert_str(*, prompt: str, line: str, alert_msg: str) -> str: + """Calculate the desired string, including ANSI escape codes, for displaying an asynchronous alert message. + + :param prompt: prompt that is displayed on the current line + :param line: current contents of the Readline line buffer + :param alert_msg: the message to display to the user + :return: the correct string so that the alert message appears to the user to be printed above the current line. + """ + import shutil + from colorama import Cursor + + from .rl_utils import rl_get_point + + # Get the size of the terminal + terminal_size = shutil.get_terminal_size() + + # Split the prompt lines since it can contain newline characters. + prompt_lines = prompt.splitlines() + + # Calculate how many terminal lines are taken up by all prompt lines except for the last one. + # That will be included in the input lines calculations since that is where the cursor is. + num_prompt_terminal_lines = 0 + for line in prompt_lines[:-1]: + line_width = ansi_safe_wcswidth(line) + num_prompt_terminal_lines += int(line_width / terminal_size.columns) + 1 + + # Now calculate how many terminal lines are take up by the input + last_prompt_line = prompt_lines[-1] + last_prompt_line_width = ansi_safe_wcswidth(last_prompt_line) + + input_width = last_prompt_line_width + ansi_safe_wcswidth(line) + + num_input_terminal_lines = int(input_width / terminal_size.columns) + 1 + + # Get the cursor's offset from the beginning of the first input line + cursor_input_offset = last_prompt_line_width + rl_get_point() + + # Calculate what input line the cursor is on + cursor_input_line = int(cursor_input_offset / terminal_size.columns) + 1 + + # Create a string that when printed will clear all input lines and display the alert + terminal_str = '' + + # Move the cursor down to the last input line + if cursor_input_line != num_input_terminal_lines: + terminal_str += Cursor.DOWN(num_input_terminal_lines - cursor_input_line) + + # Clear each line from the bottom up so that the cursor ends up on the first prompt line + total_lines = num_prompt_terminal_lines + num_input_terminal_lines + terminal_str += (colorama.ansi.clear_line() + Cursor.UP(1)) * (total_lines - 1) + + # Clear the first prompt line + terminal_str += colorama.ansi.clear_line() + + # Move the cursor to the beginning of the first prompt line and print the alert + terminal_str += '\r' + alert_msg + return terminal_str + + +def set_title_str(title: str) -> str: + """Get the required string, including ANSI escape codes, for setting window title for the terminal. + + :param title: new title for the window + :return string to write to sys.stderr in order to set the window title to the desired test + """ + return colorama.ansi.set_title(title) diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index d41a631d..77af3910 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -42,8 +42,6 @@ from collections import namedtuple from contextlib import redirect_stdout from typing import Any, Callable, Dict, List, Mapping, Optional, Tuple, Type, Union -import colorama - from . import ansi from . import constants from . import plugin @@ -54,7 +52,7 @@ from .history import History, HistoryItem from .parsing import StatementParser, Statement, Macro, MacroArg, shlex_split # Set up readline -from .rl_utils import rl_type, RlType, rl_get_point, rl_set_prompt, vt100_support, rl_make_safe_prompt +from .rl_utils import rl_type, RlType, rl_set_prompt, vt100_support, rl_make_safe_prompt if rl_type == RlType.NONE: # pragma: no cover rl_warning = "Readline features including tab completion have been disabled since no \n" \ @@ -359,9 +357,6 @@ class Cmd(cmd.Cmd): except AttributeError: pass - # Override whether ansi codes should be stripped from the output since cmd2 has its own logic for doing this - colorama.init(strip=False) - # initialize plugin system # needs to be done before we call __init__(0) self._initialize_plugin_system() @@ -3782,9 +3777,6 @@ class Cmd(cmd.Cmd): if not (vt100_support and self.use_rawinput): return - import shutil - from colorama import Cursor - # Sanity check that can't fail if self.terminal_lock was acquired before calling this function if self.terminal_lock.acquire(blocking=False): @@ -3808,50 +3800,8 @@ class Cmd(cmd.Cmd): update_terminal = True if update_terminal: - # Get the size of the terminal - terminal_size = shutil.get_terminal_size() - - # Split the prompt lines since it can contain newline characters. - prompt_lines = current_prompt.splitlines() - - # Calculate how many terminal lines are taken up by all prompt lines except for the last one. - # That will be included in the input lines calculations since that is where the cursor is. - num_prompt_terminal_lines = 0 - for line in prompt_lines[:-1]: - line_width = ansi.ansi_safe_wcswidth(line) - num_prompt_terminal_lines += int(line_width / terminal_size.columns) + 1 - - # Now calculate how many terminal lines are take up by the input - last_prompt_line = prompt_lines[-1] - last_prompt_line_width = ansi.ansi_safe_wcswidth(last_prompt_line) - - input_width = last_prompt_line_width + ansi.ansi_safe_wcswidth(readline.get_line_buffer()) - - num_input_terminal_lines = int(input_width / terminal_size.columns) + 1 - - # Get the cursor's offset from the beginning of the first input line - cursor_input_offset = last_prompt_line_width + rl_get_point() - - # Calculate what input line the cursor is on - cursor_input_line = int(cursor_input_offset / terminal_size.columns) + 1 - - # Create a string that when printed will clear all input lines and display the alert - terminal_str = '' - - # Move the cursor down to the last input line - if cursor_input_line != num_input_terminal_lines: - terminal_str += Cursor.DOWN(num_input_terminal_lines - cursor_input_line) - - # Clear each line from the bottom up so that the cursor ends up on the first prompt line - total_lines = num_prompt_terminal_lines + num_input_terminal_lines - terminal_str += (colorama.ansi.clear_line() + Cursor.UP(1)) * (total_lines - 1) - - # Clear the first prompt line - terminal_str += colorama.ansi.clear_line() - - # Move the cursor to the beginning of the first prompt line and print the alert - terminal_str += '\r' + alert_msg - + terminal_str = ansi.async_alert_str(prompt=current_prompt, line=readline.get_line_buffer(), + alert_msg=alert_msg) if rl_type == RlType.GNU: sys.stderr.write(terminal_str) elif rl_type == RlType.PYREADLINE: @@ -3904,7 +3854,7 @@ class Cmd(cmd.Cmd): # Sanity check that can't fail if self.terminal_lock was acquired before calling this function if self.terminal_lock.acquire(blocking=False): try: - sys.stderr.write(colorama.ansi.set_title(title)) + sys.stderr.write(ansi.set_title_str(title)) except AttributeError: # Debugging in Pycharm has issues with setting terminal title pass diff --git a/tests/test_ansi.py b/tests/test_ansi.py index bf628ef0..53c917d5 100644 --- a/tests/test_ansi.py +++ b/tests/test_ansi.py @@ -4,7 +4,6 @@ Unit testing for cmd2/ansi.py module """ import pytest -from colorama import Fore, Back, Style import cmd2.ansi as ansi @@ -13,14 +12,14 @@ HELLO_WORLD = 'Hello, world!' def test_strip_ansi(): base_str = HELLO_WORLD - ansi_str = Fore.GREEN + base_str + Fore.RESET + ansi_str = ansi.style(base_str, fg='green') assert base_str != ansi_str assert base_str == ansi.strip_ansi(ansi_str) def test_ansi_safe_wcswidth(): base_str = HELLO_WORLD - ansi_str = Fore.GREEN + base_str + Fore.RESET + ansi_str = ansi.style(base_str, fg='green') assert ansi.ansi_safe_wcswidth(ansi_str) != len(ansi_str) @@ -32,19 +31,21 @@ def test_style_none(): def test_style_fg(): base_str = HELLO_WORLD - ansi_str = Fore.BLUE + base_str + Fore.RESET - assert ansi.style(base_str, fg='blue') == ansi_str + fg_color = 'blue' + ansi_str = ansi.FG_COLORS[fg_color] + base_str + ansi.FG_RESET + assert ansi.style(base_str, fg=fg_color) == ansi_str def test_style_bg(): base_str = HELLO_WORLD - ansi_str = Back.GREEN + base_str + Back.RESET - assert ansi.style(base_str, bg='green') == ansi_str + bg_color = 'green' + ansi_str = ansi.BG_COLORS[bg_color] + base_str + ansi.BG_RESET + assert ansi.style(base_str, bg=bg_color) == ansi_str def test_style_bold(): base_str = HELLO_WORLD - ansi_str = Style.BRIGHT + base_str + Style.NORMAL + ansi_str = ansi.BRIGHT + base_str + ansi.NORMAL assert ansi.style(base_str, bold=True) == ansi_str @@ -56,9 +57,11 @@ def test_style_underline(): def test_style_multi(): base_str = HELLO_WORLD - ansi_str = Fore.BLUE + Back.GREEN + Style.BRIGHT + ansi.UNDERLINE_ENABLE + \ - base_str + Fore.RESET + Back.RESET + Style.NORMAL + ansi.UNDERLINE_DISABLE - assert ansi.style(base_str, fg='blue', bg='green', bold=True, underline=True) == ansi_str + fg_color = 'blue' + bg_color = 'green' + ansi_str = ansi.FG_COLORS[fg_color] + ansi.BG_COLORS[bg_color] + ansi.BRIGHT + ansi.UNDERLINE_ENABLE + \ + base_str + ansi.FG_RESET + ansi.BG_RESET + ansi.NORMAL + ansi.UNDERLINE_DISABLE + assert ansi.style(base_str, fg=fg_color, bg=bg_color, bold=True, underline=True) == ansi_str def test_style_color_not_exist(): @@ -72,7 +75,8 @@ def test_style_color_not_exist(): def test_fg_lookup_exist(): - assert ansi.fg_lookup('green') == Fore.GREEN + fg_color = 'green' + assert ansi.fg_lookup(fg_color) == ansi.FG_COLORS[fg_color] def test_fg_lookup_nonexist(): @@ -81,9 +85,17 @@ def test_fg_lookup_nonexist(): def test_bg_lookup_exist(): - assert ansi.bg_lookup('green') == Back.GREEN + bg_color = 'green' + assert ansi.bg_lookup(bg_color) == ansi.BG_COLORS[bg_color] def test_bg_lookup_nonexist(): with pytest.raises(ValueError): ansi.bg_lookup('bar') + + +def test_set_title_str(): + OSC = '\033]' + BEL = '\007' + title = HELLO_WORLD + assert ansi.set_title_str(title) == OSC + '2;' + title + BEL -- cgit v1.2.1 From 8417cfed896aa21753b55dce8be6ab8848edc6b3 Mon Sep 17 00:00:00 2001 From: Todd Leonhardt Date: Sun, 30 Jun 2019 18:14:48 -0400 Subject: Refactored ansi.async_alert_str() so that the cursor offset within the readline buffer is passed in This way, the function is independent of readline and can more easily be unit tested --- cmd2/ansi.py | 7 +++---- cmd2/cmd2.py | 4 ++-- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/cmd2/ansi.py b/cmd2/ansi.py index 59dcb43c..c0d8017a 100644 --- a/cmd2/ansi.py +++ b/cmd2/ansi.py @@ -188,19 +188,18 @@ style_warning = functools.partial(style, fg='bright_yellow') style_error = functools.partial(style, fg='bright_red') -def async_alert_str(*, prompt: str, line: str, alert_msg: str) -> str: +def async_alert_str(*, prompt: str, line: str, cursor_offset: int, alert_msg: str) -> str: """Calculate the desired string, including ANSI escape codes, for displaying an asynchronous alert message. :param prompt: prompt that is displayed on the current line :param line: current contents of the Readline line buffer + :param cursor_offset: the offset of the current cursor position within line :param alert_msg: the message to display to the user :return: the correct string so that the alert message appears to the user to be printed above the current line. """ import shutil from colorama import Cursor - from .rl_utils import rl_get_point - # Get the size of the terminal terminal_size = shutil.get_terminal_size() @@ -223,7 +222,7 @@ def async_alert_str(*, prompt: str, line: str, alert_msg: str) -> str: num_input_terminal_lines = int(input_width / terminal_size.columns) + 1 # Get the cursor's offset from the beginning of the first input line - cursor_input_offset = last_prompt_line_width + rl_get_point() + cursor_input_offset = last_prompt_line_width + cursor_offset # Calculate what input line the cursor is on cursor_input_line = int(cursor_input_offset / terminal_size.columns) + 1 diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 77af3910..217a92c8 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -52,7 +52,7 @@ from .history import History, HistoryItem from .parsing import StatementParser, Statement, Macro, MacroArg, shlex_split # Set up readline -from .rl_utils import rl_type, RlType, rl_set_prompt, vt100_support, rl_make_safe_prompt +from .rl_utils import rl_type, RlType, rl_get_point, rl_set_prompt, vt100_support, rl_make_safe_prompt if rl_type == RlType.NONE: # pragma: no cover rl_warning = "Readline features including tab completion have been disabled since no \n" \ @@ -3801,7 +3801,7 @@ class Cmd(cmd.Cmd): if update_terminal: terminal_str = ansi.async_alert_str(prompt=current_prompt, line=readline.get_line_buffer(), - alert_msg=alert_msg) + cursor_offset=rl_get_point(), alert_msg=alert_msg) if rl_type == RlType.GNU: sys.stderr.write(terminal_str) elif rl_type == RlType.PYREADLINE: -- cgit v1.2.1 From 031f2626e63708e4a7334826167a97d56fa29fdb Mon Sep 17 00:00:00 2001 From: Todd Leonhardt Date: Mon, 1 Jul 2019 00:34:07 -0400 Subject: Modified async_alert_str to also get passed the number of terminal columns and added unit tests for it --- cmd2/ansi.py | 14 +++++--------- cmd2/cmd2.py | 4 +++- tests/test_ansi.py | 11 +++++++++++ 3 files changed, 19 insertions(+), 10 deletions(-) diff --git a/cmd2/ansi.py b/cmd2/ansi.py index c0d8017a..f1b2def8 100644 --- a/cmd2/ansi.py +++ b/cmd2/ansi.py @@ -188,21 +188,17 @@ style_warning = functools.partial(style, fg='bright_yellow') style_error = functools.partial(style, fg='bright_red') -def async_alert_str(*, prompt: str, line: str, cursor_offset: int, alert_msg: str) -> str: +def async_alert_str(*, terminal_columns: int, prompt: str, line: str, cursor_offset: int, alert_msg: str) -> str: """Calculate the desired string, including ANSI escape codes, for displaying an asynchronous alert message. + :param terminal_columns: terminal width (number of columns) :param prompt: prompt that is displayed on the current line :param line: current contents of the Readline line buffer :param cursor_offset: the offset of the current cursor position within line :param alert_msg: the message to display to the user :return: the correct string so that the alert message appears to the user to be printed above the current line. """ - import shutil from colorama import Cursor - - # Get the size of the terminal - terminal_size = shutil.get_terminal_size() - # Split the prompt lines since it can contain newline characters. prompt_lines = prompt.splitlines() @@ -211,7 +207,7 @@ def async_alert_str(*, prompt: str, line: str, cursor_offset: int, alert_msg: st num_prompt_terminal_lines = 0 for line in prompt_lines[:-1]: line_width = ansi_safe_wcswidth(line) - num_prompt_terminal_lines += int(line_width / terminal_size.columns) + 1 + num_prompt_terminal_lines += int(line_width / terminal_columns) + 1 # Now calculate how many terminal lines are take up by the input last_prompt_line = prompt_lines[-1] @@ -219,13 +215,13 @@ def async_alert_str(*, prompt: str, line: str, cursor_offset: int, alert_msg: st input_width = last_prompt_line_width + ansi_safe_wcswidth(line) - num_input_terminal_lines = int(input_width / terminal_size.columns) + 1 + num_input_terminal_lines = int(input_width / terminal_columns) + 1 # Get the cursor's offset from the beginning of the first input line cursor_input_offset = last_prompt_line_width + cursor_offset # Calculate what input line the cursor is on - cursor_input_line = int(cursor_input_offset / terminal_size.columns) + 1 + cursor_input_line = int(cursor_input_offset / terminal_columns) + 1 # Create a string that when printed will clear all input lines and display the alert terminal_str = '' diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 7efa6849..a10219b1 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -3830,7 +3830,9 @@ class Cmd(cmd.Cmd): update_terminal = True if update_terminal: - terminal_str = ansi.async_alert_str(prompt=current_prompt, line=readline.get_line_buffer(), + import shutil + terminal_str = ansi.async_alert_str(terminal_columns=shutil.get_terminal_size().columns, + prompt=current_prompt, line=readline.get_line_buffer(), cursor_offset=rl_get_point(), alert_msg=alert_msg) if rl_type == RlType.GNU: sys.stderr.write(terminal_str) diff --git a/tests/test_ansi.py b/tests/test_ansi.py index 53c917d5..75e5ba35 100644 --- a/tests/test_ansi.py +++ b/tests/test_ansi.py @@ -99,3 +99,14 @@ def test_set_title_str(): BEL = '\007' title = HELLO_WORLD assert ansi.set_title_str(title) == OSC + '2;' + title + BEL + + +@pytest.mark.parametrize('cols, prompt, line, cursor, msg, expected', [ + (127, '(Cmd) ', 'help his', 12, ansi.style('Hello World!', fg='magenta'), '\x1b[2K\r\x1b[35mHello World!\x1b[39m'), + (127, '\n(Cmd) ', 'help ', 5, 'foo', '\x1b[2K\x1b[1A\x1b[2K\rfoo'), + (10, '(Cmd) ', 'help history of the american republic', 4, 'boo', '\x1b[3B\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\rboo') +]) +def test_async_alert_str(cols, prompt, line, cursor, msg, expected): + alert_str = ansi.async_alert_str(terminal_columns=cols, prompt=prompt, line=line, cursor_offset=cursor, + alert_msg=msg) + assert alert_str == expected -- cgit v1.2.1 From ef3eb96e99d0a04a2034f7815ed4bbc6d3838a42 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Mon, 1 Jul 2019 01:22:03 -0400 Subject: Whitespace and doc fixes --- cmd2/rl_utils.py | 2 +- examples/async_printing.py | 2 +- tests/test_ansi.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/cmd2/rl_utils.py b/cmd2/rl_utils.py index b5ba8e4a..7f47db79 100644 --- a/cmd2/rl_utils.py +++ b/cmd2/rl_utils.py @@ -58,7 +58,7 @@ if 'pyreadline' in sys.modules: retVal = False - # Check if ENABLE_VIRTUAL_TERMINAL_PROCESSING is already enabled + # Check if ENABLE_VIRTUAL_TERMINAL_PROCESSING is already enabled if (cur_mode.value & ENABLE_VIRTUAL_TERMINAL_PROCESSING) != 0: retVal = True diff --git a/examples/async_printing.py b/examples/async_printing.py index a9b20408..dd46c75f 100755 --- a/examples/async_printing.py +++ b/examples/async_printing.py @@ -19,7 +19,7 @@ ALERTS = ["Watch as this application prints alerts and updates the prompt", "Keep typing...", "Move that cursor...", "Pretty seamless, eh?", - "Feedback can also be given in the window title. Notice the arg count up there?", + "Feedback can also be given in the window title. Notice the alert count up there?", "You can stop and start the alerts by typing stop_alerts and start_alerts", "This demo will now continue to print alerts at random intervals" ] diff --git a/tests/test_ansi.py b/tests/test_ansi.py index 75e5ba35..056bb2db 100644 --- a/tests/test_ansi.py +++ b/tests/test_ansi.py @@ -109,4 +109,4 @@ def test_set_title_str(): def test_async_alert_str(cols, prompt, line, cursor, msg, expected): alert_str = ansi.async_alert_str(terminal_columns=cols, prompt=prompt, line=line, cursor_offset=cursor, alert_msg=msg) - assert alert_str == expected + assert alert_str == expected -- cgit v1.2.1