use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::screens::explore::trace::renderer::TimeViewKind;
use crate::screens::explore::trace::view::TimeView;
use crate::navigation::route::Workspace;
const RECENT: usize = 10;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SavedTimeView {
pub kind: TimeViewKind,
#[serde(default)]
pub source: String,
#[serde(default = "default_lanes")]
pub lanes: usize,
#[serde(default = "default_gain")]
pub gain: f32,
#[serde(default = "yes")]
pub auto_scale: bool,
#[serde(default = "yes")]
pub remove_dc: bool,
#[serde(default)]
pub pinned_selection: bool,
#[serde(default = "yes")]
pub spikes: bool,
#[serde(default)]
pub color_by: crate::screens::explore::trace::renderer::ColorBy,
#[serde(default = "yes")]
pub events: bool,
#[serde(default)]
pub events_only: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub recording: Option<std::path::PathBuf>,
}
impl SavedTimeView {
pub fn from_view(v: &TimeView) -> Self {
Self {
kind: v.kind,
source: v.source.clone(),
lanes: v.lanes,
gain: v.gain,
auto_scale: v.auto_scale,
remove_dc: v.remove_dc,
pinned_selection: v.pinned_selection,
spikes: v.spikes,
color_by: v.color_by,
recording: v.recording.clone(),
events: v.events,
events_only: v.events_only,
}
}
pub fn apply(&self, v: &mut TimeView) {
v.lanes = self.lanes.max(1);
v.gain = self.gain.clamp(0.1, 20.0);
v.auto_scale = self.auto_scale;
v.remove_dc = self.remove_dc;
v.pinned_selection = self.pinned_selection;
v.spikes = self.spikes;
v.color_by = self.color_by;
v.recording = self.recording.clone();
v.events = self.events;
v.events_only = self.events_only;
v.needs_render = true;
}
}
fn default_lanes() -> usize {
8
}
fn default_gain() -> f32 {
1.0
}
fn yes() -> bool {
true
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ExploreSession {
#[serde(default = "yes")]
pub left_open: bool,
#[serde(default = "yes")]
pub right_open: bool,
#[serde(default = "yes")]
pub bottom_open: bool,
#[serde(default)]
pub views: Vec<SavedTimeView>,
#[serde(default)]
pub right_panel: String,
#[serde(default)]
pub pipeline_dock: bool,
}
impl Default for ExploreSession {
fn default() -> Self {
Self { left_open: true, right_open: true, bottom_open: true, views: Vec::new(), right_panel: String::new(), pipeline_dock: false }
}
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct Session {
pub version: u32,
#[serde(default)]
pub recent: Vec<PathBuf>,
#[serde(default)]
pub recent_sortings: Vec<PathBuf>,
#[serde(default)]
pub workspace: Workspace,
#[serde(default)]
pub dark: Option<bool>,
#[serde(default)]
pub tune_suggested: bool,
#[serde(default, deserialize_with = "accent_or_old", skip_serializing_if = "Option::is_none")]
pub accent: Option<kitchen_ui::Rgb>,
#[serde(default)]
pub dark_plots: bool,
#[serde(default)]
pub explore: ExploreSession,
#[serde(default)]
pub panel_widths: std::collections::BTreeMap<String, f32>,
#[serde(default)]
pub panel_open: std::collections::BTreeMap<String, bool>,
#[serde(default)]
pub servers: Vec<crate::data::remote::ServerProfile>,
#[serde(default)]
pub results_dir: Option<PathBuf>,
#[serde(default)]
pub runtime: Option<String>,
#[serde(default)]
pub reopen_last: bool,
}
fn accent_or_old<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Option<kitchen_ui::Rgb>, D::Error> {
let own = |rgb: kitchen_ui::Rgb| (rgb != kitchen_ui::Rgb(0xc6f26b)).then_some(rgb);
Ok(match serde_json::Value::deserialize(d)? {
serde_json::Value::String(t) => kitchen_ui::Rgb::parse(&t).and_then(own),
serde_json::Value::Number(n) => n.as_u64().and_then(|v| own(kitchen_ui::Rgb(v as u32 & 0xffffff))),
_ => None,
})
}
impl Session {
pub const VERSION: u32 = 1;
pub fn new() -> Self {
Self { version: Self::VERSION, ..Self::default() }
}
pub fn default_results_dir() -> PathBuf {
dirs::home_dir().unwrap_or_default().join("dspk").join("remote")
}
pub fn results_dir(&self) -> PathBuf {
self.results_dir.clone().unwrap_or_else(Self::default_results_dir)
}
pub fn recipes_dir() -> Option<PathBuf> {
Some(Self::config_dir()?.join("dspk-app").join("recipes"))
}
fn config_dir() -> Option<PathBuf> {
std::env::var_os("XDG_CONFIG_HOME")
.map(PathBuf::from)
.or_else(|| std::env::var_os("APPDATA").map(PathBuf::from))
.or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config")))
}
pub fn path() -> Option<PathBuf> {
Some(Self::config_dir()?.join("dspk-app").join("workbench.json"))
}
pub fn document_path() -> Option<PathBuf> {
Some(Self::config_dir()?.join("dspk-app").join("current-session.json"))
}
pub fn load(path: Option<&Path>) -> Self {
let read = |p: &Path| std::fs::read_to_string(p).ok();
if let Some(s) = path.and_then(read).and_then(|t| serde_json::from_str::<Session>(&t).ok()).filter(|s| s.version == Self::VERSION) {
return s;
}
let slint = path.and_then(|p| p.parent()).map(|d| d.join("session.json"));
let recent = slint
.as_deref()
.and_then(read)
.and_then(|t| serde_json::from_str::<serde_json::Value>(&t).ok())
.and_then(|v| serde_json::from_value::<Vec<PathBuf>>(v.get("recent")?.clone()).ok())
.unwrap_or_default();
Self { recent, ..Self::new() }
}
pub fn save(&self, path: Option<&Path>) -> std::io::Result<()> {
let Some(path) = path else { return Ok(()) };
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir)?;
}
std::fs::write(path, serde_json::to_string_pretty(self).map_err(std::io::Error::other)?)
}
pub fn forget_recent(&mut self, path: &Path) {
self.recent.retain(|p| p != path);
self.recent_sortings.retain(|p| p != path);
}
pub fn clear_recent(&mut self) {
self.recent.clear();
self.recent_sortings.clear();
}
pub fn push_recent(&mut self, path: &Path) {
self.recent.retain(|p| p != path);
self.recent.insert(0, path.to_path_buf());
self.recent.truncate(RECENT);
}
#[allow(dead_code)] pub fn push_recent_sorting(&mut self, path: &Path) {
self.recent_sortings.retain(|p| p != path);
self.recent_sortings.insert(0, path.to_path_buf());
self.recent_sortings.truncate(RECENT);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn scratch(name: &str) -> PathBuf {
let p = std::env::temp_dir().join(format!("dspk-app-session-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&p);
std::fs::create_dir_all(&p).unwrap();
p
}
#[test]
fn test_round_trip_and_recent_order() {
let dir = scratch("round");
let path = dir.join("workbench.json");
let mut s = Session::new();
for p in ["/a", "/b", "/a"] {
s.push_recent(Path::new(p));
}
assert_eq!(s.recent, vec![PathBuf::from("/a"), PathBuf::from("/b")]);
s.workspace = Workspace::Curation;
s.save(Some(&path)).unwrap();
assert_eq!(Session::load(Some(&path)), s);
}
#[test]
fn recent_entries_are_forgotten_and_the_last_file_is_not_reopened_by_default() {
let mut s = Session::new();
assert!(!s.reopen_last, "start on the start screen unless Settings says otherwise");
let old: Session = serde_json::from_str(r#"{"version": 1, "recent": ["/a"]}"#).unwrap();
assert!(!old.reopen_last);
s.push_recent(Path::new("/a"));
s.push_recent(Path::new("/b"));
s.push_recent_sorting(Path::new("/a"));
s.forget_recent(Path::new("/a"));
assert_eq!((s.recent.clone(), s.recent_sortings.len()), (vec![PathBuf::from("/b")], 0));
s.push_recent_sorting(Path::new("/c"));
s.clear_recent();
assert!(s.recent.is_empty() && s.recent_sortings.is_empty());
}
#[test]
fn the_accent_is_saved_as_hex_and_old_values_read() {
let read = |json: &str| serde_json::from_str::<Session>(&format!("{{\"version\": 1, \"accent\": {json}}}")).unwrap().accent;
assert_eq!(read("\"#2b7f86\""), Some(kitchen_ui::Rgb(0x2b7f86)));
assert_eq!(read("2850694"), Some(kitchen_ui::Rgb(0x2b7f86)), "a number still reads");
assert_eq!(read("\"#c6f26b\""), None, "lime is the app's own accent");
assert_eq!(read("\"rose\""), None, "a colour that does not parse: the app's");
assert_eq!(read("null"), None);
let old: Session = serde_json::from_str(r#"{"version": 1}"#).unwrap();
assert_eq!(old.accent, None, "a session from before accents");
let s = Session { accent: Some(kitchen_ui::Rgb(0x2b7f86)), ..Session::new() };
let text = serde_json::to_string(&s).unwrap();
assert!(text.contains("\"accent\":\"#2b7f86\""), "{text}");
assert_eq!(serde_json::from_str::<Session>(&text).unwrap(), s);
}
#[test]
fn test_first_run_takes_the_slint_apps_recent_files() {
let dir = scratch("slint");
std::fs::write(dir.join("session.json"), r#"{"version": 3, "recent": ["/data/x.bin"], "active": "Time"}"#).unwrap();
let s = Session::load(Some(&dir.join("workbench.json")));
assert_eq!(s.recent, vec![PathBuf::from("/data/x.bin")]);
assert_eq!(s.workspace, Workspace::Explore);
}
}