Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.11/site-packages/wcwidth/sgr_state.py: 34%
Shortcuts on this page
r m x toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
Shortcuts on this page
r m x toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
1"""
2SGR (Select Graphic Rendition) state tracking for terminal escape sequences.
4This module provides functions for tracking and propagating terminal styling (bold, italic, colors,
5etc.) via public API propagate_sgr(), and its dependent functions, cut() and wrap(). It only has
6attributes necessary to perform its functions, eg 'RED' and 'BLUE' attributes are not defined.
7"""
9from __future__ import annotations
11# std imports
12import re
13from enum import IntEnum
15from typing import TYPE_CHECKING, Iterator, NamedTuple
17if TYPE_CHECKING: # pragma: no cover
18 from typing import Sequence
21class _SGR(IntEnum):
22 """
23 SGR (Select Graphic Rendition) parameter codes.
25 References:
26 - https://invisible-island.net/xterm/ctlseqs/ctlseqs.html
27 - https://github.com/tehmaze/ansi/tree/master/ansi/colour
28 """
30 RESET = 0
31 BOLD = 1
32 DIM = 2
33 ITALIC = 3
34 UNDERLINE = 4
35 BLINK = 5
36 RAPID_BLINK = 6
37 INVERSE = 7
38 HIDDEN = 8
39 STRIKETHROUGH = 9
40 DOUBLE_UNDERLINE = 21
41 BOLD_DIM_OFF = 22
42 ITALIC_OFF = 23
43 UNDERLINE_OFF = 24
44 BLINK_OFF = 25
45 INVERSE_OFF = 27
46 HIDDEN_OFF = 28
47 STRIKETHROUGH_OFF = 29
48 FG_BLACK = 30
49 FG_WHITE = 37
50 FG_EXTENDED = 38
51 FG_DEFAULT = 39
52 BG_BLACK = 40
53 BG_WHITE = 47
54 BG_EXTENDED = 48
55 BG_DEFAULT = 49
56 FG_BRIGHT_BLACK = 90
57 FG_BRIGHT_WHITE = 97
58 BG_BRIGHT_BLACK = 100
59 BG_BRIGHT_WHITE = 107
62# SGR sequence pattern: CSI followed by params (digits, semicolons, colons) ending with 'm'
63# Colons are used in ITU T.416 (ISO 8613-6) extended color format: 38:2::R:G:B
64# This colon format is less common than semicolon (38;2;R;G;B) but supported by kitty,
65# iTerm2, and newer VTE-based terminals.
66_SGR_PATTERN = re.compile(r'\x1b\[([\d;:]*)m')
68# Fast path: quick check if any SGR sequence exists
69_SGR_QUICK_CHECK = re.compile(r'\x1b\[[\d;:]*m')
71# Reset sequence
72_SGR_RESET = '\x1b[0m'
75class _SGRState(NamedTuple):
76 """
77 Track active SGR terminal attributes by category (immutable).
79 :param bold: Bold attribute (SGR 1).
80 :param dim: Dim/faint attribute (SGR 2).
81 :param italic: Italic attribute (SGR 3).
82 :param underline: Underline attribute (SGR 4).
83 :param blink: Slow blink attribute (SGR 5).
84 :param rapid_blink: Rapid blink attribute (SGR 6).
85 :param inverse: Inverse/reverse attribute (SGR 7).
86 :param hidden: Hidden/invisible attribute (SGR 8).
87 :param strikethrough: Strikethrough attribute (SGR 9).
88 :param double_underline: Double underline attribute (SGR 21).
89 :param foreground: Foreground color as tuple of SGR params, or None for default.
90 :param background: Background color as tuple of SGR params, or None for default.
91 """
93 bold: bool = False
94 dim: bool = False
95 italic: bool = False
96 underline: bool = False
97 blink: bool = False
98 rapid_blink: bool = False
99 inverse: bool = False
100 hidden: bool = False
101 strikethrough: bool = False
102 double_underline: bool = False
103 foreground: tuple[int, ...] | None = None
104 background: tuple[int, ...] | None = None
107# Default state with no attributes set
108_SGR_STATE_DEFAULT = _SGRState()
111def _sgr_state_is_active(state: _SGRState) -> bool:
112 """
113 Return True if any attributes are set.
115 :param state: The SGR state to check.
116 :returns: True if any attribute differs from default.
117 """
118 return (state.bold or state.dim or state.italic or state.underline
119 or state.blink or state.rapid_blink or state.inverse or state.hidden
120 or state.strikethrough or state.double_underline
121 or state.foreground is not None or state.background is not None)
124def _color_params_to_str(color: tuple[int, ...]) -> str:
125 """
126 Join color parameters, preserving the ITU T.416 colon form where it was used.
128 ``38:2:<colour space id>:R:G:B`` carries a colour space element that the legacy
129 ``38;2;R;G;B`` form has no slot for, so joining it with ``;`` would shift R, G and B
130 one position and leave a trailing parameter.
132 :param color: Color parameters as parsed by :func:`_parse_sgr_params`.
133 :returns: Parameter string for embedding in an SGR sequence.
134 """
135 if len(color) > 5 and color[1] == 2:
136 return ':'.join(str(p) for p in color)
137 return ';'.join(str(p) for p in color)
140def _sgr_state_to_sequence(state: _SGRState) -> str:
141 """
142 Generate minimal SGR sequence to restore this state from reset.
144 :param state: The SGR state to convert.
145 :returns: SGR escape sequence string, or empty string if no attributes set.
146 """
147 if not _sgr_state_is_active(state):
148 return ''
150 # Map boolean attributes to their SGR codes
151 bool_attrs = [
152 (state.bold, '1'), (state.dim, '2'), (state.italic, '3'),
153 (state.underline, '4'), (state.blink, '5'), (state.rapid_blink, '6'),
154 (state.inverse, '7'), (state.hidden, '8'), (state.strikethrough, '9'),
155 (state.double_underline, '21'),
156 ]
157 params = [code for active, code in bool_attrs if active]
159 # Add color params (already formatted as tuples)
160 if state.foreground is not None:
161 params.append(_color_params_to_str(state.foreground))
162 if state.background is not None:
163 params.append(_color_params_to_str(state.background))
165 return f'\x1b[{";".join(params)}m'
168def _parse_sgr_params(sequence: str) -> list[int | tuple[int, ...]]:
169 r"""
170 Parse SGR sequence and return list of parameter values.
172 Handles compound sequences like ``\x1b[1;31;4m`` -> [1, 31, 4].
173 Empty params (e.g., ``\x1b[m``) are treated as [0] (reset).
174 Colon-separated extended colors like ``\x1b[38:2::255:0:0m`` are returned
175 as tuples: [(38, 2, 255, 0, 0)].
177 :param sequence: SGR escape sequence string.
178 :returns: List of integer parameters or tuples for colon-separated colors.
179 """
180 match = _SGR_PATTERN.match(sequence)
181 if not match:
182 return []
183 params_str = match.group(1)
184 if not params_str:
185 return [0] # \x1b[m is equivalent to \x1b[0m
186 result: list[int | tuple[int, ...]] = []
187 for param in params_str.split(';'):
188 if ':' in param:
189 # Colon-separated extended color (ITU T.416 format)
190 # e.g., "38:2::255:0:0" or "38:2:1:255:0:0" (with colorspace)
191 parts = [int(p) if p else 0 for p in param.split(':')]
192 result.append(tuple(parts))
193 else:
194 result.append(int(param) if param else 0)
195 return result
198def _parse_extended_color(
199 params: Iterator[int | tuple[int, ...]], base: int
200) -> tuple[int, ...] | None:
201 """
202 Parse extended color (256-color or RGB) from parameter iterator.
204 :param params: Iterator of remaining SGR parameters (semicolon-separated format).
205 :param base: Base code (38 for foreground, 48 for background).
206 :returns: Color tuple like (38, 5, N) or (38, 2, R, G, B), or None if malformed.
207 """
208 try:
209 mode = next(params)
210 if isinstance(mode, tuple):
211 return None # Unexpected tuple, colon format handled separately
212 if mode == 5: # 256-color
213 n = next(params)
214 if isinstance(n, tuple):
215 return None
216 return (int(base), 5, n)
217 if mode == 2: # RGB
218 r, g, b = next(params), next(params), next(params)
219 if isinstance(r, tuple) or isinstance(g, tuple) or isinstance(b, tuple):
220 return None
221 return (int(base), 2, r, g, b)
222 except StopIteration:
223 pass
224 return None
227def _sgr_state_update(state: _SGRState, sequence: str) -> _SGRState:
228 # pylint: disable=too-many-branches,too-complex,too-many-statements
229 # NOTE: When minimum Python version is 3.10+, this can be simplified using match/case.
230 """
231 Parse SGR sequence and return new state with updates applied.
233 :param state: Current SGR state.
234 :param sequence: SGR escape sequence string.
235 :returns: New SGRState with updates applied.
236 """
237 params_list = _parse_sgr_params(sequence)
238 params = iter(params_list)
239 for p in params:
240 # Handle colon-separated extended colors (ITU T.416 format)
241 if isinstance(p, tuple):
242 if len(p) >= 2 and p[0] == _SGR.FG_EXTENDED:
243 # Foreground: (38, 2, [colorspace,] R, G, B) or (38, 5, N)
244 state = state._replace(foreground=p)
245 elif len(p) >= 2 and p[0] == _SGR.BG_EXTENDED:
246 # Background: (48, 2, [colorspace,] R, G, B) or (48, 5, N)
247 state = state._replace(background=p)
248 continue
249 if p == _SGR.RESET:
250 state = _SGR_STATE_DEFAULT
251 # Attribute ON codes
252 elif p == _SGR.BOLD:
253 state = state._replace(bold=True)
254 elif p == _SGR.DIM:
255 state = state._replace(dim=True)
256 elif p == _SGR.ITALIC:
257 state = state._replace(italic=True)
258 elif p == _SGR.UNDERLINE:
259 state = state._replace(underline=True)
260 elif p == _SGR.BLINK:
261 state = state._replace(blink=True)
262 elif p == _SGR.RAPID_BLINK:
263 state = state._replace(rapid_blink=True)
264 elif p == _SGR.INVERSE:
265 state = state._replace(inverse=True)
266 elif p == _SGR.HIDDEN:
267 state = state._replace(hidden=True)
268 elif p == _SGR.STRIKETHROUGH:
269 state = state._replace(strikethrough=True)
270 elif p == _SGR.DOUBLE_UNDERLINE:
271 state = state._replace(double_underline=True)
272 # Attribute OFF codes
273 elif p == _SGR.BOLD_DIM_OFF:
274 state = state._replace(bold=False, dim=False)
275 elif p == _SGR.ITALIC_OFF:
276 state = state._replace(italic=False)
277 elif p == _SGR.UNDERLINE_OFF:
278 state = state._replace(underline=False, double_underline=False)
279 elif p == _SGR.BLINK_OFF:
280 state = state._replace(blink=False, rapid_blink=False)
281 elif p == _SGR.INVERSE_OFF:
282 state = state._replace(inverse=False)
283 elif p == _SGR.HIDDEN_OFF:
284 state = state._replace(hidden=False)
285 elif p == _SGR.STRIKETHROUGH_OFF:
286 state = state._replace(strikethrough=False)
287 # Basic colors (30-37, 40-47 standard; 90-97, 100-107 bright)
288 elif (_SGR.FG_BLACK <= p <= _SGR.FG_WHITE
289 or _SGR.FG_BRIGHT_BLACK <= p <= _SGR.FG_BRIGHT_WHITE):
290 state = state._replace(foreground=(p,))
291 elif (_SGR.BG_BLACK <= p <= _SGR.BG_WHITE
292 or _SGR.BG_BRIGHT_BLACK <= p <= _SGR.BG_BRIGHT_WHITE):
293 state = state._replace(background=(p,))
294 elif p == _SGR.FG_DEFAULT:
295 state = state._replace(foreground=None)
296 elif p == _SGR.BG_DEFAULT:
297 state = state._replace(background=None)
298 # Extended colors (semicolon-separated format)
299 elif p == _SGR.FG_EXTENDED:
300 if color := _parse_extended_color(params, _SGR.FG_EXTENDED):
301 state = state._replace(foreground=color)
302 elif p == _SGR.BG_EXTENDED:
303 if color := _parse_extended_color(params, _SGR.BG_EXTENDED):
304 state = state._replace(background=color)
305 return state
308def propagate_sgr(lines: Sequence[str]) -> list[str]:
309 r"""
310 Propagate SGR codes across wrapped lines.
312 When text with SGR styling is wrapped across multiple lines, each line
313 needs to be self-contained for proper display. This function:
315 - Ends each line with ``\x1b[0m`` if styles are active (prevents bleeding)
316 - Starts each subsequent line with the active style restored
318 :param lines: List of text lines, possibly containing SGR sequences.
319 :returns: List of lines with SGR codes propagated.
321 Example::
323 >>> propagate_sgr(['\x1b[31mhello', 'world\x1b[0m'])
324 ['\x1b[31mhello\x1b[0m', '\x1b[31mworld\x1b[0m']
326 This is useful in cases of making special editors and viewers, and is used for the
327 default modes (propagate_sgr=True) of :func:`wcwidth.wrap` and :func:`wcwidth.clip`.
329 When wrapping and clipping text containing SGR sequences, maybe a previous line enabled the BLUE
330 color--if we are viewing *only* the line following, we would want the carry over the BLUE color,
331 and all lines with sequences should end with terminating reset (``\x1b[0m``).
332 """
333 # Fast path: check if any line contains SGR sequences
334 if not any(_SGR_QUICK_CHECK.search(line) for line in lines) or not lines:
335 return list(lines)
337 result: list[str] = []
338 state = _SGR_STATE_DEFAULT
340 for line in lines:
341 # Prefix with restoration sequence if state is active
342 prefix = _sgr_state_to_sequence(state)
344 # Update state by processing all SGR sequences in this line
345 for match in _SGR_PATTERN.finditer(line):
346 state = _sgr_state_update(state, match.group())
348 # Build output line
349 output_line = prefix + line if prefix else line
350 if _sgr_state_is_active(state):
351 output_line = output_line + _SGR_RESET
353 result.append(output_line)
355 return result