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
21pub(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
35pub(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 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; }
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 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 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 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
249fn get_row_value(app: &App, row: SettingRow) -> (String, Color) {
251 match row {
252 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 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 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 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 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 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 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 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 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 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 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 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 SettingRow::Bluetooth => bool_span(app.state.bluetooth),
397 SettingRow::BtAutoConnect => mcp_bool(app, |s| s.settings().bt_auto_connect()),
398
399 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 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 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 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
459fn 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
468fn 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
477fn 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}