valentine

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

commit 273b13d1071a6d14921f41b73d491b1477c95325
parent 81a1380dbaf27b6b7929e3722e592f28547b734a
Author: Matthew Gantenbein <ganten1998@gmail.com>
Date:   Mon,  1 Jun 2026 13:10:32 -0500

feat: stage-then-confirm for routing changes and preset load

Routing: left/right now STAGE a source for the selected destination (no write);
Enter applies, Esc cancels — avoids momentarily routing through every source
while cycling (feedback-loop risk). Staged source shown in accent with a *.
Preset load: L stages the preset, Enter/Y applies, Esc/N discards (it rewrites
many controls at once). Status line + help reflect the confirm step.

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

Diffstat:
Mvalentine/src/main.rs | 160+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------
Mvalentine/src/panels/routing.rs | 62+++++++++++++++++++++++++++++++++++++++-----------------------
2 files changed, 159 insertions(+), 63 deletions(-)

diff --git a/valentine/src/main.rs b/valentine/src/main.rs @@ -136,6 +136,14 @@ struct App { monitor_cursor: MonitorCursor, mixer_cursor: MixerCursor, routing_cursor: RoutingCursor, + /// Staged (uncommitted) routing change: `(dest_port, source_port)`. `←→` + /// stages a new source for the selected destination WITHOUT writing — + /// Enter commits, Esc cancels. This avoids momentarily routing through every + /// source while cycling (which could create a feedback loop). + routing_pending: Option<(u16, u16)>, + /// A preset loaded from disk but not yet applied — `L` stages it (it can + /// rewrite many controls at once), Enter/`Y` applies, Esc/`N` discards. + pending_preset: Option<scarlett_core::preset::Preset>, /// Inputs panel: stereo-pair view (default) vs. one row per channel. stereo_inputs: bool, status: Option<String>, @@ -161,6 +169,8 @@ impl App { monitor_cursor: MonitorCursor::default(), mixer_cursor: MixerCursor::default(), routing_cursor: RoutingCursor::default(), + routing_pending: None, + pending_preset: None, stereo_inputs: true, status: None, show_help: false, @@ -186,7 +196,7 @@ impl App { return; } KeyCode::Char('L') => { - self.load_preset(); + self.stage_preset(); return; } KeyCode::Char('W') => { @@ -201,6 +211,20 @@ 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. @@ -259,61 +283,104 @@ impl App { fn routing_key(&mut self, code: KeyCode) { let n = scarlett_core::mux::num_dsts(&scarlett_core::mux::PORT_COUNT_18I20_GEN3); match code { - KeyCode::Up | KeyCode::Char('k') => self.routing_cursor.up(), - KeyCode::Down | KeyCode::Char('j') => self.routing_cursor.down(n), - KeyCode::Left | KeyCode::Char('h') => self.cycle_route(-1), - KeyCode::Right | KeyCode::Char('l') => self.cycle_route(1), + // Moving rows abandons any uncommitted staged change. + KeyCode::Up | KeyCode::Char('k') => { + self.routing_pending = None; + self.routing_cursor.up(); + } + KeyCode::Down | KeyCode::Char('j') => { + self.routing_pending = None; + self.routing_cursor.down(n); + } + // ←→ only STAGE a source; nothing is written until Enter. + KeyCode::Left | KeyCode::Char('h') => self.stage_route(-1), + KeyCode::Right | KeyCode::Char('l') => self.stage_route(1), + KeyCode::Enter => self.commit_route(), + KeyCode::Esc => { + if self.routing_pending.take().is_some() { + self.status = Some("route change cancelled".into()); + } + } _ => {} } } - /// Cycle the focused destination's source by `delta` in the source list and - /// write the change to the device. - fn cycle_route(&mut self, delta: i32) { + /// Staged source (port number) shown for `dst`: the pending choice if one is + /// staged for this destination, else the device's current source. + fn routing_shown_src(&self, dst: u16) -> u16 { + if let Some((pd, ps)) = self.routing_pending { + if pd == dst { + return ps; + } + } + match &self.device { + Ok(d) => d.routing.as_ref().map(|s| s.get(dst)).unwrap_or(0), + Err(_) => 0, + } + } + + /// Stage (do NOT write) a new source for the focused destination by stepping + /// `delta` through the source list from whatever is currently shown. + fn stage_route(&mut self, delta: i32) { let pc = scarlett_core::mux::PORT_COUNT_18I20_GEN3; let srcs = scarlett_core::mux::source_list(&pc); let dst = self.routing_cursor.row as u16; + if !matches!(&self.device, Ok(d) if d.routing.is_some()) { + return; + } + let cur = self.routing_shown_src(dst); + let cur_i = srcs.iter().position(|(n, _)| *n == cur).unwrap_or(0) as i32; + let new_i = (cur_i + delta).rem_euclid(srcs.len() as i32) as usize; + let (new_src, new_name) = srcs[new_i].clone(); + self.routing_pending = Some((dst, new_src)); + self.status = Some(format!("staged: ← {new_name} (Enter to apply, Esc to cancel)")); + } + /// Commit the staged routing change to the device (the only place that writes). + fn commit_route(&mut self) { + let pc = scarlett_core::mux::PORT_COUNT_18I20_GEN3; + let (dst, src) = match self.routing_pending { + Some(p) => p, + None => return, + }; let dev = match &mut self.device { Ok(d) => d, Err(_) => return, }; - let state = match &dev.routing { - Some(s) => s, - None => return, - }; - let cur = state.get(dst); - // index of current source in the list, then step with wraparound - let cur_i = srcs.iter().position(|(n, _)| *n == cur).unwrap_or(0) as i32; - let new_i = (cur_i + delta).rem_euclid(srcs.len() as i32) as usize; - let (new_src, new_name) = srcs[new_i].clone(); - - match dev.scarlett.set_route(pc, dst, new_src) { + match dev.scarlett.set_route(pc, dst, src) { Ok(updated) => { dev.routing = Some(updated); + self.routing_pending = None; let dname = scarlett_core::ports::sink_name( scarlett_core::mux::num_to_id(&pc, scarlett_core::mux::Dir::Out, dst), ); - self.status = Some(format!("{dname} ← {new_name}")); + let sname = if src == 0 { + "Off".to_string() + } else { + scarlett_core::ports::source_name(scarlett_core::mux::num_to_id( + &pc, + scarlett_core::mux::Dir::In, + src, + )) + }; + self.status = Some(format!("applied: {dname} ← {sname}")); } Err(e) => self.status = Some(format!("route write failed: {e}")), } } - /// Build the `(sink, source, is_off)` display rows for the routing panel. - fn routing_rows(&self) -> Vec<(String, String, bool)> { + /// Build the display rows for the routing panel: + /// `(sink, source_name, is_off, is_pending)`. The shown source reflects any + /// staged (uncommitted) change; `is_pending` marks the row awaiting Enter. + fn routing_rows(&self) -> Vec<routing::RouteRow> { let pc = scarlett_core::mux::PORT_COUNT_18I20_GEN3; - let state = match &self.device { - Ok(d) => match &d.routing { - Some(s) => s, - None => return Vec::new(), - }, - Err(_) => return Vec::new(), - }; + if !matches!(&self.device, Ok(d) if d.routing.is_some()) { + return Vec::new(); + } scarlett_core::mux::dest_list(&pc) .into_iter() .map(|(d, sink)| { - let src = state.get(d); + let src = self.routing_shown_src(d); let src_name = if src == 0 { "Off".to_string() } else { @@ -323,7 +390,8 @@ impl App { src, )) }; - (sink, src_name, src == 0) + let pending = self.routing_pending.map(|(pd, _)| pd == d).unwrap_or(false); + routing::RouteRow { sink, source: src_name, off: src == 0, pending } }) .collect() } @@ -556,7 +624,9 @@ impl App { } /// Load the preset from disk and apply it to the device. - fn load_preset(&mut self) { + /// Read + parse the preset from disk and STAGE it (no device writes yet). + /// A preset rewrites many controls at once, so we confirm before applying. + fn stage_preset(&mut self) { let path = match preset_path() { Some(p) => p, None => { @@ -571,23 +641,33 @@ impl App { return; } }; - let preset = match Preset::from_json(&text) { - Ok(p) => p, - Err(e) => { - self.status = Some(format!("preset parse error: {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" + )); } + 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, }; let dev = match &mut self.device { Ok(d) => d, Err(_) => { - self.status = Some("load: not connected".into()); + self.status = Some("apply: not connected".into()); return; } }; match dev.scarlett.apply_preset(&preset) { Ok(n) => { - // refresh cached state so the UI reflects what we just wrote dev.inputs = dev.scarlett.read_input_state().unwrap_or_default(); dev.monitor = dev.scarlett.read_monitor_state().unwrap_or_default(); dev.mixer.clear(); @@ -849,7 +929,7 @@ fn help_overlay(f: &mut Frame, t: &Theme) { Line::from(""), head("Presets & device"), key("S", "save current config to a preset file"), - key("L", "load preset and apply to device"), + key("L", "load preset (Enter/Y applies, Esc/N cancels)"), key("W", "write current config to device NVRAM"), key("r", "reconnect to the device"), Line::from(""), diff --git a/valentine/src/panels/routing.rs b/valentine/src/panels/routing.rs @@ -2,14 +2,24 @@ //! feeds each output (sink). The write path is hardware-verified (see //! `scarlett-core::mux` + the muxcheck probe). //! -//! ↑↓ select a destination; ←→ cycle its source (Off = unrouted/mute); the -//! change is written to the device immediately. +//! ↑↓ select a destination; ←→ STAGE a source (Off = unrouted/mute); Enter +//! applies, Esc cancels. Staging-then-confirm avoids momentarily routing through +//! every source while cycling (which could create a feedback loop). use ratatui::prelude::*; use ratatui::widgets::{Block, Borders, Paragraph}; use crate::theme::Theme; +/// One destination row for display. +pub struct RouteRow { + pub sink: String, + pub source: String, + pub off: bool, + /// True if this row has a staged-but-uncommitted source change. + pub pending: bool, +} + /// Which destination row is selected. #[derive(Debug, Clone, Copy, Default)] pub struct Cursor { @@ -27,13 +37,12 @@ impl Cursor { } } -/// `dests[i] = (dest_name, current_source_name, is_off)` for each destination, -/// in destination-port order (matches the core's dest_list / mux indexing). +/// `dests` in destination-port order (matches the core's dest_list / mux index). pub fn render( f: &mut Frame, area: Rect, theme: &Theme, - dests: &[(String, String, bool)], + dests: &[RouteRow], cursor: Cursor, focused: bool, ) { @@ -70,9 +79,16 @@ pub fn render( Style::default().fg(theme.fg_dim).add_modifier(Modifier::BOLD), ))); - for (i, (sink, source, off)) in dests.iter().enumerate().take(end).skip(start) { + let mut any_pending = false; + for (i, row) in dests.iter().enumerate().take(end).skip(start) { let here = focused && i == cursor.row; - let src_style = if *off { + any_pending |= row.pending; + + // Source colour: pending = accent (cyan, "armed to apply"), live = amber, + // off = dim. + let src_style = if row.pending { + Style::default().fg(theme.accent).add_modifier(Modifier::BOLD) + } else if row.off { Style::default().fg(theme.fg_dim) } else { Style::default().fg(theme.armed) @@ -82,32 +98,32 @@ pub fn render( } else { Style::default().fg(theme.fg) }; - // When selected, show ‹ source › to signal it's editable with ←→. - let (lbr, rbr, arrow) = if here { - ("‹", "›", " ← ") - } else if *off { - (" ", " ", " · ") - } else { - (" ", " ", " ← ") - }; + // Selected row gets ‹ › brackets to signal it's editable; a pending row + // gets a trailing * marker. + let (lbr, rbr) = if here { ("‹", "›") } else { (" ", " ") }; + let mark = if row.pending { " *" } else { "" }; + let arrow = if row.off && !row.pending { " · " } else { " ← " }; + let spans = vec![ Span::styled(if here { "▸" } else { " " }, Style::default().fg(theme.accent)), - Span::styled(format!("{sink:<20}"), sink_style), + Span::styled(format!("{:<20}", row.sink), sink_style), Span::styled(arrow.to_string(), Style::default().fg(theme.fg_dim)), Span::styled(lbr.to_string(), Style::default().fg(theme.accent)), - Span::styled(source.clone(), src_style), + Span::styled(row.source.clone(), src_style), Span::styled(rbr.to_string(), Style::default().fg(theme.accent)), + Span::styled(mark.to_string(), Style::default().fg(theme.accent)), ]; lines.push(Line::from(spans)); } + let help = if any_pending { + "↑↓ output ←→ change source ENTER apply ESC cancel" + } else { + "↑↓ output ←→ stage source (Off = unrouted) Enter apply" + }; lines.push(Line::from(Span::styled( - format!( - "↑↓ output ←→ source (Off = unrouted) [{}/{}]", - cursor.row + 1, - dests.len() - ), - Style::default().fg(theme.fg_dim), + format!("{help} [{}/{}]", cursor.row + 1, dests.len()), + Style::default().fg(if any_pending { theme.accent } else { theme.fg_dim }), ))); f.render_widget(Paragraph::new(lines), inner);