valentine

Terminal control panel for the Focusrite Scarlett 18i20 — a from-scratch replacement for Focusrite Control.
Log | Files | Refs | README | LICENSE

commit 82fdcb2a7e26425769871c000972367b78f1fb2d
parent 56f904ea99b0dbd86b063f8542ccfd4f446efa19
Author: Matthew Gantenbein <ganten1998@gmail.com>
Date:   Mon,  1 Jun 2026 13:39:39 -0500

fix: presets capture+apply routing; p-only picker with instant Y/N confirm

- Preset now includes full routing (mux), captured on save and applied on
  load — fixes 'preset doesn't switch settings' (routing was the missing,
  main thing). apply_preset writes routing atomically; load refreshes views.
- Preset picker is now the 'p' key only (dropped L).
- No staging: pick in the picker -> single yes/no confirm window -> instant
  apply. Removes the pending-preset stage/confirm-on-status flow.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Diffstat:
Mscarlett-core/src/preset.rs | 22++++++++++++++++++++--
Mvalentine/src/main.rs | 117++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------------
2 files changed, 100 insertions(+), 39 deletions(-)

diff --git a/scarlett-core/src/preset.rs b/scarlett-core/src/preset.rs @@ -34,16 +34,22 @@ pub struct Preset { pub dim: bool, /// Mixer matrix as `[bus][input]` in dB. pub mixer: Vec<Vec<f32>>, + /// Full routing as a flat `mux[dest_num] = source_num` array (port numbers). + /// Empty in older presets; when present, applying restores routing too. + #[serde(default)] + pub routing: Vec<u16>, } impl Preset { - /// Build a preset from already-read state (no device I/O). + /// Build a preset from already-read state (no device I/O). `routing` is the + /// flat `mux[dest]=src` port-number array (empty = don't capture routing). pub fn from_state( name: impl Into<String>, pid: u16, inputs: &InputState, monitor: &MonitorState, mixer: &[Vec<f32>], + routing: &[u16], ) -> Self { Preset { version: PRESET_VERSION, @@ -56,6 +62,7 @@ impl Preset { mute: monitor.mute, dim: monitor.dim, mixer: mixer.to_vec(), + routing: routing.to_vec(), } } @@ -106,6 +113,16 @@ impl<T: Transport> Scarlett<T> { } } + // Routing last, as one atomic write (the big visible change). + if !p.routing.is_empty() { + use crate::mux::{mux_assignment_18i20_gen3, MuxState, PORT_COUNT_18I20_GEN3}; + let pc = PORT_COUNT_18I20_GEN3; + let state = MuxState { pc, mux: p.routing.clone() }; + let tables = state.encode_all(&mux_assignment_18i20_gen3()); + self.write_routing_tables(&tables)?; + writes += 1; + } + Ok(writes) } } @@ -128,6 +145,7 @@ mod tests { mute: false, dim: true, mixer: vec![vec![0.0, -6.0]], + routing: vec![], } } @@ -150,7 +168,7 @@ mod tests { }; let mon = MonitorState { master_db: -3, mute: true, dim: false }; let mixer = vec![vec![0.0, 0.0]]; - let p = Preset::from_state("test", 0x8215, &inputs, &mon, &mixer); + let p = Preset::from_state("test", 0x8215, &inputs, &mon, &mixer, &[]); assert_eq!(p.air, vec![true, true]); assert_eq!(p.inst, vec![false, true]); assert!(p.mute); diff --git a/valentine/src/main.rs b/valentine/src/main.rs @@ -133,6 +133,8 @@ enum Modal { None, /// Preset picker: list of `(name, path)` + the highlighted index. LoadPicker { entries: Vec<(String, std::path::PathBuf)>, sel: usize }, + /// Yes/no confirm to apply a chosen preset (loaded and ready). + Confirm { name: String, preset: Box<scarlett_core::preset::Preset> }, /// Save-as name entry: the text typed so far. SaveName { buf: String }, } @@ -151,10 +153,7 @@ struct App { /// avoids momentarily routing through every source while cycling (which could /// create a feedback loop). routing_pending: Option<(usize, usize)>, - /// A preset loaded from disk but not yet applied — staged via the picker, - /// Enter/`Y` applies, Esc/`N` discards. - pending_preset: Option<scarlett_core::preset::Preset>, - /// Modal UI state (preset picker / name entry), reachable from any screen. + /// Modal UI state (preset picker / confirm / name entry), any-screen. modal: Modal, /// Inputs panel: stereo-pair view (default) vs. one row per channel. stereo_inputs: bool, @@ -182,7 +181,6 @@ impl App { mixer_cursor: MixerCursor::default(), routing_cursor: RoutingCursor::default(), routing_pending: None, - pending_preset: None, modal: Modal::None, stereo_inputs: true, status: None, @@ -216,7 +214,7 @@ impl App { self.status = Some("save preset as… (type a name, Enter to save)".into()); return; } - KeyCode::Char('L') | KeyCode::Char('p') | KeyCode::Char('P') => { + KeyCode::Char('p') | KeyCode::Char('P') => { let entries = list_presets(); if entries.is_empty() { self.status = Some("no presets saved yet (press S to save one)".into()); @@ -237,20 +235,6 @@ impl App { self.show_help = false; return; } - // A staged preset takes over Enter/Esc until confirmed or cancelled. - KeyCode::Enter | KeyCode::Char('y') | KeyCode::Char('Y') - if self.pending_preset.is_some() => - { - self.apply_pending_preset(); - return; - } - KeyCode::Esc | KeyCode::Char('n') | KeyCode::Char('N') - if self.pending_preset.is_some() => - { - self.pending_preset = None; - self.status = Some("preset load cancelled".into()); - return; - } KeyCode::Tab | KeyCode::Right => { if self.tab != 0 || !matches!(code, KeyCode::Right) { // Right is consumed by the Inputs grid; Tab always cycles. @@ -320,8 +304,7 @@ impl App { } KeyCode::Enter => { let path = entries[*sel].1.clone(); - self.modal = Modal::None; - self.stage_preset_path(&path); + self.open_confirm(&path); } KeyCode::Esc => { self.modal = Modal::None; @@ -329,6 +312,16 @@ impl App { } _ => {} }, + Modal::Confirm { .. } => match code { + KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Enter => { + self.apply_confirmed_preset(); + } + KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => { + self.modal = Modal::None; + self.status = Some("load cancelled".into()); + } + _ => {} + }, Modal::SaveName { buf } => match code { KeyCode::Char(c) if !c.is_control() => buf.push(c), KeyCode::Backspace => { @@ -696,7 +689,22 @@ impl App { if dev.mixer.is_empty() { dev.load_mixer(); } - let preset = Preset::from_state(name, S18I20_GEN3.pid, &dev.inputs, &dev.monitor, &dev.mixer); + // Capture current routing too (the main thing presets switch). + let routing: Vec<u16> = { + let pc = scarlett_core::mux::PORT_COUNT_18I20_GEN3; + match dev.scarlett.get_mux(S18I20_GEN3.mux_dst_count()) { + Ok(entries) => scarlett_core::mux::MuxState::from_entries(pc, &entries).mux, + Err(_) => Vec::new(), + } + }; + let preset = Preset::from_state( + name, + S18I20_GEN3.pid, + &dev.inputs, + &dev.monitor, + &dev.mixer, + &routing, + ); match preset_file(name) { Some(path) => { if let Some(parent) = path.parent() { @@ -711,31 +719,35 @@ impl App { } } - /// Read + parse a preset file and STAGE it (no device writes yet). A preset - /// rewrites many controls at once, so we confirm before applying. - fn stage_preset_path(&mut self, path: &std::path::Path) { + /// Read + parse the chosen preset and open a yes/no confirm window. + fn open_confirm(&mut self, path: &std::path::Path) { let text = match std::fs::read_to_string(path) { Ok(t) => t, Err(e) => { + self.modal = Modal::None; self.status = Some(format!("read failed: {e}")); return; } }; match Preset::from_json(&text) { Ok(p) => { - let name = p.name.clone(); - self.pending_preset = Some(p); - self.status = Some(format!("load “{name}”? — Enter/Y to apply, Esc/N to cancel")); + self.modal = Modal::Confirm { name: p.name.clone(), preset: Box::new(p) }; + } + Err(e) => { + self.modal = Modal::None; + self.status = Some(format!("preset parse error: {e}")); } - Err(e) => self.status = Some(format!("preset parse error: {e}")), } } - /// Apply the staged preset to the device (the only place preset writes happen). - fn apply_pending_preset(&mut self) { - let preset = match self.pending_preset.take() { - Some(p) => p, - None => return, + /// Apply the preset held in the Confirm modal, immediately. + fn apply_confirmed_preset(&mut self) { + let preset = match std::mem::replace(&mut self.modal, Modal::None) { + Modal::Confirm { preset, .. } => *preset, + other => { + self.modal = other; + return; + } }; let dev = match &mut self.device { Ok(d) => d, @@ -749,6 +761,8 @@ impl App { dev.inputs = dev.scarlett.read_input_state().unwrap_or_default(); dev.monitor = dev.scarlett.read_monitor_state().unwrap_or_default(); dev.mixer.clear(); + dev.load_routing(); // refresh routing view to the applied state + dev.refresh_src_meter(); self.status = Some(format!("preset “{}” applied ({n} writes)", preset.name)); } Err(e) => self.status = Some(format!("apply failed: {e}")), @@ -998,6 +1012,7 @@ fn ui(f: &mut Frame, app: &App) { match &app.modal { Modal::LoadPicker { entries, sel } => preset_picker(f, t, entries, *sel), + Modal::Confirm { name, .. } => confirm_modal(f, t, name), Modal::SaveName { buf } => save_name_modal(f, t, buf), Modal::None => {} } @@ -1056,6 +1071,34 @@ fn preset_picker(f: &mut Frame, t: &Theme, entries: &[(String, std::path::PathBu } /// The save-as name-entry overlay. +/// Yes/no confirm window shown after picking a preset. +fn confirm_modal(f: &mut Frame, t: &Theme, name: &str) { + let area = f.area(); + let rect = modal_rect(area, 46, 5); + f.render_widget(ratatui::widgets::Clear, rect); + let block = Block::default() + .title(Span::styled(" apply preset ", Style::default().fg(t.accent))) + .borders(Borders::ALL) + .border_type(ratatui::widgets::BorderType::Rounded) + .border_style(Style::default().fg(t.border_focus)) + .style(Style::default().bg(t.bg_elevated)); + let inner = block.inner(rect); + f.render_widget(block, rect); + let lines = vec![ + Line::from(vec![ + Span::styled("Apply “", Style::default().fg(t.fg)), + Span::styled(name.to_string(), Style::default().fg(t.accent).add_modifier(Modifier::BOLD)), + Span::styled("” now?", Style::default().fg(t.fg)), + ]), + Line::from(""), + Line::from(Span::styled( + "Y / Enter = apply N / Esc = cancel", + Style::default().fg(t.fg_dim), + )), + ]; + f.render_widget(Paragraph::new(lines), inner); +} + fn save_name_modal(f: &mut Frame, t: &Theme, buf: &str) { let area = f.area(); let rect = modal_rect(area, 44, 5); @@ -1127,7 +1170,7 @@ fn help_overlay(f: &mut Frame, t: &Theme) { Line::from(""), head("Presets & device"), key("S", "save current config as a named preset"), - key("L / p", "preset picker — choose, Enter loads (confirm to apply)"), + key("p", "preset picker — choose, then Y/N confirm to apply"), key("W", "write current config to device NVRAM"), key("r", "reconnect to the device"), Line::from(""), @@ -1195,7 +1238,7 @@ fn status_bar(app: &App) -> Paragraph<'_> { } spans.push(Span::styled( - " Tab panels · S save · L/p presets · W →NVRAM · ? help · q quit", + " Tab panels · S save · p presets · W →NVRAM · ? help · q quit", Style::default().fg(t.fg_dim).bg(t.bg_elevated), ));