blob: 4b9a907a54705d284c701dc6419cee8dd3c759bc [file] [log] [blame]
Tom Rini10e47792018-05-06 17:58:06 -04001# SPDX-License-Identifier: GPL-2.0+
Simon Glass26132882012-01-14 15:12:45 +00002# Copyright (c) 2011 The Chromium OS Authors.
3#
Simon Glass26132882012-01-14 15:12:45 +00004
5"""Terminal utilities
6
7This module handles terminal interaction including ANSI color codes.
8"""
9
Simon Glass14d64e32025-04-29 07:21:59 -060010from contextlib import contextmanager
11from io import StringIO
Simon Glassa9f7edb2012-12-15 10:42:01 +000012import os
Simon Glass5f9325d2020-04-09 15:08:40 -060013import re
Simon Glassbbde0532020-04-09 15:08:41 -060014import shutil
Simon Glassa9f7edb2012-12-15 10:42:01 +000015import sys
16
17# Selection of when we want our output to be colored
18COLOR_IF_TERMINAL, COLOR_ALWAYS, COLOR_NEVER = range(3)
19
Simon Glassfb35f9f2014-09-05 19:00:06 -060020# Initially, we are set up to print to the terminal
21print_test_mode = False
22print_test_list = []
23
Simon Glass5f9325d2020-04-09 15:08:40 -060024# The length of the last line printed without a newline
25last_print_len = None
26
27# credit:
28# stackoverflow.com/questions/14693701/how-can-i-remove-the-ansi-escape-sequences-from-a-string-in-python
29ansi_escape = re.compile(r'\x1b(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])')
30
Simon Glasse8844982025-04-29 07:22:01 -060031# True if we are capturing console output
32CAPTURING = False
33
34# Set this to False to disable output-capturing globally
35USE_CAPTURE = True
36
37
Simon Glassfb35f9f2014-09-05 19:00:06 -060038class PrintLine:
39 """A line of text output
40
41 Members:
42 text: Text line that was printed
43 newline: True to output a newline after the text
44 colour: Text colour to use
45 """
Simon Glass3db916d2020-10-29 21:46:35 -060046 def __init__(self, text, colour, newline=True, bright=True):
Simon Glassfb35f9f2014-09-05 19:00:06 -060047 self.text = text
48 self.newline = newline
49 self.colour = colour
Simon Glass3db916d2020-10-29 21:46:35 -060050 self.bright = bright
51
52 def __eq__(self, other):
53 return (self.text == other.text and
54 self.newline == other.newline and
55 self.colour == other.colour and
56 self.bright == other.bright)
Simon Glassfb35f9f2014-09-05 19:00:06 -060057
58 def __str__(self):
Simon Glass3db916d2020-10-29 21:46:35 -060059 return ("newline=%s, colour=%s, bright=%d, text='%s'" %
60 (self.newline, self.colour, self.bright, self.text))
61
Simon Glassfb35f9f2014-09-05 19:00:06 -060062
Simon Glass02811582022-01-29 14:14:18 -070063def calc_ascii_len(text):
Simon Glass5f9325d2020-04-09 15:08:40 -060064 """Calculate the length of a string, ignoring any ANSI sequences
65
Simon Glassbbde0532020-04-09 15:08:41 -060066 When displayed on a terminal, ANSI sequences don't take any space, so we
67 need to ignore them when calculating the length of a string.
68
Simon Glass5f9325d2020-04-09 15:08:40 -060069 Args:
70 text: Text to check
71
72 Returns:
73 Length of text, after skipping ANSI sequences
74
75 >>> col = Color(COLOR_ALWAYS)
Simon Glassf45d3742022-01-29 14:14:17 -070076 >>> text = col.build(Color.RED, 'abc')
Simon Glass5f9325d2020-04-09 15:08:40 -060077 >>> len(text)
78 14
Simon Glass02811582022-01-29 14:14:18 -070079 >>> calc_ascii_len(text)
Simon Glass5f9325d2020-04-09 15:08:40 -060080 3
81 >>>
82 >>> text += 'def'
Simon Glass02811582022-01-29 14:14:18 -070083 >>> calc_ascii_len(text)
Simon Glass5f9325d2020-04-09 15:08:40 -060084 6
Simon Glassf45d3742022-01-29 14:14:17 -070085 >>> text += col.build(Color.RED, 'abc')
Simon Glass02811582022-01-29 14:14:18 -070086 >>> calc_ascii_len(text)
Simon Glass5f9325d2020-04-09 15:08:40 -060087 9
88 """
89 result = ansi_escape.sub('', text)
90 return len(result)
91
Simon Glass02811582022-01-29 14:14:18 -070092def trim_ascii_len(text, size):
Simon Glassbbde0532020-04-09 15:08:41 -060093 """Trim a string containing ANSI sequences to the given ASCII length
94
95 The string is trimmed with ANSI sequences being ignored for the length
96 calculation.
97
98 >>> col = Color(COLOR_ALWAYS)
Simon Glassf45d3742022-01-29 14:14:17 -070099 >>> text = col.build(Color.RED, 'abc')
Simon Glassbbde0532020-04-09 15:08:41 -0600100 >>> len(text)
101 14
Simon Glass02811582022-01-29 14:14:18 -0700102 >>> calc_ascii_len(trim_ascii_len(text, 4))
Simon Glassbbde0532020-04-09 15:08:41 -0600103 3
Simon Glass02811582022-01-29 14:14:18 -0700104 >>> calc_ascii_len(trim_ascii_len(text, 2))
Simon Glassbbde0532020-04-09 15:08:41 -0600105 2
106 >>> text += 'def'
Simon Glass02811582022-01-29 14:14:18 -0700107 >>> calc_ascii_len(trim_ascii_len(text, 4))
Simon Glassbbde0532020-04-09 15:08:41 -0600108 4
Simon Glassf45d3742022-01-29 14:14:17 -0700109 >>> text += col.build(Color.RED, 'ghi')
Simon Glass02811582022-01-29 14:14:18 -0700110 >>> calc_ascii_len(trim_ascii_len(text, 7))
Simon Glassbbde0532020-04-09 15:08:41 -0600111 7
112 """
Simon Glass02811582022-01-29 14:14:18 -0700113 if calc_ascii_len(text) < size:
Simon Glassbbde0532020-04-09 15:08:41 -0600114 return text
115 pos = 0
116 out = ''
117 left = size
118
119 # Work through each ANSI sequence in turn
120 for m in ansi_escape.finditer(text):
121 # Find the text before the sequence and add it to our string, making
122 # sure it doesn't overflow
123 before = text[pos:m.start()]
124 toadd = before[:left]
125 out += toadd
126
127 # Figure out how much non-ANSI space we have left
128 left -= len(toadd)
129
130 # Add the ANSI sequence and move to the position immediately after it
131 out += m.group()
132 pos = m.start() + len(m.group())
133
134 # Deal with text after the last ANSI sequence
135 after = text[pos:]
136 toadd = after[:left]
137 out += toadd
138
139 return out
140
Simon Glass5f9325d2020-04-09 15:08:40 -0600141
Simon Glass02811582022-01-29 14:14:18 -0700142def tprint(text='', newline=True, colour=None, limit_to_line=False, bright=True):
Simon Glassfb35f9f2014-09-05 19:00:06 -0600143 """Handle a line of output to the terminal.
144
145 In test mode this is recorded in a list. Otherwise it is output to the
146 terminal.
147
148 Args:
149 text: Text to print
150 newline: True to add a new line at the end of the text
151 colour: Colour to use for the text
152 """
Simon Glass5f9325d2020-04-09 15:08:40 -0600153 global last_print_len
154
Simon Glassfb35f9f2014-09-05 19:00:06 -0600155 if print_test_mode:
Simon Glass3db916d2020-10-29 21:46:35 -0600156 print_test_list.append(PrintLine(text, colour, newline, bright))
Simon Glassfb35f9f2014-09-05 19:00:06 -0600157 else:
158 if colour:
159 col = Color()
Simon Glassf45d3742022-01-29 14:14:17 -0700160 text = col.build(colour, text, bright=bright)
Simon Glassfb35f9f2014-09-05 19:00:06 -0600161 if newline:
Simon Glass82e4c642020-04-09 15:08:39 -0600162 print(text)
Simon Glass5f9325d2020-04-09 15:08:40 -0600163 last_print_len = None
Simon Glass9c45a4e2016-09-18 16:48:30 -0600164 else:
Simon Glassbbde0532020-04-09 15:08:41 -0600165 if limit_to_line:
166 cols = shutil.get_terminal_size().columns
Simon Glass02811582022-01-29 14:14:18 -0700167 text = trim_ascii_len(text, cols)
Simon Glass82e4c642020-04-09 15:08:39 -0600168 print(text, end='', flush=True)
Simon Glass02811582022-01-29 14:14:18 -0700169 last_print_len = calc_ascii_len(text)
Simon Glass5f9325d2020-04-09 15:08:40 -0600170
Simon Glass02811582022-01-29 14:14:18 -0700171def print_clear():
Simon Glass5f9325d2020-04-09 15:08:40 -0600172 """Clear a previously line that was printed with no newline"""
173 global last_print_len
174
175 if last_print_len:
Simon Glassc229d322024-06-23 11:55:15 -0600176 if print_test_mode:
177 print_test_list.append(PrintLine(None, None, None, None))
178 else:
179 print('\r%s\r' % (' '* last_print_len), end='', flush=True)
180 last_print_len = None
Simon Glassfb35f9f2014-09-05 19:00:06 -0600181
Simon Glass02811582022-01-29 14:14:18 -0700182def set_print_test_mode(enable=True):
Simon Glassfb35f9f2014-09-05 19:00:06 -0600183 """Go into test mode, where all printing is recorded"""
184 global print_test_mode
185
Simon Glass3db916d2020-10-29 21:46:35 -0600186 print_test_mode = enable
Simon Glass02811582022-01-29 14:14:18 -0700187 get_print_test_lines()
Simon Glassfb35f9f2014-09-05 19:00:06 -0600188
Simon Glass02811582022-01-29 14:14:18 -0700189def get_print_test_lines():
190 """Get a list of all lines output through tprint()
Simon Glassfb35f9f2014-09-05 19:00:06 -0600191
192 Returns:
193 A list of PrintLine objects
194 """
195 global print_test_list
196
197 ret = print_test_list
198 print_test_list = []
199 return ret
200
Simon Glass02811582022-01-29 14:14:18 -0700201def echo_print_test_lines():
Simon Glassfb35f9f2014-09-05 19:00:06 -0600202 """Print out the text lines collected"""
203 for line in print_test_list:
204 if line.colour:
205 col = Color()
Simon Glassf45d3742022-01-29 14:14:17 -0700206 print(col.build(line.colour, line.text), end='')
Simon Glassfb35f9f2014-09-05 19:00:06 -0600207 else:
Paul Burtonc3931342016-09-27 16:03:50 +0100208 print(line.text, end='')
Simon Glassfb35f9f2014-09-05 19:00:06 -0600209 if line.newline:
Paul Burtonc3931342016-09-27 16:03:50 +0100210 print()
Simon Glassfb35f9f2014-09-05 19:00:06 -0600211
212
Simon Glass26132882012-01-14 15:12:45 +0000213class Color(object):
Simon Glass381fad82014-08-28 09:43:34 -0600214 """Conditionally wraps text in ANSI color escape sequences."""
215 BLACK, RED, GREEN, YELLOW, BLUE, MAGENTA, CYAN, WHITE = range(8)
216 BOLD = -1
217 BRIGHT_START = '\033[1;%dm'
218 NORMAL_START = '\033[22;%dm'
219 BOLD_START = '\033[1m'
220 RESET = '\033[0m'
Simon Glass26132882012-01-14 15:12:45 +0000221
Simon Glass381fad82014-08-28 09:43:34 -0600222 def __init__(self, colored=COLOR_IF_TERMINAL):
223 """Create a new Color object, optionally disabling color output.
Simon Glass26132882012-01-14 15:12:45 +0000224
Simon Glass381fad82014-08-28 09:43:34 -0600225 Args:
226 enabled: True if color output should be enabled. If False then this
227 class will not add color codes at all.
228 """
Simon Glassb0cd3412014-08-28 09:43:35 -0600229 try:
230 self._enabled = (colored == COLOR_ALWAYS or
231 (colored == COLOR_IF_TERMINAL and
232 os.isatty(sys.stdout.fileno())))
233 except:
234 self._enabled = False
Simon Glass26132882012-01-14 15:12:45 +0000235
Simon Glass02811582022-01-29 14:14:18 -0700236 def start(self, color, bright=True):
Simon Glass381fad82014-08-28 09:43:34 -0600237 """Returns a start color code.
Simon Glass26132882012-01-14 15:12:45 +0000238
Simon Glass381fad82014-08-28 09:43:34 -0600239 Args:
240 color: Color to use, .e.g BLACK, RED, etc.
Simon Glass26132882012-01-14 15:12:45 +0000241
Simon Glass381fad82014-08-28 09:43:34 -0600242 Returns:
243 If color is enabled, returns an ANSI sequence to start the given
244 color, otherwise returns empty string
245 """
246 if self._enabled:
247 base = self.BRIGHT_START if bright else self.NORMAL_START
248 return base % (color + 30)
249 return ''
Simon Glass26132882012-01-14 15:12:45 +0000250
Simon Glass02811582022-01-29 14:14:18 -0700251 def stop(self):
Anatolij Gustschinf2bcb322019-10-27 17:55:04 +0100252 """Returns a stop color code.
Simon Glass26132882012-01-14 15:12:45 +0000253
Simon Glass381fad82014-08-28 09:43:34 -0600254 Returns:
255 If color is enabled, returns an ANSI color reset sequence,
256 otherwise returns empty string
257 """
258 if self._enabled:
259 return self.RESET
260 return ''
Simon Glass26132882012-01-14 15:12:45 +0000261
Simon Glassf45d3742022-01-29 14:14:17 -0700262 def build(self, color, text, bright=True):
Simon Glass381fad82014-08-28 09:43:34 -0600263 """Returns text with conditionally added color escape sequences.
Simon Glass26132882012-01-14 15:12:45 +0000264
Simon Glass381fad82014-08-28 09:43:34 -0600265 Keyword arguments:
266 color: Text color -- one of the color constants defined in this
267 class.
268 text: The text to color.
Simon Glass26132882012-01-14 15:12:45 +0000269
Simon Glass381fad82014-08-28 09:43:34 -0600270 Returns:
271 If self._enabled is False, returns the original text. If it's True,
272 returns text with color escape sequences based on the value of
273 color.
274 """
275 if not self._enabled:
276 return text
277 if color == self.BOLD:
278 start = self.BOLD_START
279 else:
280 base = self.BRIGHT_START if bright else self.NORMAL_START
281 start = base % (color + 30)
282 return start + text + self.RESET
Simon Glass14d64e32025-04-29 07:21:59 -0600283
284
285# Use this to suppress stdout/stderr output:
286# with terminal.capture() as (stdout, stderr)
287# ...do something...
288@contextmanager
289def capture():
Simon Glasse8844982025-04-29 07:22:01 -0600290 global CAPTURING
291
Simon Glass14d64e32025-04-29 07:21:59 -0600292 capture_out, capture_err = StringIO(), StringIO()
293 old_out, old_err = sys.stdout, sys.stderr
294 try:
Simon Glasse8844982025-04-29 07:22:01 -0600295 CAPTURING = True
Simon Glass14d64e32025-04-29 07:21:59 -0600296 sys.stdout, sys.stderr = capture_out, capture_err
297 yield capture_out, capture_err
298 finally:
299 sys.stdout, sys.stderr = old_out, old_err
Simon Glasse8844982025-04-29 07:22:01 -0600300 CAPTURING = False
301 if not USE_CAPTURE:
302 sys.stdout.write(capture_out.getvalue())
303 sys.stderr.write(capture_err.getvalue())