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/screen.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/screen.py')
| -rw-r--r-- | tests/extern/kitty/screen.py | 476 |
1 files changed, 476 insertions, 0 deletions
diff --git a/tests/extern/kitty/screen.py b/tests/extern/kitty/screen.py new file mode 100644 index 0000000..80f8e71 --- /dev/null +++ b/tests/extern/kitty/screen.py @@ -0,0 +1,476 @@ +"""ctypes wrapper providing the Kitty ``Screen`` API on top of our C terminal. + +The shared library ``libterm.so`` is loaded via :mod:`ctypes`. Python calls +only the ``kitty_*`` functions of ``screen_bridge.c``, which wrap the public +API (declared in ``src/term/term.h``). Python classes on top of them match the +subset of Kitty's test API used by ``parser.py``. +""" + +import ctypes +import os + +# --------------------------------------------------------------------------- +# Shared library loading +# --------------------------------------------------------------------------- + +_lib = ctypes.CDLL(os.environ.get('WSAP_KITTY_LIB', os.path.join(os.path.dirname(__file__), 'libterm.so'))) + +# struct term_cell { uint32_t text, fg, bg; uint16_t attrs; uint8_t width, reserved; } +class TermCell(ctypes.Structure): + _fields_ = [ + ('text', ctypes.c_uint32), + ('fg', ctypes.c_uint32), + ('bg', ctypes.c_uint32), + ('attrs', ctypes.c_uint16), + ('width', ctypes.c_uint8), + ('reserved', ctypes.c_uint8), + ] + + +assert ctypes.sizeof(TermCell) == 16 + +# Reply callback type: void (*)(void *ud, const char *bytes, size_t len). +# The bytes are not NUL-terminated, so the callback takes a raw pointer. +REPLY_FN = ctypes.CFUNCTYPE(None, ctypes.c_void_p, ctypes.POINTER(ctypes.c_char), ctypes.c_size_t) + +# Function signatures +_lib.kitty_term_new.restype = ctypes.c_void_p +_lib.kitty_term_new.argtypes = [ctypes.c_int, ctypes.c_int, REPLY_FN, ctypes.c_void_p] +_lib.kitty_term_free.restype = None +_lib.kitty_term_free.argtypes = [ctypes.c_void_p] +_lib.kitty_term_resize.restype = ctypes.c_bool +_lib.kitty_term_resize.argtypes = [ctypes.c_void_p, ctypes.c_int, ctypes.c_int] +_lib.kitty_term_write.restype = None +_lib.kitty_term_write.argtypes = [ctypes.c_void_p, ctypes.c_char_p, ctypes.c_size_t] +_lib.kitty_term_cell.restype = ctypes.c_bool +_lib.kitty_term_cell.argtypes = [ctypes.c_void_p, ctypes.c_int, ctypes.c_int, + ctypes.POINTER(TermCell)] +_lib.kitty_term_cell_text.restype = ctypes.c_int +_lib.kitty_term_cell_text.argtypes = [ctypes.c_void_p, ctypes.c_int, ctypes.c_int, + ctypes.POINTER(ctypes.c_uint32), ctypes.c_int] +for _name, _type in { + 'cols': ctypes.c_int, + 'rows': ctypes.c_int, + 'cursor_x': ctypes.c_int, + 'cursor_y': ctypes.c_int, + 'cursor_style': ctypes.c_int, + 'cursor_blink': ctypes.c_bool, + 'application_cursor': ctypes.c_bool, +}.items(): + _fn = getattr(_lib, 'kitty_term_' + _name) + _fn.argtypes = [ctypes.c_void_p] + _fn.restype = _type + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +TERM_DEFAULT_FG = 0xFFFFFFFF +TERM_DEFAULT_BG = 0xFF1E1E2E + +# Cell attributes (must match enum term_attr in term.h) +TERM_ATTR_BOLD = 1 << 0 +TERM_ATTR_ITALIC = 1 << 1 +TERM_ATTR_FAINT = 1 << 2 +TERM_ATTR_STRIKE = 1 << 3 +TERM_ATTR_OVERLINE = 1 << 4 +TERM_ATTR_LINK = 1 << 5 +TERM_ATTR_EMOJI = 1 << 6 +TERM_ATTR_TEXT = 1 << 7 +TERM_ATTR_UNDERLINE_SHIFT = 8 +TERM_ATTR_UNDERLINE_MASK = 7 << TERM_ATTR_UNDERLINE_SHIFT + +# The longest grapheme cluster that a cell's text is read with. +_CLUSTER_CAP = 32 + +# Test-only accessors compiled against the current C terminal definition. +for _name, _type in { + 'scroll_top': ctypes.c_int, + 'scroll_bottom': ctypes.c_int, + 'cur_fg': ctypes.c_uint32, + 'cur_bg': ctypes.c_uint32, + 'bold': ctypes.c_bool, + 'faint': ctypes.c_bool, + 'italic': ctypes.c_bool, + 'strike': ctypes.c_bool, + 'inverse': ctypes.c_bool, + 'underline': ctypes.c_uint8, +}.items(): + _fn = getattr(_lib, 'kitty_screen_' + _name) + _fn.argtypes = [ctypes.c_void_p] + _fn.restype = _type + +_lib.kitty_screen_row_length.argtypes = [ctypes.c_void_p, ctypes.c_int] +_lib.kitty_screen_row_length.restype = ctypes.c_int +_lib.kitty_screen_cell_is_tab.argtypes = [ctypes.c_void_p, ctypes.c_int, ctypes.c_int] +_lib.kitty_screen_cell_is_tab.restype = ctypes.c_bool + +# ANSI color table from escape.c — used for reverse-mapping to Kitty indices. +# Must match the built-in default palette in src/term/palette.c. +_ANSI_COLORS = [ + 0xFF585B70, 0xFFCC6666, 0xFFB5BD68, 0xFFF0C674, + 0xFF81A2BE, 0xFFB294BB, 0xFF8ABEB7, 0xFFC5C8C6, + 0xFF666666, 0xFFD54E53, 0xFFB9CA4A, 0xFFE7C547, + 0xFF7AA6DA, 0xFFC397D8, 0xFF70C0B1, 0xFFEAEAEA, +] + + +def _build_color_table(): + """Build a reverse lookup from RGB (0xFFRRGGBB) to Kitty indexed color.""" + table = {} + for i, rgb in enumerate(_ANSI_COLORS): + table[rgb] = (i << 8) | 1 + levels = [0, 95, 135, 175, 215, 255] + for i in range(16, 232): + n = i - 16 + r, g, b = levels[n // 36], levels[(n // 6) % 6], levels[n % 6] + rgb = 0xFF000000 | (r << 16) | (g << 8) | b + table[rgb] = (i << 8) | 1 + for i in range(232, 256): + gray = 8 + (i - 232) * 10 + rgb = 0xFF000000 | (gray << 16) | (gray << 8) | gray + table[rgb] = (i << 8) | 1 + return table + + +_COLOR_TO_KITTY = _build_color_table() + + +TERM_COLOR_REF = 0x01000000 + + +def _convert_color(color): + """Convert our cell color to Kitty's format. + + Ours: a palette reference (top byte 0x01; slot 0-255, 0x100 default fg, + 0x101 default bg) or a literal 0xFFRRGGBB color. + + Kitty format: + 0 = default + index<<8|1 = indexed (0-255) + r<<24|g<<16|b<<8|2 = RGB + """ + if (color >> 24) == (TERM_COLOR_REF >> 24): + slot = color & 0x1FF + return 0 if slot >= 0x100 else (slot << 8) | 1 + if color == TERM_DEFAULT_FG or color == TERM_DEFAULT_BG: + return 0 + kitty = _COLOR_TO_KITTY.get(color) + if kitty is not None: + return kitty + r = (color >> 16) & 0xFF + g = (color >> 8) & 0xFF + b = color & 0xFF + return (r << 24) | (g << 16) | (b << 8) | 2 + + +# --------------------------------------------------------------------------- +# Screen property readers +# --------------------------------------------------------------------------- + +def _read_property(ptr, name): + return getattr(_lib, 'kitty_screen_' + name)(ptr) + + +# --------------------------------------------------------------------------- +# Cursor — wraps the terminal's current cursor SGR state + position +# --------------------------------------------------------------------------- + +class Cursor: + """Wraps the terminal's current cursor state (SGR attributes + position).""" + + def __init__(self, screen): + self._screen = screen + + @property + def _ptr(self): + return self._screen._ptr + + # -- position -- + @property + def x(self): + return _lib.kitty_term_cursor_x(self._screen._term) + + @property + def y(self): + return _lib.kitty_term_cursor_y(self._screen._term) + + # -- SGR boolean attributes -- + @property + def bold(self): + return _read_property(self._ptr, 'bold') + + @property + def italic(self): + return _read_property(self._ptr, 'italic') + + @property + def dim(self): + return _read_property(self._ptr, 'faint') + + @property + def reverse(self): + return _read_property(self._ptr, 'inverse') + + @property + def strikethrough(self): + return _read_property(self._ptr, 'strike') + + @property + def decoration(self): + # Kitty: 0=none, 1=straight, 2=double, 3=curly, 4=dotted, 5=dashed + return _read_property(self._ptr, 'underline') + + # -- colors -- + @property + def fg(self): + return _convert_color(_read_property(self._ptr, 'cur_fg')) + + @property + def bg(self): + return _convert_color(_read_property(self._ptr, 'cur_bg')) + + # -- cursor style -- + @property + def blink(self): + return _lib.kitty_term_cursor_blink(self._screen._term) + + @property + def shape(self): + return _lib.kitty_term_cursor_style(self._screen._term) + + +# --------------------------------------------------------------------------- +# CellCursor — wraps a single cell's attributes +# --------------------------------------------------------------------------- + +class CellCursor: + """Wraps a single cell's attributes for SGR inspection.""" + + def __init__(self, cell): + self._cell = cell + + @property + def bold(self): + return bool(self._cell.attrs & TERM_ATTR_BOLD) + + @property + def italic(self): + return bool(self._cell.attrs & TERM_ATTR_ITALIC) + + @property + def dim(self): + return bool(self._cell.attrs & TERM_ATTR_FAINT) + + @property + def reverse(self): + # Our terminal swaps fg/bg when inverse is set, so cells don't + # carry a separate inverse flag. + return False + + @property + def strikethrough(self): + return bool(self._cell.attrs & TERM_ATTR_STRIKE) + + @property + def decoration(self): + # enum term_underline uses Kitty's numbers: 0=none, 1=straight, + # 2=double, 3=curly, 4=dotted, 5=dashed. + return (self._cell.attrs & TERM_ATTR_UNDERLINE_MASK) >> TERM_ATTR_UNDERLINE_SHIFT + + @property + def fg(self): + return _convert_color(self._cell.fg) + + @property + def bg(self): + return _convert_color(self._cell.bg) + + +# --------------------------------------------------------------------------- +# Line — wraps a screen line, providing cell access +# --------------------------------------------------------------------------- + +class Line: + """Wraps a screen line, providing cell access and text content.""" + + def __init__(self, screen, y): + self._screen = screen + self._y = y + + def cursor_from(self, x): + """Return a :class:`CellCursor` for cell (x, y).""" + cell = TermCell() + if not _lib.kitty_term_cell(self._screen._term, x, self._y, ctypes.byref(cell)): + return CellCursor(TermCell()) + return CellCursor(cell) + + def __str__(self): + """Return the text content of the line up to the written length.""" + length = min(_lib.kitty_screen_row_length(self._screen._term, self._y), + self._screen.columns) + if length == 0: + return '' + result = [] + cell = TermCell() + cps = (ctypes.c_uint32 * _CLUSTER_CAP)() + for x in range(length): + if not _lib.kitty_term_cell(self._screen._term, x, self._y, ctypes.byref(cell)): + result.append(' ') + continue + # Width 0 is the right half of a wide glyph. + if cell.width == 0: + continue + if _lib.kitty_screen_cell_is_tab(self._screen._term, x, self._y): + result.append('\t') + continue + n = _lib.kitty_term_cell_text(self._screen._term, x, self._y, cps, _CLUSTER_CAP) + n = min(n, _CLUSTER_CAP) + result.append(''.join(chr(cp) for cp in cps[:n])) + return ''.join(result) + + +# --------------------------------------------------------------------------- +# Callbacks — mock callbacks object for OSC/DCS responses +# --------------------------------------------------------------------------- + +class Callbacks: + """Mock callbacks object for OSC/DCS responses.""" + + def __init__(self, screen=None): + self._screen = screen + self.wtcbuf = b'' + self.titlebuf = [] + self.iconbuf = '' + self.colorbuf = '' + self.notifications = [] + self.printbuf = [] + + def clear(self): + self.wtcbuf = b'' + self.titlebuf = [] + self.iconbuf = '' + self.colorbuf = '' + self.notifications = [] + self.printbuf = [] + if self._screen is not None: + self._screen._response_buf.clear() + + +# --------------------------------------------------------------------------- +# Screen — main screen wrapper using ctypes +# --------------------------------------------------------------------------- + +class Screen: + """Main screen wrapper using ctypes to drive the C terminal.""" + + def __init__(self, cols=80, rows=5): + self._cols = cols + self._rows = rows + self._response_buf = bytearray() + self._write_buf = bytearray() + self._callbacks = Callbacks(self) + # Keep a reference to the callback function so it isn't GC'd + self._resp_fn = REPLY_FN(self._on_response) + self._term = _lib.kitty_term_new(cols, rows, self._resp_fn, None) + if not self._term: + raise RuntimeError('kitty_term_new failed') + + @property + def _ptr(self): + return ctypes.cast(self._term, ctypes.c_void_p).value + + # -- response callback -- + def _on_response(self, ud, data, length): + if data and length: + self._response_buf.extend(data[:length]) + self._callbacks.wtcbuf = bytes(self._response_buf) + + # -- writing -- + def write(self, data): + if data: + _lib.kitty_term_write(self._term, bytes(data), len(data)) + + # -- test write buffer API (matches Kitty's test_parse interface) -- + def test_create_write_buffer(self): + """Return the available space in the write buffer (int, falsy when full).""" + from fast_data_types import VT_PARSER_BUFFER_SIZE + return VT_PARSER_BUFFER_SIZE - len(self._write_buf) + + def test_commit_write_buffer(self, data, write_buf): + """Append *data* to the internal write buffer, return bytes consumed.""" + from fast_data_types import VT_PARSER_BUFFER_SIZE + available = VT_PARSER_BUFFER_SIZE - len(self._write_buf) + n = min(len(data), available) + if n > 0: + self._write_buf.extend(data[:n]) + return n + + def test_parse_written_data(self, cd=None): + """Parse the write buffer, dispatching to the C terminal and callback.""" + data = bytes(self._write_buf) + self._write_buf.clear() + from kitty_parser import parse_bytes + if cd is not None: + parse_bytes(self, data, cd) + elif data: + # No callback — still parse to keep parser state in sync + parse_bytes(self, data, lambda *a: None) + + # -- properties -- + @property + def cursor(self): + return Cursor(self) + + @property + def callbacks(self): + return self._callbacks + + @property + def columns(self): + return _lib.kitty_term_cols(self._term) + + @property + def lines(self): + return _lib.kitty_term_rows(self._term) + + @property + def margin_top(self): + return _read_property(self._ptr, 'scroll_top') + + @property + def margin_bottom(self): + return _read_property(self._ptr, 'scroll_bottom') + + @property + def cursor_key_mode(self): + return _lib.kitty_term_application_cursor(self._term) + + @cursor_key_mode.setter + def cursor_key_mode(self, value): + if value: + self.write(b'\x1b[?1h') + else: + self.write(b'\x1b[?1l') + + # -- line access -- + def line(self, n): + return Line(self, n) + + # -- operations -- + def reset(self): + self.write(b'\x1bc') + + def cursor_move(self, n): + """Move cursor back n cells (kitty cursor_move: direction -1, + allowed to move to previous lines).""" + self.write(b'\x1b[' + str(n).encode() + b'D') + + def close(self): + """Destroy the underlying C terminal.""" + if getattr(self, '_term', None): + _lib.kitty_term_free(self._term) + self._term = None + + def __del__(self): + self.close() |
