Skip to main content

dampen_dev/persistence/
storage.rs

1use crate::persistence::{PersistenceError, WindowState};
2use directories::ProjectDirs;
3use std::fs;
4use std::path::PathBuf;
5
6/// Builder for window settings with persistence support.
7///
8/// This builder allows configuring default window settings that will be used
9/// when no persisted state exists (e.g., on first launch).
10///
11/// # Example
12///
13/// ```ignore
14/// iced::application(...)
15///     .window(DampenApp::window_settings()
16///         .default_size(1024, 768)
17///         .default_maximized(true)
18///         .min_size(400, 300)
19///         .resizable(true)
20///         .build())
21///     .run()
22/// ```
23pub struct WindowSettingsBuilder {
24    app_name: String,
25    default_width: u32,
26    default_height: u32,
27    default_maximized: bool,
28    min_size: Option<(u32, u32)>,
29    max_size: Option<(u32, u32)>,
30    resizable: bool,
31}
32
33impl WindowSettingsBuilder {
34    /// Create a new builder for the given application.
35    pub fn new(app_name: &str) -> Self {
36        Self {
37            app_name: app_name.to_string(),
38            default_width: 800,
39            default_height: 600,
40            default_maximized: false,
41            min_size: None,
42            max_size: None,
43            resizable: true,
44        }
45    }
46
47    /// Set the default window size for first launch.
48    ///
49    /// This size is used when no persisted state exists.
50    pub fn default_size(mut self, width: u32, height: u32) -> Self {
51        self.default_width = width;
52        self.default_height = height;
53        self
54    }
55
56    /// Set whether the window should be maximized on first launch.
57    ///
58    /// This is used when no persisted state exists.
59    pub fn default_maximized(mut self, maximized: bool) -> Self {
60        self.default_maximized = maximized;
61        self
62    }
63
64    /// Set the minimum window size.
65    pub fn min_size(mut self, width: u32, height: u32) -> Self {
66        self.min_size = Some((width, height));
67        self
68    }
69
70    /// Set the maximum window size.
71    pub fn max_size(mut self, width: u32, height: u32) -> Self {
72        self.max_size = Some((width, height));
73        self
74    }
75
76    /// Set whether the window is resizable.
77    pub fn resizable(mut self, resizable: bool) -> Self {
78        self.resizable = resizable;
79        self
80    }
81
82    /// Build the final `iced::window::Settings`.
83    ///
84    /// This loads any persisted state and merges it with the defaults.
85    pub fn build(self) -> iced::window::Settings {
86        let mut state = load_or_default(&self.app_name, self.default_width, self.default_height);
87
88        // Apply default maximized only if no persisted state was loaded
89        // (we detect this by checking if the size matches our defaults exactly
90        // and there's no position - indicating fresh defaults were used)
91        let is_fresh = state.width == self.default_width
92            && state.height == self.default_height
93            && state.x.is_none()
94            && state.y.is_none()
95            && !state.maximized;
96
97        if is_fresh && self.default_maximized {
98            state.maximized = true;
99        }
100
101        iced::window::Settings {
102            size: state.size(),
103            position: state
104                .position()
105                .map(iced::window::Position::Specific)
106                .unwrap_or(iced::window::Position::Centered),
107            min_size: self
108                .min_size
109                .map(|(w, h)| iced::Size::new(w as f32, h as f32)),
110            max_size: self
111                .max_size
112                .map(|(w, h)| iced::Size::new(w as f32, h as f32)),
113            resizable: self.resizable,
114            ..Default::default()
115        }
116    }
117}
118
119impl From<WindowSettingsBuilder> for iced::window::Settings {
120    fn from(builder: WindowSettingsBuilder) -> Self {
121        builder.build()
122    }
123}
124
125/// Get the path where window state would be stored.
126///
127/// Useful for debugging or displaying to users. Does not create the directory.
128///
129/// # Arguments
130///
131/// * `app_name` - Application identifier
132///
133/// # Returns
134///
135/// `Some(PathBuf)` with the config file path, or `None` if the config directory
136/// cannot be determined on this platform.
137pub fn get_config_path(app_name: &str) -> Option<PathBuf> {
138    ProjectDirs::from("", "", app_name).map(|dirs| dirs.config_dir().join("window.json"))
139}
140
141fn load_window_state(app_name: &str) -> Result<WindowState, PersistenceError> {
142    let path = get_config_path(app_name).ok_or_else(|| PersistenceError::NoConfigDir {
143        app_name: app_name.to_string(),
144    })?;
145
146    if !path.exists() {
147        return Err(PersistenceError::ReadFailed {
148            path: path.clone(),
149            source: std::io::Error::new(std::io::ErrorKind::NotFound, "File not found"),
150        });
151    }
152
153    let content = fs::read_to_string(&path).map_err(|e| PersistenceError::ReadFailed {
154        path: path.clone(),
155        source: e,
156    })?;
157
158    let state: WindowState =
159        serde_json::from_str(&content).map_err(|e| PersistenceError::ParseFailed {
160            path: path.clone(),
161            source: e,
162        })?;
163
164    state.validate()?;
165
166    Ok(state)
167}
168
169/// Load persisted window state or return defaults.
170///
171/// This is the primary entry point for window persistence. It attempts to
172/// load saved state from the platform-specific config directory. If loading
173/// fails (file missing, corrupted, invalid), it returns a default WindowState
174/// with the provided dimensions.
175///
176/// # Arguments
177///
178/// * `app_name` - Application identifier used to namespace the config file
179/// * `default_width` - Default window width if no saved state exists
180/// * `default_height` - Default window height if no saved state exists
181pub fn load_or_default(app_name: &str, default_width: u32, default_height: u32) -> WindowState {
182    #[cfg(debug_assertions)]
183    println!("DEBUG: Loading window state for '{}'", app_name);
184
185    match load_window_state(app_name) {
186        Ok(mut state) => {
187            #[cfg(debug_assertions)]
188            println!("DEBUG: Loaded state: {:?}", state);
189
190            // Validate position
191            #[allow(clippy::collapsible_if)]
192            if let (Some(x), Some(y)) = (state.x, state.y) {
193                if !crate::persistence::monitor::position_is_reasonable(x, y) {
194                    tracing::warn!("Ignoring unreasonable window position: {}, {}", x, y);
195                    state.x = None;
196                    state.y = None;
197                }
198            }
199            state
200        }
201        Err(e) => {
202            #[cfg(debug_assertions)]
203            println!("DEBUG: Failed to load state: {}", e);
204
205            // Log warning if it's not just "file not found"
206            let is_not_found = matches!(
207                &e,
208                PersistenceError::ReadFailed { source, .. }
209                if source.kind() == std::io::ErrorKind::NotFound
210            );
211
212            if !is_not_found {
213                tracing::warn!("Failed to load window state for '{}': {}", app_name, e);
214            }
215
216            WindowState::with_defaults(default_width, default_height)
217        }
218    }
219}
220
221/// Save window state to the platform-specific config directory.
222///
223/// This function should be called when the application is closing (typically
224/// in response to a `CloseRequested` window event). It creates the config
225/// directory if it doesn't exist.
226///
227/// # Arguments
228///
229/// * `app_name` - Application identifier used to namespace the config file
230/// * `state` - The WindowState to persist
231pub fn save_window_state(app_name: &str, state: &WindowState) -> Result<(), PersistenceError> {
232    #[cfg(debug_assertions)]
233    println!("DEBUG: Saving window state for '{}': {:?}", app_name, state);
234
235    let path = get_config_path(app_name).ok_or_else(|| {
236        let e = PersistenceError::NoConfigDir {
237            app_name: app_name.to_string(),
238        };
239        tracing::warn!("Failed to save window state: {}", e);
240        e
241    })?;
242
243    let parent = path.parent().ok_or_else(|| {
244        let e = PersistenceError::WriteFailed {
245            path: path.clone(),
246            source: std::io::Error::other("Invalid path"),
247        };
248        tracing::warn!("Failed to save window state: {}", e);
249        e
250    })?;
251
252    #[allow(clippy::collapsible_if)]
253    if !parent.exists() {
254        if let Err(e) = fs::create_dir_all(parent) {
255            let err = PersistenceError::CreateDirFailed {
256                path: parent.to_path_buf(),
257                source: e,
258            };
259            tracing::warn!("Failed to save window state: {}", err);
260            return Err(err);
261        }
262    }
263
264    let temp_path = path.with_extension("tmp");
265    let json = match serde_json::to_string_pretty(state) {
266        Ok(j) => j,
267        Err(e) => {
268            let err = PersistenceError::WriteFailed {
269                path: path.clone(),
270                source: std::io::Error::other(e),
271            };
272            tracing::warn!("Failed to save window state: {}", err);
273            return Err(err);
274        }
275    };
276
277    if let Err(e) = fs::write(&temp_path, json) {
278        let err = PersistenceError::WriteFailed {
279            path: temp_path.clone(),
280            source: e,
281        };
282        tracing::warn!("Failed to save window state: {}", err);
283        return Err(err);
284    }
285
286    if let Err(e) = fs::rename(&temp_path, &path) {
287        let err = PersistenceError::WriteFailed {
288            path: path.clone(),
289            source: e,
290        };
291        tracing::warn!("Failed to save window state: {}", err);
292        return Err(err);
293    }
294
295    Ok(())
296}