src/app/persist.rs (3417B)
1 use serde::{Deserialize, Serialize};
2 use std::fs;
3 use std::path::PathBuf;
4
5 /// Every field defaults, so a state file written by an older build — or one
6 /// missing a key — restores what it can instead of dropping the whole session.
7 #[derive(Serialize, Deserialize)]
8 pub struct SavedState {
9 #[serde(default = "default_volume")]
10 pub volume: f32,
11 #[serde(default)]
12 pub shuffle: bool,
13 #[serde(default = "default_repeat")]
14 pub repeat: String,
15 #[serde(default = "default_pane_widths")]
16 pub pane_widths: [u16; 3],
17 #[serde(default)]
18 pub playlists: Vec<SavedPlaylist>,
19 #[serde(default = "default_info_view")]
20 pub info_view: String,
21 #[serde(default = "default_right_split")]
22 pub right_split: u16,
23 }
24
25 fn default_volume() -> f32 {
26 0.5
27 }
28
29 fn default_repeat() -> String {
30 "Off".to_string()
31 }
32
33 pub const DEFAULT_PANE_WIDTHS: [u16; 3] = [20, 60, 20];
34
35 fn default_pane_widths() -> [u16; 3] {
36 DEFAULT_PANE_WIDTHS
37 }
38
39 fn default_info_view() -> String {
40 "Clock".to_string()
41 }
42
43 fn default_right_split() -> u16 {
44 50
45 }
46
47 #[derive(Serialize, Deserialize)]
48 pub struct SavedPlaylist {
49 pub name: String,
50 pub tracks: Vec<PathBuf>,
51 }
52
53 /// Pane widths must each clear the 10% minimum the resize code assumes and sum
54 /// to exactly 100; anything else makes `LayoutAreas` produce degenerate rects
55 /// and the border-drag math underflow. Reject rather than try to repair.
56 pub fn sane_pane_widths(w: [u16; 3]) -> [u16; 3] {
57 if w.iter().all(|&p| p >= 10) && w.iter().sum::<u16>() == 100 {
58 w
59 } else {
60 DEFAULT_PANE_WIDTHS
61 }
62 }
63
64 fn state_path() -> PathBuf {
65 let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
66 PathBuf::from(home).join(".config/ommp/state.json")
67 }
68
69 pub fn save(state: &SavedState) -> anyhow::Result<()> {
70 let path = state_path();
71 if let Some(parent) = path.parent() {
72 fs::create_dir_all(parent)?;
73 }
74 let json = serde_json::to_string_pretty(state)?;
75 // Write-then-rename: a crash or full disk mid-write leaves the previous
76 // state file intact instead of a truncated one that parses to nothing.
77 let tmp = path.with_extension("json.tmp");
78 fs::write(&tmp, json)?;
79 fs::rename(&tmp, &path)?;
80 Ok(())
81 }
82
83 pub fn load() -> Option<SavedState> {
84 let path = state_path();
85 let data = fs::read_to_string(path).ok()?;
86 serde_json::from_str(&data).ok()
87 }
88
89 #[cfg(test)]
90 mod tests {
91 use super::*;
92
93 #[test]
94 fn pane_widths_reject_anything_layout_cannot_use() {
95 assert_eq!(sane_pane_widths([20, 60, 20]), [20, 60, 20]);
96 assert_eq!(sane_pane_widths([10, 80, 10]), [10, 80, 10]);
97 // Below the 10% floor the resize math underflows.
98 assert_eq!(sane_pane_widths([10, 5, 85]), DEFAULT_PANE_WIDTHS);
99 // Sums that are not 100 leave the dashboard with a gap or overflow.
100 assert_eq!(sane_pane_widths([20, 20, 20]), DEFAULT_PANE_WIDTHS);
101 assert_eq!(sane_pane_widths([0, 0, 0]), DEFAULT_PANE_WIDTHS);
102 }
103
104 #[test]
105 fn missing_fields_fall_back_instead_of_dropping_the_file() {
106 let saved: SavedState = serde_json::from_str(r#"{"volume":0.25}"#).unwrap();
107 assert_eq!(saved.volume, 0.25);
108 assert_eq!(saved.repeat, "Off");
109 assert_eq!(saved.pane_widths, DEFAULT_PANE_WIDTHS);
110 assert_eq!(saved.right_split, 50);
111 assert!(saved.playlists.is_empty());
112 }
113 }