122 lines
5.2 KiB
Python
122 lines
5.2 KiB
Python
"""Small bounded VT-like ASCII/UTF-8 text screen for staging.
|
|
|
|
This is deliberately NOT advertised as a full xterm emulator. It handles common
|
|
CSI cursor/erase controls and suppresses OSC/DCS/other escape payloads.
|
|
"""
|
|
from __future__ import annotations
|
|
import codecs
|
|
import unicodedata
|
|
|
|
class Screen:
|
|
def __init__(self, rows: int = 24, cols: int = 80):
|
|
if not 8 <= rows <= 80 or not 20 <= cols <= 200:
|
|
raise ValueError('SCREEN_DIMENSIONS_INVALID')
|
|
self.rows, self.cols = rows, cols
|
|
self.buf = [[' '] * cols for _ in range(rows)]
|
|
self.y = self.x = 0
|
|
self.mode = 'normal'
|
|
self.control = ''
|
|
self.decoder = codecs.getincrementaldecoder('utf-8')('replace')
|
|
self.max_control = 96
|
|
|
|
def reset(self):
|
|
self.buf = [[' '] * self.cols for _ in range(self.rows)]
|
|
self.y = self.x = 0
|
|
|
|
def resize(self, rows: int, cols: int):
|
|
if not 8 <= rows <= 80 or not 20 <= cols <= 200:
|
|
raise ValueError('SCREEN_DIMENSIONS_INVALID')
|
|
new = [[' '] * cols for _ in range(rows)]
|
|
for y in range(min(rows, self.rows)):
|
|
for x in range(min(cols, self.cols)):
|
|
new[y][x] = self.buf[y][x]
|
|
self.rows, self.cols, self.buf = rows, cols, new
|
|
self.y = min(self.y, rows - 1)
|
|
self.x = min(self.x, cols - 1)
|
|
|
|
def scroll(self):
|
|
self.buf.pop(0)
|
|
self.buf.append([' '] * self.cols)
|
|
self.y = self.rows - 1
|
|
|
|
def newline(self):
|
|
self.y += 1
|
|
if self.y >= self.rows:
|
|
self.scroll()
|
|
|
|
def put(self, c: str):
|
|
if unicodedata.combining(c):
|
|
return
|
|
# Avoid unmodeled full-width terminal-cell ambiguity in this R1 pilot.
|
|
if unicodedata.east_asian_width(c) in ('W', 'F'):
|
|
c = '?'
|
|
if self.x >= self.cols:
|
|
self.x = 0
|
|
self.newline()
|
|
self.buf[self.y][self.x] = c
|
|
self.x += 1
|
|
|
|
def csi(self, final: str, args: str):
|
|
if len(args) > self.max_control:
|
|
return
|
|
if args.startswith('?'):
|
|
if final in ('h', 'l') and args in ('?1049', '?1047', '?47') and final == 'h':
|
|
self.reset()
|
|
return
|
|
nums = [int(a) if a.isdecimal() else 0 for a in args.split(';')[:8]]
|
|
n = nums[0] if nums else 0
|
|
count = max(1, min(n, 200))
|
|
if final == 'A': self.y = max(0, self.y - count)
|
|
elif final == 'B': self.y = min(self.rows - 1, self.y + count)
|
|
elif final == 'C': self.x = min(self.cols - 1, self.x + count)
|
|
elif final == 'D': self.x = max(0, self.x - count)
|
|
elif final in ('H', 'f'):
|
|
self.y = max(0, min(self.rows - 1, (nums[0] if nums and nums[0] else 1) - 1))
|
|
self.x = max(0, min(self.cols - 1, (nums[1] if len(nums) > 1 and nums[1] else 1) - 1))
|
|
elif final == 'G': self.x = max(0, min(self.cols - 1, count - 1))
|
|
elif final == 'd': self.y = max(0, min(self.rows - 1, count - 1))
|
|
elif final == 'J':
|
|
if n == 2: self.reset()
|
|
elif n == 0:
|
|
for y in range(self.y, self.rows):
|
|
start = self.x if y == self.y else 0
|
|
for x in range(start, self.cols): self.buf[y][x] = ' '
|
|
elif final == 'K':
|
|
start, end = (0, self.cols) if n == 2 else ((0, self.x + 1) if n == 1 else (self.x, self.cols))
|
|
for x in range(start, min(end, self.cols)): self.buf[self.y][x] = ' '
|
|
# SGR and unrecognized commands are intentionally ignored.
|
|
|
|
def feed(self, data: bytes):
|
|
for c in self.decoder.decode(data):
|
|
if self.mode == 'normal':
|
|
if c == '\x1b': self.mode = 'esc'
|
|
elif c == '\r': self.x = 0
|
|
elif c == '\n': self.newline()
|
|
elif c == '\b': self.x = max(0, self.x - 1)
|
|
elif c == '\t': self.x = min(self.cols - 1, (self.x // 8 + 1) * 8)
|
|
elif c.isprintable() and c not in '\x7f': self.put(c)
|
|
elif self.mode == 'esc':
|
|
if c == '[': self.mode, self.control = 'csi', ''
|
|
elif c == ']': self.mode, self.control = 'osc', ''
|
|
elif c in ('P', '^', '_'): self.mode, self.control = 'str', ''
|
|
elif c == 'c': self.reset(); self.mode = 'normal'
|
|
else: self.mode = 'normal'
|
|
elif self.mode == 'csi':
|
|
if '@' <= c <= '~':
|
|
self.csi(c, self.control); self.mode = 'normal'; self.control = ''
|
|
elif len(self.control) >= self.max_control: self.mode, self.control = 'discard_csi', ''
|
|
else: self.control += c
|
|
elif self.mode == 'discard_csi':
|
|
if '@' <= c <= '~': self.mode = 'normal'
|
|
elif self.mode in ('osc', 'str'):
|
|
if c == '\x07' and self.mode == 'osc': self.mode = 'normal'
|
|
elif c == '\x1b': self.mode = 'string_esc'
|
|
elif self.mode == 'string_esc':
|
|
if c == '\\': self.mode = 'normal'
|
|
elif c == '\x1b': self.mode = 'string_esc'
|
|
else: self.mode = 'str'
|
|
|
|
def snapshot(self):
|
|
return {'rows': self.rows, 'cols': self.cols, 'cursor': {'row': self.y, 'col': self.x},
|
|
'lines': [''.join(row).rstrip() for row in self.buf]}
|