commit 019b2184f709572d24c47b51cbd50164d2c82761
parent e9f1cc736c4236d848aa32a7dff8cf98b31a1af7
Author: Matthew Gantenbein <ganten1998@gmail.com>
Date: Sun, 31 May 2026 21:06:52 -0500
feat: stereo-pair input view (default) with mono toggle
Inputs panel now groups odd/even same-kind sources into stereo rows by
default (s toggles mono). Preamp toggles apply to both channels of a pair
and stay in sync; 48V handles multi-group rows. Vertical scroll, mode shown
in the title and help.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Diffstat:
2 files changed, 176 insertions(+), 63 deletions(-)
diff --git a/valentine/src/main.rs b/valentine/src/main.rs
@@ -119,6 +119,8 @@ struct App {
monitor_cursor: MonitorCursor,
mixer_cursor: MixerCursor,
routing_cursor: RoutingCursor,
+ /// Inputs panel: stereo-pair view (default) vs. one row per channel.
+ stereo_inputs: bool,
status: Option<String>,
show_help: bool,
last_poll: Instant,
@@ -142,6 +144,7 @@ impl App {
monitor_cursor: MonitorCursor::default(),
mixer_cursor: MixerCursor::default(),
routing_cursor: RoutingCursor::default(),
+ stereo_inputs: true,
status: None,
show_help: false,
last_poll: Instant::now(),
@@ -328,15 +331,31 @@ impl App {
}
}
+ /// Current visible rows for the inputs panel (mono or stereo-paired).
+ fn input_rows(&self) -> Vec<inputs::Row> {
+ match &self.device {
+ Ok(d) => inputs::rows(&d.sources, self.stereo_inputs),
+ Err(_) => Vec::new(),
+ }
+ }
+
fn inputs_key(&mut self, code: KeyCode) {
match code {
KeyCode::Up | KeyCode::Char('k') => self.input_cursor.up(),
KeyCode::Down | KeyCode::Char('j') => {
- let n = self.device.as_ref().map(|d| d.sources.len()).unwrap_or(0);
+ let n = self.input_rows().len();
self.input_cursor.down(n);
}
KeyCode::Left | KeyCode::Char('h') => self.input_cursor.left(),
KeyCode::Right | KeyCode::Char('l') => self.input_cursor.right(),
+ KeyCode::Char('s') => {
+ self.stereo_inputs = !self.stereo_inputs;
+ self.input_cursor.clamp(self.input_rows().len());
+ self.status = Some(format!(
+ "inputs: {} view",
+ if self.stereo_inputs { "stereo" } else { "mono" }
+ ));
+ }
KeyCode::Char(' ') | KeyCode::Enter => self.toggle_focused_switch(),
_ => {}
}
@@ -345,52 +364,75 @@ impl App {
fn toggle_focused_switch(&mut self) {
let cursor = self.input_cursor;
let col = cursor.current_col();
+ let rows = self.input_rows();
+ let row = match rows.get(cursor.row) {
+ Some(r) => *r,
+ None => return,
+ };
let dev = match &mut self.device {
Ok(d) => d,
Err(_) => return,
};
- // Resolve the focused source from the catalog.
- let source = match dev.sources.get(cursor.source_index) {
- Some(s) => s.clone(),
- None => return,
- };
- let name = source.name.clone();
- // Channel index for byte-addressed switches (air/pad/inst): the source's
- // index within its kind, which for analogue == the preamp channel.
- let ch = source.index as u8;
-
- let result = match col {
- Col::P48 => match source.phantom_group {
- Some(group) => {
- let cur = dev.inputs.phantom.get(group as usize).copied().unwrap_or(false);
- dev.scarlett.set_phantom(group, !cur).map(|_| {
- if let Some(p) = dev.inputs.phantom.get_mut(group as usize) {
- *p = !cur;
- }
- })
- }
- None => {
- self.status = Some(format!("48V not available on {name}"));
- return;
+ // The channels this row covers (1 for mono, 2 for a stereo pair).
+ let mut srcs = vec![dev.sources[row.left].clone()];
+ if let Some(r) = row.right {
+ srcs.push(dev.sources[r].clone());
+ }
+ let label = srcs[0].name.clone();
+
+ // Determine the new state from the first applicable channel, then apply
+ // it to every channel in the row so a pair stays in sync.
+ let applicable: Vec<_> = srcs.iter().filter(|s| Col::applies_to(col, s)).collect();
+ if applicable.is_empty() {
+ self.status = Some(format!("{:?} not available on {label}", col));
+ return;
+ }
+
+ let mut result = Ok(());
+ match col {
+ Col::P48 => {
+ // Collect distinct phantom groups across the row's channels.
+ let mut groups: Vec<u8> = srcs.iter().filter_map(|s| s.phantom_group).collect();
+ groups.sort_unstable();
+ groups.dedup();
+ let cur = dev
+ .inputs
+ .phantom
+ .get(groups[0] as usize)
+ .copied()
+ .unwrap_or(false);
+ for g in groups {
+ if result.is_ok() {
+ result = dev.scarlett.set_phantom(g, !cur).map(|_| {
+ if let Some(p) = dev.inputs.phantom.get_mut(g as usize) {
+ *p = !cur;
+ }
+ });
+ }
}
- },
+ }
other => {
- if !Col::applies_to(other, &source) {
- self.status = Some(format!("{:?} not available on {name}", other));
- return;
- }
let sw = col_switch(other).expect("non-P48 columns map to a switch");
- let cur = switch_state(&dev.inputs, other, ch);
- dev.scarlett.set_input_switch(sw, ch, !cur).map(|_| {
- set_switch_state(&mut dev.inputs, other, ch, !cur);
- })
+ let ch0 = applicable[0].index as u8;
+ let new = !switch_state(&dev.inputs, other, ch0);
+ for s in &srcs {
+ if !Col::applies_to(other, s) {
+ continue;
+ }
+ let ch = s.index as u8;
+ if result.is_ok() {
+ result = dev.scarlett.set_input_switch(sw, ch, new).map(|_| {
+ set_switch_state(&mut dev.inputs, other, ch, new);
+ });
+ }
+ }
}
- };
+ }
match result {
- Ok(()) => self.status = Some(format!("{:?} · {name} toggled", col)),
+ Ok(()) => self.status = Some(format!("{:?} · {label} toggled", col)),
Err(e) => self.status = Some(format!("toggle failed: {e}")),
}
}
@@ -627,7 +669,18 @@ fn ui(f: &mut Frame, app: &App) {
// Body
match (&app.device, app.tab) {
(Ok(dev), 0) => {
- inputs::render(f, chunks[2], t, &dev.inputs, &dev.sources, app.input_cursor, true);
+ let rows = inputs::rows(&dev.sources, app.stereo_inputs);
+ inputs::render(
+ f,
+ chunks[2],
+ t,
+ &dev.inputs,
+ &dev.sources,
+ &rows,
+ app.input_cursor,
+ app.stereo_inputs,
+ true,
+ );
}
(Ok(dev), 1) => {
monitor::render(f, chunks[2], t, &dev.monitor, app.monitor_cursor, true);
@@ -706,6 +759,7 @@ fn help_overlay(f: &mut Frame, t: &Theme) {
key("↑↓←→ / hjkl", "move within a panel"),
key("Space/Enter", "toggle / engage focused control"),
key("+ - 0 m", "mixer: ±1 dB, unity, mute (on Mixer tab)"),
+ key("s", "inputs: toggle stereo-pair / mono view"),
Line::from(""),
head("Presets & device"),
key("S", "save current config to a preset file"),
diff --git a/valentine/src/panels/inputs.rs b/valentine/src/panels/inputs.rs
@@ -47,22 +47,43 @@ impl Col {
}
}
-/// Cursor position within the grid.
+/// One visible row: a single source, or a stereo pair (two catalog indices).
+#[derive(Debug, Clone, Copy)]
+pub struct Row {
+ pub left: usize,
+ pub right: Option<usize>,
+}
+
+/// Build the visible rows for the catalog. In `stereo` mode consecutive
+/// odd/even sources of the same kind collapse into one row; otherwise every
+/// source is its own row.
+pub fn rows(sources: &[Source], stereo: bool) -> Vec<Row> {
+ if stereo {
+ scarlett_core::sources::stereo_pairs(sources)
+ .into_iter()
+ .map(|p| Row { left: p.left, right: p.right })
+ .collect()
+ } else {
+ (0..sources.len()).map(|i| Row { left: i, right: None }).collect()
+ }
+}
+
+/// Cursor position within the grid (by visible row + column).
#[derive(Debug, Clone, Copy, Default)]
pub struct Cursor {
- /// Index into the source catalog (the visible row).
- pub source_index: usize,
+ /// Index into the current visible row list.
+ pub row: usize,
/// Column within [`Col::ALL`].
pub col: usize,
}
impl Cursor {
pub fn up(&mut self) {
- self.source_index = self.source_index.saturating_sub(1);
+ self.row = self.row.saturating_sub(1);
}
- pub fn down(&mut self, source_count: usize) {
- if self.source_index + 1 < source_count {
- self.source_index += 1;
+ pub fn down(&mut self, row_count: usize) {
+ if self.row + 1 < row_count {
+ self.row += 1;
}
}
pub fn left(&mut self) {
@@ -73,6 +94,12 @@ impl Cursor {
self.col += 1;
}
}
+ /// Keep the cursor in range after the row list changes (e.g. mode toggle).
+ pub fn clamp(&mut self, row_count: usize) {
+ if row_count > 0 && self.row >= row_count {
+ self.row = row_count - 1;
+ }
+ }
pub fn current_col(&self) -> Col {
Col::ALL[self.col]
@@ -103,38 +130,59 @@ pub fn col_switch(col: Col) -> Option<InputSwitch> {
}
}
-/// Render the input grid into `area`. `sources` is the device's source catalog.
+/// Display label for a visible row. A stereo pair shows e.g. "Analogue 1-2";
+/// a mono row shows the source name.
+fn row_label(sources: &[Source], row: Row) -> String {
+ let l = &sources[row.left];
+ match row.right {
+ Some(r) => {
+ // "Analogue 1-2" — take the kind word from the left source's name.
+ let word = l.name.rsplit_once(' ').map(|(w, _)| w).unwrap_or(&l.name);
+ format!("{word} {}-{}", l.index + 1, sources[r].index + 1)
+ }
+ None => l.name.clone(),
+ }
+}
+
+/// Render the input grid into `area`. `rows` is the precomputed visible-row list
+/// (mono or stereo-paired), `sources` the underlying catalog.
pub fn render(
f: &mut Frame,
area: Rect,
theme: &Theme,
state: &InputState,
sources: &[Source],
+ rows: &[Row],
cursor: Cursor,
+ stereo: bool,
focused: bool,
) {
let border = if focused { theme.border_focus } else { theme.border };
+ let mode = if stereo { "stereo" } else { "mono" };
let block = Block::default()
- .title(Span::styled(" inputs ", Style::default().fg(theme.accent)))
+ .title(Span::styled(
+ format!(" inputs · {mode} "),
+ Style::default().fg(theme.accent),
+ ))
.borders(Borders::ALL)
.border_type(ratatui::widgets::BorderType::Rounded)
.border_style(Style::default().fg(border));
let inner = block.inner(area);
f.render_widget(block, area);
- // Vertical scroll so the cursor row stays visible (39 sources > screen).
+ // Vertical scroll so the cursor row stays visible.
let visible = (inner.height as usize).saturating_sub(3).max(1); // header+blank+help
let start = cursor
- .source_index
+ .row
.saturating_sub(visible - 1)
- .min(sources.len().saturating_sub(visible).max(0));
- let end = (start + visible).min(sources.len());
+ .min(rows.len().saturating_sub(visible).max(0));
+ let end = (start + visible).min(rows.len());
let mut lines: Vec<Line> = Vec::new();
- // Header row.
+ // Header.
let mut header = vec![Span::styled(
- format!("{:<12}", "source"),
+ format!("{:<14}", "source"),
Style::default().fg(theme.fg_dim),
)];
for col in Col::ALL {
@@ -145,27 +193,37 @@ pub fn render(
}
lines.push(Line::from(header));
- // One row per source (windowed).
- for (si, source) in sources.iter().enumerate().take(end).skip(start) {
- let name_style = if focused && cursor.source_index == si {
+ // One line per visible row. A switch shows on if EITHER channel of the row
+ // has it on; the row's preamp capability is the left channel's (pairs share
+ // a kind, so capabilities match).
+ for (ri, row) in rows.iter().enumerate().take(end).skip(start) {
+ let left = &sources[row.left];
+ let name_style = if focused && cursor.row == ri {
Style::default().fg(theme.accent).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(theme.fg)
};
- let mut row = vec![Span::styled(format!("{:<12}", source.name), name_style)];
+ let mut line = vec![Span::styled(
+ format!("{:<14}", row_label(sources, *row)),
+ name_style,
+ )];
for (ci, col) in Col::ALL.iter().enumerate() {
let col = *col;
- let here = focused && cursor.source_index == si && cursor.col == ci;
+ let here = focused && cursor.row == ri && cursor.col == ci;
- let cell = if !col.applies_to(source) {
+ let cell = if !col.applies_to(left) {
let mut s = Style::default().fg(theme.fg_dim);
if here {
s = s.bg(theme.bg_selected);
}
Span::styled(format!("{:^7}", "·"), s)
} else {
- let on = is_on(state, source, col);
+ let on = is_on(state, left, col)
+ || row
+ .right
+ .map(|r| is_on(state, &sources[r], col))
+ .unwrap_or(false);
let glyph = if on { "● ON" } else { " ·" };
let mut style = if on {
Style::default().fg(theme.armed).add_modifier(Modifier::BOLD)
@@ -179,17 +237,18 @@ pub fn render(
}
Span::styled(format!("{glyph:^7}"), style)
};
- row.push(cell);
+ line.push(cell);
}
- lines.push(Line::from(row));
+ lines.push(Line::from(line));
}
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
format!(
- "↑↓ source ←→ switch space/enter toggle [{}/{}]",
- cursor.source_index + 1,
- sources.len()
+ "↑↓ row ←→ switch space toggle s {} [{}/{}]",
+ if stereo { "→mono" } else { "→stereo" },
+ cursor.row + 1,
+ rows.len()
),
Style::default().fg(theme.fg_dim),
)));