1use 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
15const RECENT: usize = 10;
17
18#[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 #[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#[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 #[serde(default)]
103 pub right_panel: String,
104 #[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 #[serde(default)]
126 pub dark: Option<bool>,
127 #[serde(default)]
129 pub tune_suggested: bool,
130 #[serde(default, deserialize_with = "accent_or_old", skip_serializing_if = "Option::is_none")]
133 pub accent: Option<kitchen_ui::Rgb>,
134 #[serde(default)]
136 pub dark_plots: bool,
137 #[serde(default)]
138 pub explore: ExploreSession,
139 #[serde(default)]
141 pub panel_widths: std::collections::BTreeMap<String, f32>,
142 #[serde(default)]
144 pub panel_open: std::collections::BTreeMap<String, bool>,
145 #[serde(default)]
147 pub servers: Vec<crate::data::remote::ServerProfile>,
148 #[serde(default)]
150 pub results_dir: Option<PathBuf>,
151 #[serde(default)]
154 pub runtime: Option<String>,
155 #[serde(default)]
157 pub reopen_last: bool,
158}
159
160fn 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 pub fn default_results_dir() -> PathBuf {
181 dirs::home_dir().unwrap_or_default().join("dspk").join("remote")
182 }
183
184 pub fn results_dir(&self) -> PathBuf {
186 self.results_dir.clone().unwrap_or_else(Self::default_results_dir)
187 }
188
189 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 pub fn document_path() -> Option<PathBuf> {
208 Some(Self::config_dir()?.join("dspk-app").join("current-session.json"))
209 }
210
211 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 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 pub fn clear_recent(&mut self) {
243 self.recent.clear();
244 self.recent_sortings.clear();
245 }
246
247 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 #[allow(dead_code)] 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 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}