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"""
8from __future__ import annotations
10# std imports
11import re
12from enum import IntEnum
14from typing import TYPE_CHECKING, Iterator, NamedTuple
16if TYPE_CHECKING: # pragma: no cover
17 from typing import Sequence
20class _SGR(IntEnum):
21 """
22 SGR (Select Graphic Rendition) parameter codes.
24 References:
25 - https://invisible-island.net/xterm/ctlseqs/ctlseqs.html
26 - https://github.com/tehmaze/ansi/tree/master/ansi/colour
27 """
29 RESET = 0
30 BOLD = 1
31 DIM = 2
32 ITALIC = 3
33 UNDERLINE = 4
34 BLINK = 5
35 RAPID_BLINK = 6
36 INVERSE = 7
37 HIDDEN = 8
38 STRIKETHROUGH = 9
39 DOUBLE_UNDERLINE = 21
40 BOLD_DIM_OFF = 22
41 ITALIC_OFF = 23
42 UNDERLINE_OFF = 24
43 BLINK_OFF = 25
44 INVERSE_OFF = 27
45 HIDDEN_OFF = 28
46 STRIKETHROUGH_OFF = 29
47 FG_BLACK = 30
48 FG_WHITE = 37
49 FG_EXTENDED = 38
50 FG_DEFAULT = 39
51 BG_BLACK = 40
52 BG_WHITE = 47
53 BG_EXTENDED = 48
54 BG_DEFAULT = 49
55 FG_BRIGHT_BLACK = 90
56 FG_BRIGHT_WHITE = 97
57 BG_BRIGHT_BLACK = 100
58 BG_BRIGHT_WHITE = 107
61# SGR sequence pattern: CSI followed by params (digits, semicolons, colons) ending with 'm'
62# Colons are used in ITU T.416 (ISO 8613-6) extended color format: 38:2::R:G:B
63# This colon format is less common than semicolon (38;2;R;G;B) but supported by kitty,
64# iTerm2, and newer VTE-based terminals.
65_SGR_PATTERN = re.compile(r'\x1b\[([\d;:]*)m')
67# Fast path: quick check if any SGR sequence exists
68_SGR_QUICK_CHECK = re.compile(r'\x1b\[[\d;:]*m')
70# Reset sequence
71_SGR_RESET = '\x1b[0m'
74class _SGRState(NamedTuple):
75 """
76 Track active SGR terminal attributes by category (immutable).
78 :param bold: Bold attribute (SGR 1).
79 :param dim: Dim/faint attribute (SGR 2).
80 :param italic: Italic attribute (SGR 3).
81 :param underline: Underline attribute (SGR 4).
82 :param blink: Slow blink attribute (SGR 5).
83 :param rapid_blink: Rapid blink attribute (SGR 6).
84 :param inverse: Inverse/reverse attribute (SGR 7).
85 :param hidden: Hidden/invisible attribute (SGR 8).
86 :param strikethrough: Strikethrough attribute (SGR 9).
87 :param double_underline: Double underline attribute (SGR 21).
88 :param foreground: Foreground color as tuple of SGR params, or None for default.
89 :param background: Background color as tuple of SGR params, or None for default.
90 """
92 bold: bool = False
93 dim: bool = False
94 italic: bool = False
95 underline: bool = False
96 blink: bool = False
97 rapid_blink: bool = False
98 inverse: bool = False
99 hidden: bool = False
100 strikethrough: bool = False
101 double_underline: bool = False
102 foreground: tuple[int, ...] | None = None
103 background: tuple[int, ...] | None = None
106# Default state with no attributes set
107_SGR_STATE_DEFAULT = _SGRState()
110def _sgr_state_is_active(state: _SGRState) -> bool:
111 """
112 Return True if any attributes are set.
114 :param state: The SGR state to check.
115 :returns: True if any attribute differs from default.
116 """
117 return (state.bold or state.dim or state.italic or state.underline
118 or state.blink or state.rapid_blink or state.inverse or state.hidden
119 or state.strikethrough or state.double_underline
120 or state.foreground is not None or state.background is not None)
123def _sgr_state_to_sequence(state: _SGRState) -> str:
124 """
125 Generate minimal SGR sequence to restore this state from reset.
127 :param state: The SGR state to convert.
128 :returns: SGR escape sequence string, or empty string if no attributes set.
129 """
130 if not _sgr_state_is_active(state):
131 return ''
133 # Map boolean attributes to their SGR codes
134 bool_attrs = [
135 (state.bold, '1'), (state.dim, '2'), (state.italic, '3'),
136 (state.underline, '4'), (state.blink, '5'), (state.rapid_blink, '6'),
137 (state.inverse, '7'), (state.hidden, '8'), (state.strikethrough, '9'),
138 (state.double_underline, '21'),
139 ]
140 params = [code for active, code in bool_attrs if active]
142 # Add color params (already formatted as tuples)
143 if state.foreground is not None:
144 params.append(';'.join(str(p) for p in state.foreground))
145 if state.background is not None:
146 params.append(';'.join(str(p) for p in state.background))
148 return f'\x1b[{";".join(params)}m'
151def _parse_sgr_params(sequence: str) -> list[int | tuple[int, ...]]:
152 r"""
153 Parse SGR sequence and return list of parameter values.
155 Handles compound sequences like ``\x1b[1;31;4m`` -> [1, 31, 4].
156 Empty params (e.g., ``\x1b[m``) are treated as [0] (reset).
157 Colon-separated extended colors like ``\x1b[38:2::255:0:0m`` are returned
158 as tuples: [(38, 2, 255, 0, 0)].
160 :param sequence: SGR escape sequence string.
161 :returns: List of integer parameters or tuples for colon-separated colors.
162 """
163 match = _SGR_PATTERN.match(sequence)
164 if not match:
165 return []
166 params_str = match.group(1)
167 if not params_str:
168 return [0] # \x1b[m is equivalent to \x1b[0m
169 result: list[int | tuple[int, ...]] = []
170 for param in params_str.split(';'):
171 if ':' in param:
172 # Colon-separated extended color (ITU T.416 format)
173 # e.g., "38:2::255:0:0" or "38:2:1:255:0:0" (with colorspace)
174 parts = [int(p) if p else 0 for p in param.split(':')]
175 result.append(tuple(parts))
176 else:
177 result.append(int(param) if param else 0)
178 return result
181def _parse_extended_color(
182 params: Iterator[int | tuple[int, ...]], base: int
183) -> tuple[int, ...] | None:
184 """
185 Parse extended color (256-color or RGB) from parameter iterator.
187 :param params: Iterator of remaining SGR parameters (semicolon-separated format).
188 :param base: Base code (38 for foreground, 48 for background).
189 :returns: Color tuple like (38, 5, N) or (38, 2, R, G, B), or None if malformed.
190 """
191 try:
192 mode = next(params)
193 if isinstance(mode, tuple):
194 return None # Unexpected tuple, colon format handled separately
195 if mode == 5: # 256-color
196 n = next(params)
197 if isinstance(n, tuple):
198 return None
199 return (int(base), 5, n)
200 if mode == 2: # RGB
201 r, g, b = next(params), next(params), next(params)
202 if isinstance(r, tuple) or isinstance(g, tuple) or isinstance(b, tuple):
203 return None
204 return (int(base), 2, r, g, b)
205 except StopIteration:
206 pass
207 return None
210def _sgr_state_update(state: _SGRState, sequence: str) -> _SGRState:
211 # pylint: disable=too-many-branches,too-complex,too-many-statements
212 # NOTE: When minimum Python version is 3.10+, this can be simplified using match/case.
213 """
214 Parse SGR sequence and return new state with updates applied.
216 :param state: Current SGR state.
217 :param sequence: SGR escape sequence string.
218 :returns: New SGRState with updates applied.
219 """
220 params_list = _parse_sgr_params(sequence)
221 params = iter(params_list)
222 for p in params:
223 # Handle colon-separated extended colors (ITU T.416 format)
224 if isinstance(p, tuple):
225 if len(p) >= 2 and p[0] == _SGR.FG_EXTENDED:
226 # Foreground: (38, 2, [colorspace,] R, G, B) or (38, 5, N)
227 state = state._replace(foreground=p)
228 elif len(p) >= 2 and p[0] == _SGR.BG_EXTENDED:
229 # Background: (48, 2, [colorspace,] R, G, B) or (48, 5, N)
230 state = state._replace(background=p)
231 continue
232 if p == _SGR.RESET:
233 state = _SGR_STATE_DEFAULT
234 # Attribute ON codes
235 elif p == _SGR.BOLD:
236 state = state._replace(bold=True)
237 elif p == _SGR.DIM:
238 state = state._replace(dim=True)
239 elif p == _SGR.ITALIC:
240 state = state._replace(italic=True)
241 elif p == _SGR.UNDERLINE:
242 state = state._replace(underline=True)
243 elif p == _SGR.BLINK:
244 state = state._replace(blink=True)
245 elif p == _SGR.RAPID_BLINK:
246 state = state._replace(rapid_blink=True)
247 elif p == _SGR.INVERSE:
248 state = state._replace(inverse=True)
249 elif p == _SGR.HIDDEN:
250 state = state._replace(hidden=True)
251 elif p == _SGR.STRIKETHROUGH:
252 state = state._replace(strikethrough=True)
253 elif p == _SGR.DOUBLE_UNDERLINE:
254 state = state._replace(double_underline=True)
255 # Attribute OFF codes
256 elif p == _SGR.BOLD_DIM_OFF:
257 state = state._replace(bold=False, dim=False)
258 elif p == _SGR.ITALIC_OFF:
259 state = state._replace(italic=False)
260 elif p == _SGR.UNDERLINE_OFF:
261 state = state._replace(underline=False, double_underline=False)
262 elif p == _SGR.BLINK_OFF:
263 state = state._replace(blink=False, rapid_blink=False)
264 elif p == _SGR.INVERSE_OFF:
265 state = state._replace(inverse=False)
266 elif p == _SGR.HIDDEN_OFF:
267 state = state._replace(hidden=False)
268 elif p == _SGR.STRIKETHROUGH_OFF:
269 state = state._replace(strikethrough=False)
270 # Basic colors (30-37, 40-47 standard; 90-97, 100-107 bright)
271 elif (_SGR.FG_BLACK <= p <= _SGR.FG_WHITE
272 or _SGR.FG_BRIGHT_BLACK <= p <= _SGR.FG_BRIGHT_WHITE):
273 state = state._replace(foreground=(p,))
274 elif (_SGR.BG_BLACK <= p <= _SGR.BG_WHITE
275 or _SGR.BG_BRIGHT_BLACK <= p <= _SGR.BG_BRIGHT_WHITE):
276 state = state._replace(background=(p,))
277 elif p == _SGR.FG_DEFAULT:
278 state = state._replace(foreground=None)
279 elif p == _SGR.BG_DEFAULT:
280 state = state._replace(background=None)
281 # Extended colors (semicolon-separated format)
282 elif p == _SGR.FG_EXTENDED:
283 if color := _parse_extended_color(params, _SGR.FG_EXTENDED):
284 state = state._replace(foreground=color)
285 elif p == _SGR.BG_EXTENDED:
286 if color := _parse_extended_color(params, _SGR.BG_EXTENDED):
287 state = state._replace(background=color)
288 return state
291def propagate_sgr(lines: Sequence[str]) -> list[str]:
292 r"""
293 Propagate SGR codes across wrapped lines.
295 When text with SGR styling is wrapped across multiple lines, each line
296 needs to be self-contained for proper display. This function:
298 - Ends each line with ``\x1b[0m`` if styles are active (prevents bleeding)
299 - Starts each subsequent line with the active style restored
301 :param lines: List of text lines, possibly containing SGR sequences.
302 :returns: List of lines with SGR codes propagated.
304 Example::
306 >>> propagate_sgr(['\x1b[31mhello', 'world\x1b[0m'])
307 ['\x1b[31mhello\x1b[0m', '\x1b[31mworld\x1b[0m']
309 This is useful in cases of making special editors and viewers, and is used for the
310 default modes (propagate_sgr=True) of :func:`wcwidth.width` and :func:`wcwidth.clip`.
312 When wrapping and clipping text containing SGR sequences, maybe a previous line enabled the BLUE
313 color--if we are viewing *only* the line following, we would want the carry over the BLUE color,
314 and all lines with sequences should end with terminating reset (``\x1b[0m``).
315 """
316 # Fast path: check if any line contains SGR sequences
317 if not any(_SGR_QUICK_CHECK.search(line) for line in lines) or not lines:
318 return list(lines)
320 result: list[str] = []
321 state = _SGR_STATE_DEFAULT
323 for line in lines:
324 # Prefix with restoration sequence if state is active
325 prefix = _sgr_state_to_sequence(state)
327 # Update state by processing all SGR sequences in this line
328 for match in _SGR_PATTERN.finditer(line):
329 state = _sgr_state_update(state, match.group())
331 # Build output line
332 output_line = prefix + line if prefix else line
333 if _sgr_state_is_active(state):
334 output_line = output_line + _SGR_RESET
336 result.append(output_line)
338 return result