Redraw cleanly when the window is resized
Resizing the terminal during a scan left the screen full of wreckage: box corners in the middle of a line, borders twice the width of the window, a "receiver" header printed eight times down the left edge. Four separate defects, which is why it looked so bad. Live rendering works by moving the cursor back over the frame it drew last time and overwriting it. That is only correct while the frame is still where it was put, and none of these programs noticed when it was not. 1. Nothing detected a resize. Both the scan display and saunterbrowse now compare the console size on every frame and clear the screen when it changes -- polled rather than handled as a signal, because the display is redrawn several times a second anyway and a signal handler that runs in the middle of a write has to be right about far more than this does. Anything printed before the scan started scrolls away at that point, which the manual now says. 2. The layout's model of its own height was wrong, in two places that cancelled. The sweep panel was counted as one line shorter than it is, the hit list as one line taller. The sum came out right whenever both were drawn and wrong on a terminal too short for the hit list -- where the frame then overflowed by one line on every refresh and the top of it marched down the screen. That is what the eight headers were. Each panel height is a named constant now, and a test checks every one of them against what is actually rendered. 3. Lines inside the panels could wrap. A band name, a long status line or a decoded message made a panel a row taller than the arithmetic allowed for, with the same result. Every one is drawn on a single line and ellipsised now. The receiver panel drops its optional parts instead, keeping the tuner and the flags: "SIMULATED" disappearing off the end of a narrow line is how somebody comes to believe they are listening to the air. 4. saunterbrowse's full-screen views did not fill the screen. Nothing erases the alternate screen between frames -- the cursor is sent home and the new frame written over the old one -- so pressing t or ? on a tall window left most of the recording list visible underneath. Both are wrapped in a layout now, which fills the terminal exactly. The layout also gives up the receiver panel on a very short terminal, which it previously had no way to do: on eight rows the smallest frame it could describe was nine lines. Testing this by rendering to a wide Console and reading the text back cannot work -- whether the cursor lands where it should is a property of the terminal, not of the renderable. So tests/terminal.py runs the program in a pty, resizes the window underneath it the way a window manager does, and feeds what it writes to a terminal emulator whose screen is then read. Every fix above has a test that fails without it, checked by reverting each one in turn. pyte is a dev dependency and those tests skip without it; the arithmetic ones need nothing. Also: t now opens the reader for a capture that carries decoded data rather than speech, because the decoded panel already told the reader to press it. 869 -> 949 tests.
This commit is contained in:
parent
68b05a031c
commit
d6ae6d0c22
12 changed files with 625 additions and 46 deletions
116
tests/terminal.py
Normal file
116
tests/terminal.py
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
"""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
|
||||
Loading…
Add table
Add a link
Reference in a new issue