pluto-siege 2.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pluto_siege/__init__.py +43 -0
- pluto_siege/__main__.py +23 -0
- pluto_siege/config.py +249 -0
- pluto_siege/constants.py +132 -0
- pluto_siege/device.py +311 -0
- pluto_siege/dsp.py +157 -0
- pluto_siege/engine.py +460 -0
- pluto_siege/gps.py +419 -0
- pluto_siege/settings.py +54 -0
- pluto_siege/sigmf.py +329 -0
- pluto_siege/sound.py +162 -0
- pluto_siege/ui/__init__.py +21 -0
- pluto_siege/ui/screens/__init__.py +32 -0
- pluto_siege/ui/screens/capture.py +226 -0
- pluto_siege/ui/screens/loopback.py +90 -0
- pluto_siege/ui/screens/main_menu.py +363 -0
- pluto_siege/ui/screens/settings.py +504 -0
- pluto_siege/ui/screens/transmit.py +240 -0
- pluto_siege/ui/widgets/__init__.py +45 -0
- pluto_siege/ui/widgets/framework.py +863 -0
- pluto_siege-2.0.1.dist-info/METADATA +187 -0
- pluto_siege-2.0.1.dist-info/RECORD +26 -0
- pluto_siege-2.0.1.dist-info/WHEEL +5 -0
- pluto_siege-2.0.1.dist-info/entry_points.txt +2 -0
- pluto_siege-2.0.1.dist-info/licenses/LICENSE +674 -0
- pluto_siege-2.0.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,863 @@
|
|
|
1
|
+
# PlutoSiege - RF capture and replay tool for PlutoSDR.
|
|
2
|
+
# Copyright (C) 2026 SketchTurnerDev
|
|
3
|
+
#
|
|
4
|
+
# This program is free software: you can redistribute it and/or modify
|
|
5
|
+
# it under the terms of the GNU General Public License as published by
|
|
6
|
+
# the Free Software Foundation, either version 3 of the License, or
|
|
7
|
+
# (at your option) any later version.
|
|
8
|
+
#
|
|
9
|
+
# This program is distributed in the hope that it will be useful,
|
|
10
|
+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
11
|
+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
12
|
+
# GNU General Public License for more details.
|
|
13
|
+
#
|
|
14
|
+
# You should have received a copy of the GNU General Public License
|
|
15
|
+
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
16
|
+
|
|
17
|
+
"""Curses UI framework: colours, drawing primitives, menus, text editors."""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import curses
|
|
22
|
+
import math
|
|
23
|
+
import os
|
|
24
|
+
import time
|
|
25
|
+
from dataclasses import dataclass
|
|
26
|
+
from typing import Any, overload
|
|
27
|
+
|
|
28
|
+
from pluto_siege.constants import LOGO_LINES
|
|
29
|
+
|
|
30
|
+
os.environ.setdefault("ESCDELAY", "50")
|
|
31
|
+
|
|
32
|
+
# ---------------------------------------------------------------------------
|
|
33
|
+
# Colour pairs & layout constants
|
|
34
|
+
# ---------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
C_TITLE = 1
|
|
37
|
+
C_ACCENT = 2
|
|
38
|
+
C_OK = 3
|
|
39
|
+
C_WARN = 4
|
|
40
|
+
C_ERR = 5
|
|
41
|
+
C_DIM = 6
|
|
42
|
+
C_BAR = C_OK
|
|
43
|
+
|
|
44
|
+
CONTENT_X = 2
|
|
45
|
+
MENU_X = 4
|
|
46
|
+
RIGHT_INSET = 4
|
|
47
|
+
FOOTER_ROW_OFFSET = 1
|
|
48
|
+
MIN_WIN_HEIGHT = 37
|
|
49
|
+
MIN_WIN_WIDTH = 97
|
|
50
|
+
|
|
51
|
+
FLASH_SECONDS = 0.9
|
|
52
|
+
METER_REFRESH_SECONDS = 0.1
|
|
53
|
+
METER_FLOOR_DBFS = -80.0
|
|
54
|
+
KEY_ENTER = (
|
|
55
|
+
10,
|
|
56
|
+
13,
|
|
57
|
+
curses.KEY_ENTER,
|
|
58
|
+
getattr(curses, "PADENTER", 459),
|
|
59
|
+
getattr(curses, "CTL_ENTER", 529),
|
|
60
|
+
)
|
|
61
|
+
KEY_ESC = 27
|
|
62
|
+
ESC_DELAY_MS = 50
|
|
63
|
+
|
|
64
|
+
BUTTON_CLICK = getattr(curses, "BUTTON1_CLICKED", 0) | getattr(
|
|
65
|
+
curses, "BUTTON1_PRESSED", 0
|
|
66
|
+
)
|
|
67
|
+
BUTTON_DOUBLE = getattr(curses, "BUTTON1_DOUBLE_CLICKED", 0)
|
|
68
|
+
BUTTON_RIGHT = getattr(curses, "BUTTON3_CLICKED", 0) | getattr(
|
|
69
|
+
curses, "BUTTON3_PRESSED", 0
|
|
70
|
+
)
|
|
71
|
+
WHEEL_UP = getattr(curses, "BUTTON4_PRESSED", 0) | getattr(curses, "BUTTON4_CLICKED", 0)
|
|
72
|
+
WHEEL_DOWN = getattr(curses, "BUTTON5_PRESSED", 0) | getattr(
|
|
73
|
+
curses, "BUTTON5_CLICKED", 0
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass(frozen=True)
|
|
78
|
+
class HitBox:
|
|
79
|
+
"""Inclusive-left/exclusive-right cell rectangle used for mouse targets."""
|
|
80
|
+
|
|
81
|
+
y: int
|
|
82
|
+
x: int
|
|
83
|
+
height: int
|
|
84
|
+
width: int
|
|
85
|
+
|
|
86
|
+
def contains(self, x: int, y: int) -> bool:
|
|
87
|
+
return self.x <= x < self.x + self.width and self.y <= y < self.y + self.height
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def init_mouse(enable: bool = True) -> None:
|
|
91
|
+
"""Enable or disable curses mouse tracking."""
|
|
92
|
+
if not hasattr(curses, "mousemask"):
|
|
93
|
+
return
|
|
94
|
+
try:
|
|
95
|
+
mask = getattr(curses, "ALL_MOUSE_EVENTS", 0) if enable else 0
|
|
96
|
+
curses.mousemask(mask)
|
|
97
|
+
except curses.error:
|
|
98
|
+
pass
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def get_mouse_event() -> tuple[int, int, int, int, int] | None:
|
|
102
|
+
"""Safely fetch curses mouse event tuple (id, x, y, z, bstate) or None."""
|
|
103
|
+
if not hasattr(curses, "getmouse"):
|
|
104
|
+
return None
|
|
105
|
+
try:
|
|
106
|
+
return curses.getmouse()
|
|
107
|
+
except (curses.error, OSError, ValueError):
|
|
108
|
+
return None
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def init_colors() -> None:
|
|
112
|
+
if not curses.has_colors():
|
|
113
|
+
return
|
|
114
|
+
try:
|
|
115
|
+
curses.start_color()
|
|
116
|
+
except curses.error:
|
|
117
|
+
pass
|
|
118
|
+
try:
|
|
119
|
+
curses.use_default_colors()
|
|
120
|
+
bg = -1
|
|
121
|
+
except curses.error:
|
|
122
|
+
bg = curses.COLOR_BLACK
|
|
123
|
+
try:
|
|
124
|
+
curses.init_pair(C_TITLE, curses.COLOR_CYAN, bg)
|
|
125
|
+
curses.init_pair(C_ACCENT, curses.COLOR_BLACK, curses.COLOR_CYAN)
|
|
126
|
+
curses.init_pair(C_OK, curses.COLOR_GREEN, bg)
|
|
127
|
+
curses.init_pair(C_WARN, curses.COLOR_YELLOW, bg)
|
|
128
|
+
curses.init_pair(C_ERR, curses.COLOR_RED, bg)
|
|
129
|
+
curses.init_pair(C_DIM, curses.COLOR_WHITE, bg)
|
|
130
|
+
except curses.error:
|
|
131
|
+
pass
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def cp(pair: int) -> int:
|
|
135
|
+
try:
|
|
136
|
+
if curses.has_colors():
|
|
137
|
+
return curses.color_pair(pair)
|
|
138
|
+
except curses.error:
|
|
139
|
+
pass
|
|
140
|
+
return curses.A_REVERSE if pair == C_ACCENT else 0
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def set_cursor_visible(visible: bool) -> None:
|
|
144
|
+
"""Show or hide the caret. Terminals may refuse, which is not an error."""
|
|
145
|
+
try:
|
|
146
|
+
curses.curs_set(1 if visible else 0)
|
|
147
|
+
except curses.error:
|
|
148
|
+
pass
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
hide_cursor = set_cursor_visible
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def flush_input(win: curses.window | None = None) -> None:
|
|
155
|
+
"""Purge all pending keyboard input from Curses and OS input buffers."""
|
|
156
|
+
try:
|
|
157
|
+
curses.flushinp()
|
|
158
|
+
except curses.error:
|
|
159
|
+
pass
|
|
160
|
+
if win is not None:
|
|
161
|
+
try:
|
|
162
|
+
win.nodelay(True)
|
|
163
|
+
while win.getch() != -1:
|
|
164
|
+
pass
|
|
165
|
+
win.nodelay(False)
|
|
166
|
+
except (curses.error, AttributeError):
|
|
167
|
+
pass
|
|
168
|
+
try:
|
|
169
|
+
curses.flushinp()
|
|
170
|
+
except curses.error:
|
|
171
|
+
pass
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _sanitize_text(text: str) -> str:
|
|
175
|
+
"""Replace control characters with spaces so labels cannot inject escapes."""
|
|
176
|
+
return "".join(ch if ch.isprintable() else " " for ch in str(text))
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _put(win: curses.window, y: int, x: int, text: str, attr: int = 0) -> None:
|
|
180
|
+
"""Draw text clipped to the window, reserving the bottom-right terminal cell."""
|
|
181
|
+
try:
|
|
182
|
+
h, w = win.getmaxyx()
|
|
183
|
+
except (curses.error, AttributeError, TypeError, ValueError):
|
|
184
|
+
return
|
|
185
|
+
if y < 0 or y >= h or x < 0 or x >= w:
|
|
186
|
+
return
|
|
187
|
+
clipped = _sanitize_text(text)[: max(0, w - 1 - x)]
|
|
188
|
+
if not clipped:
|
|
189
|
+
return
|
|
190
|
+
try:
|
|
191
|
+
win.addstr(y, x, clipped, attr)
|
|
192
|
+
except curses.error:
|
|
193
|
+
pass
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _draw_hint_line(win: curses.window, y: int, hint: str) -> None:
|
|
197
|
+
"""Draw footer hint line with bold cyan '▎' separators and dim white text."""
|
|
198
|
+
hint_str = hint.strip()
|
|
199
|
+
if not hint_str:
|
|
200
|
+
return
|
|
201
|
+
full_hint = f"▎ {hint_str}"
|
|
202
|
+
parts = full_hint.split("▎")
|
|
203
|
+
curr_x = CONTENT_X
|
|
204
|
+
for i, part in enumerate(parts):
|
|
205
|
+
if part:
|
|
206
|
+
_put(win, y, curr_x, part, cp(C_DIM) | curses.A_DIM)
|
|
207
|
+
curr_x += len(part)
|
|
208
|
+
if i < len(parts) - 1:
|
|
209
|
+
_put(win, y, curr_x, "▎", cp(C_TITLE) | curses.A_BOLD)
|
|
210
|
+
curr_x += 1
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def draw_footer(win: curses.window, hint: str) -> None:
|
|
214
|
+
"""Draw unified keyboard hint in the bottom row."""
|
|
215
|
+
if not hint:
|
|
216
|
+
return
|
|
217
|
+
try:
|
|
218
|
+
h, _ = win.getmaxyx()
|
|
219
|
+
except (curses.error, AttributeError, TypeError, ValueError):
|
|
220
|
+
return
|
|
221
|
+
_draw_hint_line(win, h - FOOTER_ROW_OFFSET, hint)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def draw_action_button(
|
|
225
|
+
win: curses.window,
|
|
226
|
+
row: int,
|
|
227
|
+
x: int,
|
|
228
|
+
label: str,
|
|
229
|
+
selected: bool = False,
|
|
230
|
+
accent: int = C_ACCENT,
|
|
231
|
+
) -> HitBox:
|
|
232
|
+
"""Draw one explicit action button and return its clickable HitBox."""
|
|
233
|
+
text = f"[ {_sanitize_text(label)} ]"
|
|
234
|
+
try:
|
|
235
|
+
height, width = win.getmaxyx()
|
|
236
|
+
except (curses.error, AttributeError, TypeError, ValueError):
|
|
237
|
+
return HitBox(row, x, 0, 0)
|
|
238
|
+
visible_width = min(len(text), max(0, width - 1 - x))
|
|
239
|
+
visible_height = 1 if visible_width and 0 <= row < height and 0 <= x < width else 0
|
|
240
|
+
if visible_height:
|
|
241
|
+
_put(win, row, x, text, cp(accent) if selected else cp(C_DIM))
|
|
242
|
+
return HitBox(row, x, visible_height, visible_width if visible_height else 0)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def draw_list_row(win: curses.window, row: int, label: str, selected: bool) -> HitBox:
|
|
246
|
+
"""Draw a menu or settings row with a consistent, clickable full-width bar."""
|
|
247
|
+
try:
|
|
248
|
+
height, width = win.getmaxyx()
|
|
249
|
+
except (curses.error, AttributeError, TypeError, ValueError):
|
|
250
|
+
return HitBox(row, MENU_X, 0, 0)
|
|
251
|
+
field_width = max(0, width - MENU_X - RIGHT_INSET)
|
|
252
|
+
visible_width = min(field_width, max(0, width - 1 - MENU_X))
|
|
253
|
+
if visible_width <= 0 or not (0 <= row < height):
|
|
254
|
+
return HitBox(row, MENU_X, 0, 0)
|
|
255
|
+
prefix = " > " if selected else " "
|
|
256
|
+
_put(
|
|
257
|
+
win,
|
|
258
|
+
row,
|
|
259
|
+
MENU_X,
|
|
260
|
+
f"{prefix}{label}".ljust(visible_width),
|
|
261
|
+
cp(C_ACCENT) if selected else cp(C_DIM),
|
|
262
|
+
)
|
|
263
|
+
return HitBox(row, MENU_X, 1, visible_width)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def update_term_size(win: curses.window | None = None) -> None:
|
|
267
|
+
"""Synchronize curses screen dimensions when the terminal resizes."""
|
|
268
|
+
try:
|
|
269
|
+
if hasattr(curses, "update_lines_cols"):
|
|
270
|
+
curses.update_lines_cols()
|
|
271
|
+
except curses.error:
|
|
272
|
+
pass
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def screen_is_usable(win: curses.window) -> bool:
|
|
276
|
+
"""Return True if the terminal window meets minimum 97x37 dimensions."""
|
|
277
|
+
try:
|
|
278
|
+
update_term_size(win)
|
|
279
|
+
h, w = win.getmaxyx()
|
|
280
|
+
return h >= MIN_WIN_HEIGHT and w >= MIN_WIN_WIDTH
|
|
281
|
+
except (curses.error, AttributeError, TypeError, ValueError):
|
|
282
|
+
return False
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def wait_for_usable_screen(win: curses.window) -> bool:
|
|
286
|
+
"""Wait until the terminal meets minimum dimensions; Esc/Q cancels."""
|
|
287
|
+
while draw_chrome(win, "", "Esc/Q = cancel") == -1:
|
|
288
|
+
try:
|
|
289
|
+
win.refresh()
|
|
290
|
+
if win.getch() in (KEY_ESC, ord("q"), ord("Q")):
|
|
291
|
+
return False
|
|
292
|
+
except curses.error:
|
|
293
|
+
return False
|
|
294
|
+
return True
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def draw_chrome(win: curses.window, subtitle: str = "", hint: str = "") -> int:
|
|
298
|
+
"""Draw logo, divider, subtitle, and footer hint. Returns first free content row or -1."""
|
|
299
|
+
update_term_size(win)
|
|
300
|
+
try:
|
|
301
|
+
win.keypad(True)
|
|
302
|
+
except curses.error:
|
|
303
|
+
pass
|
|
304
|
+
win.erase()
|
|
305
|
+
h, w = win.getmaxyx()
|
|
306
|
+
if h < MIN_WIN_HEIGHT or w < MIN_WIN_WIDTH:
|
|
307
|
+
msg = "Terminal is too small"
|
|
308
|
+
sub = f"Minimum size required: {MIN_WIN_WIDTH}x{MIN_WIN_HEIGHT} (Current: {w}x{h})"
|
|
309
|
+
_put(win, max(0, h // 2 - 1), max(0, (w - len(msg)) // 2), msg, cp(C_WARN))
|
|
310
|
+
_put(win, max(0, h // 2), max(0, (w - len(sub)) // 2), sub, cp(C_DIM))
|
|
311
|
+
if hint and h >= 3:
|
|
312
|
+
draw_footer(win, hint)
|
|
313
|
+
return -1
|
|
314
|
+
|
|
315
|
+
y = 0
|
|
316
|
+
for line in LOGO_LINES:
|
|
317
|
+
_put(win, y, CONTENT_X, line, cp(C_TITLE) | curses.A_BOLD)
|
|
318
|
+
y += 1
|
|
319
|
+
y += 1
|
|
320
|
+
_put(win, y, CONTENT_X, "=" * (w - 4), cp(C_TITLE))
|
|
321
|
+
y += 2
|
|
322
|
+
if subtitle:
|
|
323
|
+
_put(win, y, CONTENT_X, subtitle, cp(C_DIM) | curses.A_BOLD)
|
|
324
|
+
y += 2
|
|
325
|
+
if hint:
|
|
326
|
+
draw_footer(win, hint)
|
|
327
|
+
return y
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def draw_signal_meter(
|
|
331
|
+
win: curses.window, row: int, lvl: float, trigger_threshold: float, saturated: bool
|
|
332
|
+
) -> None:
|
|
333
|
+
"""Render signal level progress bar widget with dBFS readout and saturation indicator."""
|
|
334
|
+
try:
|
|
335
|
+
h, w = win.getmaxyx()
|
|
336
|
+
level = float(lvl)
|
|
337
|
+
threshold = float(trigger_threshold)
|
|
338
|
+
if not isinstance(h, int) or not isinstance(w, int):
|
|
339
|
+
return
|
|
340
|
+
except (ValueError, TypeError, curses.error, AttributeError):
|
|
341
|
+
return
|
|
342
|
+
if not math.isfinite(level) or not math.isfinite(threshold):
|
|
343
|
+
return
|
|
344
|
+
|
|
345
|
+
meter_w = max(1, min(40, w - 30))
|
|
346
|
+
bars = max(
|
|
347
|
+
0,
|
|
348
|
+
min(
|
|
349
|
+
meter_w,
|
|
350
|
+
int((level - METER_FLOOR_DBFS) / -METER_FLOOR_DBFS * meter_w),
|
|
351
|
+
),
|
|
352
|
+
)
|
|
353
|
+
_put(win, row, CONTENT_X, "Signal:", cp(C_DIM))
|
|
354
|
+
_put(win, row, 10, "[", cp(C_DIM))
|
|
355
|
+
_put(win, row, 11, "#" * bars, cp(C_BAR))
|
|
356
|
+
_put(
|
|
357
|
+
win,
|
|
358
|
+
row,
|
|
359
|
+
11 + bars,
|
|
360
|
+
"." * (meter_w - bars),
|
|
361
|
+
cp(C_DIM) | curses.A_DIM,
|
|
362
|
+
)
|
|
363
|
+
_put(win, row, 11 + meter_w, "]", cp(C_DIM))
|
|
364
|
+
info = f" {level:6.1f} dBFS thr {threshold:.0f}"
|
|
365
|
+
_put(win, row, 12 + meter_w, info, cp(C_DIM))
|
|
366
|
+
sat_x = 12 + meter_w + len(info) + 1
|
|
367
|
+
if saturated:
|
|
368
|
+
_put(win, row, sat_x, "SAT!", cp(C_ERR) | curses.A_BOLD)
|
|
369
|
+
else:
|
|
370
|
+
_put(win, row, sat_x, " ", 0)
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def draw_gps_status(
|
|
374
|
+
win: curses.window, row: int, status_text: str, has_fix: bool = False
|
|
375
|
+
) -> None:
|
|
376
|
+
"""Render live GPS status line with colored status tags."""
|
|
377
|
+
try:
|
|
378
|
+
h, w = win.getmaxyx()
|
|
379
|
+
if not isinstance(h, int) or not isinstance(w, int):
|
|
380
|
+
return
|
|
381
|
+
except (ValueError, TypeError, curses.error, AttributeError):
|
|
382
|
+
return
|
|
383
|
+
if row >= h - 1:
|
|
384
|
+
return
|
|
385
|
+
lowered = status_text.lower()
|
|
386
|
+
if has_fix:
|
|
387
|
+
color = cp(C_OK) | curses.A_BOLD
|
|
388
|
+
tag = "[FIX] "
|
|
389
|
+
elif any(
|
|
390
|
+
err in lowered
|
|
391
|
+
for err in ("cannot", "lost", "invalid", "permission", "missing", "error")
|
|
392
|
+
):
|
|
393
|
+
color = cp(C_ERR)
|
|
394
|
+
tag = "[ERR] "
|
|
395
|
+
elif "searching" in lowered:
|
|
396
|
+
color = cp(C_WARN)
|
|
397
|
+
tag = "[NO FIX] "
|
|
398
|
+
elif any(wait in lowered for wait in ("connecting", "waiting")):
|
|
399
|
+
color = cp(C_WARN)
|
|
400
|
+
tag = "[WAIT] "
|
|
401
|
+
else:
|
|
402
|
+
color = cp(C_DIM)
|
|
403
|
+
tag = ""
|
|
404
|
+
|
|
405
|
+
label = "GPS: "
|
|
406
|
+
_put(win, row, CONTENT_X, label, cp(C_DIM))
|
|
407
|
+
avail_w = max(0, w - 4 - len(label))
|
|
408
|
+
msg = f"{tag}{status_text}"
|
|
409
|
+
_put(win, row, CONTENT_X + len(label), msg[:avail_w].ljust(avail_w), color)
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def _content_rows(
|
|
413
|
+
win: curses.window, start: int, reserved: int = 2
|
|
414
|
+
) -> tuple[int, bool]:
|
|
415
|
+
"""Rows free for content, and whether the reserved rows below exist."""
|
|
416
|
+
if start < 0:
|
|
417
|
+
return (0, False)
|
|
418
|
+
h, _ = win.getmaxyx()
|
|
419
|
+
room = h - start - reserved
|
|
420
|
+
return (room, True) if room >= 1 else (1, False)
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def _tail(
|
|
424
|
+
log: list[tuple[str, int]], win: curses.window, start: int, reserved: int
|
|
425
|
+
) -> int:
|
|
426
|
+
"""Draw the most recent log lines that fit. Returns the number drawn."""
|
|
427
|
+
avail, _ = _content_rows(win, start, reserved)
|
|
428
|
+
visible = log[-avail:] if avail > 0 else []
|
|
429
|
+
for i, (text, pair) in enumerate(visible):
|
|
430
|
+
_put(win, start + i, CONTENT_X, text, cp(pair))
|
|
431
|
+
return len(visible)
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def _move_cursor(idx: int, key: int, count: int, page: int | None = None) -> int:
|
|
435
|
+
"""Cursor movement shared by every list the user navigates."""
|
|
436
|
+
if count <= 0:
|
|
437
|
+
return 0
|
|
438
|
+
if key in (curses.KEY_UP, ord("k"), ord("K")):
|
|
439
|
+
return (idx - 1) % count
|
|
440
|
+
if key in (curses.KEY_DOWN, ord("j"), ord("J")):
|
|
441
|
+
return (idx + 1) % count
|
|
442
|
+
if page is None:
|
|
443
|
+
return idx
|
|
444
|
+
if key == curses.KEY_NPAGE:
|
|
445
|
+
return min(count - 1, idx + page)
|
|
446
|
+
if key == curses.KEY_PPAGE:
|
|
447
|
+
return max(0, idx - page)
|
|
448
|
+
if key == curses.KEY_HOME:
|
|
449
|
+
return 0
|
|
450
|
+
if key == curses.KEY_END:
|
|
451
|
+
return count - 1
|
|
452
|
+
return idx
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def scroll_view(
|
|
456
|
+
win: curses.window,
|
|
457
|
+
subtitle: str,
|
|
458
|
+
lines: list[tuple[str, int]],
|
|
459
|
+
hint: str = "Enter/Esc = back",
|
|
460
|
+
exit_keys: tuple[int, ...] = (KEY_ESC, ord("q"), ord("Q")) + KEY_ENTER,
|
|
461
|
+
) -> int:
|
|
462
|
+
"""Show scrollable lines until one of exit_keys is pressed. Returns that key."""
|
|
463
|
+
flat_lines: list[tuple[str, int]] = []
|
|
464
|
+
for text, pair in lines:
|
|
465
|
+
for sub in str(text).splitlines():
|
|
466
|
+
flat_lines.append((sub, pair))
|
|
467
|
+
lines = flat_lines or [("", 0)]
|
|
468
|
+
top = 0
|
|
469
|
+
flush_input(win)
|
|
470
|
+
win.nodelay(False)
|
|
471
|
+
while True:
|
|
472
|
+
start = draw_chrome(win, subtitle, hint)
|
|
473
|
+
if start == -1:
|
|
474
|
+
win.refresh()
|
|
475
|
+
k = win.getch()
|
|
476
|
+
if k in exit_keys or k in (KEY_ESC, ord("q"), ord("Q")):
|
|
477
|
+
return k
|
|
478
|
+
continue
|
|
479
|
+
avail, _ = _content_rows(win, start)
|
|
480
|
+
max_top = max(0, len(lines) - avail)
|
|
481
|
+
top = min(top, max_top)
|
|
482
|
+
for i in range(top, min(len(lines), top + avail)):
|
|
483
|
+
text, pair = lines[i]
|
|
484
|
+
_put(win, start + (i - top), CONTENT_X, text, cp(pair))
|
|
485
|
+
win.refresh()
|
|
486
|
+
k = win.getch()
|
|
487
|
+
if k in exit_keys:
|
|
488
|
+
return k
|
|
489
|
+
elif k == getattr(curses, "KEY_RESIZE", -1):
|
|
490
|
+
continue
|
|
491
|
+
elif k in (curses.KEY_UP, ord("k"), ord("K")):
|
|
492
|
+
top = max(0, top - 1)
|
|
493
|
+
elif k in (curses.KEY_DOWN, ord("j"), ord("J")):
|
|
494
|
+
top = min(max_top, top + 1)
|
|
495
|
+
elif k == curses.KEY_NPAGE:
|
|
496
|
+
top = min(max_top, top + avail)
|
|
497
|
+
elif k == curses.KEY_PPAGE:
|
|
498
|
+
top = max(0, top - avail)
|
|
499
|
+
elif k == curses.KEY_HOME:
|
|
500
|
+
top = 0
|
|
501
|
+
elif k == curses.KEY_END:
|
|
502
|
+
top = max_top
|
|
503
|
+
elif k == getattr(curses, "KEY_MOUSE", -1):
|
|
504
|
+
m = get_mouse_event()
|
|
505
|
+
if m is not None:
|
|
506
|
+
_mid, _mx, _my, _mz, bstate = m
|
|
507
|
+
if bstate & WHEEL_UP:
|
|
508
|
+
top = max(0, top - 3)
|
|
509
|
+
elif bstate & WHEEL_DOWN:
|
|
510
|
+
top = min(max_top, top + 3)
|
|
511
|
+
elif bstate & BUTTON_RIGHT:
|
|
512
|
+
if KEY_ESC in exit_keys:
|
|
513
|
+
return KEY_ESC
|
|
514
|
+
return exit_keys[0] if exit_keys else KEY_ESC
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
def message_box(
|
|
518
|
+
win: curses.window,
|
|
519
|
+
title: str,
|
|
520
|
+
lines: list[tuple[str, int]],
|
|
521
|
+
hint: str = "Enter/Esc/Q = back",
|
|
522
|
+
) -> None:
|
|
523
|
+
scroll_view(
|
|
524
|
+
win,
|
|
525
|
+
title,
|
|
526
|
+
lines,
|
|
527
|
+
hint=hint,
|
|
528
|
+
exit_keys=(KEY_ESC, ord("q"), ord("Q")) + KEY_ENTER,
|
|
529
|
+
)
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
@overload
|
|
533
|
+
def menu_select(
|
|
534
|
+
win: curses.window,
|
|
535
|
+
subtitle: str,
|
|
536
|
+
items: list[str],
|
|
537
|
+
start_idx: int = 0,
|
|
538
|
+
hint: str | None = None,
|
|
539
|
+
action_keys: tuple[()] = (),
|
|
540
|
+
hotkeys: bool = False,
|
|
541
|
+
) -> int | None: ...
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
@overload
|
|
545
|
+
def menu_select(
|
|
546
|
+
win: curses.window,
|
|
547
|
+
subtitle: str,
|
|
548
|
+
items: list[str],
|
|
549
|
+
start_idx: int = 0,
|
|
550
|
+
hint: str | None = None,
|
|
551
|
+
action_keys: tuple[int, ...] = ...,
|
|
552
|
+
hotkeys: bool = False,
|
|
553
|
+
) -> tuple[int | None, int]: ...
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
def menu_select(
|
|
557
|
+
win: curses.window,
|
|
558
|
+
subtitle: str,
|
|
559
|
+
items: list[str],
|
|
560
|
+
start_idx: int = 0,
|
|
561
|
+
hint: str | None = None,
|
|
562
|
+
action_keys: tuple[int, ...] = (),
|
|
563
|
+
hotkeys: bool = False,
|
|
564
|
+
) -> int | None | tuple[int | None, int]:
|
|
565
|
+
"""Show a scrolling menu. Returns index, or (index, key) if action_keys is specified, or None on Esc."""
|
|
566
|
+
if not items:
|
|
567
|
+
return (None, KEY_ESC) if action_keys else None
|
|
568
|
+
idx = max(0, min(start_idx, len(items) - 1))
|
|
569
|
+
top = 0
|
|
570
|
+
flush_input(win)
|
|
571
|
+
win.nodelay(False)
|
|
572
|
+
if hint is None:
|
|
573
|
+
hint = "Up/Down = move ▎ Enter = select ▎ Esc/Q = back"
|
|
574
|
+
while True:
|
|
575
|
+
start = draw_chrome(win, subtitle, hint)
|
|
576
|
+
if start == -1:
|
|
577
|
+
win.refresh()
|
|
578
|
+
k = win.getch()
|
|
579
|
+
if k in (KEY_ESC, ord("q"), ord("Q")):
|
|
580
|
+
return (None, KEY_ESC) if action_keys else None
|
|
581
|
+
continue
|
|
582
|
+
avail, _ = _content_rows(win, start)
|
|
583
|
+
if idx < top:
|
|
584
|
+
top = idx
|
|
585
|
+
elif idx >= top + avail:
|
|
586
|
+
top = idx - avail + 1
|
|
587
|
+
top = max(0, min(top, max(0, len(items) - avail)))
|
|
588
|
+
hitboxes: dict[int, HitBox] = {}
|
|
589
|
+
for i in range(top, min(len(items), top + avail)):
|
|
590
|
+
row = start + (i - top)
|
|
591
|
+
hitboxes[i] = draw_list_row(win, row, items[i], i == idx)
|
|
592
|
+
win.refresh()
|
|
593
|
+
k = win.getch()
|
|
594
|
+
if k in KEY_ENTER:
|
|
595
|
+
return (idx, k) if action_keys else idx
|
|
596
|
+
elif k in (KEY_ESC, ord("q"), ord("Q")):
|
|
597
|
+
return (None, KEY_ESC) if action_keys else None
|
|
598
|
+
elif action_keys and k in action_keys:
|
|
599
|
+
return (idx, k)
|
|
600
|
+
elif hotkeys and ord("1") <= k <= ord("9"):
|
|
601
|
+
num_idx = k - ord("1")
|
|
602
|
+
if 0 <= num_idx < len(items):
|
|
603
|
+
return (num_idx, KEY_ENTER[0]) if action_keys else num_idx
|
|
604
|
+
elif hotkeys and 32 <= k <= 126 and chr(k).isalnum():
|
|
605
|
+
ch = chr(k).upper()
|
|
606
|
+
for item_idx, item in enumerate(items):
|
|
607
|
+
clean_item = item.strip()
|
|
608
|
+
if clean_item and clean_item[0].upper() == ch:
|
|
609
|
+
return (item_idx, KEY_ENTER[0]) if action_keys else item_idx
|
|
610
|
+
elif k == getattr(curses, "KEY_RESIZE", -1):
|
|
611
|
+
continue
|
|
612
|
+
elif k == getattr(curses, "KEY_MOUSE", -1):
|
|
613
|
+
m = get_mouse_event()
|
|
614
|
+
if m is not None:
|
|
615
|
+
_mid, mx, my, _mz, bstate = m
|
|
616
|
+
if bstate & WHEEL_UP:
|
|
617
|
+
idx = max(0, idx - 1)
|
|
618
|
+
elif bstate & WHEEL_DOWN:
|
|
619
|
+
idx = min(len(items) - 1, idx + 1)
|
|
620
|
+
elif bstate & BUTTON_RIGHT:
|
|
621
|
+
return (None, KEY_ESC) if action_keys else None
|
|
622
|
+
elif bstate & (BUTTON_CLICK | BUTTON_DOUBLE):
|
|
623
|
+
clicked_idx = top + (my - start)
|
|
624
|
+
hitbox = hitboxes.get(clicked_idx)
|
|
625
|
+
if hitbox is not None and hitbox.contains(mx, my):
|
|
626
|
+
if clicked_idx == idx or (bstate & BUTTON_DOUBLE):
|
|
627
|
+
return (
|
|
628
|
+
(clicked_idx, KEY_ENTER[0])
|
|
629
|
+
if action_keys
|
|
630
|
+
else clicked_idx
|
|
631
|
+
)
|
|
632
|
+
idx = clicked_idx
|
|
633
|
+
else:
|
|
634
|
+
idx = _move_cursor(idx, k, len(items), avail)
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
def confirm_dialog(win: curses.window, subtitle: str, question: str) -> bool:
|
|
638
|
+
"""Ask a confirmation question with keyboard and mouse button support."""
|
|
639
|
+
selected_yes = True
|
|
640
|
+
flush_input(win)
|
|
641
|
+
win.nodelay(False)
|
|
642
|
+
while True:
|
|
643
|
+
start = draw_chrome(
|
|
644
|
+
win,
|
|
645
|
+
subtitle,
|
|
646
|
+
"Left/Right/Space = choose ▎ Enter = confirm ▎ Y = yes ▎ N/Esc = no",
|
|
647
|
+
)
|
|
648
|
+
if start == -1:
|
|
649
|
+
win.refresh()
|
|
650
|
+
key = win.getch()
|
|
651
|
+
if key in (KEY_ESC, ord("n"), ord("N")):
|
|
652
|
+
return False
|
|
653
|
+
continue
|
|
654
|
+
_put(win, start, CONTENT_X, question, cp(C_WARN))
|
|
655
|
+
_put(win, start + 2, CONTENT_X, "Are you sure you want to proceed?", cp(C_DIM))
|
|
656
|
+
yes = draw_action_button(win, start + 4, CONTENT_X, "Y Yes", selected_yes)
|
|
657
|
+
no = draw_action_button(
|
|
658
|
+
win, start + 4, CONTENT_X + yes.width + 2, "N No", not selected_yes
|
|
659
|
+
)
|
|
660
|
+
win.refresh()
|
|
661
|
+
key = win.getch()
|
|
662
|
+
if key in KEY_ENTER:
|
|
663
|
+
return selected_yes
|
|
664
|
+
if key in (ord("y"), ord("Y")):
|
|
665
|
+
return True
|
|
666
|
+
if key in (KEY_ESC, ord("n"), ord("N")):
|
|
667
|
+
return False
|
|
668
|
+
if key in (curses.KEY_LEFT, ord("h"), curses.KEY_RIGHT, ord("l"), ord(" ")):
|
|
669
|
+
selected_yes = not selected_yes
|
|
670
|
+
elif key == getattr(curses, "KEY_RESIZE", -1):
|
|
671
|
+
continue
|
|
672
|
+
elif key == getattr(curses, "KEY_MOUSE", -1):
|
|
673
|
+
event = get_mouse_event()
|
|
674
|
+
if event is not None:
|
|
675
|
+
_mid, mx, my, _mz, state = event
|
|
676
|
+
if state & BUTTON_RIGHT:
|
|
677
|
+
return False
|
|
678
|
+
if state & (BUTTON_CLICK | BUTTON_DOUBLE):
|
|
679
|
+
if yes.contains(mx, my):
|
|
680
|
+
return True
|
|
681
|
+
if no.contains(mx, my):
|
|
682
|
+
return False
|
|
683
|
+
|
|
684
|
+
|
|
685
|
+
def edit_text(
|
|
686
|
+
win: curses.window, prompt: str, initial: str, redraw_fn: Any | None = None
|
|
687
|
+
) -> str | None:
|
|
688
|
+
"""Single-line editor. Returns the edited text, or None on Esc."""
|
|
689
|
+
buf = list(initial)
|
|
690
|
+
pos = len(buf)
|
|
691
|
+
flush_input(win)
|
|
692
|
+
win.nodelay(False)
|
|
693
|
+
set_cursor_visible(True)
|
|
694
|
+
try:
|
|
695
|
+
while True:
|
|
696
|
+
if not screen_is_usable(win):
|
|
697
|
+
draw_chrome(win, "", "Esc = cancel")
|
|
698
|
+
win.refresh()
|
|
699
|
+
k = win.getch()
|
|
700
|
+
if k == KEY_ESC:
|
|
701
|
+
return None
|
|
702
|
+
continue
|
|
703
|
+
|
|
704
|
+
if redraw_fn:
|
|
705
|
+
redraw_fn()
|
|
706
|
+
|
|
707
|
+
h, w = win.getmaxyx()
|
|
708
|
+
row = h - 3
|
|
709
|
+
for r in range(h - 4, h):
|
|
710
|
+
_put(win, r, 0, " " * w)
|
|
711
|
+
_put(win, row - 1, CONTENT_X, prompt, cp(C_WARN))
|
|
712
|
+
field_w = max(1, w - 5)
|
|
713
|
+
off = max(0, pos - field_w + 1)
|
|
714
|
+
_put(win, row, CONTENT_X, "> " + "".join(buf[off : off + field_w]))
|
|
715
|
+
draw_footer(win, "Enter = confirm ▎ Esc = cancel")
|
|
716
|
+
try:
|
|
717
|
+
win.move(row, 4 + pos - off)
|
|
718
|
+
except curses.error:
|
|
719
|
+
pass
|
|
720
|
+
win.refresh()
|
|
721
|
+
k = win.getch()
|
|
722
|
+
if k in KEY_ENTER:
|
|
723
|
+
return "".join(buf)
|
|
724
|
+
elif k == KEY_ESC:
|
|
725
|
+
return None
|
|
726
|
+
elif k == curses.KEY_RESIZE:
|
|
727
|
+
update_term_size(win)
|
|
728
|
+
win.erase()
|
|
729
|
+
elif k in (curses.KEY_BACKSPACE, 127, 8):
|
|
730
|
+
if pos > 0:
|
|
731
|
+
del buf[pos - 1]
|
|
732
|
+
pos -= 1
|
|
733
|
+
elif k == curses.KEY_DC:
|
|
734
|
+
if pos < len(buf):
|
|
735
|
+
del buf[pos]
|
|
736
|
+
elif k == curses.KEY_LEFT:
|
|
737
|
+
pos = max(0, pos - 1)
|
|
738
|
+
elif k == curses.KEY_RIGHT:
|
|
739
|
+
pos = min(len(buf), pos + 1)
|
|
740
|
+
elif k == curses.KEY_HOME:
|
|
741
|
+
pos = 0
|
|
742
|
+
elif k == curses.KEY_END:
|
|
743
|
+
pos = len(buf)
|
|
744
|
+
elif 32 <= k <= 126:
|
|
745
|
+
buf.insert(pos, chr(k))
|
|
746
|
+
pos += 1
|
|
747
|
+
elif k == getattr(curses, "KEY_MOUSE", -1):
|
|
748
|
+
m = get_mouse_event()
|
|
749
|
+
if m is not None:
|
|
750
|
+
_mid, mx, my, _mz, bstate = m
|
|
751
|
+
if bstate & BUTTON_RIGHT:
|
|
752
|
+
return None
|
|
753
|
+
elif (
|
|
754
|
+
bstate & (BUTTON_CLICK | BUTTON_DOUBLE)
|
|
755
|
+
and my == row
|
|
756
|
+
and mx >= 4
|
|
757
|
+
):
|
|
758
|
+
pos = max(0, min(len(buf), off + (mx - 4)))
|
|
759
|
+
finally:
|
|
760
|
+
set_cursor_visible(False)
|
|
761
|
+
|
|
762
|
+
|
|
763
|
+
def parse_number(
|
|
764
|
+
raw: str, cast_type: type, min_val: float, max_val: float
|
|
765
|
+
) -> int | float:
|
|
766
|
+
"""Parse numeric input string with unit multipliers (k, M, G, Hz, SPS).
|
|
767
|
+
|
|
768
|
+
Auto-converts decimal MHz (e.g. 433.92) to Hz when cast_type is int and min_val >= 100_000.
|
|
769
|
+
"""
|
|
770
|
+
cleaned = raw.strip().lower()
|
|
771
|
+
mult = 1.0
|
|
772
|
+
explicit_unit = False
|
|
773
|
+
for suffix, factor in (
|
|
774
|
+
("ghz", 1e9),
|
|
775
|
+
("g", 1e9),
|
|
776
|
+
("msps", 1e6),
|
|
777
|
+
("mhz", 1e6),
|
|
778
|
+
("m", 1e6),
|
|
779
|
+
("khz", 1e3),
|
|
780
|
+
("k", 1e3),
|
|
781
|
+
("sps", 1.0),
|
|
782
|
+
("hz", 1.0),
|
|
783
|
+
):
|
|
784
|
+
if cleaned.endswith(suffix):
|
|
785
|
+
cleaned = cleaned[: -len(suffix)].strip()
|
|
786
|
+
mult = factor
|
|
787
|
+
explicit_unit = True
|
|
788
|
+
break
|
|
789
|
+
|
|
790
|
+
try:
|
|
791
|
+
num = float(cleaned)
|
|
792
|
+
except (ValueError, OverflowError):
|
|
793
|
+
raise ValueError("Invalid number.") from None
|
|
794
|
+
|
|
795
|
+
if not math.isfinite(num):
|
|
796
|
+
raise ValueError("Value must be finite.")
|
|
797
|
+
|
|
798
|
+
num *= mult
|
|
799
|
+
if not math.isfinite(num):
|
|
800
|
+
raise ValueError("Value must be finite.")
|
|
801
|
+
|
|
802
|
+
if (
|
|
803
|
+
cast_type is int
|
|
804
|
+
and not explicit_unit
|
|
805
|
+
and min_val >= 100_000
|
|
806
|
+
and 0 < num < 10_000
|
|
807
|
+
):
|
|
808
|
+
num *= 1e6
|
|
809
|
+
if not math.isfinite(num):
|
|
810
|
+
raise ValueError("Value must be finite.")
|
|
811
|
+
|
|
812
|
+
if cast_type is int:
|
|
813
|
+
rounded = round(num)
|
|
814
|
+
if abs(num - rounded) > 1e-3:
|
|
815
|
+
raise ValueError("Value must be an integer.")
|
|
816
|
+
return int(rounded)
|
|
817
|
+
return num
|
|
818
|
+
|
|
819
|
+
|
|
820
|
+
def edit_number(
|
|
821
|
+
win: curses.window,
|
|
822
|
+
prompt: str,
|
|
823
|
+
initial: Any,
|
|
824
|
+
cast_type: type,
|
|
825
|
+
min_val: float,
|
|
826
|
+
max_val: float,
|
|
827
|
+
redraw_fn: Any | None = None,
|
|
828
|
+
) -> Any | None:
|
|
829
|
+
"""Prompt until a number within range is entered. Returns None on Esc."""
|
|
830
|
+
text = str(initial)
|
|
831
|
+
while True:
|
|
832
|
+
raw = edit_text(
|
|
833
|
+
win, f"{prompt} [{min_val} .. {max_val}]", text, redraw_fn=redraw_fn
|
|
834
|
+
)
|
|
835
|
+
if raw is None:
|
|
836
|
+
return None
|
|
837
|
+
raw = raw.strip()
|
|
838
|
+
if raw == "":
|
|
839
|
+
return initial
|
|
840
|
+
text = raw
|
|
841
|
+
try:
|
|
842
|
+
val = parse_number(raw, cast_type, min_val, max_val)
|
|
843
|
+
except ValueError as err:
|
|
844
|
+
_flash(win, str(err) if str(err) else "Invalid number.", C_ERR)
|
|
845
|
+
continue
|
|
846
|
+
except OverflowError:
|
|
847
|
+
_flash(win, "Invalid number.", C_ERR)
|
|
848
|
+
continue
|
|
849
|
+
if val < min_val:
|
|
850
|
+
_flash(win, f"Must be >= {min_val}", C_ERR)
|
|
851
|
+
continue
|
|
852
|
+
if val > max_val:
|
|
853
|
+
_flash(win, f"Must be <= {max_val}", C_ERR)
|
|
854
|
+
continue
|
|
855
|
+
return val
|
|
856
|
+
|
|
857
|
+
|
|
858
|
+
def _flash(win: curses.window, msg: str, pair: int) -> None:
|
|
859
|
+
h, w = win.getmaxyx()
|
|
860
|
+
_put(win, h - 3, CONTENT_X, msg.ljust(max(0, w - 4)), cp(pair))
|
|
861
|
+
win.refresh()
|
|
862
|
+
time.sleep(FLASH_SECONDS)
|
|
863
|
+
flush_input(win)
|