| # SPDX-License-Identifier: GPL-2.0+ |
| # Copyright (c) 2011 The Chromium OS Authors. |
| # |
| |
| """Terminal utilities |
| |
| This module handles terminal interaction including ANSI color codes. |
| """ |
| |
| import os |
| import re |
| import shutil |
| import sys |
| |
| # Selection of when we want our output to be colored |
| COLOR_IF_TERMINAL, COLOR_ALWAYS, COLOR_NEVER = range(3) |
| |
| # Initially, we are set up to print to the terminal |
| print_test_mode = False |
| print_test_list = [] |
| |
| # The length of the last line printed without a newline |
| last_print_len = None |
| |
| # credit: |
| # stackoverflow.com/questions/14693701/how-can-i-remove-the-ansi-escape-sequences-from-a-string-in-python |
| ansi_escape = re.compile(r'\x1b(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])') |
| |
| class PrintLine: |
| """A line of text output |
| |
| Members: |
| text: Text line that was printed |
| newline: True to output a newline after the text |
| colour: Text colour to use |
| """ |
| def __init__(self, text, colour, newline=True, bright=True): |
| self.text = text |
| self.newline = newline |
| self.colour = colour |
| self.bright = bright |
| |
| def __eq__(self, other): |
| return (self.text == other.text and |
| self.newline == other.newline and |
| self.colour == other.colour and |
| self.bright == other.bright) |
| |
| def __str__(self): |
| return ("newline=%s, colour=%s, bright=%d, text='%s'" % |
| (self.newline, self.colour, self.bright, self.text)) |
| |
| |
| def calc_ascii_len(text): |
| """Calculate the length of a string, ignoring any ANSI sequences |
| |
| When displayed on a terminal, ANSI sequences don't take any space, so we |
| need to ignore them when calculating the length of a string. |
| |
| Args: |
| text: Text to check |
| |
| Returns: |
| Length of text, after skipping ANSI sequences |
| |
| >>> col = Color(COLOR_ALWAYS) |
| >>> text = col.build(Color.RED, 'abc') |
| >>> len(text) |
| 14 |
| >>> calc_ascii_len(text) |
| 3 |
| >>> |
| >>> text += 'def' |
| >>> calc_ascii_len(text) |
| 6 |
| >>> text += col.build(Color.RED, 'abc') |
| >>> calc_ascii_len(text) |
| 9 |
| """ |
| result = ansi_escape.sub('', text) |
| return len(result) |
| |
| def trim_ascii_len(text, size): |
| """Trim a string containing ANSI sequences to the given ASCII length |
| |
| The string is trimmed with ANSI sequences being ignored for the length |
| calculation. |
| |
| >>> col = Color(COLOR_ALWAYS) |
| >>> text = col.build(Color.RED, 'abc') |
| >>> len(text) |
| 14 |
| >>> calc_ascii_len(trim_ascii_len(text, 4)) |
| 3 |
| >>> calc_ascii_len(trim_ascii_len(text, 2)) |
| 2 |
| >>> text += 'def' |
| >>> calc_ascii_len(trim_ascii_len(text, 4)) |
| 4 |
| >>> text += col.build(Color.RED, 'ghi') |
| >>> calc_ascii_len(trim_ascii_len(text, 7)) |
| 7 |
| """ |
| if calc_ascii_len(text) < size: |
| return text |
| pos = 0 |
| out = '' |
| left = size |
| |
| # Work through each ANSI sequence in turn |
| for m in ansi_escape.finditer(text): |
| # Find the text before the sequence and add it to our string, making |
| # sure it doesn't overflow |
| before = text[pos:m.start()] |
| toadd = before[:left] |
| out += toadd |
| |
| # Figure out how much non-ANSI space we have left |
| left -= len(toadd) |
| |
| # Add the ANSI sequence and move to the position immediately after it |
| out += m.group() |
| pos = m.start() + len(m.group()) |
| |
| # Deal with text after the last ANSI sequence |
| after = text[pos:] |
| toadd = after[:left] |
| out += toadd |
| |
| return out |
| |
| |
| def tprint(text='', newline=True, colour=None, limit_to_line=False, bright=True): |
| """Handle a line of output to the terminal. |
| |
| In test mode this is recorded in a list. Otherwise it is output to the |
| terminal. |
| |
| Args: |
| text: Text to print |
| newline: True to add a new line at the end of the text |
| colour: Colour to use for the text |
| """ |
| global last_print_len |
| |
| if print_test_mode: |
| print_test_list.append(PrintLine(text, colour, newline, bright)) |
| else: |
| if colour: |
| col = Color() |
| text = col.build(colour, text, bright=bright) |
| if newline: |
| print(text) |
| last_print_len = None |
| else: |
| if limit_to_line: |
| cols = shutil.get_terminal_size().columns |
| text = trim_ascii_len(text, cols) |
| print(text, end='', flush=True) |
| last_print_len = calc_ascii_len(text) |
| |
| def print_clear(): |
| """Clear a previously line that was printed with no newline""" |
| global last_print_len |
| |
| if last_print_len: |
| if print_test_mode: |
| print_test_list.append(PrintLine(None, None, None, None)) |
| else: |
| print('\r%s\r' % (' '* last_print_len), end='', flush=True) |
| last_print_len = None |
| |
| def set_print_test_mode(enable=True): |
| """Go into test mode, where all printing is recorded""" |
| global print_test_mode |
| |
| print_test_mode = enable |
| get_print_test_lines() |
| |
| def get_print_test_lines(): |
| """Get a list of all lines output through tprint() |
| |
| Returns: |
| A list of PrintLine objects |
| """ |
| global print_test_list |
| |
| ret = print_test_list |
| print_test_list = [] |
| return ret |
| |
| def echo_print_test_lines(): |
| """Print out the text lines collected""" |
| for line in print_test_list: |
| if line.colour: |
| col = Color() |
| print(col.build(line.colour, line.text), end='') |
| else: |
| print(line.text, end='') |
| if line.newline: |
| print() |
| |
| |
| class Color(object): |
| """Conditionally wraps text in ANSI color escape sequences.""" |
| BLACK, RED, GREEN, YELLOW, BLUE, MAGENTA, CYAN, WHITE = range(8) |
| BOLD = -1 |
| BRIGHT_START = '\033[1;%dm' |
| NORMAL_START = '\033[22;%dm' |
| BOLD_START = '\033[1m' |
| RESET = '\033[0m' |
| |
| def __init__(self, colored=COLOR_IF_TERMINAL): |
| """Create a new Color object, optionally disabling color output. |
| |
| Args: |
| enabled: True if color output should be enabled. If False then this |
| class will not add color codes at all. |
| """ |
| try: |
| self._enabled = (colored == COLOR_ALWAYS or |
| (colored == COLOR_IF_TERMINAL and |
| os.isatty(sys.stdout.fileno()))) |
| except: |
| self._enabled = False |
| |
| def start(self, color, bright=True): |
| """Returns a start color code. |
| |
| Args: |
| color: Color to use, .e.g BLACK, RED, etc. |
| |
| Returns: |
| If color is enabled, returns an ANSI sequence to start the given |
| color, otherwise returns empty string |
| """ |
| if self._enabled: |
| base = self.BRIGHT_START if bright else self.NORMAL_START |
| return base % (color + 30) |
| return '' |
| |
| def stop(self): |
| """Returns a stop color code. |
| |
| Returns: |
| If color is enabled, returns an ANSI color reset sequence, |
| otherwise returns empty string |
| """ |
| if self._enabled: |
| return self.RESET |
| return '' |
| |
| def build(self, color, text, bright=True): |
| """Returns text with conditionally added color escape sequences. |
| |
| Keyword arguments: |
| color: Text color -- one of the color constants defined in this |
| class. |
| text: The text to color. |
| |
| Returns: |
| If self._enabled is False, returns the original text. If it's True, |
| returns text with color escape sequences based on the value of |
| color. |
| """ |
| if not self._enabled: |
| return text |
| if color == self.BOLD: |
| start = self.BOLD_START |
| else: |
| base = self.BRIGHT_START if bright else self.NORMAL_START |
| start = base % (color + 30) |
| return start + text + self.RESET |