onedrop_gui/
settings.rs

1//! Persistent user settings for the OneDrop GUI.
2//!
3//! Stored at `${XDG_CONFIG_HOME:-~/.config}/onedrop/settings.toml`.
4//! The file is loaded once at startup; the Options overlay calls
5//! [`Settings::save`] whenever the user tweaks a value so the change
6//! survives the next launch.
7//!
8//! Runtime state that isn't user-authored (ratings, history) lives
9//! under [`data_dir()`] — `${XDG_DATA_HOME:-~/.local/share}/onedrop/`.
10//! Use [`data_path`] to resolve a leaf file inside it.
11
12use serde::{Deserialize, Serialize};
13use std::path::PathBuf;
14
15/// User-facing knobs persisted between launches.
16///
17/// Every field is `pub` so the Options UI can edit them in place. We
18/// intentionally don't validate ranges here — the renderer setters
19/// (`set_mesh_size`, etc.) clamp at apply time.
20#[derive(Debug, Default, Clone, Serialize, Deserialize)]
21pub struct Settings {
22    /// Visuals tab.
23    #[serde(default)]
24    pub visuals: VisualsSettings,
25    /// Engine tab.
26    #[serde(default)]
27    pub engine: EngineSettings,
28    /// Playback tab.
29    #[serde(default)]
30    pub playback: PlaybackSettings,
31    /// Audio tab.
32    #[serde(default)]
33    pub audio: AudioSettings,
34    /// Window tab.
35    #[serde(default)]
36    pub window: WindowSettings,
37    /// Preset library behaviour: selection mode, transition feel,
38    /// history depth. Persisted ratings live separately under
39    /// [`data_path`] so the user-facing config stays human-curated.
40    #[serde(default)]
41    pub presets: PresetsSettings,
42    /// Keymap overrides. Empty by default — the GUI's built-in
43    /// MD2-style table applies until the user rebinds something.
44    #[serde(default)]
45    pub keymap: KeymapSettings,
46}
47
48#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct VisualsSettings {
50    /// Warp mesh column count. MD2 caps at 192.
51    pub mesh_cols: u32,
52    /// Warp mesh row count. MD2 caps at 96.
53    pub mesh_rows: u32,
54    /// VSync on at boot (changing this requires surface reconfigure;
55    /// V1.0 honours it at startup only — flagged "restart" in the UI).
56    pub vsync: bool,
57    /// Frame-rate cap. `0` = unlimited. Honoured by the renderer's
58    /// `RenderConfig::target_fps`. Currently advisory — the frame loop
59    /// doesn't gate on it yet, but it gets persisted for V1.1.
60    pub target_fps: u32,
61    /// MSAA sample count. Pipeline-bound, requires restart in V1.0.
62    pub msaa_samples: u32,
63}
64
65#[derive(Debug, Clone, Serialize, Deserialize)]
66pub struct EngineSettings {
67    /// Mirror of `EngineConfig::enable_per_frame`. Hot-toggleable.
68    pub enable_per_frame: bool,
69    /// Mirror of `EngineConfig::enable_per_pixel`. Hot-toggleable but
70    /// expensive — applied on the next preset load in practice.
71    pub enable_per_pixel: bool,
72    /// Mirror of `EngineConfig::transition_duration_s`. `0` = instant
73    /// cut. MD2 default is 2.7s.
74    pub transition_duration_s: f32,
75    /// Mirror of `EngineConfig::profile`. Off in production; on enables
76    /// the per-phase timing logger.
77    pub profile: bool,
78}
79
80impl Default for VisualsSettings {
81    fn default() -> Self {
82        // 48×36 ("Medium" quality) matches RenderConfig::default().
83        Self {
84            mesh_cols: 48,
85            mesh_rows: 36,
86            vsync: true,
87            target_fps: 60,
88            msaa_samples: 1,
89        }
90    }
91}
92
93impl Default for EngineSettings {
94    fn default() -> Self {
95        Self {
96            enable_per_frame: true,
97            enable_per_pixel: false,
98            transition_duration_s: 2.7,
99            profile: false,
100        }
101    }
102}
103
104#[derive(Debug, Clone, Serialize, Deserialize)]
105pub struct PlaybackSettings {
106    /// Beat detection mode by name — `Off`, `HardCut1`..`HardCut6`.
107    /// Stored as a string so we don't have to take a serde dep on the
108    /// engine just for this enum.
109    pub beat_detection_mode: String,
110}
111
112impl Default for PlaybackSettings {
113    fn default() -> Self {
114        Self {
115            beat_detection_mode: "Off".to_string(),
116        }
117    }
118}
119
120#[derive(Debug, Default, Clone, Serialize, Deserialize)]
121pub struct AudioSettings {
122    /// Preferred input device name, as reported by cpal's `name()`.
123    /// `None` = use the OS default input device. If the named device
124    /// disappears (unplugged, renamed) the GUI falls back to default
125    /// with a warning logged.
126    #[serde(default)]
127    pub input_device: Option<String>,
128}
129
130#[derive(Debug, Clone, Serialize, Deserialize)]
131pub struct WindowSettings {
132    /// Render a corner HUD (FPS + preset name) over the visuals.
133    pub hud: bool,
134    /// Hide the mouse cursor after this many seconds of inactivity.
135    /// `0` disables auto-hide.
136    pub cursor_auto_hide_secs: f32,
137}
138
139impl Default for WindowSettings {
140    fn default() -> Self {
141        Self {
142            hud: true,
143            cursor_auto_hide_secs: 3.0,
144        }
145    }
146}
147
148/// Preset library settings — the bits of MD2's behaviour that survive
149/// across launches.
150#[derive(Debug, Clone, Serialize, Deserialize)]
151pub struct PresetsSettings {
152    /// `random` → pick the next preset by weighted random;
153    /// `sequential` → walk the queue in order. Cycled by `R`.
154    pub mode: PresetSelectionMode,
155    /// History ring depth (back-stack size). MD2's default is 64.
156    pub history_size: usize,
157    /// `true` → preset changes are blocked by the user (Scroll Lock).
158    /// Persisted so a locked session survives a restart.
159    pub locked: bool,
160}
161
162/// Random / sequential selection — what `R` toggles between.
163#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
164#[serde(rename_all = "lowercase")]
165pub enum PresetSelectionMode {
166    Random,
167    Sequential,
168}
169
170impl Default for PresetsSettings {
171    fn default() -> Self {
172        Self {
173            mode: PresetSelectionMode::Random,
174            history_size: 64,
175            locked: false,
176        }
177    }
178}
179
180/// Per-action keybind overrides, keyed by canonical action name
181/// (see `onedrop-gui::keymap::Action::as_str`). Each value is a
182/// human-readable binding string like `"Ctrl+Shift+K"` parsed by the
183/// keymap loader. Unrecognised actions are ignored; unbound actions
184/// inherit the GUI's built-in defaults.
185#[derive(Debug, Default, Clone, Serialize, Deserialize)]
186pub struct KeymapSettings {
187    #[serde(default)]
188    pub bindings: std::collections::BTreeMap<String, String>,
189}
190
191/// Resolve `${XDG_DATA_HOME:-~/.local/share}/onedrop/`. Returns
192/// `None` only in stripped-down sandboxes that don't expose a data
193/// dir. Callers should treat `None` as "don't persist" and keep going.
194pub fn data_dir() -> Option<PathBuf> {
195    dirs::data_dir().map(|p| p.join("onedrop"))
196}
197
198/// Resolve a leaf file inside [`data_dir`]. Convenience for the
199/// `ratings.toml` / `history.toml` callers so they don't all repeat
200/// the `.map(|d| d.join(...))` chain.
201pub fn data_path(leaf: &str) -> Option<PathBuf> {
202    data_dir().map(|d| d.join(leaf))
203}
204
205impl Settings {
206    /// Resolve `${XDG_CONFIG_HOME:-~/.config}/onedrop/settings.toml`.
207    /// Returns `None` if the OS doesn't expose a config dir (rare —
208    /// only happens in stripped-down sandboxes).
209    pub fn config_path() -> Option<PathBuf> {
210        dirs::config_dir().map(|p| p.join("onedrop").join("settings.toml"))
211    }
212
213    /// Load from disk. Missing file → defaults, no error (first launch
214    /// always lands here). Parse errors are logged and also fall back
215    /// to defaults so a corrupted file doesn't brick the GUI.
216    pub fn load() -> Self {
217        let Some(path) = Self::config_path() else {
218            log::warn!("No config dir available, using default settings");
219            return Self::default();
220        };
221        let text = match std::fs::read_to_string(&path) {
222            Ok(b) => b,
223            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
224                log::info!("No settings file at {}, using defaults", path.display());
225                return Self::default();
226            }
227            Err(e) => {
228                log::warn!("Failed to read {}: {} — using defaults", path.display(), e);
229                return Self::default();
230            }
231        };
232        match toml::from_str::<Settings>(&text) {
233            Ok(s) => {
234                log::info!("Loaded settings from {}", path.display());
235                s
236            }
237            Err(e) => {
238                log::warn!("Failed to parse {}: {} — using defaults", path.display(), e);
239                Self::default()
240            }
241        }
242    }
243
244    /// Best-effort serialize back to disk. Creates the parent dir if
245    /// missing. Failures are logged but never propagate — a write
246    /// failure shouldn't crash the visualiser mid-show.
247    pub fn save(&self) {
248        let Some(path) = Self::config_path() else {
249            log::warn!("No config dir available, skipping settings save");
250            return;
251        };
252        if let Some(parent) = path.parent()
253            && let Err(e) = std::fs::create_dir_all(parent)
254        {
255            log::warn!(
256                "Failed to create {}: {} — settings not saved",
257                parent.display(),
258                e
259            );
260            return;
261        }
262        match toml::to_string_pretty(self) {
263            Ok(s) => {
264                if let Err(e) = std::fs::write(&path, s) {
265                    log::warn!("Failed to write {}: {}", path.display(), e);
266                } else {
267                    log::debug!("Saved settings to {}", path.display());
268                }
269            }
270            Err(e) => log::warn!("Failed to serialize settings: {}", e),
271        }
272    }
273}