Files
tamigo-cli/venv/lib/python3.12/site-packages/wcwidth/sgr_state.py

339 lines
12 KiB
Python

"""
SGR (Select Graphic Rendition) state tracking for terminal escape sequences.
This module provides functions for tracking and propagating terminal styling (bold, italic, colors,
etc.) via public API propagate_sgr(), and its dependent functions, cut() and wrap(). It only has
attributes necessary to perform its functions, eg 'RED' and 'BLUE' attributes are not defined.
"""
from __future__ import annotations
# std imports
import re
from enum import IntEnum
from typing import TYPE_CHECKING, Iterator, NamedTuple
if TYPE_CHECKING: # pragma: no cover
from typing import Sequence
class _SGR(IntEnum):
"""
SGR (Select Graphic Rendition) parameter codes.
References:
- https://invisible-island.net/xterm/ctlseqs/ctlseqs.html
- https://github.com/tehmaze/ansi/tree/master/ansi/colour
"""
RESET = 0
BOLD = 1
DIM = 2
ITALIC = 3
UNDERLINE = 4
BLINK = 5
RAPID_BLINK = 6
INVERSE = 7
HIDDEN = 8
STRIKETHROUGH = 9
DOUBLE_UNDERLINE = 21
BOLD_DIM_OFF = 22
ITALIC_OFF = 23
UNDERLINE_OFF = 24
BLINK_OFF = 25
INVERSE_OFF = 27
HIDDEN_OFF = 28
STRIKETHROUGH_OFF = 29
FG_BLACK = 30
FG_WHITE = 37
FG_EXTENDED = 38
FG_DEFAULT = 39
BG_BLACK = 40
BG_WHITE = 47
BG_EXTENDED = 48
BG_DEFAULT = 49
FG_BRIGHT_BLACK = 90
FG_BRIGHT_WHITE = 97
BG_BRIGHT_BLACK = 100
BG_BRIGHT_WHITE = 107
# SGR sequence pattern: CSI followed by params (digits, semicolons, colons) ending with 'm'
# Colons are used in ITU T.416 (ISO 8613-6) extended color format: 38:2::R:G:B
# This colon format is less common than semicolon (38;2;R;G;B) but supported by kitty,
# iTerm2, and newer VTE-based terminals.
_SGR_PATTERN = re.compile(r'\x1b\[([\d;:]*)m')
# Fast path: quick check if any SGR sequence exists
_SGR_QUICK_CHECK = re.compile(r'\x1b\[[\d;:]*m')
# Reset sequence
_SGR_RESET = '\x1b[0m'
class _SGRState(NamedTuple):
"""
Track active SGR terminal attributes by category (immutable).
:param bold: Bold attribute (SGR 1).
:param dim: Dim/faint attribute (SGR 2).
:param italic: Italic attribute (SGR 3).
:param underline: Underline attribute (SGR 4).
:param blink: Slow blink attribute (SGR 5).
:param rapid_blink: Rapid blink attribute (SGR 6).
:param inverse: Inverse/reverse attribute (SGR 7).
:param hidden: Hidden/invisible attribute (SGR 8).
:param strikethrough: Strikethrough attribute (SGR 9).
:param double_underline: Double underline attribute (SGR 21).
:param foreground: Foreground color as tuple of SGR params, or None for default.
:param background: Background color as tuple of SGR params, or None for default.
"""
bold: bool = False
dim: bool = False
italic: bool = False
underline: bool = False
blink: bool = False
rapid_blink: bool = False
inverse: bool = False
hidden: bool = False
strikethrough: bool = False
double_underline: bool = False
foreground: tuple[int, ...] | None = None
background: tuple[int, ...] | None = None
# Default state with no attributes set
_SGR_STATE_DEFAULT = _SGRState()
def _sgr_state_is_active(state: _SGRState) -> bool:
"""
Return True if any attributes are set.
:param state: The SGR state to check.
:returns: True if any attribute differs from default.
"""
return (state.bold or state.dim or state.italic or state.underline
or state.blink or state.rapid_blink or state.inverse or state.hidden
or state.strikethrough or state.double_underline
or state.foreground is not None or state.background is not None)
def _sgr_state_to_sequence(state: _SGRState) -> str:
"""
Generate minimal SGR sequence to restore this state from reset.
:param state: The SGR state to convert.
:returns: SGR escape sequence string, or empty string if no attributes set.
"""
if not _sgr_state_is_active(state):
return ''
# Map boolean attributes to their SGR codes
bool_attrs = [
(state.bold, '1'), (state.dim, '2'), (state.italic, '3'),
(state.underline, '4'), (state.blink, '5'), (state.rapid_blink, '6'),
(state.inverse, '7'), (state.hidden, '8'), (state.strikethrough, '9'),
(state.double_underline, '21'),
]
params = [code for active, code in bool_attrs if active]
# Add color params (already formatted as tuples)
if state.foreground is not None:
params.append(';'.join(str(p) for p in state.foreground))
if state.background is not None:
params.append(';'.join(str(p) for p in state.background))
return f'\x1b[{";".join(params)}m'
def _parse_sgr_params(sequence: str) -> list[int | tuple[int, ...]]:
r"""
Parse SGR sequence and return list of parameter values.
Handles compound sequences like ``\x1b[1;31;4m`` -> [1, 31, 4].
Empty params (e.g., ``\x1b[m``) are treated as [0] (reset).
Colon-separated extended colors like ``\x1b[38:2::255:0:0m`` are returned
as tuples: [(38, 2, 255, 0, 0)].
:param sequence: SGR escape sequence string.
:returns: List of integer parameters or tuples for colon-separated colors.
"""
match = _SGR_PATTERN.match(sequence)
if not match:
return []
params_str = match.group(1)
if not params_str:
return [0] # \x1b[m is equivalent to \x1b[0m
result: list[int | tuple[int, ...]] = []
for param in params_str.split(';'):
if ':' in param:
# Colon-separated extended color (ITU T.416 format)
# e.g., "38:2::255:0:0" or "38:2:1:255:0:0" (with colorspace)
parts = [int(p) if p else 0 for p in param.split(':')]
result.append(tuple(parts))
else:
result.append(int(param) if param else 0)
return result
def _parse_extended_color(
params: Iterator[int | tuple[int, ...]], base: int
) -> tuple[int, ...] | None:
"""
Parse extended color (256-color or RGB) from parameter iterator.
:param params: Iterator of remaining SGR parameters (semicolon-separated format).
:param base: Base code (38 for foreground, 48 for background).
:returns: Color tuple like (38, 5, N) or (38, 2, R, G, B), or None if malformed.
"""
try:
mode = next(params)
if isinstance(mode, tuple):
return None # Unexpected tuple, colon format handled separately
if mode == 5: # 256-color
n = next(params)
if isinstance(n, tuple):
return None
return (int(base), 5, n)
if mode == 2: # RGB
r, g, b = next(params), next(params), next(params)
if isinstance(r, tuple) or isinstance(g, tuple) or isinstance(b, tuple):
return None
return (int(base), 2, r, g, b)
except StopIteration:
pass
return None
def _sgr_state_update(state: _SGRState, sequence: str) -> _SGRState:
# pylint: disable=too-many-branches,too-complex,too-many-statements
# NOTE: When minimum Python version is 3.10+, this can be simplified using match/case.
"""
Parse SGR sequence and return new state with updates applied.
:param state: Current SGR state.
:param sequence: SGR escape sequence string.
:returns: New SGRState with updates applied.
"""
params_list = _parse_sgr_params(sequence)
params = iter(params_list)
for p in params:
# Handle colon-separated extended colors (ITU T.416 format)
if isinstance(p, tuple):
if len(p) >= 2 and p[0] == _SGR.FG_EXTENDED:
# Foreground: (38, 2, [colorspace,] R, G, B) or (38, 5, N)
state = state._replace(foreground=p)
elif len(p) >= 2 and p[0] == _SGR.BG_EXTENDED:
# Background: (48, 2, [colorspace,] R, G, B) or (48, 5, N)
state = state._replace(background=p)
continue
if p == _SGR.RESET:
state = _SGR_STATE_DEFAULT
# Attribute ON codes
elif p == _SGR.BOLD:
state = state._replace(bold=True)
elif p == _SGR.DIM:
state = state._replace(dim=True)
elif p == _SGR.ITALIC:
state = state._replace(italic=True)
elif p == _SGR.UNDERLINE:
state = state._replace(underline=True)
elif p == _SGR.BLINK:
state = state._replace(blink=True)
elif p == _SGR.RAPID_BLINK:
state = state._replace(rapid_blink=True)
elif p == _SGR.INVERSE:
state = state._replace(inverse=True)
elif p == _SGR.HIDDEN:
state = state._replace(hidden=True)
elif p == _SGR.STRIKETHROUGH:
state = state._replace(strikethrough=True)
elif p == _SGR.DOUBLE_UNDERLINE:
state = state._replace(double_underline=True)
# Attribute OFF codes
elif p == _SGR.BOLD_DIM_OFF:
state = state._replace(bold=False, dim=False)
elif p == _SGR.ITALIC_OFF:
state = state._replace(italic=False)
elif p == _SGR.UNDERLINE_OFF:
state = state._replace(underline=False, double_underline=False)
elif p == _SGR.BLINK_OFF:
state = state._replace(blink=False, rapid_blink=False)
elif p == _SGR.INVERSE_OFF:
state = state._replace(inverse=False)
elif p == _SGR.HIDDEN_OFF:
state = state._replace(hidden=False)
elif p == _SGR.STRIKETHROUGH_OFF:
state = state._replace(strikethrough=False)
# Basic colors (30-37, 40-47 standard; 90-97, 100-107 bright)
elif (_SGR.FG_BLACK <= p <= _SGR.FG_WHITE
or _SGR.FG_BRIGHT_BLACK <= p <= _SGR.FG_BRIGHT_WHITE):
state = state._replace(foreground=(p,))
elif (_SGR.BG_BLACK <= p <= _SGR.BG_WHITE
or _SGR.BG_BRIGHT_BLACK <= p <= _SGR.BG_BRIGHT_WHITE):
state = state._replace(background=(p,))
elif p == _SGR.FG_DEFAULT:
state = state._replace(foreground=None)
elif p == _SGR.BG_DEFAULT:
state = state._replace(background=None)
# Extended colors (semicolon-separated format)
elif p == _SGR.FG_EXTENDED:
if color := _parse_extended_color(params, _SGR.FG_EXTENDED):
state = state._replace(foreground=color)
elif p == _SGR.BG_EXTENDED:
if color := _parse_extended_color(params, _SGR.BG_EXTENDED):
state = state._replace(background=color)
return state
def propagate_sgr(lines: Sequence[str]) -> list[str]:
r"""
Propagate SGR codes across wrapped lines.
When text with SGR styling is wrapped across multiple lines, each line
needs to be self-contained for proper display. This function:
- Ends each line with ``\x1b[0m`` if styles are active (prevents bleeding)
- Starts each subsequent line with the active style restored
:param lines: List of text lines, possibly containing SGR sequences.
:returns: List of lines with SGR codes propagated.
Example::
>>> propagate_sgr(['\x1b[31mhello', 'world\x1b[0m'])
['\x1b[31mhello\x1b[0m', '\x1b[31mworld\x1b[0m']
This is useful in cases of making special editors and viewers, and is used for the
default modes (propagate_sgr=True) of :func:`wcwidth.width` and :func:`wcwidth.clip`.
When wrapping and clipping text containing SGR sequences, maybe a previous line enabled the BLUE
color--if we are viewing *only* the line following, we would want the carry over the BLUE color,
and all lines with sequences should end with terminating reset (``\x1b[0m``).
"""
# Fast path: check if any line contains SGR sequences
if not any(_SGR_QUICK_CHECK.search(line) for line in lines) or not lines:
return list(lines)
result: list[str] = []
state = _SGR_STATE_DEFAULT
for line in lines:
# Prefix with restoration sequence if state is active
prefix = _sgr_state_to_sequence(state)
# Update state by processing all SGR sequences in this line
for match in _SGR_PATTERN.finditer(line):
state = _sgr_state_update(state, match.group())
# Build output line
output_line = prefix + line if prefix else line
if _sgr_state_is_active(state):
output_line = output_line + _SGR_RESET
result.append(output_line)
return result