feat: implement prefs.rs - preferences with save/load/update
This commit is contained in:
@@ -1,3 +1,5 @@
|
||||
pub mod prefs;
|
||||
pub mod recents;
|
||||
pub mod state;
|
||||
pub mod utils;
|
||||
|
||||
|
||||
441
src-tauri/src/prefs.rs
Normal file
441
src-tauri/src/prefs.rs
Normal file
@@ -0,0 +1,441 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::path::Path;
|
||||
use std::time::SystemTime;
|
||||
|
||||
use crate::state::{atomic_write_json, load_json_with_fallbacks, BACKUP_COUNT};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct WindowState {
|
||||
pub width: i32,
|
||||
pub height: i32,
|
||||
pub x: Option<i32>,
|
||||
pub y: Option<i32>,
|
||||
}
|
||||
|
||||
impl Default for WindowState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
width: 1320,
|
||||
height: 860,
|
||||
x: None,
|
||||
y: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Prefs {
|
||||
#[serde(default = "default_version")]
|
||||
pub version: u32,
|
||||
#[serde(default = "default_ui_zoom")]
|
||||
pub ui_zoom: f64,
|
||||
#[serde(default = "default_split_ratio")]
|
||||
pub split_ratio: f64,
|
||||
#[serde(default = "default_dock_ratio")]
|
||||
pub dock_ratio: f64,
|
||||
#[serde(default)]
|
||||
pub always_on_top: bool,
|
||||
#[serde(default)]
|
||||
pub window: WindowState,
|
||||
#[serde(default)]
|
||||
pub last_folder_path: Option<String>,
|
||||
#[serde(default)]
|
||||
pub last_library_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub updated_at: u64,
|
||||
}
|
||||
|
||||
fn default_version() -> u32 {
|
||||
19
|
||||
}
|
||||
fn default_ui_zoom() -> f64 {
|
||||
1.0
|
||||
}
|
||||
fn default_split_ratio() -> f64 {
|
||||
0.62
|
||||
}
|
||||
fn default_dock_ratio() -> f64 {
|
||||
0.62
|
||||
}
|
||||
|
||||
impl Default for Prefs {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
version: 19,
|
||||
ui_zoom: 1.0,
|
||||
split_ratio: 0.62,
|
||||
dock_ratio: 0.62,
|
||||
always_on_top: false,
|
||||
window: WindowState::default(),
|
||||
last_folder_path: None,
|
||||
last_library_id: None,
|
||||
updated_at: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Prefs {
|
||||
/// Load preferences from `{state_dir}/prefs.json`, merging with defaults.
|
||||
///
|
||||
/// Window fields are merged individually so that a saved file with only
|
||||
/// `{"window": {"width": 800}}` keeps the default height, x, and y.
|
||||
pub fn load(state_dir: &Path) -> Prefs {
|
||||
let path = state_dir.join("prefs.json");
|
||||
let loaded = load_json_with_fallbacks(&path, BACKUP_COUNT);
|
||||
|
||||
let raw = match loaded {
|
||||
Some(v) => v,
|
||||
None => return Prefs::default(),
|
||||
};
|
||||
|
||||
let raw_obj = match raw.as_object() {
|
||||
Some(obj) => obj,
|
||||
None => return Prefs::default(),
|
||||
};
|
||||
|
||||
// Start with defaults
|
||||
let mut prefs = Prefs::default();
|
||||
|
||||
// Merge window fields individually if present
|
||||
if let Some(Value::Object(win_obj)) = raw_obj.get("window") {
|
||||
if let Some(Value::Number(n)) = win_obj.get("width") {
|
||||
if let Some(v) = n.as_i64() {
|
||||
prefs.window.width = v as i32;
|
||||
}
|
||||
}
|
||||
if let Some(Value::Number(n)) = win_obj.get("height") {
|
||||
if let Some(v) = n.as_i64() {
|
||||
prefs.window.height = v as i32;
|
||||
}
|
||||
}
|
||||
if let Some(val) = win_obj.get("x") {
|
||||
prefs.window.x = val.as_i64().map(|v| v as i32);
|
||||
}
|
||||
if let Some(val) = win_obj.get("y") {
|
||||
prefs.window.y = val.as_i64().map(|v| v as i32);
|
||||
}
|
||||
}
|
||||
|
||||
// Merge all other top-level keys
|
||||
if let Some(Value::Number(n)) = raw_obj.get("version") {
|
||||
if let Some(v) = n.as_u64() {
|
||||
prefs.version = v as u32;
|
||||
}
|
||||
}
|
||||
if let Some(Value::Number(n)) = raw_obj.get("ui_zoom") {
|
||||
if let Some(v) = n.as_f64() {
|
||||
prefs.ui_zoom = v;
|
||||
}
|
||||
}
|
||||
if let Some(Value::Number(n)) = raw_obj.get("split_ratio") {
|
||||
if let Some(v) = n.as_f64() {
|
||||
prefs.split_ratio = v;
|
||||
}
|
||||
}
|
||||
if let Some(Value::Number(n)) = raw_obj.get("dock_ratio") {
|
||||
if let Some(v) = n.as_f64() {
|
||||
prefs.dock_ratio = v;
|
||||
}
|
||||
}
|
||||
if let Some(Value::Bool(b)) = raw_obj.get("always_on_top") {
|
||||
prefs.always_on_top = *b;
|
||||
}
|
||||
if let Some(val) = raw_obj.get("last_folder_path") {
|
||||
prefs.last_folder_path = val.as_str().map(|s| s.to_string());
|
||||
}
|
||||
if let Some(val) = raw_obj.get("last_library_id") {
|
||||
prefs.last_library_id = val.as_str().map(|s| s.to_string());
|
||||
}
|
||||
if let Some(Value::Number(n)) = raw_obj.get("updated_at") {
|
||||
if let Some(v) = n.as_u64() {
|
||||
prefs.updated_at = v;
|
||||
}
|
||||
}
|
||||
|
||||
prefs
|
||||
}
|
||||
|
||||
/// Save preferences to `{state_dir}/prefs.json`.
|
||||
pub fn save(&self, state_dir: &Path) {
|
||||
let path = state_dir.join("prefs.json");
|
||||
let value = serde_json::to_value(self).expect("failed to serialize Prefs");
|
||||
atomic_write_json(&path, &value, BACKUP_COUNT);
|
||||
}
|
||||
|
||||
/// Apply a partial JSON update, merge window fields individually, set
|
||||
/// `updated_at` to the current unix timestamp, and save.
|
||||
pub fn update(&mut self, patch: &Value, state_dir: &Path) {
|
||||
if let Some(obj) = patch.as_object() {
|
||||
// Merge window fields individually
|
||||
if let Some(Value::Object(win_obj)) = obj.get("window") {
|
||||
if let Some(Value::Number(n)) = win_obj.get("width") {
|
||||
if let Some(v) = n.as_i64() {
|
||||
self.window.width = v as i32;
|
||||
}
|
||||
}
|
||||
if let Some(Value::Number(n)) = win_obj.get("height") {
|
||||
if let Some(v) = n.as_i64() {
|
||||
self.window.height = v as i32;
|
||||
}
|
||||
}
|
||||
if let Some(val) = win_obj.get("x") {
|
||||
self.window.x = val.as_i64().map(|v| v as i32);
|
||||
}
|
||||
if let Some(val) = win_obj.get("y") {
|
||||
self.window.y = val.as_i64().map(|v| v as i32);
|
||||
}
|
||||
}
|
||||
|
||||
// Apply other top-level keys
|
||||
if let Some(Value::Number(n)) = obj.get("version") {
|
||||
if let Some(v) = n.as_u64() {
|
||||
self.version = v as u32;
|
||||
}
|
||||
}
|
||||
if let Some(Value::Number(n)) = obj.get("ui_zoom") {
|
||||
if let Some(v) = n.as_f64() {
|
||||
self.ui_zoom = v;
|
||||
}
|
||||
}
|
||||
if let Some(Value::Number(n)) = obj.get("split_ratio") {
|
||||
if let Some(v) = n.as_f64() {
|
||||
self.split_ratio = v;
|
||||
}
|
||||
}
|
||||
if let Some(Value::Number(n)) = obj.get("dock_ratio") {
|
||||
if let Some(v) = n.as_f64() {
|
||||
self.dock_ratio = v;
|
||||
}
|
||||
}
|
||||
if let Some(Value::Bool(b)) = obj.get("always_on_top") {
|
||||
self.always_on_top = *b;
|
||||
}
|
||||
if let Some(val) = obj.get("last_folder_path") {
|
||||
self.last_folder_path = val.as_str().map(|s| s.to_string());
|
||||
}
|
||||
if let Some(val) = obj.get("last_library_id") {
|
||||
self.last_library_id = val.as_str().map(|s| s.to_string());
|
||||
}
|
||||
|
||||
// Set updated_at to current unix timestamp
|
||||
self.updated_at = SystemTime::now()
|
||||
.duration_since(SystemTime::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
|
||||
self.save(state_dir);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn test_default_prefs() {
|
||||
let prefs = Prefs::default();
|
||||
assert_eq!(prefs.version, 19);
|
||||
assert_eq!(prefs.ui_zoom, 1.0);
|
||||
assert_eq!(prefs.split_ratio, 0.62);
|
||||
assert_eq!(prefs.dock_ratio, 0.62);
|
||||
assert!(!prefs.always_on_top);
|
||||
assert_eq!(prefs.window.width, 1320);
|
||||
assert_eq!(prefs.window.height, 860);
|
||||
assert_eq!(prefs.window.x, None);
|
||||
assert_eq!(prefs.window.y, None);
|
||||
assert_eq!(prefs.last_folder_path, None);
|
||||
assert_eq!(prefs.last_library_id, None);
|
||||
assert_eq!(prefs.updated_at, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_default_window_state() {
|
||||
let ws = WindowState::default();
|
||||
assert_eq!(ws.width, 1320);
|
||||
assert_eq!(ws.height, 860);
|
||||
assert_eq!(ws.x, None);
|
||||
assert_eq!(ws.y, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_save_and_load_round_trip() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let mut prefs = Prefs::default();
|
||||
prefs.ui_zoom = 1.5;
|
||||
prefs.split_ratio = 0.75;
|
||||
prefs.always_on_top = true;
|
||||
prefs.window.width = 1920;
|
||||
prefs.window.height = 1080;
|
||||
prefs.window.x = Some(100);
|
||||
prefs.window.y = Some(200);
|
||||
prefs.last_folder_path = Some("/my/folder".to_string());
|
||||
prefs.last_library_id = Some("lib-abc".to_string());
|
||||
prefs.updated_at = 1234567890;
|
||||
|
||||
prefs.save(dir.path());
|
||||
|
||||
let loaded = Prefs::load(dir.path());
|
||||
assert_eq!(loaded.version, 19);
|
||||
assert_eq!(loaded.ui_zoom, 1.5);
|
||||
assert_eq!(loaded.split_ratio, 0.75);
|
||||
assert_eq!(loaded.dock_ratio, 0.62);
|
||||
assert!(loaded.always_on_top);
|
||||
assert_eq!(loaded.window.width, 1920);
|
||||
assert_eq!(loaded.window.height, 1080);
|
||||
assert_eq!(loaded.window.x, Some(100));
|
||||
assert_eq!(loaded.window.y, Some(200));
|
||||
assert_eq!(loaded.last_folder_path, Some("/my/folder".to_string()));
|
||||
assert_eq!(loaded.last_library_id, Some("lib-abc".to_string()));
|
||||
assert_eq!(loaded.updated_at, 1234567890);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_load_nonexistent_returns_defaults() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let prefs = Prefs::load(dir.path());
|
||||
assert_eq!(prefs.version, 19);
|
||||
assert_eq!(prefs.ui_zoom, 1.0);
|
||||
assert_eq!(prefs.window.width, 1320);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_partial_update() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let mut prefs = Prefs::default();
|
||||
|
||||
let patch = json!({
|
||||
"ui_zoom": 2.0,
|
||||
"always_on_top": true
|
||||
});
|
||||
prefs.update(&patch, dir.path());
|
||||
|
||||
assert_eq!(prefs.ui_zoom, 2.0);
|
||||
assert!(prefs.always_on_top);
|
||||
// Unchanged fields stay at defaults
|
||||
assert_eq!(prefs.split_ratio, 0.62);
|
||||
assert_eq!(prefs.dock_ratio, 0.62);
|
||||
assert_eq!(prefs.window.width, 1320);
|
||||
// updated_at should have been set
|
||||
assert!(prefs.updated_at > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_saves_to_disk() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let mut prefs = Prefs::default();
|
||||
|
||||
let patch = json!({"ui_zoom": 3.0});
|
||||
prefs.update(&patch, dir.path());
|
||||
|
||||
let loaded = Prefs::load(dir.path());
|
||||
assert_eq!(loaded.ui_zoom, 3.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_window_merge_on_load() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
// Write a JSON file with only partial window data
|
||||
let partial = json!({
|
||||
"window": {"width": 800}
|
||||
});
|
||||
let path = dir.path().join("prefs.json");
|
||||
atomic_write_json(&path, &partial, BACKUP_COUNT);
|
||||
|
||||
let prefs = Prefs::load(dir.path());
|
||||
// Width should be overridden
|
||||
assert_eq!(prefs.window.width, 800);
|
||||
// Height should remain at default
|
||||
assert_eq!(prefs.window.height, 860);
|
||||
// x, y should remain None (defaults)
|
||||
assert_eq!(prefs.window.x, None);
|
||||
assert_eq!(prefs.window.y, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_window_merge_on_update() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let mut prefs = Prefs::default();
|
||||
prefs.window.width = 1000;
|
||||
prefs.window.height = 700;
|
||||
prefs.window.x = Some(50);
|
||||
prefs.window.y = Some(60);
|
||||
|
||||
// Patch only width — height, x, y should remain
|
||||
let patch = json!({
|
||||
"window": {"width": 1600}
|
||||
});
|
||||
prefs.update(&patch, dir.path());
|
||||
|
||||
assert_eq!(prefs.window.width, 1600);
|
||||
assert_eq!(prefs.window.height, 700);
|
||||
assert_eq!(prefs.window.x, Some(50));
|
||||
assert_eq!(prefs.window.y, Some(60));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_window_merge_with_position() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
// Saved file has window with x/y set
|
||||
let saved = json!({
|
||||
"version": 19,
|
||||
"window": {"width": 1320, "height": 860, "x": 150, "y": 250}
|
||||
});
|
||||
let path = dir.path().join("prefs.json");
|
||||
atomic_write_json(&path, &saved, BACKUP_COUNT);
|
||||
|
||||
let prefs = Prefs::load(dir.path());
|
||||
assert_eq!(prefs.window.x, Some(150));
|
||||
assert_eq!(prefs.window.y, Some(250));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_load_with_missing_fields_uses_defaults() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
// Write a JSON with only version — everything else should be defaults
|
||||
let partial = json!({"version": 20});
|
||||
let path = dir.path().join("prefs.json");
|
||||
atomic_write_json(&path, &partial, BACKUP_COUNT);
|
||||
|
||||
let prefs = Prefs::load(dir.path());
|
||||
assert_eq!(prefs.version, 20);
|
||||
assert_eq!(prefs.ui_zoom, 1.0);
|
||||
assert_eq!(prefs.split_ratio, 0.62);
|
||||
assert_eq!(prefs.window.width, 1320);
|
||||
assert_eq!(prefs.window.height, 860);
|
||||
assert_eq!(prefs.last_folder_path, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_with_non_object_is_noop() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let mut prefs = Prefs::default();
|
||||
let original_zoom = prefs.ui_zoom;
|
||||
|
||||
prefs.update(&json!("not an object"), dir.path());
|
||||
|
||||
// Nothing should have changed
|
||||
assert_eq!(prefs.ui_zoom, original_zoom);
|
||||
assert_eq!(prefs.updated_at, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_window_null_xy_in_update() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let mut prefs = Prefs::default();
|
||||
prefs.window.x = Some(100);
|
||||
prefs.window.y = Some(200);
|
||||
|
||||
// Setting x/y to null should clear them
|
||||
let patch = json!({
|
||||
"window": {"x": null, "y": null}
|
||||
});
|
||||
prefs.update(&patch, dir.path());
|
||||
|
||||
assert_eq!(prefs.window.x, None);
|
||||
assert_eq!(prefs.window.y, None);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user