1# SPDX-License-Identifier: GPL-2.0+ 2# Copyright (c) 2011 The Chromium OS Authors. 3# 4 5"""Terminal utilities 6 7This module handles terminal interaction including ANSI color codes. 8""" 9 10import os 11import re 12import shutil 13import sys 14 15# Selection of when we want our output to be colored 16COLOR_IF_TERMINAL, COLOR_ALWAYS, COLOR_NEVER = range(3) 17 18# Initially, we are set up to print to the terminal 19print_test_mode = False 20print_test_list = [] 21 22# The length of the last line printed without a newline 23last_print_len = None 24 25# credit: 26# stackoverflow.com/questions/14693701/how-can-i-remove-the-ansi-escape-sequences-from-a-string-in-python 27ansi_escape = re.compile(r'\x1b(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])') 28 29class PrintLine: 30 """A line of text output 31 32 Members: 33 text: Text line that was printed 34 newline: True to output a newline after the text 35 colour: Text colour to use 36 """ 37 def __init__(self, text, colour, newline=True, bright=True): 38 self.text = text 39 self.newline = newline 40 self.colour = colour 41 self.bright = bright 42 43 def __eq__(self, other): 44 return (self.text == other.text and 45 self.newline == other.newline and 46 self.colour == other.colour and 47 self.bright == other.bright) 48 49 def __str__(self): 50 return ("newline=%s, colour=%s, bright=%d, text='%s'" % 51 (self.newline, self.colour, self.bright, self.text)) 52 53 54def CalcAsciiLen(text): 55 """Calculate the length of a string, ignoring any ANSI sequences 56 57 When displayed on a terminal, ANSI sequences don't take any space, so we 58 need to ignore them when calculating the length of a string. 59 60 Args: 61 text: Text to check 62 63 Returns: 64 Length of text, after skipping ANSI sequences 65 66 >>> col = Color(COLOR_ALWAYS) 67 >>> text = col.Color(Color.RED, 'abc') 68 >>> len(text) 69 14 70 >>> CalcAsciiLen(text) 71 3 72 >>> 73 >>> text += 'def' 74 >>> CalcAsciiLen(text) 75 6 76 >>> text += col.Color(Color.RED, 'abc') 77 >>> CalcAsciiLen(text) 78 9 79 """ 80 result = ansi_escape.sub('', text) 81 return len(result) 82 83def TrimAsciiLen(text, size): 84 """Trim a string containing ANSI sequences to the given ASCII length 85 86 The string is trimmed with ANSI sequences being ignored for the length 87 calculation. 88 89 >>> col = Color(COLOR_ALWAYS) 90 >>> text = col.Color(Color.RED, 'abc') 91 >>> len(text) 92 14 93 >>> CalcAsciiLen(TrimAsciiLen(text, 4)) 94 3 95 >>> CalcAsciiLen(TrimAsciiLen(text, 2)) 96 2 97 >>> text += 'def' 98 >>> CalcAsciiLen(TrimAsciiLen(text, 4)) 99 4 100 >>> text += col.Color(Color.RED, 'ghi') 101 >>> CalcAsciiLen(TrimAsciiLen(text, 7)) 102 7 103 """ 104 if CalcAsciiLen(text) < size: 105 return text 106 pos = 0 107 out = '' 108 left = size 109 110 # Work through each ANSI sequence in turn 111 for m in ansi_escape.finditer(text): 112 # Find the text before the sequence and add it to our string, making 113 # sure it doesn't overflow 114 before = text[pos:m.start()] 115 toadd = before[:left] 116 out += toadd 117 118 # Figure out how much non-ANSI space we have left 119 left -= len(toadd) 120 121 # Add the ANSI sequence and move to the position immediately after it 122 out += m.group() 123 pos = m.start() + len(m.group()) 124 125 # Deal with text after the last ANSI sequence 126 after = text[pos:] 127 toadd = after[:left] 128 out += toadd 129 130 return out 131 132 133def Print(text='', newline=True, colour=None, limit_to_line=False, bright=True): 134 """Handle a line of output to the terminal. 135 136 In test mode this is recorded in a list. Otherwise it is output to the 137 terminal. 138 139 Args: 140 text: Text to print 141 newline: True to add a new line at the end of the text 142 colour: Colour to use for the text 143 """ 144 global last_print_len 145 146 if print_test_mode: 147 print_test_list.append(PrintLine(text, colour, newline, bright)) 148 else: 149 if colour: 150 col = Color() 151 text = col.Color(colour, text, bright=bright) 152 if newline: 153 print(text) 154 last_print_len = None 155 else: 156 if limit_to_line: 157 cols = shutil.get_terminal_size().columns 158 text = TrimAsciiLen(text, cols) 159 print(text, end='', flush=True) 160 last_print_len = CalcAsciiLen(text) 161 162def PrintClear(): 163 """Clear a previously line that was printed with no newline""" 164 global last_print_len 165 166 if last_print_len: 167 print('\r%s\r' % (' '* last_print_len), end='', flush=True) 168 last_print_len = None 169 170def SetPrintTestMode(enable=True): 171 """Go into test mode, where all printing is recorded""" 172 global print_test_mode 173 174 print_test_mode = enable 175 GetPrintTestLines() 176 177def GetPrintTestLines(): 178 """Get a list of all lines output through Print() 179 180 Returns: 181 A list of PrintLine objects 182 """ 183 global print_test_list 184 185 ret = print_test_list 186 print_test_list = [] 187 return ret 188 189def EchoPrintTestLines(): 190 """Print out the text lines collected""" 191 for line in print_test_list: 192 if line.colour: 193 col = Color() 194 print(col.Color(line.colour, line.text), end='') 195 else: 196 print(line.text, end='') 197 if line.newline: 198 print() 199 200 201class Color(object): 202 """Conditionally wraps text in ANSI color escape sequences.""" 203 BLACK, RED, GREEN, YELLOW, BLUE, MAGENTA, CYAN, WHITE = range(8) 204 BOLD = -1 205 BRIGHT_START = '\033[1;%dm' 206 NORMAL_START = '\033[22;%dm' 207 BOLD_START = '\033[1m' 208 RESET = '\033[0m' 209 210 def __init__(self, colored=COLOR_IF_TERMINAL): 211 """Create a new Color object, optionally disabling color output. 212 213 Args: 214 enabled: True if color output should be enabled. If False then this 215 class will not add color codes at all. 216 """ 217 try: 218 self._enabled = (colored == COLOR_ALWAYS or 219 (colored == COLOR_IF_TERMINAL and 220 os.isatty(sys.stdout.fileno()))) 221 except: 222 self._enabled = False 223 224 def Start(self, color, bright=True): 225 """Returns a start color code. 226 227 Args: 228 color: Color to use, .e.g BLACK, RED, etc. 229 230 Returns: 231 If color is enabled, returns an ANSI sequence to start the given 232 color, otherwise returns empty string 233 """ 234 if self._enabled: 235 base = self.BRIGHT_START if bright else self.NORMAL_START 236 return base % (color + 30) 237 return '' 238 239 def Stop(self): 240 """Returns a stop color code. 241 242 Returns: 243 If color is enabled, returns an ANSI color reset sequence, 244 otherwise returns empty string 245 """ 246 if self._enabled: 247 return self.RESET 248 return '' 249 250 def Color(self, color, text, bright=True): 251 """Returns text with conditionally added color escape sequences. 252 253 Keyword arguments: 254 color: Text color -- one of the color constants defined in this 255 class. 256 text: The text to color. 257 258 Returns: 259 If self._enabled is False, returns the original text. If it's True, 260 returns text with color escape sequences based on the value of 261 color. 262 """ 263 if not self._enabled: 264 return text 265 if color == self.BOLD: 266 start = self.BOLD_START 267 else: 268 base = self.BRIGHT_START if bright else self.NORMAL_START 269 start = base % (color + 30) 270 return start + text + self.RESET 271