A night's scan leaves hundreds of files, most worth nothing and a few of them the reason it was left running. Sorting that out meant leaving the browser and going at the directory with mv and rm. Five keys, meant to be pressed once each going down the list: S I N file it into saved/, investigate/ or noise/ u put the last one filed back d delete it and its sidecars, for good -- asks first m lock the frequency out, so no later scan stops on it Each of these acts on the whole capture -- the .wav, the JSON sidecar, the IQ, the transcript and the decoded data -- because a recording in one directory and its transcript in another is a pair nothing will ever put back together. A move that cannot be finished puts back whatever already moved. The cursor stays on the row it was on, which is now the next recording, since a cursor that jumped would make one-key-per-recording impossible. m writes to the lock-out list in the settings file, the same one the scanner's own l key maintains, so a birdie found while reading last night's recordings is gone from tonight's. It says "the next scan": one already running read its settings when it started. The subdirectories sit under the recordings directory, so a scan writing there never looks in them, and saunterbrowse ~/bandsaunter/saved reads one back. Also here, because this is the first part of the browser that writes: - The help screen is back inside eighty by twenty-four. It had grown past the bottom of an ordinary window, which puts "q quit" off the screen. - The footer drops keys in a deliberate order when the window is narrow, rather than ellipsising whichever happened to be at the end. - Moving or deleting what is playing stops the player first. - The pty harness accepted an env and ignored it, so a test aimed at a throwaway settings directory wrote to the real one. It honours it now, and conftest redirects the settings directory for every test besides. 1014 tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016PsWPTweCT6pwxKngvVxcg
122 lines
4.5 KiB
Python
122 lines
4.5 KiB
Python
"""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):
|
|
"""``env`` is added to the child's environment, not substituted for it.
|
|
|
|
It was accepted and silently ignored until a test that pointed the
|
|
program at a throwaway settings directory wrote to the real one
|
|
instead.
|
|
"""
|
|
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", **(env or {}))
|
|
# 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
|