"""A real terminal, for tests that have to see what a resize leaves behind. Rendering to a wide `Console` and reading the text back cannot show this class of bug at all. Live rendering works by moving the cursor over the frame it drew last time and overwriting it, and whether that lands where it should is a property of the terminal, not of the renderable. So the program is run in a pty, the window is resized underneath it the way a window manager would, and the bytes it writes are fed to a terminal emulator whose screen is then read. """ import codecs import fcntl import os import pty import select import signal import struct import termios import time class Terminal: """One program running in a pty, with a screen that can be read back.""" def __init__(self, argv, cols=100, rows=30, env=None): import pyte self.cols, self.rows = cols, rows self.screen = pyte.Screen(cols, rows) self.stream = pyte.Stream(self.screen) # Incremental, because a read can land in the middle of a multi-byte # character: decoding each chunk on its own turns the second half of a # box-drawing character into a replacement character, which then looks # exactly like the debris these tests are here to find. self._decoder = codecs.getincrementaldecoder("utf8")("replace") self.pid, self.fd = pty.fork() if self.pid == 0: # pragma: no cover -- the child child = dict(os.environ, TERM="xterm-256color", PYTHONUNBUFFERED="1") # rich reads COLUMNS and LINES in preference to asking the # terminal, so leaving them set would make every resize invisible # and every one of these tests pass for the wrong reason. child.pop("COLUMNS", None) child.pop("LINES", None) os.execvpe(argv[0], argv, child) self._set_size(cols, rows) def _set_size(self, cols: int, rows: int) -> None: fcntl.ioctl(self.fd, termios.TIOCSWINSZ, struct.pack("HHHH", rows, cols, 0, 0)) def resize(self, cols: int, rows: int) -> None: """Resize the window, exactly as a window manager does.""" self.cols, self.rows = cols, rows self._set_size(cols, rows) os.kill(self.pid, signal.SIGWINCH) self.screen.resize(rows, cols) def pump(self, seconds: float = 1.0) -> None: """Read whatever the program writes for a while.""" end = time.time() + seconds while time.time() < end: ready, _, _ = select.select([self.fd], [], [], 0.05) if not ready: continue try: data = os.read(self.fd, 65536) except OSError: break if not data: break self.stream.feed(self._decoder.decode(data)) def send(self, text: str) -> None: os.write(self.fd, text.encode()) def display(self) -> list[str]: return [line.rstrip() for line in self.screen.display] def text(self) -> str: return "\n".join(self.display()) def close(self) -> None: for action in (lambda: os.kill(self.pid, signal.SIGKILL), lambda: os.close(self.fd), lambda: os.waitpid(self.pid, 0)): try: action() except OSError: pass def __enter__(self): return self def __exit__(self, *exc): self.close() return False def stray_rows(lines: list[str], width: int) -> list[str]: """Rows that belong to a frame drawn for some other window size. A panel drawn at the current width starts and ends at the edges of the screen. A row carrying box-drawing characters that does neither is a piece of a frame drawn when the window was a different shape -- the debris a resize leaves when nothing erases it. """ out = [] for line in lines: stripped = line.strip() if not stripped or not any(c in line for c in "╭╮╰╯│─"): continue starts = stripped[0] in "╭╰│─" ends = stripped[-1] in "╮╯│─" if not (starts and ends) or len(line) > width: out.append(line) return out