mirror of
https://github.com/tmux/tmux.git
synced 2026-10-15 16:04:34 +00:00
191 lines
7.7 KiB
Python
191 lines
7.7 KiB
Python
"""Observe cursor positions, colours and erases in tmux's terminal output."""
|
|
|
|
import re
|
|
import unicodedata
|
|
from image_test import decode_sixel
|
|
|
|
|
|
class Terminal:
|
|
"""Track image pixels and text cells for the controls used by these tests."""
|
|
|
|
def __init__(self, sx=40, sy=16, cw=8, ch=16):
|
|
self.sx, self.sy, self.cw, self.ch = sx, sy, cw, ch
|
|
self.x = self.y = 0
|
|
self.top, self.bottom = 0, sy - 1
|
|
self.fg = self.bg = None
|
|
self.bold = False
|
|
self.pixels = {}
|
|
self.cells = {}
|
|
self.pending = b''
|
|
|
|
def erase(self, x, y, w, h):
|
|
"""Remove image pixels and text in a rectangle of cells."""
|
|
for px, py in list(self.pixels):
|
|
if x * self.cw <= px < (x + w) * self.cw and \
|
|
y * self.ch <= py < (y + h) * self.ch:
|
|
del self.pixels[px, py]
|
|
for cy in range(y, y + h):
|
|
for cx in range(x, x + w):
|
|
self.cells[cx, cy] = (' ', self.fg, self.bg, self.bold)
|
|
|
|
def sgr(self, args):
|
|
"""Apply text colours and bold attributes."""
|
|
i = 0
|
|
while i < len(args):
|
|
value = args[i]
|
|
i += 1
|
|
if value == 0:
|
|
self.fg = self.bg = None
|
|
self.bold = False
|
|
elif value in (1, 22):
|
|
self.bold = value == 1
|
|
elif value in (39, 49):
|
|
setattr(self, 'fg' if value == 39 else 'bg', None)
|
|
elif 30 <= value <= 37 or 40 <= value <= 47:
|
|
setattr(self, 'fg' if value < 40 else 'bg', value % 10)
|
|
elif 90 <= value <= 97 or 100 <= value <= 107:
|
|
setattr(self, 'fg' if value < 100 else 'bg', value % 10 + 8)
|
|
elif value in (38, 48):
|
|
mode = args[i]
|
|
i += 1
|
|
assert mode in (2, 5), args
|
|
n = 3 if mode == 2 else 1
|
|
colour = tuple(args[i:i + n]) if n == 3 else args[i]
|
|
setattr(self, 'fg' if value == 38 else 'bg', colour)
|
|
i += n
|
|
|
|
def scroll(self, top, bottom, n):
|
|
"""Move pixels and text within a vertical scroll region."""
|
|
moved = {}
|
|
for (x, y), colour in list(self.pixels.items()):
|
|
if top * self.ch <= y < (bottom + 1) * self.ch:
|
|
del self.pixels[x, y]
|
|
if top * self.ch <= y - n * self.ch < (bottom + 1) * self.ch:
|
|
moved[x, y - n * self.ch] = colour
|
|
cells = {(x, y - n): cell for (x, y), cell in self.cells.items()
|
|
if top <= y <= bottom and top <= y - n <= bottom}
|
|
self.erase(0, top, self.sx, bottom - top + 1)
|
|
self.pixels.update(moved)
|
|
self.cells.update(cells)
|
|
|
|
def csi(self, params, command):
|
|
"""Apply cursor movement and erases; ignore terminal mode controls."""
|
|
if params.startswith((b'?', b'>', b'=')) or b' ' in params:
|
|
return
|
|
args = [int(p or b'0') for p in params.split(b';')]
|
|
n = args[0] or 1
|
|
if command in b'Hf':
|
|
self.y = n - 1
|
|
self.x = (args[1] or 1) - 1 if len(args) > 1 else 0
|
|
elif command == ord('G'):
|
|
self.x = n - 1
|
|
elif command == ord('d'):
|
|
self.y = n - 1
|
|
elif command in b'ABCD':
|
|
dx, dy = {65: (0, -n), 66: (0, n),
|
|
67: (n, 0), 68: (-n, 0)}[command]
|
|
self.x = max(0, self.x + dx)
|
|
self.y = max(0, self.y + dy)
|
|
elif command == ord('X'):
|
|
self.erase(self.x, self.y, n, 1)
|
|
elif command == ord('K'):
|
|
start = self.x if args[0] == 0 else 0
|
|
end = self.x + 1 if args[0] == 1 else self.sx
|
|
self.erase(start, self.y, end - start, 1)
|
|
elif command == ord('J'):
|
|
if args[0] in (2, 3):
|
|
self.erase(0, 0, self.sx, self.sy)
|
|
elif args[0] == 0:
|
|
self.erase(self.x, self.y, self.sx - self.x, 1)
|
|
self.erase(0, self.y + 1, self.sx, self.sy - self.y - 1)
|
|
elif args[0] == 1:
|
|
self.erase(0, 0, self.sx, self.y)
|
|
self.erase(0, self.y, self.x + 1, 1)
|
|
elif command == ord('m'):
|
|
self.sgr(args)
|
|
elif command == ord('r'):
|
|
self.top = n - 1
|
|
self.bottom = (args[1] or self.sy) - 1 if len(args) > 1 else self.sy - 1
|
|
self.x = self.y = 0
|
|
elif command in b'STLM':
|
|
top = self.y if command in b'LM' else self.top
|
|
self.scroll(top, self.bottom, n if command in b'SM' else -n)
|
|
else:
|
|
assert command in b'hlcqnt', (params, chr(command))
|
|
|
|
def feed(self, data):
|
|
"""Apply complete output sequences, keeping any unfinished sequence."""
|
|
data = self.pending + bytes(data)
|
|
pos = 0
|
|
while pos < len(data):
|
|
rest = data[pos:]
|
|
if rest.startswith((b'\033P', b'\033]')):
|
|
match = re.match(rb'\033([P\]])(.*?)(?:\007|\033\\)',
|
|
rest, re.S)
|
|
if match is None:
|
|
break
|
|
if match[1] == b'P':
|
|
image = re.match(rb'[0-9;]*q(.*)', match[2], re.S)
|
|
if image is not None:
|
|
w, h, pixels = decode_sixel(image[1])
|
|
for i, colour in enumerate(pixels):
|
|
if colour is not None:
|
|
self.pixels[self.x * self.cw + i % w,
|
|
self.y * self.ch + i // w] = colour
|
|
pos += match.end()
|
|
elif rest.startswith(b'\033['):
|
|
match = re.match(rb'\033\[([0-?]*[ -/]*)([@-~])', rest)
|
|
if match is None:
|
|
break
|
|
self.csi(match[1], match[2][0])
|
|
pos += match.end()
|
|
elif rest[0] == 27:
|
|
match = re.match(rb'\033(?:[()][0-~]|.)', rest, re.S)
|
|
if match is None:
|
|
break
|
|
assert match[0] in (b'\033(B', b'\033(0', b'\033)B',
|
|
b'\033=', b'\033>', b'\033\\'), match[0]
|
|
pos += match.end()
|
|
elif rest[0] < 32 or rest[0] == 127:
|
|
if rest[0] == 13:
|
|
self.x = 0
|
|
elif rest[0] == 10:
|
|
if self.y == self.bottom:
|
|
self.scroll(self.top, self.bottom, 1)
|
|
else:
|
|
self.y += 1
|
|
elif rest[0] == 8:
|
|
self.x = max(0, self.x - 1)
|
|
pos += 1
|
|
else:
|
|
if rest[0] < 128:
|
|
n = 1
|
|
elif rest[0] < 224:
|
|
n = 2
|
|
elif rest[0] < 240:
|
|
n = 3
|
|
else:
|
|
n = 4
|
|
if len(rest) < n:
|
|
break
|
|
char = rest[:n].decode()
|
|
width = 2 if unicodedata.east_asian_width(char) in 'WF' else 1
|
|
if self.x >= self.sx:
|
|
self.x = 0
|
|
self.y += 1
|
|
self.erase(self.x, self.y, width, 1)
|
|
self.cells[self.x, self.y] = (char, self.fg, self.bg, self.bold)
|
|
self.x += width
|
|
pos += n
|
|
self.pending = data[pos:]
|
|
|
|
def check(self, expected):
|
|
"""Compare all visible image pixels, including their positions."""
|
|
assert self.pixels.keys() == expected.keys(), (
|
|
'pixel positions differ', len(self.pixels), len(expected),
|
|
list(self.pixels.keys() - expected.keys())[:5],
|
|
list(expected.keys() - self.pixels.keys())[:5])
|
|
for point, colour in expected.items():
|
|
assert max(abs(a - b) for a, b in zip(self.pixels[point], colour)) \
|
|
<= 3, (point, self.pixels[point], colour)
|