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,504 @@
|
|
|
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
|
+
"""Settings screen controller."""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import curses
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
from pluto_siege.config import (
|
|
25
|
+
CONFIG,
|
|
26
|
+
DEFAULT_SETTINGS,
|
|
27
|
+
AppConfig,
|
|
28
|
+
freq_bounds,
|
|
29
|
+
save_settings,
|
|
30
|
+
validate_config,
|
|
31
|
+
)
|
|
32
|
+
from pluto_siege.constants import (
|
|
33
|
+
AUTO_MARGIN_RANGE,
|
|
34
|
+
POST_TRIGGER_RANGE,
|
|
35
|
+
PRE_TRIGGER_RANGE,
|
|
36
|
+
RX_BUFFER_RANGE,
|
|
37
|
+
RX_GAIN_RANGE,
|
|
38
|
+
SAMPLE_RATE_RANGE,
|
|
39
|
+
SILENCE_RANGE,
|
|
40
|
+
THRESHOLD_RANGE,
|
|
41
|
+
TX_GAIN_RANGE,
|
|
42
|
+
)
|
|
43
|
+
from pluto_siege.gps import init_gps
|
|
44
|
+
from pluto_siege.sound import SoundCue, play_cue
|
|
45
|
+
from pluto_siege.ui.widgets.framework import (
|
|
46
|
+
BUTTON_CLICK,
|
|
47
|
+
BUTTON_DOUBLE,
|
|
48
|
+
BUTTON_RIGHT,
|
|
49
|
+
C_ERR,
|
|
50
|
+
C_OK,
|
|
51
|
+
C_WARN,
|
|
52
|
+
KEY_ENTER,
|
|
53
|
+
KEY_ESC,
|
|
54
|
+
WHEEL_DOWN,
|
|
55
|
+
WHEEL_UP,
|
|
56
|
+
_content_rows,
|
|
57
|
+
_flash,
|
|
58
|
+
_move_cursor,
|
|
59
|
+
confirm_dialog,
|
|
60
|
+
draw_chrome,
|
|
61
|
+
draw_list_row,
|
|
62
|
+
edit_number,
|
|
63
|
+
edit_text,
|
|
64
|
+
flush_input,
|
|
65
|
+
get_mouse_event,
|
|
66
|
+
init_mouse,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
SETTINGS_HINT = (
|
|
70
|
+
"Up/Down = move ▎ Left/Right = adjust ▎ Enter = edit ▎ "
|
|
71
|
+
"S = save ▎ R = reset ▎ Esc/Q = back"
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _build_setting_rows(cfg: AppConfig) -> list[dict[str, Any]]:
|
|
76
|
+
freq_range = freq_bounds(cfg)
|
|
77
|
+
return [
|
|
78
|
+
# --- Radio & Connection ---
|
|
79
|
+
{
|
|
80
|
+
"key": "connection_type",
|
|
81
|
+
"label": "Connection type",
|
|
82
|
+
"val": lambda: "ip" if cfg.pluto_uri.startswith("ip:") else "usb",
|
|
83
|
+
"kind": "enum",
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
"key": "pluto_ip",
|
|
87
|
+
"label": "Pluto IP address",
|
|
88
|
+
"val": lambda: (
|
|
89
|
+
f"{cfg.pluto_ip}"
|
|
90
|
+
+ ("" if cfg.pluto_uri.startswith("ip:") else " (unused: USB mode)")
|
|
91
|
+
),
|
|
92
|
+
"kind": "text",
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
"key": "rx_freq",
|
|
96
|
+
"label": "RX frequency (Hz)",
|
|
97
|
+
"val": lambda: f"{cfg.rx_freq} ({cfg.rx_freq / 1e6:.3f} MHz)",
|
|
98
|
+
"kind": "int",
|
|
99
|
+
"range": freq_range,
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
"key": "tx_freq",
|
|
103
|
+
"label": "TX frequency (Hz)",
|
|
104
|
+
"val": lambda: f"{cfg.tx_freq} ({cfg.tx_freq / 1e6:.3f} MHz)",
|
|
105
|
+
"kind": "int",
|
|
106
|
+
"range": freq_range,
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
"key": "permit_out_of_spec_frequency",
|
|
110
|
+
"label": "Extended freq (70MHz-6GHz)",
|
|
111
|
+
"val": lambda: "yes" if cfg.permit_out_of_spec_frequency else "no",
|
|
112
|
+
"kind": "bool",
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
"key": "sample_rate",
|
|
116
|
+
"label": "Sample rate (Hz)",
|
|
117
|
+
"val": lambda: f"{cfg.sample_rate} ({cfg.sample_rate / 1e6:.2f} MSPS)",
|
|
118
|
+
"kind": "int",
|
|
119
|
+
"range": SAMPLE_RATE_RANGE,
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
"key": "rx_gain",
|
|
123
|
+
"label": "RX gain (dB)",
|
|
124
|
+
"val": lambda: f"{cfg.rx_gain:.2f}",
|
|
125
|
+
"kind": "float",
|
|
126
|
+
"range": RX_GAIN_RANGE,
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
"key": "tx_gain",
|
|
130
|
+
"label": "TX gain (dB)",
|
|
131
|
+
"val": lambda: f"{cfg.tx_gain:.2f}",
|
|
132
|
+
"kind": "float",
|
|
133
|
+
"range": TX_GAIN_RANGE,
|
|
134
|
+
},
|
|
135
|
+
# --- Capture Engine & Trigger ---
|
|
136
|
+
{
|
|
137
|
+
"key": "auto_threshold",
|
|
138
|
+
"label": "Auto noise threshold",
|
|
139
|
+
"val": lambda: "yes" if cfg.auto_threshold else "no",
|
|
140
|
+
"kind": "bool",
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
"key": "auto_trigger_margin",
|
|
144
|
+
"label": "Auto trigger margin (dB)",
|
|
145
|
+
"val": lambda: (
|
|
146
|
+
f"{cfg.auto_trigger_margin:.1f}"
|
|
147
|
+
+ ("" if cfg.auto_threshold else " (unused: auto off)")
|
|
148
|
+
),
|
|
149
|
+
"kind": "float",
|
|
150
|
+
"range": AUTO_MARGIN_RANGE,
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
"key": "manual_threshold",
|
|
154
|
+
"label": "Manual threshold (dBFS)",
|
|
155
|
+
"val": lambda: (
|
|
156
|
+
f"{cfg.manual_threshold:.1f}"
|
|
157
|
+
+ ("" if not cfg.auto_threshold else " (unused: auto on)")
|
|
158
|
+
),
|
|
159
|
+
"kind": "float",
|
|
160
|
+
"range": THRESHOLD_RANGE,
|
|
161
|
+
},
|
|
162
|
+
{
|
|
163
|
+
"key": "pre_trigger_buffers",
|
|
164
|
+
"label": "Pre-trigger buffers",
|
|
165
|
+
"val": lambda: str(cfg.pre_trigger_buffers),
|
|
166
|
+
"kind": "int",
|
|
167
|
+
"range": PRE_TRIGGER_RANGE,
|
|
168
|
+
},
|
|
169
|
+
{
|
|
170
|
+
"key": "silence_seconds",
|
|
171
|
+
"label": "Silence seconds",
|
|
172
|
+
"val": lambda: f"{cfg.silence_seconds:.2f}",
|
|
173
|
+
"kind": "float",
|
|
174
|
+
"range": SILENCE_RANGE,
|
|
175
|
+
},
|
|
176
|
+
{
|
|
177
|
+
"key": "max_post_trigger_seconds",
|
|
178
|
+
"label": "Max post-trigger seconds",
|
|
179
|
+
"val": lambda: f"{cfg.max_post_trigger_seconds:.2f}",
|
|
180
|
+
"kind": "float",
|
|
181
|
+
"range": POST_TRIGGER_RANGE,
|
|
182
|
+
},
|
|
183
|
+
{
|
|
184
|
+
"key": "rx_buffer_size",
|
|
185
|
+
"label": "RX buffer size",
|
|
186
|
+
"val": lambda: str(cfg.rx_buffer_size),
|
|
187
|
+
"kind": "int",
|
|
188
|
+
"range": RX_BUFFER_RANGE,
|
|
189
|
+
},
|
|
190
|
+
# --- GPS Geolocation ---
|
|
191
|
+
{
|
|
192
|
+
"key": "enable_gps",
|
|
193
|
+
"label": "Enable GPS (gpsd)",
|
|
194
|
+
"val": lambda: "yes" if cfg.enable_gps else "no",
|
|
195
|
+
"kind": "bool",
|
|
196
|
+
},
|
|
197
|
+
{
|
|
198
|
+
"key": "gpsd_host",
|
|
199
|
+
"label": "gpsd host",
|
|
200
|
+
"val": lambda: (
|
|
201
|
+
cfg.gpsd_host + ("" if cfg.enable_gps else " (unused: GPS off)")
|
|
202
|
+
),
|
|
203
|
+
"kind": "text",
|
|
204
|
+
},
|
|
205
|
+
{
|
|
206
|
+
"key": "gpsd_port",
|
|
207
|
+
"label": "gpsd port",
|
|
208
|
+
"val": lambda: (
|
|
209
|
+
str(cfg.gpsd_port) + ("" if cfg.enable_gps else " (unused: GPS off)")
|
|
210
|
+
),
|
|
211
|
+
"kind": "int",
|
|
212
|
+
"range": (1, 65535),
|
|
213
|
+
},
|
|
214
|
+
# --- Interface & System ---
|
|
215
|
+
{
|
|
216
|
+
"key": "enable_sound",
|
|
217
|
+
"label": "Enable sound effects",
|
|
218
|
+
"val": lambda: "yes" if cfg.enable_sound else "no",
|
|
219
|
+
"kind": "bool",
|
|
220
|
+
},
|
|
221
|
+
{
|
|
222
|
+
"key": "enable_mouse",
|
|
223
|
+
"label": "Enable mouse support",
|
|
224
|
+
"val": lambda: "yes" if cfg.enable_mouse else "no",
|
|
225
|
+
"kind": "bool",
|
|
226
|
+
},
|
|
227
|
+
]
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _guard_inactive_setting(
|
|
231
|
+
key: str, cfg: AppConfig, win: curses.window | None
|
|
232
|
+
) -> bool:
|
|
233
|
+
"""Return True (and flash a warning) if the setting is currently inactive."""
|
|
234
|
+
msg: str | None = None
|
|
235
|
+
if key == "pluto_ip" and not cfg.pluto_uri.startswith("ip:"):
|
|
236
|
+
msg = "USB mode is active. Switch 'Connection type' to 'ip' to set IP address."
|
|
237
|
+
elif not cfg.enable_gps and key in ("gpsd_host", "gpsd_port"):
|
|
238
|
+
msg = "GPS is disabled. Turn on 'Enable GPS (gpsd)' first."
|
|
239
|
+
elif key == "auto_trigger_margin" and not cfg.auto_threshold:
|
|
240
|
+
msg = "Auto margin is inactive (Auto threshold is off)."
|
|
241
|
+
elif key == "manual_threshold" and cfg.auto_threshold:
|
|
242
|
+
msg = "Manual threshold is inactive (Auto threshold is on)."
|
|
243
|
+
|
|
244
|
+
if msg is not None:
|
|
245
|
+
if win is not None:
|
|
246
|
+
_flash(win, msg, C_WARN)
|
|
247
|
+
return True
|
|
248
|
+
return False
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _apply_setting(
|
|
252
|
+
row: dict[str, Any], win: curses.window, cfg: AppConfig, redraw_fn: Any = None
|
|
253
|
+
) -> None:
|
|
254
|
+
key, kind = row["key"], row["kind"]
|
|
255
|
+
label = row["label"]
|
|
256
|
+
|
|
257
|
+
if _guard_inactive_setting(key, cfg, win):
|
|
258
|
+
return
|
|
259
|
+
|
|
260
|
+
if key == "connection_type":
|
|
261
|
+
if cfg.pluto_uri.startswith("ip:"):
|
|
262
|
+
cfg.pluto_uri = "usb:"
|
|
263
|
+
else:
|
|
264
|
+
ip_val = cfg.pluto_ip.strip() if cfg.pluto_ip else "192.168.2.1"
|
|
265
|
+
cfg.pluto_uri = f"ip:{ip_val}"
|
|
266
|
+
return
|
|
267
|
+
|
|
268
|
+
if key == "pluto_ip":
|
|
269
|
+
prompt = f"{label} (e.g. 192.168.2.1)"
|
|
270
|
+
new = edit_text(win, prompt, cfg.pluto_ip, redraw_fn=redraw_fn)
|
|
271
|
+
if new is not None:
|
|
272
|
+
clean_ip = new.strip()
|
|
273
|
+
if clean_ip.startswith("ip:"):
|
|
274
|
+
clean_ip = clean_ip[3:].strip()
|
|
275
|
+
if clean_ip:
|
|
276
|
+
cfg.pluto_ip = clean_ip
|
|
277
|
+
cfg.pluto_uri = f"ip:{clean_ip}"
|
|
278
|
+
return
|
|
279
|
+
|
|
280
|
+
cur_val = getattr(cfg, key)
|
|
281
|
+
if kind == "text":
|
|
282
|
+
prompt = f"{label} (e.g. 127.0.0.1)" if key == "gpsd_host" else label
|
|
283
|
+
new = edit_text(win, prompt, str(cur_val), redraw_fn=redraw_fn)
|
|
284
|
+
if new is not None and new.strip():
|
|
285
|
+
setattr(cfg, key, new.strip())
|
|
286
|
+
elif kind == "bool":
|
|
287
|
+
setattr(cfg, key, not cur_val)
|
|
288
|
+
else:
|
|
289
|
+
cast = int if kind == "int" else float
|
|
290
|
+
lo, hi = row["range"]
|
|
291
|
+
prompt = label
|
|
292
|
+
if key in ("rx_freq", "tx_freq"):
|
|
293
|
+
prompt = f"{label} (e.g. 433.92M or 433920000)"
|
|
294
|
+
elif key == "sample_rate":
|
|
295
|
+
prompt = f"{label} (e.g. 2M or 2000000)"
|
|
296
|
+
new = edit_number(win, prompt, cur_val, cast, lo, hi, redraw_fn=redraw_fn)
|
|
297
|
+
if new is not None:
|
|
298
|
+
setattr(cfg, key, cast(new))
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _step_setting(
|
|
302
|
+
row: dict[str, Any],
|
|
303
|
+
cfg: AppConfig,
|
|
304
|
+
direction: int,
|
|
305
|
+
win: curses.window | None = None,
|
|
306
|
+
) -> bool:
|
|
307
|
+
"""Increment (+1) or decrement (-1) a setting directly in the list."""
|
|
308
|
+
key = row["key"]
|
|
309
|
+
kind = row["kind"]
|
|
310
|
+
|
|
311
|
+
if _guard_inactive_setting(key, cfg, win):
|
|
312
|
+
return False
|
|
313
|
+
|
|
314
|
+
if key == "connection_type":
|
|
315
|
+
cur_is_ip = cfg.pluto_uri.startswith("ip:")
|
|
316
|
+
new_is_ip = direction >= 0
|
|
317
|
+
if new_is_ip != cur_is_ip:
|
|
318
|
+
if new_is_ip:
|
|
319
|
+
ip_val = cfg.pluto_ip.strip() if cfg.pluto_ip else "192.168.2.1"
|
|
320
|
+
cfg.pluto_uri = f"ip:{ip_val}"
|
|
321
|
+
else:
|
|
322
|
+
cfg.pluto_uri = "usb:"
|
|
323
|
+
return True
|
|
324
|
+
return False
|
|
325
|
+
|
|
326
|
+
cur_val = getattr(cfg, key)
|
|
327
|
+
|
|
328
|
+
if kind == "bool":
|
|
329
|
+
new_val = direction >= 0
|
|
330
|
+
if new_val != cur_val:
|
|
331
|
+
setattr(cfg, key, new_val)
|
|
332
|
+
return True
|
|
333
|
+
return False
|
|
334
|
+
|
|
335
|
+
if kind in ("int", "float"):
|
|
336
|
+
lo, hi = row["range"]
|
|
337
|
+
if key == "rx_buffer_size":
|
|
338
|
+
new_buf = (
|
|
339
|
+
min(hi, cur_val * 2 if cur_val < hi else hi)
|
|
340
|
+
if direction > 0
|
|
341
|
+
else max(lo, cur_val // 2 if cur_val > lo else lo)
|
|
342
|
+
)
|
|
343
|
+
if new_buf != cur_val:
|
|
344
|
+
setattr(cfg, key, new_buf)
|
|
345
|
+
return True
|
|
346
|
+
return False
|
|
347
|
+
|
|
348
|
+
steps: dict[str, float] = {
|
|
349
|
+
"rx_gain": 1.0,
|
|
350
|
+
"tx_gain": 1.0,
|
|
351
|
+
"manual_threshold": 1.0,
|
|
352
|
+
"auto_trigger_margin": 0.5,
|
|
353
|
+
"silence_seconds": 0.05,
|
|
354
|
+
"max_post_trigger_seconds": 0.5,
|
|
355
|
+
"rx_freq": 10_000,
|
|
356
|
+
"tx_freq": 10_000,
|
|
357
|
+
"sample_rate": 250_000,
|
|
358
|
+
"pre_trigger_buffers": 1,
|
|
359
|
+
}
|
|
360
|
+
step = steps.get(key, 1 if kind == "int" else 0.5)
|
|
361
|
+
raw_val = cur_val + direction * step
|
|
362
|
+
new_num: int | float = (
|
|
363
|
+
max(int(lo), min(int(hi), round(raw_val)))
|
|
364
|
+
if kind == "int"
|
|
365
|
+
else max(float(lo), min(float(hi), round(raw_val, 2)))
|
|
366
|
+
)
|
|
367
|
+
if new_num != cur_val:
|
|
368
|
+
setattr(cfg, key, new_num)
|
|
369
|
+
return True
|
|
370
|
+
return False
|
|
371
|
+
|
|
372
|
+
return False
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def screen_settings(win: curses.window, config: AppConfig | None = None) -> None:
|
|
376
|
+
cfg = CONFIG if config is None else config
|
|
377
|
+
idx = 0
|
|
378
|
+
top = 0
|
|
379
|
+
win.nodelay(False)
|
|
380
|
+
backup = dict(cfg.to_dict())
|
|
381
|
+
row_hitboxes: dict[int, Any] = {}
|
|
382
|
+
|
|
383
|
+
def render_settings_list(current_rows: list[dict[str, Any]] | None = None) -> int:
|
|
384
|
+
nonlocal top
|
|
385
|
+
rows = _build_setting_rows(cfg) if current_rows is None else current_rows
|
|
386
|
+
start = draw_chrome(win, "Settings", SETTINGS_HINT)
|
|
387
|
+
row_hitboxes.clear()
|
|
388
|
+
if start == -1:
|
|
389
|
+
return -1
|
|
390
|
+
avail, _ = _content_rows(win, start, 4)
|
|
391
|
+
val_col = 38
|
|
392
|
+
if idx < top:
|
|
393
|
+
top = idx
|
|
394
|
+
elif idx >= top + avail:
|
|
395
|
+
top = idx - avail + 1
|
|
396
|
+
top = max(0, min(top, max(0, len(rows) - avail)))
|
|
397
|
+
for i in range(top, min(len(rows), top + avail)):
|
|
398
|
+
row = start + (i - top)
|
|
399
|
+
setting = rows[i]
|
|
400
|
+
value = setting["val"]()
|
|
401
|
+
value_text = f" {value} " if i == idx else value
|
|
402
|
+
label_width = max(0, val_col - 4 - 3)
|
|
403
|
+
sep = " " if not value_text.startswith(" ") and len(setting["label"]) >= label_width else ""
|
|
404
|
+
line = setting["label"].ljust(label_width) + sep + value_text
|
|
405
|
+
row_hitboxes[i] = draw_list_row(win, row, line, i == idx)
|
|
406
|
+
return start
|
|
407
|
+
|
|
408
|
+
def handle_common_key(k: int) -> bool:
|
|
409
|
+
if k in (ord("s"), ord("S")):
|
|
410
|
+
if save_settings(cfg):
|
|
411
|
+
backup.clear()
|
|
412
|
+
backup.update(cfg.to_dict())
|
|
413
|
+
_flash(win, "Settings saved.", C_OK)
|
|
414
|
+
else:
|
|
415
|
+
_flash(win, "Could not write settings file!", C_ERR)
|
|
416
|
+
return True
|
|
417
|
+
elif k in (ord("r"), ord("R")):
|
|
418
|
+
cfg.update(DEFAULT_SETTINGS)
|
|
419
|
+
init_mouse(cfg.enable_mouse)
|
|
420
|
+
init_gps(cfg)
|
|
421
|
+
_flash(win, "Defaults restored.", C_OK)
|
|
422
|
+
return True
|
|
423
|
+
return False
|
|
424
|
+
|
|
425
|
+
def post_change_sync(target_row: dict[str, Any]) -> None:
|
|
426
|
+
flush_input(win)
|
|
427
|
+
before = dict(cfg.to_dict())
|
|
428
|
+
validated = validate_config(before).to_dict()
|
|
429
|
+
cfg.update(validated)
|
|
430
|
+
reset = [key for key, val in validated.items() if before[key] != val]
|
|
431
|
+
if reset:
|
|
432
|
+
_flash(win, "Out of range, reset to default: " + ", ".join(reset), C_WARN)
|
|
433
|
+
init_mouse(cfg.enable_mouse)
|
|
434
|
+
if target_row["key"] in ("enable_gps", "gpsd_host", "gpsd_port"):
|
|
435
|
+
init_gps(cfg)
|
|
436
|
+
if target_row["key"] == "enable_sound" and cfg.enable_sound:
|
|
437
|
+
play_cue(SoundCue.PASS, enabled=True)
|
|
438
|
+
|
|
439
|
+
def handle_exit() -> bool:
|
|
440
|
+
if cfg.to_dict() != backup:
|
|
441
|
+
if confirm_dialog(win, "Unsaved Changes", "Discard unsaved changes?"):
|
|
442
|
+
cfg.update(backup)
|
|
443
|
+
init_mouse(cfg.enable_mouse)
|
|
444
|
+
init_gps(cfg)
|
|
445
|
+
return True
|
|
446
|
+
return False
|
|
447
|
+
return True
|
|
448
|
+
|
|
449
|
+
while True:
|
|
450
|
+
rows = _build_setting_rows(cfg)
|
|
451
|
+
start = render_settings_list(rows)
|
|
452
|
+
if start == -1:
|
|
453
|
+
win.refresh()
|
|
454
|
+
k = win.getch()
|
|
455
|
+
if (k in (KEY_ESC, ord("q"), ord("Q"))) and handle_exit():
|
|
456
|
+
return
|
|
457
|
+
continue
|
|
458
|
+
|
|
459
|
+
avail, _ = _content_rows(win, start, 4)
|
|
460
|
+
win.refresh()
|
|
461
|
+
k = win.getch()
|
|
462
|
+
if handle_common_key(k):
|
|
463
|
+
continue
|
|
464
|
+
|
|
465
|
+
if k in KEY_ENTER or (k == ord(" ") and rows[idx]["kind"] == "bool"):
|
|
466
|
+
_apply_setting(rows[idx], win, cfg, redraw_fn=render_settings_list)
|
|
467
|
+
post_change_sync(rows[idx])
|
|
468
|
+
elif k in (getattr(curses, "KEY_LEFT", 260), ord("h")):
|
|
469
|
+
if _step_setting(rows[idx], cfg, -1, win=win):
|
|
470
|
+
post_change_sync(rows[idx])
|
|
471
|
+
elif k in (getattr(curses, "KEY_RIGHT", 261), ord("l")):
|
|
472
|
+
if _step_setting(rows[idx], cfg, +1, win=win):
|
|
473
|
+
post_change_sync(rows[idx])
|
|
474
|
+
elif k in (KEY_ESC, ord("q"), ord("Q")):
|
|
475
|
+
if handle_exit():
|
|
476
|
+
return
|
|
477
|
+
elif k == getattr(curses, "KEY_MOUSE", -1):
|
|
478
|
+
m = get_mouse_event()
|
|
479
|
+
if m is not None:
|
|
480
|
+
_mid, mx, my, _mz, bstate = m
|
|
481
|
+
if bstate & WHEEL_UP:
|
|
482
|
+
idx = max(0, idx - 1)
|
|
483
|
+
elif bstate & WHEEL_DOWN:
|
|
484
|
+
idx = min(len(rows) - 1, idx + 1)
|
|
485
|
+
elif bstate & BUTTON_RIGHT:
|
|
486
|
+
if handle_exit():
|
|
487
|
+
return
|
|
488
|
+
elif bstate & (BUTTON_CLICK | BUTTON_DOUBLE):
|
|
489
|
+
clicked_idx = top + (my - start)
|
|
490
|
+
hitbox = row_hitboxes.get(clicked_idx)
|
|
491
|
+
if hitbox is not None and hitbox.contains(mx, my):
|
|
492
|
+
if clicked_idx == idx or (bstate & BUTTON_DOUBLE):
|
|
493
|
+
idx = clicked_idx
|
|
494
|
+
_apply_setting(
|
|
495
|
+
rows[clicked_idx],
|
|
496
|
+
win,
|
|
497
|
+
cfg,
|
|
498
|
+
redraw_fn=render_settings_list,
|
|
499
|
+
)
|
|
500
|
+
post_change_sync(rows[clicked_idx])
|
|
501
|
+
else:
|
|
502
|
+
idx = clicked_idx
|
|
503
|
+
else:
|
|
504
|
+
idx = _move_cursor(idx, k, len(rows), avail)
|