iLO-manager first main commitgit status

This commit is contained in:
Jiří Stach committed 2026-02-03 15:56:53 +01:00
1 parent e6f4cc04ca
commit 599d8da562
3271 files changed
+550655

No files matched your search

@@ -0,0 +1,44 @@
"""
Wcwidth module.
https://github.com/jquast/wcwidth
"""
# re-export all functions & definitions, even private ones, from top-level
# module path, to allow for 'from wcwidth import _private_func'. Of course,
# user beware that any _private functions or variables not exported by __all__
# may disappear or change signature at any future version.
# local
from .wcwidth import ZERO_WIDTH # noqa
from .wcwidth import (WIDE_EASTASIAN,
AMBIGUOUS_EASTASIAN,
VS16_NARROW_TO_WIDE,
clip,
ljust,
rjust,
width,
center,
wcwidth,
wcswidth,
list_versions,
iter_sequences,
strip_sequences,
_wcmatch_version,
_wcversion_value)
from .bisearch import bisearch as _bisearch
from .grapheme import grapheme_boundary_before # noqa
from .grapheme import iter_graphemes, iter_graphemes_reverse
from .textwrap import SequenceTextWrapper, wrap
from .sgr_state import propagate_sgr
# The __all__ attribute defines the items exported from statement,
# 'from wcwidth import *', but also to say, "This is the public API".
__all__ = ('wcwidth', 'wcswidth', 'width', 'iter_sequences', 'iter_graphemes',
'iter_graphemes_reverse', 'grapheme_boundary_before',
'ljust', 'rjust', 'center', 'wrap', 'clip', 'strip_sequences',
'list_versions', 'propagate_sgr')
# We also used pkg_resources to load unicode version tables from version.json,
# generated by bin/update-tables.py, but some environments are unable to
# import pkg_resources for one reason or another, yikes!
__version__ = '0.5.0'
@@ -0,0 +1,29 @@
"""Binary search function for Unicode interval tables."""
from __future__ import annotations
def bisearch(ucs: int, table: tuple[tuple[int, int], ...]) -> int:
"""
Binary search in interval table.
:param ucs: Ordinal value of unicode character.
:param table: Tuple of starting and ending ranges of ordinal values,
in form of ``((start, end), ...)``.
:returns: 1 if ordinal value ucs is found within lookup table, else 0.
"""
lbound = 0
ubound = len(table) - 1
if ucs < table[0][0] or ucs > table[ubound][1]:
return 0
while ubound >= lbound:
mid = (lbound + ubound) // 2
if ucs > table[mid][1]:
lbound = mid + 1
elif ucs < table[mid][0]:
ubound = mid - 1
else:
return 1
return 0
@@ -0,0 +1,46 @@
"""
Control character sets for terminal handling.
This module provides the control character sets used by the width() function to handle terminal
control characters.
"""
# Illegal C0/C1 control characters.
# These raise ValueError in 'strict' mode.
ILLEGAL_CTRL = frozenset(
chr(c) for c in (
list(range(0x01, 0x07)) + # SOH, STX, ETX (^C), EOT (^D), ENQ, ACK
list(range(0x10, 0x1b)) + # DLE through SUB (^Z)
list(range(0x1c, 0x20)) + # FS, GS, RS, US
[0x7f] + # DEL
list(range(0x80, 0xa0)) # C1 control characters
)
)
# Vertical movement control characters.
# These raise ValueError in 'strict' mode (indeterminate horizontal position).
VERTICAL_CTRL = frozenset({
'\x0a', # LF (line feed)
'\x0b', # VT (vertical tab)
'\x0c', # FF (form feed)
})
# Horizontal movement control characters.
# These affect cursor position and are tracked in 'strict' and 'parse' modes.
HORIZONTAL_CTRL = frozenset({
'\x08', # BS (backspace) - cursor left 1
'\x09', # HT (horizontal tab) - advance to next tab stop
'\x0d', # CR (carriage return) - cursor to column 0
})
# Terminal-valid zero-width control characters.
# These are allowed in all modes (zero-width, no movement).
ZERO_WIDTH_CTRL = frozenset({
'\x00', # NUL
'\x07', # BEL (bell)
'\x0e', # SO (shift out)
'\x0f', # SI (shift in)
})
# All control characters that need special handling (not regular printable).
ALL_CTRL = ILLEGAL_CTRL | VERTICAL_CTRL | HORIZONTAL_CTRL | ZERO_WIDTH_CTRL | {'\x1b'}
@@ -0,0 +1,69 @@
r"""
Terminal escape sequence patterns.
This module provides regex patterns for matching terminal escape sequences. All patterns match
sequences that begin with ESC (``\x1b``). Before calling re.match with these patterns, callers
should first check that the character at the current position is ESC for optimal performance.
"""
# std imports
import re
# Zero-width escape sequences (SGR, OSC, CSI, etc.). This table, like INDETERMINATE_EFFECT_SEQUENCE,
# originated from the 'blessed' library.
ZERO_WIDTH_PATTERN = re.compile(
# CSI sequences
r'\x1b\[[\x30-\x3f]*[\x20-\x2f]*[\x40-\x7e]|'
# OSC sequences
r'\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|'
# APC sequences
r'\x1b_[^\x1b\x07]*(?:\x07|\x1b\\)|'
# DCS sequences
r'\x1bP[^\x1b\x07]*(?:\x07|\x1b\\)|'
# PM sequences
r'\x1b\^[^\x1b\x07]*(?:\x07|\x1b\\)|'
# Character set designation
r'\x1b[()].|'
# Fe sequences
r'\x1b[\x40-\x5f]|'
# Fp sequences
r'\x1b[78=>g]'
)
# Cursor right movement: CSI [n] C, parameter may be parsed by width()
CURSOR_RIGHT_SEQUENCE = re.compile(r'\x1b\[(\d*)C')
# Cursor left movement: CSI [n] D, parameter may be parsed by width()
CURSOR_LEFT_SEQUENCE = re.compile(r'\x1b\[(\d*)D')
# Indeterminate effect sequences - raise ValueError in 'strict' mode. The effects of these sequences
# are likely to be undesirable, moving the cursor vertically or to any unknown position, and
# otherwise not managed by the 'width' method of this library.
#
# This table was created initially with code generation by extraction of termcap library with
# techniques used at 'blessed' library runtime for 'xterm', 'alacritty', 'kitty', ghostty',
# 'screen', 'tmux', and others. Then, these common capabilities were merged into the list below.
INDETERMINATE_EFFECT_SEQUENCE = re.compile(
'|'.join(f'(?:{_pattern})' for _pattern in (
r'\x1b\[\d+;\d+r', # change_scroll_region
r'\x1b\[\d*K', # erase_in_line (clr_eol, clr_bol)
r'\x1b\[\d*J', # erase_in_display (clr_eos, erase_display)
r'\x1b\[\d*G', # column_address
r'\x1b\[\d+;\d+H', # cursor_address
r'\x1b\[\d*H', # cursor_home
r'\x1b\[\d*A', # cursor_up
r'\x1b\[\d*B', # cursor_down
r'\x1b\[\d*P', # delete_character
r'\x1b\[\d*M', # delete_line
r'\x1b\[\d*L', # insert_line
r'\x1b\[\d*@', # insert_character
r'\x1b\[\d+X', # erase_chars
r'\x1b\[\d*S', # scroll_up (parm_index)
r'\x1b\[\d*T', # scroll_down (parm_rindex)
r'\x1b\[\d*d', # row_address
r'\x1b\[\?1049[hl]', # alternate screen buffer
r'\x1b\[\?47[hl]', # alternate screen (legacy)
r'\x1b8', # restore_cursor
r'\x1bD', # scroll_forward (index)
r'\x1bM', # scroll_reverse (reverse index)
))
)
@@ -0,0 +1,425 @@
"""
Grapheme cluster segmentation following Unicode Standard Annex #29.
This module provides pure-Python implementation of the grapheme cluster boundary algorithm as
defined in UAX #29: Unicode Text Segmentation.
https://www.unicode.org/reports/tr29/
"""
from __future__ import annotations
# std imports
from enum import IntEnum
from functools import lru_cache
from typing import TYPE_CHECKING, NamedTuple
# local
from .bisearch import bisearch as _bisearch
from .table_grapheme import (GRAPHEME_L,
GRAPHEME_T,
GRAPHEME_V,
GRAPHEME_LV,
INCB_EXTEND,
INCB_LINKER,
GRAPHEME_LVT,
INCB_CONSONANT,
GRAPHEME_EXTEND,
GRAPHEME_CONTROL,
GRAPHEME_PREPEND,
GRAPHEME_SPACINGMARK,
EXTENDED_PICTOGRAPHIC,
GRAPHEME_REGIONAL_INDICATOR)
if TYPE_CHECKING: # pragma: no cover
# std imports
from collections.abc import Iterator
# Maximum backward scan distance when finding grapheme cluster boundaries.
# Covers all known Unicode grapheme clusters with margin; longer sequences are pathological.
MAX_GRAPHEME_SCAN = 32
class GCB(IntEnum):
"""Grapheme Cluster Break property values."""
OTHER = 0
CR = 1
LF = 2
CONTROL = 3
EXTEND = 4
ZWJ = 5
REGIONAL_INDICATOR = 6
PREPEND = 7
SPACING_MARK = 8
L = 9
V = 10
T = 11
LV = 12
LVT = 13
@lru_cache(maxsize=1000)
def _grapheme_cluster_break(ucs: int) -> GCB:
# pylint: disable=too-many-branches,too-complex
"""Return the Grapheme_Cluster_Break property for a codepoint."""
# Single codepoint matches
if ucs == 0x000d:
return GCB.CR
if ucs == 0x000a:
return GCB.LF
if ucs == 0x200d:
return GCB.ZWJ
# Matching by codepoint ranges, requiring binary search
if _bisearch(ucs, GRAPHEME_CONTROL):
return GCB.CONTROL
if _bisearch(ucs, GRAPHEME_EXTEND):
return GCB.EXTEND
if _bisearch(ucs, GRAPHEME_REGIONAL_INDICATOR):
return GCB.REGIONAL_INDICATOR
if _bisearch(ucs, GRAPHEME_PREPEND):
return GCB.PREPEND
if _bisearch(ucs, GRAPHEME_SPACINGMARK):
return GCB.SPACING_MARK
if _bisearch(ucs, GRAPHEME_L):
return GCB.L
if _bisearch(ucs, GRAPHEME_V):
return GCB.V
if _bisearch(ucs, GRAPHEME_T):
return GCB.T
if _bisearch(ucs, GRAPHEME_LV):
return GCB.LV
if _bisearch(ucs, GRAPHEME_LVT):
return GCB.LVT
return GCB.OTHER
@lru_cache(maxsize=512)
def _is_extended_pictographic(ucs: int) -> bool:
"""Check if codepoint has Extended_Pictographic property."""
return bool(_bisearch(ucs, EXTENDED_PICTOGRAPHIC))
@lru_cache(maxsize=128)
def _is_incb_linker(ucs: int) -> bool:
"""Check if codepoint has InCB=Linker property."""
return bool(_bisearch(ucs, INCB_LINKER))
@lru_cache(maxsize=256)
def _is_incb_consonant(ucs: int) -> bool:
"""Check if codepoint has InCB=Consonant property."""
return bool(_bisearch(ucs, INCB_CONSONANT))
@lru_cache(maxsize=256)
def _is_incb_extend(ucs: int) -> bool:
"""Check if codepoint has InCB=Extend property."""
return bool(_bisearch(ucs, INCB_EXTEND))
class BreakResult(NamedTuple):
"""Result of grapheme cluster break decision."""
should_break: bool
ri_count: int
@lru_cache(maxsize=196) # 14 GCB values × 14 = 196 max combinations
def _simple_break_check(prev_gcb: GCB, curr_gcb: GCB) -> BreakResult | None:
"""
Check simple GCB-pair-based break rules (cacheable).
Returns BreakResult for rules that can be determined from GCB properties alone, or None if
complex lookback rules (GB9c, GB11) need to be checked.
"""
# GB3: CR x LF
if prev_gcb == GCB.CR and curr_gcb == GCB.LF:
return BreakResult(should_break=False, ri_count=0)
# GB4: (Control|CR|LF) ÷
if prev_gcb in (GCB.CONTROL, GCB.CR, GCB.LF):
return BreakResult(should_break=True, ri_count=0)
# GB5: ÷ (Control|CR|LF)
if curr_gcb in (GCB.CONTROL, GCB.CR, GCB.LF):
return BreakResult(should_break=True, ri_count=0)
# GB6: L x (L|V|LV|LVT)
if prev_gcb == GCB.L and curr_gcb in (GCB.L, GCB.V, GCB.LV, GCB.LVT):
return BreakResult(should_break=False, ri_count=0)
# GB7: (LV|V) x (V|T)
if prev_gcb in (GCB.LV, GCB.V) and curr_gcb in (GCB.V, GCB.T):
return BreakResult(should_break=False, ri_count=0)
# GB8: (LVT|T) x T
if prev_gcb in (GCB.LVT, GCB.T) and curr_gcb == GCB.T:
return BreakResult(should_break=False, ri_count=0)
# GB9: x (Extend|ZWJ) - but ZWJ needs GB11 check, so only handle Extend here
if curr_gcb == GCB.EXTEND:
return BreakResult(should_break=False, ri_count=0)
# GB9a: x SpacingMark
if curr_gcb == GCB.SPACING_MARK:
return BreakResult(should_break=False, ri_count=0)
# GB9b: Prepend x
if prev_gcb == GCB.PREPEND:
return BreakResult(should_break=False, ri_count=0)
# GB9c and GB11 need lookback - return None to signal complex check needed
# GB12/13 (RI pairs) need ri_count state - also handled in main function
return None
def _should_break(
prev_gcb: GCB,
curr_gcb: GCB,
text: str,
curr_idx: int,
ri_count: int,
) -> BreakResult:
# pylint: disable=too-many-branches,too-complex
"""
Determine if there should be a grapheme cluster break between prev and curr.
Implements UAX #29 grapheme cluster boundary rules.
"""
# Try cached simple rules first
result = _simple_break_check(prev_gcb, curr_gcb)
if result is not None:
return result
# GB9: x ZWJ (not cached because GB11 needs lookback when prev is ZWJ)
if curr_gcb == GCB.ZWJ:
return BreakResult(should_break=False, ri_count=0)
# GB9c: Indic conjunct cluster
# \p{InCB=Consonant} [\p{InCB=Extend}\p{InCB=Linker}]* \p{InCB=Linker}
# [\p{InCB=Extend}\p{InCB=Linker}]* x \p{InCB=Consonant}
curr_ucs = ord(text[curr_idx])
if _is_incb_consonant(curr_ucs):
has_linker = False
i = curr_idx - 1
while i >= 0:
prev_ucs = ord(text[i])
if _is_incb_linker(prev_ucs):
has_linker = True
i -= 1
elif _is_incb_extend(prev_ucs):
i -= 1
elif _is_incb_consonant(prev_ucs):
if has_linker:
return BreakResult(should_break=False, ri_count=0)
break
else:
break
# GB11: ExtPict Extend* ZWJ x ExtPict
if prev_gcb == GCB.ZWJ and _is_extended_pictographic(curr_ucs):
i = curr_idx - 2 # Skip the ZWJ at curr_idx - 1
while i >= 0:
prev_ucs = ord(text[i])
prev_prop = _grapheme_cluster_break(prev_ucs)
if prev_prop == GCB.EXTEND:
i -= 1
elif _is_extended_pictographic(prev_ucs):
return BreakResult(should_break=False, ri_count=0)
else:
break
# GB12/GB13: RI x RI (pair matching)
if prev_gcb == GCB.REGIONAL_INDICATOR and curr_gcb == GCB.REGIONAL_INDICATOR:
if ri_count % 2 == 1:
return BreakResult(should_break=False, ri_count=ri_count + 1)
return BreakResult(should_break=True, ri_count=1)
# GB999: Any ÷ Any
ri_count = 1 if curr_gcb == GCB.REGIONAL_INDICATOR else 0
return BreakResult(should_break=True, ri_count=ri_count)
def iter_graphemes(
unistr: str,
start: int = 0,
end: int | None = None,
) -> Iterator[str]:
r"""
Iterate over grapheme clusters in a Unicode string.
Grapheme clusters are "user-perceived characters" - what a user would
consider a single character, which may consist of multiple Unicode
codepoints (e.g., a base character with combining marks, emoji sequences).
:param unistr: The Unicode string to segment.
:param start: Starting index (default 0).
:param end: Ending index (default len(unistr)).
:yields: Grapheme cluster substrings.
Example::
>>> list(iter_graphemes('cafe\u0301'))
['c', 'a', 'f', 'e\u0301']
>>> list(iter_graphemes('\U0001F468\u200D\U0001F469\u200D\U0001F467'))
['o', 'k', '\U0001F468\u200D\U0001F469\u200D\U0001F467']
>>> list(iter_graphemes('\U0001F1FA\U0001F1F8'))
['o', 'k', '\U0001F1FA\U0001F1F8']
.. versionadded:: 0.3.0
"""
if not unistr:
return
length = len(unistr)
if end is None:
end = length
if start >= end or start >= length:
return
end = min(end, length)
# Track state for grapheme cluster boundaries
cluster_start = start
ri_count = 0
# Get GCB for first character
prev_gcb = _grapheme_cluster_break(ord(unistr[start]))
# Handle Regional Indicator count initialization
if prev_gcb == GCB.REGIONAL_INDICATOR:
ri_count = 1
for idx in range(start + 1, end):
curr_gcb = _grapheme_cluster_break(ord(unistr[idx]))
result = _should_break(prev_gcb, curr_gcb, unistr, idx, ri_count)
ri_count = result.ri_count
if result.should_break:
yield unistr[cluster_start:idx]
cluster_start = idx
prev_gcb = curr_gcb
# Yield the final cluster
yield unistr[cluster_start:end]
def _find_cluster_start(text: str, pos: int) -> int:
"""
Find the start of the grapheme cluster containing the character before pos.
Scans backwards from pos to find a safe starting point, then iterates forward using standard
break rules to find the actual cluster boundary.
:param text: The Unicode string.
:param pos: Position to search before (exclusive).
:returns: Start position of the grapheme cluster.
"""
target_cp = ord(text[pos - 1])
# GB3: CR x LF - LF after CR is part of same cluster
if target_cp == 0x0A and pos >= 2 and text[pos - 2] == '\r':
return pos - 2
# Fast path: ASCII (except LF) starts its own cluster
if target_cp < 0x80:
# GB9b: Check for preceding PREPEND (rare: Arabic/Brahmic)
if pos >= 2 and target_cp >= 0x20:
prev_cp = ord(text[pos - 2])
if prev_cp >= 0x80 and _grapheme_cluster_break(prev_cp) == GCB.PREPEND:
return _find_cluster_start(text, pos - 1)
return pos - 1
# Scan backward to find a safe starting point
safe_start = pos - 1
while safe_start > 0 and (pos - safe_start) < MAX_GRAPHEME_SCAN:
cp = ord(text[safe_start])
if 0x20 <= cp < 0x80: # ASCII always starts a cluster
break
if _grapheme_cluster_break(cp) == GCB.CONTROL: # GB4
break
safe_start -= 1
# Verify forward to find the actual cluster boundary
cluster_start = safe_start
left_gcb = _grapheme_cluster_break(ord(text[safe_start]))
ri_count = 1 if left_gcb == GCB.REGIONAL_INDICATOR else 0
for i in range(safe_start + 1, pos):
right_gcb = _grapheme_cluster_break(ord(text[i]))
result = _should_break(left_gcb, right_gcb, text, i, ri_count)
ri_count = result.ri_count
if result.should_break:
cluster_start = i
left_gcb = right_gcb
return cluster_start
def grapheme_boundary_before(unistr: str, pos: int) -> int:
r"""
Find the grapheme cluster boundary immediately before a position.
:param unistr: The Unicode string to search.
:param pos: Position in the string (0 < pos <= len(unistr)).
:returns: Start index of the grapheme cluster containing the character at pos-1.
Example::
>>> grapheme_boundary_before('Hello \U0001F44B\U0001F3FB', 8)
6
>>> grapheme_boundary_before('a\r\nb', 3)
1
.. versionadded:: 0.3.6
"""
if pos <= 0:
return 0
return _find_cluster_start(unistr, min(pos, len(unistr)))
def iter_graphemes_reverse(
unistr: str,
start: int = 0,
end: int | None = None,
) -> Iterator[str]:
r"""
Iterate over grapheme clusters in reverse order (last to first).
:param unistr: The Unicode string to segment.
:param start: Starting index (default 0).
:param end: Ending index (default len(unistr)).
:yields: Grapheme cluster substrings in reverse order.
Example::
>>> list(iter_graphemes_reverse('cafe\u0301'))
['e\u0301', 'f', 'a', 'c']
.. versionadded:: 0.3.6
"""
if not unistr:
return
length = len(unistr)
end = length if end is None else min(end, length)
start = max(start, 0)
if start >= end or start >= length:
return
pos = end
while pos > start:
cluster_start = _find_cluster_start(unistr, pos)
# Don't yield partial graphemes that extend before start
if cluster_start < start:
break
yield unistr[cluster_start:pos]
pos = cluster_start
Whitespace-only changes.
@@ -0,0 +1,338 @@
"""
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
@@ -0,0 +1,189 @@
"""
Exports AMBIGUOUS_EASTASIAN table keyed by supporting unicode version level.
This code generated by wcwidth/bin/update-tables.py on 2026-01-18 23:27:15 UTC.
"""
# pylint: disable=duplicate-code
AMBIGUOUS_EASTASIAN = {
'17.0.0': (
# Source: EastAsianWidth-17.0.0.txt
# Date: 2025-07-24, 00:12:54 GMT
#
(0x000a1, 0x000a1,), # Inverted Exclamation Mark
(0x000a4, 0x000a4,), # Currency Sign
(0x000a7, 0x000a8,), # Section Sign ..Diaeresis
(0x000aa, 0x000aa,), # Feminine Ordinal Indicator
(0x000ad, 0x000ae,), # Soft Hyphen ..Registered Sign
(0x000b0, 0x000b4,), # Degree Sign ..Acute Accent
(0x000b6, 0x000ba,), # Pilcrow Sign ..Masculine Ordinal Indica
(0x000bc, 0x000bf,), # Vulgar Fraction One Quar..Inverted Question Mark
(0x000c6, 0x000c6,), # Latin Capital Letter Ae
(0x000d0, 0x000d0,), # Latin Capital Letter Eth
(0x000d7, 0x000d8,), # Multiplication Sign ..Latin Capital Letter O W
(0x000de, 0x000e1,), # Latin Capital Letter Tho..Latin Small Letter A Wit
(0x000e6, 0x000e6,), # Latin Small Letter Ae
(0x000e8, 0x000ea,), # Latin Small Letter E Wit..Latin Small Letter E Wit
(0x000ec, 0x000ed,), # Latin Small Letter I Wit..Latin Small Letter I Wit
(0x000f0, 0x000f0,), # Latin Small Letter Eth
(0x000f2, 0x000f3,), # Latin Small Letter O Wit..Latin Small Letter O Wit
(0x000f7, 0x000fa,), # Division Sign ..Latin Small Letter U Wit
(0x000fc, 0x000fc,), # Latin Small Letter U With Diaeresis
(0x000fe, 0x000fe,), # Latin Small Letter Thorn
(0x00101, 0x00101,), # Latin Small Letter A With Macron
(0x00111, 0x00111,), # Latin Small Letter D With Stroke
(0x00113, 0x00113,), # Latin Small Letter E With Macron
(0x0011b, 0x0011b,), # Latin Small Letter E With Caron
(0x00126, 0x00127,), # Latin Capital Letter H W..Latin Small Letter H Wit
(0x0012b, 0x0012b,), # Latin Small Letter I With Macron
(0x00131, 0x00133,), # Latin Small Letter Dotle..Latin Small Ligature Ij
(0x00138, 0x00138,), # Latin Small Letter Kra
(0x0013f, 0x00142,), # Latin Capital Letter L W..Latin Small Letter L Wit
(0x00144, 0x00144,), # Latin Small Letter N With Acute
(0x00148, 0x0014b,), # Latin Small Letter N Wit..Latin Small Letter Eng
(0x0014d, 0x0014d,), # Latin Small Letter O With Macron
(0x00152, 0x00153,), # Latin Capital Ligature O..Latin Small Ligature Oe
(0x00166, 0x00167,), # Latin Capital Letter T W..Latin Small Letter T Wit
(0x0016b, 0x0016b,), # Latin Small Letter U With Macron
(0x001ce, 0x001ce,), # Latin Small Letter A With Caron
(0x001d0, 0x001d0,), # Latin Small Letter I With Caron
(0x001d2, 0x001d2,), # Latin Small Letter O With Caron
(0x001d4, 0x001d4,), # Latin Small Letter U With Caron
(0x001d6, 0x001d6,), # Latin Small Letter U With Diaeresis And Macron
(0x001d8, 0x001d8,), # Latin Small Letter U With Diaeresis And Acute
(0x001da, 0x001da,), # Latin Small Letter U With Diaeresis And Caron
(0x001dc, 0x001dc,), # Latin Small Letter U With Diaeresis And Grave
(0x00251, 0x00251,), # Latin Small Letter Alpha
(0x00261, 0x00261,), # Latin Small Letter Script G
(0x002c4, 0x002c4,), # Modifier Letter Up Arrowhead
(0x002c7, 0x002c7,), # Caron
(0x002c9, 0x002cb,), # Modifier Letter Macron ..Modifier Letter Grave Ac
(0x002cd, 0x002cd,), # Modifier Letter Low Macron
(0x002d0, 0x002d0,), # Modifier Letter Triangular Colon
(0x002d8, 0x002db,), # Breve ..Ogonek
(0x002dd, 0x002dd,), # Double Acute Accent
(0x002df, 0x002df,), # Modifier Letter Cross Accent
(0x00391, 0x003a1,), # Greek Capital Letter Alp..Greek Capital Letter Rho
(0x003a3, 0x003a9,), # Greek Capital Letter Sig..Greek Capital Letter Ome
(0x003b1, 0x003c1,), # Greek Small Letter Alpha..Greek Small Letter Rho
(0x003c3, 0x003c9,), # Greek Small Letter Sigma..Greek Small Letter Omega
(0x00401, 0x00401,), # Cyrillic Capital Letter Io
(0x00410, 0x0044f,), # Cyrillic Capital Letter ..Cyrillic Small Letter Ya
(0x00451, 0x00451,), # Cyrillic Small Letter Io
(0x02010, 0x02010,), # Hyphen
(0x02013, 0x02016,), # En Dash ..Double Vertical Line
(0x02018, 0x02019,), # Left Single Quotation Ma..Right Single Quotation M
(0x0201c, 0x0201d,), # Left Double Quotation Ma..Right Double Quotation M
(0x02020, 0x02022,), # Dagger ..Bullet
(0x02024, 0x02027,), # One Dot Leader ..Hyphenation Point
(0x02030, 0x02030,), # Per Mille Sign
(0x02032, 0x02033,), # Prime ..Double Prime
(0x02035, 0x02035,), # Reversed Prime
(0x0203b, 0x0203b,), # Reference Mark
(0x0203e, 0x0203e,), # Overline
(0x02074, 0x02074,), # Superscript Four
(0x0207f, 0x0207f,), # Superscript Latin Small Letter N
(0x02081, 0x02084,), # Subscript One ..Subscript Four
(0x020ac, 0x020ac,), # Euro Sign
(0x02103, 0x02103,), # Degree Celsius
(0x02105, 0x02105,), # Care Of
(0x02109, 0x02109,), # Degree Fahrenheit
(0x02113, 0x02113,), # Script Small L
(0x02116, 0x02116,), # Numero Sign
(0x02121, 0x02122,), # Telephone Sign ..Trade Mark Sign
(0x02126, 0x02126,), # Ohm Sign
(0x0212b, 0x0212b,), # Angstrom Sign
(0x02153, 0x02154,), # Vulgar Fraction One Thir..Vulgar Fraction Two Thir
(0x0215b, 0x0215e,), # Vulgar Fraction One Eigh..Vulgar Fraction Seven Ei
(0x02160, 0x0216b,), # Roman Numeral One ..Roman Numeral Twelve
(0x02170, 0x02179,), # Small Roman Numeral One ..Small Roman Numeral Ten
(0x02189, 0x02189,), # Vulgar Fraction Zero Thirds
(0x02190, 0x02199,), # Leftwards Arrow ..South West Arrow
(0x021b8, 0x021b9,), # North West Arrow To Long..Leftwards Arrow To Bar O
(0x021d2, 0x021d2,), # Rightwards Double Arrow
(0x021d4, 0x021d4,), # Left Right Double Arrow
(0x021e7, 0x021e7,), # Upwards White Arrow
(0x02200, 0x02200,), # For All
(0x02202, 0x02203,), # Partial Differential ..There Exists
(0x02207, 0x02208,), # Nabla ..Element Of
(0x0220b, 0x0220b,), # Contains As Member
(0x0220f, 0x0220f,), # N-ary Product
(0x02211, 0x02211,), # N-ary Summation
(0x02215, 0x02215,), # Division Slash
(0x0221a, 0x0221a,), # Square Root
(0x0221d, 0x02220,), # Proportional To ..Angle
(0x02223, 0x02223,), # Divides
(0x02225, 0x02225,), # Parallel To
(0x02227, 0x0222c,), # Logical And ..Double Integral
(0x0222e, 0x0222e,), # Contour Integral
(0x02234, 0x02237,), # Therefore ..Proportion
(0x0223c, 0x0223d,), # Tilde Operator ..Reversed Tilde
(0x02248, 0x02248,), # Almost Equal To
(0x0224c, 0x0224c,), # All Equal To
(0x02252, 0x02252,), # Approximately Equal To Or The Image Of
(0x02260, 0x02261,), # Not Equal To ..Identical To
(0x02264, 0x02267,), # Less-than Or Equal To ..Greater-than Over Equal
(0x0226a, 0x0226b,), # Much Less-than ..Much Greater-than
(0x0226e, 0x0226f,), # Not Less-than ..Not Greater-than
(0x02282, 0x02283,), # Subset Of ..Superset Of
(0x02286, 0x02287,), # Subset Of Or Equal To ..Superset Of Or Equal To
(0x02295, 0x02295,), # Circled Plus
(0x02299, 0x02299,), # Circled Dot Operator
(0x022a5, 0x022a5,), # Up Tack
(0x022bf, 0x022bf,), # Right Triangle
(0x02312, 0x02312,), # Arc
(0x02460, 0x024e9,), # Circled Digit One ..Circled Latin Small Lett
(0x024eb, 0x0254b,), # Negative Circled Number ..Box Drawings Heavy Verti
(0x02550, 0x02573,), # Box Drawings Double Hori..Box Drawings Light Diago
(0x02580, 0x0258f,), # Upper Half Block ..Left One Eighth Block
(0x02592, 0x02595,), # Medium Shade ..Right One Eighth Block
(0x025a0, 0x025a1,), # Black Square ..White Square
(0x025a3, 0x025a9,), # White Square Containing ..Square With Diagonal Cro
(0x025b2, 0x025b3,), # Black Up-pointing Triang..White Up-pointing Triang
(0x025b6, 0x025b7,), # Black Right-pointing Tri..White Right-pointing Tri
(0x025bc, 0x025bd,), # Black Down-pointing Tria..White Down-pointing Tria
(0x025c0, 0x025c1,), # Black Left-pointing Tria..White Left-pointing Tria
(0x025c6, 0x025c8,), # Black Diamond ..White Diamond Containing
(0x025cb, 0x025cb,), # White Circle
(0x025ce, 0x025d1,), # Bullseye ..Circle With Right Half B
(0x025e2, 0x025e5,), # Black Lower Right Triang..Black Upper Right Triang
(0x025ef, 0x025ef,), # Large Circle
(0x02605, 0x02606,), # Black Star ..White Star
(0x02609, 0x02609,), # Sun
(0x0260e, 0x0260f,), # Black Telephone ..White Telephone
(0x0261c, 0x0261c,), # White Left Pointing Index
(0x0261e, 0x0261e,), # White Right Pointing Index
(0x02640, 0x02640,), # Female Sign
(0x02642, 0x02642,), # Male Sign
(0x02660, 0x02661,), # Black Spade Suit ..White Heart Suit
(0x02663, 0x02665,), # Black Club Suit ..Black Heart Suit
(0x02667, 0x0266a,), # White Club Suit ..Eighth Note
(0x0266c, 0x0266d,), # Beamed Sixteenth Notes ..Music Flat Sign
(0x0266f, 0x0266f,), # Music Sharp Sign
(0x0269e, 0x0269f,), # Three Lines Converging R..Three Lines Converging L
(0x026bf, 0x026bf,), # Squared Key
(0x026c6, 0x026cd,), # Rain ..Disabled Car
(0x026cf, 0x026d3,), # Pick ..Chains
(0x026d5, 0x026e1,), # Alternate One-way Left W..Restricted Left Entry-2
(0x026e3, 0x026e3,), # Heavy Circle With Stroke And Two Dots Above
(0x026e8, 0x026e9,), # Black Cross On Shield ..Shinto Shrine
(0x026eb, 0x026f1,), # Castle ..Umbrella On Ground
(0x026f4, 0x026f4,), # Ferry
(0x026f6, 0x026f9,), # Square Four Corners ..Person With Ball
(0x026fb, 0x026fc,), # Japanese Bank Symbol ..Headstone Graveyard Symb
(0x026fe, 0x026ff,), # Cup On Black Square ..White Flag With Horizont
(0x0273d, 0x0273d,), # Heavy Teardrop-spoked Asterisk
(0x02776, 0x0277f,), # Dingbat Negative Circled..Dingbat Negative Circled
(0x02b56, 0x02b59,), # Heavy Oval With Oval Ins..Heavy Circled Saltire
(0x03248, 0x0324f,), # Circled Number Ten On Bl..Circled Number Eighty On
(0x0e000, 0x0f8ff,), # (nil)
(0x0fffd, 0x0fffd,), # Replacement Character
(0x1f100, 0x1f10a,), # Digit Zero Full Stop ..Digit Nine Comma
(0x1f110, 0x1f12d,), # Parenthesized Latin Capi..Circled Cd
(0x1f130, 0x1f169,), # Squared Latin Capital Le..Negative Circled Latin C
(0x1f170, 0x1f18d,), # Negative Squared Latin C..Negative Squared Sa
(0x1f18f, 0x1f190,), # Negative Squared Wc ..Square Dj
(0x1f19b, 0x1f1ac,), # Squared Three D ..Squared Vod
(0xf0000, 0xffffd,), # (nil)
(0x100000, 0x10fffd,), # (nil)
),
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,126 @@
"""
Exports VS16_NARROW_TO_WIDE table keyed by supporting unicode version level.
This code generated by wcwidth/bin/update-tables.py on 2025-09-15 16:57:50 UTC.
"""
# pylint: disable=duplicate-code
VS16_NARROW_TO_WIDE = {
'9.0.0': (
# Source: 9.0.0
# Date: 2025-01-30, 21:48:29 GMT
#
(0x00023, 0x00023,), # Number Sign
(0x0002a, 0x0002a,), # Asterisk
(0x00030, 0x00039,), # Digit Zero ..Digit Nine
(0x000a9, 0x000a9,), # Copyright Sign
(0x000ae, 0x000ae,), # Registered Sign
(0x0203c, 0x0203c,), # Double Exclamation Mark
(0x02049, 0x02049,), # Exclamation Question Mark
(0x02122, 0x02122,), # Trade Mark Sign
(0x02139, 0x02139,), # Information Source
(0x02194, 0x02199,), # Left Right Arrow ..South West Arrow
(0x021a9, 0x021aa,), # Leftwards Arrow With Hoo..Rightwards Arrow With Ho
(0x02328, 0x02328,), # Keyboard
(0x023cf, 0x023cf,), # Eject Symbol
(0x023ed, 0x023ef,), # Black Right-pointing Dou..Black Right-pointing Tri
(0x023f1, 0x023f2,), # Stopwatch ..Timer Clock
(0x023f8, 0x023fa,), # Double Vertical Bar ..Black Circle For Record
(0x024c2, 0x024c2,), # Circled Latin Capital Letter M
(0x025aa, 0x025ab,), # Black Small Square ..White Small Square
(0x025b6, 0x025b6,), # Black Right-pointing Triangle
(0x025c0, 0x025c0,), # Black Left-pointing Triangle
(0x025fb, 0x025fc,), # White Medium Square ..Black Medium Square
(0x02600, 0x02604,), # Black Sun With Rays ..Comet
(0x0260e, 0x0260e,), # Black Telephone
(0x02611, 0x02611,), # Ballot Box With Check
(0x02618, 0x02618,), # Shamrock
(0x0261d, 0x0261d,), # White Up Pointing Index
(0x02620, 0x02620,), # Skull And Crossbones
(0x02622, 0x02623,), # Radioactive Sign ..Biohazard Sign
(0x02626, 0x02626,), # Orthodox Cross
(0x0262a, 0x0262a,), # Star And Crescent
(0x0262e, 0x0262f,), # Peace Symbol ..Yin Yang
(0x02638, 0x0263a,), # Wheel Of Dharma ..White Smiling Face
(0x02640, 0x02640,), # Female Sign
(0x02642, 0x02642,), # Male Sign
(0x0265f, 0x02660,), # Black Chess Pawn ..Black Spade Suit
(0x02663, 0x02663,), # Black Club Suit
(0x02665, 0x02666,), # Black Heart Suit ..Black Diamond Suit
(0x02668, 0x02668,), # Hot Springs
(0x0267b, 0x0267b,), # Black Universal Recycling Symbol
(0x0267e, 0x0267e,), # Permanent Paper Sign
(0x02692, 0x02692,), # Hammer And Pick
(0x02694, 0x02697,), # Crossed Swords ..Alembic
(0x02699, 0x02699,), # Gear
(0x0269b, 0x0269c,), # Atom Symbol ..Fleur-de-lis
(0x026a0, 0x026a0,), # Warning Sign
(0x026a7, 0x026a7,), # Male With Stroke And Male And Female Sign
(0x026b0, 0x026b1,), # Coffin ..Funeral Urn
(0x026c8, 0x026c8,), # Thunder Cloud And Rain
(0x026cf, 0x026cf,), # Pick
(0x026d1, 0x026d1,), # Helmet With White Cross
(0x026d3, 0x026d3,), # Chains
(0x026e9, 0x026e9,), # Shinto Shrine
(0x026f0, 0x026f1,), # Mountain ..Umbrella On Ground
(0x026f4, 0x026f4,), # Ferry
(0x026f7, 0x026f9,), # Skier ..Person With Ball
(0x02702, 0x02702,), # Black Scissors
(0x02708, 0x02709,), # Airplane ..Envelope
(0x0270c, 0x0270d,), # Victory Hand ..Writing Hand
(0x0270f, 0x0270f,), # Pencil
(0x02712, 0x02712,), # Black Nib
(0x02714, 0x02714,), # Heavy Check Mark
(0x02716, 0x02716,), # Heavy Multiplication X
(0x0271d, 0x0271d,), # Latin Cross
(0x02721, 0x02721,), # Star Of David
(0x02733, 0x02734,), # Eight Spoked Asterisk ..Eight Pointed Black Star
(0x02744, 0x02744,), # Snowflake
(0x02747, 0x02747,), # Sparkle
(0x02763, 0x02764,), # Heavy Heart Exclamation ..Heavy Black Heart
(0x027a1, 0x027a1,), # Black Rightwards Arrow
(0x02934, 0x02935,), # Arrow Pointing Rightward..Arrow Pointing Rightward
(0x02b05, 0x02b07,), # Leftwards Black Arrow ..Downwards Black Arrow
(0x1f170, 0x1f171,), # Negative Squared Latin C..Negative Squared Latin C
(0x1f17e, 0x1f17f,), # Negative Squared Latin C..Negative Squared Latin C
(0x1f321, 0x1f321,), # Thermometer
(0x1f324, 0x1f32c,), # White Sun With Small Clo..Wind Blowing Face
(0x1f336, 0x1f336,), # Hot Pepper
(0x1f37d, 0x1f37d,), # Fork And Knife With Plate
(0x1f396, 0x1f397,), # Military Medal ..Reminder Ribbon
(0x1f399, 0x1f39b,), # Studio Microphone ..Control Knobs
(0x1f39e, 0x1f39f,), # Film Frames ..Admission Tickets
(0x1f3cb, 0x1f3ce,), # Weight Lifter ..Racing Car
(0x1f3d4, 0x1f3df,), # Snow Capped Mountain ..Stadium
(0x1f3f3, 0x1f3f3,), # Waving White Flag
(0x1f3f5, 0x1f3f5,), # Rosette
(0x1f3f7, 0x1f3f7,), # Label
(0x1f43f, 0x1f43f,), # Chipmunk
(0x1f441, 0x1f441,), # Eye
(0x1f4fd, 0x1f4fd,), # Film Projector
(0x1f549, 0x1f54a,), # Om Symbol ..Dove Of Peace
(0x1f56f, 0x1f570,), # Candle ..Mantelpiece Clock
(0x1f573, 0x1f579,), # Hole ..Joystick
(0x1f587, 0x1f587,), # Linked Paperclips
(0x1f58a, 0x1f58d,), # Lower Left Ballpoint Pen..Lower Left Crayon
(0x1f590, 0x1f590,), # Raised Hand With Fingers Splayed
(0x1f5a5, 0x1f5a5,), # Desktop Computer
(0x1f5a8, 0x1f5a8,), # Printer
(0x1f5b1, 0x1f5b2,), # Three Button Mouse ..Trackball
(0x1f5bc, 0x1f5bc,), # Frame With Picture
(0x1f5c2, 0x1f5c4,), # Card Index Dividers ..File Cabinet
(0x1f5d1, 0x1f5d3,), # Wastebasket ..Spiral Calendar Pad
(0x1f5dc, 0x1f5de,), # Compression ..Rolled-up Newspaper
(0x1f5e1, 0x1f5e1,), # Dagger Knife
(0x1f5e3, 0x1f5e3,), # Speaking Head In Silhouette
(0x1f5e8, 0x1f5e8,), # Left Speech Bubble
(0x1f5ef, 0x1f5ef,), # Right Anger Bubble
(0x1f5f3, 0x1f5f3,), # Ballot Box With Ballot
(0x1f5fa, 0x1f5fa,), # World Map
(0x1f6cb, 0x1f6cb,), # Couch And Lamp
(0x1f6cd, 0x1f6cf,), # Shopping Bags ..Bed
(0x1f6e0, 0x1f6e5,), # Hammer And Wrench ..Motor Boat
(0x1f6e9, 0x1f6e9,), # Small Airplane
(0x1f6f0, 0x1f6f0,), # Satellite
(0x1f6f3, 0x1f6f3,), # Passenger Ship
),
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,552 @@
"""
Sequence-aware text wrapping functions.
This module provides functions for wrapping text that may contain terminal escape sequences, with
proper handling of Unicode grapheme clusters and character display widths.
"""
from __future__ import annotations
# std imports
import re
import secrets
import textwrap
from typing import TYPE_CHECKING, NamedTuple
# local
from .wcwidth import width as _width
from .wcwidth import iter_sequences
from .grapheme import iter_graphemes
from .sgr_state import propagate_sgr as _propagate_sgr
from .escape_sequences import ZERO_WIDTH_PATTERN
if TYPE_CHECKING: # pragma: no cover
from typing import Any, Literal
class _HyperlinkState(NamedTuple):
"""State for tracking an open OSC 8 hyperlink across line breaks."""
url: str # hyperlink target URL
params: str # id=xxx and other key=value pairs separated by :
terminator: str # BEL (\x07) or ST (\x1b\\)
# Hyperlink parsing: captures (params, url, terminator)
_HYPERLINK_OPEN_RE = re.compile(r'\x1b]8;([^;]*);([^\x07\x1b]*)(\x07|\x1b\\)')
def _parse_hyperlink_open(seq: str) -> _HyperlinkState | None:
"""Parse OSC 8 open sequence, return state or None."""
if (m := _HYPERLINK_OPEN_RE.match(seq)):
return _HyperlinkState(url=m.group(2), params=m.group(1), terminator=m.group(3))
return None
def _make_hyperlink_open(url: str, params: str, terminator: str) -> str:
"""Generate OSC 8 open sequence."""
return f'\x1b]8;{params};{url}{terminator}'
def _make_hyperlink_close(terminator: str) -> str:
"""Generate OSC 8 close sequence."""
return f'\x1b]8;;{terminator}'
class SequenceTextWrapper(textwrap.TextWrapper):
"""
Sequence-aware text wrapper extending :class:`textwrap.TextWrapper`.
This wrapper properly handles terminal escape sequences and Unicode grapheme clusters when
calculating text width for wrapping.
This implementation is based on the SequenceTextWrapper from the 'blessed' library, with
contributions from Avram Lubkin and grayjk.
The key difference from the blessed implementation is the addition of grapheme cluster support
via :func:`~.iter_graphemes`, providing width calculation for ZWJ emoji sequences, VS-16 emojis
and variations, regional indicator flags, and combining characters.
OSC 8 hyperlinks are handled specially: when a hyperlink must span multiple lines, each line
receives complete open/close sequences with a shared ``id`` parameter, ensuring terminals
treat the fragments as a single hyperlink for hover underlining. If the original hyperlink
already has an ``id`` parameter, it is preserved; otherwise, one is generated.
"""
def __init__(self, width: int = 70, *,
control_codes: Literal['parse', 'strict', 'ignore'] = 'parse',
tabsize: int = 8,
ambiguous_width: int = 1,
**kwargs: Any) -> None:
"""
Initialize the wrapper.
:param width: Maximum line width in display cells.
:param control_codes: How to handle control sequences (see :func:`~.width`).
:param tabsize: Tab stop width for tab expansion.
:param ambiguous_width: Width to use for East Asian Ambiguous (A) characters.
:param kwargs: Additional arguments passed to :class:`textwrap.TextWrapper`.
"""
super().__init__(width=width, **kwargs)
self.control_codes = control_codes
self.tabsize = tabsize
self.ambiguous_width = ambiguous_width
@staticmethod
def _next_hyperlink_id() -> str:
"""Generate unique hyperlink id as 8-character hex string."""
return secrets.token_hex(4)
def _width(self, text: str) -> int:
"""Measure text width accounting for sequences."""
return _width(text, control_codes=self.control_codes, tabsize=self.tabsize,
ambiguous_width=self.ambiguous_width)
def _strip_sequences(self, text: str) -> str:
"""Strip all terminal sequences from text."""
result = []
for segment, is_seq in iter_sequences(text):
if not is_seq:
result.append(segment)
return ''.join(result)
def _extract_sequences(self, text: str) -> str:
"""Extract only terminal sequences from text."""
result = []
for segment, is_seq in iter_sequences(text):
if is_seq:
result.append(segment)
return ''.join(result)
def _split(self, text: str) -> list[str]: # pylint: disable=too-many-locals
r"""
Sequence-aware variant of :meth:`textwrap.TextWrapper._split`.
This method ensures that terminal escape sequences don't interfere with the text splitting
logic, particularly for hyphen-based word breaking. It builds a position mapping from
stripped text to original text, calls the parent's _split on stripped text, then maps chunks
back.
OSC hyperlink sequences are treated as word boundaries::
>>> wrap('foo \x1b]8;;https://example.com\x07link\x1b]8;;\x07 bar', 6)
['foo', '\x1b]8;;https://example.com\x07link\x1b]8;;\x07', 'bar']
Both BEL (``\x07``) and ST (``\x1b\\``) terminators are supported.
"""
# pylint: disable=too-many-locals,too-many-branches
# Build a mapping from stripped text positions to original text positions.
#
# Track where each character ENDS so that sequences between characters
# attach to the following text (not preceding text). This ensures sequences
# aren't lost when whitespace is dropped.
#
# char_end[i] = position in original text right after the i-th stripped char
char_end: list[int] = []
stripped_text = ''
original_pos = 0
prev_was_hyperlink_close = False
for segment, is_seq in iter_sequences(text):
if not is_seq:
# Conditionally insert space after hyperlink close to force word boundary
if prev_was_hyperlink_close and segment and not segment[0].isspace():
stripped_text += ' '
char_end.append(original_pos)
for char in segment:
original_pos += 1
char_end.append(original_pos)
stripped_text += char
prev_was_hyperlink_close = False
else:
is_hyperlink_close = segment.startswith(('\x1b]8;;\x1b\\', '\x1b]8;;\x07'))
# Conditionally insert space before OSC sequences to artificially create word
# boundary, but *not* before hyperlink close sequences, to ensure hyperlink is
# terminated on the same line.
if (segment.startswith('\x1b]') and stripped_text and not
stripped_text[-1].isspace()):
if not is_hyperlink_close:
stripped_text += ' '
char_end.append(original_pos)
# Escape sequences advance position but don't add to stripped text
original_pos += len(segment)
prev_was_hyperlink_close = is_hyperlink_close
# Add sentinel for final position
char_end.append(original_pos)
# Use parent's _split on the stripped text
# pylint: disable-next=protected-access
stripped_chunks = textwrap.TextWrapper._split(self, stripped_text)
# Handle text that contains only sequences (no visible characters).
# Return the sequences as a single chunk to preserve them.
if not stripped_chunks and text:
return [text]
# Map the chunks back to the original text with sequences
result: list[str] = []
stripped_pos = 0
num_chunks = len(stripped_chunks)
for idx, chunk in enumerate(stripped_chunks):
chunk_len = len(chunk)
# Start is where previous character ended (or 0 for first chunk)
start_orig = 0 if stripped_pos == 0 else char_end[stripped_pos - 1]
# End is where next character starts. For last chunk, use sentinel
# to include any trailing sequences.
if idx == num_chunks - 1:
end_orig = char_end[-1] # sentinel includes trailing sequences
else:
end_orig = char_end[stripped_pos + chunk_len - 1]
# Extract the corresponding portion from the original text
# Skip empty chunks (from virtual spaces inserted at OSC boundaries)
if start_orig != end_orig:
result.append(text[start_orig:end_orig])
stripped_pos += chunk_len
return result
def _wrap_chunks(self, chunks: list[str]) -> list[str]: # pylint: disable=too-many-branches
"""
Wrap chunks into lines using sequence-aware width.
Override TextWrapper._wrap_chunks to use _width instead of len. Follows stdlib's algorithm:
greedily fill lines, handle long words. Also handle OSC hyperlink processing. When
hyperlinks span multiple lines, each line gets complete open/close sequences with matching
id parameters for hover underlining continuity per OSC 8 spec.
"""
# pylint: disable=too-many-branches,too-many-statements,too-complex,too-many-locals
# pylint: disable=too-many-nested-blocks
# the hyperlink code in particular really pushes the complexity rating of this method.
# preferring to keep it "all in one method" because of so much local state and manipulation.
if not chunks:
return []
lines: list[str] = []
is_first_line = True
hyperlink_state: _HyperlinkState | None = None
# Track the id we're using for the current hyperlink continuation
current_hyperlink_id: str | None = None
# Arrange in reverse order so items can be efficiently popped
chunks = list(reversed(chunks))
while chunks:
current_line: list[str] = []
current_width = 0
# Get the indent and available width for current line
indent = self.initial_indent if is_first_line else self.subsequent_indent
line_width = self.width - self._width(indent)
# If continuing a hyperlink from previous line, prepend open sequence
if hyperlink_state is not None:
open_seq = _make_hyperlink_open(
hyperlink_state.url, hyperlink_state.params, hyperlink_state.terminator)
chunks[-1] = open_seq + chunks[-1]
# Drop leading whitespace (except at very start)
# When dropping, transfer any sequences to the next chunk.
# Only drop if there's actual whitespace text, not if it's only sequences.
stripped = self._strip_sequences(chunks[-1])
if self.drop_whitespace and lines and stripped and not stripped.strip():
sequences = self._extract_sequences(chunks[-1])
del chunks[-1]
if sequences and chunks:
chunks[-1] = sequences + chunks[-1]
# Greedily add chunks that fit
while chunks:
chunk = chunks[-1]
chunk_width = self._width(chunk)
if current_width + chunk_width <= line_width:
current_line.append(chunks.pop())
current_width += chunk_width
else:
break
# Handle chunk that's too long for any line
if chunks and self._width(chunks[-1]) > line_width:
self._handle_long_word(
chunks, current_line, current_width, line_width
)
current_width = self._width(''.join(current_line))
# Remove any empty chunks left by _handle_long_word
while chunks and not chunks[-1]:
del chunks[-1]
# Drop trailing whitespace
# When dropping, transfer any sequences to the previous chunk.
# Only drop if there's actual whitespace text, not if it's only sequences.
stripped_last = self._strip_sequences(current_line[-1]) if current_line else ''
if (self.drop_whitespace and current_line and
stripped_last and not stripped_last.strip()):
sequences = self._extract_sequences(current_line[-1])
current_width -= self._width(current_line[-1])
del current_line[-1]
if sequences and current_line:
current_line[-1] = current_line[-1] + sequences
if current_line:
line_content = ''.join(current_line)
# Track hyperlink state through this line's content
new_state = self._track_hyperlink_state(line_content, hyperlink_state)
# If we end inside a hyperlink, append close sequence
if new_state is not None:
# Ensure we have an id for continuation
if current_hyperlink_id is None:
if 'id=' in new_state.params:
current_hyperlink_id = new_state.params
elif new_state.params:
# Prepend id to existing params (per OSC 8 spec, params can have
# multiple key=value pairs separated by :)
current_hyperlink_id = (
f'id={self._next_hyperlink_id()}:{new_state.params}')
else:
current_hyperlink_id = f'id={self._next_hyperlink_id()}'
line_content = line_content + _make_hyperlink_close(new_state.terminator)
# Also need to inject the id into the opening sequence if it didn't have one
if 'id=' not in new_state.params:
# Find and replace the original open sequence with one that has id
old_open = _make_hyperlink_open(
new_state.url, new_state.params, new_state.terminator)
new_open = _make_hyperlink_open(
new_state.url, current_hyperlink_id, new_state.terminator)
line_content = line_content.replace(old_open, new_open, 1)
# Update state for next line, using computed id
hyperlink_state = _HyperlinkState(
new_state.url, current_hyperlink_id, new_state.terminator)
else:
hyperlink_state = None
current_hyperlink_id = None # Reset id when hyperlink closes
# Strip trailing whitespace when drop_whitespace is enabled
# (matches CPython #140627 fix behavior)
if self.drop_whitespace:
line_content = line_content.rstrip()
lines.append(indent + line_content)
is_first_line = False
return lines
def _track_hyperlink_state(
self, text: str,
state: _HyperlinkState | None) -> _HyperlinkState | None:
"""
Track hyperlink state through text.
:param text: Text to scan for hyperlink sequences.
:param state: Current state or None if outside hyperlink.
:returns: Updated state after processing text.
"""
for segment, is_seq in iter_sequences(text):
if is_seq:
parsed_link = _parse_hyperlink_open(segment)
if parsed_link is not None and parsed_link.url: # has URL = open
state = parsed_link
elif segment.startswith(('\x1b]8;;\x1b\\', '\x1b]8;;\x07')): # close
state = None
return state
def _handle_long_word(self, reversed_chunks: list[str],
cur_line: list[str], cur_len: int,
width: int) -> None:
"""
Sequence-aware :meth:`textwrap.TextWrapper._handle_long_word`.
This method ensures that word boundaries are not broken mid-sequence, and respects grapheme
cluster boundaries when breaking long words.
"""
if width < 1:
space_left = 1
else:
space_left = width - cur_len
chunk = reversed_chunks[-1]
if self.break_long_words:
break_at_hyphen = False
hyphen_end = 0
# Handle break_on_hyphens: find last hyphen within space_left
if self.break_on_hyphens:
# Strip sequences to find hyphen in logical text
stripped = self._strip_sequences(chunk)
if len(stripped) > space_left:
# Find last hyphen in the portion that fits
hyphen_pos = stripped.rfind('-', 0, space_left)
if hyphen_pos > 0 and any(c != '-' for c in stripped[:hyphen_pos]):
# Map back to original position including sequences
hyphen_end = self._map_stripped_pos_to_original(chunk, hyphen_pos + 1)
break_at_hyphen = True
# Break at grapheme boundaries to avoid splitting multi-codepoint characters
if break_at_hyphen:
actual_end = hyphen_end
else:
actual_end = self._find_break_position(chunk, space_left)
# If no progress possible (e.g., wide char exceeds line width),
# force at least one grapheme to avoid infinite loop.
# Only force when cur_line is empty; if line has content,
# appending nothing is safe and the line will be committed.
if actual_end == 0 and not cur_line:
actual_end = self._find_first_grapheme_end(chunk)
cur_line.append(chunk[:actual_end])
reversed_chunks[-1] = chunk[actual_end:]
elif not cur_line:
cur_line.append(reversed_chunks.pop())
def _map_stripped_pos_to_original(self, text: str, stripped_pos: int) -> int:
"""Map a position in stripped text back to original text position."""
stripped_idx = 0
original_idx = 0
for segment, is_seq in iter_sequences(text):
if is_seq:
original_idx += len(segment)
elif stripped_idx + len(segment) > stripped_pos:
# Position is within this segment
return original_idx + (stripped_pos - stripped_idx)
else:
stripped_idx += len(segment)
original_idx += len(segment)
# Caller guarantees stripped_pos < total stripped chars, so we always
# return from within the loop. This line satisfies the type checker.
return original_idx # pragma: no cover
def _find_break_position(self, text: str, max_width: int) -> int:
"""Find string index in text that fits within max_width cells."""
idx = 0
width_so_far = 0
while idx < len(text):
char = text[idx]
# Skip escape sequences (they don't add width)
if char == '\x1b':
match = ZERO_WIDTH_PATTERN.match(text, idx)
if match:
idx = match.end()
continue
# Get grapheme (use start= to avoid slice allocation)
grapheme = next(iter_graphemes(text, start=idx))
grapheme_width = self._width(grapheme)
if width_so_far + grapheme_width > max_width:
return idx # Found break point
width_so_far += grapheme_width
idx += len(grapheme)
# Caller guarantees chunk_width > max_width, so a grapheme always
# exceeds and we return from within the loop. Type checker requires this.
return idx # pragma: no cover
def _find_first_grapheme_end(self, text: str) -> int:
"""Find the end position of the first grapheme."""
return len(next(iter_graphemes(text)))
def wrap(text: str, width: int = 70, *,
control_codes: Literal['parse', 'strict', 'ignore'] = 'parse',
tabsize: int = 8,
ambiguous_width: int = 1,
initial_indent: str = '',
subsequent_indent: str = '',
break_long_words: bool = True,
break_on_hyphens: bool = True,
propagate_sgr: bool = True) -> list[str]:
r"""
Wrap text to fit within given width, returning a list of wrapped lines.
Like :func:`textwrap.wrap`, but measures width in display cells rather than
characters, correctly handling wide characters, combining marks, and terminal
escape sequences.
:param text: Text to wrap, may contain terminal sequences.
:param width: Maximum line width in display cells.
:param control_codes: How to handle terminal sequences (see :func:`~.width`).
:param tabsize: Tab stop width for tab expansion.
:param ambiguous_width: Width to use for East Asian Ambiguous (A)
characters. Default is ``1`` (narrow). Set to ``2`` for CJK contexts.
:param initial_indent: String prepended to first line.
:param subsequent_indent: String prepended to subsequent lines.
:param break_long_words: If True, break words longer than width.
:param break_on_hyphens: If True, allow breaking at hyphens.
:param propagate_sgr: If True (default), SGR (terminal styling) sequences
are propagated across wrapped lines. Each line ends with a reset
sequence and the next line begins with the active style restored.
:returns: List of wrapped lines without trailing newlines.
SGR (terminal styling) sequences are propagated across wrapped lines
by default. Each line ends with a reset sequence and the next line
begins with the active style restored::
>>> wrap('\x1b[1;34mHello world\x1b[0m', width=6)
['\x1b[1;34mHello\x1b[0m', '\x1b[1;34mworld\x1b[0m']
Set ``propagate_sgr=False`` to disable this behavior.
Like :func:`textwrap.wrap`, newlines in the input text are treated as
whitespace and collapsed. To preserve paragraph breaks, wrap each
paragraph separately::
>>> text = 'First line.\nSecond line.'
>>> wrap(text, 40) # newline collapsed to space
['First line. Second line.']
>>> [line for para in text.split('\n')
... for line in (wrap(para, 40) if para else [''])]
['First line.', 'Second line.']
.. seealso::
:func:`textwrap.wrap`, :class:`textwrap.TextWrapper`
Standard library text wrapping (character-based).
:class:`.SequenceTextWrapper`
Class interface for advanced wrapping options.
.. versionadded:: 0.3.0
.. versionchanged:: 0.5.0
Added ``propagate_sgr`` parameter (default True).
Example::
>>> from wcwidth import wrap
>>> wrap('hello world', 5)
['hello', 'world']
>>> wrap('中文字符', 4) # CJK characters (2 cells each)
['中文', '字符']
"""
wrapper = SequenceTextWrapper(
width=width,
control_codes=control_codes,
tabsize=tabsize,
ambiguous_width=ambiguous_width,
initial_indent=initial_indent,
subsequent_indent=subsequent_indent,
break_long_words=break_long_words,
break_on_hyphens=break_on_hyphens,
)
lines = wrapper.wrap(text)
if propagate_sgr:
lines = _propagate_sgr(lines)
return lines
@@ -0,0 +1,21 @@
"""
Exports function list_versions() for unicode version level support.
This code generated by wcwidth/bin/update-tables.py on 2026-01-27 00:41:01 UTC.
"""
from __future__ import annotations
def list_versions() -> tuple[str, ...]:
"""
Return Unicode version levels supported by this module release.
.. versionchanged:: 0.5.0
Now returns a single-element tuple containing only the latest version.
:returns: Supported Unicode version numbers in ascending sorted order.
"""
return (
"17.0.0",
)
@@ -0,0 +1,850 @@
"""
This is a python implementation of wcwidth() and wcswidth().
https://github.com/jquast/wcwidth
from Markus Kuhn's C code, retrieved from:
http://www.cl.cam.ac.uk/~mgk25/ucs/wcwidth.c
This is an implementation of wcwidth() and wcswidth() (defined in
IEEE Std 1002.1-2001) for Unicode.
http://www.opengroup.org/onlinepubs/007904975/functions/wcwidth.html
http://www.opengroup.org/onlinepubs/007904975/functions/wcswidth.html
In fixed-width output devices, Latin characters all occupy a single
"cell" position of equal width, whereas ideographic CJK characters
occupy two such cells. Interoperability between terminal-line
applications and (teletype-style) character terminals using the
UTF-8 encoding requires agreement on which character should advance
the cursor by how many cell positions. No established formal
standards exist at present on which Unicode character shall occupy
how many cell positions on character terminals. These routines are
a first attempt of defining such behavior based on simple rules
applied to data provided by the Unicode Consortium.
For some graphical characters, the Unicode standard explicitly
defines a character-cell width via the definition of the East Asian
FullWidth (F), Wide (W), Half-width (H), and Narrow (Na) classes.
In all these cases, there is no ambiguity about which width a
terminal shall use. For characters in the East Asian Ambiguous (A)
class, the width choice depends purely on a preference of backward
compatibility with either historic CJK or Western practice.
Choosing single-width for these characters is easy to justify as
the appropriate long-term solution, as the CJK practice of
displaying these characters as double-width comes from historic
implementation simplicity (8-bit encoded characters were displayed
single-width and 16-bit ones double-width, even for Greek,
Cyrillic, etc.) and not any typographic considerations.
Much less clear is the choice of width for the Not East Asian
(Neutral) class. Existing practice does not dictate a width for any
of these characters. It would nevertheless make sense
typographically to allocate two character cells to characters such
as for instance EM SPACE or VOLUME INTEGRAL, which cannot be
represented adequately with a single-width glyph. The following
routines at present merely assign a single-cell width to all
neutral characters, in the interest of simplicity. This is not
entirely satisfactory and should be reconsidered before
establishing a formal standard in this area. At the moment, the
decision which Not East Asian (Neutral) characters should be
represented by double-width glyphs cannot yet be answered by
applying a simple rule from the Unicode database content. Setting
up a proper standard for the behavior of UTF-8 character terminals
will require a careful analysis not only of each Unicode character,
but also of each presentation form, something the author of these
routines has avoided to do so far.
http://www.unicode.org/unicode/reports/tr11/
Latest version: http://www.cl.cam.ac.uk/~mgk25/ucs/wcwidth.c
"""
from __future__ import annotations
# std imports
from functools import lru_cache
from typing import TYPE_CHECKING
# local
from .bisearch import bisearch as _bisearch
from .grapheme import iter_graphemes
from .sgr_state import (_SGR_PATTERN,
_SGR_STATE_DEFAULT,
_sgr_state_update,
_sgr_state_is_active,
_sgr_state_to_sequence)
from .table_vs16 import VS16_NARROW_TO_WIDE
from .table_wide import WIDE_EASTASIAN
from .table_zero import ZERO_WIDTH
from .control_codes import ILLEGAL_CTRL, VERTICAL_CTRL, HORIZONTAL_CTRL, ZERO_WIDTH_CTRL
from .table_ambiguous import AMBIGUOUS_EASTASIAN
from .escape_sequences import (ZERO_WIDTH_PATTERN,
CURSOR_LEFT_SEQUENCE,
CURSOR_RIGHT_SEQUENCE,
INDETERMINATE_EFFECT_SEQUENCE)
from .unicode_versions import list_versions
if TYPE_CHECKING: # pragma: no cover
# std imports
from collections.abc import Iterator
from typing import Literal
# Pre-compute table references for the latest (and only) Unicode version.
# This avoids dictionary lookups in the hot path.
_LATEST_VERSION = list_versions()[-1]
_ZERO_WIDTH_TABLE = ZERO_WIDTH[_LATEST_VERSION]
_WIDE_EASTASIAN_TABLE = WIDE_EASTASIAN[_LATEST_VERSION]
_AMBIGUOUS_TABLE = AMBIGUOUS_EASTASIAN[next(iter(AMBIGUOUS_EASTASIAN))]
# Translation table to strip C0/C1 control characters for fast 'ignore' mode.
_CONTROL_CHAR_TABLE = str.maketrans('', '', (
''.join(chr(c) for c in range(0x00, 0x20)) + # C0: NUL through US (including tab)
'\x7f' + # DEL
''.join(chr(c) for c in range(0x80, 0xa0)) # C1: U+0080-U+009F
))
# Unlike wcwidth.__all__, wcwidth.wcwidth.__all__ is NOT for the purpose of defining a public API,
# or what we prefer to be imported with statement, "from wcwidth.wcwidth import *". Explicitly
# re-export imports here for no other reason than to satisfy the type checkers (mypy). Yak shavings.
__all__ = (
'ZERO_WIDTH',
'WIDE_EASTASIAN',
'AMBIGUOUS_EASTASIAN',
'VS16_NARROW_TO_WIDE',
'list_versions',
'wcwidth',
'wcswidth',
'width',
'iter_sequences',
'ljust',
'rjust',
'center',
'clip',
'strip_sequences',
'_wcmatch_version',
'_wcversion_value',
)
@lru_cache(maxsize=2000)
def wcwidth(wc: str, unicode_version: str = 'auto', ambiguous_width: int = 1) -> int: # pylint: disable=unused-argument
r"""
Given one Unicode codepoint, return its printable length on a terminal.
:param wc: A single Unicode character.
:param unicode_version: Ignored. Retained for backwards compatibility.
.. deprecated:: 0.3.0
Only the latest Unicode version is now shipped.
:param ambiguous_width: Width to use for East Asian Ambiguous (A)
characters. Default is ``1`` (narrow). Set to ``2`` for CJK contexts
where ambiguous characters display as double-width. See
:ref:`ambiguous_width` for details.
:returns: The width, in cells, necessary to display the character of
Unicode string character, ``wc``. Returns 0 if the ``wc`` argument has
no printable effect on a terminal (such as NUL '\0'), -1 if ``wc`` is
not printable, or has an indeterminate effect on the terminal, such as
a control character. Otherwise, the number of column positions the
character occupies on a graphic terminal (1 or 2) is returned.
See :ref:`Specification` for details of cell measurement.
"""
ucs = ord(wc) if wc else 0
# small optimization: early return of 1 for printable ASCII, this provides
# approximately 40% performance improvement for mostly-ascii documents, with
# less than 1% impact to others.
if 32 <= ucs < 0x7f:
return 1
# C0/C1 control characters are -1 for compatibility with POSIX-like calls
if ucs and ucs < 32 or 0x07F <= ucs < 0x0A0:
return -1
# Zero width
if _bisearch(ucs, _ZERO_WIDTH_TABLE):
return 0
# Wide (F/W categories)
if _bisearch(ucs, _WIDE_EASTASIAN_TABLE):
return 2
# Ambiguous width (A category) - only when ambiguous_width=2
if ambiguous_width == 2 and _bisearch(ucs, _AMBIGUOUS_TABLE):
return 2
return 1
def wcswidth( # pylint: disable=unused-argument
pwcs: str,
n: int | None = None,
unicode_version: str = 'auto',
ambiguous_width: int = 1,
) -> int:
"""
Given a unicode string, return its printable length on a terminal.
:param pwcs: Measure width of given unicode string.
:param n: When ``n`` is None (default), return the length of the entire
string, otherwise only the first ``n`` characters are measured. This
argument exists only for compatibility with the C POSIX function
signature. It is suggested instead to use python's string slicing
capability, ``wcswidth(pwcs[:n])``
:param unicode_version: Ignored. Retained for backwards compatibility.
.. deprecated:: 0.3.0
Only the latest Unicode version is now shipped.
:param ambiguous_width: Width to use for East Asian Ambiguous (A)
characters. Default is ``1`` (narrow). Set to ``2`` for CJK contexts.
:returns: The width, in cells, needed to display the first ``n`` characters
of the unicode string ``pwcs``. Returns ``-1`` for C0 and C1 control
characters!
See :ref:`Specification` for details of cell measurement.
"""
# this 'n' argument is a holdover for POSIX function
# Fast path: pure ASCII printable strings are always width == length
if n is None and pwcs.isascii() and pwcs.isprintable():
return len(pwcs)
# Select wcwidth call pattern for best lru_cache performance:
# - ambiguous_width=1 (default): single-arg calls share cache with direct wcwidth() calls
# - ambiguous_width=2: full positional args needed (results differ, separate cache is correct)
_wcwidth = wcwidth if ambiguous_width == 1 else lambda c: wcwidth(c, 'auto', ambiguous_width)
end = len(pwcs) if n is None else n
total_width = 0
idx = 0
last_measured_idx = -2 # Track index of last measured char for VS16
while idx < end:
char = pwcs[idx]
if char == '\u200D':
# Zero Width Joiner, do not measure this or next character
idx += 2
continue
if char == '\uFE0F' and last_measured_idx >= 0:
# VS16 following a measured character: add 1 if that character is
# known to be converted from narrow to wide by VS16.
# Note: VS16 support requires Unicode 9.0+, which is always true
# since only the latest version is shipped.
total_width += _bisearch(ord(pwcs[last_measured_idx]),
VS16_NARROW_TO_WIDE["9.0.0"])
last_measured_idx = -2 # Prevent double application
idx += 1
continue
wcw = _wcwidth(char)
if wcw < 0:
# early return -1 on C0 and C1 control characters
return wcw
if wcw > 0:
last_measured_idx = idx
total_width += wcw
idx += 1
return total_width
# NOTE: _wcversion_value and _wcmatch_version are no longer used internally
# by wcwidth since version 0.5.0 (only the latest Unicode version is shipped).
# They are retained for API compatibility with external tools like ucs-detect
# that may use these private functions.
@lru_cache(maxsize=128)
def _wcversion_value(ver_string: str) -> tuple[int, ...]: # pragma: no cover
"""
Integer-mapped value of given dotted version string.
.. note::
This function is no longer used internally by wcwidth but is retained
for API compatibility with external tools.
:param ver_string: Unicode version string, of form ``n.n.n``.
:returns: tuple of digit tuples, ``tuple(int, [...])``.
"""
retval = tuple(map(int, (ver_string.split('.'))))
return retval
@lru_cache(maxsize=8)
def _wcmatch_version(given_version: str) -> str: # pylint: disable=unused-argument
"""
Return the supported Unicode version level.
.. note::
This function is no longer used internally by wcwidth but is retained
for API compatibility with external tools.
.. deprecated:: 0.3.0
This function now always returns the latest version.
The ``unicode_version`` parameter and ``UNICODE_VERSION`` environment
variable are ignored.
:param given_version: Ignored. Any value is accepted for compatibility.
:returns: The latest unicode version string.
"""
return _LATEST_VERSION
def iter_sequences(text: str) -> Iterator[tuple[str, bool]]:
r"""
Iterate through text, yielding segments with sequence identification.
This generator yields tuples of ``(segment, is_sequence)`` for each part
of the input text, where ``is_sequence`` is ``True`` if the segment is
a recognized terminal escape sequence.
:param text: String to iterate through.
:returns: Iterator of (segment, is_sequence) tuples.
.. versionadded:: 0.3.0
Example::
>>> list(iter_sequences('hello'))
[('hello', False)]
>>> list(iter_sequences('\x1b[31mred'))
[('\x1b[31m', True), ('red', False)]
>>> list(iter_sequences('\x1b[1m\x1b[31m'))
[('\x1b[1m', True), ('\x1b[31m', True)]
"""
idx = 0
text_len = len(text)
segment_start = 0
while idx < text_len:
char = text[idx]
if char == '\x1b':
# Yield any accumulated non-sequence text
if idx > segment_start:
yield (text[segment_start:idx], False)
# Try to match an escape sequence
match = ZERO_WIDTH_PATTERN.match(text, idx)
if match:
yield (match.group(), True)
idx = match.end()
else:
# Lone ESC or unrecognized - yield as sequence anyway
yield (char, True)
idx += 1
segment_start = idx
else:
idx += 1
# Yield any remaining text
if segment_start < text_len:
yield (text[segment_start:], False)
def _width_ignored_codes(text: str, ambiguous_width: int = 1) -> int:
"""
Fast path for width() with control_codes='ignore'.
Strips escape sequences and control characters, then measures remaining text.
"""
return wcswidth(
strip_sequences(text).translate(_CONTROL_CHAR_TABLE),
ambiguous_width=ambiguous_width
)
def width(
text: str,
*,
control_codes: Literal['parse', 'strict', 'ignore'] = 'parse',
tabsize: int = 8,
ambiguous_width: int = 1,
) -> int:
r"""
Return printable width of text containing many kinds of control codes and sequences.
Unlike :func:`wcswidth`, this function handles most control characters and many popular terminal
output sequences. Never returns -1.
:param text: String to measure.
:param control_codes: How to handle control characters and sequences:
- ``'parse'`` (default): Track horizontal cursor movement from BS ``\b``, CR ``\r``, TAB
``\t``, and cursor left and right movement sequences. Vertical movement (LF, VT, FF) and
indeterminate sequences are zero-width. Never raises.
- ``'strict'``: Like parse, but raises :exc:`ValueError` on control characters with
indeterminate results of the screen or cursor, like clear or vertical movement. Generally,
these should be handled with a virtual terminal emulator (like 'pyte').
- ``'ignore'``: All C0 and C1 control characters and escape sequences are measured as
width 0. This is the fastest measurement for text already filtered or known not to contain
any kinds of control codes or sequences. TAB ``\t`` is zero-width; for tab expansion,
pre-process: ``text.replace('\t', ' ' * 8)``.
:param tabsize: Tab stop width for ``'parse'`` and ``'strict'`` modes. Default is 8.
Must be positive. Has no effect when ``control_codes='ignore'``.
:param ambiguous_width: Width to use for East Asian Ambiguous (A)
characters. Default is ``1`` (narrow). Set to ``2`` for CJK contexts.
:returns: Maximum cursor position reached, "extent", accounting for cursor movement sequences
present in ``text`` according to given parameters. This represents the rightmost column the
cursor reaches. Always a non-negative integer.
:raises ValueError: If ``control_codes='strict'`` and control characters with indeterminate
effects, such as vertical movement or clear sequences are encountered, or on unexpected
C0 or C1 control code. Also raised when ``control_codes`` is not one of the valid values.
.. versionadded:: 0.3.0
Examples::
>>> width('hello')
5
>>> width('コンニチハ')
10
>>> width('\x1b[31mred\x1b[0m')
3
>>> width('\x1b[31mred\x1b[0m', control_codes='ignore') # same result (ignored)
3
>>> width('123\b4') # backspace overwrites previous cell (outputs '124')
3
>>> width('abc\t') # tab caused cursor to move to column 8
8
>>> width('1\x1b[10C') # '1' + cursor right 10, cursor ends on column 11
11
>>> width('1\x1b[10C', control_codes='ignore') # faster but wrong in this case
1
"""
# pylint: disable=too-complex,too-many-branches,too-many-statements,too-many-locals
# This could be broken into sub-functions (#1, #3, and 6 especially), but for reduced overhead
# considering this function is a likely "hot path", they are inlined, breaking many of our
# complexity rules.
# Fast path for ASCII printable (no tabs, escapes, or control chars)
if text.isascii() and text.isprintable():
return len(text)
# Fast parse: if no horizontal cursor movements are possible, switch to 'ignore' mode.
# Only check for longer strings - the detection overhead hurts short string performance.
if control_codes == 'parse' and len(text) > 20:
# Check for cursor-affecting control characters
if '\b' not in text and '\t' not in text and '\r' not in text:
# Check for escape sequences - if none, or only non-cursor-movement sequences
if '\x1b' not in text or (
not CURSOR_RIGHT_SEQUENCE.search(text) and
not CURSOR_LEFT_SEQUENCE.search(text)
):
control_codes = 'ignore'
# Fast path for ignore mode -- this is useful if you know the text is already "clean"
if control_codes == 'ignore':
return _width_ignored_codes(text, ambiguous_width)
strict = control_codes == 'strict'
# Track absolute positions: tab stops need modulo on absolute column, CR resets to 0.
# Initialize max_extent to 0 so backward movement (CR, BS) won't yield negative width.
current_col = 0
max_extent = 0
idx = 0
last_measured_idx = -2 # Track index of last measured char for VS16; -2 can never match idx-1
text_len = len(text)
# Select wcwidth call pattern for best lru_cache performance:
# - ambiguous_width=1 (default): single-arg calls share cache with direct wcwidth() calls
# - ambiguous_width=2: full positional args needed (results differ, separate cache is correct)
_wcwidth = wcwidth if ambiguous_width == 1 else lambda c: wcwidth(c, 'auto', ambiguous_width)
while idx < text_len:
char = text[idx]
# 1. Handle ESC sequences
if char == '\x1b':
match = ZERO_WIDTH_PATTERN.match(text, idx)
if match:
seq = match.group()
if strict and INDETERMINATE_EFFECT_SEQUENCE.match(seq):
raise ValueError(f"Indeterminate cursor sequence at position {idx}")
# Apply cursor movement
right = CURSOR_RIGHT_SEQUENCE.match(seq)
if right:
current_col += int(right.group(1) or 1)
else:
left = CURSOR_LEFT_SEQUENCE.match(seq)
if left:
current_col = max(0, current_col - int(left.group(1) or 1))
idx = match.end()
else:
idx += 1
max_extent = max(max_extent, current_col)
continue
# 2. Handle illegal and vertical control characters (zero width, error in strict)
if char in ILLEGAL_CTRL:
if strict:
raise ValueError(f"Illegal control character {ord(char):#x} at position {idx}")
idx += 1
continue
if char in VERTICAL_CTRL:
if strict:
raise ValueError(f"Vertical movement character {ord(char):#x} at position {idx}")
idx += 1
continue
# 3. Handle horizontal movement characters
if char in HORIZONTAL_CTRL:
if char == '\x09' and tabsize > 0: # Tab
current_col += tabsize - (current_col % tabsize)
elif char == '\x08': # Backspace
if current_col > 0:
current_col -= 1
elif char == '\x0d': # Carriage return
current_col = 0
max_extent = max(max_extent, current_col)
idx += 1
continue
# 4. Handle ZWJ (skip this and next character)
if char == '\u200D':
idx += 2
continue
# 5. Handle other zero-width characters (control chars)
if char in ZERO_WIDTH_CTRL:
idx += 1
continue
# 6. Handle VS16: converts preceding narrow character to wide
if char == '\uFE0F':
if last_measured_idx == idx - 1:
if _bisearch(ord(text[last_measured_idx]), VS16_NARROW_TO_WIDE["9.0.0"]):
current_col += 1
max_extent = max(max_extent, current_col)
idx += 1
continue
# 7. Normal characters: measure with wcwidth
w = _wcwidth(char)
if w > 0:
current_col += w
max_extent = max(max_extent, current_col)
last_measured_idx = idx
idx += 1
return max_extent
def ljust(
text: str,
dest_width: int,
fillchar: str = ' ',
*,
control_codes: Literal['parse', 'strict', 'ignore'] = 'parse',
ambiguous_width: int = 1,
) -> str:
r"""
Return text left-justified in a string of given display width.
:param text: String to justify, may contain terminal sequences.
:param dest_width: Total display width of result in terminal cells.
:param fillchar: Single character for padding (default space). Must have
display width of 1 (not wide, not zero-width, not combining). Unicode
characters like ``'·'`` are acceptable. The width is not validated.
:param control_codes: How to handle control sequences when measuring.
Passed to :func:`width` for measurement.
:param ambiguous_width: Width to use for East Asian Ambiguous (A)
characters. Default is ``1`` (narrow). Set to ``2`` for CJK contexts.
:returns: Text padded on the right to reach ``dest_width``.
.. versionadded:: 0.3.0
Example::
>>> wcwidth.ljust('hi', 5)
'hi '
>>> wcwidth.ljust('\x1b[31mhi\x1b[0m', 5)
'\x1b[31mhi\x1b[0m '
>>> wcwidth.ljust('\U0001F468\u200D\U0001F469\u200D\U0001F467', 6)
'👨‍👩‍👧 '
"""
if text.isascii() and text.isprintable():
text_width = len(text)
else:
text_width = width(text, control_codes=control_codes, ambiguous_width=ambiguous_width)
padding_cells = max(0, dest_width - text_width)
return text + fillchar * padding_cells
def rjust(
text: str,
dest_width: int,
fillchar: str = ' ',
*,
control_codes: Literal['parse', 'strict', 'ignore'] = 'parse',
ambiguous_width: int = 1,
) -> str:
r"""
Return text right-justified in a string of given display width.
:param text: String to justify, may contain terminal sequences.
:param dest_width: Total display width of result in terminal cells.
:param fillchar: Single character for padding (default space). Must have
display width of 1 (not wide, not zero-width, not combining). Unicode
characters like ``'·'`` are acceptable. The width is not validated.
:param control_codes: How to handle control sequences when measuring.
Passed to :func:`width` for measurement.
:param ambiguous_width: Width to use for East Asian Ambiguous (A)
characters. Default is ``1`` (narrow). Set to ``2`` for CJK contexts.
:returns: Text padded on the left to reach ``dest_width``.
.. versionadded:: 0.3.0
Example::
>>> wcwidth.rjust('hi', 5)
' hi'
>>> wcwidth.rjust('\x1b[31mhi\x1b[0m', 5)
' \x1b[31mhi\x1b[0m'
>>> wcwidth.rjust('\U0001F468\u200D\U0001F469\u200D\U0001F467', 6)
' 👨‍👩‍👧'
"""
if text.isascii() and text.isprintable():
text_width = len(text)
else:
text_width = width(text, control_codes=control_codes, ambiguous_width=ambiguous_width)
padding_cells = max(0, dest_width - text_width)
return fillchar * padding_cells + text
def center(
text: str,
dest_width: int,
fillchar: str = ' ',
*,
control_codes: Literal['parse', 'strict', 'ignore'] = 'parse',
ambiguous_width: int = 1,
) -> str:
r"""
Return text centered in a string of given display width.
:param text: String to center, may contain terminal sequences.
:param dest_width: Total display width of result in terminal cells.
:param fillchar: Single character for padding (default space). Must have
display width of 1 (not wide, not zero-width, not combining). Unicode
characters like ``'·'`` are acceptable. The width is not validated.
:param control_codes: How to handle control sequences when measuring.
Passed to :func:`width` for measurement.
:param ambiguous_width: Width to use for East Asian Ambiguous (A)
characters. Default is ``1`` (narrow). Set to ``2`` for CJK contexts.
:returns: Text padded on both sides to reach ``dest_width``.
For odd-width padding, the extra cell goes on the right (matching
Python's :meth:`str.center` behavior).
.. versionadded:: 0.3.0
Example::
>>> wcwidth.center('hi', 6)
' hi '
>>> wcwidth.center('\x1b[31mhi\x1b[0m', 6)
' \x1b[31mhi\x1b[0m '
>>> wcwidth.center('\U0001F468\u200D\U0001F469\u200D\U0001F467', 6)
' 👨‍👩‍👧 '
"""
if text.isascii() and text.isprintable():
text_width = len(text)
else:
text_width = width(text, control_codes=control_codes, ambiguous_width=ambiguous_width)
total_padding = max(0, dest_width - text_width)
# matching https://jazcap53.github.io/pythons-eccentric-strcenter.html
left_pad = total_padding // 2 + (total_padding & dest_width & 1)
right_pad = total_padding - left_pad
return fillchar * left_pad + text + fillchar * right_pad
def strip_sequences(text: str) -> str:
r"""
Return text with all terminal escape sequences removed.
Unknown or incomplete ESC sequences are preserved.
:param text: String that may contain terminal escape sequences.
:returns: The input text with all escape sequences stripped.
.. versionadded:: 0.3.0
Example::
>>> strip_sequences('\x1b[31mred\x1b[0m')
'red'
>>> strip_sequences('hello')
'hello'
>>> strip_sequences('\x1b[1m\x1b[31mbold red\x1b[0m text')
'bold red text'
"""
return ZERO_WIDTH_PATTERN.sub('', text)
def clip(
text: str,
start: int,
end: int,
*,
fillchar: str = ' ',
tabsize: int = 8,
ambiguous_width: int = 1,
propagate_sgr: bool = True,
) -> str:
r"""
Clip text to display columns ``(start, end)`` while preserving all terminal sequences.
This function extracts a substring based on visible column positions rather than
character indices. Terminal escape sequences are preserved in the output since
they have zero display width. If a wide character (width 2) would be split at
either boundary, it is replaced with ``fillchar``.
TAB characters (``\t``) are expanded to spaces up to the next tab stop,
controlled by the ``tabsize`` parameter.
Other cursor movement characters (backspace, carriage return) and cursor
movement sequences are passed through unchanged as zero-width.
:param text: String to clip, may contain terminal escape sequences.
:param start: Absolute starting column (inclusive, 0-indexed).
:param end: Absolute ending column (exclusive).
:param fillchar: Character to use when a wide character must be split at
a boundary (default space). Must have display width of 1.
:param tabsize: Tab stop width (default 8). Set to 0 to pass tabs through
as zero-width (preserved in output but don't advance column position).
:param ambiguous_width: Width to use for East Asian Ambiguous (A)
characters. Default is ``1`` (narrow). Set to ``2`` for CJK contexts.
:param propagate_sgr: If True (default), SGR (terminal styling) sequences
are propagated. The result begins with any active style at the start
position and ends with a reset sequence if styles are active.
:returns: Substring of ``text`` spanning display columns ``(start, end)``,
with all terminal sequences preserved and wide characters at boundaries
replaced with ``fillchar``.
SGR (terminal styling) sequences are propagated by default. The result
begins with any active style and ends with a reset::
>>> clip('\x1b[1;34mHello world\x1b[0m', 6, 11)
'\x1b[1;34mworld\x1b[0m'
Set ``propagate_sgr=False`` to disable this behavior.
.. versionadded:: 0.3.0
.. versionchanged:: 0.5.0
Added ``propagate_sgr`` parameter (default True).
Example::
>>> clip('hello world', 0, 5)
'hello'
>>> clip('中文字', 0, 3) # Wide char split at column 3
'中 '
>>> clip('a\tb', 0, 10) # Tab expanded to spaces
'a b'
"""
# pylint: disable=too-complex,too-many-locals,too-many-branches,too-many-statements,too-many-nested-blocks
# Again, for 'hot path', we avoid additional delegate functions and accept the cost
# of complexity for improved python performance.
start = max(start, 0)
if end <= start:
return ''
# Fast path: printable ASCII only (no tabs, escape sequences, or wide or zero-width chars)
if text.isascii() and text.isprintable():
return text[start:end]
# Fast path: no escape sequences means no SGR tracking needed
if propagate_sgr and '\x1b' not in text:
propagate_sgr = False
# SGR tracking state (only when propagate_sgr=True)
sgr_at_clip_start = None # state when first visible char emitted (None = not yet)
if propagate_sgr:
sgr = _SGR_STATE_DEFAULT # current SGR state, updated by all sequences
output: list[str] = []
col = 0
idx = 0
while idx < len(text):
char = text[idx]
# Early exit: past visible region, SGR captured, no escape ahead
if col >= end and sgr_at_clip_start is not None and char != '\x1b':
break
# Handle escape sequences
if char == '\x1b' and (match := ZERO_WIDTH_PATTERN.match(text, idx)):
seq = match.group()
if propagate_sgr and _SGR_PATTERN.match(seq):
# Update SGR state; will be applied as prefix when visible content starts
sgr = _sgr_state_update(sgr, seq)
else:
# Non-SGR sequences always preserved
output.append(seq)
idx = match.end()
continue
# Handle bare ESC (not a valid sequence)
if char == '\x1b':
output.append(char)
idx += 1
continue
# TAB expansion
if char == '\t':
if tabsize > 0:
next_tab = col + (tabsize - (col % tabsize))
while col < next_tab:
if start <= col < end:
output.append(' ')
if propagate_sgr and sgr_at_clip_start is None:
sgr_at_clip_start = sgr
col += 1
else:
output.append(char)
idx += 1
continue
# Grapheme clustering for everything else
grapheme = next(iter_graphemes(text, start=idx))
w = width(grapheme, ambiguous_width=ambiguous_width)
if w == 0:
if start <= col < end:
output.append(grapheme)
elif col >= start and col + w <= end:
# Fully visible
output.append(grapheme)
if propagate_sgr and sgr_at_clip_start is None:
sgr_at_clip_start = sgr
col += w
elif col < end and col + w > start:
# Partially visible (wide char at boundary)
output.append(fillchar * (min(end, col + w) - max(start, col)))
if propagate_sgr and sgr_at_clip_start is None:
sgr_at_clip_start = sgr
col += w
else:
col += w
idx += len(grapheme)
result = ''.join(output)
# Apply SGR prefix/suffix
if sgr_at_clip_start is not None:
if prefix := _sgr_state_to_sequence(sgr_at_clip_start):
result = prefix + result
if _sgr_state_is_active(sgr_at_clip_start):
result += '\x1b[0m'
return result