codekingpro/portable-devtools
115k
1r"""2`kitty text sizing protocol`_ (OSC 66) parsing and measurement.3 4The kitty text sizing protocol allows terminal apps to explicitly tell5terminals how many cells text occupies, using the escape sequence::6 7 ESC ] 66 ; metadata ; text BEL/ST8 9Metadata is colon-separated ``key=value`` pairs:10 11- ``s``: scale12- ``w``: width in cells13- ``n``: fractional numerator14- ``d``: fractional denominator15- ``v``: vertical alignment16- ``h``: horizontal alignment17 18Parsing is pretty straight-forward:19 20- When ``w > 0``, return ``s * w``.21- Otherwise ``w == 0``, ``s * wcswidth(inner_text_width)`` cells.22 23Numerator, denominator, and alignment codes and values are parsed but otherwise ignored24and have no effect on measurements made in this library.25 26.. _`kitty text sizing protocol`: https://sw.kovidgoyal.net/kitty/text-sizing-protocol/27 28.. versionadded:: 0.7.029"""30 31from __future__ import annotations32 33# std imports34import re35 36import typing37 38# local39from ._wcswidth import wcswidth40 41 42class _FieldMeta(typing.NamedTuple):43 name: str44 low: int45 high: int46 default: int47 48 49TEXT_FIELD_MAPPING: dict[str, _FieldMeta] = {50 's': _FieldMeta(name='scale', low=1, high=7, default=1),51 'w': _FieldMeta(name='width', low=0, high=7, default=0),52 'n': _FieldMeta(name='numerator', low=0, high=15, default=0),53 'd': _FieldMeta(name='denominator', low=0, high=15, default=0),54 'v': _FieldMeta(name='vertical_align', low=0, high=2, default=0),55 'h': _FieldMeta(name='horizontal_align', low=0, high=2, default=0)}56 57 58class TextSizingParams(typing.NamedTuple):59 """60 Parsed parameters from a text sizing escape sequence (OSC 66).61 62 :param scale: Scale factor (1-7). Text occupies ``scale`` rows tall and ``scale * width``63 columns wide.64 :param width: Width in cells (0-7). When 0, width is auto-calculated from the inner text.65 :param numerator: Fractional scaling numerator (0-15).66 :param denominator: Fractional scaling denominator (0-15).67 :param vertical_align: Vertical alignment (0=top, 1=bottom, 2=center).68 :param horizontal_align: Horizontal alignment (0=left, 1=right, 2=center).69 """70 71 scale: int = 172 width: int = 073 numerator: int = 074 denominator: int = 075 vertical_align: int = 076 horizontal_align: int = 077 78 def __repr__(self) -> str:79 """80 Return a compact representation including only non-default fields.81 82 This avoids verbose output when most fields are defaults.83 """84 # modified to show values only when non-default85 repr_fmt = ', '.join(f'{field.name}={getattr(self, field.name)}'86 for field in TEXT_FIELD_MAPPING.values()87 if getattr(self, field.name) != field.default)88 return f'{self.__class__.__name__}({repr_fmt})'89 90 def make_sequence(self) -> str:91 """Build and return sub-part of an OSC 66 sequence."""92 parts = []93 # build string for all known parameters of non-default values94 for field_key, field in TEXT_FIELD_MAPPING.items():95 if (val := getattr(self, field.name)) != field.default:96 parts.append(f'{field_key}={val}')97 return ':'.join(parts)98 99 @classmethod100 def from_params(cls, raw: str, control_codes: str = 'parse') -> TextSizingParams:101 """102 Parse colon-separated ``key=value`` metadata string.103 104 :param raw: Metadata string, e.g. ``'s=2:w=3'``.105 :param control_codes: 'parse' or 'strict'.106 :raises ValueError: If ``control_codes='strict'`` unrecognized text sizing parameters raise107 ValueError.108 :returns: Parsed parameters with values clamped to valid ranges.109 Unknown keys are ignored. Non-integer values use defaults.110 111 Example::112 113 >>> TextSizingParams.from_params('s=2:w=3')114 TextSizingParams(scale=2, width=3, numerator=0, denominator=0, \115 vertical_align=0, horizontal_align=0)116 """117 kwargs: typing.Dict[str, int] = {}118 if not raw:119 return cls()120 for part in raw.split(':'):121 if '=' not in part:122 if control_codes == 'strict':123 raise ValueError(f"Expected '=' in text sizing parameter (key=val), "124 f"got {part!r} in OSC 66 sequence, {raw!r}")125 continue126 key, _eq, val = part.partition('=')127 field = TEXT_FIELD_MAPPING.get(key)128 if field is None:129 if control_codes == 'strict':130 raise ValueError(f"Unknown text sizing field '{key}' "131 f"in OSC 66 sequence, {raw!r}")132 # ignore unknown fields unless 'strict'133 continue134 try:135 value = int(val)136 except ValueError as exc:137 if control_codes == 'strict':138 raise ValueError(f"Illegal text sizing value '{val}' "139 f"in OSC 66 sequence, {raw!r}: {exc}") from exc140 # ignore value, uses default value without warning unless 'strict'141 continue142 if control_codes == 'strict' and (value > field.high or value < field.low):143 raise ValueError(f"Out of bounds text sizing value '{val}' "144 f"in OSC 66 sequence, {raw!r}: "145 f"allowed range for '{key}' ({field.name}) "146 f"is {field.low} to {field.high}")147 kwargs[field.name] = max(field.low, min(field.high, value))148 return cls(**kwargs)149 150 151class TextSizing(typing.NamedTuple):152 """Basic horizontal width measurement for kitty text sizing protocol."""153 154 params: TextSizingParams155 text: str156 terminator: str157 158 @classmethod159 def from_match(cls, match: re.Match[str], control_codes: str = 'parse') -> TextSizing:160 r"""161 Parse using matching OSC 66 Sequence.162 163 :param match: match object from :attr:`wcwidth.escape_sequences.TEXT_SIZING_PATTERN`.164 :param control_codes: 'parse' or 'strict', same meaning as delegated by165 :func:`wcwidth.width`.166 :raises ValueError: When ``control_codes='strict'`` for unrecognized, invalid, or out of167 bounds text sizing parameters.168 :returns: TextSizing object from parsed sequence169 170 Example::171 172 from wcwidth.escape_sequences import TEXT_SIZING_PATTERN173 >>> TextSizing.from_match(TEXT_SIZING_PATTERN.match('\x1b]66;w=2;XY\x07'))174 TextSizing(params=TextSizingParams(scale=1, width=2, numerator=0, denominator=0, \175 vertical_align=0, horizontal_align=0), text='XY', terminator='\x07')176 """177 return cls(params=TextSizingParams.from_params(match.group(1), control_codes=control_codes),178 text=match.group(2),179 terminator=match.group(3))180 181 def display_width(self, ambiguous_width: int = 1) -> int:182 """183 Calculate the display width of a text sizing sequence.184 185 :param ambiguous_width: Width for East Asian Ambiguous characters.186 :returns: Display width in terminal cells. When ``width > 0``, returns187 ``params.scale * params.width``. When ``width == 0``, returns188 ``params.scale * measured_inner_width``.189 190 .. note: Fractional scaling (numerator/denominator) does not affect the191 cell count, it adjusts only the font size within the cells allocated by 'w'.192 """193 if self.params.width > 0:194 return self.params.scale * self.params.width195 w = wcswidth(self.text, ambiguous_width=ambiguous_width)196 return self.params.scale * max(0, w)197 198 def make_sequence(self) -> str:199 """Build and return complete OSC 66 Terminal Sequence."""200 return f'\x1b]66;{self.params.make_sequence()};{self.text}{self.terminator}'201 