thd75_tui/ui/
settings.rs

1use ratatui::Frame;
2use ratatui::layout::Rect;
3use ratatui::style::{Color, Modifier, Style};
4use ratatui::text::{Line, Span};
5use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph};
6
7use crate::app::{App, McpState, Pane, SettingRow, cat_settings, mcp_settings};
8
9fn bool_span(b: bool) -> (String, Color) {
10    if b {
11        ("On".into(), Color::Green)
12    } else {
13        ("Off".into(), Color::DarkGray)
14    }
15}
16
17fn num_span(v: impl std::fmt::Display) -> (String, Color) {
18    (format!("{v}"), Color::Yellow)
19}
20
21/// Render the CAT settings list (instant writes, no disconnect).
22pub(crate) fn render_cat(app: &App, frame: &mut Frame<'_>, list_area: Rect, detail_area: Rect) {
23    let rows = cat_settings();
24    render_settings_list(
25        app,
26        frame,
27        list_area,
28        detail_area,
29        &rows,
30        app.settings_cat_index,
31        " Settings (CAT — instant) [Enter: toggle, +/-: adjust] ",
32    );
33}
34
35/// Render the MCP settings list (~3s per change, brief disconnect).
36pub(crate) fn render_mcp(app: &App, frame: &mut Frame<'_>, list_area: Rect, detail_area: Rect) {
37    let rows = mcp_settings();
38    render_settings_list(
39        app,
40        frame,
41        list_area,
42        detail_area,
43        &rows,
44        app.settings_mcp_index,
45        " Settings (MCP — ~3s per change) [Enter: toggle, +/-: adjust] ",
46    );
47}
48
49fn render_settings_list(
50    app: &App,
51    frame: &mut Frame<'_>,
52    list_area: Rect,
53    detail_area: Rect,
54    rows: &[SettingRow],
55    selected_index: usize,
56    title: &str,
57) {
58    let block = Block::default()
59        .title(title.to_string())
60        .borders(Borders::ALL)
61        .border_style(super::border_style(app, Pane::Main));
62
63    let detail_block = Block::default()
64        .title(" Radio Info (live) ")
65        .borders(Borders::ALL)
66        .border_style(super::border_style(app, Pane::Detail));
67
68    // Build list items: section headers interspersed with setting rows.
69    // selected_index tracks the rows slice index; we map it to the ListItem index
70    // (which is larger due to the interleaved section headers) for ListState scrolling.
71
72    // Compute the ListItem index for the currently selected row.
73    let selected_list_idx = {
74        let mut list_item_idx = 0usize;
75        let mut found = None;
76        for (row_idx, &row) in rows.iter().enumerate() {
77            if row.section_header().is_some() {
78                list_item_idx += 1; // header item
79            }
80            if row_idx == selected_index {
81                found = Some(list_item_idx);
82                break;
83            }
84            list_item_idx += 1;
85        }
86        found
87    };
88
89    let list_items: Vec<ListItem<'_>> =
90        if matches!(app.mcp, McpState::Loaded { .. }) || rows.iter().any(|r| r.is_cat()) {
91            let mut result = Vec::new();
92            for (idx, &row) in rows.iter().enumerate() {
93                // Section header if this row starts a new group
94                if let Some(header) = row.section_header() {
95                    result.push(ListItem::new(Line::from(vec![Span::styled(
96                        format!(" {header}"),
97                        Style::default()
98                            .fg(Color::Cyan)
99                            .add_modifier(Modifier::BOLD),
100                    )])));
101                }
102
103                let (val, color) = get_row_value(app, row);
104                let hint = if row.is_numeric() { " [+/-]" } else { "" };
105                let selected_marker = if idx == selected_index {
106                    "\u{25b8} "
107                } else {
108                    "  "
109                };
110                result.push(ListItem::new(Line::from(vec![
111                    Span::styled(
112                        format!("{selected_marker}{:<22}", row.label()),
113                        Style::default().fg(Color::White),
114                    ),
115                    Span::styled(val, Style::default().fg(color)),
116                    Span::styled(hint.to_string(), Style::default().fg(Color::DarkGray)),
117                ])));
118            }
119            result
120        } else {
121            vec![ListItem::new(" No MCP data loaded. Press [m] then [r].")]
122        };
123
124    // Use ListState to scroll the list so the selected item is visible.
125    // The selected item in ListState is the ListItem index, not the row slice index.
126    let mut list_state = ListState::default();
127    list_state.select(selected_list_idx);
128
129    let list = List::new(list_items).block(block).highlight_style(
130        Style::default()
131            .fg(Color::Black)
132            .bg(Color::Cyan)
133            .add_modifier(Modifier::BOLD),
134    );
135
136    frame.render_stateful_widget(list, list_area, &mut list_state);
137
138    // Right pane: live radio state from CAT commands (read-only display)
139    let s = &app.state;
140    let mut lines: Vec<Line<'_>> = Vec::new();
141
142    lines.push(Line::from(Span::styled(
143        " Radio Identity",
144        Style::default()
145            .fg(Color::Yellow)
146            .add_modifier(Modifier::BOLD),
147    )));
148    lines.push(kv(" Firmware", &s.firmware_version));
149    lines.push(kv(" Type", &s.radio_type));
150    lines.push(kv(" Port", &app.port_path));
151    lines.push(Line::from(""));
152
153    lines.push(Line::from(Span::styled(
154        " Live CAT State",
155        Style::default()
156            .fg(Color::Yellow)
157            .add_modifier(Modifier::BOLD),
158    )));
159    lines.push(kv(" Battery", &{
160        use kenwood_thd75::types::BatteryLevel;
161        match s.battery_level {
162            BatteryLevel::Empty => "Empty (Red)".to_string(),
163            BatteryLevel::OneThird => "1/3 (Yellow)".to_string(),
164            BatteryLevel::TwoThirds => "2/3 (Green)".to_string(),
165            BatteryLevel::Full => "Full (Green)".to_string(),
166            BatteryLevel::Charging => "Charging".to_string(),
167        }
168    }));
169    lines.push(kv(" AF Gain", &format!("{}", s.af_gain)));
170    lines.push(kv(" Beep", &on_off(s.beep)));
171    lines.push(kv(" Lock", &on_off(s.lock)));
172    lines.push(kv(" Dual Band", &on_off(s.dual_band)));
173    lines.push(kv(" Bluetooth", &on_off(s.bluetooth)));
174    lines.push(kv(" VOX", &on_off(s.vox)));
175    lines.push(kv(" GPS", &on_off(s.gps_enabled)));
176    lines.push(kv(" Beacon", &format!("{}", s.beacon_type)));
177    lines.push(kv(
178        " Fine Step",
179        &s.fine_step
180            .map_or_else(|| "N/A".to_string(), |fs| format!("{fs}")),
181    ));
182    lines.push(kv(
183        " Filter SSB",
184        &s.filter_width_ssb
185            .map_or_else(|| "N/A".to_string(), |w| format!("{w}")),
186    ));
187    lines.push(kv(
188        " Filter CW",
189        &s.filter_width_cw
190            .map_or_else(|| "N/A".to_string(), |w| format!("{w}")),
191    ));
192    lines.push(kv(
193        " Filter AM",
194        &s.filter_width_am
195            .map_or_else(|| "N/A".to_string(), |w| format!("{w}")),
196    ));
197    lines.push(Line::from(""));
198
199    lines.push(Line::from(Span::styled(
200        " Band A",
201        Style::default()
202            .fg(Color::Yellow)
203            .add_modifier(Modifier::BOLD),
204    )));
205    lines.push(kv(
206        " Step",
207        &s.band_a
208            .step_size
209            .map_or_else(|| "N/A".into(), |st| format!("{st}")),
210    ));
211    lines.push(kv(" Attenuator", &on_off(s.band_a.attenuator)));
212    lines.push(kv(" Squelch", &s.band_a.squelch.to_string()));
213    lines.push(Line::from(""));
214
215    lines.push(Line::from(Span::styled(
216        " Band B",
217        Style::default()
218            .fg(Color::Yellow)
219            .add_modifier(Modifier::BOLD),
220    )));
221    lines.push(kv(
222        " Step",
223        &s.band_b
224            .step_size
225            .map_or_else(|| "N/A".into(), |st| format!("{st}")),
226    ));
227    lines.push(kv(" Attenuator", &on_off(s.band_b.attenuator)));
228    lines.push(kv(" Squelch", &s.band_b.squelch.to_string()));
229    lines.push(Line::from(""));
230
231    lines.push(Line::from(Span::styled(
232        " Not Available via CAT/MCP",
233        Style::default()
234            .fg(Color::DarkGray)
235            .add_modifier(Modifier::ITALIC),
236    )));
237    lines.push(Line::from(Span::styled(
238        " Recording: radio-button only",
239        Style::default().fg(Color::DarkGray),
240    )));
241    lines.push(Line::from(Span::styled(
242        " (no known MCP offset for Menu 301/302)",
243        Style::default().fg(Color::DarkGray),
244    )));
245
246    frame.render_widget(Paragraph::new(lines).block(detail_block), detail_area);
247}
248
249/// Get the display value and color for a settings row.
250fn get_row_value(app: &App, row: SettingRow) -> (String, Color) {
251    match row {
252        // --- RX (live CAT for squelch, MCP for filters) ---
253        SettingRow::SquelchA => num_span(app.state.band_a.squelch.as_u8()),
254        SettingRow::SquelchB => num_span(app.state.band_b.squelch.as_u8()),
255        SettingRow::StepSizeA => (
256            app.state
257                .band_a
258                .step_size
259                .map_or_else(|| "N/A".into(), |st| format!("{st}")),
260            Color::Yellow,
261        ),
262        SettingRow::StepSizeB => (
263            app.state
264                .band_b
265                .step_size
266                .map_or_else(|| "N/A".into(), |st| format!("{st}")),
267            Color::Yellow,
268        ),
269        SettingRow::FineStep => (
270            app.state
271                .fine_step
272                .map_or_else(|| "N/A".into(), |fs| format!("{fs}")),
273            Color::Yellow,
274        ),
275        SettingRow::FilterWidthSsb => (
276            app.state
277                .filter_width_ssb
278                .map_or_else(|| "N/A".into(), |w| format!("{w}")),
279            Color::Yellow,
280        ),
281        SettingRow::FilterWidthCw => (
282            app.state
283                .filter_width_cw
284                .map_or_else(|| "N/A".into(), |w| format!("{w}")),
285            Color::Yellow,
286        ),
287        SettingRow::FilterWidthAm => (
288            app.state
289                .filter_width_am
290                .map_or_else(|| "N/A".into(), |w| format!("{w}")),
291            Color::Yellow,
292        ),
293        SettingRow::FmNarrow => mcp_num(app, |s| s.settings().fm_narrow()),
294        SettingRow::SsbHighCut => mcp_num(app, |s| s.settings().ssb_high_cut()),
295        SettingRow::CwHighCut => mcp_num(app, |s| s.settings().cw_high_cut()),
296        SettingRow::AmHighCut => mcp_num(app, |s| s.settings().am_high_cut()),
297        SettingRow::AutoFilter => mcp_num(app, |s| s.settings().auto_filter()),
298
299        // --- Scan ---
300        SettingRow::ScanResume => mcp_num(app, |s| s.settings().scan_resume()),
301        SettingRow::DigitalScanResume => mcp_num(app, |s| s.settings().digital_scan_resume()),
302        SettingRow::ScanRestartTime => mcp_num(app, |s| s.settings().scan_restart_time()),
303        SettingRow::ScanRestartCarrier => mcp_num(app, |s| s.settings().scan_restart_carrier()),
304
305        // --- TX ---
306        SettingRow::TimeoutTimer => mcp_num(app, |s| s.settings().timeout_timer()),
307        SettingRow::TxInhibit => mcp_bool(app, |s| s.settings().tx_inhibit()),
308        SettingRow::BeatShift => mcp_bool(app, |s| s.settings().beat_shift()),
309
310        // --- VOX (gain/delay: live CAT; rest: MCP) ---
311        SettingRow::VoxEnabled => bool_span(app.state.vox),
312        SettingRow::VoxGain => num_span(app.state.vox_gain.as_u8()),
313        SettingRow::VoxDelay => (format!("{}", app.state.vox_delay), Color::Yellow),
314        SettingRow::VoxTxOnBusy => mcp_bool(app, |s| s.settings().vox_tx_on_busy()),
315
316        // --- CW ---
317        SettingRow::CwBreakIn => mcp_bool(app, |s| s.settings().cw_break_in()),
318        SettingRow::CwDelayTime => mcp_num(app, |s| s.settings().cw_delay_time()),
319        SettingRow::CwPitch => mcp_num(app, |s| s.settings().cw_pitch()),
320
321        // --- DTMF ---
322        SettingRow::DtmfSpeed => mcp_num(app, |s| s.settings().dtmf_speed()),
323        SettingRow::DtmfPauseTime => mcp_num(app, |s| s.settings().dtmf_pause_time()),
324        SettingRow::DtmfTxHold => mcp_bool(app, |s| s.settings().dtmf_tx_hold()),
325
326        // --- Repeater ---
327        SettingRow::RepeaterAutoOffset => mcp_bool(app, |s| s.settings().repeater_auto_offset()),
328        SettingRow::RepeaterCallKey => mcp_num(app, |s| s.settings().repeater_call_key()),
329
330        // --- Auxiliary ---
331        SettingRow::MicSensitivity => mcp_num(app, |s| s.settings().mic_sensitivity()),
332        SettingRow::PfKey1 => mcp_num(app, |s| s.settings().pf_key1()),
333        SettingRow::PfKey2 => mcp_num(app, |s| s.settings().pf_key2()),
334
335        // --- Lock (Lock: live CAT; rest: MCP) ---
336        SettingRow::Lock => bool_span(app.state.lock),
337        SettingRow::KeyLockType => mcp_str(app, |s| match s.settings().key_lock_type_raw() {
338            0 => "Key Only".into(),
339            1 => "Key+PTT".into(),
340            2 => "Key+PTT+Dial".into(),
341            v => format!("{v}"),
342        }),
343        SettingRow::LockKeyA => mcp_bool(app, |s| s.settings().lock_key_a()),
344        SettingRow::LockKeyB => mcp_bool(app, |s| s.settings().lock_key_b()),
345        SettingRow::LockKeyC => mcp_bool(app, |s| s.settings().lock_key_c()),
346        SettingRow::LockPtt => mcp_bool(app, |s| s.settings().lock_key_ptt()),
347        SettingRow::AprsLock => mcp_bool(app, |s| s.settings().aprs_lock()),
348
349        // --- Display (DualBand: live CAT; rest: MCP) ---
350        SettingRow::DualDisplaySize => mcp_num(app, |s| s.settings().dual_display_size()),
351        SettingRow::DisplayArea => mcp_num(app, |s| s.settings().display_area()),
352        SettingRow::InfoLine => mcp_num(app, |s| s.settings().info_line()),
353        SettingRow::BacklightControl => mcp_num(app, |s| s.settings().backlight_control()),
354        SettingRow::BacklightTimer => mcp_num(app, |s| s.settings().backlight_timer()),
355        SettingRow::DisplayHoldTime => mcp_num(app, |s| s.settings().display_hold_time()),
356        SettingRow::DisplayMethod => mcp_num(app, |s| s.settings().display_method()),
357        SettingRow::PowerOnDisplay => mcp_num(app, |s| s.settings().power_on_display()),
358        SettingRow::DualBand => bool_span(app.state.dual_band),
359
360        // --- Audio ---
361        SettingRow::EmrVolumeLevel => mcp_num(app, |s| s.settings().emr_volume_level()),
362        SettingRow::AutoMuteReturnTime => mcp_num(app, |s| s.settings().auto_mute_return_time()),
363        SettingRow::Announce => mcp_bool(app, |s| s.settings().announce()),
364        SettingRow::KeyBeep => mcp_bool(app, |s| s.settings().key_beep()),
365        SettingRow::BeepVolume => mcp_num(app, |s| s.settings().beep_volume()),
366        SettingRow::VoiceLanguage => mcp_num(app, |s| s.settings().voice_language()),
367        SettingRow::VoiceVolume => mcp_num(app, |s| s.settings().voice_volume()),
368        SettingRow::VoiceSpeed => mcp_num(app, |s| s.settings().voice_speed()),
369        SettingRow::VolumeLock => mcp_bool(app, |s| s.settings().volume_lock()),
370
371        // --- Units ---
372        SettingRow::SpeedDistanceUnit => {
373            mcp_str(app, |s| match s.settings().speed_distance_unit_raw() {
374                0 => "mph".into(),
375                1 => "km/h".into(),
376                2 => "knots".into(),
377                v => format!("{v}"),
378            })
379        }
380        SettingRow::AltitudeRainUnit => mcp_str(app, |s| {
381            if s.settings().altitude_rain_unit_raw() == 0 {
382                "ft/in".into()
383            } else {
384                "m/mm".into()
385            }
386        }),
387        SettingRow::TemperatureUnit => mcp_str(app, |s| {
388            if s.settings().temperature_unit_raw() == 0 {
389                "°F".into()
390            } else {
391                "°C".into()
392            }
393        }),
394
395        // --- Bluetooth (Bluetooth: live CAT; BtAutoConnect: MCP) ---
396        SettingRow::Bluetooth => bool_span(app.state.bluetooth),
397        SettingRow::BtAutoConnect => mcp_bool(app, |s| s.settings().bt_auto_connect()),
398
399        // --- Interface ---
400        SettingRow::GpsBtInterface => mcp_num(app, |s| s.settings().gps_bt_interface()),
401        SettingRow::PcOutputMode => mcp_num(app, |s| s.settings().pc_output_mode()),
402        SettingRow::AprsUsbMode => mcp_num(app, |s| s.settings().aprs_usb_mode()),
403        SettingRow::UsbAudioOutput => mcp_bool(app, |s| s.settings().usb_audio_output()),
404        SettingRow::InternetLink => mcp_bool(app, |s| s.settings().internet_link()),
405
406        // --- System ---
407        SettingRow::Language => mcp_str(app, |s| {
408            use kenwood_thd75::types::settings::Language;
409            match s.settings().language() {
410                Language::English => "English".into(),
411                Language::Japanese => "Japanese".into(),
412            }
413        }),
414        SettingRow::PowerOnMessageFlag => mcp_bool(app, |s| s.settings().power_on_message_flag()),
415
416        // --- Battery ---
417        SettingRow::BatterySaver => mcp_bool(app, |s| s.settings().battery_saver()),
418        SettingRow::AutoPowerOff => mcp_str(app, |s| match s.settings().auto_power_off_raw() {
419            0 => "Off".into(),
420            1 => "30 min".into(),
421            2 => "60 min".into(),
422            3 => "90 min".into(),
423            4 => "120 min".into(),
424            v => format!("{v}"),
425        }),
426
427        // --- CAT Radio Controls ---
428        SettingRow::PowerA => (format!("{}", app.state.band_a.power_level), Color::Yellow),
429        SettingRow::PowerB => (format!("{}", app.state.band_b.power_level), Color::Yellow),
430        SettingRow::AttenuatorA => bool_span(app.state.band_a.attenuator),
431        SettingRow::AttenuatorB => bool_span(app.state.band_b.attenuator),
432        SettingRow::ModeA => (format!("{}", app.state.band_a.mode), Color::Cyan),
433        SettingRow::ModeB => (format!("{}", app.state.band_b.mode), Color::Cyan),
434        SettingRow::BeaconType => (format!("{}", app.state.beacon_type), Color::Yellow),
435        SettingRow::GpsEnabled => bool_span(app.state.gps_enabled),
436        SettingRow::ScanResumeCat => (
437            app.state.scan_resume_cat.map_or_else(
438                || "? (write-only)".into(),
439                |m| match m {
440                    kenwood_thd75::types::ScanResumeMethod::TimeOperated => "Time".into(),
441                    kenwood_thd75::types::ScanResumeMethod::CarrierOperated => "Carrier".into(),
442                    kenwood_thd75::types::ScanResumeMethod::Seek => "Seek".into(),
443                },
444            ),
445            Color::Yellow,
446        ),
447        SettingRow::ActiveBand
448        | SettingRow::VfoMemModeA
449        | SettingRow::VfoMemModeB
450        | SettingRow::FmRadio
451        | SettingRow::TncBaud
452        | SettingRow::GpsPcOutput
453        | SettingRow::AutoInfo
454        | SettingRow::CallsignSlot
455        | SettingRow::DstarSlot => ("?".into(), Color::DarkGray),
456    }
457}
458
459/// Read a boolean from the MCP image; returns ("?", `DarkGray`) if not loaded.
460fn mcp_bool(app: &App, f: impl Fn(&kenwood_thd75::memory::MemoryImage) -> bool) -> (String, Color) {
461    if let McpState::Loaded { ref image, .. } = app.mcp {
462        bool_span(f(image))
463    } else {
464        ("?".into(), Color::DarkGray)
465    }
466}
467
468/// Read a u8 from the MCP image; returns ("?", `DarkGray`) if not loaded.
469fn mcp_num(app: &App, f: impl Fn(&kenwood_thd75::memory::MemoryImage) -> u8) -> (String, Color) {
470    if let McpState::Loaded { ref image, .. } = app.mcp {
471        num_span(f(image))
472    } else {
473        ("?".into(), Color::DarkGray)
474    }
475}
476
477/// Read a string from the MCP image; returns ("?", `DarkGray`) if not loaded.
478fn mcp_str(
479    app: &App,
480    f: impl Fn(&kenwood_thd75::memory::MemoryImage) -> String,
481) -> (String, Color) {
482    if let McpState::Loaded { ref image, .. } = app.mcp {
483        (f(image), Color::Yellow)
484    } else {
485        ("?".into(), Color::DarkGray)
486    }
487}
488
489fn kv<'a>(label: &'a str, value: &str) -> Line<'a> {
490    Line::from(vec![
491        Span::styled(format!("{label:<16}"), Style::default().fg(Color::DarkGray)),
492        Span::styled(value.to_string(), Style::default().fg(Color::White)),
493    ])
494}
495
496fn on_off(b: bool) -> String {
497    if b { "On".into() } else { "Off".into() }
498}