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