flashkraft_gui/core/
storage.rs1use iced::Theme;
19use serde::{Deserialize, Serialize};
20use std::path::PathBuf;
21use std::sync::Arc;
22
23pub fn custom_theme_from_core(name: &str, t: &flashkraft_core::AppTheme) -> Theme {
31 use iced::theme::{Custom, Palette};
32 let palette = Palette {
33 background: iced::Color::from_rgb8(t.background.r, t.background.g, t.background.b),
34 text: iced::Color::from_rgb8(t.text_primary.r, t.text_primary.g, t.text_primary.b),
35 primary: iced::Color::from_rgb8(t.accent.r, t.accent.g, t.accent.b),
36 success: iced::Color::from_rgb8(t.success.r, t.success.g, t.success.b),
37 warning: iced::Color::from_rgb8(t.warning.r, t.warning.g, t.warning.b),
38 danger: iced::Color::from_rgb8(t.error.r, t.error.g, t.error.b),
39 };
40 Theme::Custom(Arc::new(Custom::new(name.to_string(), palette)))
41}
42
43fn theme_from_core(index: usize) -> Theme {
47 let t = flashkraft_core::theme_by_index(index);
48 custom_theme_from_core(flashkraft_core::THEME_NAMES[index], &t)
49}
50
51pub fn all_themes() -> Vec<Theme> {
59 (0..flashkraft_core::THEME_COUNT)
60 .map(theme_from_core)
61 .collect()
62}
63
64fn theme_to_string(theme: &Theme) -> String {
70 match theme {
71 Theme::Custom(c) => c.to_string(),
72 other => {
75 let s = format!("{other:?}");
77 if flashkraft_core::theme_index_by_name(&s).is_some() {
80 s
81 } else {
82 "Default".to_string()
83 }
84 }
85 }
86}
87
88fn theme_from_string(s: &str) -> Option<Theme> {
93 let idx = flashkraft_core::theme_index_by_name(s)?;
94 Some(theme_from_core(idx))
95}
96
97#[derive(Debug, Clone, Serialize, Deserialize)]
103pub struct GuiSettings {
104 #[serde(default = "default_theme_name")]
106 pub theme: String,
107}
108
109fn default_theme_name() -> String {
110 "Default".to_string()
111}
112
113impl Default for GuiSettings {
114 fn default() -> Self {
115 Self {
116 theme: default_theme_name(),
117 }
118 }
119}
120
121#[derive(Debug)]
125pub struct Storage {
126 path: PathBuf,
127 settings: GuiSettings,
128}
129
130impl Storage {
131 pub fn new() -> Result<Self, String> {
135 let path = Self::settings_path()?;
136 let settings = Self::load_from(&path);
137 Ok(Self { path, settings })
138 }
139
140 pub fn load_theme(&self) -> Option<Theme> {
142 theme_from_string(&self.settings.theme)
143 }
144
145 pub fn save_theme(&mut self, theme: &Theme) -> Result<(), String> {
149 self.settings.theme = theme_to_string(theme);
150 self.flush()
151 }
152
153 fn settings_path() -> Result<PathBuf, String> {
156 let mut path =
157 dirs::config_dir().ok_or_else(|| "Could not determine config directory".to_string())?;
158 path.push("flashkraft");
159 std::fs::create_dir_all(&path)
160 .map_err(|e| format!("Failed to create config directory: {e}"))?;
161 path.push("gui-settings.json");
162 Ok(path)
163 }
164
165 fn load_from(path: &PathBuf) -> GuiSettings {
166 std::fs::read_to_string(path)
167 .ok()
168 .and_then(|s| serde_json::from_str(&s).ok())
169 .unwrap_or_default()
170 }
171
172 fn flush(&self) -> Result<(), String> {
173 let json = serde_json::to_string_pretty(&self.settings)
174 .map_err(|e| format!("Failed to serialise settings: {e}"))?;
175 std::fs::write(&self.path, json).map_err(|e| format!("Failed to write settings file: {e}"))
176 }
177}
178
179#[cfg(test)]
180mod tests {
181 use super::*;
182 use tempfile::TempDir;
183
184 fn temp_storage() -> (Storage, TempDir) {
185 let tmp = tempfile::tempdir().unwrap();
186 let path = tmp.path().join("gui-settings.json");
187 let settings = GuiSettings::default();
188 let storage = Storage { path, settings };
189 (storage, tmp)
190 }
191
192 #[test]
193 fn test_theme_to_string() {
194 let default_theme = theme_from_core(0);
196 assert_eq!(theme_to_string(&default_theme), "Default");
197 let dracula = theme_from_string("Dracula").unwrap();
198 assert_eq!(theme_to_string(&dracula), "Dracula");
199 }
200
201 #[test]
202 fn test_theme_from_string() {
203 let default_theme = theme_from_string("Default");
205 assert!(default_theme.is_some());
206 assert!(matches!(default_theme.unwrap(), Theme::Custom(_)));
207
208 let dracula = theme_from_string("Dracula");
209 assert!(dracula.is_some());
210 assert!(matches!(dracula.unwrap(), Theme::Custom(_)));
211
212 assert!(theme_from_string("Invalid").is_none());
213 }
214
215 #[test]
216 fn test_roundtrip() {
217 for theme in all_themes() {
218 let name = theme_to_string(&theme);
219 let restored = theme_from_string(&name);
220 assert!(restored.is_some(), "roundtrip failed for {name}");
221 }
222 }
223
224 #[test]
225 fn test_all_themes_count() {
226 assert_eq!(all_themes().len(), flashkraft_core::THEME_COUNT);
228 assert_eq!(all_themes().len(), 43);
229 }
230
231 #[test]
232 fn test_save_and_load_theme() {
233 let (mut storage, _tmp) = temp_storage();
234 let tokyo = theme_from_core(flashkraft_core::theme_index_by_name("Tokyo Night").unwrap());
235 storage.save_theme(&tokyo).unwrap();
236 let loaded = storage.load_theme().unwrap();
237 assert_eq!(theme_to_string(&loaded), "Tokyo Night");
238 }
239
240 #[test]
241 fn test_save_writes_json_file() {
242 let (mut storage, _tmp) = temp_storage();
243 let nord = theme_from_string("Nord").unwrap();
244 storage.save_theme(&nord).unwrap();
245 let contents = std::fs::read_to_string(&storage.path).unwrap();
246 assert!(contents.contains("Nord"), "JSON should contain theme name");
247 }
248
249 #[test]
250 fn test_missing_file_yields_default() {
251 let tmp = tempfile::tempdir().unwrap();
252 let path = tmp.path().join("nonexistent.json");
253 let settings = Storage::load_from(&path);
254 assert_eq!(settings.theme, "Default");
255 }
256
257 #[test]
258 fn test_corrupt_file_yields_default() {
259 let tmp = tempfile::tempdir().unwrap();
260 let path = tmp.path().join("corrupt.json");
261 std::fs::write(&path, "not valid json {{{{").unwrap();
262 let settings = Storage::load_from(&path);
263 assert_eq!(settings.theme, "Default");
264 }
265
266 #[test]
267 fn test_all_themes_roundtrip() {
268 for theme in all_themes() {
269 let name = theme_to_string(&theme);
270 let restored = theme_from_string(&name).expect("roundtrip should succeed");
271 assert_eq!(
272 theme_to_string(&restored),
273 name,
274 "roundtrip failed for {name}"
275 );
276 }
277 }
278
279 #[test]
280 fn every_core_theme_is_present_in_gui() {
281 let gui_names: Vec<String> = all_themes().iter().map(theme_to_string).collect();
282 for name in flashkraft_core::THEME_NAMES {
283 assert!(
284 gui_names.iter().any(|n| n == name),
285 "Core theme '{name}' is missing from GUI all_themes()"
286 );
287 }
288 }
289}