diff options
| author | Berke Güzel <wenekar1@gmail.com> | 2026-10-12 17:49:34 +0300 |
|---|---|---|
| committer | Berke Güzel <wenekar1@gmail.com> | 2026-10-12 17:49:34 +0300 |
| commit | 4384435941b8c7658856b381d071aee2e0035e89 (patch) | |
| tree | 5ece99c3dd1decb8ec3fd404c0ece2b5865a552a /tests/extern/kitty/kitty_parser.py | |
term: move the term core to subprojects/term
The core now builds, tests, and benchmarks alone, as a Meson subproject
with no dependency on the main tree.
- include/term/ holds term.h and the header-only unicode/. src/ holds
the core and its private headers. The main tree gets only
include/term through term_dep, so it cannot reach a private header.
- The term tests, the term benchmarks, the Kitty parser suite, and
tools/amalgamate.py and tools/generate_unicode.py move with it. The
tests run as term:<name>.
- The main build passes its -march (arch_flag) and b_ndebug to the
subproject. A directory configured before this commit needs
meson configure -Dterm:arch_flag=-march=... once.
- The term tests compile with -UNDEBUG. They do work inside assert.
- The subproject exports term_test_dep with term_api.h, for the main
tree's render, thumbnail, color, and selection tests.
- The Kitty bridge used struct vector2i and has not compiled since the
public API redesign. It builds again. 2 of its 14 tests fail: REP
after a tab, and a cursor report after a resize.
- dirgraph.py drops the term and unicode layers. Its self-test uses pty.
- The symbols test ignores the __odr_asan symbols that ASan adds.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'tests/extern/kitty/kitty_parser.py')
| -rw-r--r-- | tests/extern/kitty/kitty_parser.py | 1068 |
1 files changed, 1068 insertions, 0 deletions
diff --git a/tests/extern/kitty/kitty_parser.py b/tests/extern/kitty/kitty_parser.py new file mode 100644 index 0000000..f34a608 --- /dev/null +++ b/tests/extern/kitty/kitty_parser.py @@ -0,0 +1,1068 @@ +"""Pure-Python VT parser that emits Kitty-style callback events. + +This parser mirrors the event vocabulary that Kitty's ``parser.py`` test +suite expects (``draw``, ``screen_cursor_position``, +``select_graphic_rendition``, ``set_title``, …) while feeding every byte +to the C terminal through ``screen.write(data)`` so that observable +screen state is driven by the real implementation. + +The parser maintains state across calls so that partial escape sequences +split across ``parse_bytes`` invocations are handled correctly — this is +exercised by ``test_parser_threading``. +""" + +# Parser states +_GROUND = 0 +_ESC = 1 +_CSI = 2 +_OSC = 3 +_DCS = 4 +_APC = 5 +_PM = 6 +_SOS = 7 +_STR_ESC = 8 +_CHARSET = 9 +_ESC_PCT = 10 + +# CSI byte classes +_CSI_PARAM = 0 # 0x30-0x3a (digits, ';', ':') +_CSI_PREFIX = 1 # 0x3c-0x3f (< = > ?) +_CSI_INTER = 2 # 0x20-0x2f +_CSI_FINAL = 3 # 0x40-0x7e +_CSI_OTHER = 4 + +_MAX_PARAMS = 256 + + +class _CSIState: + """CSI parse state, mirroring kitty's vt-parser.c grammar. + + Params accumulate into ``accum`` with ``num_digits`` digits seen; + ``commit`` pushes mult*accum. A param with no digits is never + committed, so a trailing ';' does not create an empty final param. + ``phase``: 0=start, 1=body, 2=post-secondary (after intermediate). + """ + + __slots__ = ('prefix', 'intermediate', 'final', 'params', 'nparams', 'subparams', + 'num_digits', 'mult', 'accum', 'phase') + + def __init__(self): + self.reset() + + def reset(self): + self.prefix = 0 + self.intermediate = 0 + self.final = 0 + self.params = [0] * _MAX_PARAMS + self.nparams = 0 + self.subparams = 0 + self.num_digits = 0 + self.mult = 1 + self.accum = 0 + self.phase = 0 + + def add_digit(self, d): + if self.num_digits >= 17: + return + self.accum = self.accum * 10 + d + self.num_digits += 1 + + def commit(self): + """Commit the pending param. Returns False on param overflow.""" + if not self.num_digits: + return True + if self.nparams >= _MAX_PARAMS: + return False + v = self.mult * self.accum + self.params[self.nparams] = ((v + 2**31) % 2**32) - 2**31 + self.nparams += 1 + self.num_digits = 0 + self.mult = 1 + self.accum = 0 + return True + + +class KittyParser: + """Stateful VT parser that emits Kitty-style events.""" + + def __init__(self): + self.state = _GROUND + self.text = '' # accumulated UTF-8 text for draw events + self.utf8_pending = b'' # incomplete UTF-8 sequence + self.csi = _CSIState() + self.str_buf = bytearray() # OSC/DCS/APC/PM/SOS payload + self.str_type = 0 # which string mode we're in + self.charset_slot = 0 + + # -- text / draw -- + + def _flush_text(self, cb): + if self.text: + cb(0, 'draw', self.text) + self.text = '' + + def _emit(self, cb, *args): + self._flush_text(cb) + cb(0, *args) + + def _kill_pending(self): + """A control byte kills a pending incomplete UTF-8 sequence.""" + if self.utf8_pending: + self.utf8_pending = b'' + self.text += '\ufffd' + + @staticmethod + def _control_event(cb, b): + if b == 0x0a: + cb(0, 'screen_linefeed') + elif b == 0x0d: + cb(0, 'screen_carriage_return') + elif b == 0x08: + cb(0, 'screen_backspace') + elif b == 0x09: + cb(0, 'screen_tab') + + def _decode_utf8(self, data, cb): + """Decode UTF-8 bytes, appending to self.text. Uses U+FFFD for errors.""" + # Combine pending bytes with new data + buf = self.utf8_pending + bytes(data) + self.utf8_pending = b'' + i = 0 + n = len(buf) + while i < n: + b = buf[i] + # C0 controls (including ESC) terminate text and are handled by caller + if b < 0x20: + # Do NOT save to utf8_pending — C0 controls are not incomplete UTF-8 + return i # Return position of the control byte + if b < 0x80: + self.text += chr(b) + i += 1 + elif b < 0xC2: + # Invalid starter (0x80-0xC1) or continuation byte + self.text += '\ufffd' + i += 1 + elif b < 0xE0: + # 2-byte sequence + if i + 1 < n: + b2 = buf[i + 1] + if 0x80 <= b2 <= 0xBF: + self.text += chr((b & 0x1F) << 6 | (b2 & 0x3F)) + i += 2 + else: + # Bad continuation + self.text += '\ufffd' + i += 1 + else: + # Incomplete — save for next call + self.utf8_pending = buf[i:] + return n + elif b < 0xF0: + # 3-byte sequence + if i + 2 < n: + b2, b3 = buf[i + 1], buf[i + 2] + # Check validity + if b == 0xE0: + valid = 0xA0 <= b2 <= 0xBF + elif b == 0xED: + valid = 0x80 <= b2 <= 0x9F + else: + valid = 0x80 <= b2 <= 0xBF + if valid and 0x80 <= b3 <= 0xBF: + self.text += chr((b & 0x0F) << 12 | (b2 & 0x3F) << 6 | (b3 & 0x3F)) + i += 3 + elif 0x80 <= b2 <= 0xBF: + # First continuation is valid, second is not + self.text += '\ufffd' + i += 2 + # b3 will be processed next (may be another U+FFFD) + else: + self.text += '\ufffd' + i += 1 + elif i + 1 < n: + b2 = buf[i + 1] + if 0x80 <= b2 <= 0xBF: + # Could be valid — need more data + self.utf8_pending = buf[i:] + return n + else: + self.text += '\ufffd' + i += 1 + else: + self.utf8_pending = buf[i:] + return n + elif b < 0xF5: + # 4-byte sequence + if i + 3 < n: + b2, b3, b4 = buf[i + 1], buf[i + 2], buf[i + 3] + if b == 0xF0: + valid2 = 0x90 <= b2 <= 0xBF + elif b == 0xF4: + valid2 = 0x80 <= b2 <= 0x8F + else: + valid2 = 0x80 <= b2 <= 0xBF + if valid2 and 0x80 <= b3 <= 0xBF and 0x80 <= b4 <= 0xBF: + cp = (b & 0x07) << 18 | (b2 & 0x3F) << 12 | (b3 & 0x3F) << 6 | (b4 & 0x3F) + self.text += chr(cp) + i += 4 + elif 0x80 <= b2 <= 0xBF: + if 0x80 <= b3 <= 0xBF: + # b4 is bad + self.text += '\ufffd' + i += 3 + else: + self.text += '\ufffd' + i += 2 + else: + self.text += '\ufffd' + i += 1 + else: + # Not enough bytes — save for next call + self.utf8_pending = buf[i:] + return n + else: + # 0xF5-0xFF: never valid + self.text += '\ufffd' + i += 1 + return n + + # -- CSI dispatch -- + + def _csi_count(self, idx=0): + """Return param[idx] or 1 if zero/missing.""" + if idx < self.csi.nparams and self.csi.params[idx] > 0: + return self.csi.params[idx] + return 1 + + def _format_sgr(self): + """Format SGR params into Kitty-style colon/semicolon groups.""" + if self.csi.nparams == 0: + return ['0'] + params = self.csi.params[:self.csi.nparams] + subparams = self.csi.subparams + groups = [] + current = [] + i = 0 + while i < len(params): + p = params[i] + next_has_colon = (i + 1 < len(params)) and bool(subparams & (1 << (i + 1))) + if p in (38, 48, 58): + # Extended color — flush current, consume following params + if current: + groups.append(';'.join(current)) + current = [] + sub = [str(p)] + i += 1 + while i < len(params) and params[i] not in (38, 48, 58): + sub.append(str(params[i])) + i += 1 + groups.append(':'.join(sub)) + elif next_has_colon: + # This param starts a colon group + if current: + groups.append(';'.join(current)) + current = [] + sub = [str(p)] + i += 1 + while i < len(params) and (subparams & (1 << i)): + sub.append(str(params[i])) + i += 1 + groups.append(':'.join(sub)) + else: + current.append(str(p)) + i += 1 + if current: + groups.append(';'.join(current)) + return groups + + def _dispatch_csi(self, screen, cb): + c = self.csi + final = chr(c.intermediate) if c.intermediate else '' + # Handle intermediate-based dispatches first + if c.intermediate: + if c.intermediate == ord(' ') and c.prefix == 0: + if c.final == ord('q'): + # DECSCUSR: CSI Ps SP q + if c.nparams > 0 and c.params[0] in (3, 4): + self._emit(cb, 'screen_set_cursor', c.params[0], ord(' ')) + elif c.nparams > 0 and c.params[0] in (1, 2, 5, 6): + self._emit(cb, 'screen_set_cursor', c.params[0], ord(' ')) + elif c.nparams == 0: + self._emit(cb, 'screen_set_cursor', 0, ord(' ')) + elif c.nparams > 0 and c.params[0] == 3: + # Shift codes + if c.final == ord('@'): + self._emit(cb, 'Shift left escape code not implemented') + elif c.final == ord('A'): + self._emit(cb, 'Shift right escape code not implemented') + elif c.final == ord('S'): + self._emit(cb, 'Select presentation directions escape code not implemented') + return + if c.intermediate == ord('!') and c.prefix == 0: + # Soft reset: CSI ! p — no event in Kitty's test + return + if c.intermediate == ord('$'): + if c.final == ord('p'): + # DECRQM: CSI ? Ps $ p / CSI Ps $ p + mode = c.params[0] if c.nparams > 0 else 0 + private = 1 if c.prefix == ord('?') else 0 + self._emit(cb, 'report_mode_status', mode, private) + elif c.final == ord('r') and c.prefix == 0: + # DECCARA: CSI $ r + for event_str in self._format_deccara(): + self._emit(cb, 'deccara', event_str) + return + if c.intermediate == ord('*') and c.prefix == 0: + # DECSACE: CSI Ps * x + if c.nparams > 0: + self._emit(cb, 'screen_decsace', c.params[0]) + else: + self._emit(cb, 'screen_decsace', 0) + return + if c.intermediate == ord('+') and c.final == ord('T'): + self._emit(cb, 'screen_reverse_scroll_and_fill_from_scrollback', + self._csi_count()) + return + return + + # No intermediate + f = c.final + pfx = c.prefix + + if f == ord('@'): + if c.nparams > 1: + self._emit(cb, f'CSI code @ has {c.nparams} > 1 parameters') + return + if c.nparams == 1 and c.params[0] < 0: + self._emit(cb, f'CSI code @ is not allowed to have negative parameter ({c.params[0]})') + return + self._emit(cb, 'screen_insert_characters', self._csi_count()) + return + + if f == ord('H') or f == ord('f'): + row = c.params[0] if c.nparams > 0 else 1 + col = c.params[1] if c.nparams > 1 else 1 + self._emit(cb, 'screen_cursor_position', row, col) + return + + if f == ord('J'): + mode = c.params[0] if c.nparams > 0 else 0 + private = 1 if pfx == ord('?') else 0 + self._emit(cb, 'screen_erase_in_display', mode, private) + return + + if f == ord('K'): + mode = c.params[0] if c.nparams > 0 else 0 + private = 1 if pfx == ord('?') else 0 + self._emit(cb, 'screen_erase_in_line', mode, private) + return + + if f == ord('h'): + private = 1 if pfx == ord('?') else 0 + if c.nparams == 0: + return + for i in range(c.nparams): + self._emit(cb, 'screen_set_mode', c.params[i], private) + return + + if f == ord('l'): + private = 1 if pfx == ord('?') else 0 + if c.nparams == 0: + return + for i in range(c.nparams): + self._emit(cb, 'screen_reset_mode', c.params[i], private) + return + + if f == ord('c'): + if pfx == ord('>'): + self._emit(cb, 'report_device_attributes', c.params[0] if c.nparams > 0 else 0, 62) + elif pfx == ord('='): + self._emit(cb, 'report_device_attributes', c.params[0] if c.nparams > 0 else 0, 61) + elif pfx == 0: + self._emit(cb, 'report_device_attributes', c.params[0] if c.nparams > 0 else 0, 0) + return + + if f == ord('m'): + for grp in self._format_sgr(): + self._emit(cb, 'select_graphic_rendition', grp) + return + + if f == ord('n'): + mode = c.params[0] if c.nparams > 0 else 0 + private = 1 if pfx == ord('?') else 0 + self._emit(cb, 'report_device_status', mode, private) + return + + if f == ord('r'): + if pfx: + return + if c.nparams == 0: + self._emit(cb, 'screen_set_margins', 0, 0) + else: + top = c.params[0] if c.params[0] > 0 else 1 + bottom = c.params[1] if c.nparams > 1 and c.params[1] > 0 else 0 + self._emit(cb, 'screen_set_margins', top, bottom) + return + + if f == ord('b'): + if c.nparams > 1: + return + self._emit(cb, 'screen_repeat_character', self._csi_count()) + return + + if f == ord('T'): + if pfx == 0 and c.nparams <= 1: + self._emit(cb, 'screen_reverse_scroll', self._csi_count()) + return + + if f == ord('t'): + if pfx == 0 and c.nparams > 0: + self._emit(cb, 'screen_report_size', c.params[0], c.params[1] if c.nparams > 1 else 0) + return + + if f == ord('p'): + return + + # Unhandled CSI — silently ignore (no event) + + def _format_deccara(self): + """Format DECCARA params (CSI $ r) into a list of event strings. + + Each event is: 'top;left;bottom;right;sgr_group' + """ + c = self.csi + if c.nparams == 0: + return ['0;0;0;0;0'] + params = list(c.params[:c.nparams]) + while len(params) < 4: + params.append(0) + subparams = c.subparams + rect = ';'.join(str(p) for p in params[:4]) + # Format SGR groups from params[4:] + sgr_params = params[4:] + if not sgr_params: + return [f'{rect};0'] + groups = [] + current = [] + i = 0 + while i < len(sgr_params): + p = sgr_params[i] + next_has_colon = (i + 1 < len(sgr_params)) and bool(subparams & (1 << (i + 5))) + if p in (38, 48, 58): + if current: + groups.append(';'.join(current)) + current = [] + sub = [str(p)] + i += 1 + # Consume following params that are subparams (preceded by ':') + while i < len(sgr_params) and (subparams & (1 << (i + 4))): + sub.append(str(sgr_params[i])) + i += 1 + groups.append(':'.join(sub)) + elif next_has_colon: + if current: + groups.append(';'.join(current)) + current = [] + sub = [str(p)] + i += 1 + while i < len(sgr_params) and (subparams & (1 << (i + 4))): + sub.append(str(sgr_params[i])) + i += 1 + groups.append(':'.join(sub)) + else: + current.append(str(p)) + i += 1 + if current: + groups.append(';'.join(current)) + return [f'{rect};{g}' for g in groups] + + # -- OSC dispatch -- + + def _dispatch_osc(self, screen, cb): + payload = bytes(self.str_buf).decode('utf-8', errors='replace') + # Parse OSC code: leading digits form the code, rest is data. + # If there's a ';', it separates code from data. + sep = payload.find(';') + if sep < 0: + # No ';' — leading digits are the code, rest is data + i = 0 + while i < len(payload) and payload[i].isdigit(): + i += 1 + if i == 0: + # No leading digits — treat as OSC 0 with payload as title + code = 0 + rest = payload + elif i == len(payload): + # Entire payload is digits — code only, no data + code = int(payload) + rest = '' + else: + code = int(payload[:i]) + rest = payload[i:] + else: + code_str = payload[:sep] + rest = payload[sep + 1:] + try: + code = int(code_str) if code_str else 0 + except ValueError: + code = -1 + + if code == 0: + self._emit(cb, 'set_title', rest) + self._emit(cb, 'set_icon', rest) + elif code == 1: + self._emit(cb, 'set_icon', rest) + elif code == 2: + self._emit(cb, 'set_title', rest) + elif code == 8: + # Hyperlink + if not rest: + self._emit(cb, 'set_active_hyperlink', None, None) + elif ';' not in rest: + self._emit(cb, 'Ignoring malformed OSC 8 code') + else: + params, uri = rest.split(';', 1) + if not uri: + uri = None + link_id = None + if params: + for kv in params.split(':'): + if kv.startswith('id=') and link_id is None: + link_id = kv[3:] + self._emit(cb, 'set_active_hyperlink', link_id, uri) + elif code == 9: + self._emit(cb, 'desktop_notify', 9, rest) + elif code == 99: + self._emit(cb, 'desktop_notify', 99, rest) + elif code == 52: + self._emit(cb, 'clipboard_control', 52, rest) + elif code == 110: + self._emit(cb, 'set_dynamic_color', 110, rest) + elif code == 22: + self._emit(cb, 'set_dynamic_color', 22, rest) + else: + # Unknown OSC — pass through as set_dynamic_color for some, + # or ignore. Kitty's test only uses specific codes. + pass + + # -- DCS dispatch -- + + def _dispatch_dcs(self, screen, cb): + payload = bytes(self.str_buf) + if not payload: + return + + # DCS +q — request capabilities + if payload.startswith(b'+q'): + data = payload[2:].decode('ascii', errors='replace') + self._emit(cb, 'screen_request_capabilities', 43, data) + return + + # DCS $q — request status + if payload.startswith(b'$q'): + data = payload[2:].decode('ascii', errors='replace') + self._emit(cb, 'screen_request_capabilities', ord('$'), data) + return + + # DCS @kitty-cmd + if payload.startswith(b'@kitty-cmd'): + data = payload[len(b'@kitty-cmd'):].decode('utf-8', errors='replace') + self._emit(cb, 'handle_remote_cmd', data) + return + + # DCS @kitty-print + if payload.startswith(b'@kitty-print|'): + data = payload[len(b'@kitty-print|'):].decode('ascii', errors='replace') + self._emit(cb, 'handle_remote_print', data) + return + + # DCS =1s / =2s — pending mode + if payload == b'=1s': + self._emit(cb, 'screen_start_pending_mode') + return + if payload == b'=2s': + self._emit(cb, 'screen_stop_pending_mode') + return + + # -- Graphics command (APC _ G) -- + + def _dispatch_graphics(self, screen, cb): + payload = bytes(self.str_buf) + if not payload: + return + + # Short key → full name mapping (from Kitty's graphics protocol) + key_map = { + 'a': 'action', 'd': 'delete_action', 't': 'transmission_type', + 'c': 'compressed', 'f': 'format', 'm': 'more', 'i': 'id', + 's': 'data_width', 'o': 'data_offset', 'w': 'width', + 'h': 'height', 'x': 'x_offset', 'y': 'y_offset', + 'v': 'data_height', 'u': 'data_sz', 'C': 'cursor_movement', + 'n': 'num_cells', 'r': 'num_lines', 'X': 'cell_x_offset', + 'Y': 'cell_y_offset', 'z': 'z_index', 'p': 'placement_id', + 'I': 'image_number', 'q': 'quiet', 'U': 'unicode_placement', + 'P': 'parent_id', 'Q': 'parent_placement_id', + 'O': 'offset_from_parent_x', 'N': 'offset_from_parent_y', + } + + # Parse the graphics command + # Format: _G{params};{base64_payload}ESC\ + # params are key=value pairs separated by commas + semi = payload.find(b';') + if semi >= 0: + cmd_str = payload[:semi] + b64_payload = payload[semi + 1:] + else: + cmd_str = payload + b64_payload = b'' + + # Parse key=value pairs + result = {} + # Defaults + for f in 'action delete_action transmission_type compressed'.split(): + result[f] = b'\0' + for f in ('format more id data_sz data_offset width height x_offset y_offset ' + 'data_height data_width cursor_movement num_cells num_lines ' + 'cell_x_offset cell_y_offset z_index placement_id image_number ' + 'quiet unicode_placement parent_id parent_placement_id ' + 'offset_from_parent_x offset_from_parent_y').split(): + result[f] = 0 + try: + from fast_data_types import base64_decode + result[''] = base64_decode(b64_payload).decode('utf-8', errors='replace') + except Exception: + result[''] = b64_payload.decode('ascii', errors='replace') + + i = 0 + n = len(cmd_str) + while i < n: + # Parse key + key_start = i + while i < n and cmd_str[i] != ord('='): + ch = cmd_str[i] + if ch == ord(','): + self._emit(cb, 'Malformed GraphicsCommand control block, invalid key character: 0x%02x' % ch) + return + # Only accept known graphics command keys + if chr(ch) not in key_map: + self._emit(cb, 'Malformed GraphicsCommand control block, invalid key character: 0x%02x' % ch) + return + i += 1 + if i >= n: + self._emit(cb, 'Malformed GraphicsCommand control block, no = after key') + return + key = cmd_str[key_start:i].decode('ascii') + i += 1 # skip '=" + + # Parse value + val_start = i + full_key = key_map.get(key, key) + is_char_key = full_key in ('action', 'delete_action', 'transmission_type', 'compressed') + while i < n and cmd_str[i] != ord(','): + ch = cmd_str[i] + if ch == ord('='): + # = after value — check if we have a value yet + if i == val_start: + # No value parsed yet + self._emit(cb, 'Malformed GraphicsCommand control block, expecting an integer value for key: %s' % key) + else: + self._emit(cb, 'Malformed GraphicsCommand control block, expecting a , or semi-colon after a value, found: 0x%02x' % ch) + return + if is_char_key: + # Character keys accept a single character + if i > val_start: + # Already got one char — only one expected + self._emit(cb, 'Malformed GraphicsCommand control block, expecting a , or semi-colon after a value, found: 0x%02x' % ch) + return + elif not (ord('0') <= ch <= ord('9') or ch == ord('-')): + self._emit(cb, 'Malformed GraphicsCommand control block, expecting an integer value for key: %s' % key) + return + i += 1 + + val_str = cmd_str[val_start:i].decode('ascii') + if not val_str: + self._emit(cb, 'Malformed GraphicsCommand control block, expecting an integer value') + return + if is_char_key: + result[full_key] = val_str.encode('ascii') + else: + try: + val = int(val_str) + except ValueError: + self._emit(cb, 'Malformed GraphicsCommand control block, expecting an integer value for key: %s' % key) + return + if val > 0xFFFFFFFF: + self._emit(cb, 'Malformed GraphicsCommand control block, number is too large') + return + result[full_key] = val + + if i < n and cmd_str[i] == ord(','): + i += 1 + + self._emit(cb, 'graphics_command', result) + + # -- ESC dispatch -- + + def _dispatch_esc(self, ch, cb): + if ch == ord('D'): + self._emit(cb, 'screen_index') + elif ch == ord('E'): + self._emit(cb, 'screen_nel') + elif ch == ord('c'): + self._emit(cb, 'screen_reset') + elif ch == ord('M'): + self._emit(cb, 'screen_reverse_index') + elif ch == ord('7'): + self._emit(cb, 'screen_save_cursor') + elif ch == ord('8'): + self._emit(cb, 'screen_restore_cursor') + elif ch == ord('H'): + self._emit(cb, 'screen_set_tab') + elif ch == ord('['): + pass # handled by state transition + elif ch == ord(']'): + pass # handled by state transition + elif ch == ord('P'): + pass # handled by state transition + elif ch == ord('_'): + pass # handled by state transition + elif ch == ord('^'): + pass # handled by state transition + elif ch == ord('X'): + pass # handled by state transition + elif ch == ord('(') or ch == ord(')'): + pass # handled by state transition + elif ch == ord('='): + self._emit(cb, 'screen_application_keypad', True) + elif ch == ord('>'): + self._emit(cb, 'screen_application_keypad', False) + elif ch == ord('.'): + # Unhandled charset escape — needs the next ESC byte + pass + else: + self._emit(cb, 'Unknown char after ESC: 0x%x' % ch) + + # -- Main parse loop -- + + def parse(self, screen, data, cb): + """Parse *data*, emitting events to *cb* and feeding bytes to screen.""" + if isinstance(data, str): + data = data.encode('utf-8') + data = bytes(data) + + # Feed ALL bytes to the C terminal for screen state + screen.write(data) + + i = 0 + n = len(data) + while i < n: + b = data[i] + + if self.state == _GROUND: + if b == 0x1b: + self._kill_pending() + self._flush_text(cb) + self.state = _ESC + i += 1 + elif b < 0x20: + # C0 control + self._kill_pending() + self._flush_text(cb) + self._control_event(cb, b) + # Other C0 controls: no event + i += 1 + else: + # Text byte — decode UTF-8 + consumed = self._decode_utf8(data[i:], cb) + i += consumed + + elif self.state == _ESC: + if b == 0x1b: + # ESC after ESC + self._emit(cb, 'Unknown char after ESC: 0x1b') + # Stay in ESC state + i += 1 + elif b < 0x20: + # C0 control after ESC — execute it + self._flush_text(cb) + self._control_event(cb, b) + self.state = _GROUND + i += 1 + elif b == ord('['): + self.csi.reset() + self.state = _CSI + i += 1 + elif b == ord(']'): + self.str_buf.clear() + self.str_type = _OSC + self.state = _OSC + i += 1 + elif b == ord('P'): + self.str_buf.clear() + self.str_type = _DCS + self.state = _DCS + i += 1 + elif b == ord('_'): + self.str_buf.clear() + self.str_type = _APC + self.state = _APC + i += 1 + elif b == ord('^'): + self.str_buf.clear() + self.str_type = _PM + self.state = _PM + i += 1 + elif b == ord('X'): + self.str_buf.clear() + self.str_type = _SOS + self.state = _SOS + i += 1 + elif b == ord('(') or b == ord(')'): + self.charset_slot = 0 if b == ord('(') else 1 + self.state = _CHARSET + i += 1 + elif b == ord('.'): + # Unhandled charset escape — needs the next ESC byte + # Emit special message when we see the next ESC + self.state = _CHARSET + self.charset_slot = -1 # special marker + i += 1 + elif b == ord('%'): + # ESC % — codepage selector; needs one more byte + self.state = _ESC_PCT + i += 1 + else: + self._dispatch_esc(b, cb) + self.state = _GROUND + i += 1 + + elif self.state == _ESC_PCT: + self.state = _GROUND + if b in (ord('G'), ord('@'), ord('/')): + # DOCS selectors (ESC % G etc.) — consume silently + i += 1 + else: + # Unknown — kitty draws the sequence literally + self.text += '%' + # Reprocess this byte in GROUND (don't advance) + + elif self.state == _CHARSET: + if self.charset_slot == -1: + # Unhandled charset escape (ESC .) + if b == 0x1b: + self._emit(cb, 'Unhandled charset related escape code: 0x2e 0x1b') + self.state = _GROUND + else: + self._emit(cb, 'Unhandled charset related escape code: 0x2e 0x%02x' % b) + self.state = _GROUND + i += 1 + else: + self._emit(cb, 'screen_designate_charset', self.charset_slot, b) + if self.charset_slot == 1: + # G1 charset — also emit screen_change_charset + self._emit(cb, 'screen_change_charset', 1) + self.state = _GROUND + i += 1 + + elif self.state == _CSI: + c = self.csi + if b == 0x1b: + # ESC interrupts CSI + self._flush_text(cb) + self.state = _ESC + i += 1 + elif b < 0x20: + # C0 control embedded inside CSI — dispatch, keep parsing + self._flush_text(cb) + self._control_event(cb, b) + i += 1 + elif c.phase == 2: + # Post-secondary: only a final byte is valid + if 0x40 <= b <= 0x7e: + c.final = b + self._dispatch_csi(screen, cb) + else: + self._emit(cb, 'Invalid character in CSI: %s (0x%x), ignoring the sequence' + % (chr(b) if 0x21 <= b <= 0x7e else '0x%x' % b, b)) + self.state = _GROUND + i += 1 + elif c.phase == 0: + if b == ord(';'): + if c.nparams < _MAX_PARAMS: + c.params[c.nparams] = 0 + c.nparams += 1 + c.phase = 1 + elif 0x30 <= b <= 0x39: + c.add_digit(b - 0x30) + c.phase = 1 + elif b in (ord('?'), ord('>'), ord('<'), ord('=')): + c.prefix = b + c.phase = 1 + elif 0x20 <= b <= 0x2f: + if b == ord('-'): + c.mult = -1 + c.num_digits = 1 + c.phase = 1 + else: + c.intermediate = b + c.phase = 2 + elif 0x40 <= b <= 0x7e: + c.final = b + self._dispatch_csi(screen, cb) + self.state = _GROUND + else: + self._emit(cb, 'Invalid character in CSI: %s (0x%x), ignoring the sequence' + % (chr(b) if 0x21 <= b <= 0x7e else '0x%x' % b, b)) + self.state = _GROUND + i += 1 + else: + # CSI_BODY + if 0x30 <= b <= 0x39: + c.add_digit(b - 0x30) + elif b == ord(';'): + if not c.num_digits: + c.num_digits = 1 # empty means zero + if not c.commit(): + self._emit(cb, 'CSI escape code has too many parameters, ignoring it') + self.state = _GROUND + elif b == ord(':'): + if c.commit(): + c.subparams |= 1 << c.nparams + else: + self._emit(cb, 'CSI escape code has too many parameters, ignoring it') + self.state = _GROUND + elif 0x20 <= b <= 0x2f: + if b == ord('-') and c.num_digits == 0: + c.mult = -1 + c.num_digits = 1 + elif c.commit(): + c.intermediate = b + c.phase = 2 + else: + self._emit(cb, 'CSI escape code has too many parameters, ignoring it') + self.state = _GROUND + elif 0x40 <= b <= 0x7e: + if c.num_digits == 1 and c.intermediate == 0 and c.mult == -1: + # A bare '-' before the final is an intermediate + c.num_digits = 0 + c.intermediate = ord('-') + if c.commit(): + c.final = b + self._dispatch_csi(screen, cb) + else: + self._emit(cb, 'CSI escape code has too many parameters, ignoring it') + self.state = _GROUND + else: + self._emit(cb, 'Invalid character in CSI: %s (0x%x), ignoring the sequence' + % (chr(b) if 0x21 <= b <= 0x7e else '0x%x' % b, b)) + self.state = _GROUND + i += 1 + + elif self.state in (_OSC, _DCS, _APC, _PM, _SOS): + if b == 0x07: + # BEL terminates string + self._finish_string(screen, cb) + self.state = _GROUND + i += 1 + elif b == 0x1b: + self.state = _STR_ESC + i += 1 + elif b < 0x20: + # C0 control — abort string (except for BEL handled above) + self.str_buf.clear() + self.state = _GROUND + # Process the control byte in GROUND + self._control_event(cb, b) + i += 1 + else: + self.str_buf.append(b) + i += 1 + + elif self.state == _STR_ESC: + if b == ord('\\'): + # ST (ESC \) terminates string + self._finish_string(screen, cb) + self.state = _GROUND + i += 1 + else: + # Other byte after ESC in string — reprocess + self.str_buf.clear() + self.state = _GROUND + # Don't consume — reprocess in GROUND/ESC + # Actually, the ESC should be reprocessed + self.state = _ESC + i += 0 # Don't advance — reprocess this byte via ESC + # But we already consumed the ESC, so process this byte in ESC state + if b == 0x1b: + self._emit(cb, 'Unknown char after ESC: 0x1b') + # Stay in ESC + elif b < 0x20: + self._control_event(cb, b) + self.state = _GROUND + else: + self._dispatch_esc(b, cb) + self.state = _GROUND + i += 1 + + # End of data — flush any pending text + self._flush_text(cb) + + def _finish_string(self, screen, cb): + """Dispatch the completed string (OSC/DCS/APC/PM/SOS).""" + st = self.str_type + if st == _OSC: + self._dispatch_osc(screen, cb) + elif st == _DCS: + self._dispatch_dcs(screen, cb) + elif st == _APC: + # Check for graphics command (_G) + payload = bytes(self.str_buf) + if payload.startswith(b'G'): + self.str_buf = bytearray(payload[1:]) + self._dispatch_graphics(screen, cb) + else: + code = payload[0] if payload else 0 + self._emit(cb, 'Unrecognized APC code: 0x%02x' % code) + elif st == _PM: + code = self.str_buf[0] if self.str_buf else 0 + self._emit(cb, 'Unrecognized PM code: 0x%02x' % code) + elif st == _SOS: + code = self.str_buf[0] if self.str_buf else 0 + self._emit(cb, 'Unrecognized SOS code: 0x%02x' % code) + self.str_buf.clear() + + +# Module-level parse_bytes function +def parse_bytes(screen, data, callback=None): + """Parse *data* and emit events to *callback*. + + Maintains parser state on the screen object so partial sequences + split across ``parse_bytes`` invocations are handled correctly. + Also updates screen.callbacks for OSC events (titlebuf, etc.). + """ + if callback is None: + callback = lambda *args: None + if not hasattr(screen, '_kitty_parser'): + screen._kitty_parser = KittyParser() + + def wrapped_cb(wid, *args): + # Update screen callbacks for OSC/DCS events + if args and args[0] == 'set_title': + screen.callbacks.titlebuf.append(args[1]) + elif args and args[0] == 'set_icon': + screen.callbacks.iconbuf += args[1] if isinstance(args[1], str) else str(args[1], 'utf-8') + elif args and args[0] == 'desktop_notify': + screen.callbacks.notifications.append((args[1], args[2])) + elif args and args[0] == 'set_dynamic_color': + val = args[2] if isinstance(args[2], str) else str(args[2], 'utf-8') + screen.callbacks.colorbuf += val + elif args and args[0] == 'handle_remote_print': + try: + from fast_data_types import base64_decode + screen.callbacks.printbuf.append(base64_decode(args[1]).decode('utf-8')) + except Exception: + screen.callbacks.printbuf.append(args[1]) + # Forward to original callback + callback(wid, *args) + + screen._kitty_parser.parse(screen, data, wrapped_cb) |
