Skip to main content

dspk_app_core/data/
preferences.rs

1//! What the app remembers between runs: recent recordings (newest first), the workspace shown.
2//!
3//! Its own file, `$XDG_CONFIG_HOME/dspk-app/workbench.json`, so the Slint app (which keeps
4//! `session.json` in the same folder) and this one never overwrite each other. On a first run the
5//! recent list is taken from the Slint app's file.
6
7use std::path::{Path, PathBuf};
8
9use serde::{Deserialize, Serialize};
10
11use crate::screens::explore::trace::renderer::TimeViewKind;
12use crate::screens::explore::trace::view::TimeView;
13use crate::navigation::route::Workspace;
14
15/// Recent recordings kept.
16const RECENT: usize = 10;
17
18/// Saved per-view settings in the Explore workspace.
19#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
20pub struct SavedTimeView {
21    pub kind: TimeViewKind,
22    #[serde(default)]
23    pub source: String,
24    #[serde(default = "default_lanes")]
25    pub lanes: usize,
26    #[serde(default = "default_gain")]
27    pub gain: f32,
28    #[serde(default = "yes")]
29    pub auto_scale: bool,
30    #[serde(default = "yes")]
31    pub remove_dc: bool,
32    #[serde(default)]
33    pub pinned_selection: bool,
34    #[serde(default = "yes")]
35    pub spikes: bool,
36    #[serde(default)]
37    pub color_by: crate::screens::explore::trace::renderer::ColorBy,
38    #[serde(default = "yes")]
39    pub events: bool,
40    #[serde(default)]
41    pub events_only: bool,
42    /// A view of another loaded recording (see `TimeView::recording`).
43    #[serde(default, skip_serializing_if = "Option::is_none")]
44    pub recording: Option<std::path::PathBuf>,
45}
46
47impl SavedTimeView {
48    pub fn from_view(v: &TimeView) -> Self {
49        Self {
50            kind: v.kind,
51            source: v.source.clone(),
52            lanes: v.lanes,
53            gain: v.gain,
54            auto_scale: v.auto_scale,
55            remove_dc: v.remove_dc,
56            pinned_selection: v.pinned_selection,
57            spikes: v.spikes,
58            color_by: v.color_by,
59            recording: v.recording.clone(),
60            events: v.events,
61            events_only: v.events_only,
62        }
63    }
64
65    pub fn apply(&self, v: &mut TimeView) {
66        v.lanes = self.lanes.max(1);
67        v.gain = self.gain.clamp(0.1, 20.0);
68        v.auto_scale = self.auto_scale;
69        v.remove_dc = self.remove_dc;
70        v.pinned_selection = self.pinned_selection;
71        v.spikes = self.spikes;
72        v.color_by = self.color_by;
73        v.recording = self.recording.clone();
74        v.events = self.events;
75        v.events_only = self.events_only;
76        v.needs_render = true;
77    }
78}
79
80fn default_lanes() -> usize {
81    8
82}
83fn default_gain() -> f32 {
84    1.0
85}
86fn yes() -> bool {
87    true
88}
89
90/// Saved Explore workspace layout (side-dock states and open views).
91#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
92pub struct ExploreSession {
93    #[serde(default = "yes")]
94    pub left_open: bool,
95    #[serde(default = "yes")]
96    pub right_open: bool,
97    #[serde(default = "yes")]
98    pub bottom_open: bool,
99    #[serde(default)]
100    pub views: Vec<SavedTimeView>,
101    /// The panel shown on the right side bar (`processing`, `display`, `plugin0`, …).
102    #[serde(default)]
103    pub right_panel: String,
104    /// The Pipeline docked under the views.
105    #[serde(default)]
106    pub pipeline_dock: bool,
107}
108
109impl Default for ExploreSession {
110    fn default() -> Self {
111        Self { left_open: true, right_open: true, bottom_open: true, views: Vec::new(), right_panel: String::new(), pipeline_dock: false }
112    }
113}
114
115#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
116pub struct Session {
117    pub version: u32,
118    #[serde(default)]
119    pub recent: Vec<PathBuf>,
120    #[serde(default)]
121    pub recent_sortings: Vec<PathBuf>,
122    #[serde(default)]
123    pub workspace: Workspace,
124    /// `Some(true)`: dark, `Some(false)`: light, `None`: follow the system.
125    #[serde(default)]
126    pub dark: Option<bool>,
127    /// "Tune this device" was suggested (once, at the first launch).
128    #[serde(default)]
129    pub tune_suggested: bool,
130    /// The accent the user picked (`None`: dsp-kitchen's lime). Saved as `"#rrggbb"`; a number
131    /// (`0xRRGGBB`) or a colour that does not parse still reads.
132    #[serde(default, deserialize_with = "accent_or_old", skip_serializing_if = "Option::is_none")]
133    pub accent: Option<kitchen_ui::Rgb>,
134    /// Keep plots dark in the light theme.
135    #[serde(default)]
136    pub dark_plots: bool,
137    #[serde(default)]
138    pub explore: ExploreSession,
139    /// Widths (heights for bottom panels) of resizable panels, by key (`explore.left`, …).
140    #[serde(default)]
141    pub panel_widths: std::collections::BTreeMap<String, f32>,
142    /// Side panels shown or hidden, by key (`sorting.left`, …).
143    #[serde(default)]
144    pub panel_open: std::collections::BTreeMap<String, bool>,
145    /// Servers (`dspk serve`) the user works with (Settings).
146    #[serde(default)]
147    pub servers: Vec<crate::data::remote::ServerProfile>,
148    /// Where results fetched from servers go (`None`: [`Session::default_results_dir`]).
149    #[serde(default)]
150    pub results_dir: Option<PathBuf>,
151    /// The compute runtime (`wgpu`, `cuda`, `cpu`; `None`: the first that is compiled in). Takes
152    /// effect at the next start.
153    #[serde(default)]
154    pub runtime: Option<String>,
155    /// Open the last recording when the app starts (default off: the start screen).
156    #[serde(default)]
157    pub reopen_last: bool,
158}
159
160/// `"#rrggbb"`, `null`, or a colour as a number (lime, the app's own accent, is `None`); anything
161/// else is `None` rather than an error, so the rest of the session still loads.
162fn accent_or_old<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Option<kitchen_ui::Rgb>, D::Error> {
163    let own = |rgb: kitchen_ui::Rgb| (rgb != kitchen_ui::Rgb(0xc6f26b)).then_some(rgb);
164    Ok(match serde_json::Value::deserialize(d)? {
165        serde_json::Value::String(t) => kitchen_ui::Rgb::parse(&t).and_then(own),
166        serde_json::Value::Number(n) => n.as_u64().and_then(|v| own(kitchen_ui::Rgb(v as u32 & 0xffffff))),
167        _ => None,
168    })
169}
170
171impl Session {
172    pub const VERSION: u32 = 1;
173
174    pub fn new() -> Self {
175        Self { version: Self::VERSION, ..Self::default() }
176    }
177
178    /// Results fetched from servers by default: `~/dspk/remote` (the home folder, or the current
179    /// one when there is no home).
180    pub fn default_results_dir() -> PathBuf {
181        dirs::home_dir().unwrap_or_default().join("dspk").join("remote")
182    }
183
184    /// Where results fetched from servers go.
185    pub fn results_dir(&self) -> PathBuf {
186        self.results_dir.clone().unwrap_or_else(Self::default_results_dir)
187    }
188
189    /// The user's recipe library: `<config>/dspk-app/recipes/`.
190    pub fn recipes_dir() -> Option<PathBuf> {
191        Some(Self::config_dir()?.join("dspk-app").join("recipes"))
192    }
193
194    fn config_dir() -> Option<PathBuf> {
195        std::env::var_os("XDG_CONFIG_HOME")
196            .map(PathBuf::from)
197            .or_else(|| std::env::var_os("APPDATA").map(PathBuf::from))
198            .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config")))
199    }
200
201    pub fn path() -> Option<PathBuf> {
202        Some(Self::config_dir()?.join("dspk-app").join("workbench.json"))
203    }
204
205    /// The session document of the open work (views, op chains, linked sortings), next to the
206    /// preferences: `current-session.json`. An agent edits it; the app picks the edits up.
207    pub fn document_path() -> Option<PathBuf> {
208        Some(Self::config_dir()?.join("dspk-app").join("current-session.json"))
209    }
210
211    /// The saved session; else the Slint app's recent files; else an empty one.
212    pub fn load(path: Option<&Path>) -> Self {
213        let read = |p: &Path| std::fs::read_to_string(p).ok();
214        if let Some(s) = path.and_then(read).and_then(|t| serde_json::from_str::<Session>(&t).ok()).filter(|s| s.version == Self::VERSION) {
215            return s;
216        }
217        let slint = path.and_then(|p| p.parent()).map(|d| d.join("session.json"));
218        let recent = slint
219            .as_deref()
220            .and_then(read)
221            .and_then(|t| serde_json::from_str::<serde_json::Value>(&t).ok())
222            .and_then(|v| serde_json::from_value::<Vec<PathBuf>>(v.get("recent")?.clone()).ok())
223            .unwrap_or_default();
224        Self { recent, ..Self::new() }
225    }
226
227    pub fn save(&self, path: Option<&Path>) -> std::io::Result<()> {
228        let Some(path) = path else { return Ok(()) };
229        if let Some(dir) = path.parent() {
230            std::fs::create_dir_all(dir)?;
231        }
232        std::fs::write(path, serde_json::to_string_pretty(self).map_err(std::io::Error::other)?)
233    }
234
235    /// Forgets `path` from the recent recordings and sortings.
236    pub fn forget_recent(&mut self, path: &Path) {
237        self.recent.retain(|p| p != path);
238        self.recent_sortings.retain(|p| p != path);
239    }
240
241    /// Forgets every recent recording and sorting.
242    pub fn clear_recent(&mut self) {
243        self.recent.clear();
244        self.recent_sortings.clear();
245    }
246
247    /// `path` becomes the newest recent recording.
248    pub fn push_recent(&mut self, path: &Path) {
249        self.recent.retain(|p| p != path);
250        self.recent.insert(0, path.to_path_buf());
251        self.recent.truncate(RECENT);
252    }
253
254    /// `path` becomes the newest recent sorting folder.
255    #[allow(dead_code)] // Curation (parked) opens sortings
256    pub fn push_recent_sorting(&mut self, path: &Path) {
257        self.recent_sortings.retain(|p| p != path);
258        self.recent_sortings.insert(0, path.to_path_buf());
259        self.recent_sortings.truncate(RECENT);
260    }
261}
262
263#[cfg(test)]
264mod tests {
265    use super::*;
266
267    fn scratch(name: &str) -> PathBuf {
268        let p = std::env::temp_dir().join(format!("dspk-app-session-{name}-{}", std::process::id()));
269        let _ = std::fs::remove_dir_all(&p);
270        std::fs::create_dir_all(&p).unwrap();
271        p
272    }
273
274    #[test]
275    fn test_round_trip_and_recent_order() {
276        let dir = scratch("round");
277        let path = dir.join("workbench.json");
278        let mut s = Session::new();
279        for p in ["/a", "/b", "/a"] {
280            s.push_recent(Path::new(p));
281        }
282        assert_eq!(s.recent, vec![PathBuf::from("/a"), PathBuf::from("/b")]);
283        s.workspace = Workspace::Curation;
284        s.save(Some(&path)).unwrap();
285        assert_eq!(Session::load(Some(&path)), s);
286    }
287
288    #[test]
289    fn recent_entries_are_forgotten_and_the_last_file_is_not_reopened_by_default() {
290        let mut s = Session::new();
291        assert!(!s.reopen_last, "start on the start screen unless Settings says otherwise");
292        // An older session file without the field: off too
293        let old: Session = serde_json::from_str(r#"{"version": 1, "recent": ["/a"]}"#).unwrap();
294        assert!(!old.reopen_last);
295        s.push_recent(Path::new("/a"));
296        s.push_recent(Path::new("/b"));
297        s.push_recent_sorting(Path::new("/a"));
298        s.forget_recent(Path::new("/a"));
299        assert_eq!((s.recent.clone(), s.recent_sortings.len()), (vec![PathBuf::from("/b")], 0));
300        s.push_recent_sorting(Path::new("/c"));
301        s.clear_recent();
302        assert!(s.recent.is_empty() && s.recent_sortings.is_empty());
303    }
304
305    #[test]
306    fn the_accent_is_saved_as_hex_and_old_values_read() {
307        let read = |json: &str| serde_json::from_str::<Session>(&format!("{{\"version\": 1, \"accent\": {json}}}")).unwrap().accent;
308        assert_eq!(read("\"#2b7f86\""), Some(kitchen_ui::Rgb(0x2b7f86)));
309        assert_eq!(read("2850694"), Some(kitchen_ui::Rgb(0x2b7f86)), "a number still reads");
310        assert_eq!(read("\"#c6f26b\""), None, "lime is the app's own accent");
311        assert_eq!(read("\"rose\""), None, "a colour that does not parse: the app's");
312        assert_eq!(read("null"), None);
313        let old: Session = serde_json::from_str(r#"{"version": 1}"#).unwrap();
314        assert_eq!(old.accent, None, "a session from before accents");
315        let s = Session { accent: Some(kitchen_ui::Rgb(0x2b7f86)), ..Session::new() };
316        let text = serde_json::to_string(&s).unwrap();
317        assert!(text.contains("\"accent\":\"#2b7f86\""), "{text}");
318        assert_eq!(serde_json::from_str::<Session>(&text).unwrap(), s);
319    }
320
321    #[test]
322    fn test_first_run_takes_the_slint_apps_recent_files() {
323        let dir = scratch("slint");
324        std::fs::write(dir.join("session.json"), r#"{"version": 3, "recent": ["/data/x.bin"], "active": "Time"}"#).unwrap();
325        let s = Session::load(Some(&dir.join("workbench.json")));
326        assert_eq!(s.recent, vec![PathBuf::from("/data/x.bin")]);
327        assert_eq!(s.workspace, Workspace::Explore);
328    }
329}