valentine

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

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:
Mvalentine/src/main.rs | 126++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------
Mvalentine/src/panels/inputs.rs | 113++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------
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), )));