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:
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),
));