thd75_tui/
app.rs

1use std::path::PathBuf;
2use std::time::{Instant, SystemTime};
3
4use kenwood_thd75::memory::MemoryImage;
5use kenwood_thd75::types::{
6    AfGainLevel, BatteryLevel, BeaconMode, Frequency, Mode, PowerLevel, SMeterReading,
7    SquelchLevel, VoxDelay, VoxGain,
8};
9
10/// Path to the MCP cache file.
11///
12/// Platform cache directories (no `dirs` crate needed):
13/// - macOS: `~/Library/Caches`
14/// - Linux: `$XDG_CACHE_HOME` or `~/.cache`
15/// - Windows: `%LOCALAPPDATA%`
16fn cache_path() -> PathBuf {
17    let base = cache_dir().unwrap_or_else(|| PathBuf::from("."));
18    base.join("thd75-tui").join("mcp.bin")
19}
20
21/// Platform-specific cache directory.
22fn cache_dir() -> Option<PathBuf> {
23    #[cfg(target_os = "macos")]
24    {
25        std::env::var_os("HOME").map(|h| PathBuf::from(h).join("Library/Caches"))
26    }
27    #[cfg(target_os = "windows")]
28    {
29        std::env::var_os("LOCALAPPDATA").map(PathBuf::from)
30    }
31    #[cfg(not(any(target_os = "macos", target_os = "windows")))]
32    {
33        std::env::var_os("XDG_CACHE_HOME")
34            .map(PathBuf::from)
35            .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".cache")))
36    }
37}
38
39/// Save raw MCP image to disk cache.
40///
41/// Logs errors but does not propagate — a failed cache write should not
42/// block radio operation. The user will see a warning in the log.
43pub(crate) fn save_cache(data: &[u8]) {
44    let path = cache_path();
45    if let Some(parent) = path.parent()
46        && let Err(e) = std::fs::create_dir_all(parent)
47    {
48        tracing::error!(path = %parent.display(), "failed to create cache dir: {e}");
49        return;
50    }
51    if let Err(e) = std::fs::write(&path, data) {
52        tracing::error!(path = %path.display(), "failed to write MCP cache: {e}");
53    }
54}
55
56/// Load cached MCP image from disk. Returns (image, age).
57pub(crate) fn load_cache() -> Option<(MemoryImage, std::time::Duration)> {
58    let path = cache_path();
59    let data = std::fs::read(&path).ok()?;
60    let age = std::fs::metadata(&path)
61        .ok()
62        .and_then(|m| m.modified().ok())
63        .and_then(|t| SystemTime::now().duration_since(t).ok())
64        .unwrap_or_default();
65    let image = MemoryImage::from_raw(data).ok()?;
66    Some((image, age))
67}
68
69/// Number of rows in the settings list (must match `SettingRow::ALL.len()`).
70pub(crate) const SETTINGS_COUNT: usize = 92;
71
72/// Settings row identifiers for the interactive settings list.
73///
74/// Organized by the radio's menu groups. Settings backed by CAT commands are
75/// noted; all others modify the in-memory MCP image and require an MCP write
76/// to take effect.
77#[derive(Debug, Clone, Copy, PartialEq, Eq)]
78pub(crate) enum SettingRow {
79    // --- RX ---
80    /// Squelch level Band A (CAT: SQ band,level).
81    SquelchA,
82    /// Squelch level Band B (CAT: SQ band,level).
83    SquelchB,
84    /// Step size Band A (CAT: SF read/write).
85    StepSizeA,
86    /// Step size Band B (CAT: SF read/write).
87    StepSizeB,
88    /// Fine step (CAT: FS read-only, no band parameter).
89    FineStep,
90    /// Filter width SSB (CAT: SH read-only).
91    FilterWidthSsb,
92    /// Filter width CW (CAT: SH read-only).
93    FilterWidthCw,
94    /// Filter width AM (CAT: SH read-only).
95    FilterWidthAm,
96    /// FM narrow (MCP only).
97    FmNarrow,
98    /// SSB high-cut filter (MCP only).
99    SsbHighCut,
100    /// CW high-cut filter (MCP only).
101    CwHighCut,
102    /// AM high-cut filter (MCP only).
103    AmHighCut,
104    /// Auto filter (MCP only).
105    AutoFilter,
106
107    // --- Scan ---
108    /// Scan resume mode (MCP only).
109    ScanResume,
110    /// Digital scan resume (MCP only).
111    DigitalScanResume,
112    /// Scan restart time (MCP only).
113    ScanRestartTime,
114    /// Scan restart carrier (MCP only).
115    ScanRestartCarrier,
116
117    // --- TX ---
118    /// Timeout timer (MCP only).
119    TimeoutTimer,
120    /// TX inhibit (MCP only).
121    TxInhibit,
122    /// Beat shift (MCP only).
123    BeatShift,
124
125    // --- VOX ---
126    /// VOX enabled (CAT: VX).
127    VoxEnabled,
128    /// VOX gain 0-9 (CAT: VG).
129    VoxGain,
130    /// VOX delay ×100ms (CAT: VD).
131    VoxDelay,
132    /// VOX TX on busy (MCP only).
133    VoxTxOnBusy,
134
135    // --- CW ---
136    /// CW break-in (MCP only).
137    CwBreakIn,
138    /// CW delay time (MCP only).
139    CwDelayTime,
140    /// CW pitch (MCP only).
141    CwPitch,
142
143    // --- DTMF ---
144    /// DTMF speed (MCP only).
145    DtmfSpeed,
146    /// DTMF pause time (MCP only).
147    DtmfPauseTime,
148    /// DTMF TX hold (MCP only).
149    DtmfTxHold,
150
151    // --- Repeater ---
152    /// Repeater auto offset (MCP only).
153    RepeaterAutoOffset,
154    /// Repeater call key function (MCP only).
155    RepeaterCallKey,
156
157    // --- Auxiliary ---
158    /// Microphone sensitivity (MCP only).
159    MicSensitivity,
160    /// PF key 1 assignment (MCP only).
161    PfKey1,
162    /// PF key 2 assignment (MCP only).
163    PfKey2,
164
165    // --- Lock ---
166    /// Lock (CAT: LC).
167    Lock,
168    /// Key lock type (MCP only).
169    KeyLockType,
170    /// Lock key A (MCP only).
171    LockKeyA,
172    /// Lock key B (MCP only).
173    LockKeyB,
174    /// Lock key C (MCP only).
175    LockKeyC,
176    /// Lock PTT (MCP only).
177    LockPtt,
178    /// APRS lock (MCP only).
179    AprsLock,
180
181    // --- Display ---
182    /// Dual display size (MCP only).
183    DualDisplaySize,
184    /// Display area (MCP only).
185    DisplayArea,
186    /// Info line (MCP only).
187    InfoLine,
188    /// Backlight control (MCP only).
189    BacklightControl,
190    /// Backlight timer (MCP only).
191    BacklightTimer,
192    /// Display hold time (MCP only).
193    DisplayHoldTime,
194    /// Display method (MCP only).
195    DisplayMethod,
196    /// Power-on display (MCP only).
197    PowerOnDisplay,
198    /// Dual band (CAT: DL).
199    DualBand,
200
201    // --- Audio ---
202    /// EMR volume level (MCP only).
203    EmrVolumeLevel,
204    /// Auto mute return time (MCP only).
205    AutoMuteReturnTime,
206    /// Announce (MCP only).
207    Announce,
208    /// Key beep (MCP only).
209    KeyBeep,
210    /// Beep volume 1-7 (MCP only).
211    BeepVolume,
212    /// Voice language (MCP only).
213    VoiceLanguage,
214    /// Voice volume (MCP only).
215    VoiceVolume,
216    /// Voice speed (MCP only).
217    VoiceSpeed,
218    /// Volume lock (MCP only).
219    VolumeLock,
220
221    // --- Units ---
222    /// Speed/distance unit (MCP only).
223    SpeedDistanceUnit,
224    /// Altitude/rain unit (MCP only).
225    AltitudeRainUnit,
226    /// Temperature unit (MCP only).
227    TemperatureUnit,
228
229    // --- Bluetooth ---
230    /// Bluetooth (CAT: BT).
231    Bluetooth,
232    /// Bluetooth auto-connect (MCP only).
233    BtAutoConnect,
234
235    // --- Interface ---
236    /// GPS/BT interface (MCP only).
237    GpsBtInterface,
238    /// PC output mode (MCP only).
239    PcOutputMode,
240    /// APRS USB mode (MCP only).
241    AprsUsbMode,
242    /// USB audio output (MCP only).
243    UsbAudioOutput,
244    /// Internet link (MCP only).
245    InternetLink,
246
247    // --- System ---
248    /// Language (MCP only).
249    Language,
250    /// Power-on message flag (MCP only).
251    PowerOnMessageFlag,
252
253    // --- Battery ---
254    /// Battery saver (MCP only).
255    BatterySaver,
256    /// Auto power off (MCP only).
257    AutoPowerOff,
258
259    // --- CAT-only Radio Controls ---
260    /// Power level Band A (CAT: PC).
261    PowerA,
262    /// Power level Band B (CAT: PC).
263    PowerB,
264    /// Attenuator Band A (CAT: RA).
265    AttenuatorA,
266    /// Attenuator Band B (CAT: RA).
267    AttenuatorB,
268    /// Mode Band A (CAT: MD).
269    ModeA,
270    /// Mode Band B (CAT: MD).
271    ModeB,
272    /// Active band A/B (CAT: BC).
273    ActiveBand,
274    /// VFO/Memory mode Band A (CAT: VM).
275    VfoMemModeA,
276    /// VFO/Memory mode Band B (CAT: VM).
277    VfoMemModeB,
278    /// FM Radio on/off (CAT: FR).
279    FmRadio,
280    /// TNC baud rate (CAT: AS).
281    TncBaud,
282    /// Beacon type (CAT: PT).
283    BeaconType,
284    /// GPS enabled (CAT: GP).
285    GpsEnabled,
286    /// GPS PC output (CAT: GP).
287    GpsPcOutput,
288    /// Auto-info notifications (CAT: AI).
289    AutoInfo,
290    /// D-STAR callsign slot (CAT: CS).
291    CallsignSlot,
292    /// D-STAR slot (CAT: DS).
293    DstarSlot,
294    /// Scan resume method (CAT: SR write-only).
295    ScanResumeCat,
296}
297
298impl SettingRow {
299    /// All settings rows in display order.
300    pub(crate) const ALL: [Self; SETTINGS_COUNT] = [
301        // RX
302        Self::SquelchA,
303        Self::SquelchB,
304        Self::StepSizeA,
305        Self::StepSizeB,
306        Self::FineStep,
307        Self::FilterWidthSsb,
308        Self::FilterWidthCw,
309        Self::FilterWidthAm,
310        Self::FmNarrow,
311        Self::SsbHighCut,
312        Self::CwHighCut,
313        Self::AmHighCut,
314        Self::AutoFilter,
315        // Scan
316        Self::ScanResume,
317        Self::DigitalScanResume,
318        Self::ScanRestartTime,
319        Self::ScanRestartCarrier,
320        // TX
321        Self::TimeoutTimer,
322        Self::TxInhibit,
323        Self::BeatShift,
324        // VOX
325        Self::VoxEnabled,
326        Self::VoxGain,
327        Self::VoxDelay,
328        Self::VoxTxOnBusy,
329        // CW
330        Self::CwBreakIn,
331        Self::CwDelayTime,
332        Self::CwPitch,
333        // DTMF
334        Self::DtmfSpeed,
335        Self::DtmfPauseTime,
336        Self::DtmfTxHold,
337        // Repeater
338        Self::RepeaterAutoOffset,
339        Self::RepeaterCallKey,
340        // Auxiliary
341        Self::MicSensitivity,
342        Self::PfKey1,
343        Self::PfKey2,
344        // Lock
345        Self::Lock,
346        Self::KeyLockType,
347        Self::LockKeyA,
348        Self::LockKeyB,
349        Self::LockKeyC,
350        Self::LockPtt,
351        Self::AprsLock,
352        // Display
353        Self::DualDisplaySize,
354        Self::DisplayArea,
355        Self::InfoLine,
356        Self::BacklightControl,
357        Self::BacklightTimer,
358        Self::DisplayHoldTime,
359        Self::DisplayMethod,
360        Self::PowerOnDisplay,
361        Self::DualBand,
362        // Audio
363        Self::EmrVolumeLevel,
364        Self::AutoMuteReturnTime,
365        Self::Announce,
366        Self::KeyBeep,
367        Self::BeepVolume,
368        Self::VoiceLanguage,
369        Self::VoiceVolume,
370        Self::VoiceSpeed,
371        Self::VolumeLock,
372        // Units
373        Self::SpeedDistanceUnit,
374        Self::AltitudeRainUnit,
375        Self::TemperatureUnit,
376        // Bluetooth
377        Self::Bluetooth,
378        Self::BtAutoConnect,
379        // Interface
380        Self::GpsBtInterface,
381        Self::PcOutputMode,
382        Self::AprsUsbMode,
383        Self::UsbAudioOutput,
384        Self::InternetLink,
385        // System
386        Self::Language,
387        Self::PowerOnMessageFlag,
388        // Battery
389        Self::BatterySaver,
390        Self::AutoPowerOff,
391        // CAT Radio Controls
392        Self::PowerA,
393        Self::PowerB,
394        Self::AttenuatorA,
395        Self::AttenuatorB,
396        Self::ModeA,
397        Self::ModeB,
398        Self::ActiveBand,
399        Self::VfoMemModeA,
400        Self::VfoMemModeB,
401        Self::FmRadio,
402        Self::TncBaud,
403        Self::BeaconType,
404        Self::GpsEnabled,
405        Self::GpsPcOutput,
406        Self::AutoInfo,
407        Self::CallsignSlot,
408        Self::DstarSlot,
409        Self::ScanResumeCat,
410    ];
411
412    /// Human-readable label for the setting.
413    pub(crate) const fn label(self) -> &'static str {
414        match self {
415            Self::SquelchA => "Squelch A",
416            Self::SquelchB => "Squelch B",
417            Self::StepSizeA => "Step Size A",
418            Self::StepSizeB => "Step Size B",
419            Self::FineStep => "Fine Step",
420            Self::FilterWidthSsb => "Filter Width SSB",
421            Self::FilterWidthCw => "Filter Width CW",
422            Self::FilterWidthAm => "Filter Width AM",
423            Self::FmNarrow => "FM Narrow",
424            Self::SsbHighCut => "SSB High Cut",
425            Self::CwHighCut => "CW High Cut",
426            Self::AmHighCut => "AM High Cut",
427            Self::AutoFilter => "Auto Filter",
428            Self::ScanResume => "Scan Resume",
429            Self::DigitalScanResume => "Digital Scan Resume",
430            Self::ScanRestartTime => "Scan Restart Time",
431            Self::ScanRestartCarrier => "Scan Restart Carrier",
432            Self::TimeoutTimer => "Timeout Timer",
433            Self::TxInhibit => "TX Inhibit",
434            Self::BeatShift => "Beat Shift",
435            Self::VoxEnabled => "VOX",
436            Self::VoxGain => "VOX Gain",
437            Self::VoxDelay => "VOX Delay",
438            Self::VoxTxOnBusy => "VOX TX on Busy",
439            Self::CwBreakIn => "CW Break-In",
440            Self::CwDelayTime => "CW Delay Time",
441            Self::CwPitch => "CW Pitch",
442            Self::DtmfSpeed => "DTMF Speed",
443            Self::DtmfPauseTime => "DTMF Pause Time",
444            Self::DtmfTxHold => "DTMF TX Hold",
445            Self::RepeaterAutoOffset => "Repeater Auto Offset",
446            Self::RepeaterCallKey => "Call Key Function",
447            Self::MicSensitivity => "Mic Sensitivity",
448            Self::PfKey1 => "PF Key 1",
449            Self::PfKey2 => "PF Key 2",
450            Self::Lock => "Lock",
451            Self::KeyLockType => "Key Lock Type",
452            Self::LockKeyA => "Lock Key A",
453            Self::LockKeyB => "Lock Key B",
454            Self::LockKeyC => "Lock Key C",
455            Self::LockPtt => "Lock PTT",
456            Self::AprsLock => "APRS Lock",
457            Self::DualDisplaySize => "Dual Display Size",
458            Self::DisplayArea => "Display Area",
459            Self::InfoLine => "Info Line",
460            Self::BacklightControl => "Backlight Control",
461            Self::BacklightTimer => "Backlight Timer",
462            Self::DisplayHoldTime => "Display Hold Time",
463            Self::DisplayMethod => "Display Method",
464            Self::PowerOnDisplay => "Power-On Display",
465            Self::DualBand => "Dual Band",
466            Self::EmrVolumeLevel => "EMR Volume Level",
467            Self::AutoMuteReturnTime => "Auto Mute Return",
468            Self::Announce => "Announce",
469            Self::KeyBeep => "Key Beep",
470            Self::BeepVolume => "Beep Volume",
471            Self::VoiceLanguage => "Voice Language",
472            Self::VoiceVolume => "Voice Volume",
473            Self::VoiceSpeed => "Voice Speed",
474            Self::VolumeLock => "Volume Lock",
475            Self::SpeedDistanceUnit => "Speed/Distance Unit",
476            Self::AltitudeRainUnit => "Altitude/Rain Unit",
477            Self::TemperatureUnit => "Temperature Unit",
478            Self::Bluetooth => "Bluetooth",
479            Self::BtAutoConnect => "BT Auto Connect",
480            Self::GpsBtInterface => "GPS/BT Interface",
481            Self::PcOutputMode => "PC Output Mode",
482            Self::AprsUsbMode => "APRS USB Mode",
483            Self::UsbAudioOutput => "USB Audio Output",
484            Self::InternetLink => "Internet Link",
485            Self::Language => "Language",
486            Self::PowerOnMessageFlag => "Power-On Msg Flag",
487            Self::BatterySaver => "Battery Saver",
488            Self::AutoPowerOff => "Auto Power Off",
489            Self::PowerA => "Power A",
490            Self::PowerB => "Power B",
491            Self::AttenuatorA => "Attenuator A",
492            Self::AttenuatorB => "Attenuator B",
493            Self::ModeA => "Mode A",
494            Self::ModeB => "Mode B",
495            Self::ActiveBand => "Active Band",
496            Self::VfoMemModeA => "VFO/Mem A",
497            Self::VfoMemModeB => "VFO/Mem B",
498            Self::FmRadio => "FM Radio",
499            Self::TncBaud => "TNC Baud",
500            Self::BeaconType => "Beacon Type",
501            Self::GpsEnabled => "GPS Enabled",
502            Self::GpsPcOutput => "GPS PC Output",
503            Self::AutoInfo => "Auto Info",
504            Self::CallsignSlot => "Callsign Slot",
505            Self::DstarSlot => "D-STAR Slot",
506            Self::ScanResumeCat => "Scan Resume (CAT)",
507        }
508    }
509
510    /// Section header label shown above this row. `None` means same group as previous row.
511    pub(crate) const fn section_header(self) -> Option<&'static str> {
512        match self {
513            Self::SquelchA => Some("── RX ──"),
514            Self::ScanResume => Some("── Scan ──"),
515            Self::TimeoutTimer => Some("── TX ──"),
516            Self::VoxEnabled => Some("── VOX ──"),
517            Self::CwBreakIn => Some("── CW ──"),
518            Self::DtmfSpeed => Some("── DTMF ──"),
519            Self::RepeaterAutoOffset => Some("── Repeater ──"),
520            Self::MicSensitivity => Some("── Auxiliary ──"),
521            Self::Lock => Some("── Lock ──"),
522            Self::DualDisplaySize => Some("── Display ──"),
523            Self::EmrVolumeLevel => Some("── Audio ──"),
524            Self::SpeedDistanceUnit => Some("── Units ──"),
525            Self::Bluetooth => Some("── Bluetooth ──"),
526            Self::GpsBtInterface => Some("── Interface ──"),
527            Self::Language => Some("── System ──"),
528            Self::BatterySaver => Some("── Battery ──"),
529            Self::PowerA => Some("── Radio Controls ──"),
530            _ => None,
531        }
532    }
533
534    /// True if this setting is adjusted with +/- rather than toggled with Enter.
535    pub(crate) const fn is_numeric(self) -> bool {
536        matches!(
537            self,
538            Self::SquelchA
539                | Self::SquelchB
540                | Self::StepSizeA
541                | Self::StepSizeB
542                | Self::ScanResumeCat
543                | Self::FmNarrow
544                | Self::SsbHighCut
545                | Self::CwHighCut
546                | Self::AmHighCut
547                | Self::AutoFilter
548                | Self::ScanResume
549                | Self::DigitalScanResume
550                | Self::ScanRestartTime
551                | Self::ScanRestartCarrier
552                | Self::TimeoutTimer
553                | Self::VoxGain
554                | Self::VoxDelay
555                | Self::CwDelayTime
556                | Self::CwPitch
557                | Self::DtmfSpeed
558                | Self::DtmfPauseTime
559                | Self::RepeaterCallKey
560                | Self::MicSensitivity
561                | Self::PfKey1
562                | Self::PfKey2
563                | Self::KeyLockType
564                | Self::DualDisplaySize
565                | Self::DisplayArea
566                | Self::InfoLine
567                | Self::BacklightControl
568                | Self::BacklightTimer
569                | Self::DisplayHoldTime
570                | Self::DisplayMethod
571                | Self::PowerOnDisplay
572                | Self::EmrVolumeLevel
573                | Self::AutoMuteReturnTime
574                | Self::BeepVolume
575                | Self::VoiceLanguage
576                | Self::VoiceVolume
577                | Self::VoiceSpeed
578                | Self::SpeedDistanceUnit
579                | Self::AltitudeRainUnit
580                | Self::TemperatureUnit
581                | Self::GpsBtInterface
582                | Self::PcOutputMode
583                | Self::AprsUsbMode
584                | Self::AutoPowerOff
585                | Self::PowerA
586                | Self::PowerB
587                | Self::ModeA
588                | Self::ModeB
589                | Self::ActiveBand
590                | Self::VfoMemModeA
591                | Self::VfoMemModeB
592                | Self::TncBaud
593                | Self::BeaconType
594                | Self::CallsignSlot
595                | Self::DstarSlot
596        )
597    }
598
599    /// True if this setting is writable via instant CAT command (no disconnect).
600    pub(crate) const fn is_cat(self) -> bool {
601        matches!(
602            self,
603            Self::SquelchA
604                | Self::SquelchB
605                | Self::StepSizeA
606                | Self::StepSizeB
607                | Self::FineStep
608                | Self::FilterWidthSsb
609                | Self::FilterWidthCw
610                | Self::FilterWidthAm
611                | Self::VoxEnabled
612                | Self::VoxGain
613                | Self::VoxDelay
614                | Self::Lock
615                | Self::DualBand
616                | Self::Bluetooth
617                | Self::PowerA
618                | Self::PowerB
619                | Self::AttenuatorA
620                | Self::AttenuatorB
621                | Self::ModeA
622                | Self::ModeB
623                | Self::ActiveBand
624                | Self::VfoMemModeA
625                | Self::VfoMemModeB
626                | Self::FmRadio
627                | Self::TncBaud
628                | Self::BeaconType
629                | Self::GpsEnabled
630                | Self::GpsPcOutput
631                | Self::AutoInfo
632                | Self::CallsignSlot
633                | Self::DstarSlot
634                | Self::ScanResumeCat
635        )
636    }
637}
638
639/// Settings that use instant CAT writes (no disconnect).
640pub(crate) fn cat_settings() -> Vec<SettingRow> {
641    SettingRow::ALL
642        .iter()
643        .copied()
644        .filter(|r| r.is_cat())
645        .collect()
646}
647
648/// Settings that require MCP page write (~3s, brief disconnect).
649pub(crate) fn mcp_settings() -> Vec<SettingRow> {
650    SettingRow::ALL
651        .iter()
652        .copied()
653        .filter(|r| !r.is_cat())
654        .collect()
655}
656
657const fn on_off(b: bool) -> &'static str {
658    if b { "On" } else { "Off" }
659}
660
661/// Which pane currently has input focus.
662#[derive(Debug, Clone, Copy, PartialEq, Eq)]
663pub(crate) enum Pane {
664    BandA,
665    BandB,
666    Main,
667    Detail,
668}
669
670impl Pane {
671    pub(crate) const fn next(self) -> Self {
672        match self {
673            Self::BandA => Self::BandB,
674            Self::BandB => Self::Main,
675            Self::Main => Self::Detail,
676            Self::Detail => Self::BandA,
677        }
678    }
679
680    pub(crate) const fn prev(self) -> Self {
681        match self {
682            Self::BandA => Self::Detail,
683            Self::BandB => Self::BandA,
684            Self::Main => Self::BandB,
685            Self::Detail => Self::Main,
686        }
687    }
688}
689
690/// Which view is shown in the main pane.
691#[derive(Debug, Clone, Copy, PartialEq, Eq)]
692pub(crate) enum MainView {
693    Channels,
694    /// CAT settings — instant, no disconnect.
695    SettingsCat,
696    /// MCP settings — ~3s per change, brief disconnect.
697    SettingsMcp,
698    Aprs,
699    DStar,
700    Gps,
701    Mcp,
702    /// FM broadcast radio control (76-108 MHz WFM on Band B).
703    FmRadio,
704}
705
706/// Which field is selected in channel edit mode.
707#[derive(Debug, Clone, Copy, PartialEq, Eq)]
708pub(crate) enum ChannelEditField {
709    Frequency,
710    Name,
711    Mode,
712    ToneMode,
713    ToneFreq,
714    Duplex,
715    Offset,
716}
717
718impl ChannelEditField {
719    pub(crate) const fn next(self) -> Self {
720        match self {
721            Self::Frequency => Self::Name,
722            Self::Name => Self::Mode,
723            Self::Mode => Self::ToneMode,
724            Self::ToneMode => Self::ToneFreq,
725            Self::ToneFreq => Self::Duplex,
726            Self::Duplex => Self::Offset,
727            Self::Offset => Self::Frequency,
728        }
729    }
730
731    pub(crate) const fn label(self) -> &'static str {
732        match self {
733            Self::Frequency => "Frequency",
734            Self::Name => "Name",
735            Self::Mode => "Mode",
736            Self::ToneMode => "Tone Mode",
737            Self::ToneFreq => "Tone Freq",
738            Self::Duplex => "Duplex",
739            Self::Offset => "Offset",
740        }
741    }
742}
743
744/// Input mode for the UI.
745#[derive(Debug, Clone, PartialEq, Eq)]
746pub(crate) enum InputMode {
747    Normal,
748    /// Searching channels — buffer holds the search string.
749    Search(String),
750    /// Entering a frequency — buffer holds digits typed so far.
751    FreqInput(String),
752}
753
754/// Live state for one band, updated by the radio poller.
755#[derive(Debug, Clone)]
756pub(crate) struct BandState {
757    pub frequency: Frequency,
758    pub mode: Mode,
759    /// S-meter level (0–5). Driven by AI-pushed BY notifications, not polled.
760    pub s_meter: SMeterReading,
761    /// Squelch setting (0–6 on D75).
762    pub squelch: SquelchLevel,
763    pub power_level: PowerLevel,
764    /// Squelch is open (receiving). Driven by AI-pushed BY notifications.
765    pub busy: bool,
766    pub attenuator: bool,
767    pub step_size: Option<kenwood_thd75::types::StepSize>,
768}
769
770impl Default for BandState {
771    fn default() -> Self {
772        Self {
773            frequency: Frequency::new(145_000_000),
774            mode: Mode::Fm,
775            s_meter: SMeterReading::new(0).unwrap(),
776            squelch: SquelchLevel::new(0).unwrap(),
777            power_level: PowerLevel::High,
778            busy: false,
779            attenuator: false,
780            step_size: None,
781        }
782    }
783}
784
785/// Aggregated radio state from the poller.
786#[derive(Debug, Clone)]
787#[allow(clippy::struct_excessive_bools)]
788pub(crate) struct RadioState {
789    pub band_a: BandState,
790    pub band_b: BandState,
791    pub battery_level: BatteryLevel,
792    pub beep: bool,
793    pub lock: bool,
794    pub dual_band: bool,
795    pub bluetooth: bool,
796    pub vox: bool,
797    pub vox_gain: VoxGain,
798    pub vox_delay: VoxDelay,
799    pub af_gain: AfGainLevel,
800    pub firmware_version: String,
801    pub radio_type: String,
802    pub gps_enabled: bool,
803    pub gps_pc_output: bool,
804    /// NMEA sentence enable flags: (GGA, GLL, GSA, GSV, RMC, VTG).
805    pub gps_sentences: Option<(bool, bool, bool, bool, bool, bool)>,
806    /// GPS/Radio operating mode (GM read).
807    pub gps_mode: Option<kenwood_thd75::types::GpsRadioMode>,
808    pub beacon_type: BeaconMode,
809    pub fine_step: Option<kenwood_thd75::types::FineStep>,
810    pub filter_width_ssb: Option<kenwood_thd75::types::FilterWidthIndex>,
811    pub filter_width_cw: Option<kenwood_thd75::types::FilterWidthIndex>,
812    pub filter_width_am: Option<kenwood_thd75::types::FilterWidthIndex>,
813    /// Last-written scan resume method (write-only, not readable from D75).
814    pub scan_resume_cat: Option<kenwood_thd75::types::ScanResumeMethod>,
815    /// D-STAR URCALL callsign (8-char, space-padded).
816    pub dstar_urcall: String,
817    /// D-STAR URCALL suffix (4-char, space-padded).
818    pub dstar_urcall_suffix: String,
819    /// D-STAR RPT1 callsign.
820    pub dstar_rpt1: String,
821    /// D-STAR RPT1 suffix.
822    pub dstar_rpt1_suffix: String,
823    /// D-STAR RPT2 callsign.
824    pub dstar_rpt2: String,
825    /// D-STAR RPT2 suffix.
826    pub dstar_rpt2_suffix: String,
827    /// D-STAR gateway mode.
828    pub dstar_gateway_mode: Option<kenwood_thd75::types::DvGatewayMode>,
829    /// Active D-STAR slot.
830    pub dstar_slot: Option<kenwood_thd75::types::DstarSlot>,
831    /// Active callsign slot.
832    pub dstar_callsign_slot: Option<kenwood_thd75::types::CallsignSlot>,
833}
834
835impl Default for RadioState {
836    fn default() -> Self {
837        Self {
838            band_a: BandState::default(),
839            band_b: BandState::default(),
840            battery_level: BatteryLevel::Empty,
841            beep: false,
842            lock: false,
843            dual_band: false,
844            bluetooth: false,
845            vox: false,
846            vox_gain: VoxGain::new(0).unwrap(),
847            vox_delay: VoxDelay::new(0).unwrap(),
848            af_gain: AfGainLevel::new(0),
849            firmware_version: String::new(),
850            radio_type: String::new(),
851            gps_enabled: false,
852            gps_pc_output: false,
853            gps_sentences: None,
854            gps_mode: None,
855            beacon_type: BeaconMode::Off,
856            fine_step: None,
857            filter_width_ssb: None,
858            filter_width_cw: None,
859            filter_width_am: None,
860            scan_resume_cat: None,
861            dstar_urcall: String::new(),
862            dstar_urcall_suffix: String::new(),
863            dstar_rpt1: String::new(),
864            dstar_rpt1_suffix: String::new(),
865            dstar_rpt2: String::new(),
866            dstar_rpt2_suffix: String::new(),
867            dstar_gateway_mode: None,
868            dstar_slot: None,
869            dstar_callsign_slot: None,
870        }
871    }
872}
873
874/// Whether the D-STAR gateway is active in the radio task.
875#[derive(Debug, Clone, Copy, PartialEq, Eq)]
876pub(crate) enum DStarMode {
877    /// Not in gateway mode — show CAT config view on the D-STAR panel.
878    Inactive,
879    /// Gateway mode active — `DStarGateway` is running in the radio task.
880    Active,
881}
882
883/// Whether the APRS client is active in the radio task.
884#[derive(Debug, Clone, Copy, PartialEq, Eq)]
885pub(crate) enum AprsMode {
886    /// Not in APRS mode — show MCP config view on the APRS panel.
887    Inactive,
888    /// APRS mode active — `AprsClient` is running in the radio task.
889    Active,
890}
891
892/// Tracking state for a sent APRS message.
893#[derive(Debug, Clone)]
894pub(crate) struct AprsMessageStatus {
895    /// Destination callsign.
896    pub addressee: String,
897    /// Message text.
898    pub text: String,
899    /// Message ID from the messenger.
900    pub message_id: String,
901    /// When the message was sent.
902    #[allow(dead_code)]
903    pub sent_at: Instant,
904    /// Delivery state.
905    pub state: AprsMessageState,
906}
907
908/// Delivery state for a tracked APRS message.
909#[derive(Debug, Clone, Copy, PartialEq, Eq)]
910pub(crate) enum AprsMessageState {
911    /// Waiting for acknowledgement.
912    Pending,
913    /// Acknowledged by the remote station.
914    Delivered,
915    /// Rejected by the remote station.
916    Rejected,
917    /// Expired after exhausting all retries.
918    Expired,
919}
920
921/// Cached APRS station for the TUI display.
922///
923/// The library's `StationEntry` uses `Instant` for timestamps which is
924/// not useful for display. This caches the fields we need plus a
925/// wall-clock time for "ago" display.
926#[derive(Debug, Clone)]
927pub(crate) struct AprsStationCache {
928    pub callsign: String,
929    pub latitude: Option<f64>,
930    pub longitude: Option<f64>,
931    pub speed_knots: Option<u16>,
932    pub course_degrees: Option<u16>,
933    pub symbol_table: Option<char>,
934    pub symbol_code: Option<char>,
935    pub comment: Option<String>,
936    pub packet_count: u32,
937    pub last_path: Vec<String>,
938    pub last_heard: Instant,
939}
940
941/// MCP programming state machine.
942#[derive(Debug)]
943pub(crate) enum McpState {
944    Idle,
945    Reading { page: u16, total: u16 },
946    Loaded { image: MemoryImage, modified: bool },
947    Writing { page: u16, total: u16 },
948    Reconnecting,
949}
950
951/// All events that can flow into the update loop.
952#[derive(Debug)]
953pub(crate) enum Message {
954    Key(crossterm::event::KeyEvent),
955    RadioUpdate(RadioState),
956    RadioError(String),
957    Disconnected,
958    Reconnected,
959    McpProgress {
960        page: u16,
961        total: u16,
962    },
963    McpReadComplete(Vec<u8>),
964    McpWriteComplete,
965    /// A single MCP byte was written successfully — update the in-memory
966    /// cache without requiring a full re-read.
967    McpByteWritten {
968        offset: u16,
969        value: u8,
970    },
971    McpError(String),
972    /// The radio task has entered APRS mode successfully.
973    AprsStarted,
974    /// The radio task has exited APRS mode.
975    AprsStopped,
976    /// An APRS event was received from the radio task.
977    AprsEvent(kenwood_thd75::AprsEvent),
978    /// An APRS message was sent and assigned a message ID for tracking.
979    AprsMessageSent {
980        addressee: String,
981        text: String,
982        message_id: String,
983    },
984    /// Error from the APRS subsystem.
985    AprsError(String),
986    /// The radio task has entered D-STAR gateway mode successfully.
987    DStarStarted,
988    /// The radio task has exited D-STAR gateway mode.
989    DStarStopped,
990    /// A D-STAR event was received from the radio task (gateway mode).
991    DStarEvent(kenwood_thd75::DStarEvent),
992    /// Error from the D-STAR subsystem.
993    DStarError(String),
994    Quit,
995}
996
997/// Central application state.
998#[allow(clippy::struct_excessive_bools)]
999pub(crate) struct App {
1000    pub connected: bool,
1001    pub port_path: String,
1002    pub state: RadioState,
1003    pub focus: Pane,
1004    pub main_view: MainView,
1005    pub input_mode: InputMode,
1006    pub mcp: McpState,
1007    pub should_quit: bool,
1008    pub quit_pending: bool,
1009    pub status_message: Option<String>,
1010    pub show_help: bool,
1011    pub channel_list_index: usize,
1012    /// Selected row in the CAT settings viewer.
1013    pub settings_cat_index: usize,
1014    /// Selected row in the MCP settings viewer.
1015    pub settings_mcp_index: usize,
1016    /// Active search filter for channel list (empty = show all).
1017    pub search_filter: String,
1018    /// Which band channel-tune and freq-input target (last focused band pane).
1019    pub target_band: kenwood_thd75::types::Band,
1020    /// Sender for commands to the radio background task.
1021    pub cmd_tx: Option<tokio::sync::mpsc::UnboundedSender<crate::event::RadioCommand>>,
1022    /// APRS mode state.
1023    pub aprs_mode: AprsMode,
1024    /// Cached APRS stations, sorted by last heard (most recent first).
1025    pub aprs_stations: Vec<AprsStationCache>,
1026    /// Tracked sent APRS messages.
1027    pub aprs_messages: Vec<AprsMessageStatus>,
1028    /// Selected station index in the APRS station list.
1029    pub aprs_station_index: usize,
1030    /// When set, the APRS message compose prompt is active.
1031    pub aprs_compose: Option<String>,
1032    /// D-STAR mode state.
1033    pub dstar_mode: DStarMode,
1034    /// D-STAR last heard entries (gateway mode).
1035    pub dstar_last_heard: Vec<kenwood_thd75::LastHeardEntry>,
1036    /// Selected index in the D-STAR last heard list.
1037    pub dstar_last_heard_index: usize,
1038    /// Current D-STAR text message (from slow data).
1039    pub dstar_text_message: Option<String>,
1040    /// Current D-STAR RX header (gateway mode).
1041    pub dstar_rx_header: Option<dstar_gateway_core::DStarHeader>,
1042    /// Whether a D-STAR voice transmission is active.
1043    pub dstar_rx_active: bool,
1044    /// D-STAR URCALL input buffer (when prompting).
1045    pub dstar_urcall_input: Option<String>,
1046    /// D-STAR reflector input buffer (when prompting).
1047    pub dstar_reflector_input: Option<String>,
1048    /// Channel edit mode is active.
1049    pub channel_edit_mode: bool,
1050    /// Which field is selected in channel edit mode.
1051    pub channel_edit_field: ChannelEditField,
1052    /// Text buffer for the currently edited field.
1053    pub channel_edit_buffer: String,
1054    /// FM radio status (true = on). Tracked locally since FR is write-only.
1055    pub fm_radio_on: bool,
1056}
1057
1058impl App {
1059    /// Returns the list of used channel numbers, filtered by `search_filter`.
1060    pub(crate) fn filtered_channels(&self) -> Vec<u16> {
1061        if let McpState::Loaded { ref image, .. } = self.mcp {
1062            let channels = image.channels();
1063            let filter = self.search_filter.to_uppercase();
1064            (0u16..1200)
1065                .filter(|&i| {
1066                    if !channels.is_used(i) {
1067                        return false;
1068                    }
1069                    if filter.is_empty() {
1070                        return true;
1071                    }
1072                    // Match against channel name or number
1073                    if let Some(entry) = channels.get(i) {
1074                        entry.name.to_uppercase().contains(&filter)
1075                            || i.to_string().contains(&filter)
1076                    } else {
1077                        false
1078                    }
1079                })
1080                .collect()
1081        } else {
1082            Vec::new()
1083        }
1084    }
1085
1086    fn used_channel_count(&self) -> usize {
1087        self.filtered_channels().len()
1088    }
1089
1090    /// Create a new app instance, loading MCP cache from disk if available.
1091    pub(crate) fn new(port_path: String) -> Self {
1092        let (mcp, status_message) = match load_cache() {
1093            Some((image, age)) => {
1094                let mins = age.as_secs() / 60;
1095                let msg = if mins < 60 {
1096                    format!("Loaded cached MCP data ({mins}m ago)")
1097                } else if mins < 1440 {
1098                    format!("Loaded cached MCP data ({}h ago)", mins / 60)
1099                } else {
1100                    format!("Loaded cached MCP data ({}d ago)", mins / 1440)
1101                };
1102                (
1103                    McpState::Loaded {
1104                        image,
1105                        modified: false,
1106                    },
1107                    Some(msg),
1108                )
1109            }
1110            None => (McpState::Idle, None),
1111        };
1112
1113        Self {
1114            connected: false,
1115            port_path,
1116            state: RadioState::default(),
1117            focus: Pane::BandA,
1118            main_view: MainView::Channels,
1119            input_mode: InputMode::Normal,
1120            mcp,
1121            should_quit: false,
1122            quit_pending: false,
1123            status_message,
1124            show_help: false,
1125            channel_list_index: 0,
1126            settings_cat_index: 0,
1127            settings_mcp_index: 0,
1128            search_filter: String::new(),
1129            target_band: kenwood_thd75::types::Band::A,
1130            cmd_tx: None,
1131            aprs_mode: AprsMode::Inactive,
1132            aprs_stations: Vec::new(),
1133            aprs_messages: Vec::new(),
1134            aprs_station_index: 0,
1135            aprs_compose: None,
1136            dstar_mode: DStarMode::Inactive,
1137            dstar_last_heard: Vec::new(),
1138            dstar_last_heard_index: 0,
1139            dstar_text_message: None,
1140            dstar_rx_header: None,
1141            dstar_rx_active: false,
1142            dstar_urcall_input: None,
1143            dstar_reflector_input: None,
1144            channel_edit_mode: false,
1145            channel_edit_field: ChannelEditField::Frequency,
1146            channel_edit_buffer: String::new(),
1147            fm_radio_on: false,
1148        }
1149    }
1150
1151    /// Process a message and update state. Returns true if a render is needed.
1152    pub(crate) fn update(&mut self, msg: Message) -> bool {
1153        match msg {
1154            Message::Quit => {
1155                self.should_quit = true;
1156                true
1157            }
1158            Message::Key(key) => self.handle_key(key),
1159            Message::RadioUpdate(mut state) => {
1160                // Preserve static fields that are only read once at connect
1161                if state.firmware_version.is_empty() {
1162                    state.firmware_version = std::mem::take(&mut self.state.firmware_version);
1163                }
1164                if state.radio_type.is_empty() {
1165                    state.radio_type = std::mem::take(&mut self.state.radio_type);
1166                }
1167                // Preserve write-only fields not readable from radio
1168                if state.scan_resume_cat.is_none() {
1169                    state.scan_resume_cat = self.state.scan_resume_cat;
1170                }
1171                // Preserve D-STAR state when not provided by poll
1172                if state.dstar_urcall.is_empty() {
1173                    state.dstar_urcall = std::mem::take(&mut self.state.dstar_urcall);
1174                    state.dstar_urcall_suffix = std::mem::take(&mut self.state.dstar_urcall_suffix);
1175                }
1176                if state.dstar_rpt1.is_empty() {
1177                    state.dstar_rpt1 = std::mem::take(&mut self.state.dstar_rpt1);
1178                    state.dstar_rpt1_suffix = std::mem::take(&mut self.state.dstar_rpt1_suffix);
1179                }
1180                if state.dstar_rpt2.is_empty() {
1181                    state.dstar_rpt2 = std::mem::take(&mut self.state.dstar_rpt2);
1182                    state.dstar_rpt2_suffix = std::mem::take(&mut self.state.dstar_rpt2_suffix);
1183                }
1184                if state.dstar_gateway_mode.is_none() {
1185                    state.dstar_gateway_mode = self.state.dstar_gateway_mode;
1186                }
1187                if state.dstar_slot.is_none() {
1188                    state.dstar_slot = self.state.dstar_slot;
1189                }
1190                if state.dstar_callsign_slot.is_none() {
1191                    state.dstar_callsign_slot = self.state.dstar_callsign_slot;
1192                }
1193                self.state = state;
1194                self.connected = true;
1195                true
1196            }
1197            Message::RadioError(err) => {
1198                self.status_message = Some(err);
1199                true
1200            }
1201            Message::Disconnected => {
1202                self.connected = false;
1203                self.status_message = Some("Disconnected — reconnecting...".into());
1204                true
1205            }
1206            Message::Reconnected => {
1207                self.connected = true;
1208                self.status_message = Some("Reconnected".into());
1209                true
1210            }
1211            Message::McpProgress { page, total } => {
1212                self.mcp = if matches!(self.mcp, McpState::Writing { .. }) {
1213                    McpState::Writing { page, total }
1214                } else {
1215                    McpState::Reading { page, total }
1216                };
1217                true
1218            }
1219            Message::McpReadComplete(data) => {
1220                save_cache(&data);
1221                match MemoryImage::from_raw(data) {
1222                    Ok(image) => {
1223                        self.mcp = McpState::Loaded {
1224                            image,
1225                            modified: false,
1226                        };
1227                        self.status_message = Some("MCP read complete — cached to disk".into());
1228                    }
1229                    Err(e) => {
1230                        self.mcp = McpState::Idle;
1231                        self.status_message = Some(format!("MCP parse error: {e}"));
1232                    }
1233                }
1234                true
1235            }
1236            Message::McpWriteComplete => {
1237                self.mcp = McpState::Reconnecting;
1238                self.status_message = Some("MCP write complete — reconnecting...".into());
1239                true
1240            }
1241            Message::McpByteWritten { offset, value } => {
1242                // Update the cached memory image with the single byte that
1243                // was just written via MCP, so the TUI stays in sync without
1244                // requiring a full re-read after reconnect.
1245                if let McpState::Loaded { ref mut image, .. } = self.mcp {
1246                    image.as_raw_mut()[offset as usize] = value;
1247                    save_cache(image.as_raw());
1248                }
1249                true
1250            }
1251            Message::McpError(err) => {
1252                // Only reset to Idle if we don't have a loaded image.
1253                // A failed MCP write shouldn't destroy the cached data.
1254                if !matches!(self.mcp, McpState::Loaded { .. }) {
1255                    self.mcp = McpState::Idle;
1256                }
1257                self.status_message = Some(format!("MCP error: {err}"));
1258                true
1259            }
1260            Message::AprsStarted => {
1261                self.aprs_mode = AprsMode::Active;
1262                self.status_message = Some("APRS mode active".into());
1263                true
1264            }
1265            Message::AprsStopped => {
1266                self.aprs_mode = AprsMode::Inactive;
1267                self.status_message = Some("APRS mode stopped — CAT polling resumed".into());
1268                true
1269            }
1270            Message::AprsEvent(event) => {
1271                self.handle_aprs_event(event);
1272                true
1273            }
1274            Message::AprsMessageSent {
1275                addressee,
1276                text,
1277                message_id,
1278            } => {
1279                self.aprs_messages.push(AprsMessageStatus {
1280                    addressee,
1281                    text,
1282                    message_id,
1283                    sent_at: Instant::now(),
1284                    state: AprsMessageState::Pending,
1285                });
1286                true
1287            }
1288            Message::AprsError(err) => {
1289                self.status_message = Some(format!("APRS: {err}"));
1290                true
1291            }
1292            Message::DStarStarted => {
1293                self.dstar_mode = DStarMode::Active;
1294                self.status_message = Some("D-STAR gateway mode active".into());
1295                true
1296            }
1297            Message::DStarStopped => {
1298                self.dstar_mode = DStarMode::Inactive;
1299                self.dstar_rx_active = false;
1300                self.dstar_rx_header = None;
1301                self.status_message =
1302                    Some("D-STAR gateway mode stopped — CAT polling resumed".into());
1303                true
1304            }
1305            Message::DStarEvent(event) => {
1306                self.handle_dstar_event(event);
1307                true
1308            }
1309            Message::DStarError(err) => {
1310                self.status_message = Some(format!("D-STAR: {err}"));
1311                true
1312            }
1313        }
1314    }
1315
1316    #[allow(clippy::cognitive_complexity)]
1317    fn handle_key(&mut self, key: crossterm::event::KeyEvent) -> bool {
1318        use crossterm::event::{KeyCode, KeyModifiers};
1319
1320        // Ctrl-C always quits regardless of mode
1321        if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
1322            self.should_quit = true;
1323            return true;
1324        }
1325
1326        // Handle search input mode
1327        if let InputMode::Search(ref mut buf) = self.input_mode {
1328            match key.code {
1329                KeyCode::Esc => {
1330                    self.search_filter.clear();
1331                    self.input_mode = InputMode::Normal;
1332                    self.channel_list_index = 0;
1333                }
1334                KeyCode::Enter => {
1335                    self.input_mode = InputMode::Normal;
1336                }
1337                KeyCode::Backspace => {
1338                    let _ = buf.pop();
1339                    self.search_filter = buf.clone();
1340                    self.channel_list_index = 0;
1341                }
1342                KeyCode::Char(c) => {
1343                    buf.push(c);
1344                    self.search_filter = buf.clone();
1345                    self.channel_list_index = 0;
1346                }
1347                _ => {}
1348            }
1349            return true;
1350        }
1351
1352        // Handle APRS message compose mode
1353        if let Some(ref mut buf) = self.aprs_compose {
1354            match key.code {
1355                KeyCode::Esc => {
1356                    self.aprs_compose = None;
1357                }
1358                KeyCode::Enter => {
1359                    let text = buf.clone();
1360                    self.aprs_compose = None;
1361                    if !text.is_empty()
1362                        && let Some(station) = self.aprs_stations.get(self.aprs_station_index)
1363                        && let Some(ref tx) = self.cmd_tx
1364                    {
1365                        let addressee = station.callsign.clone();
1366                        let _ = tx.send(crate::event::RadioCommand::SendAprsMessage {
1367                            addressee: addressee.clone(),
1368                            text: text.clone(),
1369                        });
1370                        self.status_message = Some(format!("Sending to {addressee}: {text}"));
1371                    }
1372                }
1373                KeyCode::Backspace => {
1374                    let _ = buf.pop();
1375                }
1376                KeyCode::Char(c) => {
1377                    buf.push(c);
1378                }
1379                _ => {}
1380            }
1381            return true;
1382        }
1383
1384        // Handle D-STAR URCALL input mode
1385        if let Some(ref mut buf) = self.dstar_urcall_input {
1386            match key.code {
1387                KeyCode::Esc => {
1388                    self.dstar_urcall_input = None;
1389                }
1390                KeyCode::Enter => {
1391                    let input = buf.clone();
1392                    self.dstar_urcall_input = None;
1393                    if !input.is_empty()
1394                        && let Some(ref tx) = self.cmd_tx
1395                    {
1396                        let _ = tx.send(crate::event::RadioCommand::SetUrcall {
1397                            callsign: input.clone(),
1398                            suffix: String::new(),
1399                        });
1400                        self.status_message = Some(format!("URCALL set to {input}"));
1401                    }
1402                }
1403                KeyCode::Backspace => {
1404                    let _ = buf.pop();
1405                }
1406                KeyCode::Char(c) => {
1407                    if buf.len() < 8 {
1408                        buf.push(c.to_ascii_uppercase());
1409                    }
1410                }
1411                _ => {}
1412            }
1413            return true;
1414        }
1415
1416        // Handle D-STAR reflector input mode (format: NAME MODULE, e.g. "REF030 C")
1417        if let Some(ref mut buf) = self.dstar_reflector_input {
1418            match key.code {
1419                KeyCode::Esc => {
1420                    self.dstar_reflector_input = None;
1421                }
1422                KeyCode::Enter => {
1423                    let input = buf.clone();
1424                    self.dstar_reflector_input = None;
1425                    // Parse "REF030 C" or "REF030C"
1426                    let parts: Vec<&str> = input.split_whitespace().collect();
1427                    let (name, module) = if parts.len() >= 2 {
1428                        (parts[0].to_string(), parts[1].chars().next().unwrap_or('A'))
1429                    } else if input.len() > 1 {
1430                        let module = input.chars().last().unwrap_or('A');
1431                        let name = &input[..input.len() - 1];
1432                        (name.trim().to_string(), module)
1433                    } else {
1434                        self.status_message = Some("Invalid reflector (e.g. REF030 C)".into());
1435                        return true;
1436                    };
1437                    if let Some(ref tx) = self.cmd_tx {
1438                        let _ = tx.send(crate::event::RadioCommand::ConnectReflector {
1439                            name: name.clone(),
1440                            module,
1441                        });
1442                        self.status_message =
1443                            Some(format!("Connecting to {name} module {module}..."));
1444                    }
1445                }
1446                KeyCode::Backspace => {
1447                    let _ = buf.pop();
1448                }
1449                KeyCode::Char(c) => {
1450                    if buf.len() < 12 {
1451                        buf.push(c.to_ascii_uppercase());
1452                    }
1453                }
1454                _ => {}
1455            }
1456            return true;
1457        }
1458
1459        // Handle channel edit mode
1460        if self.channel_edit_mode {
1461            match key.code {
1462                KeyCode::Esc => {
1463                    self.channel_edit_mode = false;
1464                    self.channel_edit_buffer.clear();
1465                    self.status_message = Some("Edit cancelled".into());
1466                }
1467                KeyCode::Tab => {
1468                    self.channel_edit_field = self.channel_edit_field.next();
1469                    self.channel_edit_buffer.clear();
1470                    self.status_message = Some(format!(
1471                        "Editing: {} (type value, Enter to apply)",
1472                        self.channel_edit_field.label()
1473                    ));
1474                }
1475                KeyCode::Backspace => {
1476                    let _ = self.channel_edit_buffer.pop();
1477                }
1478                KeyCode::Char(c) => {
1479                    self.channel_edit_buffer.push(c);
1480                }
1481                KeyCode::Enter => {
1482                    let field = self.channel_edit_field;
1483                    let buf = self.channel_edit_buffer.clone();
1484                    self.apply_channel_edit(field, &buf);
1485                    self.channel_edit_buffer.clear();
1486                }
1487                _ => {}
1488            }
1489            return true;
1490        }
1491
1492        // Handle frequency input mode
1493        if let InputMode::FreqInput(ref mut buf) = self.input_mode {
1494            match key.code {
1495                KeyCode::Esc => {
1496                    self.input_mode = InputMode::Normal;
1497                }
1498                KeyCode::Enter => {
1499                    // Parse as MHz (e.g. "145.19" -> 145_190_000 Hz)
1500                    if let Ok(mhz) = buf.parse::<f64>() {
1501                        #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
1502                        let hz = (mhz * 1_000_000.0) as u32;
1503                        if let Some(ref tx) = self.cmd_tx {
1504                            let _ = tx.send(crate::event::RadioCommand::TuneFreq {
1505                                band: self.target_band,
1506                                freq: hz,
1507                            });
1508                        }
1509                        let band_label = if self.target_band == kenwood_thd75::types::Band::B {
1510                            "B"
1511                        } else {
1512                            "A"
1513                        };
1514                        self.status_message =
1515                            Some(format!("Tuning Band {band_label} to {mhz:.6} MHz..."));
1516                    } else {
1517                        self.status_message = Some(format!("Invalid frequency: {buf}"));
1518                    }
1519                    self.input_mode = InputMode::Normal;
1520                }
1521                KeyCode::Backspace => {
1522                    let _ = buf.pop();
1523                }
1524                KeyCode::Char(c) if c.is_ascii_digit() || c == '.' => {
1525                    buf.push(c);
1526                }
1527                _ => {}
1528            }
1529            return true;
1530        }
1531
1532        // --- Normal mode ---
1533
1534        // Reset quit confirmation on any key that isn't 'q'
1535        if key.code != KeyCode::Char('q') {
1536            self.quit_pending = false;
1537        }
1538
1539        match key.code {
1540            KeyCode::Char('q') => {
1541                if self.quit_pending {
1542                    self.should_quit = true;
1543                } else if let McpState::Loaded { modified: true, .. } = &self.mcp {
1544                    self.quit_pending = true;
1545                    self.status_message =
1546                        Some("Unsaved MCP changes! Press q again to confirm quit.".into());
1547                } else {
1548                    self.should_quit = true;
1549                }
1550                true
1551            }
1552            KeyCode::Char('?') => {
1553                self.show_help = !self.show_help;
1554                true
1555            }
1556            KeyCode::Tab => {
1557                self.focus = self.focus.next();
1558                if self.focus == Pane::BandA {
1559                    self.target_band = kenwood_thd75::types::Band::A;
1560                }
1561                if self.focus == Pane::BandB {
1562                    self.target_band = kenwood_thd75::types::Band::B;
1563                }
1564                true
1565            }
1566            KeyCode::BackTab => {
1567                self.focus = self.focus.prev();
1568                if self.focus == Pane::BandA {
1569                    self.target_band = kenwood_thd75::types::Band::A;
1570                }
1571                if self.focus == Pane::BandB {
1572                    self.target_band = kenwood_thd75::types::Band::B;
1573                }
1574                true
1575            }
1576            KeyCode::Char('1') => {
1577                self.focus = Pane::BandA;
1578                self.target_band = kenwood_thd75::types::Band::A;
1579                true
1580            }
1581            KeyCode::Char('2') => {
1582                self.focus = Pane::BandB;
1583                self.target_band = kenwood_thd75::types::Band::B;
1584                true
1585            }
1586            KeyCode::Char('3') => {
1587                self.focus = Pane::Main;
1588                true
1589            }
1590            KeyCode::Char('4') => {
1591                self.focus = Pane::Detail;
1592                true
1593            }
1594            KeyCode::Char('c') => {
1595                self.main_view = MainView::Channels;
1596                self.focus = Pane::Main;
1597                true
1598            }
1599            KeyCode::Char('s') => {
1600                self.main_view = MainView::SettingsCat;
1601                self.focus = Pane::Main;
1602                true
1603            }
1604            KeyCode::Char('S') => {
1605                self.main_view = MainView::SettingsMcp;
1606                self.focus = Pane::Main;
1607                true
1608            }
1609            // Channel edit mode: press 'e' on channel detail pane
1610            KeyCode::Char('e')
1611                if self.main_view == MainView::Channels
1612                    && matches!(self.focus, Pane::Main | Pane::Detail)
1613                    && matches!(self.mcp, McpState::Loaded { .. }) =>
1614            {
1615                let used = self.filtered_channels();
1616                if used.get(self.channel_list_index).is_some() {
1617                    self.channel_edit_mode = true;
1618                    self.channel_edit_field = ChannelEditField::Frequency;
1619                    self.channel_edit_buffer.clear();
1620                    self.status_message =
1621                        Some("Edit mode: Tab=next field, Enter=apply, Esc=cancel".into());
1622                }
1623                true
1624            }
1625            // FM Radio panel
1626            KeyCode::Char('F') => {
1627                self.main_view = MainView::FmRadio;
1628                self.focus = Pane::Main;
1629                true
1630            }
1631            // FM Radio toggle (when viewing FM panel)
1632            KeyCode::Char('f')
1633                if self.main_view == MainView::FmRadio && self.focus == Pane::Main =>
1634            {
1635                self.toggle_fm_radio();
1636                true
1637            }
1638            KeyCode::Char('a') => {
1639                if self.main_view == MainView::Aprs && self.focus == Pane::Main {
1640                    // Toggle APRS mode on/off when already viewing APRS panel.
1641                    self.toggle_aprs_mode();
1642                } else {
1643                    self.main_view = MainView::Aprs;
1644                    self.focus = Pane::Main;
1645                }
1646                true
1647            }
1648            KeyCode::Char('d') => {
1649                if self.main_view == MainView::DStar && self.focus == Pane::Main {
1650                    // Toggle D-STAR gateway mode on/off when already viewing D-STAR panel.
1651                    self.toggle_dstar_mode();
1652                } else {
1653                    self.main_view = MainView::DStar;
1654                    self.focus = Pane::Main;
1655                }
1656                true
1657            }
1658            KeyCode::Char('p') if self.main_view == MainView::Gps && self.focus == Pane::Main => {
1659                self.toggle_gps_pc_output();
1660                true
1661            }
1662            KeyCode::Char('m') => {
1663                self.main_view = MainView::Mcp;
1664                self.focus = Pane::Main;
1665                true
1666            }
1667            // Channel search
1668            KeyCode::Char('/')
1669                if self.focus == Pane::Main && self.main_view == MainView::Channels =>
1670            {
1671                self.input_mode = InputMode::Search(self.search_filter.clone());
1672                true
1673            }
1674            // Frequency direct entry
1675            KeyCode::Char('f') if matches!(self.focus, Pane::BandA | Pane::BandB) => {
1676                self.input_mode = InputMode::FreqInput(String::new());
1677                true
1678            }
1679            // GPS panel or jump-to-first-channel
1680            KeyCode::Char('g') if self.focus == Pane::Main => {
1681                if self.main_view == MainView::Gps {
1682                    // Toggle GPS on/off when already viewing GPS panel.
1683                    self.toggle_gps();
1684                } else if self.main_view == MainView::Channels {
1685                    // Jump to first channel in channel list.
1686                    self.channel_list_index = 0;
1687                } else {
1688                    // Switch to GPS view from any other panel.
1689                    self.main_view = MainView::Gps;
1690                }
1691                true
1692            }
1693            KeyCode::Char('g') => {
1694                // Switch to GPS view when focus is not on Main pane.
1695                self.main_view = MainView::Gps;
1696                self.focus = Pane::Main;
1697                true
1698            }
1699            KeyCode::Char('G') if self.focus == Pane::Main => {
1700                self.channel_list_index = self.used_channel_count().saturating_sub(1);
1701                true
1702            }
1703            KeyCode::Char('j') | KeyCode::Down => {
1704                match self.focus {
1705                    Pane::Main => match self.main_view {
1706                        MainView::Channels => {
1707                            let max = self.used_channel_count().saturating_sub(1);
1708                            self.channel_list_index =
1709                                self.channel_list_index.saturating_add(1).min(max);
1710                        }
1711                        MainView::SettingsCat => {
1712                            let max = cat_settings().len().saturating_sub(1);
1713                            self.settings_cat_index =
1714                                self.settings_cat_index.saturating_add(1).min(max);
1715                        }
1716                        MainView::SettingsMcp => {
1717                            let max = mcp_settings().len().saturating_sub(1);
1718                            self.settings_mcp_index =
1719                                self.settings_mcp_index.saturating_add(1).min(max);
1720                        }
1721                        MainView::Aprs => {
1722                            let max = self.aprs_stations.len().saturating_sub(1);
1723                            self.aprs_station_index =
1724                                self.aprs_station_index.saturating_add(1).min(max);
1725                        }
1726                        MainView::DStar => {
1727                            let max = self.dstar_last_heard.len().saturating_sub(1);
1728                            self.dstar_last_heard_index =
1729                                self.dstar_last_heard_index.saturating_add(1).min(max);
1730                        }
1731                        MainView::Gps | MainView::Mcp | MainView::FmRadio => {}
1732                    },
1733                    Pane::BandA => {
1734                        if let Some(ref tx) = self.cmd_tx {
1735                            let _ = tx.send(crate::event::RadioCommand::FreqDown(
1736                                kenwood_thd75::types::Band::A,
1737                            ));
1738                        }
1739                    }
1740                    Pane::BandB => {
1741                        if let Some(ref tx) = self.cmd_tx {
1742                            let _ = tx.send(crate::event::RadioCommand::FreqDown(
1743                                kenwood_thd75::types::Band::B,
1744                            ));
1745                        }
1746                    }
1747                    Pane::Detail => {}
1748                }
1749                true
1750            }
1751            KeyCode::Char('k') | KeyCode::Up => {
1752                match self.focus {
1753                    Pane::Main => match self.main_view {
1754                        MainView::Channels => {
1755                            self.channel_list_index = self.channel_list_index.saturating_sub(1);
1756                        }
1757                        MainView::SettingsCat => {
1758                            self.settings_cat_index = self.settings_cat_index.saturating_sub(1);
1759                        }
1760                        MainView::SettingsMcp => {
1761                            self.settings_mcp_index = self.settings_mcp_index.saturating_sub(1);
1762                        }
1763                        MainView::Aprs => {
1764                            self.aprs_station_index = self.aprs_station_index.saturating_sub(1);
1765                        }
1766                        MainView::DStar => {
1767                            self.dstar_last_heard_index =
1768                                self.dstar_last_heard_index.saturating_sub(1);
1769                        }
1770                        MainView::Gps | MainView::Mcp | MainView::FmRadio => {}
1771                    },
1772                    Pane::BandA => {
1773                        if let Some(ref tx) = self.cmd_tx {
1774                            let _ = tx.send(crate::event::RadioCommand::FreqUp(
1775                                kenwood_thd75::types::Band::A,
1776                            ));
1777                        }
1778                    }
1779                    Pane::BandB => {
1780                        if let Some(ref tx) = self.cmd_tx {
1781                            let _ = tx.send(crate::event::RadioCommand::FreqUp(
1782                                kenwood_thd75::types::Band::B,
1783                            ));
1784                        }
1785                    }
1786                    Pane::Detail => {}
1787                }
1788                true
1789            }
1790            KeyCode::Enter if self.focus == Pane::Main && self.main_view == MainView::Channels => {
1791                let used = self.filtered_channels();
1792                if let Some(&ch_num) = used.get(self.channel_list_index)
1793                    && let Some(ref tx) = self.cmd_tx
1794                {
1795                    let band_label = if self.target_band == kenwood_thd75::types::Band::B {
1796                        "B"
1797                    } else {
1798                        "A"
1799                    };
1800                    let _ = tx.send(crate::event::RadioCommand::TuneChannel {
1801                        band: self.target_band,
1802                        channel: ch_num,
1803                    });
1804                    self.status_message =
1805                        Some(format!("Tuning Band {band_label} to channel {ch_num}..."));
1806                }
1807                true
1808            }
1809            // Settings: Enter toggles boolean, +/- adjusts numeric
1810            KeyCode::Enter
1811                if self.focus == Pane::Main
1812                    && matches!(
1813                        self.main_view,
1814                        MainView::SettingsCat | MainView::SettingsMcp
1815                    ) =>
1816            {
1817                self.toggle_setting();
1818                true
1819            }
1820            KeyCode::Char('+' | '=')
1821                if self.focus == Pane::Main
1822                    && matches!(
1823                        self.main_view,
1824                        MainView::SettingsCat | MainView::SettingsMcp
1825                    ) =>
1826            {
1827                self.adjust_setting(1);
1828                true
1829            }
1830            KeyCode::Char('-')
1831                if self.focus == Pane::Main
1832                    && matches!(
1833                        self.main_view,
1834                        MainView::SettingsCat | MainView::SettingsMcp
1835                    ) =>
1836            {
1837                self.adjust_setting(-1);
1838                true
1839            }
1840            KeyCode::Char('p') if matches!(self.focus, Pane::BandA | Pane::BandB) => {
1841                let band = if self.focus == Pane::BandA {
1842                    kenwood_thd75::types::Band::A
1843                } else {
1844                    kenwood_thd75::types::Band::B
1845                };
1846                let current = if self.focus == Pane::BandA {
1847                    &self.state.band_a.power_level
1848                } else {
1849                    &self.state.band_b.power_level
1850                };
1851                let next = match current {
1852                    PowerLevel::High => PowerLevel::Medium,
1853                    PowerLevel::Medium => PowerLevel::Low,
1854                    PowerLevel::Low => PowerLevel::ExtraLow,
1855                    PowerLevel::ExtraLow => PowerLevel::High,
1856                };
1857                if let Some(ref tx) = self.cmd_tx {
1858                    let _ = tx.send(crate::event::RadioCommand::SetPower { band, level: next });
1859                }
1860                true
1861            }
1862            // Attenuator toggle on band pane
1863            KeyCode::Char('t') if matches!(self.focus, Pane::BandA | Pane::BandB) => {
1864                let (band, cur) = if self.focus == Pane::BandA {
1865                    (kenwood_thd75::types::Band::A, self.state.band_a.attenuator)
1866                } else {
1867                    (kenwood_thd75::types::Band::B, self.state.band_b.attenuator)
1868                };
1869                if let Some(ref tx) = self.cmd_tx {
1870                    let _ = tx.send(crate::event::RadioCommand::SetAttenuator {
1871                        band,
1872                        enabled: !cur,
1873                    });
1874                    self.status_message = Some(format!("Attenuator → {}", on_off(!cur)));
1875                }
1876                true
1877            }
1878            // Squelch adjust on band pane: [ and ]
1879            KeyCode::Char('[') if matches!(self.focus, Pane::BandA | Pane::BandB) => {
1880                let (band, cur) = if self.focus == Pane::BandA {
1881                    (
1882                        kenwood_thd75::types::Band::A,
1883                        self.state.band_a.squelch.as_u8(),
1884                    )
1885                } else {
1886                    (
1887                        kenwood_thd75::types::Band::B,
1888                        self.state.band_b.squelch.as_u8(),
1889                    )
1890                };
1891                let next = cur.saturating_sub(1);
1892                if let (Some(tx), Ok(level)) = (&self.cmd_tx, SquelchLevel::new(next)) {
1893                    let _ = tx.send(crate::event::RadioCommand::SetSquelch { band, level });
1894                    self.status_message = Some(format!("Squelch → {next}"));
1895                }
1896                true
1897            }
1898            KeyCode::Char(']') if matches!(self.focus, Pane::BandA | Pane::BandB) => {
1899                let (band, cur) = if self.focus == Pane::BandA {
1900                    (
1901                        kenwood_thd75::types::Band::A,
1902                        self.state.band_a.squelch.as_u8(),
1903                    )
1904                } else {
1905                    (
1906                        kenwood_thd75::types::Band::B,
1907                        self.state.band_b.squelch.as_u8(),
1908                    )
1909                };
1910                let next = cur.saturating_add(1).min(6);
1911                if let (Some(tx), Ok(level)) = (&self.cmd_tx, SquelchLevel::new(next)) {
1912                    let _ = tx.send(crate::event::RadioCommand::SetSquelch { band, level });
1913                    self.status_message = Some(format!("Squelch → {next}"));
1914                }
1915                true
1916            }
1917            KeyCode::Esc => {
1918                if self.show_help {
1919                    self.show_help = false;
1920                    return true;
1921                }
1922                // Clear search filter
1923                if !self.search_filter.is_empty() {
1924                    self.search_filter.clear();
1925                    self.channel_list_index = 0;
1926                    return true;
1927                }
1928                false
1929            }
1930            // APRS: compose message to selected station
1931            KeyCode::Char('M')
1932                if self.main_view == MainView::Aprs
1933                    && self.focus == Pane::Main
1934                    && self.aprs_mode == AprsMode::Active
1935                    && !self.aprs_stations.is_empty() =>
1936            {
1937                self.aprs_compose = Some(String::new());
1938                true
1939            }
1940            // APRS: manual position beacon
1941            KeyCode::Char('b')
1942                if self.main_view == MainView::Aprs
1943                    && self.focus == Pane::Main
1944                    && self.aprs_mode == AprsMode::Active =>
1945            {
1946                if let Some(ref tx) = self.cmd_tx {
1947                    // Use 0,0 as placeholder — real GPS position would come from the radio.
1948                    let _ = tx.send(crate::event::RadioCommand::BeaconPosition {
1949                        lat: 0.0,
1950                        lon: 0.0,
1951                        comment: String::new(),
1952                    });
1953                    self.status_message = Some("Beacon sent".into());
1954                }
1955                true
1956            }
1957            KeyCode::Char('r') if self.main_view == MainView::Mcp => {
1958                if matches!(self.mcp, McpState::Idle | McpState::Loaded { .. }) {
1959                    self.mcp = McpState::Reading {
1960                        page: 0,
1961                        total: kenwood_thd75::protocol::programming::TOTAL_PAGES,
1962                    };
1963                    self.status_message = Some("Starting MCP read...".into());
1964                    if let Some(ref tx) = self.cmd_tx {
1965                        let _ = tx.send(crate::event::RadioCommand::ReadMemory);
1966                    }
1967                }
1968                true
1969            }
1970            KeyCode::Char('w') if self.main_view == MainView::Mcp => {
1971                if let McpState::Loaded { ref image, .. } = self.mcp {
1972                    let data = image.as_raw().to_vec();
1973                    self.mcp = McpState::Writing {
1974                        page: 0,
1975                        total: kenwood_thd75::protocol::programming::TOTAL_PAGES,
1976                    };
1977                    self.status_message = Some("Starting MCP write...".into());
1978                    if let Some(ref tx) = self.cmd_tx {
1979                        let _ = tx.send(crate::event::RadioCommand::WriteMemory(data));
1980                    }
1981                }
1982                true
1983            }
1984            // D-STAR: set CQ (URCALL = CQCQCQ)
1985            KeyCode::Char('C')
1986                if self.main_view == MainView::DStar
1987                    && self.focus == Pane::Main
1988                    && self.dstar_mode == DStarMode::Inactive =>
1989            {
1990                if let Some(ref tx) = self.cmd_tx {
1991                    let _ = tx.send(crate::event::RadioCommand::SetCQ);
1992                    self.status_message = Some("URCALL set to CQCQCQ".into());
1993                }
1994                true
1995            }
1996            // D-STAR: set URCALL (prompt)
1997            KeyCode::Char('u')
1998                if self.main_view == MainView::DStar
1999                    && self.focus == Pane::Main
2000                    && self.dstar_mode == DStarMode::Inactive =>
2001            {
2002                self.dstar_urcall_input = Some(String::new());
2003                true
2004            }
2005            // D-STAR: connect reflector (prompt)
2006            KeyCode::Char('r')
2007                if self.main_view == MainView::DStar
2008                    && self.focus == Pane::Main
2009                    && self.dstar_mode == DStarMode::Inactive =>
2010            {
2011                self.dstar_reflector_input = Some(String::new());
2012                true
2013            }
2014            // D-STAR: unlink reflector
2015            KeyCode::Char('U')
2016                if self.main_view == MainView::DStar
2017                    && self.focus == Pane::Main
2018                    && self.dstar_mode == DStarMode::Inactive =>
2019            {
2020                if let Some(ref tx) = self.cmd_tx {
2021                    let _ = tx.send(crate::event::RadioCommand::DisconnectReflector);
2022                    self.status_message = Some("Unlinking reflector...".into());
2023                }
2024                true
2025            }
2026            _ => false,
2027        }
2028    }
2029
2030    /// Toggle a boolean setting or show hint for numeric ones.
2031    fn toggle_setting(&mut self) {
2032        let (rows, idx) = if self.main_view == MainView::SettingsCat {
2033            (cat_settings(), self.settings_cat_index)
2034        } else {
2035            (mcp_settings(), self.settings_mcp_index)
2036        };
2037        let row = match rows.get(idx) {
2038            Some(r) => *r,
2039            None => return,
2040        };
2041
2042        // CAT-backed boolean settings
2043        if let Some(ref tx) = self.cmd_tx.clone() {
2044            match row {
2045                SettingRow::Lock => {
2046                    let next = !self.state.lock;
2047                    let _ = tx.send(crate::event::RadioCommand::SetLock(next));
2048                    self.status_message = Some(format!("Lock → {}", on_off(next)));
2049                    return;
2050                }
2051                SettingRow::DualBand => {
2052                    let next = !self.state.dual_band;
2053                    let _ = tx.send(crate::event::RadioCommand::SetDualBand(next));
2054                    self.status_message = Some(format!("Dual band → {}", on_off(next)));
2055                    return;
2056                }
2057                SettingRow::Bluetooth => {
2058                    let next = !self.state.bluetooth;
2059                    let _ = tx.send(crate::event::RadioCommand::SetBluetooth(next));
2060                    self.status_message = Some(format!("Bluetooth → {}", on_off(next)));
2061                    return;
2062                }
2063                SettingRow::VoxEnabled => {
2064                    let next = !self.state.vox;
2065                    let _ = tx.send(crate::event::RadioCommand::SetVox(next));
2066                    self.status_message = Some(format!("VOX → {}", on_off(next)));
2067                    return;
2068                }
2069                SettingRow::AttenuatorA => {
2070                    let next = !self.state.band_a.attenuator;
2071                    let _ = tx.send(crate::event::RadioCommand::SetAttenuator {
2072                        band: kenwood_thd75::types::Band::A,
2073                        enabled: next,
2074                    });
2075                    self.status_message = Some(format!("Atten A → {}", on_off(next)));
2076                    return;
2077                }
2078                SettingRow::AttenuatorB => {
2079                    let next = !self.state.band_b.attenuator;
2080                    let _ = tx.send(crate::event::RadioCommand::SetAttenuator {
2081                        band: kenwood_thd75::types::Band::B,
2082                        enabled: next,
2083                    });
2084                    self.status_message = Some(format!("Atten B → {}", on_off(next)));
2085                    return;
2086                }
2087                SettingRow::FmRadio => {
2088                    let _ = tx.send(crate::event::RadioCommand::SetFmRadio(true));
2089                    self.status_message =
2090                        Some("FM Radio: enabled (read-back not available)".into());
2091                    return;
2092                }
2093                SettingRow::GpsEnabled => {
2094                    let next = !self.state.gps_enabled;
2095                    let _ = tx.send(crate::event::RadioCommand::SetGpsConfig(next, false));
2096                    self.status_message = Some(format!("GPS → {}", on_off(next)));
2097                    return;
2098                }
2099                SettingRow::GpsPcOutput => {
2100                    self.status_message =
2101                        Some("GPS PC Output: use SetGpsSentences — not yet wired".into());
2102                    return;
2103                }
2104                SettingRow::AutoInfo => {
2105                    self.status_message = Some("Auto Info: not yet wired".into());
2106                    return;
2107                }
2108                _ => {}
2109            }
2110        }
2111
2112        // Numeric settings: show hint
2113        if row.is_numeric() {
2114            self.status_message = Some(format!("{}: use +/- to adjust", row.label()));
2115            return;
2116        }
2117
2118        // MCP-backed boolean settings — write directly to radio via single-page MCP
2119        let Some(tx) = self.cmd_tx.clone() else {
2120            return;
2121        };
2122
2123        let McpState::Loaded { ref mut image, .. } = self.mcp else {
2124            self.status_message = Some(format!("{}: load MCP data first (m → r)", row.label()));
2125            return;
2126        };
2127
2128        macro_rules! toggle_bool {
2129            ($getter:ident, $setter:ident, $label:expr) => {{
2130                let new_val = !image.settings().$getter();
2131                if let Some((offset, value)) = image.modify_setting(|w| w.$setter(new_val)) {
2132                    let _ = tx.send(crate::event::RadioCommand::McpWriteByte { offset, value });
2133                    self.status_message =
2134                        Some(format!("{} → {} — applying...", $label, on_off(new_val)));
2135                }
2136            }};
2137        }
2138
2139        match row {
2140            SettingRow::TxInhibit => toggle_bool!(tx_inhibit, set_tx_inhibit, "TX Inhibit"),
2141            SettingRow::BeatShift => toggle_bool!(beat_shift, set_beat_shift, "Beat Shift"),
2142            SettingRow::VoxTxOnBusy => {
2143                toggle_bool!(vox_tx_on_busy, set_vox_tx_on_busy, "VOX TX Busy");
2144            }
2145            SettingRow::CwBreakIn => toggle_bool!(cw_break_in, set_cw_break_in, "CW Break-In"),
2146            SettingRow::DtmfTxHold => {
2147                toggle_bool!(dtmf_tx_hold, set_dtmf_tx_hold, "DTMF TX Hold");
2148            }
2149            SettingRow::RepeaterAutoOffset => {
2150                toggle_bool!(
2151                    repeater_auto_offset,
2152                    set_repeater_auto_offset,
2153                    "Rpt Auto Offset"
2154                );
2155            }
2156            SettingRow::LockKeyA => toggle_bool!(lock_key_a, set_lock_key_a, "Lock Key A"),
2157            SettingRow::LockKeyB => toggle_bool!(lock_key_b, set_lock_key_b, "Lock Key B"),
2158            SettingRow::LockKeyC => toggle_bool!(lock_key_c, set_lock_key_c, "Lock Key C"),
2159            SettingRow::LockPtt => toggle_bool!(lock_key_ptt, set_lock_key_ptt, "Lock PTT"),
2160            SettingRow::AprsLock => toggle_bool!(aprs_lock, set_aprs_lock, "APRS Lock"),
2161            SettingRow::Announce => toggle_bool!(announce, set_announce, "Announce"),
2162            SettingRow::KeyBeep => toggle_bool!(key_beep, set_key_beep, "Key Beep"),
2163            SettingRow::VolumeLock => toggle_bool!(volume_lock, set_volume_lock, "Vol Lock"),
2164            SettingRow::BtAutoConnect => {
2165                toggle_bool!(bt_auto_connect, set_bt_auto_connect, "BT Auto Connect");
2166            }
2167            SettingRow::UsbAudioOutput => {
2168                toggle_bool!(usb_audio_output, set_usb_audio_output, "USB Audio Out");
2169            }
2170            SettingRow::InternetLink => {
2171                toggle_bool!(internet_link, set_internet_link, "Internet Link");
2172            }
2173            SettingRow::PowerOnMessageFlag => {
2174                toggle_bool!(
2175                    power_on_message_flag,
2176                    set_power_on_message_flag,
2177                    "PowerOn Msg"
2178                );
2179            }
2180            SettingRow::BatterySaver => {
2181                toggle_bool!(battery_saver, set_battery_saver, "Battery Saver");
2182            }
2183            _ => {
2184                self.status_message = Some(format!("{}: use +/- to adjust", row.label()));
2185            }
2186        }
2187    }
2188
2189    /// Adjust a numeric setting by delta with +/-.
2190    #[allow(clippy::too_many_lines, clippy::cognitive_complexity)]
2191    fn adjust_setting(&mut self, delta: i8) {
2192        let (rows, idx) = if self.main_view == MainView::SettingsCat {
2193            (cat_settings(), self.settings_cat_index)
2194        } else {
2195            (mcp_settings(), self.settings_mcp_index)
2196        };
2197        let row = match rows.get(idx) {
2198            Some(r) => *r,
2199            None => return,
2200        };
2201
2202        // CAT-backed numeric settings
2203        if let Some(ref tx) = self.cmd_tx.clone() {
2204            match row {
2205                SettingRow::SquelchA => {
2206                    let cur = self.state.band_a.squelch.as_u8();
2207                    let next = if delta > 0 {
2208                        cur.saturating_add(1).min(6)
2209                    } else {
2210                        cur.saturating_sub(1)
2211                    };
2212                    if let Ok(level) = SquelchLevel::new(next) {
2213                        let _ = tx.send(crate::event::RadioCommand::SetSquelch {
2214                            band: kenwood_thd75::types::Band::A,
2215                            level,
2216                        });
2217                    }
2218                    self.status_message = Some(format!("Squelch A → {next}"));
2219                    return;
2220                }
2221                SettingRow::SquelchB => {
2222                    let cur = self.state.band_b.squelch.as_u8();
2223                    let next = if delta > 0 {
2224                        cur.saturating_add(1).min(6)
2225                    } else {
2226                        cur.saturating_sub(1)
2227                    };
2228                    if let Ok(level) = SquelchLevel::new(next) {
2229                        let _ = tx.send(crate::event::RadioCommand::SetSquelch {
2230                            band: kenwood_thd75::types::Band::B,
2231                            level,
2232                        });
2233                    }
2234                    self.status_message = Some(format!("Squelch B → {next}"));
2235                    return;
2236                }
2237                SettingRow::VoxGain => {
2238                    let cur = self.state.vox_gain.as_u8();
2239                    let next = if delta > 0 {
2240                        cur.saturating_add(1).min(9)
2241                    } else {
2242                        cur.saturating_sub(1)
2243                    };
2244                    if let Ok(gain) = VoxGain::new(next) {
2245                        let _ = tx.send(crate::event::RadioCommand::SetVoxGain(gain));
2246                    }
2247                    self.status_message = Some(format!("VOX Gain → {next}"));
2248                    return;
2249                }
2250                SettingRow::VoxDelay => {
2251                    let cur = self.state.vox_delay.as_u8();
2252                    let next = if delta > 0 {
2253                        cur.saturating_add(1).min(30)
2254                    } else {
2255                        cur.saturating_sub(1)
2256                    };
2257                    if let Ok(delay) = VoxDelay::new(next) {
2258                        let _ = tx.send(crate::event::RadioCommand::SetVoxDelay(delay));
2259                    }
2260                    self.status_message = Some(format!("VOX Delay → {next}"));
2261                    return;
2262                }
2263                SettingRow::StepSizeA => {
2264                    use kenwood_thd75::types::StepSize;
2265                    let steps = [
2266                        StepSize::Hz5000,
2267                        StepSize::Hz6250,
2268                        StepSize::Hz8330,
2269                        StepSize::Hz9000,
2270                        StepSize::Hz10000,
2271                        StepSize::Hz12500,
2272                        StepSize::Hz15000,
2273                        StepSize::Hz20000,
2274                        StepSize::Hz25000,
2275                        StepSize::Hz30000,
2276                        StepSize::Hz50000,
2277                        StepSize::Hz100000,
2278                    ];
2279                    let cur_idx = self
2280                        .state
2281                        .band_a
2282                        .step_size
2283                        .and_then(|s| steps.iter().position(|&x| x == s))
2284                        .unwrap_or(0);
2285                    let next_idx = if delta > 0 {
2286                        (cur_idx + 1).min(steps.len() - 1)
2287                    } else {
2288                        cur_idx.saturating_sub(1)
2289                    };
2290                    let next = steps[next_idx];
2291                    let _ = tx.send(crate::event::RadioCommand::SetStepSize {
2292                        band: kenwood_thd75::types::Band::A,
2293                        step: next,
2294                    });
2295                    self.status_message = Some(format!("Step A → {next}"));
2296                    return;
2297                }
2298                SettingRow::StepSizeB => {
2299                    use kenwood_thd75::types::StepSize;
2300                    let steps = [
2301                        StepSize::Hz5000,
2302                        StepSize::Hz6250,
2303                        StepSize::Hz8330,
2304                        StepSize::Hz9000,
2305                        StepSize::Hz10000,
2306                        StepSize::Hz12500,
2307                        StepSize::Hz15000,
2308                        StepSize::Hz20000,
2309                        StepSize::Hz25000,
2310                        StepSize::Hz30000,
2311                        StepSize::Hz50000,
2312                        StepSize::Hz100000,
2313                    ];
2314                    let cur_idx = self
2315                        .state
2316                        .band_b
2317                        .step_size
2318                        .and_then(|s| steps.iter().position(|&x| x == s))
2319                        .unwrap_or(0);
2320                    let next_idx = if delta > 0 {
2321                        (cur_idx + 1).min(steps.len() - 1)
2322                    } else {
2323                        cur_idx.saturating_sub(1)
2324                    };
2325                    let next = steps[next_idx];
2326                    let _ = tx.send(crate::event::RadioCommand::SetStepSize {
2327                        band: kenwood_thd75::types::Band::B,
2328                        step: next,
2329                    });
2330                    self.status_message = Some(format!("Step B → {next}"));
2331                    return;
2332                }
2333                SettingRow::FineStep => {
2334                    self.status_message = Some("Fine Step: read-only".into());
2335                    return;
2336                }
2337                SettingRow::FilterWidthSsb
2338                | SettingRow::FilterWidthCw
2339                | SettingRow::FilterWidthAm => {
2340                    self.status_message = Some("Filter Width: read-only".into());
2341                    return;
2342                }
2343                SettingRow::ScanResumeCat => {
2344                    use kenwood_thd75::types::ScanResumeMethod;
2345                    let methods = [
2346                        ScanResumeMethod::TimeOperated,
2347                        ScanResumeMethod::CarrierOperated,
2348                        ScanResumeMethod::Seek,
2349                    ];
2350                    let cur_idx = self
2351                        .state
2352                        .scan_resume_cat
2353                        .and_then(|m| methods.iter().position(|&x| x == m))
2354                        .unwrap_or(0);
2355                    let next_idx = if delta > 0 {
2356                        (cur_idx + 1) % methods.len()
2357                    } else {
2358                        (cur_idx + methods.len() - 1) % methods.len()
2359                    };
2360                    let next = methods[next_idx];
2361                    let _ = tx.send(crate::event::RadioCommand::SetScanResumeCat(next));
2362                    self.state.scan_resume_cat = Some(next);
2363                    let label = match next {
2364                        ScanResumeMethod::TimeOperated => "Time",
2365                        ScanResumeMethod::CarrierOperated => "Carrier",
2366                        ScanResumeMethod::Seek => "Seek",
2367                    };
2368                    self.status_message = Some(format!("Scan Resume → {label}"));
2369                    return;
2370                }
2371                SettingRow::PowerA => {
2372                    let next = match self.state.band_a.power_level {
2373                        PowerLevel::High => PowerLevel::Medium,
2374                        PowerLevel::Medium => PowerLevel::Low,
2375                        PowerLevel::Low => PowerLevel::ExtraLow,
2376                        PowerLevel::ExtraLow => PowerLevel::High,
2377                    };
2378                    let _ = tx.send(crate::event::RadioCommand::SetPower {
2379                        band: kenwood_thd75::types::Band::A,
2380                        level: next,
2381                    });
2382                    self.status_message = Some(format!("Power A → {next}"));
2383                    return;
2384                }
2385                SettingRow::PowerB => {
2386                    let next = match self.state.band_b.power_level {
2387                        PowerLevel::High => PowerLevel::Medium,
2388                        PowerLevel::Medium => PowerLevel::Low,
2389                        PowerLevel::Low => PowerLevel::ExtraLow,
2390                        PowerLevel::ExtraLow => PowerLevel::High,
2391                    };
2392                    let _ = tx.send(crate::event::RadioCommand::SetPower {
2393                        band: kenwood_thd75::types::Band::B,
2394                        level: next,
2395                    });
2396                    self.status_message = Some(format!("Power B → {next}"));
2397                    return;
2398                }
2399                SettingRow::ModeA => {
2400                    use kenwood_thd75::types::Mode;
2401                    let next = match self.state.band_a.mode {
2402                        Mode::Fm => Mode::Nfm,
2403                        Mode::Nfm => Mode::Am,
2404                        Mode::Am => Mode::Lsb,
2405                        Mode::Lsb => Mode::Usb,
2406                        Mode::Usb => Mode::Cw,
2407                        Mode::Cw => Mode::Dv,
2408                        Mode::Dv => Mode::Dr,
2409                        Mode::Dr => Mode::Wfm,
2410                        Mode::Wfm => Mode::CwReverse,
2411                        Mode::CwReverse => Mode::Fm,
2412                    };
2413                    let _ = tx.send(crate::event::RadioCommand::SetMode {
2414                        band: kenwood_thd75::types::Band::A,
2415                        mode: next,
2416                    });
2417                    self.status_message = Some(format!("Mode A → {next}"));
2418                    return;
2419                }
2420                SettingRow::ModeB => {
2421                    use kenwood_thd75::types::Mode;
2422                    let next = match self.state.band_b.mode {
2423                        Mode::Fm => Mode::Nfm,
2424                        Mode::Nfm => Mode::Am,
2425                        Mode::Am => Mode::Lsb,
2426                        Mode::Lsb => Mode::Usb,
2427                        Mode::Usb => Mode::Cw,
2428                        Mode::Cw => Mode::Dv,
2429                        Mode::Dv => Mode::Dr,
2430                        Mode::Dr => Mode::Wfm,
2431                        Mode::Wfm => Mode::CwReverse,
2432                        Mode::CwReverse => Mode::Fm,
2433                    };
2434                    let _ = tx.send(crate::event::RadioCommand::SetMode {
2435                        band: kenwood_thd75::types::Band::B,
2436                        mode: next,
2437                    });
2438                    self.status_message = Some(format!("Mode B → {next}"));
2439                    return;
2440                }
2441                SettingRow::ActiveBand => {
2442                    self.status_message =
2443                        Some("Active Band: use BC command — not yet wired".into());
2444                    return;
2445                }
2446                SettingRow::VfoMemModeA => {
2447                    self.status_message = Some("VFO/Mem A: use VM command — not yet wired".into());
2448                    return;
2449                }
2450                SettingRow::VfoMemModeB => {
2451                    self.status_message = Some("VFO/Mem B: use VM command — not yet wired".into());
2452                    return;
2453                }
2454                SettingRow::TncBaud => {
2455                    let baud = if delta > 0 {
2456                        kenwood_thd75::types::TncBaud::Bps9600
2457                    } else {
2458                        kenwood_thd75::types::TncBaud::Bps1200
2459                    };
2460                    let _ = tx.send(crate::event::RadioCommand::SetTncBaud(baud));
2461                    self.status_message = Some(format!("TNC Baud → {baud}"));
2462                    return;
2463                }
2464                SettingRow::BeaconType => {
2465                    let cur = u8::from(self.state.beacon_type);
2466                    let next = if delta > 0 {
2467                        cur.saturating_add(1).min(4)
2468                    } else {
2469                        cur.saturating_sub(1)
2470                    };
2471                    if let Ok(mode) = BeaconMode::try_from(next) {
2472                        let _ = tx.send(crate::event::RadioCommand::SetBeaconType(mode));
2473                        self.status_message = Some(format!("Beacon Type → {mode}"));
2474                    }
2475                    return;
2476                }
2477                SettingRow::CallsignSlot => {
2478                    self.status_message =
2479                        Some("Callsign Slot: not yet polled — cannot adjust".into());
2480                    return;
2481                }
2482                SettingRow::DstarSlot => {
2483                    self.status_message =
2484                        Some("D-STAR Slot: not yet polled — cannot adjust".into());
2485                    return;
2486                }
2487                _ => {}
2488            }
2489        }
2490
2491        // MCP-backed numeric settings — write directly via single-page MCP
2492        let Some(tx) = self.cmd_tx.clone() else {
2493            return;
2494        };
2495
2496        let McpState::Loaded { ref mut image, .. } = self.mcp else {
2497            self.status_message = Some(format!("{}: load MCP data first (m → r)", row.label()));
2498            return;
2499        };
2500
2501        /// Compute a new numeric value by applying `delta` to the current value,
2502        /// then write it via `modify_setting`.
2503        macro_rules! adjust_numeric {
2504            ($getter:ident, $setter:ident, $label:expr, $image:expr, $delta:expr, $tx:expr) => {{
2505                let new_val = $image.settings().$getter().saturating_add_signed($delta);
2506                if let Some((offset, value)) = $image.modify_setting(|w| w.$setter(new_val)) {
2507                    let _ = $tx.send(crate::event::RadioCommand::McpWriteByte { offset, value });
2508                    self.status_message =
2509                        Some(format!("{} → {} — applying...", $label, new_val));
2510                }
2511            }};
2512        }
2513
2514        match row {
2515            SettingRow::FmNarrow => {
2516                adjust_numeric!(fm_narrow, set_fm_narrow, "FM Narrow", image, delta, tx);
2517            }
2518            SettingRow::SsbHighCut => {
2519                adjust_numeric!(
2520                    ssb_high_cut,
2521                    set_ssb_high_cut,
2522                    "SSB High Cut",
2523                    image,
2524                    delta,
2525                    tx
2526                );
2527            }
2528            SettingRow::CwHighCut => {
2529                adjust_numeric!(
2530                    cw_high_cut,
2531                    set_cw_high_cut,
2532                    "CW High Cut",
2533                    image,
2534                    delta,
2535                    tx
2536                );
2537            }
2538            SettingRow::AmHighCut => {
2539                adjust_numeric!(
2540                    am_high_cut,
2541                    set_am_high_cut,
2542                    "AM High Cut",
2543                    image,
2544                    delta,
2545                    tx
2546                );
2547            }
2548            SettingRow::AutoFilter => {
2549                adjust_numeric!(
2550                    auto_filter,
2551                    set_auto_filter,
2552                    "Auto Filter",
2553                    image,
2554                    delta,
2555                    tx
2556                );
2557            }
2558            SettingRow::ScanResume => {
2559                adjust_numeric!(
2560                    scan_resume,
2561                    set_scan_resume,
2562                    "Scan Resume",
2563                    image,
2564                    delta,
2565                    tx
2566                );
2567            }
2568            SettingRow::DigitalScanResume => {
2569                adjust_numeric!(
2570                    digital_scan_resume,
2571                    set_digital_scan_resume,
2572                    "Dig Scan Resume",
2573                    image,
2574                    delta,
2575                    tx
2576                );
2577            }
2578            SettingRow::ScanRestartTime => {
2579                adjust_numeric!(
2580                    scan_restart_time,
2581                    set_scan_restart_time,
2582                    "Scan Restart Time",
2583                    image,
2584                    delta,
2585                    tx
2586                );
2587            }
2588            SettingRow::ScanRestartCarrier => {
2589                adjust_numeric!(
2590                    scan_restart_carrier,
2591                    set_scan_restart_carrier,
2592                    "Scan Restart Carrier",
2593                    image,
2594                    delta,
2595                    tx
2596                );
2597            }
2598            SettingRow::TimeoutTimer => {
2599                adjust_numeric!(
2600                    timeout_timer,
2601                    set_timeout_timer,
2602                    "Timeout Timer",
2603                    image,
2604                    delta,
2605                    tx
2606                );
2607            }
2608            SettingRow::CwDelayTime => {
2609                adjust_numeric!(
2610                    cw_delay_time,
2611                    set_cw_delay_time,
2612                    "CW Delay",
2613                    image,
2614                    delta,
2615                    tx
2616                );
2617            }
2618            SettingRow::CwPitch => {
2619                adjust_numeric!(cw_pitch, set_cw_pitch, "CW Pitch", image, delta, tx);
2620            }
2621            SettingRow::DtmfSpeed => {
2622                adjust_numeric!(dtmf_speed, set_dtmf_speed, "DTMF Speed", image, delta, tx);
2623            }
2624            SettingRow::DtmfPauseTime => {
2625                adjust_numeric!(
2626                    dtmf_pause_time,
2627                    set_dtmf_pause_time,
2628                    "DTMF Pause",
2629                    image,
2630                    delta,
2631                    tx
2632                );
2633            }
2634            SettingRow::RepeaterCallKey => {
2635                adjust_numeric!(
2636                    repeater_call_key,
2637                    set_repeater_call_key,
2638                    "Call Key",
2639                    image,
2640                    delta,
2641                    tx
2642                );
2643            }
2644            SettingRow::MicSensitivity => {
2645                adjust_numeric!(
2646                    mic_sensitivity,
2647                    set_mic_sensitivity,
2648                    "Mic Sens",
2649                    image,
2650                    delta,
2651                    tx
2652                );
2653            }
2654            SettingRow::PfKey1 => {
2655                adjust_numeric!(pf_key1, set_pf_key1, "PF Key 1", image, delta, tx);
2656            }
2657            SettingRow::PfKey2 => {
2658                adjust_numeric!(pf_key2, set_pf_key2, "PF Key 2", image, delta, tx);
2659            }
2660            SettingRow::KeyLockType => {
2661                let new_val = image
2662                    .settings()
2663                    .key_lock_type_raw()
2664                    .saturating_add_signed(delta)
2665                    .min(2);
2666                if let Some((offset, value)) =
2667                    image.modify_setting(|w| w.set_key_lock_type_raw(new_val))
2668                {
2669                    let _ = tx.send(crate::event::RadioCommand::McpWriteByte { offset, value });
2670                    self.status_message = Some(format!("Lock Type → {new_val} — applying..."));
2671                }
2672            }
2673            SettingRow::DualDisplaySize => {
2674                adjust_numeric!(
2675                    dual_display_size,
2676                    set_dual_display_size,
2677                    "Dual Display",
2678                    image,
2679                    delta,
2680                    tx
2681                );
2682            }
2683            SettingRow::DisplayArea => {
2684                adjust_numeric!(
2685                    display_area,
2686                    set_display_area,
2687                    "Display Area",
2688                    image,
2689                    delta,
2690                    tx
2691                );
2692            }
2693            SettingRow::InfoLine => {
2694                adjust_numeric!(info_line, set_info_line, "Info Line", image, delta, tx);
2695            }
2696            SettingRow::BacklightControl => {
2697                adjust_numeric!(
2698                    backlight_control,
2699                    set_backlight_control,
2700                    "Backlight Ctrl",
2701                    image,
2702                    delta,
2703                    tx
2704                );
2705            }
2706            SettingRow::BacklightTimer => {
2707                adjust_numeric!(
2708                    backlight_timer,
2709                    set_backlight_timer,
2710                    "Backlight Timer",
2711                    image,
2712                    delta,
2713                    tx
2714                );
2715            }
2716            SettingRow::DisplayHoldTime => {
2717                adjust_numeric!(
2718                    display_hold_time,
2719                    set_display_hold_time,
2720                    "Display Hold",
2721                    image,
2722                    delta,
2723                    tx
2724                );
2725            }
2726            SettingRow::DisplayMethod => {
2727                adjust_numeric!(
2728                    display_method,
2729                    set_display_method,
2730                    "Display Method",
2731                    image,
2732                    delta,
2733                    tx
2734                );
2735            }
2736            SettingRow::PowerOnDisplay => {
2737                adjust_numeric!(
2738                    power_on_display,
2739                    set_power_on_display,
2740                    "PowerOn Display",
2741                    image,
2742                    delta,
2743                    tx
2744                );
2745            }
2746            SettingRow::EmrVolumeLevel => {
2747                adjust_numeric!(
2748                    emr_volume_level,
2749                    set_emr_volume_level,
2750                    "EMR Vol",
2751                    image,
2752                    delta,
2753                    tx
2754                );
2755            }
2756            SettingRow::AutoMuteReturnTime => {
2757                adjust_numeric!(
2758                    auto_mute_return_time,
2759                    set_auto_mute_return_time,
2760                    "Auto Mute",
2761                    image,
2762                    delta,
2763                    tx
2764                );
2765            }
2766            SettingRow::BeepVolume => {
2767                let cur = image.settings().beep_volume();
2768                let new_val = if delta > 0 {
2769                    cur.saturating_add(1).min(7)
2770                } else {
2771                    cur.saturating_sub(1).max(1)
2772                };
2773                if let Some((offset, value)) = image.modify_setting(|w| w.set_beep_volume(new_val))
2774                {
2775                    let _ = tx.send(crate::event::RadioCommand::McpWriteByte { offset, value });
2776                    self.status_message = Some(format!("Beep Vol → {new_val} — applying..."));
2777                }
2778            }
2779            SettingRow::VoiceLanguage => {
2780                adjust_numeric!(
2781                    voice_language,
2782                    set_voice_language,
2783                    "Voice Lang",
2784                    image,
2785                    delta,
2786                    tx
2787                );
2788            }
2789            SettingRow::VoiceVolume => {
2790                adjust_numeric!(
2791                    voice_volume,
2792                    set_voice_volume,
2793                    "Voice Vol",
2794                    image,
2795                    delta,
2796                    tx
2797                );
2798            }
2799            SettingRow::VoiceSpeed => {
2800                adjust_numeric!(
2801                    voice_speed,
2802                    set_voice_speed,
2803                    "Voice Speed",
2804                    image,
2805                    delta,
2806                    tx
2807                );
2808            }
2809            SettingRow::SpeedDistanceUnit => {
2810                let new_val = if delta > 0 {
2811                    image
2812                        .settings()
2813                        .speed_distance_unit_raw()
2814                        .saturating_add(1)
2815                        .min(2)
2816                } else {
2817                    image.settings().speed_distance_unit_raw().saturating_sub(1)
2818                };
2819                if let Some((offset, value)) =
2820                    image.modify_setting(|w| w.set_speed_distance_unit_raw(new_val))
2821                {
2822                    let _ = tx.send(crate::event::RadioCommand::McpWriteByte { offset, value });
2823                    self.status_message = Some(format!(
2824                        "Speed Unit → {} — applying...",
2825                        ["mph", "km/h", "knots"]
2826                            .get(new_val as usize)
2827                            .unwrap_or(&"?")
2828                    ));
2829                }
2830            }
2831            SettingRow::AltitudeRainUnit => {
2832                let new_val = if delta > 0 {
2833                    image
2834                        .settings()
2835                        .altitude_rain_unit_raw()
2836                        .saturating_add(1)
2837                        .min(1)
2838                } else {
2839                    image.settings().altitude_rain_unit_raw().saturating_sub(1)
2840                };
2841                if let Some((offset, value)) =
2842                    image.modify_setting(|w| w.set_altitude_rain_unit_raw(new_val))
2843                {
2844                    let _ = tx.send(crate::event::RadioCommand::McpWriteByte { offset, value });
2845                    self.status_message = Some(format!(
2846                        "Alt Unit → {} — applying...",
2847                        if new_val == 0 { "ft/in" } else { "m/mm" }
2848                    ));
2849                }
2850            }
2851            SettingRow::TemperatureUnit => {
2852                let new_val = if delta > 0 {
2853                    image
2854                        .settings()
2855                        .temperature_unit_raw()
2856                        .saturating_add(1)
2857                        .min(1)
2858                } else {
2859                    image.settings().temperature_unit_raw().saturating_sub(1)
2860                };
2861                if let Some((offset, value)) =
2862                    image.modify_setting(|w| w.set_temperature_unit_raw(new_val))
2863                {
2864                    let _ = tx.send(crate::event::RadioCommand::McpWriteByte { offset, value });
2865                    self.status_message = Some(format!(
2866                        "Temp Unit → {} — applying...",
2867                        if new_val == 0 { "°F" } else { "°C" }
2868                    ));
2869                }
2870            }
2871            SettingRow::GpsBtInterface => {
2872                adjust_numeric!(
2873                    gps_bt_interface,
2874                    set_gps_bt_interface,
2875                    "GPS/BT",
2876                    image,
2877                    delta,
2878                    tx
2879                );
2880            }
2881            SettingRow::PcOutputMode => {
2882                adjust_numeric!(
2883                    pc_output_mode,
2884                    set_pc_output_mode,
2885                    "PC Output",
2886                    image,
2887                    delta,
2888                    tx
2889                );
2890            }
2891            SettingRow::AprsUsbMode => {
2892                adjust_numeric!(
2893                    aprs_usb_mode,
2894                    set_aprs_usb_mode,
2895                    "APRS USB",
2896                    image,
2897                    delta,
2898                    tx
2899                );
2900            }
2901            SettingRow::AutoPowerOff => {
2902                let new_val = if delta > 0 {
2903                    image
2904                        .settings()
2905                        .auto_power_off_raw()
2906                        .saturating_add(1)
2907                        .min(4)
2908                } else {
2909                    image.settings().auto_power_off_raw().saturating_sub(1)
2910                };
2911                if let Some((offset, value)) =
2912                    image.modify_setting(|w| w.set_auto_power_off_raw(new_val))
2913                {
2914                    let _ = tx.send(crate::event::RadioCommand::McpWriteByte { offset, value });
2915                    self.status_message = Some(format!(
2916                        "Auto PwrOff → {} — applying...",
2917                        ["Off", "30m", "60m", "90m", "120m"]
2918                            .get(new_val as usize)
2919                            .unwrap_or(&"?")
2920                    ));
2921                }
2922            }
2923            _ => {
2924                self.status_message = Some(format!("{}: not adjustable", row.label()));
2925            }
2926        }
2927    }
2928
2929    /// Process an incoming APRS event from the radio task.
2930    fn handle_aprs_event(&mut self, event: kenwood_thd75::AprsEvent) {
2931        use kenwood_thd75::AprsEvent;
2932        match event {
2933            AprsEvent::StationHeard(entry) => {
2934                self.update_station_cache(&entry);
2935            }
2936            AprsEvent::PositionReceived { source, position } => {
2937                // Build a minimal cache entry from position data.
2938                let idx = self.aprs_stations.iter().position(|s| s.callsign == source);
2939                if let Some(idx) = idx {
2940                    let cached = &mut self.aprs_stations[idx];
2941                    cached.latitude = Some(position.latitude);
2942                    cached.longitude = Some(position.longitude);
2943                    cached.speed_knots = position.speed_knots;
2944                    cached.course_degrees = position.course_degrees;
2945                    cached.symbol_table = Some(position.symbol_table);
2946                    cached.symbol_code = Some(position.symbol_code);
2947                    if !position.comment.is_empty() {
2948                        cached.comment = Some(position.comment);
2949                    }
2950                    cached.last_heard = Instant::now();
2951                    cached.packet_count = cached.packet_count.saturating_add(1);
2952                } else {
2953                    self.aprs_stations.push(AprsStationCache {
2954                        callsign: source,
2955                        latitude: Some(position.latitude),
2956                        longitude: Some(position.longitude),
2957                        speed_knots: position.speed_knots,
2958                        course_degrees: position.course_degrees,
2959                        symbol_table: Some(position.symbol_table),
2960                        symbol_code: Some(position.symbol_code),
2961                        comment: if position.comment.is_empty() {
2962                            None
2963                        } else {
2964                            Some(position.comment)
2965                        },
2966                        packet_count: 1,
2967                        last_path: Vec::new(),
2968                        last_heard: Instant::now(),
2969                    });
2970                }
2971                self.sort_aprs_stations();
2972            }
2973            AprsEvent::MessageReceived(msg) => {
2974                self.status_message =
2975                    Some(format!("APRS msg from {}: {}", msg.addressee, msg.text));
2976            }
2977            AprsEvent::MessageDelivered(id) => {
2978                if let Some(m) = self.aprs_messages.iter_mut().find(|m| m.message_id == id) {
2979                    m.state = AprsMessageState::Delivered;
2980                }
2981                self.status_message = Some(format!("Message {id} delivered"));
2982            }
2983            AprsEvent::MessageRejected(id) => {
2984                if let Some(m) = self.aprs_messages.iter_mut().find(|m| m.message_id == id) {
2985                    m.state = AprsMessageState::Rejected;
2986                }
2987                self.status_message = Some(format!("Message {id} rejected"));
2988            }
2989            AprsEvent::MessageExpired(id) => {
2990                if let Some(m) = self.aprs_messages.iter_mut().find(|m| m.message_id == id) {
2991                    m.state = AprsMessageState::Expired;
2992                }
2993                self.status_message = Some(format!("Message {id} expired"));
2994            }
2995            AprsEvent::WeatherReceived { source, .. } => {
2996                self.status_message = Some(format!("WX from {source}"));
2997            }
2998            AprsEvent::PacketDigipeated { source } => {
2999                self.status_message = Some(format!("Digipeated packet from {source}"));
3000            }
3001            AprsEvent::QueryResponded { to } => {
3002                self.status_message = Some(format!("Responded to query from {to}"));
3003            }
3004            AprsEvent::RawPacket(_) => {
3005                // Silently ignore raw packets for now.
3006            }
3007        }
3008    }
3009
3010    /// Update the station cache from a `StationEntry`.
3011    fn update_station_cache(&mut self, entry: &kenwood_thd75::StationEntry) {
3012        let cached = AprsStationCache {
3013            callsign: entry.callsign.clone(),
3014            latitude: entry.position.as_ref().map(|p| p.latitude),
3015            longitude: entry.position.as_ref().map(|p| p.longitude),
3016            speed_knots: entry.position.as_ref().and_then(|p| p.speed_knots),
3017            course_degrees: entry.position.as_ref().and_then(|p| p.course_degrees),
3018            symbol_table: entry.position.as_ref().map(|p| p.symbol_table),
3019            symbol_code: entry.position.as_ref().map(|p| p.symbol_code),
3020            comment: entry
3021                .position
3022                .as_ref()
3023                .filter(|p| !p.comment.is_empty())
3024                .map(|p| p.comment.clone()),
3025            packet_count: entry.packet_count,
3026            last_path: entry.last_path.clone(),
3027            last_heard: entry.last_heard,
3028        };
3029
3030        if let Some(idx) = self
3031            .aprs_stations
3032            .iter()
3033            .position(|s| s.callsign == cached.callsign)
3034        {
3035            self.aprs_stations[idx] = cached;
3036        } else {
3037            self.aprs_stations.push(cached);
3038        }
3039        self.sort_aprs_stations();
3040    }
3041
3042    /// Sort stations by most recently heard.
3043    fn sort_aprs_stations(&mut self) {
3044        self.aprs_stations
3045            .sort_by(|a, b| b.last_heard.cmp(&a.last_heard));
3046    }
3047
3048    /// Toggle APRS mode on or off.
3049    fn handle_dstar_event(&mut self, event: kenwood_thd75::DStarEvent) {
3050        use kenwood_thd75::DStarEvent;
3051        match event {
3052            DStarEvent::VoiceStart(header) => {
3053                self.dstar_rx_active = true;
3054                self.dstar_rx_header = Some(header);
3055                self.dstar_text_message = None;
3056            }
3057            DStarEvent::VoiceData(_frame) => {
3058                // Voice data — no UI action needed.
3059            }
3060            DStarEvent::VoiceEnd => {
3061                self.dstar_rx_active = false;
3062            }
3063            DStarEvent::VoiceLost => {
3064                self.dstar_rx_active = false;
3065                self.status_message = Some("D-STAR: voice lost (no clean EOT)".into());
3066            }
3067            DStarEvent::TextMessage(text) => {
3068                self.dstar_text_message = Some(text);
3069            }
3070            DStarEvent::StationHeard(entry) => {
3071                // Update the last-heard list (newest first).
3072                if let Some(idx) = self
3073                    .dstar_last_heard
3074                    .iter()
3075                    .position(|e| e.callsign == entry.callsign)
3076                {
3077                    let _ = self.dstar_last_heard.remove(idx);
3078                }
3079                self.dstar_last_heard.insert(0, entry);
3080                // Limit to 100 entries.
3081                self.dstar_last_heard.truncate(100);
3082            }
3083            DStarEvent::UrCallCommand(action) => {
3084                self.status_message = Some(format!("D-STAR: URCALL command detected: {action:?}"));
3085            }
3086            DStarEvent::StatusUpdate(_status) => {
3087                // Modem status — no UI action needed.
3088            }
3089        }
3090    }
3091
3092    fn toggle_gps(&mut self) {
3093        let next = !self.state.gps_enabled;
3094        if let Some(ref tx) = self.cmd_tx {
3095            let _ = tx.send(crate::event::RadioCommand::SetGpsConfig(
3096                next,
3097                self.state.gps_pc_output,
3098            ));
3099            self.status_message =
3100                Some(format!("GPS {}", if next { "enabled" } else { "disabled" }));
3101        }
3102    }
3103
3104    fn toggle_gps_pc_output(&mut self) {
3105        let next = !self.state.gps_pc_output;
3106        if let Some(ref tx) = self.cmd_tx {
3107            let _ = tx.send(crate::event::RadioCommand::SetGpsConfig(
3108                self.state.gps_enabled,
3109                next,
3110            ));
3111            self.status_message = Some(format!(
3112                "GPS PC Output {}",
3113                if next { "enabled" } else { "disabled" }
3114            ));
3115        }
3116    }
3117
3118    fn toggle_dstar_mode(&mut self) {
3119        match self.dstar_mode {
3120            DStarMode::Inactive => {
3121                // Build D-STAR config from MCP data if available.
3122                let callsign = if let McpState::Loaded { ref image, .. } = self.mcp {
3123                    let cs = image.dstar().my_callsign();
3124                    if cs.is_empty() {
3125                        "N0CALL".to_string()
3126                    } else {
3127                        cs
3128                    }
3129                } else {
3130                    "N0CALL".to_string()
3131                };
3132
3133                let config = kenwood_thd75::DStarGatewayConfig::new(&callsign);
3134                if let Some(ref tx) = self.cmd_tx {
3135                    let _ = tx.send(crate::event::RadioCommand::EnterDStar { config });
3136                    self.status_message = Some("Entering D-STAR gateway mode...".into());
3137                }
3138            }
3139            DStarMode::Active => {
3140                if let Some(ref tx) = self.cmd_tx {
3141                    let _ = tx.send(crate::event::RadioCommand::ExitDStar);
3142                    self.status_message = Some("Exiting D-STAR gateway mode...".into());
3143                }
3144            }
3145        }
3146    }
3147
3148    fn toggle_fm_radio(&mut self) {
3149        let next = !self.fm_radio_on;
3150        if let Some(ref tx) = self.cmd_tx {
3151            let _ = tx.send(crate::event::RadioCommand::SetFmRadio(next));
3152            self.fm_radio_on = next;
3153            self.status_message = Some(format!(
3154                "FM Radio {}",
3155                if next { "enabled" } else { "disabled" }
3156            ));
3157        }
3158    }
3159
3160    /// Apply a channel edit from the edit buffer.
3161    ///
3162    /// Uses ME (memory channel) write via CAT. This tunes the radio's live
3163    /// channel, not permanent memory storage (which would require MCP).
3164    fn apply_channel_edit(&mut self, field: ChannelEditField, buf: &str) {
3165        if buf.is_empty() {
3166            self.status_message = Some("No value entered".into());
3167            return;
3168        }
3169
3170        let used = self.filtered_channels();
3171        let Some(&ch_num) = used.get(self.channel_list_index) else {
3172            self.status_message = Some("No channel selected".into());
3173            return;
3174        };
3175
3176        match field {
3177            ChannelEditField::Frequency => {
3178                // Parse as MHz, tune via CAT
3179                if let Ok(mhz) = buf.parse::<f64>() {
3180                    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
3181                    let hz = (mhz * 1_000_000.0) as u32;
3182                    if let Some(ref tx) = self.cmd_tx {
3183                        let _ = tx.send(crate::event::RadioCommand::TuneFreq {
3184                            band: self.target_band,
3185                            freq: hz,
3186                        });
3187                        self.status_message = Some(format!("Ch {ch_num}: tuning to {mhz:.6} MHz"));
3188                    }
3189                } else {
3190                    self.status_message = Some(format!("Invalid frequency: {buf}"));
3191                }
3192            }
3193            ChannelEditField::Name => {
3194                // Channel name editing requires MCP write (no CAT command for name-only).
3195                self.status_message = Some(format!(
3196                    "Ch {ch_num}: name editing requires MCP write — use MCP panel (m)"
3197                ));
3198            }
3199            ChannelEditField::Mode => {
3200                // Cycle mode via CAT
3201                if let Some(ref tx) = self.cmd_tx {
3202                    use kenwood_thd75::types::Mode;
3203                    let mode = match buf.to_uppercase().as_str() {
3204                        "FM" => Some(Mode::Fm),
3205                        "NFM" => Some(Mode::Nfm),
3206                        "AM" => Some(Mode::Am),
3207                        "DV" => Some(Mode::Dv),
3208                        "LSB" => Some(Mode::Lsb),
3209                        "USB" => Some(Mode::Usb),
3210                        "CW" => Some(Mode::Cw),
3211                        "DR" => Some(Mode::Dr),
3212                        "WFM" => Some(Mode::Wfm),
3213                        _ => None,
3214                    };
3215                    if let Some(mode) = mode {
3216                        let _ = tx.send(crate::event::RadioCommand::SetMode {
3217                            band: self.target_band,
3218                            mode,
3219                        });
3220                        self.status_message = Some(format!("Ch {ch_num}: mode set to {mode}"));
3221                    } else {
3222                        self.status_message = Some(format!(
3223                            "Unknown mode '{buf}' (try FM/NFM/AM/DV/LSB/USB/CW/DR/WFM)"
3224                        ));
3225                    }
3226                }
3227            }
3228            ChannelEditField::ToneMode
3229            | ChannelEditField::ToneFreq
3230            | ChannelEditField::Duplex
3231            | ChannelEditField::Offset => {
3232                // These fields are stored in the ME channel record and require
3233                // either a full ME write (which changes the live channel) or MCP
3234                // for permanent memory storage. Full ME write support is planned.
3235                self.status_message = Some(format!(
3236                    "Ch {ch_num}: {} editing not yet implemented — requires ME write",
3237                    field.label()
3238                ));
3239            }
3240        }
3241    }
3242
3243    fn toggle_aprs_mode(&mut self) {
3244        match self.aprs_mode {
3245            AprsMode::Inactive => {
3246                // Build APRS config from MCP data if available, else use defaults.
3247                let (callsign, ssid) = if let McpState::Loaded { ref image, .. } = self.mcp {
3248                    let cs = image.aprs().my_callsign();
3249                    if cs.is_empty() {
3250                        ("N0CALL".to_string(), 7u8)
3251                    } else {
3252                        // Parse SSID from callsign if present (e.g., "KQ4NIT-9").
3253                        if let Some((call, ssid_str)) = cs.split_once('-') {
3254                            let ssid = ssid_str.parse::<u8>().unwrap_or(7);
3255                            (call.to_string(), ssid)
3256                        } else {
3257                            (cs, 7)
3258                        }
3259                    }
3260                } else {
3261                    ("N0CALL".to_string(), 7)
3262                };
3263
3264                let config = Box::new(kenwood_thd75::AprsClientConfig::new(&callsign, ssid));
3265                if let Some(ref tx) = self.cmd_tx {
3266                    let _ = tx.send(crate::event::RadioCommand::EnterAprs { config });
3267                    self.status_message = Some("Entering APRS mode...".into());
3268                }
3269            }
3270            AprsMode::Active => {
3271                if let Some(ref tx) = self.cmd_tx {
3272                    let _ = tx.send(crate::event::RadioCommand::ExitAprs);
3273                    self.status_message = Some("Exiting APRS mode...".into());
3274                }
3275            }
3276        }
3277    }
3278}