valentine

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

commit e9f1cc736c4236d848aa32a7dff8cf98b31a1af7
parent b8b9de94b30c3533c6af9f508834e31367e41dbc
Author: Matthew Gantenbein <ganten1998@gmail.com>
Date:   Sun, 31 May 2026 20:57:16 -0500

fix: reconcile inputs panel with real sources API; add source catalog

The previous commit rewrote inputs.rs against a sources::{Source,SourceType}
API that was never created, leaving the workspace uncompilable. Point the
panel at the actual scarlett-core::sources catalog (all 39 sources: analogue
incl. talkback, ADAT, S/PDIF, PCM), wire Device.sources + main.rs call sites,
and drop dead code. Also: lowercase wordmark/tabs, theme loads ~/.config first.

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

Diffstat:
M.gitignore | 1+
Mscarlett-core/src/lib.rs | 1+
Mscarlett-core/src/model.rs | 2+-
Ascarlett-core/src/sources.rs | 176+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mspike/src/bin/hwcheck.rs | 2+-
Mvalentine/src/main.rs | 69++++++++++++++++++++++++++++++++++++++++-----------------------------
Mvalentine/src/panels/clock.rs | 2+-
Mvalentine/src/panels/inputs.rs | 104++++++++++++++++++++++++++++++++++++++++++++-----------------------------------
Mvalentine/src/panels/meters.rs | 2+-
Mvalentine/src/panels/mixer.rs | 2+-
Mvalentine/src/panels/monitor.rs | 2+-
Mvalentine/src/panels/routing.rs | 2+-
Mvalentine/src/theme.rs | 21+++++++++++++++++++--
13 files changed, 302 insertions(+), 84 deletions(-)

