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:
| M | valentine/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),
));