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:
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)