diff --git a/README.md b/README.md index 10f5737..001c7d3 100644 --- a/README.md +++ b/README.md @@ -1020,6 +1020,22 @@ bandsaunter analyze recordings/2026-08-19/.../iq.cf32 # identify bandsaunter analyze recordings/2026-08-19/.../audio.wav # decode CW ``` +### Fitting the window + +The display is redrawn in place several times a second, which only works while +the frame is exactly where it was last drawn. Two things follow. + +On a short terminal the optional parts are given up in order — the spectrum +row, then the list of recorded signals, then the key hints, and last of all the +receiver panel, which says nothing that changes. Never given up: the sweep line +and, while one is running, the recording. + +Resizing the window redraws everything from a blank screen. The frame that was +on it was drawn for a window that no longer exists — and the terminal has +already reflowed everything above it — so anything printed before the scan +started scrolls away at that point. `--plain` prints one line per hit and needs +none of this, which is what to use over a pipe or into a log. + ## Live controls The display sizes itself to the terminal, giving up the spectrum row, then the diff --git a/bandsaunter/__init__.py b/bandsaunter/__init__.py index 31e588e..49dab82 100755 --- a/bandsaunter/__init__.py +++ b/bandsaunter/__init__.py @@ -9,7 +9,7 @@ and transcribing speech. # 2026-08-21_02 is the second build made on the 21st. The revision is padded # to two digits so versions sort as text. VERSION_DATE = "2026-08-28" -VERSION_REVISION = 2 +VERSION_REVISION = 3 __version__ = f"{VERSION_DATE}_{VERSION_REVISION:02d}" diff --git a/bandsaunter/browse.py b/bandsaunter/browse.py index 384f019..c369191 100644 --- a/bandsaunter/browse.py +++ b/bandsaunter/browse.py @@ -501,6 +501,7 @@ class Browser: self.show_help = False self.reading = False # full-screen transcript self.read_top = 0 + self._size = self.console.size self.reload() # -- state ------------------------------------------------------------ @@ -950,10 +951,16 @@ class Browser: border_style="green", padding=(1, 4)) def render(self): + # Every screen is wrapped in a layout, which fills the terminal + # exactly. Nothing erases the alternate screen between frames -- the + # cursor is sent home and the new frame written over the old one -- so + # a frame shorter than the screen leaves the tail of the last one + # visible below it. Pressing t on a full window used to leave most of + # the recording list under the reader. if self.show_help: - return Align.center(self._help(), vertical="middle") + return Layout(Align.center(self._help(), vertical="middle")) if self.reading: - return self._reader() + return Layout(self._reader()) layout = Layout() # One height, computed once: the panel and the region it sits in have # to agree, or the difference shows up as a gap in the middle of the @@ -1023,11 +1030,14 @@ class Browser: self.show_help = not self.show_help elif key == "t": cap = self.current - if cap is not None and cap.transcript: + # Either kind of content: the decoded panel says "press t to read + # it all" when a long paging capture overflows it, and the key has + # to mean what the panel says it means. + if cap is not None and (cap.transcript or cap.decoded): self.reading = True self.read_top = 0 else: - self.message = "no transcript for this recording" + self.message = "nothing to read for this recording" elif key == "o": cap = self.current if cap is not None: @@ -1081,6 +1091,20 @@ class Browser: if self.player.error: self.message = self.player.error + def resized(self) -> bool: + """True once each time the terminal has changed size. + + Polled rather than handled as a signal: the screen is redrawn on + every key and on every timeout anyway, and a signal handler that runs + in the middle of a write would have to be right about far more than + this does. + """ + size = self.console.size + if size == self._size: + return False + self._size = size + return True + # -- main loop -------------------------------------------------------- def run(self) -> int: with Keyboard() as keys, Live(self.render(), console=self.console, @@ -1090,6 +1114,10 @@ class Browser: key = keys.get(0.15 if self.player.active else 0.4) if not self.handle(key): break + if self.resized(): + # What is on the screen was drawn for a window that no + # longer exists, and nothing here erases before it draws. + self.console.clear() live.update(self.render(), refresh=True) self.player.stop() if self.message: diff --git a/bandsaunter/cli.py b/bandsaunter/cli.py index 720b046..db22da0 100755 --- a/bandsaunter/cli.py +++ b/bandsaunter/cli.py @@ -380,6 +380,12 @@ def _run_live(scanner: Scanner, cfg: ScanConfig) -> int: key = keys.get() if key: _handle_key(key, scanner, display) + if display.resized(): + # Start again from a blank screen. The frame rich is + # about to erase is no longer where it thinks it is, + # and the text above it has been reflowed by the + # terminal in any case. + console.clear() live.update(display.render()) time.sleep(0.1) except KeyboardInterrupt: diff --git a/bandsaunter/ui.py b/bandsaunter/ui.py index 4c4182b..cdb0067 100755 --- a/bandsaunter/ui.py +++ b/bandsaunter/ui.py @@ -29,6 +29,37 @@ _SPARK = " ▁▂▃▄▅▆▇█" # layout to shrink and so fixes it once. _PLAIN_BAND = 20 +# How many terminal lines each panel of the display occupies. The layout has +# to know this exactly: rich moves the cursor back over as many lines as it +# wrote last time, so a model that is one line out overflows the terminal by +# one line on every refresh, and the top of the frame marches down the screen. +# The sweep panel used to be counted as one line shorter than it is, which +# cancelled against the hit list being counted one line taller -- so the sum +# came out right whenever both were drawn, and wrong on a terminal too short +# for the hit list. test_resize.py checks these against what is rendered. +_H_RECEIVER = 3 # one line of text, two borders +_H_SWEEP = 4 # bar and state, two borders +_H_SPECTRUM = 1 # the sparkline row inside the sweep panel +_H_RECORD = 3 +_H_FOOTER = 3 +_H_HITS_CHROME = 3 # two borders and the column headings + + +def _one_line(markup: str) -> Text: + """A line of markup that will never wrap onto a second row. + + Every panel in this display is counted as a fixed number of lines by + :meth:`ScanDisplay._layout`, which decides what fits on the terminal. One + line wrapping makes the whole frame a row taller than that arithmetic + allows for, the frame no longer fits where it was drawn, and each refresh + leaves another copy of the header behind -- which is what a narrow window + used to look like after a few seconds. + """ + text = Text.from_markup(markup) + text.no_wrap = True + text.overflow = "ellipsis" + return text + def _sparkline(values: np.ndarray, width: int = 60, lo: float | None = None, hi: float | None = None) -> str: @@ -121,6 +152,7 @@ class ScanDisplay: self._rec = _RecState() self._last_detection: Detection | None = None self._dirty = True + self._size = self.console.size # -- callbacks -------------------------------------------------------- def attach(self, scanner: Scanner | None = None) -> None: @@ -190,6 +222,26 @@ class ScanDisplay: self._dirty = True # -- rendering --------------------------------------------------------- + def resized(self) -> bool: + """True once each time the terminal has changed size. + + Live rendering works by moving the cursor back over the frame it drew + last time and overwriting it, which is only correct while the frame is + still where it was put. Resizing the window reflows everything on the + screen, so the arithmetic no longer describes anything, and what is + left behind is half of the old frame in pieces. The caller clears the + screen when this returns true; 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. + """ + size = self.console.size + if size == self._size: + return False + self._size = size + self._dirty = True + return True + def _spark_width(self) -> int: return max(20, min(120, self.console.width - 24)) @@ -199,17 +251,25 @@ class ScanDisplay: gain = st.get("gain", "?") gain = f"{gain:.1f} dB" if isinstance(gain, (int, float)) else str(gain) ds = st.get("direct_sampling", 0) - bits = [ - f"[bold]{st.get('tuner', '?')}[/bold]", - f"{st.get('sample_rate', 0)/1e6:.3f} MS/s", - f"gain {gain}", - f"{st.get('ppm', 0):+d} ppm", - ] + # Ordered by what has to survive a narrow window. The flags come + # first among the optional parts because "SIMULATED" disappearing off + # the end of the line is how somebody comes to believe they are + # listening to the air. + head = [f"[bold]{st.get('tuner', '?')}[/bold]"] if ds: - bits.append("[yellow]direct sampling[/yellow]") + head.append("[yellow]direct sampling[/yellow]") if st.get("simulated"): - bits.append("[magenta]SIMULATED[/magenta]") - return Panel(Text.from_markup(" ".join(bits)), + head.append("[magenta]SIMULATED[/magenta]") + optional = [f"{st.get('sample_rate', 0)/1e6:.3f} MS/s", + f"gain {gain}", + f"{st.get('ppm', 0):+d} ppm"] + room = max(10, self.console.width - 4) + bits = head + optional + while len(optional) > 0 and \ + len(Text.from_markup(" ".join(bits)).plain) > room: + optional.pop() + bits = head + optional + return Panel(_one_line(" ".join(bits)), title="receiver", border_style="blue", padding=(0, 1)) def _sweep_panel(self, show_spectrum: bool = True) -> Panel: @@ -220,18 +280,18 @@ class ScanDisplay: bar = "[green]" + "━" * filled + "[/green]" + \ "[grey37]" + "━" * (bar_w - filled) + "[/grey37]" lines = [ - Text.from_markup( + _one_line( f"{bar} step {self._step_i + 1}/{self._n_steps} " f"[bold]{self._span}[/bold]" + (f" [magenta]{self._band}[/magenta]" if self._band else "")), ] if show_spectrum and self.show_spectrum and self._spark: - lines.append(Text.from_markup( + lines.append(_one_line( f"[cyan]{self._spark}[/cyan] peak {self._peak:6.1f} dBFS")) state = s.state colour = {"recording": "red", "sweeping": "green", "paused": "yellow"}.get(state, "white") - lines.append(Text.from_markup( + lines.append(_one_line( f"[{colour}]{state}[/{colour}] cycle {s.cycles + 1} " f"hits {s.recordings} dropped {s.discarded} " f"detections {s.detections} up {_dur(s.elapsed)}")) @@ -264,42 +324,77 @@ class ScanDisplay: # A control channel is not being recorded so much as identified # and abandoned; saying "REC" while that happens is a lie. return Panel( - Text.from_markup( - f"[bold black on yellow] {escape(r.note)} [/bold black on yellow] " + _one_line( + f"[bold black on yellow] {escape(r.note)} " + f"[/bold black on yellow] " f"{fmt_hz(r.frequency)}{where} [{r.mode}] " f"SNR {r.snr:5.1f} dB [yellow]skipping[/yellow]"), border_style="yellow", padding=(0, 1)) return Panel( - Text.from_markup( + _one_line( f"[bold red]REC[/bold red] {fmt_hz(r.frequency)}{where} " f"[{r.mode}] {bar} {r.elapsed:5.1f}{limit_s} " f"{sq} SNR {r.snr:5.1f} dB"), border_style="red", padding=(0, 1)) - def _layout(self) -> tuple[bool, int, bool]: - """Decide what fits: ``(spectrum row, hit rows, footer)``. + def _layout(self) -> tuple[bool, bool, int, bool]: + """Decide what fits: ``(receiver, spectrum, hit rows, footer)``. - On a short terminal the optional parts are given up in order -- - spectrum, then the hit list, then the key hints -- so the receiver and - sweep panels always fit. A frame taller than the terminal cannot be - redrawn in place, and every refresh would leave another copy of it - behind, which is why the header ends up on screen several times over. + On a short terminal the optional parts are given up in order -- the + spectrum row, then the hit list, then the key hints, and last of all + the receiver panel, which says nothing that changes. What is never + given up is the sweep line and, while one is running, the recording. + + A frame taller than the terminal cannot be redrawn where it was drawn: + rich moves the cursor back over exactly as many lines as it wrote last + time, and if the frame did not fit, those are not the lines it is + looking at. Every refresh then leaves another copy of the top of the + frame behind, which is how a header ends up printed down the screen. """ height = self.console.size.height or 24 + # One line short of the window: the cursor has to sit somewhere after + # the frame without the terminal scrolling to make room for it. budget = max(6, height - 1) - rec = 3 if self._rec.active else 0 spectrum = bool(self.show_spectrum and self._spark) - for want_spectrum in ((True, False) if spectrum else (False,)): - for want_footer in (True, False): - base = 3 + (4 if want_spectrum else 3) + rec + \ - (3 if want_footer else 0) - rows = budget - base - 4 # 4 = hits panel chrome - if rows >= 1: - return want_spectrum, rows, want_footer - if budget - base >= 0: - return want_spectrum, 0, want_footer - return False, 0, False + for want_receiver in (True, False): + for want_spectrum in ((True, False) if spectrum else (False,)): + for want_footer in (True, False): + base = self._fixed_height(want_receiver, want_spectrum, + want_footer) + rows = budget - base - _H_HITS_CHROME + if rows >= 1: + return want_receiver, want_spectrum, rows, want_footer + if budget - base >= 0: + return want_receiver, want_spectrum, 0, want_footer + # Narrower than anything can be drawn in. The sweep line alone, which + # rich will crop; there is nothing further to give up. + return False, False, 0, False + + def _fixed_height(self, receiver: bool, spectrum: bool, + footer: bool) -> int: + """Every line of the frame except the hit list.""" + return ((_H_RECEIVER if receiver else 0) + + _H_SWEEP + (_H_SPECTRUM if spectrum else 0) + + (_H_RECORD if self._rec.active else 0) + + (_H_FOOTER if footer else 0)) + + def _layout_height(self) -> int: + """How many lines the frame will occupy, from the layout alone. + + The number the overwriting depends on: rich moves the cursor back over + exactly this many lines before drawing the next frame, so if what is + actually rendered is even one line taller -- because a panel wrapped -- + the frame creeps down the screen a row at a time. + """ + receiver, spectrum, rows, footer = self._layout() + base = self._fixed_height(receiver, spectrum, footer) + if rows <= 0: + return base + # The hit list is as tall as it has hits to show, up to the room it + # was given -- and one row regardless, for the "nothing yet" line. + drawn = max(1, min(rows, len(self.hits))) + return base + drawn + _H_HITS_CHROME def _hit_capacity(self) -> int: """How many hit rows fit without pushing the display off the screen. @@ -309,7 +404,7 @@ class ScanDisplay: appears over and over. The number of hits grows as the scan runs, which is why it starts fine and degrades. """ - return self._layout()[1] + return self._layout()[2] def _band_width(self) -> int: """How much of the row the band column may take, or 0 for none. @@ -401,12 +496,15 @@ class ScanDisplay: "[bold]s[/bold] skip [bold]l[/bold] lock out " "[bold]+/-[/bold] threshold") msg = self.messages[0] if self.messages else "" - return Panel(Text.from_markup(f"{keys} {msg}"), + return Panel(_one_line(f"{keys} {msg}"), border_style="grey37", padding=(0, 1)) def render(self): - spectrum, rows, footer = self._layout() - parts = [self._header(), self._sweep_panel(show_spectrum=spectrum)] + receiver, spectrum, rows, footer = self._layout() + parts = [] + if receiver: + parts.append(self._header()) + parts.append(self._sweep_panel(show_spectrum=spectrum)) rec = self._record_panel() if rec is not None: parts.append(rec) diff --git a/packaging/bandsaunter.1 b/packaging/bandsaunter.1 index 64ae95b..c6d249c 100644 --- a/packaging/bandsaunter.1 +++ b/packaging/bandsaunter.1 @@ -1,5 +1,5 @@ .\" Generated by packaging/make-man.py -- do not edit by hand. -.TH BANDSAUNTER 1 "2026-08-28" "bandsaunter 2026-08-28_02" "User Commands" +.TH BANDSAUNTER 1 "2026-08-28" "bandsaunter 2026-08-28_03" "User Commands" .SH NAME bandsaunter \- scan, record and identify radio signals with an RTL-SDR .SH SYNOPSIS @@ -836,6 +836,22 @@ settings alone; and .B \-\-simulate is looking at an invented band, whose frequencies would be nonsense in a real settings file. Both still lock out for the run in hand, and say so. +.SH THE LIVE DISPLAY +The display is redrawn in place several times a second, so it has to fit the +window. On a short terminal the optional parts are given up in order \[em] the +spectrum row, then the list of recorded signals, then the key hints, and last +of all the receiver panel, which says nothing that changes. What is never +given up is the sweep line and, while one is running, the recording. +.PP +Resizing the window redraws everything from a blank screen. The frame that was +on it was drawn for a window that no longer exists, and the text above it has +been reflowed by the terminal in any case, so what was printed before the scan +started \[em] the sweep plan and the settings summary \[em] scrolls away at that +point. +.PP +.B \-\-plain +prints one line per hit instead and needs none of this, which is what to use +when the output is going into a pipe or a log. .SH KEYS DURING A SCAN .TP .B q diff --git a/packaging/make-man.py b/packaging/make-man.py index d096e3e..986a1ea 100755 --- a/packaging/make-man.py +++ b/packaging/make-man.py @@ -258,6 +258,22 @@ settings alone; and .B \-\-simulate is looking at an invented band, whose frequencies would be nonsense in a real settings file. Both still lock out for the run in hand, and say so. +.SH THE LIVE DISPLAY +The display is redrawn in place several times a second, so it has to fit the +window. On a short terminal the optional parts are given up in order \[em] the +spectrum row, then the list of recorded signals, then the key hints, and last +of all the receiver panel, which says nothing that changes. What is never +given up is the sweep line and, while one is running, the recording. +.PP +Resizing the window redraws everything from a blank screen. The frame that was +on it was drawn for a window that no longer exists, and the text above it has +been reflowed by the terminal in any case, so what was printed before the scan +started \[em] the sweep plan and the settings summary \[em] scrolls away at that +point. +.PP +.B \-\-plain +prints one line per hit instead and needs none of this, which is what to use +when the output is going into a pipe or a log. .SH KEYS DURING A SCAN .TP .B q diff --git a/packaging/saunterbrowse.1 b/packaging/saunterbrowse.1 index bc716d8..9d2dc27 100644 --- a/packaging/saunterbrowse.1 +++ b/packaging/saunterbrowse.1 @@ -1,5 +1,5 @@ .\" Generated by packaging/make-browse-man.py -- do not edit by hand. -.TH SAUNTERBROWSE 1 "2026-08-28" "bandsaunter 2026-08-28_02" "User Commands" +.TH SAUNTERBROWSE 1 "2026-08-28" "bandsaunter 2026-08-28_03" "User Commands" .SH NAME saunterbrowse \- read and listen to what a bandsaunter scan collected .SH SYNOPSIS diff --git a/pyproject.toml b/pyproject.toml index e52e873..1b9aa7a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,7 +18,9 @@ dependencies = [ [project.optional-dependencies] plots = ["matplotlib>=3.5"] -dev = ["pytest>=7.0"] +# pyte is a terminal emulator, used by the resize tests to read back what +# a real terminal would show. Those tests skip without it. +dev = ["pytest>=7.0", "pyte>=0.8"] # Speech recognition for --transcribe. Optional: transcription is off by # default, and the scan says so plainly when no recogniser is installed. transcribe = ["faster-whisper>=1.0"] diff --git a/tests/terminal.py b/tests/terminal.py new file mode 100644 index 0000000..d12b65c --- /dev/null +++ b/tests/terminal.py @@ -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 diff --git a/tests/test_browse.py b/tests/test_browse.py index 192e6fd..1510766 100644 --- a/tests/test_browse.py +++ b/tests/test_browse.py @@ -273,7 +273,19 @@ def test_the_reader_refuses_when_there_is_nothing_to_read(library): b.index = [i for i, c in enumerate(b.view) if c.mode == "fsk"][0] b.handle("t") assert not b.reading - assert "no transcript" in b.message + assert "nothing to read" in b.message + + +def test_the_reader_opens_on_a_decoded_capture_too(tmp_path): + """The decoded panel says "press t to read it all", so t has to work.""" + make_capture(tmp_path, 929.6125, "2026-08-22_11_00_00", "fsk", 4.0, + meta={"category": "digital", "data_protocol": "POCSAG 1200", + "data_messages": ["[1234568D] ENGINE 4 RESPOND"]}) + b = browser(tmp_path) + assert not b.current.transcript and b.current.decoded + b.handle("t") + assert b.reading, b.message + assert "ENGINE 4 RESPOND" in frame(b) # -- the frame --------------------------------------------------------------- diff --git a/tests/test_resize.py b/tests/test_resize.py new file mode 100644 index 0000000..2bba4ff --- /dev/null +++ b/tests/test_resize.py @@ -0,0 +1,269 @@ +"""Resizing the window, and the debris it used to leave behind. + +Live rendering moves the cursor back over the frame it drew last time and +overwrites it. That is only correct while the frame is still where it was +put, and resizing the window reflows everything on the screen, so what is left +is half of the old frame in pieces -- box corners in the middle of a line, a +border twice the width of the window, a header printed eight times. + +Two kinds of test here. The first are arithmetic: every frame has to be +exactly as tall as the terminal, or never taller, because that is the property +the overwriting depends on and it can be checked without a terminal at all. +The second run the programs in a real pty and resize them. +""" +import os +import sys +import tempfile +from pathlib import Path + +import numpy as np +import pytest +from rich.console import Console + +sys.path.insert(0, str(Path(__file__).parent)) +from terminal import Terminal, stray_rows # noqa: E402 +from bandsaunter.browse import Browser, Player # noqa: E402 +from bandsaunter.config import ScanConfig # noqa: E402 +from bandsaunter.ranges import parse_range_list # noqa: E402 +from bandsaunter.recorder import HitRecord # noqa: E402 +from bandsaunter.scanner import Scanner # noqa: E402 +from bandsaunter.simulator import SimulatedDevice # noqa: E402 +from bandsaunter.ui import ScanDisplay # noqa: E402 + +pyte = pytest.importorskip("pyte", reason="terminal emulator not installed") + +SIZES = [(120, 40), (100, 30), (100, 24), (90, 20), (80, 24), (70, 18), + (64, 16), (60, 12), (50, 10), (40, 8), (200, 60)] + + +# --------------------------------------------------------------------------- +# The arithmetic the overwriting depends on +# --------------------------------------------------------------------------- + +def _scan_display(width: int, height: int, busy: bool = True) -> ScanDisplay: + console = Console(width=width, height=height, force_terminal=True, + file=open(os.devnull, "w")) + cfg = ScanConfig(ranges=parse_range_list("144M-148M"), + output_dir=tempfile.mkdtemp()) + scanner = Scanner(cfg, device=SimulatedDevice().open()) + scanner.prepare() + display = ScanDisplay(scanner, console=console) + display.attach() + if busy: + step = scanner.plan[3] + display.on_step(3, 6, step, np.full(1024, -70.0), + np.linspace(step.low, step.high, 1024)) + display._rec.active = True + display._rec.frequency = 146.520555e6 + display._rec.mode = "nfm" + display._rec.present = True + display._rec.snr = 50.4 + display._rec.elapsed = 3.0 + display.on_status("decoded 146.52 MHz: a status line long enough to " + "run off the end of a narrow window") + for i in range(8): + hit = HitRecord(frequency=146e6 + i * 1e5, started_at=0, + duration=5.0, snr_db=20.0, + classification="Narrowband FM voice " + "(CTCSS 100.0 Hz)") + display.hits.appendleft(hit) + return display + + +def _rendered_height(renderable, width: int, height: int) -> int: + probe = Console(width=width, height=height, force_terminal=True, + record=True, file=open(os.devnull, "w")) + probe.print(renderable) + return len(probe.export_text().rstrip("\n").split("\n")) + + +@pytest.mark.parametrize("width,height", SIZES) +@pytest.mark.parametrize("busy", [False, True]) +def test_the_scan_frame_never_outgrows_the_terminal(width, height, busy): + """A frame taller than the window cannot be redrawn where it was drawn.""" + display = _scan_display(width, height, busy) + drawn = _rendered_height(display.render(), width, height) + assert drawn <= height, f"{width}x{height}: frame is {drawn} lines" + + +@pytest.mark.parametrize("width,height", SIZES) +def test_the_frame_is_exactly_as_tall_as_the_layout_says(width, height): + """The number the whole thing depends on, checked against reality. + + The layout counts each panel as a fixed number of lines when it decides + what fits, and rich moves the cursor back over exactly that many. A model + one line out overflows by one line on every refresh, and the top of the + frame marches down the screen -- which is what a column of "receiver" + headers was. Two ways to be wrong: a panel counted at the wrong height, + or a long band name or status message wrapping inside one. + """ + display = _scan_display(width, height) + probe = Console(width=width, height=400, force_terminal=True, record=True, + file=open(os.devnull, "w")) + probe.print(display.render()) + lines = probe.export_text().rstrip("\n").split("\n") + assert len(lines) == display._layout_height(), \ + "the frame is not the height the layout budgeted for" + + +@pytest.mark.parametrize("width,height", SIZES) +@pytest.mark.parametrize("view", ["list", "reader", "help"]) +def test_every_browser_screen_fills_the_terminal_exactly(width, height, view, + tmp_path): + """Nothing erases the alternate screen between frames. + + The cursor is sent home and the new frame written over the old one, so a + frame shorter than the screen leaves the tail of the last one visible + underneath it. + """ + console = Console(width=width, height=height, force_terminal=True, + file=open(os.devnull, "w")) + browser = Browser(tmp_path, console=console, player=Player([])) + browser.reading = view == "reader" + browser.show_help = view == "help" + drawn = _rendered_height(browser.render(), width, height) + assert drawn == height, f"{width}x{height} {view}: {drawn} lines" + + +# --------------------------------------------------------------------------- +# Noticing the change +# --------------------------------------------------------------------------- + +class _Resizable(Console): + """A console whose size can be changed the way a window can.""" + + def __init__(self, width, height): + super().__init__(width=width, height=height, force_terminal=True, + file=open(os.devnull, "w")) + + def become(self, width, height): + self.width = width + self.height = height + + +def test_a_size_change_is_noticed_once_and_only_once(): + console = _Resizable(100, 30) + display = _scan_display(100, 30, busy=False) + display.console = console + display._size = console.size + assert not display.resized(), "reported a resize that never happened" + console.become(70, 20) + assert display.resized(), "did not notice the window changing" + assert not display.resized(), "reported the same resize twice" + console.become(70, 40) + assert display.resized(), "a change in height is a change" + + +def test_the_browser_notices_a_size_change_too(tmp_path): + console = _Resizable(100, 30) + browser = Browser(tmp_path, console=console, player=Player([])) + assert not browser.resized() + console.become(64, 18) + assert browser.resized() + assert not browser.resized() + + +# --------------------------------------------------------------------------- +# In a real terminal +# --------------------------------------------------------------------------- + +def _scan_argv(directory: Path) -> list[str]: + return [sys.executable, "-m", "bandsaunter", "scan", "--simulate", + "--no-config", "-r", "144M-148M", "-o", str(directory), + "--duration", "90"] + + +@pytest.mark.parametrize("after", [(70, 20), (120, 40), (60, 12), (100, 30)]) +def test_resizing_a_running_scan_leaves_nothing_behind(tmp_path, after): + cols, rows = after + with Terminal(_scan_argv(tmp_path), cols=100, rows=30) as term: + term.pump(4.0) + term.resize(cols, rows) + term.pump(3.0) + lines = term.display() + assert any("sweep" in line for line in lines), \ + "the display did not redraw at all" + stray = stray_rows(lines, cols) + assert not stray, "left behind:\n" + "\n".join(stray) + + +def test_a_scan_survives_being_resized_over_and_over(tmp_path): + """A window being dragged sends a great many of these in a row.""" + with Terminal(_scan_argv(tmp_path), cols=110, rows=34) as term: + term.pump(3.0) + for cols, rows in ((90, 28), (70, 20), (64, 16), (100, 30), + (120, 40), (80, 24)): + term.resize(cols, rows) + term.pump(0.6) + term.pump(2.0) + lines = term.display() + stray = stray_rows(lines, term.cols) + assert not stray, "left behind:\n" + "\n".join(stray) + + +def test_the_header_is_never_drawn_more_than_once(tmp_path): + """It used to march down the screen, one copy per refresh.""" + with Terminal(_scan_argv(tmp_path), cols=100, rows=30) as term: + term.pump(4.0) + term.resize(78, 22) + term.pump(3.0) + headers = [ln for ln in term.display() if " receiver " in ln] + assert len(headers) <= 1, f"{len(headers)} copies of the header" + + +def _browse_argv(directory: Path) -> list[str]: + return [sys.executable, "-m", "bandsaunter.browse", str(directory)] + + +@pytest.fixture +def library(tmp_path): + """A directory with something in it for the browser to show.""" + import json + import wave + stem = "0146.520000MHz--2026-08-22_10_00_00-nfm" + with wave.open(str(tmp_path / f"{stem}.wav"), "wb") as w: + w.setnchannels(1) + w.setsampwidth(2) + w.setframerate(16000) + w.writeframes(b"\0\0" * 16000) + (tmp_path / f"{stem}_transcription.txt").write_text( + "Net control, this is W1AW. " * 30) + (tmp_path / f"{stem}.json").write_text(json.dumps({"hit": { + "frequency": 146.52e6, "started_at": 1755856800.0, + "category": "voice", "classification": "Narrowband FM voice", + "confidence": 0.88, "mode": "nfm", "duration": 1.0}})) + return tmp_path + + +@pytest.mark.parametrize("after", [(70, 20), (130, 44), (60, 10)]) +def test_resizing_the_browser_leaves_nothing_behind(library, after): + cols, rows = after + with Terminal(_browse_argv(library), cols=100, rows=30) as term: + term.pump(2.5) + term.resize(cols, rows) + term.pump(2.0) + lines = term.display() + stray = stray_rows(lines, cols) + assert not stray, "left behind:\n" + "\n".join(stray) + + +def test_the_reader_covers_the_list_it_was_opened_from(library): + """Opening a full-screen view must not leave the list showing under it.""" + with Terminal(_browse_argv(library), cols=100, rows=30) as term: + term.pump(2.5) + term.send("t") + term.pump(1.5) + text = term.text() + assert "transcript" in text + assert "recordings in" not in text, \ + "the list is still visible under the reader" + + +def test_the_help_screen_covers_the_list_too(library): + with Terminal(_browse_argv(library), cols=100, rows=30) as term: + term.pump(2.5) + term.send("?") + term.pump(1.5) + text = term.text() + assert "move through the recordings" in text + assert "recordings in" not in text