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.
@@ -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)