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
16
README.md
16
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
|
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
|
## Live controls
|
||||||
|
|
||||||
The display sizes itself to the terminal, giving up the spectrum row, then the
|
The display sizes itself to the terminal, giving up the spectrum row, then the
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ and transcribing speech.
|
||||||
# 2026-08-21_02 is the second build made on the 21st. The revision is padded
|
# 2026-08-21_02 is the second build made on the 21st. The revision is padded
|
||||||
# to two digits so versions sort as text.
|
# to two digits so versions sort as text.
|
||||||
VERSION_DATE = "2026-08-28"
|
VERSION_DATE = "2026-08-28"
|
||||||
VERSION_REVISION = 2
|
VERSION_REVISION = 3
|
||||||
|
|
||||||
__version__ = f"{VERSION_DATE}_{VERSION_REVISION:02d}"
|
__version__ = f"{VERSION_DATE}_{VERSION_REVISION:02d}"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -501,6 +501,7 @@ class Browser:
|
||||||
self.show_help = False
|
self.show_help = False
|
||||||
self.reading = False # full-screen transcript
|
self.reading = False # full-screen transcript
|
||||||
self.read_top = 0
|
self.read_top = 0
|
||||||
|
self._size = self.console.size
|
||||||
self.reload()
|
self.reload()
|
||||||
|
|
||||||
# -- state ------------------------------------------------------------
|
# -- state ------------------------------------------------------------
|
||||||
|
|
@ -950,10 +951,16 @@ class Browser:
|
||||||
border_style="green", padding=(1, 4))
|
border_style="green", padding=(1, 4))
|
||||||
|
|
||||||
def render(self):
|
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:
|
if self.show_help:
|
||||||
return Align.center(self._help(), vertical="middle")
|
return Layout(Align.center(self._help(), vertical="middle"))
|
||||||
if self.reading:
|
if self.reading:
|
||||||
return self._reader()
|
return Layout(self._reader())
|
||||||
layout = Layout()
|
layout = Layout()
|
||||||
# One height, computed once: the panel and the region it sits in have
|
# 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
|
# 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
|
self.show_help = not self.show_help
|
||||||
elif key == "t":
|
elif key == "t":
|
||||||
cap = self.current
|
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.reading = True
|
||||||
self.read_top = 0
|
self.read_top = 0
|
||||||
else:
|
else:
|
||||||
self.message = "no transcript for this recording"
|
self.message = "nothing to read for this recording"
|
||||||
elif key == "o":
|
elif key == "o":
|
||||||
cap = self.current
|
cap = self.current
|
||||||
if cap is not None:
|
if cap is not None:
|
||||||
|
|
@ -1081,6 +1091,20 @@ class Browser:
|
||||||
if self.player.error:
|
if self.player.error:
|
||||||
self.message = 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 --------------------------------------------------------
|
# -- main loop --------------------------------------------------------
|
||||||
def run(self) -> int:
|
def run(self) -> int:
|
||||||
with Keyboard() as keys, Live(self.render(), console=self.console,
|
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)
|
key = keys.get(0.15 if self.player.active else 0.4)
|
||||||
if not self.handle(key):
|
if not self.handle(key):
|
||||||
break
|
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)
|
live.update(self.render(), refresh=True)
|
||||||
self.player.stop()
|
self.player.stop()
|
||||||
if self.message:
|
if self.message:
|
||||||
|
|
|
||||||
|
|
@ -380,6 +380,12 @@ def _run_live(scanner: Scanner, cfg: ScanConfig) -> int:
|
||||||
key = keys.get()
|
key = keys.get()
|
||||||
if key:
|
if key:
|
||||||
_handle_key(key, scanner, display)
|
_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())
|
live.update(display.render())
|
||||||
time.sleep(0.1)
|
time.sleep(0.1)
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,37 @@ _SPARK = " ▁▂▃▄▅▆▇█"
|
||||||
# layout to shrink and so fixes it once.
|
# layout to shrink and so fixes it once.
|
||||||
_PLAIN_BAND = 20
|
_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,
|
def _sparkline(values: np.ndarray, width: int = 60,
|
||||||
lo: float | None = None, hi: float | None = None) -> str:
|
lo: float | None = None, hi: float | None = None) -> str:
|
||||||
|
|
@ -121,6 +152,7 @@ class ScanDisplay:
|
||||||
self._rec = _RecState()
|
self._rec = _RecState()
|
||||||
self._last_detection: Detection | None = None
|
self._last_detection: Detection | None = None
|
||||||
self._dirty = True
|
self._dirty = True
|
||||||
|
self._size = self.console.size
|
||||||
|
|
||||||
# -- callbacks --------------------------------------------------------
|
# -- callbacks --------------------------------------------------------
|
||||||
def attach(self, scanner: Scanner | None = None) -> None:
|
def attach(self, scanner: Scanner | None = None) -> None:
|
||||||
|
|
@ -190,6 +222,26 @@ class ScanDisplay:
|
||||||
self._dirty = True
|
self._dirty = True
|
||||||
|
|
||||||
# -- rendering ---------------------------------------------------------
|
# -- 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:
|
def _spark_width(self) -> int:
|
||||||
return max(20, min(120, self.console.width - 24))
|
return max(20, min(120, self.console.width - 24))
|
||||||
|
|
||||||
|
|
@ -199,17 +251,25 @@ class ScanDisplay:
|
||||||
gain = st.get("gain", "?")
|
gain = st.get("gain", "?")
|
||||||
gain = f"{gain:.1f} dB" if isinstance(gain, (int, float)) else str(gain)
|
gain = f"{gain:.1f} dB" if isinstance(gain, (int, float)) else str(gain)
|
||||||
ds = st.get("direct_sampling", 0)
|
ds = st.get("direct_sampling", 0)
|
||||||
bits = [
|
# Ordered by what has to survive a narrow window. The flags come
|
||||||
f"[bold]{st.get('tuner', '?')}[/bold]",
|
# first among the optional parts because "SIMULATED" disappearing off
|
||||||
f"{st.get('sample_rate', 0)/1e6:.3f} MS/s",
|
# the end of the line is how somebody comes to believe they are
|
||||||
f"gain {gain}",
|
# listening to the air.
|
||||||
f"{st.get('ppm', 0):+d} ppm",
|
head = [f"[bold]{st.get('tuner', '?')}[/bold]"]
|
||||||
]
|
|
||||||
if ds:
|
if ds:
|
||||||
bits.append("[yellow]direct sampling[/yellow]")
|
head.append("[yellow]direct sampling[/yellow]")
|
||||||
if st.get("simulated"):
|
if st.get("simulated"):
|
||||||
bits.append("[magenta]SIMULATED[/magenta]")
|
head.append("[magenta]SIMULATED[/magenta]")
|
||||||
return Panel(Text.from_markup(" ".join(bits)),
|
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))
|
title="receiver", border_style="blue", padding=(0, 1))
|
||||||
|
|
||||||
def _sweep_panel(self, show_spectrum: bool = True) -> Panel:
|
def _sweep_panel(self, show_spectrum: bool = True) -> Panel:
|
||||||
|
|
@ -220,18 +280,18 @@ class ScanDisplay:
|
||||||
bar = "[green]" + "━" * filled + "[/green]" + \
|
bar = "[green]" + "━" * filled + "[/green]" + \
|
||||||
"[grey37]" + "━" * (bar_w - filled) + "[/grey37]"
|
"[grey37]" + "━" * (bar_w - filled) + "[/grey37]"
|
||||||
lines = [
|
lines = [
|
||||||
Text.from_markup(
|
_one_line(
|
||||||
f"{bar} step {self._step_i + 1}/{self._n_steps} "
|
f"{bar} step {self._step_i + 1}/{self._n_steps} "
|
||||||
f"[bold]{self._span}[/bold]"
|
f"[bold]{self._span}[/bold]"
|
||||||
+ (f" [magenta]{self._band}[/magenta]" if self._band else "")),
|
+ (f" [magenta]{self._band}[/magenta]" if self._band else "")),
|
||||||
]
|
]
|
||||||
if show_spectrum and self.show_spectrum and self._spark:
|
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"))
|
f"[cyan]{self._spark}[/cyan] peak {self._peak:6.1f} dBFS"))
|
||||||
state = s.state
|
state = s.state
|
||||||
colour = {"recording": "red", "sweeping": "green",
|
colour = {"recording": "red", "sweeping": "green",
|
||||||
"paused": "yellow"}.get(state, "white")
|
"paused": "yellow"}.get(state, "white")
|
||||||
lines.append(Text.from_markup(
|
lines.append(_one_line(
|
||||||
f"[{colour}]{state}[/{colour}] cycle {s.cycles + 1} "
|
f"[{colour}]{state}[/{colour}] cycle {s.cycles + 1} "
|
||||||
f"hits {s.recordings} dropped {s.discarded} "
|
f"hits {s.recordings} dropped {s.discarded} "
|
||||||
f"detections {s.detections} up {_dur(s.elapsed)}"))
|
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
|
# A control channel is not being recorded so much as identified
|
||||||
# and abandoned; saying "REC" while that happens is a lie.
|
# and abandoned; saying "REC" while that happens is a lie.
|
||||||
return Panel(
|
return Panel(
|
||||||
Text.from_markup(
|
_one_line(
|
||||||
f"[bold black on yellow] {escape(r.note)} [/bold black on yellow] "
|
f"[bold black on yellow] {escape(r.note)} "
|
||||||
|
f"[/bold black on yellow] "
|
||||||
f"{fmt_hz(r.frequency)}{where} [{r.mode}] "
|
f"{fmt_hz(r.frequency)}{where} [{r.mode}] "
|
||||||
f"SNR {r.snr:5.1f} dB [yellow]skipping[/yellow]"),
|
f"SNR {r.snr:5.1f} dB [yellow]skipping[/yellow]"),
|
||||||
border_style="yellow", padding=(0, 1))
|
border_style="yellow", padding=(0, 1))
|
||||||
return Panel(
|
return Panel(
|
||||||
Text.from_markup(
|
_one_line(
|
||||||
f"[bold red]REC[/bold red] {fmt_hz(r.frequency)}{where} "
|
f"[bold red]REC[/bold red] {fmt_hz(r.frequency)}{where} "
|
||||||
f"[{r.mode}] {bar} {r.elapsed:5.1f}{limit_s} "
|
f"[{r.mode}] {bar} {r.elapsed:5.1f}{limit_s} "
|
||||||
f"{sq} SNR {r.snr:5.1f} dB"),
|
f"{sq} SNR {r.snr:5.1f} dB"),
|
||||||
border_style="red", padding=(0, 1))
|
border_style="red", padding=(0, 1))
|
||||||
|
|
||||||
def _layout(self) -> tuple[bool, int, bool]:
|
def _layout(self) -> tuple[bool, bool, int, bool]:
|
||||||
"""Decide what fits: ``(spectrum row, hit rows, footer)``.
|
"""Decide what fits: ``(receiver, spectrum, hit rows, footer)``.
|
||||||
|
|
||||||
On a short terminal the optional parts are given up in order --
|
On a short terminal the optional parts are given up in order -- the
|
||||||
spectrum, then the hit list, then the key hints -- so the receiver and
|
spectrum row, then the hit list, then the key hints, and last of all
|
||||||
sweep panels always fit. A frame taller than the terminal cannot be
|
the receiver panel, which says nothing that changes. What is never
|
||||||
redrawn in place, and every refresh would leave another copy of it
|
given up is the sweep line and, while one is running, the recording.
|
||||||
behind, which is why the header ends up on screen several times over.
|
|
||||||
|
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
|
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)
|
budget = max(6, height - 1)
|
||||||
rec = 3 if self._rec.active else 0
|
|
||||||
spectrum = bool(self.show_spectrum and self._spark)
|
spectrum = bool(self.show_spectrum and self._spark)
|
||||||
|
|
||||||
|
for want_receiver in (True, False):
|
||||||
for want_spectrum in ((True, False) if spectrum else (False,)):
|
for want_spectrum in ((True, False) if spectrum else (False,)):
|
||||||
for want_footer in (True, False):
|
for want_footer in (True, False):
|
||||||
base = 3 + (4 if want_spectrum else 3) + rec + \
|
base = self._fixed_height(want_receiver, want_spectrum,
|
||||||
(3 if want_footer else 0)
|
want_footer)
|
||||||
rows = budget - base - 4 # 4 = hits panel chrome
|
rows = budget - base - _H_HITS_CHROME
|
||||||
if rows >= 1:
|
if rows >= 1:
|
||||||
return want_spectrum, rows, want_footer
|
return want_receiver, want_spectrum, rows, want_footer
|
||||||
if budget - base >= 0:
|
if budget - base >= 0:
|
||||||
return want_spectrum, 0, want_footer
|
return want_receiver, want_spectrum, 0, want_footer
|
||||||
return False, 0, False
|
# 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:
|
def _hit_capacity(self) -> int:
|
||||||
"""How many hit rows fit without pushing the display off the screen.
|
"""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,
|
appears over and over. The number of hits grows as the scan runs,
|
||||||
which is why it starts fine and degrades.
|
which is why it starts fine and degrades.
|
||||||
"""
|
"""
|
||||||
return self._layout()[1]
|
return self._layout()[2]
|
||||||
|
|
||||||
def _band_width(self) -> int:
|
def _band_width(self) -> int:
|
||||||
"""How much of the row the band column may take, or 0 for none.
|
"""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]s[/bold] skip [bold]l[/bold] lock out "
|
||||||
"[bold]+/-[/bold] threshold")
|
"[bold]+/-[/bold] threshold")
|
||||||
msg = self.messages[0] if self.messages else ""
|
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))
|
border_style="grey37", padding=(0, 1))
|
||||||
|
|
||||||
def render(self):
|
def render(self):
|
||||||
spectrum, rows, footer = self._layout()
|
receiver, spectrum, rows, footer = self._layout()
|
||||||
parts = [self._header(), self._sweep_panel(show_spectrum=spectrum)]
|
parts = []
|
||||||
|
if receiver:
|
||||||
|
parts.append(self._header())
|
||||||
|
parts.append(self._sweep_panel(show_spectrum=spectrum))
|
||||||
rec = self._record_panel()
|
rec = self._record_panel()
|
||||||
if rec is not None:
|
if rec is not None:
|
||||||
parts.append(rec)
|
parts.append(rec)
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
.\" Generated by packaging/make-man.py -- do not edit by hand.
|
.\" 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
|
.SH NAME
|
||||||
bandsaunter \- scan, record and identify radio signals with an RTL-SDR
|
bandsaunter \- scan, record and identify radio signals with an RTL-SDR
|
||||||
.SH SYNOPSIS
|
.SH SYNOPSIS
|
||||||
|
|
@ -836,6 +836,22 @@ settings alone; and
|
||||||
.B \-\-simulate
|
.B \-\-simulate
|
||||||
is looking at an invented band, whose frequencies would be nonsense in a real
|
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.
|
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
|
.SH KEYS DURING A SCAN
|
||||||
.TP
|
.TP
|
||||||
.B q
|
.B q
|
||||||
|
|
|
||||||
|
|
@ -258,6 +258,22 @@ settings alone; and
|
||||||
.B \-\-simulate
|
.B \-\-simulate
|
||||||
is looking at an invented band, whose frequencies would be nonsense in a real
|
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.
|
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
|
.SH KEYS DURING A SCAN
|
||||||
.TP
|
.TP
|
||||||
.B q
|
.B q
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
.\" Generated by packaging/make-browse-man.py -- do not edit by hand.
|
.\" 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
|
.SH NAME
|
||||||
saunterbrowse \- read and listen to what a bandsaunter scan collected
|
saunterbrowse \- read and listen to what a bandsaunter scan collected
|
||||||
.SH SYNOPSIS
|
.SH SYNOPSIS
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,9 @@ dependencies = [
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
plots = ["matplotlib>=3.5"]
|
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
|
# Speech recognition for --transcribe. Optional: transcription is off by
|
||||||
# default, and the scan says so plainly when no recogniser is installed.
|
# default, and the scan says so plainly when no recogniser is installed.
|
||||||
transcribe = ["faster-whisper>=1.0"]
|
transcribe = ["faster-whisper>=1.0"]
|
||||||
|
|
|
||||||
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
|
||||||
|
|
@ -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.index = [i for i, c in enumerate(b.view) if c.mode == "fsk"][0]
|
||||||
b.handle("t")
|
b.handle("t")
|
||||||
assert not b.reading
|
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 ---------------------------------------------------------------
|
# -- the frame ---------------------------------------------------------------
|
||||||
|
|
|
||||||
269
tests/test_resize.py
Normal file
269
tests/test_resize.py
Normal file
|
|
@ -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
|
||||||
Loading…
Add table
Add a link
Reference in a new issue