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}