diff --git a/.gitignore b/.gitignore @@ -1,3 +1,4 @@ /target **/*.rs.bk .DS_Store +.aider* diff --git a/scarlett-core/src/lib.rs b/scarlett-core/src/lib.rs @@ -27,6 +27,7 @@ pub mod packet; pub mod ports; pub mod preset; pub mod protocol; +pub mod sources; pub mod transport; pub use protocol::Scarlett; diff --git a/scarlett-core/src/model.rs b/scarlett-core/src/model.rs @@ -89,7 +89,7 @@ impl Param { /// A category of physical or virtual signal port. `(inputs, outputs)` counts are /// from the device descriptor — "inputs" are sources into the routing matrix, /// "outputs" are sinks out of it. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum PortType { Analogue, Spdif, diff --git a/scarlett-core/src/sources.rs b/scarlett-core/src/sources.rs @@ -0,0 +1,176 @@ +//! The full catalog of input *sources* on the device — every signal that can be +//! metered, named, faded into the monitor mix, and routed: analogue preamps, +//! ADAT, S/PDIF, and the DAW (PCM) returns. +//! +//! The old Inputs page only listed the 8 analogue preamps, so ADAT/SPDIF/PCM +//! sources were invisible. This module is the single source of truth for "what +//! inputs exist and what each can do", built from the device descriptor. + +use crate::model::{DeviceInfo, PortType}; + +/// One input source: its kind, zero-based index within that kind, and which +/// preamp features it supports (only analogue preamps have these). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Source { + pub kind: PortType, + /// 0-based index within the kind (analogue 0 = "Analogue 1"). + pub index: u16, + /// Default display name, e.g. "Analogue 1", "ADAT 3", "PCM 12". + pub name: String, + /// Preamp capabilities (false for everything except analogue preamps). + pub has_air: bool, + pub has_pad: bool, + pub has_inst: bool, + /// 48 V phantom group this source belongs to, if any (analogue only). + pub phantom_group: Option<u8>, +} + +impl Source { + /// True if this source has any preamp switch worth showing. + pub fn has_preamp(&self) -> bool { + self.has_air || self.has_pad || self.has_inst || self.phantom_group.is_some() + } +} + +fn kind_word(kind: PortType) -> &'static str { + match kind { + PortType::Analogue => "Analogue", + PortType::Spdif => "S/PDIF", + PortType::Adat => "ADAT", + PortType::Pcm => "PCM", + PortType::Mix => "Mix", + } +} + +/// Build the ordered source catalog for a device: analogue (incl. talkback as +/// the last analogue), then ADAT, S/PDIF, then PCM/DAW returns. +pub fn catalog(info: &DeviceInfo) -> Vec<Source> { + let mut out = Vec::new(); + + // Analogue sources (the descriptor's analogue *source* count includes the + // talkback mic as the last one on the 18i20 g3). + let analogue_srcs = info.port_count(PortType::Analogue).0 as u16; + for i in 0..analogue_srcs { + let is_talkback = info.has_talkback && i + 1 == analogue_srcs; + let name = if is_talkback { + "Talkback".to_string() + } else { + format!("Analogue {}", i + 1) + }; + out.push(Source { + kind: PortType::Analogue, + index: i, + name, + has_air: i < info.air_input_count as u16 && !is_talkback, + has_pad: i < info.pad_input_count as u16 && !is_talkback, + has_inst: i < info.level_input_count as u16 && !is_talkback, + phantom_group: if !is_talkback && info.inputs_per_phantom > 0 { + let g = (i / info.inputs_per_phantom as u16) as u8; + (g < info.phantom_count).then_some(g) + } else { + None + }, + }); + } + + // ADAT, then S/PDIF, then PCM — meter/route/name only, no preamp. + for kind in [PortType::Adat, PortType::Spdif, PortType::Pcm] { + let n = info.port_count(kind).0 as u16; + for i in 0..n { + out.push(Source { + kind, + index: i, + name: format!("{} {}", kind_word(kind), i + 1), + has_air: false, + has_pad: false, + has_inst: false, + phantom_group: None, + }); + } + } + + out +} + +/// A stereo pairing of catalog entries: consecutive odd/even within the same +/// kind (1-2, 3-4 …). An odd trailing source becomes a mono pair. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Pair { + pub left: usize, + /// `None` for a leftover mono source (or talkback). + pub right: Option<usize>, +} + +/// Group catalog indices into stereo pairs by kind. Talkback stays mono. +pub fn stereo_pairs(catalog: &[Source]) -> Vec<Pair> { + let mut pairs = Vec::new(); + let mut i = 0; + while i < catalog.len() { + let a = &catalog[i]; + let mono = a.name == "Talkback"; + if !mono + && i + 1 < catalog.len() + && catalog[i + 1].kind == a.kind + && a.index % 2 == 0 + && catalog[i + 1].index == a.index + 1 + { + pairs.push(Pair { left: i, right: Some(i + 1) }); + i += 2; + } else { + pairs.push(Pair { left: i, right: None }); + i += 1; + } + } + pairs +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::S18I20_GEN3; + + #[test] + fn catalog_includes_adat_spdif_pcm_not_just_analogue() { + let c = catalog(&S18I20_GEN3); + let kinds: std::collections::HashSet<_> = c.iter().map(|s| s.kind).collect(); + assert!(kinds.contains(&PortType::Analogue)); + assert!(kinds.contains(&PortType::Adat)); // the user's missing inputs + assert!(kinds.contains(&PortType::Spdif)); + assert!(kinds.contains(&PortType::Pcm)); + // 9 analogue + 8 adat + 2 spdif + 20 pcm = 39 + assert_eq!(c.len(), 9 + 8 + 2 + 20); + } + + #[test] + fn talkback_is_last_analogue_and_has_no_preamp() { + let c = catalog(&S18I20_GEN3); + let tb = c.iter().find(|s| s.name == "Talkback").unwrap(); + assert_eq!(tb.kind, PortType::Analogue); + assert!(!tb.has_preamp()); + } + + #[test] + fn analogue_1_has_full_preamp_adat_has_none() { + let c = catalog(&S18I20_GEN3); + let a1 = &c[0]; + assert_eq!(a1.name, "Analogue 1"); + assert!(a1.has_air && a1.has_pad && a1.has_inst); + assert_eq!(a1.phantom_group, Some(0)); + + let adat = c.iter().find(|s| s.kind == PortType::Adat).unwrap(); + assert!(!adat.has_preamp()); + } + + #[test] + fn stereo_pairs_group_odd_even_within_kind() { + let c = catalog(&S18I20_GEN3); + let pairs = stereo_pairs(&c); + // Analogue 1-2 should be a stereo pair. + let first = &pairs[0]; + assert_eq!(c[first.left].name, "Analogue 1"); + assert_eq!(first.right.map(|r| c[r].name.clone()), Some("Analogue 2".into())); + // Talkback (9th analogue, odd one out) must be mono. + let tb_pair = pairs.iter().find(|p| c[p.left].name == "Talkback").unwrap(); + assert_eq!(tb_pair.right, None); + } +} diff --git a/spike/src/bin/hwcheck.rs b/spike/src/bin/hwcheck.rs @@ -6,7 +6,7 @@ //! It is careful: it records each switch's original value and restores it, so //! your device ends up exactly as it started. -use scarlett_core::controls::{InputSwitch, MonitorButton}; +use scarlett_core::controls::InputSwitch; use scarlett_core::model::S18I20_GEN3; use scarlett_core::{Scarlett, UsbTransport}; diff --git a/valentine/src/main.rs b/valentine/src/main.rs @@ -50,6 +50,8 @@ struct Device { mixer: Vec<Vec<f32>>, /// Decoded `(sink, source)` routing; loaded on demand when the Routing tab opens. routing: Vec<(String, String)>, + /// The full input source catalog (analogue/ADAT/SPDIF/PCM) shown on Inputs. + sources: Vec<scarlett_core::sources::Source>, } impl Device { @@ -68,6 +70,7 @@ impl Device { meters: Vec::new(), mixer: Vec::new(), routing: Vec::new(), + sources: scarlett_core::sources::catalog(&S18I20_GEN3), }) } @@ -328,7 +331,10 @@ impl App { fn inputs_key(&mut self, code: KeyCode) { match code { KeyCode::Up | KeyCode::Char('k') => self.input_cursor.up(), - KeyCode::Down | KeyCode::Char('j') => self.input_cursor.down(), + KeyCode::Down | KeyCode::Char('j') => { + let n = self.device.as_ref().map(|d| d.sources.len()).unwrap_or(0); + 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(' ') | KeyCode::Enter => self.toggle_focused_switch(), @@ -339,38 +345,52 @@ impl App { fn toggle_focused_switch(&mut self) { let cursor = self.input_cursor; let col = cursor.current_col(); - let input = cursor.input; 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 => { - let group = input / S18I20_GEN3.inputs_per_phantom; - 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; - } - }) - } + 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; + } + }, other => { - let sw = col_switch(other).expect("non-P48 columns map to a switch"); - if !applies(other, input) { - self.status = Some(format!("{:?} not available on input {}", other, input + 1)); + if !Col::applies_to(other, &source) { + self.status = Some(format!("{:?} not available on {name}", other)); return; } - let cur = switch_state(&dev.inputs, other, input); - dev.scarlett.set_input_switch(sw, input, !cur).map(|_| { - set_switch_state(&mut dev.inputs, other, input, !cur); + 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); }) } }; match result { - Ok(()) => self.status = Some(format!("{:?} input {} toggled", col, input + 1)), + Ok(()) => self.status = Some(format!("{:?} · {name} toggled", col)), Err(e) => self.status = Some(format!("toggle failed: {e}")), } } @@ -485,15 +505,6 @@ impl App { } } -fn applies(col: Col, input: u8) -> bool { - match col { - Col::Inst => input < S18I20_GEN3.level_input_count, - Col::Air => input < S18I20_GEN3.air_input_count, - Col::Pad => input < S18I20_GEN3.pad_input_count, - Col::P48 => true, - } -} - fn switch_state(s: &InputState, col: Col, input: u8) -> bool { let i = input as usize; match col { @@ -616,7 +627,7 @@ fn ui(f: &mut Frame, app: &App) { // Body match (&app.device, app.tab) { (Ok(dev), 0) => { - inputs::render(f, chunks[2], t, &dev.inputs, app.input_cursor, true); + inputs::render(f, chunks[2], t, &dev.inputs, &dev.sources, app.input_cursor, true); } (Ok(dev), 1) => { monitor::render(f, chunks[2], t, &dev.monitor, app.monitor_cursor, true); @@ -667,7 +678,7 @@ fn help_overlay(f: &mut Frame, t: &Theme) { f.render_widget(ratatui::widgets::Clear, rect); let block = Block::default() - .title(Span::styled(" Help ", Style::default().fg(t.accent))) + .title(Span::styled(" help ", Style::default().fg(t.accent))) .borders(Borders::ALL) .border_type(ratatui::widgets::BorderType::Rounded) .border_style(Style::default().fg(t.border_focus)) diff --git a/valentine/src/panels/clock.rs b/valentine/src/panels/clock.rs @@ -11,7 +11,7 @@ use crate::theme::Theme; pub fn render(f: &mut Frame, area: Rect, theme: &Theme, locked: bool, focused: bool) { let border = if focused { theme.border_focus } else { theme.border }; let block = Block::default() - .title(Span::styled(" Clock ", Style::default().fg(theme.accent))) + .title(Span::styled(" clock ", Style::default().fg(theme.accent))) .borders(Borders::ALL) .border_type(ratatui::widgets::BorderType::Rounded) .border_style(Style::default().fg(border)); diff --git a/valentine/src/panels/inputs.rs b/valentine/src/panels/inputs.rs @@ -1,16 +1,17 @@ -//! The Inputs panel — a navigable grid of the per-channel preamp switches -//! (Inst/Line, Air, Pad, 48V phantom) for all input types. Amber = engaged. +//! The inputs panel — every input source (analogue preamps, ADAT, S/PDIF, and +//! DAW/PCM returns), one row each. Preamp switches (Inst / Air / Pad / 48V) show +//! only on the sources that actually have them; other sources still appear so +//! they can be seen, named, and (later) routed. Amber = engaged. //! -//! Arrow keys move the cursor; Space/Enter toggles the focused switch via the -//! `scarlett-core` control layer. 48V is per phantom *group* (inputs 1–4, 5–8), -//! so toggling it on any input in a group flips the whole group. +//! Arrow keys move the cursor; Space/Enter toggles the focused preamp switch via +//! the `scarlett-core` control layer. 48V is per phantom *group*, so toggling it +//! on any input in a group flips the whole group. use ratatui::prelude::*; use ratatui::widgets::{Block, Borders, Paragraph}; use scarlett_core::controls::{InputState, InputSwitch}; -use scarlett_core::model::{DeviceInfo, S18I20_GEN3}; -use scarlett_core::sources::{Source, SourceType}; +use scarlett_core::sources::Source; use crate::theme::Theme; @@ -35,36 +36,32 @@ impl Col { } } - /// Whether this switch exists for the given 0-based input. - fn applies_to(self, source: &Source) -> bool { + /// Whether this switch exists for the given source. + pub fn applies_to(self, source: &Source) -> bool { match self { - Col::Inst => source.preamp_capabilities.inst, - Col::Air => source.preamp_capabilities.air, - Col::Pad => source.preamp_capabilities.pad, - Col::P48 => true, + Col::Inst => source.has_inst, + Col::Air => source.has_air, + Col::Pad => source.has_pad, + Col::P48 => source.phantom_group.is_some(), } } } /// Cursor position within the grid. -#[derive(Debug, Clone, Copy)] +#[derive(Debug, Clone, Copy, Default)] pub struct Cursor { + /// Index into the source catalog (the visible row). pub source_index: usize, + /// Column within [`Col::ALL`]. pub col: usize, } -impl Default for Cursor { - fn default() -> Self { - Cursor { source_index: 0, col: 1 } // start on AIR of the first source - } -} - impl Cursor { pub fn up(&mut self) { self.source_index = self.source_index.saturating_sub(1); } - pub fn down(&mut self, sources: &[Source]) { - if self.source_index + 1 < sources.len() { + pub fn down(&mut self, source_count: usize) { + if self.source_index + 1 < source_count { self.source_index += 1; } } @@ -84,14 +81,15 @@ impl Cursor { /// Is the switch at (source, col) currently on, per `state`? fn is_on(state: &InputState, source: &Source, col: Col) -> bool { + let i = source.index as usize; match col { - Col::Inst => state.inst.get(source.index as usize).copied().unwrap_or(false), - Col::Air => state.air.get(source.index as usize).copied().unwrap_or(false), - Col::Pad => state.pad.get(source.index as usize).copied().unwrap_or(false), - Col::P48 => { - let group = (source.index / S18I20_GEN3.inputs_per_phantom) as usize; - state.phantom.get(group).copied().unwrap_or(false) - } + Col::Inst => state.inst.get(i).copied().unwrap_or(false), + Col::Air => state.air.get(i).copied().unwrap_or(false), + Col::Pad => state.pad.get(i).copied().unwrap_or(false), + Col::P48 => match source.phantom_group { + Some(g) => state.phantom.get(g as usize).copied().unwrap_or(false), + None => false, + }, } } @@ -105,7 +103,7 @@ pub fn col_switch(col: Col) -> Option<InputSwitch> { } } -/// Render the input grid into `area`. +/// Render the input grid into `area`. `sources` is the device's source catalog. pub fn render( f: &mut Frame, area: Rect, @@ -124,11 +122,19 @@ pub fn render( let inner = block.inner(area); f.render_widget(block, area); + // Vertical scroll so the cursor row stays visible (39 sources > screen). + let visible = (inner.height as usize).saturating_sub(3).max(1); // header+blank+help + let start = cursor + .source_index + .saturating_sub(visible - 1) + .min(sources.len().saturating_sub(visible).max(0)); + let end = (start + visible).min(sources.len()); + let mut lines: Vec<Line> = Vec::new(); // Header row. let mut header = vec![Span::styled( - format!("{:<10}", "Source"), + format!("{:<12}", "source"), Style::default().fg(theme.fg_dim), )]; for col in Col::ALL { @@ -138,22 +144,26 @@ pub fn render( )); } lines.push(Line::from(header)); - lines.push(Line::from("")); - // One row per source. - for (si, source) in sources.iter().enumerate() { - let label = format!("{} {}", source.name, if source.stereo_pair { "(Stereo)" } else { "" }); - let mut row = vec![Span::styled( - format!("{label:<10}"), - Style::default().fg(theme.fg), - )]; + // 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 { + 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)]; for (ci, col) in Col::ALL.iter().enumerate() { let col = *col; let here = focused && cursor.source_index == si && cursor.col == ci; let cell = if !col.applies_to(source) { - Span::styled(format!("{:^7}", "·"), Style::default().fg(theme.fg_dim)) + 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 glyph = if on { "● ON" } else { " ·" }; @@ -163,11 +173,9 @@ pub fn render( Style::default().fg(theme.fg_dim) }; if here { - style = style.bg(theme.bg_selected).fg(if on { - theme.armed - } else { - theme.accent - }); + style = style + .bg(theme.bg_selected) + .fg(if on { theme.armed } else { theme.accent }); } Span::styled(format!("{glyph:^7}"), style) }; @@ -178,7 +186,11 @@ pub fn render( lines.push(Line::from("")); lines.push(Line::from(Span::styled( - "↑↓ source ←→ switch space/enter toggle", + format!( + "↑↓ source ←→ switch space/enter toggle [{}/{}]", + cursor.source_index + 1, + sources.len() + ), Style::default().fg(theme.fg_dim), ))); diff --git a/valentine/src/panels/meters.rs b/valentine/src/panels/meters.rs @@ -18,7 +18,7 @@ pub fn render(f: &mut Frame, area: Rect, theme: &Theme, meters: &[u32], focused: let border = if focused { theme.border_focus } else { theme.border }; let block = Block::default() .title(Span::styled( - format!(" Meters ({}) ", meters.len()), + format!(" meters ({}) ", meters.len()), Style::default().fg(theme.accent), )) .borders(Borders::ALL) diff --git a/valentine/src/panels/mixer.rs b/valentine/src/panels/mixer.rs @@ -71,7 +71,7 @@ pub fn render( ) { let border = if focused { theme.border_focus } else { theme.border }; let block = Block::default() - .title(Span::styled(" Mixer ", Style::default().fg(theme.accent))) + .title(Span::styled(" mixer ", Style::default().fg(theme.accent))) .borders(Borders::ALL) .border_type(ratatui::widgets::BorderType::Rounded) .border_style(Style::default().fg(border)); diff --git a/valentine/src/panels/monitor.rs b/valentine/src/panels/monitor.rs @@ -73,7 +73,7 @@ pub fn render( ) { let border = if focused { theme.border_focus } else { theme.border }; let block = Block::default() - .title(Span::styled(" Monitor ", Style::default().fg(theme.accent))) + .title(Span::styled(" monitor ", Style::default().fg(theme.accent))) .borders(Borders::ALL) .border_type(ratatui::widgets::BorderType::Rounded) .border_style(Style::default().fg(border)); diff --git a/valentine/src/panels/routing.rs b/valentine/src/panels/routing.rs @@ -39,7 +39,7 @@ pub fn render( ) { let border = if focused { theme.border_focus } else { theme.border }; let block = Block::default() - .title(Span::styled(" Routing ", Style::default().fg(theme.accent))) + .title(Span::styled(" routing ", Style::default().fg(theme.accent))) .borders(Borders::ALL) .border_type(ratatui::widgets::BorderType::Rounded) .border_style(Style::default().fg(border)); diff --git a/valentine/src/theme.rs b/valentine/src/theme.rs @@ -92,9 +92,12 @@ impl From<RawTheme> for Theme { impl Theme { /// Load the user override if present, else the bundled Ember default. + /// + /// Checks `~/.config/valentine/theme.toml` first (XDG-style, where the rest of + /// the user's configs live), then the platform config dir (on macOS that's + /// `~/Library/Application Support`). The first that parses wins. pub fn load() -> Self { - if let Some(dir) = dirs_next::config_dir() { - let path = dir.join("valentine").join("theme.toml"); + for path in theme_paths() { if let Ok(text) = std::fs::read_to_string(&path) { if let Ok(raw) = toml::from_str::<RawTheme>(&text) { return raw.into(); @@ -116,6 +119,20 @@ impl Theme { } } +/// Candidate theme-override locations, in priority order. +fn theme_paths() -> Vec<std::path::PathBuf> { + let mut v = Vec::new(); + // 1. ~/.config/valentine/theme.toml (XDG-style; honoured on macOS too) + if let Some(home) = dirs_next::home_dir() { + v.push(home.join(".config").join("valentine").join("theme.toml")); + } + // 2. platform config dir (macOS: ~/Library/Application Support) + if let Some(dir) = dirs_next::config_dir() { + v.push(dir.join("valentine").join("theme.toml")); + } + v +} + impl Default for Theme { fn default() -> Self { toml::from_str::<RawTheme>(DEFAULT_THEME_TOML)