diff --git a/src/app/builder/boot.rs b/src/app/builder/boot.rs index 31fd715a..9b9c397e 100644 --- a/src/app/builder/boot.rs +++ b/src/app/builder/boot.rs @@ -409,6 +409,7 @@ pub(super) fn initial_metrics(cfg: &AppConfig, boot: Boot) -> anyhow::Result) -> KeyAction { // Nothing to steer. Should not happen, since the caller only routes here // while the menu is open, but closing is a better answer than a panic. - let Some(state) = metrics(ctx.state).ui.menu else { + let (state, has_options, option_pending) = { + let m = metrics(ctx.state); + ( + m.ui.menu, + !m.device_options.is_empty(), + matches!( + m.ui.device_option_update, + DeviceOptionUpdate::Pending { .. } + ), + ) + }; + let Some(state) = state else { return KeyAction::Continue; }; match key.code { + KeyCode::Esc if option_pending => {} + KeyCode::Tab | KeyCode::BackTab if option_pending => {} KeyCode::Esc => close(ctx), KeyCode::Char('q') => return KeyAction::Quit, + KeyCode::Right if state.pane == MenuPane::Options && has_options => { + return request_option_change(ctx, state, 1); + } + KeyCode::Left if state.pane == MenuPane::Options && has_options => { + return request_option_change(ctx, state, -1); + } KeyCode::Tab | KeyCode::Right => move_row(ctx, state, 1), KeyCode::BackTab | KeyCode::Left => move_row(ctx, state, -1), KeyCode::Down => move_down(ctx, state, 1), @@ -62,6 +82,9 @@ pub(super) fn handle(key: KeyEvent, ctx: &mut InputCtx<'_>) -> KeyAction { return open(ctx, &name); } } + KeyCode::Enter if state.pane == MenuPane::Options => { + return request_option_change(ctx, state, 1); + } _ => {} } KeyAction::Continue @@ -158,10 +181,112 @@ fn move_down(ctx: &mut InputCtx<'_>, state: MenuState, step: isize) { ..state }); } - // Nothing to move through yet. When the first setting lands this grows a - // cursor of its own; until then the arrows are quiet rather than moving - // something the reader cannot see. - MenuPane::Options => {} + MenuPane::Options => { + let count = metrics(ctx.state).device_options.len(); + if count == 0 { + return; + } + metrics(ctx.state).ui.menu = Some(MenuState { + scroll: wrap(state.scroll.min(count - 1), step, count), + ..state + }); + } + } +} + +fn request_option_change(ctx: &mut InputCtx<'_>, menu: MenuState, step: isize) -> KeyAction { + let mut m = metrics(ctx.state); + if matches!( + m.ui.device_option_update, + DeviceOptionUpdate::Pending { .. } + ) { + return KeyAction::Continue; + } + let Some(option) = m.device_options.get(menu.scroll) else { + return KeyAction::Continue; + }; + if option.choices.is_empty() { + return KeyAction::Continue; + } + let requested = { + let current = option + .choices + .iter() + .position(|choice| choice == &option.selected_choice); + let selected = match current { + Some(index) => wrap(index, step, option.choices.len()), + None if step >= 0 => 0, + None => option.choices.len() - 1, + }; + ( + option.id.clone(), + option.label.clone(), + option.choices[selected].clone(), + ) + }; + + let request = DeviceOptionRequest { + request_id: m.ui.next_device_option_request, + id: requested.0, + label: requested.1, + choice: requested.2, + }; + m.ui.next_device_option_request = m.ui.next_device_option_request.wrapping_add(1); + m.ui.device_option_update = DeviceOptionUpdate::Pending { + request_id: request.request_id, + id: request.id.clone(), + label: request.label.clone(), + choice: request.choice.clone(), + }; + KeyAction::ApplyDeviceOption(request) +} + +pub(super) fn complete_device_option( + state: &std::sync::Arc>, + completion: DeviceOptionCompletion, +) { + let mut m = metrics(state); + let DeviceOptionUpdate::Pending { request_id, .. } = &m.ui.device_option_update else { + return; + }; + if *request_id != completion.request.request_id { + return; + } + + match completion.result { + Ok(options) => { + let (options, notes) = crate::hardware::sanitize_device_options(options); + m.device_options = options; + let last_option = m.device_options.len().saturating_sub(1); + if let Some(menu) = + m.ui.menu + .as_mut() + .filter(|menu| menu.pane == MenuPane::Options) + { + menu.scroll = menu.scroll.min(last_option); + } + m.ui.device_option_update = DeviceOptionUpdate::Completed { + request_id: completion.request.request_id, + id: completion.request.id.clone(), + choice: completion.request.choice.clone(), + }; + m.push_log(format!( + "{} set to {}", + completion.request.label, completion.request.choice + )); + for note in notes { + m.push_log(note); + } + } + Err(message) => { + m.ui.device_option_update = DeviceOptionUpdate::Error { + request_id: completion.request.request_id, + id: completion.request.id, + choice: completion.request.choice, + message: message.clone(), + }; + m.push_log(format!("{} error: {message}", completion.request.label)); + } } } @@ -180,6 +305,7 @@ fn wrap(i: usize, step: isize, len: usize) -> usize { mod tests { use super::*; use crate::config::LayoutConfig; + use crate::hardware::DeviceOption; use crate::state::SdrMetrics; use crate::ui::{self, PanelRegistry}; use crossterm::event::KeyEvent; @@ -225,6 +351,28 @@ mod tests { fn menu(&self) -> MenuState { self.state.lock().unwrap().ui.menu.expect("menu is open") } + + fn show_options(&mut self, options: Vec) { + let mut m = self.state.lock().unwrap(); + m.device_options = options; + m.ui.menu = Some(MenuState { + pane: MenuPane::Options, + ..MenuState::default() + }); + } + } + + fn option(id: &str, label: &str, selected: &str) -> DeviceOption { + described_option(id, label, &["Narrow", "Wide"], selected) + } + + fn described_option(id: &str, label: &str, choices: &[&str], selected: &str) -> DeviceOption { + DeviceOption { + id: id.into(), + label: label.into(), + choices: choices.iter().map(|choice| (*choice).into()).collect(), + selected_choice: selected.into(), + } } /// Tab walks the column exactly as it is drawn: every section, then Keys, @@ -278,6 +426,192 @@ mod tests { ); } + #[test] + fn options_move_up_and_down_through_device_settings() { + let mut h = Harness::new(); + h.show_options(vec![ + option("bandwidth", "Bandwidth", "Narrow"), + option("mode", "Mode", "Wide"), + ]); + + h.key(KeyCode::Down); + assert_eq!(h.menu().scroll, 1); + h.key(KeyCode::Down); + assert_eq!(h.menu().scroll, 0); + h.key(KeyCode::Up); + assert_eq!(h.menu().scroll, 1); + } + + #[test] + fn horizontal_value_keys_do_not_leave_options_when_values_exist() { + let mut h = Harness::new(); + h.show_options(vec![option("bandwidth", "Bandwidth", "Narrow")]); + let before = h.menu(); + + let action = h.key(KeyCode::Right); + + assert_eq!(h.menu(), before); + assert!(matches!(action, KeyAction::ApplyDeviceOption(_))); + } + + #[test] + fn horizontal_keys_keep_the_old_navigation_for_empty_devices() { + let mut h = Harness::new(); + h.state.lock().unwrap().ui.menu = Some(MenuState { + pane: MenuPane::Options, + ..MenuState::default() + }); + + h.key(KeyCode::Left); + assert_eq!(h.menu().pane, MenuPane::Keys); + + h.state.lock().unwrap().ui.menu = Some(MenuState { + pane: MenuPane::Options, + ..MenuState::default() + }); + h.key(KeyCode::Right); + assert_eq!(h.menu().pane, MenuPane::Views); + assert_eq!(h.menu().section, 0); + } + + #[test] + fn a_successful_completion_refreshes_all_options() { + let mut h = Harness::new(); + h.show_options(vec![option("bandwidth", "Bandwidth", "Narrow")]); + let KeyAction::ApplyDeviceOption(request) = h.key(KeyCode::Right) else { + panic!("value change did not create a request"); + }; + + complete_device_option( + &h.state, + DeviceOptionCompletion { + request, + result: Ok(vec![ + option("bandwidth", "Bandwidth", "Wide"), + described_option("attenuation", "Attenuation", &["0 dB", "10 dB"], "0 dB"), + ]), + }, + ); + + let m = h.state.lock().unwrap(); + assert_eq!(m.device_options[0].selected_choice, "Wide"); + assert_eq!(m.device_options[1].selected_choice, "0 dB"); + assert!(matches!( + m.ui.device_option_update, + DeviceOptionUpdate::Completed { .. } + )); + assert!(m + .ui + .log + .back() + .is_some_and(|entry| entry.text.contains("Bandwidth set to Wide"))); + } + + #[test] + fn a_failed_completion_keeps_state_and_surfaces_the_error() { + let mut h = Harness::new(); + h.show_options(vec![option("bandwidth", "Bandwidth", "Narrow")]); + let before = h.state.lock().unwrap().device_options.clone(); + let KeyAction::ApplyDeviceOption(request) = h.key(KeyCode::Right) else { + panic!("value change did not create a request"); + }; + + complete_device_option( + &h.state, + DeviceOptionCompletion { + request, + result: Err("device rejected choice".to_string()), + }, + ); + + let m = h.state.lock().unwrap(); + assert_eq!(m.device_options, before); + assert!(matches!( + m.ui.device_option_update, + DeviceOptionUpdate::Error { .. } + )); + assert!(m.ui.log.back().is_some_and(|entry| entry + .text + .contains("Bandwidth error: device rejected choice"))); + } + + #[test] + fn refreshed_options_use_the_startup_sanitization_rules() { + let mut h = Harness::new(); + h.show_options(vec![option("bandwidth", "Bandwidth", "Narrow")]); + let KeyAction::ApplyDeviceOption(request) = h.key(KeyCode::Right) else { + panic!("value change did not create a request"); + }; + + complete_device_option( + &h.state, + DeviceOptionCompletion { + request, + result: Ok(vec![ + described_option("empty", "Empty", &[], ""), + described_option("bandwidth", "Bandwidth", &["Narrow", "Wide"], "Missing"), + ]), + }, + ); + + let m = h.state.lock().unwrap(); + assert_eq!(m.device_options.len(), 1); + assert_eq!(m.device_options[0].selected_choice, "Narrow"); + let log: Vec<&str> = m.ui.log.iter().map(|entry| entry.text.as_ref()).collect(); + assert!(log.iter().any(|entry| entry.contains("no choices"))); + assert!(log.iter().any(|entry| entry.contains("unavailable choice"))); + } + + #[test] + fn a_pending_change_keeps_navigation_and_quit_responsive() { + let mut h = Harness::new(); + h.show_options(vec![ + option("bandwidth", "Bandwidth", "Narrow"), + option("mode", "Mode", "Wide"), + ]); + + assert!(matches!( + h.key(KeyCode::Right), + KeyAction::ApplyDeviceOption(_) + )); + assert_eq!(h.key(KeyCode::Left), KeyAction::Continue); + h.key(KeyCode::Down); + assert_eq!(h.menu().scroll, 1); + h.key(KeyCode::Esc); + assert!(h.state.lock().unwrap().ui.menu.is_some()); + h.key(KeyCode::Tab); + assert_eq!(h.menu().pane, MenuPane::Options); + h.key(KeyCode::BackTab); + assert_eq!(h.menu().pane, MenuPane::Options); + assert_eq!(h.key(KeyCode::Char('q')), KeyAction::Quit); + } + + #[test] + fn a_late_completion_cannot_replace_a_newer_pending_request() { + let mut h = Harness::new(); + h.show_options(vec![option("bandwidth", "Bandwidth", "Narrow")]); + let KeyAction::ApplyDeviceOption(request) = h.key(KeyCode::Right) else { + panic!("value change did not create a request"); + }; + let mut late = request.clone(); + late.request_id = request.request_id.wrapping_add(1); + + complete_device_option( + &h.state, + DeviceOptionCompletion { + request: late, + result: Ok(vec![option("bandwidth", "Bandwidth", "Wide")]), + }, + ); + + let m = h.state.lock().unwrap(); + assert_eq!(m.device_options[0].selected_choice, "Narrow"); + assert!(matches!( + m.ui.device_option_update, + DeviceOptionUpdate::Pending { .. } + )); + } + /// A pane is a detour, not a reset: the place you had in the list survives /// the visit, so stepping back onto the sections does not start you at the /// top of one. diff --git a/src/app/input/mod.rs b/src/app/input/mod.rs index 57f6226a..28e4facc 100644 --- a/src/app/input/mod.rs +++ b/src/app/input/mod.rs @@ -35,16 +35,18 @@ use std::sync::{Arc, Mutex, MutexGuard}; use crossterm::event::{KeyCode, KeyEvent}; +use crate::event::{DeviceOptionCompletion, DeviceOptionRequest}; use crate::hardware; use crate::state::{InputMode, SdrMetrics}; use crate::ui; /// What the main loop should do next. `PartialEq`/`Debug` so a test can say /// which one it expected. -#[derive(Clone, Copy, PartialEq, Eq, Debug)] +#[derive(Clone, PartialEq, Eq, Debug)] pub enum KeyAction { Continue, Quit, + ApplyDeviceOption(DeviceOptionRequest), } /// Everything a key handler is allowed to touch. @@ -106,6 +108,7 @@ pub fn handle_key( handle_normal(key, &mut ctx) } } + InputMode::FrequencyInput => { text::frequency(key, state, device); KeyAction::Continue @@ -129,6 +132,13 @@ pub fn handle_key( } } +pub(super) fn complete_device_option( + state: &Arc>, + completion: DeviceOptionCompletion, +) { + menu::complete_device_option(state, completion); +} + /// Fold an uppercase letter key onto its lowercase twin, leaving every other key /// alone. See the note in [`handle_normal`] for why this exists and why it lives /// there rather than at [`handle_key`]. diff --git a/src/app/mod.rs b/src/app/mod.rs index 201b5ea3..e4b9ebce 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -13,7 +13,7 @@ use std::time::{Duration, Instant}; use ratatui::{backend::Backend, Terminal}; use crate::config::{AppConfig, DisplayConfig, RadioConfig}; -use crate::event::{AppEvent, EventStream}; +use crate::event::{AppEvent, DeviceOptionCompletion, DeviceOptionRequest, EventStream}; use crate::hardware::{self, RxContext, SdrDevice}; use crate::state::SdrMetrics; use crate::ui; @@ -76,6 +76,7 @@ impl App { pub fn run(&mut self, terminal: &mut Terminal) -> io::Result<()> { const FRAME_DURATION: Duration = Duration::from_millis(33); let mut last_draw = Instant::now(); + let mut quit_requested = false; // Repaint from a clean slate: the device selector and any backend chatter // during open may have left the alternate screen dirty before we get here. @@ -85,6 +86,9 @@ impl App { loop { let needs_redraw = match self.events.recv() { AppEvent::Key(key) => { + if quit_requested { + continue; + } match input::handle_key( key, &self.state, @@ -94,16 +98,32 @@ impl App { &self.focus_keys, ) { input::KeyAction::Quit => { - self.restore_noise_sweep(); - self.restore_sweep_tuning(); - self.save_config(); - return Ok(()); + if self.device_option_pending() { + quit_requested = true; + let mut m = + self.state.lock().unwrap_or_else(|error| error.into_inner()); + m.ui.quit_after_device_option = true; + } else { + self.finish_session(); + return Ok(()); + } } input::KeyAction::Continue => {} + input::KeyAction::ApplyDeviceOption(request) => { + self.start_device_option(request); + } } last_draw.elapsed() >= FRAME_DURATION } AppEvent::Tick => true, + AppEvent::DeviceOptionComplete(completion) => { + input::complete_device_option(&self.state, completion); + if quit_requested && !self.device_option_pending() { + self.finish_session(); + return Ok(()); + } + true + } }; if needs_redraw { @@ -113,6 +133,42 @@ impl App { } } + fn start_device_option(&self, request: DeviceOptionRequest) { + let Some(device) = self.device.as_ref().cloned() else { + input::complete_device_option( + &self.state, + DeviceOptionCompletion { + request, + result: Err("device is unavailable".to_string()), + }, + ); + return; + }; + let tx = self.events.sender(); + let worker_request = request.clone(); + Self::spawn_device_option_task(request, tx, move || { + Self::execute_device_option( + &worker_request, + |id, choice| device.set_option(id, choice), + || device.options(), + ) + }); + } + + fn device_option_pending(&self) -> bool { + let m = self.state.lock().unwrap_or_else(|error| error.into_inner()); + matches!( + m.ui.device_option_update, + crate::state::DeviceOptionUpdate::Pending { .. } + ) + } + + fn finish_session(&self) { + self.restore_noise_sweep(); + self.restore_sweep_tuning(); + self.save_config(); + } + fn draw(&mut self, terminal: &mut Terminal) -> io::Result<()> { let active_preset = self.engine.active_preset().to_string(); let sweep_active = self.engine.is_panel_visible("sweep_panel") @@ -190,12 +246,28 @@ impl App { f.render_widget(ratatui::widgets::Clear, area); ui::menu::render(f, area, &m, self.engine.menu(), &menu_state, &frame_theme); } + if m.ui.quit_after_device_option { + let full = f.size(); + let area = ratatui::layout::Rect::new( + full.x, + full.y + full.height.saturating_sub(1), + full.width, + 1, + ); + f.render_widget(ratatui::widgets::Clear, area); + f.render_widget( + ratatui::widgets::Paragraph::new(" Waiting for device before quit") + .style(ratatui::style::Style::default().fg(frame_theme.status_warn)), + area, + ); + } })?; // The deck is behind the menu from the moment it has been drawn once // without one in front of it. if m.ui.menu.is_none() { self.deck_shown = true; } + Ok(()) } @@ -231,6 +303,29 @@ impl App { } } + fn spawn_device_option_task( + request: DeviceOptionRequest, + tx: std::sync::mpsc::Sender, + apply: impl FnOnce() -> anyhow::Result> + Send + 'static, + ) { + std::thread::spawn(move || { + let completion = DeviceOptionCompletion { + request, + result: apply().map_err(|error| error.to_string()), + }; + let _ = tx.send(AppEvent::DeviceOptionComplete(completion)); + }); + } + + fn execute_device_option( + request: &DeviceOptionRequest, + set: impl FnOnce(&str, &str) -> anyhow::Result<()>, + refresh: impl FnOnce() -> Vec, + ) -> anyhow::Result> { + set(&request.id, &request.choice)?; + Ok(refresh()) + } + /// Put the tuner back before the app goes away. /// /// The same problem as `restore_noise_sweep`, one field along. A frequency @@ -335,6 +430,11 @@ impl App { #[cfg(test)] mod tests { + use super::*; + use crate::state::DeviceOptionUpdate; + use std::cell::Cell; + use std::sync::{mpsc, Arc, Mutex}; + /// Quitting must give the tuner back before the config is written. /// /// `save_config` persists `radio.frequency`, and while a sweep is running @@ -345,20 +445,20 @@ mod tests { /// back. Read as source text for the same reason the dispatch table is. #[test] fn quitting_gives_the_tuner_back_before_saving_the_config() { - let arm = include_str!("mod.rs") - .split_once("KeyAction::Quit => {") - .expect("the quit arm has been renamed") + let body = include_str!("mod.rs") + .split_once("fn finish_session(&self) {") + .expect("finish_session has been renamed") .1 - .split_once("return Ok(());") - .expect("the quit arm no longer returns") + .split_once("\n fn ") + .expect("finish_session no longer ends") .0; - let restore = arm.find("self.restore_sweep_tuning()").expect( + let restore = body.find("self.restore_sweep_tuning()").expect( "quitting no longer ends the sweep, so save_config writes out \ whichever position the scan was parked on as the tuned frequency", ); - let save = arm + let save = body .find("self.save_config()") - .expect("the quit arm no longer saves the config"); + .expect("finish_session no longer saves the config"); assert!( restore < save, "restore_sweep_tuning must run before save_config, or the config \ @@ -366,6 +466,134 @@ mod tests { ); } + #[test] + fn quitting_waits_for_a_pending_device_option() { + let run = include_str!("mod.rs") + .split_once("pub fn run") + .expect("App::run has been renamed") + .1 + .split_once("\n fn start_device_option") + .expect("App::run no longer ends before option startup") + .0; + let quit = run + .split_once("KeyAction::Quit => {") + .expect("the quit arm has been renamed") + .1 + .split_once("KeyAction::Continue") + .expect("the quit arm no longer ends") + .0; + assert!(quit.contains("self.device_option_pending()")); + assert!(quit.contains("quit_requested = true")); + assert!(quit.contains("self.finish_session()")); + let completed = run + .split_once("AppEvent::DeviceOptionComplete(completion) => {") + .expect("the option completion arm is missing") + .1; + assert!(completed.contains("quit_requested && !self.device_option_pending()")); + assert!(completed.contains("self.finish_session()")); + } + + #[test] + fn slow_device_option_work_runs_off_the_event_thread() { + let state = Arc::new(Mutex::new(SdrMetrics::fixture())); + let request = DeviceOptionRequest { + request_id: 7, + id: "bandwidth".into(), + label: "Bandwidth".into(), + choice: "Wide".into(), + }; + state.lock().unwrap().ui.device_option_update = DeviceOptionUpdate::Pending { + request_id: request.request_id, + id: request.id.clone(), + label: request.label.clone(), + choice: request.choice.clone(), + }; + let (event_tx, event_rx) = mpsc::channel(); + let (started_tx, started_rx) = mpsc::channel(); + let (release_tx, release_rx) = mpsc::channel(); + let worker_state = Arc::clone(&state); + let worker_request = request.clone(); + + App::spawn_device_option_task(request, event_tx, move || { + App::execute_device_option( + &worker_request, + |_, _| { + assert!( + worker_state.try_lock().is_ok(), + "state was locked during set_option" + ); + started_tx.send(()).unwrap(); + release_rx.recv().unwrap(); + Ok(()) + }, + || { + assert!( + worker_state.try_lock().is_ok(), + "state was locked during options" + ); + vec![ + hardware::DeviceOption { + id: "bandwidth".into(), + label: "Bandwidth".into(), + choices: vec!["Narrow".into(), "Wide".into()], + selected_choice: "Wide".into(), + }, + hardware::DeviceOption { + id: "attenuation".into(), + label: "Attenuation".into(), + choices: vec!["0 dB".into(), "10 dB".into()], + selected_choice: "0 dB".into(), + }, + ] + }, + ) + }); + + started_rx + .recv_timeout(Duration::from_secs(1)) + .expect("option worker did not start"); + assert!( + state.try_lock().is_ok(), + "event thread cannot read state while the backend waits" + ); + release_tx.send(()).unwrap(); + let AppEvent::DeviceOptionComplete(completion) = event_rx + .recv_timeout(Duration::from_secs(1)) + .expect("option worker did not complete") + else { + panic!("worker sent the wrong event"); + }; + input::complete_device_option(&state, completion); + + let m = state.lock().unwrap(); + assert_eq!(m.device_options.len(), 2); + assert_eq!(m.device_options[0].selected_choice, "Wide"); + assert_eq!(m.device_options[1].selected_choice, "0 dB"); + } + + #[test] + fn failed_device_option_set_does_not_refresh() { + let request = DeviceOptionRequest { + request_id: 1, + id: "bandwidth".into(), + label: "Bandwidth".into(), + choice: "Wide".into(), + }; + let refreshed = Cell::new(false); + + let result = App::execute_device_option( + &request, + |_, _| anyhow::bail!("device rejected choice"), + || { + refreshed.set(true); + Vec::new() + }, + ); + + assert!(result.is_err()); + assert!(!refreshed.get()); + } + /// **Every scanner that parks the radio gives the tuner back on quit.** /// /// Two now do: the frequency sweep and the NET survey. Both write their diff --git a/src/event.rs b/src/event.rs index 536fbecf..42650e29 100644 --- a/src/event.rs +++ b/src/event.rs @@ -2,45 +2,68 @@ // Copyright (C) 2026 MusiThang use crossterm::event::{self, Event, KeyEvent}; -use std::sync::mpsc::{self, Receiver}; +use std::sync::mpsc::{self, Receiver, Sender}; use std::thread; use std::time::Duration; +use crate::hardware::DeviceOption; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct DeviceOptionRequest { + pub request_id: u64, + pub id: String, + pub label: String, + pub choice: String, +} + +#[derive(Debug)] +pub struct DeviceOptionCompletion { + pub request: DeviceOptionRequest, + pub result: Result, String>, +} + pub enum AppEvent { Key(KeyEvent), Tick, + DeviceOptionComplete(DeviceOptionCompletion), } pub struct EventStream { + tx: Sender, rx: Receiver, } impl EventStream { pub fn new(tick_rate: Duration) -> Self { let (tx, rx) = mpsc::channel(); + let event_tx = tx.clone(); thread::spawn(move || loop { if event::poll(tick_rate).unwrap_or(false) { match event::read() { Ok(Event::Key(key)) => { - if tx.send(AppEvent::Key(key)).is_err() { + if event_tx.send(AppEvent::Key(key)).is_err() { break; } } Ok(Event::Resize(..)) // Trigger an immediate redraw so preferred_height re-runs with the new width. - if tx.send(AppEvent::Tick).is_err() => { + if event_tx.send(AppEvent::Tick).is_err() => { break; } _ => {} } - } else if tx.send(AppEvent::Tick).is_err() { + } else if event_tx.send(AppEvent::Tick).is_err() { break; } }); - Self { rx } + Self { tx, rx } } pub fn recv(&self) -> AppEvent { self.rx.recv().unwrap_or(AppEvent::Tick) } + + pub fn sender(&self) -> Sender { + self.tx.clone() + } } diff --git a/src/hardware/mod.rs b/src/hardware/mod.rs index ea76b761..192b26e9 100644 --- a/src/hardware/mod.rs +++ b/src/hardware/mod.rs @@ -30,7 +30,71 @@ pub use discovery::{list_all_devices, open_device, DeviceKind, DeviceListing}; #[cfg(test)] pub(crate) use traits::RateSet; pub use traits::{ - AcquisitionKind, Boost, DeliveryModel, DeviceCapabilities, DeviceInfo, DirectSweepConfig, - FeedHealth, GainModel, LevelUnit, PowerTrace, PowerTraceTarget, RxContext, SampleFormat, - SampleGeometry, SdrDevice, SoftwareStack, StageSpec, StreamBlock, IQ_TRACE_STALE_MS, + AcquisitionKind, Boost, DeliveryModel, DeviceCapabilities, DeviceInfo, DeviceOption, + DirectSweepConfig, FeedHealth, GainModel, LevelUnit, PowerTrace, PowerTraceTarget, RxContext, + SampleFormat, SampleGeometry, SdrDevice, SoftwareStack, StageSpec, StreamBlock, + IQ_TRACE_STALE_MS, }; + +pub(crate) fn sanitize_device_options( + options: Vec, +) -> (Vec, Vec) { + let mut valid = Vec::with_capacity(options.len()); + let mut notes = Vec::new(); + for mut option in options { + let name = if option.label.is_empty() { + option.id.as_str() + } else { + option.label.as_str() + }; + let Some(first) = option.choices.first().cloned() else { + notes.push(format!( + "Warning: device option '{name}' has no choices. Hiding it." + )); + continue; + }; + if !option.choices.contains(&option.selected_choice) { + notes.push(format!( + "Warning: device option '{name}' selected unavailable choice '{}'. Using '{first}'.", + option.selected_choice + )); + option.selected_choice = first; + } + valid.push(option); + } + (valid, notes) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn option(choices: &[&str], selected: &str) -> DeviceOption { + DeviceOption { + id: "bandwidth".into(), + label: "Bandwidth".into(), + choices: choices.iter().map(|choice| (*choice).into()).collect(), + selected_choice: selected.into(), + } + } + + #[test] + fn options_without_choices_are_hidden_and_reported() { + let (options, notes) = sanitize_device_options(vec![option(&[], "")]); + + assert!(options.is_empty()); + assert_eq!(notes.len(), 1); + assert!(notes[0].contains("Bandwidth")); + assert!(notes[0].contains("no choices")); + } + + #[test] + fn an_unavailable_selected_choice_uses_the_first_choice() { + let (options, notes) = + sanitize_device_options(vec![option(&["Narrow", "Wide"], "Missing")]); + + assert_eq!(options[0].selected_choice, "Narrow"); + assert_eq!(notes.len(), 1); + assert!(notes[0].contains("unavailable choice")); + } +} diff --git a/src/hardware/traits.rs b/src/hardware/traits.rs index 5d31c534..493f4293 100644 --- a/src/hardware/traits.rs +++ b/src/hardware/traits.rs @@ -65,6 +65,15 @@ pub struct DirectSweepConfig { pub generation: u64, } +/// One configurable choice exposed by a device. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct DeviceOption { + pub id: String, + pub label: String, + pub choices: Vec, + pub selected_choice: String, +} + /// How raw USB bytes encode each I/Q component. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum SampleFormat { @@ -964,6 +973,14 @@ pub trait SdrDevice: Send + Sync { anyhow::bail!("this backend does not support direct power sweeps") } + fn options(&self) -> Vec { + Vec::new() + } + + fn set_option(&self, _id: &str, _choice: &str) -> anyhow::Result<()> { + anyhow::bail!("this backend has no device options") + } + /// Set one stage by position, exactly. /// /// **One path for every backend.** The default maps position onto the two diff --git a/src/state/fixture.rs b/src/state/fixture.rs index 759a500c..bac42cae 100644 --- a/src/state/fixture.rs +++ b/src/state/fixture.rs @@ -108,6 +108,7 @@ impl SdrMetrics { demod: DemodState::default(), net: crate::state::NetState::default(), caps, + device_options: Vec::new(), acc: Accumulators::default(), } } diff --git a/src/state/mod.rs b/src/state/mod.rs index b080e609..5fb0149c 100644 --- a/src/state/mod.rs +++ b/src/state/mod.rs @@ -41,8 +41,8 @@ pub use sweep::{SweepConfig, SweepFrame, SweepState, SWEEP_SETTLING_MS}; pub use system::SystemState; pub use timing::{TimingCause, TimingQuality, TimingState, HACKRF_SAMPLES_PER_TRANSFER}; pub use ui::{ - active_recall_slot, recall_from_hz, recall_to_hz, InputMode, LogEntry, LogLevel, MenuPane, - MenuState, RailMode, UiState, RECALL_SLOTS, + active_recall_slot, recall_from_hz, recall_to_hz, DeviceOptionUpdate, InputMode, LogEntry, + LogLevel, MenuPane, MenuState, RailMode, UiState, RECALL_SLOTS, }; pub use waterfall::{BinAxis, BinWindow, FftFrame, WaterfallState, WATERFALL_MIN_ROWS}; @@ -81,6 +81,7 @@ pub struct SdrMetrics { /// rendering (gain model, BB filter / Friis applicability, ranges). Shared /// (Arc) so the per-frame `SdrMetrics` clone stays cheap. pub caps: std::sync::Arc, + pub device_options: Vec, pub(crate) acc: Accumulators, } diff --git a/src/state/ui.rs b/src/state/ui.rs index 0e7c2a93..be51ef93 100644 --- a/src/state/ui.rs +++ b/src/state/ui.rs @@ -129,6 +129,29 @@ pub struct LogEntry { pub text: Arc, } +#[derive(Clone, Debug, PartialEq, Eq, Default)] +pub enum DeviceOptionUpdate { + #[default] + Ready, + Pending { + request_id: u64, + id: String, + label: String, + choice: String, + }, + Completed { + request_id: u64, + id: String, + choice: String, + }, + Error { + request_id: u64, + id: String, + choice: String, + message: String, + }, +} + #[derive(Clone, PartialEq)] pub enum InputMode { Normal, @@ -195,6 +218,9 @@ pub struct UiState { /// Where the cursor is while the menu is open, `None` when it is closed. /// See [`MenuState`]. pub menu: Option, + pub device_option_update: DeviceOptionUpdate, + pub next_device_option_request: u64, + pub quit_after_device_option: bool, } /// Which pane the menu's right column is showing. @@ -206,9 +232,7 @@ pub enum MenuPane { /// The key reference. Replaces the `?` overlay, which had drifted out of /// step with the dispatch because nothing checked it. Keys, - /// Settings. Empty so far, and it says so on screen. The variant exists - /// ahead of its first row so that adding one is a row rather than a - /// reshuffle of the enum, the column and the dispatch together. + /// Settings exposed by the active device. Options, } @@ -228,11 +252,7 @@ pub struct MenuState { pub section: usize, pub entry: usize, pub pane: MenuPane, - /// First visible row of the [`MenuPane::Keys`] list. - /// - /// Its own field rather than reusing `entry`: the reference is taller than a - /// 24 row terminal, so it has to scroll, and one field meaning two things - /// depending on the pane is how a cursor ends up somewhere nobody expected. + /// Scroll position in Keys or selected row in Options. pub scroll: usize, } @@ -334,6 +354,9 @@ impl Default for UiState { recall_cursor: 0, log_overlay: false, menu: None, + device_option_update: DeviceOptionUpdate::default(), + next_device_option_request: 0, + quit_after_device_option: false, } } } diff --git a/src/ui/menu/mod.rs b/src/ui/menu/mod.rs index 2c775ca2..a2cb9694 100644 --- a/src/ui/menu/mod.rs +++ b/src/ui/menu/mod.rs @@ -15,7 +15,7 @@ //! - [`sections`]: the left column. //! - [`entries`]: the right column, a section's layouts. //! - [`keys`]: the right column, the key reference. -//! - [`options`]: the right column, settings. Empty for now, and honest about it. +//! - [`options`]: the right column, device settings. //! //! [`render`] is the orchestrator. It resolves the frame, carves the rows and //! columns, and calls each part once. **The parts do not call each other.** @@ -84,7 +84,7 @@ pub fn render( .split(inner); header(f, rows[0], m, theme); - footer(f, rows[2], theme); + footer(f, rows[2], state.pane, !m.device_options.is_empty(), theme); // The cursor is cloned into the frame snapshot and arrives here without the // engine, so it is clamped rather than trusted. An out of range index would @@ -159,7 +159,7 @@ fn right_pane( match state.pane { MenuPane::Views => entries::render(f, body, &menu.sections[section], cursor, theme), MenuPane::Keys => keys::render(f, body, &m.caps, state.scroll, theme), - MenuPane::Options => options::render(f, body, theme), + MenuPane::Options => options::render(f, body, m, state.scroll, theme), } } @@ -183,19 +183,30 @@ fn header(f: &mut Frame, area: Rect, m: &SdrMetrics, theme: &crate::Theme) { } /// The keys, in the order the design's "Moving around" table lists them. -fn footer(f: &mut Frame, area: Rect, theme: &crate::Theme) { +fn footer(f: &mut Frame, area: Rect, pane: MenuPane, has_options: bool, theme: &crate::Theme) { let key = Style::default().fg(theme.border_accent); let what = Style::default().fg(theme.label); let mut spans = Vec::new(); - for (k, w) in [ - ("Tab", "section"), - ("\u{2191}\u{2193}", "move"), - ("1-9", "open"), - ("Enter", "open"), - ("Esc", "close"), - ] { + let bindings: &[(&str, &str)] = if pane == MenuPane::Options && has_options { + &[ + ("Tab", "section"), + ("\u{2191}\u{2193}", "option"), + ("\u{2190}\u{2192}", "value"), + ("Enter", "next"), + ("Esc", "close"), + ] + } else { + &[ + ("Tab", "section"), + ("\u{2191}\u{2193}", "move"), + ("1-9", "open"), + ("Enter", "open"), + ("Esc", "close"), + ] + }; + for (k, w) in bindings { spans.push(Span::styled(format!(" {k} "), key)); - spans.push(Span::styled(w, what)); + spans.push(Span::styled(*w, what)); } f.render_widget(Paragraph::new(Line::from(spans)), area); } @@ -212,12 +223,15 @@ mod tests { /// the menu is not a panel, so it cannot go through /// `PanelRegistry::render_panel` and needs its own harness. fn draw(w: u16, h: u16, state: &MenuState) -> Vec { + draw_with_metrics(w, h, state, &SdrMetrics::fixture()) + } + + fn draw_with_metrics(w: u16, h: u16, state: &MenuState, metrics: &SdrMetrics) -> Vec { let menu = model::build(&LayoutConfig::default_config().presets); - let metrics = SdrMetrics::fixture(); let theme = crate::Theme::sdr(); let mut terminal = Terminal::new(TestBackend::new(w, h)).unwrap(); terminal - .draw(|f| render(f, f.size(), &metrics, &menu, state, &theme)) + .draw(|f| render(f, f.size(), metrics, &menu, state, &theme)) .unwrap(); let buf = terminal.backend().buffer().clone(); (0..h) @@ -390,6 +404,27 @@ mod tests { assert!(all.contains("Command Rail"), "{all}"); } + #[test] + fn a_short_folded_options_pane_keeps_the_selected_option_visible() { + let state = MenuState { + pane: MenuPane::Options, + scroll: 5, + ..MenuState::default() + }; + let mut metrics = SdrMetrics::fixture(); + for index in 0..6 { + metrics.device_options.push(crate::hardware::DeviceOption { + id: format!("option-{index}"), + label: format!("Option {index}"), + choices: vec!["Off".into(), "On".into()], + selected_choice: "On".into(), + }); + } + + let all = draw_with_metrics(40, 10, &state, &metrics).join("\n"); + assert!(all.contains("Option 5"), "{all}"); + } + /// Small enough that nothing sensible fits. The requirement is only that it /// does not panic and does not draw outside its area. #[test] diff --git a/src/ui/menu/options.rs b/src/ui/menu/options.rs index c85fea0d..d2ced50a 100644 --- a/src/ui/menu/options.rs +++ b/src/ui/menu/options.rs @@ -1,21 +1,7 @@ // SPDX-License-Identifier: GPL-3.0-or-later // Copyright (C) 2026 MusiThang -//! The menu's right column: settings. -//! -//! **Empty on purpose.** Nothing sdrtop can be told is told here yet, and the -//! pane says so rather than showing an enabled-looking list of things that do -//! not work. It exists now because the alternative was to cut this seam later, -//! at the same time as writing the first setting, which is how a one row change -//! turns into a refactor of the pane, the enum, the column and the dispatch all -//! at once. -//! -//! Two lines and a TODO is the whole screen. Anyone who opens it already knows -//! what an empty Options pane means, so explaining it at length would be talking -//! past the reader. -//! -//! Draws only, like [`super::entries`] and [`super::keys`]. When the first real -//! row lands it goes in here and nowhere else. +//! The menu's right column: settings exposed by the active device. use ratatui::{ layout::Rect, @@ -25,7 +11,10 @@ use ratatui::{ Frame, }; -use crate::ui::chrome; +use crate::{ + state::{DeviceOptionUpdate, SdrMetrics}, + ui::chrome, +}; /// The heading. Says what the pane is for, in the future tense, because that is /// the only true tense for it right now. @@ -44,7 +33,7 @@ const TODO: &[&str] = &[ /// Separate from drawing for the same reason `keys::lines` is: the wrapping is /// the only thing here that can be wrong, and it can be checked without a /// terminal. -fn lines(iw: usize, theme: &crate::Theme) -> Vec> { +fn empty_lines(iw: usize, theme: &crate::Theme) -> Vec> { let mut out = vec![Line::from("")]; for row in chrome::wrap(HEADING, iw.saturating_sub(4), 3) { out.push(Line::from(Span::styled( @@ -68,11 +57,139 @@ fn lines(iw: usize, theme: &crate::Theme) -> Vec> { out } -pub fn render(f: &mut Frame, area: Rect, theme: &crate::Theme) { +fn lines( + m: &SdrMetrics, + selected: usize, + iw: usize, + height: usize, + theme: &crate::Theme, +) -> Vec> { + if m.device_options.is_empty() { + return empty_lines(iw, theme); + } + + let selected = selected.min(m.device_options.len() - 1); + let mut out = option_header(iw, height, theme); + let visible = height.saturating_sub(out.len()); + let first = scroll_offset(selected, m.device_options.len(), visible); + for (index, option) in m + .device_options + .iter() + .enumerate() + .skip(first) + .take(visible) + { + out.push(option_line( + option, + index == selected, + &m.ui.device_option_update, + iw, + theme, + )); + } + out +} + +fn option_header(iw: usize, height: usize, theme: &crate::Theme) -> Vec> { + let heading = Line::from(Span::styled( + fit_text(" Device options", iw), + Style::default() + .fg(theme.value_hi) + .add_modifier(Modifier::BOLD), + )); + match height { + 0 | 1 => Vec::new(), + 2 => vec![heading], + 3 => vec![heading, Line::from("")], + _ => vec![Line::from(""), heading, Line::from("")], + } +} + +fn option_line( + option: &crate::hardware::DeviceOption, + active: bool, + update: &DeviceOptionUpdate, + iw: usize, + theme: &crate::Theme, +) -> Line<'static> { + let marker = if active { "\u{25b8} " } else { " " }; + let value_style = Style::default() + .fg(if active { theme.value_hi } else { theme.value }) + .add_modifier(if active { + Modifier::BOLD + } else { + Modifier::empty() + }); + if iw < 7 { + return Line::from(Span::styled( + fit_text(marker, iw), + Style::default().fg(theme.border_accent), + )); + } + let content_width = iw - 7; + let label_width = content_width.min(18).min(content_width / 2); + let choice_width = content_width - label_width; + let label = fit_cell(&option.label, label_width); + let shown = match update { + DeviceOptionUpdate::Pending { id, choice, .. } if id == &option.id => { + format!("{} -> {choice}...", option.selected_choice) + } + DeviceOptionUpdate::Error { id, .. } if id == &option.id => { + format!("{} (failed)", option.selected_choice) + } + _ => option.selected_choice.clone(), + }; + let choice = fit_text(&shown, choice_width); + Line::from(vec![ + Span::styled(marker, Style::default().fg(theme.border_accent)), + Span::styled(label, Style::default().fg(theme.label)), + Span::raw(" "), + Span::styled(format!("\u{25c0} {choice} \u{25b6}"), value_style), + ]) +} + +fn scroll_offset(cursor: usize, total: usize, visible: usize) -> usize { + if visible == 0 || total <= visible { + return 0; + } + cursor + .saturating_sub(visible - 1) + .min(total.saturating_sub(visible)) +} + +fn fit_cell(text: &str, width: usize) -> String { + let mut fitted = fit_text(text, width); + fitted.push_str(&" ".repeat(width.saturating_sub(text_width(&fitted)))); + fitted +} + +fn fit_text(text: &str, width: usize) -> String { + let mut fitted = String::new(); + for character in text.chars() { + fitted.push(character); + if text_width(&fitted) > width { + fitted.pop(); + break; + } + } + fitted +} + +fn text_width(text: &str) -> usize { + Line::from(text).width() +} + +pub fn render(f: &mut Frame, area: Rect, m: &SdrMetrics, selected: usize, theme: &crate::Theme) { if area.width == 0 || area.height == 0 { return; } - let all = lines(area.width as usize, theme); + let all = lines( + m, + selected, + area.width as usize, + area.height as usize, + theme, + ); let shown: Vec = all.into_iter().take(area.height as usize).collect(); f.render_widget(Paragraph::new(shown), area); } @@ -85,7 +202,7 @@ mod tests { /// so both halves are worth pinning. #[test] fn the_empty_state_names_itself_and_admits_it() { - let text: String = lines(60, &crate::Theme::sdr()) + let text: String = lines(&SdrMetrics::fixture(), 0, 60, 20, &crate::Theme::sdr()) .iter() .map(|l| l.to_string()) .collect::>() @@ -100,7 +217,7 @@ mod tests { #[test] fn every_row_fits_the_pane() { for iw in [28, 40, 44, 60, 100] { - for line in lines(iw, &crate::Theme::sdr()) { + for line in empty_lines(iw, &crate::Theme::sdr()) { assert!( line.width() <= iw, "a {}-wide row does not fit {iw} columns: {line:?}", @@ -116,4 +233,132 @@ mod tests { assert!(!HEADING.contains('\u{2014}')); assert!(TODO.iter().all(|t| !t.contains('\u{2014}'))); } + + #[test] + fn options_name_their_current_choices() { + let mut m = SdrMetrics::fixture(); + m.device_options.push(crate::hardware::DeviceOption { + id: "bandwidth".into(), + label: "Bandwidth".into(), + choices: vec!["Narrow".into(), "Wide".into()], + selected_choice: "Wide".into(), + }); + let text = lines(&m, 0, 60, 20, &crate::Theme::sdr()) + .iter() + .map(Line::to_string) + .collect::>() + .join("\n"); + assert!(text.contains("Bandwidth"), "{text}"); + assert!(text.contains("Wide"), "{text}"); + } + + #[test] + fn the_selected_option_stays_in_a_short_viewport() { + let mut m = SdrMetrics::fixture(); + for index in 0..6 { + m.device_options.push(crate::hardware::DeviceOption { + id: format!("option-{index}"), + label: format!("Option {index}"), + choices: vec!["Off".into(), "On".into()], + selected_choice: "Off".into(), + }); + } + + let text = lines(&m, 5, 60, 5, &crate::Theme::sdr()) + .iter() + .map(Line::to_string) + .collect::>() + .join("\n"); + assert!(text.contains("Option 5"), "{text}"); + assert!(!text.contains("Option 0"), "{text}"); + } + + #[test] + fn the_selected_option_uses_the_only_available_row() { + let mut m = SdrMetrics::fixture(); + m.device_options.push(crate::hardware::DeviceOption { + id: "bandwidth".into(), + label: "Bandwidth".into(), + choices: vec!["Narrow".into(), "Wide".into()], + selected_choice: "Wide".into(), + }); + + let text = lines(&m, 0, 60, 1, &crate::Theme::sdr()) + .iter() + .map(Line::to_string) + .collect::>() + .join("\n"); + assert!(text.contains("Bandwidth"), "{text}"); + assert!(text.contains("Wide"), "{text}"); + } + + #[test] + fn backend_text_never_exceeds_the_pane_width() { + let mut m = SdrMetrics::fixture(); + m.device_options.push(crate::hardware::DeviceOption { + id: "long".into(), + label: "A device-provided label that is much too long".into(), + choices: vec!["A device-provided choice that is much too long".into()], + selected_choice: "A device-provided choice that is much too long".into(), + }); + + for width in [1, 4, 7, 8, 12, 20, 28] { + for line in lines(&m, 0, width, 6, &crate::Theme::sdr()) { + assert!( + line.width() <= width, + "a {}-wide row does not fit {width} columns: {line:?}", + line.width() + ); + } + } + } + + #[test] + fn pending_change_keeps_the_accepted_choice_visible() { + let mut m = SdrMetrics::fixture(); + m.device_options.push(crate::hardware::DeviceOption { + id: "bandwidth".into(), + label: "Bandwidth".into(), + choices: vec!["Narrow".into(), "Wide".into()], + selected_choice: "Narrow".into(), + }); + m.ui.device_option_update = DeviceOptionUpdate::Pending { + request_id: 1, + id: "bandwidth".into(), + label: "Bandwidth".into(), + choice: "Wide".into(), + }; + + let text = lines(&m, 0, 80, 1, &crate::Theme::sdr()) + .into_iter() + .map(|line| line.to_string()) + .collect::>() + .join("\n"); + assert!(text.contains("Narrow -> Wide..."), "{text}"); + } + + #[test] + fn failed_change_keeps_the_accepted_choice_visible() { + let mut m = SdrMetrics::fixture(); + m.device_options.push(crate::hardware::DeviceOption { + id: "bandwidth".into(), + label: "Bandwidth".into(), + choices: vec!["Narrow".into(), "Wide".into()], + selected_choice: "Narrow".into(), + }); + m.ui.device_option_update = DeviceOptionUpdate::Error { + request_id: 1, + id: "bandwidth".into(), + choice: "Wide".into(), + message: "device rejected choice".into(), + }; + + let text = lines(&m, 0, 80, 1, &crate::Theme::sdr()) + .into_iter() + .map(|line| line.to_string()) + .collect::>() + .join("\n"); + assert!(text.contains("Narrow (failed)"), "{text}"); + assert!(!text.contains("Wide"), "{text}"); + } }