codekingpro/portable-devtools
114k
1"""2SGR (Select Graphic Rendition) state tracking for terminal escape sequences.3 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 has6attributes necessary to perform its functions, eg 'RED' and 'BLUE' attributes are not defined.7"""8 9from __future__ import annotations10 11# std imports12import re13from enum import IntEnum14 15from typing import TYPE_CHECKING, Iterator, NamedTuple16 17if TYPE_CHECKING: # pragma: no cover18 from typing import Sequence19 20 21class _SGR(IntEnum):22 """23 SGR (Select Graphic Rendition) parameter codes.24 25 References:26 - https://invisible-island.net/xterm/ctlseqs/ctlseqs.html27 - https://github.com/tehmaze/ansi/tree/master/ansi/colour28 """29 30 RESET = 031 BOLD = 132 DIM = 233 ITALIC = 334 UNDERLINE = 435 BLINK = 536 RAPID_BLINK = 637 INVERSE = 738 HIDDEN = 839 STRIKETHROUGH = 940 DOUBLE_UNDERLINE = 2141 BOLD_DIM_OFF = 2242 ITALIC_OFF = 2343 UNDERLINE_OFF = 2444 BLINK_OFF = 2545 INVERSE_OFF = 2746 HIDDEN_OFF = 2847 STRIKETHROUGH_OFF = 2948 FG_BLACK = 3049 FG_WHITE = 3750 FG_EXTENDED = 3851 FG_DEFAULT = 3952 BG_BLACK = 4053 BG_WHITE = 4754 BG_EXTENDED = 4855 BG_DEFAULT = 4956 FG_BRIGHT_BLACK = 9057 FG_BRIGHT_WHITE = 9758 BG_BRIGHT_BLACK = 10059 BG_BRIGHT_WHITE = 10760 61 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:B64# 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')67 68# Fast path: quick check if any SGR sequence exists69_SGR_QUICK_CHECK = re.compile(r'\x1b\[[\d;:]*m')70 71# Reset sequence72_SGR_RESET = '\x1b[0m'73 74 75class _SGRState(NamedTuple):76 """77 Track active SGR terminal attributes by category (immutable).78 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 """92 93 bold: bool = False94 dim: bool = False95 italic: bool = False96 underline: bool = False97 blink: bool = False98 rapid_blink: bool = False99 inverse: bool = False100 hidden: bool = False101 strikethrough: bool = False102 double_underline: bool = False103 foreground: tuple[int, ...] | None = None104 background: tuple[int, ...] | None = None105 106 107# Default state with no attributes set108_SGR_STATE_DEFAULT = _SGRState()109 110 111def _sgr_state_is_active(state: _SGRState) -> bool:112 """113 Return True if any attributes are set.114 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.underline119 or state.blink or state.rapid_blink or state.inverse or state.hidden120 or state.strikethrough or state.double_underline121 or state.foreground is not None or state.background is not None)122 123 124def _sgr_state_to_sequence(state: _SGRState) -> str:125 """126 Generate minimal SGR sequence to restore this state from reset.127 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 ''133 134 # Map boolean attributes to their SGR codes135 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]142 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))148 149 return f'\x1b[{";".join(params)}m'150 151 152def _parse_sgr_params(sequence: str) -> list[int | tuple[int, ...]]:153 r"""154 Parse SGR sequence and return list of parameter values.155 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 returned159 as tuples: [(38, 2, 255, 0, 0)].160 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[0m170 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 result180 181 182def _parse_extended_color(183 params: Iterator[int | tuple[int, ...]], base: int184) -> tuple[int, ...] | None:185 """186 Parse extended color (256-color or RGB) from parameter iterator.187 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 separately196 if mode == 5: # 256-color197 n = next(params)198 if isinstance(n, tuple):199 return None200 return (int(base), 5, n)201 if mode == 2: # RGB202 r, g, b = next(params), next(params), next(params)203 if isinstance(r, tuple) or isinstance(g, tuple) or isinstance(b, tuple):204 return None205 return (int(base), 2, r, g, b)206 except StopIteration:207 pass208 return None209 210 211def _sgr_state_update(state: _SGRState, sequence: str) -> _SGRState:212 # pylint: disable=too-many-branches,too-complex,too-many-statements213 # 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.216 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 continue233 if p == _SGR.RESET:234 state = _SGR_STATE_DEFAULT235 # Attribute ON codes236 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 codes257 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_WHITE273 or _SGR.FG_BRIGHT_BLACK <= p <= _SGR.FG_BRIGHT_WHITE):274 state = state._replace(foreground=(p,))275 elif (_SGR.BG_BLACK <= p <= _SGR.BG_WHITE276 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 state290 291 292def propagate_sgr(lines: Sequence[str]) -> list[str]:293 r"""294 Propagate SGR codes across wrapped lines.295 296 When text with SGR styling is wrapped across multiple lines, each line297 needs to be self-contained for proper display. This function:298 299 - Ends each line with ``\x1b[0m`` if styles are active (prevents bleeding)300 - Starts each subsequent line with the active style restored301 302 :param lines: List of text lines, possibly containing SGR sequences.303 :returns: List of lines with SGR codes propagated.304 305 Example::306 307 >>> propagate_sgr(['\x1b[31mhello', 'world\x1b[0m'])308 ['\x1b[31mhello\x1b[0m', '\x1b[31mworld\x1b[0m']309 310 This is useful in cases of making special editors and viewers, and is used for the311 default modes (propagate_sgr=True) of :func:`wcwidth.wrap` and :func:`wcwidth.clip`.312 313 When wrapping and clipping text containing SGR sequences, maybe a previous line enabled the BLUE314 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 sequences318 if not any(_SGR_QUICK_CHECK.search(line) for line in lines) or not lines:319 return list(lines)320 321 result: list[str] = []322 state = _SGR_STATE_DEFAULT323 324 for line in lines:325 # Prefix with restoration sequence if state is active326 prefix = _sgr_state_to_sequence(state)327 328 # Update state by processing all SGR sequences in this line329 for match in _SGR_PATTERN.finditer(line):330 state = _sgr_state_update(state, match.group())331 332 # Build output line333 output_line = prefix + line if prefix else line334 if _sgr_state_is_active(state):335 output_line = output_line + _SGR_RESET336 337 result.append(output_line)338 339 return result340 