1use crate::persistence::{PersistenceError, WindowState};
2use directories::ProjectDirs;
3use std::fs;
4use std::path::PathBuf;
5
6pub 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 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 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 pub fn default_maximized(mut self, maximized: bool) -> Self {
60 self.default_maximized = maximized;
61 self
62 }
63
64 pub fn min_size(mut self, width: u32, height: u32) -> Self {
66 self.min_size = Some((width, height));
67 self
68 }
69
70 pub fn max_size(mut self, width: u32, height: u32) -> Self {
72 self.max_size = Some((width, height));
73 self
74 }
75
76 pub fn resizable(mut self, resizable: bool) -> Self {
78 self.resizable = resizable;
79 self
80 }
81
82 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 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
125pub 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
169pub 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 #[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 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
221pub 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}