"""Helpers for image tests using an attached client on a PTY.""" import contextlib import colorsys import errno import fcntl import itertools import os import re import select import signal import struct import subprocess import sys import termios import time class Client: """An attached client and the bytes written to its terminal.""" def __init__(self, pid, fd): self.pid = pid self.fd = fd self.data = bytearray() def read(self, duration=0.1): """Read terminal output for a bounded time.""" end = time.monotonic() + duration while time.monotonic() < end: if not select.select([self.fd], [], [], 0.01)[0]: continue try: data = os.read(self.fd, 65536) except OSError as e: if e.errno == errno.EIO: raise RuntimeError("attached client exited") from e raise if not data: raise RuntimeError("attached client exited") self.data.extend(data) def clear(self): """Finish reading earlier output before starting a new check.""" self.read() self.data.clear() def images(self): """Decode complete SIXEL sequences from the terminal output.""" images = [] for params, body in re.findall( rb"\033P([0-9;]*)q(.*?)\033\\", self.data, re.S): params = params.split(b";") p2 = int(params[1] or b"0") if len(params) > 1 else 0 images.append(decode_sixel(body, p2)) return images class ImageTest: """A server with one pane which copies input directly to its output.""" serial = itertools.count() def __init__(self, tmux, sx=40, sy=16): # Short, unique socket labels also fit macOS's socket path limit. self.server = [tmux, "-Lis%d-%d" % (os.getpid(), next(self.serial)), "-f/dev/null"] self.sx = sx self.sy = sy self.clients = [] self.marker = 0 def run(self, *args, check=True, input=None): """Run a command on this server.""" p = subprocess.run(self.server + list(args), capture_output=True, input=input, timeout=10) if check and p.returncode != 0: raise RuntimeError("%r: %s" % (args, p.stderr.decode())) return p.stdout.decode().strip() def wait(self, predicate): """Read client output while waiting for a server state change.""" end = time.monotonic() + 10 while True: for client in self.clients: client.read(0.01) if predicate(): return if time.monotonic() >= end: raise RuntimeError("timed out waiting for server state") time.sleep(0.01) def start(self): """Start the pane and skip when the server has no image support.""" self.run("new-session", "-d", "-s", "image", "-x", str(self.sx), "-y", str(self.sy), "stty raw -echo; " "printf '\033]2;READY\007'; exec cat") support = self.run("display-message", "-p", "#{image_support}") if support == "0": sys.exit(101) assert support == "1", ("unexpected image_support", support) self.run("set-option", "-g", "status", "off") self.wait(lambda: self.run("display-message", "-p", "#{pane_title}") == "READY") def attach(self, graphical=True, utf8=True, cw=8, ch=16, term=None): """Attach a client with a known cell pixel size and image feature.""" if term is None: term = "xterm-256color" if graphical else "screen-256color" features = term + (":sixel" if graphical else ":sixel@") self.run("set-option", "-as", "terminal-features", features) pid, fd = os.forkpty() if pid == 0: try: os.environ.pop("TMUX", None) os.environ["TERM"] = term os.environ["LC_ALL"] = "C.UTF-8" if utf8 else "C" fcntl.ioctl(0, termios.TIOCSWINSZ, struct.pack("HHHH", self.sy, self.sx, self.sx * cw, self.sy * ch)) args = self.server + (["-u"] if utf8 else []) os.execv(self.server[0], args + ["attach-session", "-t", "image"]) finally: os._exit(1) client = Client(pid, fd) self.clients.append(client) self.wait(lambda: any(line.startswith(str(pid) + ":") for line in self.run("list-clients", "-F", "#{client_pid}:").splitlines())) # Answer DA so the startup timeout cannot schedule a later redraw. os.write(fd, b"\033[?65;4c" if graphical else b"\033[?65c") self.wait(lambda: self.client_size(client) == "%dx%d:%dx%d" % (self.sx, self.sy, cw, ch)) if len(self.clients) == 1 and cw != 0 and ch != 0: self.wait(lambda: self.run("display-message", "-p", "#{window_cell_width}x#{window_cell_height}") == "%dx%d" % (cw, ch)) client.name = next(line.split(":", 1)[1] for line in self.run("list-clients", "-F", "#{client_pid}:#{client_name}").splitlines() if line.startswith(str(pid) + ":")) client.read(0.2) client.clear() return client def client_size(self, client): """Get the character and cell pixel size of one client.""" prefix = str(client.pid) + ":" return next((line[len(prefix):] for line in self.run("list-clients", "-F", "#{client_pid}:#{client_width}x#{client_height}:" "#{client_cell_width}x#{client_cell_height}").splitlines() if line.startswith(prefix)), None) def send(self, data): """Send literal bytes to the pane.""" if len(data) > 512: self.run("load-buffer", "-b", "image", "-", input=data) self.run("paste-buffer", "-b", "image", "-S", "-r", "-d") else: self.run("send-keys", "-H", *["%02x" % ch for ch in data]) def emit(self, data): """Send pane output and wait until its trailing title is parsed.""" for client in self.clients: client.clear() self.marker += 1 title = "image-%d" % self.marker self.send(data + b"\033]2;" + title.encode() + b"\007") self.wait(lambda: self.run("display-message", "-p", "#{pane_title}") == title) for client in self.clients: client.read(0.2) def redraw(self): """Request a full redraw and collect its terminal output.""" for client in self.clients: client.clear() for client in self.clients: self.run("refresh-client", "-t", client.name) for client in self.clients: client.read(0.2) def resize(self, client, sx, sy, cw=8, ch=16): """Resize one client's terminal and wait for the window size.""" client.clear() fcntl.ioctl(client.fd, termios.TIOCSWINSZ, struct.pack("HHHH", sy, sx, sx * cw, sy * ch)) os.kill(client.pid, signal.SIGWINCH) self.wait(lambda: self.client_size(client) == "%dx%d:%dx%d" % (sx, sy, cw, ch)) self.wait(lambda: self.run("display-message", "-p", "#{window_width}x#{window_height}") == "%dx%d" % (sx, sy)) client.read(0.2) def close(self): """Stop the server and reap every attached client.""" self.run("kill-server", check=False) for client in self.clients: os.close(client.fd) if os.waitpid(client.pid, os.WNOHANG) == (0, 0): os.kill(client.pid, signal.SIGTERM) os.waitpid(client.pid, 0) @contextlib.contextmanager def image_test(tmux, sx=40, sy=16): """Clean up the server even when a test fails or skips.""" test = ImageTest(tmux, sx, sy) try: test.start() yield test finally: test.close() def sixel(w, h, palette, pixels, p2=1): """Build SIXEL input from a palette and row-major palette indexes.""" assert len(pixels) == w * h out = ['\033P9;%dq"1;1;%d;%d' % (p2, w, h)] for c, rgb in enumerate(palette): out.append("#%d;2;%d;%d;%d" % (c, *rgb)) for y in range(0, h, 6): planes = [] for c in range(len(palette)): columns = [] for x in range(w): bits = sum(1 << bit for bit in range(min(6, h - y)) if pixels[(y + bit) * w + x] == c) columns.append(chr(0x3f + bits)) if all(ch == "?" for ch in columns): continue plane = "#%d" % c for ch, run in itertools.groupby(columns): n = len(list(run)) plane += "!%d%s" % (n, ch) if n > 3 else ch * n planes.append(plane) out.append("$".join(planes)) if y + 6 < h: out.append("-") out.append("\033\\") return "".join(out).encode() def decode_sixel(body, p2=1): """Read RGB output pixels without using tmux's SIXEL decoder.""" attributes = re.match(rb'"1;1;(\d+);(\d+)', body) assert attributes is not None, ("missing raster attributes", body[:80]) w, h = map(int, attributes.groups()) pixels = [None if p2 == 1 else (0, 0, 0)] * (w * h) palette = {} c = x = y = 0 pos = attributes.end() while pos < len(body): ch = body[pos] pos += 1 if ch == ord("#"): match = re.match(rb"(\d+)(?:;([12]);(\d+);(\d+);(\d+))?", body[pos:]) assert match is not None, body[pos:pos + 80] c = int(match[1]) if match[2] is not None: values = tuple(int(match[i]) for i in range(3, 6)) if match[2] == b"1": hue, lightness, saturation = values rgb = colorsys.hls_to_rgb(((hue + 240) % 360) / 360, lightness / 100, saturation / 100) palette[c] = tuple(int(v * 255 + .5) for v in rgb) else: assert all(v <= 100 for v in values), values palette[c] = tuple((v * 255 + 50) // 100 for v in values) pos += match.end() continue if ch == ord("$"): x = 0 continue if ch == ord("-"): x = 0 y += 6 continue n = 1 if ch == ord("!"): match = re.match(rb"(\d+)(.)", body[pos:], re.S) assert match is not None n = int(match[1]) ch = match[2][0] pos += match.end() assert 0x3f <= ch <= 0x7e, ch for bit in range(6): if not (ch - 0x3f) & (1 << bit): continue assert y + bit < h and x + n <= w, (x, y, n, bit, w, h) assert c in palette, ("undefined output colour", c) for xx in range(x, x + n): pixels[(y + bit) * w + xx] = palette[c] x += n return w, h, pixels def check_image(image, w, h, expected): """Compare decoded output with expected RGB pixels and transparency.""" assert image[:2] == (w, h), (image[:2], (w, h)) assert len(expected) == w * h for i, (actual, wanted) in enumerate(zip(image[2], expected)): if wanted is None: assert actual is None, (i % w, i // w, actual, wanted) else: assert actual is not None, (i % w, i // w, actual, wanted) assert max(abs(a - b) for a, b in zip(actual, wanted)) <= 3, ( i % w, i // w, actual, wanted)