Files
Jean-Pierre MartineauandClaude Opus 5.5 3e251a9707 Docstrings and comments of meshterm/emulator are in ASD-STE100
The same rewrite as for the core package. The physical screen of a
handheld is now the display, and the computer is the handheld. The
comment on FONT_ENV now says that it is the environment variable.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-06 03:51:55 -04:00

536 lines
20 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# SPDX-License-Identifier: Apache-2.0
"""The terminal that the emulator draws: a VT byte stream, parsed into a grid of cells.
This module is small on purpose. Its only producer is MeshTerm itself: the
``Vt100_Output`` of prompt_toolkit, and the fast row writer of the session. Thus the
module understands what those two write, and the small number of similar sequences that a
stray library can write. It ignores the rest of the VT220/xterm sequences, with no
message. It understands cursor movement and addressing, erase and insert/delete, scroll
regions, the alternate screen, autowrap, and SGR in all three colour depths (16, 256, and
truecolour, with ``;`` or ``:`` separators).
The module skips what it does not model by the grammar: a whole escape sequence, never a
part of one. Thus an unknown sequence can never put its parameters on the screen as text.
The grid is the source of truth for the pixels. :class:`Terminal` keeps a list of the rows
that changed since the emulator last drew (:meth:`Terminal.take_dirty`). Thus a key press
that moves the highlight one row draws two rows of the display again, not fourteen.
"""
from __future__ import annotations
from collections.abc import Callable, Sequence
from dataclasses import dataclass
from rich.cells import cell_len
#: A colour, as 8-bit channels.
RGB = tuple[int, int, int]
#: Style flags. :attr:`Style.flags` is a bitmask of these flags.
BOLD = 1
DIM = 2
ITALIC = 4
UNDERLINE = 8
REVERSE = 16
HIDDEN = 32
STRIKE = 64
@dataclass(frozen=True, slots=True)
class Style:
"""How a cell is drawn: its colours (``None`` = the terminal's default) and its flags."""
fg: RGB | None = None
bg: RGB | None = None
flags: int = 0
#: The style of a new or erased cell.
PLAIN = Style()
#: A cell: the character that is drawn there (``""`` for the right half of a wide
#: character), and its style.
Cell = tuple[str, Style]
_BLANK: Cell = (" ", PLAIN)
#: A standard 16-colour palette, for an emulator that gives no palette of its own.
_DEFAULT_PALETTE: tuple[RGB, ...] = (
(0, 0, 0), (205, 0, 0), (0, 205, 0), (205, 205, 0),
(0, 0, 238), (205, 0, 205), (0, 205, 205), (229, 229, 229),
(127, 127, 127), (255, 0, 0), (0, 255, 0), (255, 255, 0),
(92, 92, 255), (255, 0, 255), (0, 255, 255), (255, 255, 255),
) # fmt: skip
_CUBE_LEVELS = (0, 95, 135, 175, 215, 255)
# Parser states.
_GROUND, _ESC, _CSI, _OSC, _CHARSET, _STRING = range(6)
def _xterm_256(index: int, palette: Sequence[RGB]) -> RGB:
"""The colour that the xterm 256-colour index ``index`` names."""
if index < 16:
return palette[index]
if index < 232:
index -= 16
return (
_CUBE_LEVELS[index // 36],
_CUBE_LEVELS[index // 6 % 6],
_CUBE_LEVELS[index % 6],
)
level = 8 + 10 * (index - 232)
return (level, level, level)
class Terminal:
"""A ``cols`` × ``rows`` character grid, driven by a VT byte stream.
Args:
cols: The width in cells.
rows: The height in cells.
palette: The 16 colours that SGR 30–37/90–97 (and 256-colour indices 0–15) name.
reply: Called with the bytes that a real terminal sends as the answer to a query
(a cursor-position report, a device-attributes reply). ``None`` ignores them.
"""
def __init__(
self,
cols: int,
rows: int,
*,
palette: Sequence[RGB] = _DEFAULT_PALETTE,
reply: Callable[[str], None] | None = None,
) -> None:
"""Start blank, with the cursor at home, in the main screen."""
self.cols = cols
self.rows = rows
self.palette = tuple(palette)
self._reply = reply
self._main = self._fresh_screen()
self._alt = self._fresh_screen()
self.screen = self._main
self.x = 0
self.y = 0
self.style = PLAIN
self.cursor_visible = True
self.autowrap = True
self.application_cursor = False
self._wrap_pending = False
self._top = 0
self._bottom = rows - 1
self._saved: tuple[int, int, Style] = (0, 0, PLAIN)
self._state = _GROUND
self._params = ""
self._dirty: set[int] = set(range(rows))
# --- the grid, as the emulator reads it ------------------------------------------------
def _fresh_screen(self) -> list[list[Cell]]:
return [[_BLANK] * self.cols for _ in range(self.rows)]
def take_dirty(self) -> set[int]:
"""The rows that changed since the last call. This call forgets them."""
dirty, self._dirty = self._dirty, set()
return dirty
def line(self, row: int) -> str:
"""Row ``row`` as plain text, with each wide character one time."""
return "".join(char for char, _ in self.screen[row])
def text(self) -> str:
"""The whole screen as plain text, one line for each row."""
return "\n".join(self.line(row) for row in range(self.rows))
# --- input --------------------------------------------------------------------------
def feed(self, data: str) -> None:
"""Parse ``data``, and update the grid."""
for char in data:
state = self._state
if state == _GROUND:
if char >= " " and char != "\x7f":
self._print(char)
elif char == "\x1b":
self._state = _ESC
else:
self._control(char)
elif state == _CSI:
if "\x40" <= char <= "\x7e":
self._state = _GROUND
self._csi(self._params, char)
elif char == "\x1b":
self._state = _ESC
elif char in "\x18\x1a":
self._state = _GROUND # CAN/SUB abort the sequence
elif char >= " ":
self._params += char
else:
self._control(char) # C0 controls act also in the middle of a sequence
elif state == _ESC:
self._escape(char)
elif state == _OSC:
if char == "\x07":
self._state = _GROUND
elif char == "\x1b":
self._state = _STRING # ST is ESC \, which _STRING finishes
elif state == _STRING:
if char == "\x1b":
self._state = _ESC
elif char == "\x07":
self._state = _GROUND
else: # _CHARSET: the one final character of the designator
self._state = _GROUND
def _escape(self, char: str) -> None:
self._state = _GROUND
if char == "[":
self._state, self._params = _CSI, ""
elif char == "]":
self._state = _OSC
elif char in "()*+-./":
self._state = _CHARSET
elif char in "P^_X":
self._state = _STRING
elif char == "7":
self._saved = (self.x, self.y, self.style)
elif char == "8":
self._restore()
elif char == "D":
self._linefeed()
elif char == "E":
self.x = 0
self._linefeed()
elif char == "M":
self._reverse_index()
elif char == "c":
self.__init__(self.cols, self.rows, palette=self.palette, reply=self._reply)
# "=", ">", "\\", and all other characters: nothing to draw.
def _control(self, char: str) -> None:
if char == "\r":
self.x = 0
self._wrap_pending = False
elif char in "\n\x0b\x0c":
self._linefeed()
elif char == "\b":
self.x = max(0, self.x - 1)
self._wrap_pending = False
elif char == "\t":
self.x = min(self.cols - 1, (self.x // 8 + 1) * 8)
self._wrap_pending = False
# BEL, SO/SI, and the other controls draw nothing.
# --- drawing --------------------------------------------------------------------------
def _print(self, char: str) -> None:
width = cell_len(char)
row = self.screen[self.y]
if width == 0:
# A combining mark goes into the cell before it.
if self.x > 0 or self._wrap_pending:
col = self.x if self._wrap_pending else self.x - 1
base, style = row[col]
row[col] = (base + char, style)
self._dirty.add(self.y)
return
if self._wrap_pending or (width == 2 and self.x == self.cols - 1):
if self.autowrap:
self.x = 0
self._linefeed()
row = self.screen[self.y]
self._wrap_pending = False
x = self.x
self._unsplit(row, x)
row[x] = (char, self.style)
if width == 2 and x + 1 < self.cols:
self._unsplit(row, x + 1)
row[x + 1] = ("", self.style)
self._dirty.add(self.y)
x += width
if x >= self.cols:
self.x = self.cols - 1
self._wrap_pending = self.autowrap
else:
self.x = x
def _unsplit(self, row: list[Cell], x: int) -> None:
"""Before ``x`` is overwritten: if it is half of a wide character, blank the other half."""
char, style = row[x]
if char == "" and x > 0:
row[x - 1] = (" ", row[x - 1][1])
elif x + 1 < self.cols and row[x + 1][0] == "" and cell_len(char) == 2:
row[x + 1] = (" ", style)
def _erase_style(self) -> Style:
"""The style of an erased cell: the current background, and nothing else."""
return PLAIN if self.style.bg is None else Style(bg=self.style.bg)
def _erase(self, row: int, first: int, last: int) -> None:
cell = (" ", self._erase_style())
line = self.screen[row]
for x in range(max(0, first), min(self.cols, last + 1)):
line[x] = cell
self._dirty.add(row)
def _linefeed(self) -> None:
self._wrap_pending = False
if self.y == self._bottom:
self._scroll_up(1)
elif self.y < self.rows - 1:
self.y += 1
def _reverse_index(self) -> None:
self._wrap_pending = False
if self.y == self._top:
self._scroll_down(1)
elif self.y > 0:
self.y -= 1
def _scroll_up(self, count: int, top: int | None = None) -> None:
top = self._top if top is None else top
bottom = self._bottom
count = min(count, bottom - top + 1)
region = self.screen[top : bottom + 1]
blank = [(" ", self._erase_style())] * self.cols
region = region[count:] + [list(blank) for _ in range(count)]
self.screen[top : bottom + 1] = region
self._dirty.update(range(top, bottom + 1))
def _scroll_down(self, count: int, top: int | None = None) -> None:
top = self._top if top is None else top
bottom = self._bottom
count = min(count, bottom - top + 1)
region = self.screen[top : bottom + 1]
blank = [(" ", self._erase_style())] * self.cols
region = [list(blank) for _ in range(count)] + region[: len(region) - count]
self.screen[top : bottom + 1] = region
self._dirty.update(range(top, bottom + 1))
def _restore(self) -> None:
self.x, self.y, self.style = self._saved
self.x = min(self.x, self.cols - 1)
self.y = min(self.y, self.rows - 1)
self._wrap_pending = False
def _goto(self, x: int, y: int) -> None:
self.x = min(max(0, x), self.cols - 1)
self.y = min(max(0, y), self.rows - 1)
self._wrap_pending = False
# --- CSI ------------------------------------------------------------------------------
def _csi(self, raw: str, final: str) -> None:
private = raw[:1] if raw[:1] in "?<=>" else ""
body = raw[len(private) :].rstrip(" !\"#$%&'()*+,-./") # intermediates
params = [part for part in body.split(";")] if body else []
def arg(index: int, default: int) -> int:
if index >= len(params):
return default
head = params[index].split(":")[0]
return int(head) if head.isdigit() and int(head) else default
if private == "?":
if final in "hl":
self._private_mode(params, final == "h")
return
if private:
return # secondary DA and similar queries: nothing to draw
if final == "m":
self._sgr(params)
elif final in "Hf":
self._goto(arg(1, 1) - 1, arg(0, 1) - 1)
elif final == "A":
self._goto(self.x, max(self._top if self.y >= self._top else 0, self.y - arg(0, 1)))
elif final == "B":
bottom = self._bottom if self.y <= self._bottom else self.rows - 1
self._goto(self.x, min(bottom, self.y + arg(0, 1)))
elif final == "C":
self._goto(self.x + arg(0, 1), self.y)
elif final == "D":
self._goto(self.x - arg(0, 1), self.y)
elif final == "E":
self._goto(0, self.y + arg(0, 1))
elif final == "F":
self._goto(0, self.y - arg(0, 1))
elif final == "G":
self._goto(arg(0, 1) - 1, self.y)
elif final == "d":
self._goto(self.x, arg(0, 1) - 1)
elif final == "J":
self._erase_display(arg(0, 0) if params and params[0] else 0)
elif final == "K":
mode = arg(0, 0) if params and params[0] else 0
if mode == 0:
self._erase(self.y, self.x, self.cols - 1)
elif mode == 1:
self._erase(self.y, 0, self.x)
else:
self._erase(self.y, 0, self.cols - 1)
elif final == "X":
self._erase(self.y, self.x, self.x + arg(0, 1) - 1)
elif final == "@":
self._insert_chars(arg(0, 1))
elif final == "P":
self._delete_chars(arg(0, 1))
elif final == "L":
if self._top <= self.y <= self._bottom:
self._scroll_down(arg(0, 1), top=self.y)
self.x = 0
elif final == "M":
if self._top <= self.y <= self._bottom:
self._scroll_up(arg(0, 1), top=self.y)
self.x = 0
elif final == "S":
self._scroll_up(arg(0, 1))
elif final == "T":
self._scroll_down(arg(0, 1))
elif final == "r":
top, bottom = arg(0, 1) - 1, arg(1, self.rows) - 1
if 0 <= top < bottom < self.rows:
self._top, self._bottom = top, bottom
self._goto(0, 0)
elif final == "s":
self._saved = (self.x, self.y, self.style)
elif final == "u":
self._restore()
elif final == "n" and self._reply is not None:
if arg(0, 0) == 6:
self._reply(f"\x1b[{self.y + 1};{self.x + 1}R")
elif arg(0, 0) == 5:
self._reply("\x1b[0n")
elif final == "c" and self._reply is not None:
self._reply("\x1b[?1;2c")
# All other sequences (window ops, cursor shape, ...) change nothing here.
def _erase_display(self, mode: int) -> None:
if mode == 0:
self._erase(self.y, self.x, self.cols - 1)
for row in range(self.y + 1, self.rows):
self._erase(row, 0, self.cols - 1)
elif mode == 1:
for row in range(0, self.y):
self._erase(row, 0, self.cols - 1)
self._erase(self.y, 0, self.x)
else:
for row in range(self.rows):
self._erase(row, 0, self.cols - 1)
def _insert_chars(self, count: int) -> None:
line = self.screen[self.y]
count = min(count, self.cols - self.x)
cell = (" ", self._erase_style())
line[self.x :] = ([cell] * count + line[self.x :])[: self.cols - self.x]
self._dirty.add(self.y)
def _delete_chars(self, count: int) -> None:
line = self.screen[self.y]
count = min(count, self.cols - self.x)
cell = (" ", self._erase_style())
line[self.x :] = line[self.x + count :] + [cell] * count
self._dirty.add(self.y)
def _private_mode(self, params: list[str], on: bool) -> None:
for raw in params:
mode = int(raw) if raw.isdigit() else -1
if mode == 25:
self.cursor_visible = on
elif mode == 7:
self.autowrap = on
elif mode == 1:
self.application_cursor = on
elif mode in (47, 1047, 1049):
if mode == 1049 and on:
self._saved = (self.x, self.y, self.style)
target = self._alt if on else self._main
if target is not self.screen:
if on:
self._alt = self._fresh_screen()
target = self._alt
self.screen = target
self._dirty.update(range(self.rows))
if mode == 1049 and not on:
self._restore()
# --- SGR ------------------------------------------------------------------------------
def _sgr(self, params: list[str]) -> None:
if not params:
params = ["0"]
# Flatten "38:2::r:g:b" (colon sub-parameters) into the sequence of the ";" form.
codes: list[int] = []
for part in params:
pieces = part.split(":")
if len(pieces) > 1 and pieces[0] in ("38", "48", "58"):
head, rest = pieces[0], pieces[1:]
if rest[:1] == ["2"] and len(rest) == 5:
rest = ["2", *rest[2:]] # remove the colour-space id
codes.extend(int(p) if p.isdigit() else 0 for p in (head, *rest))
else:
codes.append(int(pieces[0]) if pieces[0].isdigit() else 0)
fg, bg, flags = self.style.fg, self.style.bg, self.style.flags
i = 0
while i < len(codes):
code = codes[i]
if code == 0:
fg, bg, flags = None, None, 0
elif code == 1:
flags |= BOLD
elif code == 2:
flags |= DIM
elif code == 3:
flags |= ITALIC
elif code in (4, 21):
flags |= UNDERLINE
elif code == 7:
flags |= REVERSE
elif code == 8:
flags |= HIDDEN
elif code == 9:
flags |= STRIKE
elif code == 22:
flags &= ~(BOLD | DIM)
elif code == 23:
flags &= ~ITALIC
elif code == 24:
flags &= ~UNDERLINE
elif code == 27:
flags &= ~REVERSE
elif code == 28:
flags &= ~HIDDEN
elif code == 29:
flags &= ~STRIKE
elif 30 <= code <= 37:
fg = self.palette[code - 30]
elif 40 <= code <= 47:
bg = self.palette[code - 40]
elif 90 <= code <= 97:
fg = self.palette[code - 90 + 8]
elif 100 <= code <= 107:
bg = self.palette[code - 100 + 8]
elif code == 39:
fg = None
elif code == 49:
bg = None
elif code in (38, 48, 58):
colour, used = self._extended(codes, i + 1)
if code == 38:
fg = colour
elif code == 48:
bg = colour
i += used
i += 1
self.style = Style(fg, bg, flags)
def _extended(self, codes: list[int], start: int) -> tuple[RGB | None, int]:
"""Read an extended colour at ``codes[start:]``: ``(colour, codes consumed)``."""
kind = codes[start] if start < len(codes) else None
if kind == 5 and start + 1 < len(codes):
return _xterm_256(min(255, codes[start + 1]), self.palette), 2
if kind == 2 and start + 3 < len(codes):
r, g, b = (min(255, c) for c in codes[start + 1 : start + 4])
return (r, g, b), 4
return None, len(codes) - start