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 _sgr_state_to_sequence(state: _SGRState) -> str:
125 """
126 Generate minimal SGR sequence to restore this state from reset.
128 :param state: The SGR state to convert.
129 :returns: SGR escape sequence string, or empty string if no attributes set.
130 """
131 if not _sgr_state_is_active(state):
132 return ''
134 # Map boolean attributes to their SGR codes
135 bool_attrs = [
136 (state.bold, '1'), (state.dim, '2'), (state.italic, '3'),
137 (state.underline, '4'), (state.blink, '5'), (state.rapid_blink, '6'),
138 (state.inverse, '7'), (state.hidden, '8'), (state.strikethrough, '9'),
139 (state.double_underline, '21'),
140 ]
141 params = [code for active, code in bool_attrs if active]
143 # Add color params (already formatted as tuples)
144 if state.foreground is not None:
145 params.append(';'.join(str(p) for p in state.foreground))
146 if state.background is not None:
147 params.append(';'.join(str(p) for p in state.background))
149 return f'\x1b[{";".join(params)}m'
152def _parse_sgr_params(sequence: str) -> list[int | tuple[int, ...]]:
153 r"""
154 Parse SGR sequence and return list of parameter values.
156 Handles compound sequences like ``\x1b[1;31;4m`` -> [1, 31, 4].
157 Empty params (e.g., ``\x1b[m``) are treated as [0] (reset).
158 Colon-separated extended colors like ``\x1b[38:2::255:0:0m`` are returned
159 as tuples: [(38, 2, 255, 0, 0)].
161 :param sequence: SGR escape sequence string.
162 :returns: List of integer parameters or tuples for colon-separated colors.
163 """
164 match = _SGR_PATTERN.match(sequence)
165 if not match:
166 return []
167 params_str = match.group(1)
168 if not params_str:
169 return [0] # \x1b[m is equivalent to \x1b[0m
170 result: list[int | tuple[int, ...]] = []
171 for param in params_str.split(';'):
172 if ':' in param:
173 # Colon-separated extended color (ITU T.416 format)
174 # e.g., "38:2::255:0:0" or "38:2:1:255:0:0" (with colorspace)
175 parts = [int(p) if p else 0 for p in param.split(':')]
176 result.append(tuple(parts))
177 else:
178 result.append(int(param) if param else 0)
179 return result
182def _parse_extended_color(
183 params: Iterator[int | tuple[int, ...]], base: int
184) -> tuple[int, ...] | None:
185 """
186 Parse extended color (256-color or RGB) from parameter iterator.
188 :param params: Iterator of remaining SGR parameters (semicolon-separated format).
189 :param base: Base code (38 for foreground, 48 for background).
190 :returns: Color tuple like (38, 5, N) or (38, 2, R, G, B), or None if malformed.
191 """
192 try:
193 mode = next(params)
194 if isinstance(mode, tuple):
195 return None # Unexpected tuple, colon format handled separately
196 if mode == 5: # 256-color
197 n = next(params)
198 if isinstance(n, tuple):
199 return None
200 return (int(base), 5, n)
201 if mode == 2: # RGB
202 r, g, b = next(params), next(params), next(params)
203 if isinstance(r, tuple) or isinstance(g, tuple) or isinstance(b, tuple):
204 return None
205 return (int(base), 2, r, g, b)
206 except StopIteration:
207 pass
208 return None
211def _sgr_state_update(state: _SGRState, sequence: str) -> _SGRState:
212 # pylint: disable=too-many-branches,too-complex,too-many-statements
213 # NOTE: When minimum Python version is 3.10+, this can be simplified using match/case.
214 """
215 Parse SGR sequence and return new state with updates applied.
217 :param state: Current SGR state.
218 :param sequence: SGR escape sequence string.
219 :returns: New SGRState with updates applied.
220 """
221 params_list = _parse_sgr_params(sequence)
222 params = iter(params_list)
223 for p in params:
224 # Handle colon-separated extended colors (ITU T.416 format)
225 if isinstance(p, tuple):
226 if len(p) >= 2 and p[0] == _SGR.FG_EXTENDED:
227 # Foreground: (38, 2, [colorspace,] R, G, B) or (38, 5, N)
228 state = state._replace(foreground=p)
229 elif len(p) >= 2 and p[0] == _SGR.BG_EXTENDED:
230 # Background: (48, 2, [colorspace,] R, G, B) or (48, 5, N)
231 state = state._replace(background=p)
232 continue
233 if p == _SGR.RESET:
234 state = _SGR_STATE_DEFAULT
235 # Attribute ON codes
236 elif p == _SGR.BOLD:
237 state = state._replace(bold=True)
238 elif p == _SGR.DIM:
239 state = state._replace(dim=True)
240 elif p == _SGR.ITALIC:
241 state = state._replace(italic=True)
242 elif p == _SGR.UNDERLINE:
243 state = state._replace(underline=True)
244 elif p == _SGR.BLINK:
245 state = state._replace(blink=True)
246 elif p == _SGR.RAPID_BLINK:
247 state = state._replace(rapid_blink=True)
248 elif p == _SGR.INVERSE:
249 state = state._replace(inverse=True)
250 elif p == _SGR.HIDDEN:
251 state = state._replace(hidden=True)
252 elif p == _SGR.STRIKETHROUGH:
253 state = state._replace(strikethrough=True)
254 elif p == _SGR.DOUBLE_UNDERLINE:
255 state = state._replace(double_underline=True)
256 # Attribute OFF codes
257 elif p == _SGR.BOLD_DIM_OFF:
258 state = state._replace(bold=False, dim=False)
259 elif p == _SGR.ITALIC_OFF:
260 state = state._replace(italic=False)
261 elif p == _SGR.UNDERLINE_OFF:
262 state = state._replace(underline=False, double_underline=False)
263 elif p == _SGR.BLINK_OFF:
264 state = state._replace(blink=False, rapid_blink=False)
265 elif p == _SGR.INVERSE_OFF:
266 state = state._replace(inverse=False)
267 elif p == _SGR.HIDDEN_OFF:
268 state = state._replace(hidden=False)
269 elif p == _SGR.STRIKETHROUGH_OFF:
270 state = state._replace(strikethrough=False)
271 # Basic colors (30-37, 40-47 standard; 90-97, 100-107 bright)
272 elif (_SGR.FG_BLACK <= p <= _SGR.FG_WHITE
273 or _SGR.FG_BRIGHT_BLACK <= p <= _SGR.FG_BRIGHT_WHITE):
274 state = state._replace(foreground=(p,))
275 elif (_SGR.BG_BLACK <= p <= _SGR.BG_WHITE
276 or _SGR.BG_BRIGHT_BLACK <= p <= _SGR.BG_BRIGHT_WHITE):
277 state = state._replace(background=(p,))
278 elif p == _SGR.FG_DEFAULT:
279 state = state._replace(foreground=None)
280 elif p == _SGR.BG_DEFAULT:
281 state = state._replace(background=None)
282 # Extended colors (semicolon-separated format)
283 elif p == _SGR.FG_EXTENDED:
284 if color := _parse_extended_color(params, _SGR.FG_EXTENDED):
285 state = state._replace(foreground=color)
286 elif p == _SGR.BG_EXTENDED:
287 if color := _parse_extended_color(params, _SGR.BG_EXTENDED):
288 state = state._replace(background=color)
289 return state
292def propagate_sgr(lines: Sequence[str]) -> list[str]:
293 r"""
294 Propagate SGR codes across wrapped lines.
296 When text with SGR styling is wrapped across multiple lines, each line
297 needs to be self-contained for proper display. This function:
299 - Ends each line with ``\x1b[0m`` if styles are active (prevents bleeding)
300 - Starts each subsequent line with the active style restored
302 :param lines: List of text lines, possibly containing SGR sequences.
303 :returns: List of lines with SGR codes propagated.
305 Example::
307 >>> propagate_sgr(['\x1b[31mhello', 'world\x1b[0m'])
308 ['\x1b[31mhello\x1b[0m', '\x1b[31mworld\x1b[0m']
310 This is useful in cases of making special editors and viewers, and is used for the
311 default modes (propagate_sgr=True) of :func:`wcwidth.wrap` and :func:`wcwidth.clip`.
313 When wrapping and clipping text containing SGR sequences, maybe a previous line enabled the BLUE
314 color--if we are viewing *only* the line following, we would want the carry over the BLUE color,
315 and all lines with sequences should end with terminating reset (``\x1b[0m``).
316 """
317 # Fast path: check if any line contains SGR sequences
318 if not any(_SGR_QUICK_CHECK.search(line) for line in lines) or not lines:
319 return list(lines)
321 result: list[str] = []
322 state = _SGR_STATE_DEFAULT
324 for line in lines:
325 # Prefix with restoration sequence if state is active
326 prefix = _sgr_state_to_sequence(state)
328 # Update state by processing all SGR sequences in this line
329 for match in _SGR_PATTERN.finditer(line):
330 state = _sgr_state_update(state, match.group())
332 # Build output line
333 output_line = prefix + line if prefix else line
334 if _sgr_state_is_active(state):
335 output_line = output_line + _SGR_RESET
337 result.append(output_line)
339 return result