valentine

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

commit 56f904ea99b0dbd86b063f8542ccfd4f446efa19
parent 3c7c75fb6710c6bfa0746b2c94c9972de0fac496
Author: Matthew Gantenbein <ganten1998@gmail.com>
Date:   Mon,  1 Jun 2026 13:23:02 -0500

feat: multi-preset support + cross-screen picker/save modals

Presets are now individual <name>.json files. S opens a save-as name entry;
L or p opens a preset picker (↑↓ select, Enter loads → confirm to apply) —
both reachable from ANY tab via a modal overlay that captures input first.
Replaces the single-slot preset.json with presets_dir/preset_file/list_presets.

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

Diffstat:
Mvalentine/src/main.rs | 256++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------
1 file changed, 216 insertions(+), 40 deletions(-)

diff --git a/valentine/src/main.rs b/valentine/src/main.rs @@ -128,6 +128,15 @@ impl Device { } } +/// Modal overlays that can appear over any tab. +enum Modal { + None, + /// Preset picker: list of `(name, path)` + the highlighted index. + LoadPicker { entries: Vec<(String, std::path::PathBuf)>, sel: usize }, + /// Save-as name entry: the text typed so far. + SaveName { buf: String }, +} + struct App { theme: Theme, tab: usize, @@ -142,9 +151,11 @@ 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 — `L` stages it (it can - /// rewrite many controls at once), Enter/`Y` applies, Esc/`N` discards. + /// 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: Modal, /// Inputs panel: stereo-pair view (default) vs. one row per channel. stereo_inputs: bool, status: Option<String>, @@ -172,6 +183,7 @@ impl App { routing_cursor: RoutingCursor::default(), routing_pending: None, pending_preset: None, + modal: Modal::None, stereo_inputs: true, status: None, show_help: false, @@ -181,6 +193,12 @@ impl App { } fn on_key(&mut self, code: KeyCode) { + // Modal overlays capture all input first. + if !matches!(self.modal, Modal::None) { + self.modal_key(code); + return; + } + // Global keys first. match code { KeyCode::Char('q') => { @@ -192,12 +210,19 @@ impl App { self.status = Some("reconnected".into()); return; } + // Preset save (name entry) and load (picker) — reachable from any tab. KeyCode::Char('S') => { - self.save_preset(); + self.modal = Modal::SaveName { buf: String::new() }; + self.status = Some("save preset as… (type a name, Enter to save)".into()); return; } - KeyCode::Char('L') => { - self.stage_preset(); + KeyCode::Char('L') | 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()); + } else { + self.modal = Modal::LoadPicker { entries, sel: 0 }; + } return; } KeyCode::Char('W') => { @@ -281,6 +306,53 @@ impl App { } } + /// Handle keys while a modal overlay (picker / name entry) is open. + fn modal_key(&mut self, code: KeyCode) { + match &mut self.modal { + Modal::LoadPicker { entries, sel } => match code { + KeyCode::Up | KeyCode::Char('k') => { + *sel = sel.saturating_sub(1); + } + KeyCode::Down | KeyCode::Char('j') => { + if *sel + 1 < entries.len() { + *sel += 1; + } + } + KeyCode::Enter => { + let path = entries[*sel].1.clone(); + self.modal = Modal::None; + self.stage_preset_path(&path); + } + 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 => { + buf.pop(); + } + KeyCode::Enter => { + let name = buf.trim().to_string(); + self.modal = Modal::None; + if name.is_empty() { + self.status = Some("save cancelled (empty name)".into()); + } else { + self.save_preset_as(&name); + } + } + KeyCode::Esc => { + self.modal = Modal::None; + self.status = Some("save cancelled".into()); + } + _ => {} + }, + Modal::None => {} + } + } + fn routing_key(&mut self, code: KeyCode) { let n = scarlett_core::mux::dest_pairs(&scarlett_core::mux::PORT_COUNT_18I20_GEN3).len(); match code { @@ -611,9 +683,9 @@ impl App { // ---- Persistence (S/L/W) ------------------------------------------------ - /// Capture the current state to `~/.config/valentine/presets/preset.json`. - /// Loads the mixer first if it hasn't been read yet, so the preset is complete. - fn save_preset(&mut self) { + /// Capture the current state to `~/.config/valentine/presets/<name>.json`. + /// Loads the mixer first if needed so the preset is complete. + fn save_preset_as(&mut self, name: &str) { let dev = match &mut self.device { Ok(d) => d, Err(_) => { @@ -624,20 +696,14 @@ impl App { if dev.mixer.is_empty() { dev.load_mixer(); } - let preset = Preset::from_state( - "valentine preset", - S18I20_GEN3.pid, - &dev.inputs, - &dev.monitor, - &dev.mixer, - ); - match preset_path() { + let preset = Preset::from_state(name, S18I20_GEN3.pid, &dev.inputs, &dev.monitor, &dev.mixer); + match preset_file(name) { Some(path) => { if let Some(parent) = path.parent() { let _ = std::fs::create_dir_all(parent); } match std::fs::write(&path, preset.to_json()) { - Ok(()) => self.status = Some(format!("preset saved → {}", path.display())), + Ok(()) => self.status = Some(format!("saved preset “{name}”")), Err(e) => self.status = Some(format!("save failed: {e}")), } } @@ -645,21 +711,13 @@ impl App { } } - /// Load the preset from disk and apply it to the device. - /// 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 => { - self.status = Some("load: no config dir".into()); - return; - } - }; - let text = match std::fs::read_to_string(&path) { + /// 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) { + let text = match std::fs::read_to_string(path) { Ok(t) => t, - Err(_) => { - self.status = Some(format!("no preset at {}", path.display())); + Err(e) => { + self.status = Some(format!("read failed: {e}")); return; } }; @@ -667,9 +725,7 @@ impl App { 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.status = Some(format!("load “{name}”? — Enter/Y to apply, Esc/N to cancel")); } Err(e) => self.status = Some(format!("preset parse error: {e}")), } @@ -754,9 +810,47 @@ fn set_switch_state(s: &mut InputState, col: Col, input: u8, on: bool) { } } -/// `~/.config/valentine/presets/preset.json` — the single preset slot. -fn preset_path() -> Option<std::path::PathBuf> { - dirs_next::config_dir().map(|d| d.join("valentine").join("presets").join("preset.json")) +/// The presets directory: `~/.config/valentine/presets/` (preferred) or the +/// platform config dir. Each preset is one `<name>.json` file. +fn presets_dir() -> Option<std::path::PathBuf> { + if let Some(home) = dirs_next::home_dir() { + return Some(home.join(".config").join("valentine").join("presets")); + } + dirs_next::config_dir().map(|d| d.join("valentine").join("presets")) +} + +/// Path for a preset of the given name (sanitized to a safe filename stem). +fn preset_file(name: &str) -> Option<std::path::PathBuf> { + let stem: String = name + .chars() + .map(|c| if c.is_alphanumeric() || c == '-' || c == '_' { c } else { '_' }) + .collect(); + let stem = if stem.is_empty() { "preset".to_string() } else { stem }; + presets_dir().map(|d| d.join(format!("{stem}.json"))) +} + +/// List available presets as `(display_name, path)`, sorted by name. +fn list_presets() -> Vec<(String, std::path::PathBuf)> { + let mut out = Vec::new(); + if let Some(dir) = presets_dir() { + if let Ok(rd) = std::fs::read_dir(&dir) { + for entry in rd.flatten() { + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) == Some("json") { + // Prefer the preset's own name field; fall back to file stem. + let name = std::fs::read_to_string(&path) + .ok() + .and_then(|t| Preset::from_json(&t).ok()) + .map(|p| p.name) + .or_else(|| path.file_stem().and_then(|s| s.to_str()).map(String::from)) + .unwrap_or_else(|| "preset".into()); + out.push((name, path)); + } + } + } + } + out.sort_by(|a, b| a.0.to_lowercase().cmp(&b.0.to_lowercase())); + out } fn describe(e: TransportError) -> String { @@ -902,11 +996,93 @@ fn ui(f: &mut Frame, app: &App) { f.render_widget(status_bar(app), chunks[3]); + match &app.modal { + Modal::LoadPicker { entries, sel } => preset_picker(f, t, entries, *sel), + Modal::SaveName { buf } => save_name_modal(f, t, buf), + Modal::None => {} + } + if app.show_help { help_overlay(f, t); } } +/// Centered helper to build a modal rect. +fn modal_rect(area: Rect, w: u16, h: u16) -> Rect { + let w = w.min(area.width.saturating_sub(2)); + let h = h.min(area.height.saturating_sub(2)); + Rect { + x: (area.width.saturating_sub(w)) / 2, + y: (area.height.saturating_sub(h)) / 2, + width: w, + height: h, + } +} + +/// The preset picker overlay (load from any screen). +fn preset_picker(f: &mut Frame, t: &Theme, entries: &[(String, std::path::PathBuf)], sel: usize) { + let area = f.area(); + let h = (entries.len() as u16 + 4).min(area.height.saturating_sub(2)).max(5); + let rect = modal_rect(area, 48, h); + f.render_widget(ratatui::widgets::Clear, rect); + let block = Block::default() + .title(Span::styled(" load 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 mut lines: Vec<Line> = Vec::new(); + for (i, (name, _)) in entries.iter().enumerate() { + let here = i == sel; + let style = if here { + Style::default().fg(t.accent).add_modifier(Modifier::BOLD).bg(t.bg_selected) + } else { + Style::default().fg(t.fg) + }; + lines.push(Line::from(Span::styled( + format!("{} {}", if here { "▸" } else { " " }, name), + style, + ))); + } + lines.push(Line::from("")); + lines.push(Line::from(Span::styled( + "↑↓ select Enter load Esc cancel", + Style::default().fg(t.fg_dim), + ))); + f.render_widget(Paragraph::new(lines), inner); +} + +/// The save-as name-entry overlay. +fn save_name_modal(f: &mut Frame, t: &Theme, buf: &str) { + let area = f.area(); + let rect = modal_rect(area, 44, 5); + f.render_widget(ratatui::widgets::Clear, rect); + let block = Block::default() + .title(Span::styled(" save preset as ", 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("name: ", Style::default().fg(t.fg_dim)), + Span::styled(buf.to_string(), Style::default().fg(t.fg)), + Span::styled("▏", Style::default().fg(t.accent)), // cursor + ]), + Line::from(""), + Line::from(Span::styled( + "type a name Enter save Esc cancel", + Style::default().fg(t.fg_dim), + )), + ]; + f.render_widget(Paragraph::new(lines), inner); +} + /// A centered modal listing every key binding (toggled with `?`). fn help_overlay(f: &mut Frame, t: &Theme) { let area = f.area(); @@ -950,8 +1126,8 @@ fn help_overlay(f: &mut Frame, t: &Theme) { key("←→", "routing: change a destination's source (Off = unrouted)"), Line::from(""), head("Presets & device"), - key("S", "save current config to a preset file"), - key("L", "load preset (Enter/Y applies, Esc/N cancels)"), + key("S", "save current config as a named preset"), + key("L / p", "preset picker — choose, Enter loads (confirm to apply)"), key("W", "write current config to device NVRAM"), key("r", "reconnect to the device"), Line::from(""), @@ -1019,7 +1195,7 @@ fn status_bar(app: &App) -> Paragraph<'_> { } spans.push(Span::styled( - " Tab panels · S save · L load · W →NVRAM · ? help · q quit", + " Tab panels · S save · L/p presets · W →NVRAM · ? help · q quit", Style::default().fg(t.fg_dim).bg(t.bg_elevated), ));