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

176 statements  

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 has 

6attributes necessary to perform its functions, eg 'RED' and 'BLUE' attributes are not defined. 

7""" 

8 

9from __future__ import annotations 

10 

11# std imports 

12import re 

13from enum import IntEnum 

14 

15from typing import TYPE_CHECKING, Iterator, NamedTuple 

16 

17if TYPE_CHECKING: # pragma: no cover 

18 from typing import Sequence 

19 

20 

21class _SGR(IntEnum): 

22 """ 

23 SGR (Select Graphic Rendition) parameter codes. 

24 

25 References: 

26 - https://invisible-island.net/xterm/ctlseqs/ctlseqs.html 

27 - https://github.com/tehmaze/ansi/tree/master/ansi/colour 

28 """ 

29 

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 

60 

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: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') 

67 

68# Fast path: quick check if any SGR sequence exists 

69_SGR_QUICK_CHECK = re.compile(r'\x1b\[[\d;:]*m') 

70 

71# Reset sequence 

72_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 = 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 

105 

106 

107# Default state with no attributes set 

108_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.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) 

122 

123 

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. 

127 

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. 

131 

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) 

138 

139 

140def _sgr_state_to_sequence(state: _SGRState) -> str: 

141 """ 

142 Generate minimal SGR sequence to restore this state from reset. 

143 

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 '' 

149 

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] 

158 

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)) 

164 

165 return f'\x1b[{";".join(params)}m' 

166 

167 

168def _parse_sgr_params(sequence: str) -> list[int | tuple[int, ...]]: 

169 r""" 

170 Parse SGR sequence and return list of parameter values. 

171 

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)]. 

176 

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 

196 

197 

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. 

203 

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 

225 

226 

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. 

232 

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 

306 

307 

308def propagate_sgr(lines: Sequence[str]) -> list[str]: 

309 r""" 

310 Propagate SGR codes across wrapped lines. 

311 

312 When text with SGR styling is wrapped across multiple lines, each line 

313 needs to be self-contained for proper display. This function: 

314 

315 - Ends each line with ``\x1b[0m`` if styles are active (prevents bleeding) 

316 - Starts each subsequent line with the active style restored 

317 

318 :param lines: List of text lines, possibly containing SGR sequences. 

319 :returns: List of lines with SGR codes propagated. 

320 

321 Example:: 

322 

323 >>> propagate_sgr(['\x1b[31mhello', 'world\x1b[0m']) 

324 ['\x1b[31mhello\x1b[0m', '\x1b[31mworld\x1b[0m'] 

325 

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`. 

328 

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) 

336 

337 result: list[str] = [] 

338 state = _SGR_STATE_DEFAULT 

339 

340 for line in lines: 

341 # Prefix with restoration sequence if state is active 

342 prefix = _sgr_state_to_sequence(state) 

343 

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()) 

347 

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 

352 

353 result.append(output_line) 

354 

355 return result