From 81cd06b3334f2f91f6a1ab62a28e11c7d0c677eb Mon Sep 17 00:00:00 2001 From: Rubén Beltrán del Río Date: Fri, 16 Jan 2026 08:26:35 +0100 Subject: Add preferences --- Cargo.lock | 168 ++++++++++++++++++- Cargo.toml | 6 +- src/actions.rs | 10 +- src/file_registry.rs | 10 ++ src/handlers/export.rs | 4 +- src/handlers/view.rs | 2 +- src/main.rs | 128 +++++++++++++- src/preferences/mod.rs | 22 +++ src/preferences/models.rs | 266 +++++++++++++++++++++++++++++ src/preferences/pages/editor.rs | 163 ++++++++++++++++++ src/preferences/pages/general.rs | 155 +++++++++++++++++ src/preferences/pages/map.rs | 187 +++++++++++++++++++++ src/preferences/pages/mod.rs | 20 +++ src/preferences/pages/stages.rs | 252 ++++++++++++++++++++++++++++ src/preferences/pages/templates.rs | 312 ++++++++++++++++++++++++++++++++++ src/preferences/storage.rs | 99 +++++++++++ src/preferences/window.rs | 334 +++++++++++++++++++++++++++++++++++++ test.wmap | 10 ++ 18 files changed, 2130 insertions(+), 18 deletions(-) create mode 100644 src/preferences/mod.rs create mode 100644 src/preferences/models.rs create mode 100644 src/preferences/pages/editor.rs create mode 100644 src/preferences/pages/general.rs create mode 100644 src/preferences/pages/map.rs create mode 100644 src/preferences/pages/mod.rs create mode 100644 src/preferences/pages/stages.rs create mode 100644 src/preferences/pages/templates.rs create mode 100644 src/preferences/storage.rs create mode 100644 src/preferences/window.rs create mode 100644 test.wmap diff --git a/Cargo.lock b/Cargo.lock index 4a70162..836a1b7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -120,6 +120,27 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.48.0", +] + [[package]] name = "earcutr" version = "0.4.3" @@ -155,7 +176,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -454,7 +475,7 @@ dependencies = [ "gobject-sys", "libc", "system-deps", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -765,6 +786,16 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" +[[package]] +name = "libredox" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" +dependencies = [ + "bitflags", + "libc", +] + [[package]] name = "linux-raw-sys" version = "0.11.0" @@ -791,12 +822,16 @@ name = "map-linux" version = "0.1.0" dependencies = [ "cairo-rs", + "dirs", "relm4", "relm4-components", "relm4-icons", "relm4-icons-build", "relm4-macros", + "serde", + "serde_json", "sourceview5", + "uuid", "wmap-parser", "wmap-renderer", ] @@ -851,6 +886,12 @@ version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + [[package]] name = "pango" version = "0.21.5" @@ -995,6 +1036,17 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.16", + "libredox", + "thiserror 1.0.69", +] + [[package]] name = "relm4" version = "0.10.0" @@ -1103,7 +1155,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -1311,7 +1363,16 @@ dependencies = [ "getrandom 0.3.4", "once_cell", "rustix", - "windows-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", ] [[package]] @@ -1320,7 +1381,18 @@ version = "2.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" dependencies = [ - "thiserror-impl", + "thiserror-impl 2.0.17", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", ] [[package]] @@ -1451,6 +1523,18 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" +[[package]] +name = "uuid" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2e054861b4bd027cd373e18e8d8d8e6548085000e41290d95ce0c373a654b4a" +dependencies = [ + "getrandom 0.3.4", + "js-sys", + "serde_core", + "wasm-bindgen", +] + [[package]] name = "version-compare" version = "0.2.1" @@ -1533,7 +1617,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -1542,6 +1626,15 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -1551,6 +1644,63 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + [[package]] name = "winnow" version = "0.7.14" @@ -1574,16 +1724,16 @@ checksum = "c5a2c331c28b392f06cd9d81118a6f73b084708c6ff320457f4cbd75912d499b" [[package]] name = "wmap-renderer" -version = "1.0.0" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f28d6ed66e7d4f27781c63072e685ba827d6e4857ed31add3ec29db5e79c7f9" +checksum = "6eaff4963c129be024d3252a61380f69aa40d927b46fa89eb256eeac72a6a676" dependencies = [ "cairo-rs", "geo", "once_cell", "rustc-hash", "tempfile", - "thiserror", + "thiserror 2.0.17", "wmap-parser", ] diff --git a/Cargo.toml b/Cargo.toml index c1fcb4b..4cb05b5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,13 +5,17 @@ edition = "2024" [dependencies] cairo-rs = "0.21.5" +dirs = "5.0" relm4 = "0.10.0" relm4-components = "0.10.0" relm4-icons = "0.10.0" relm4-macros = "0.10.0" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" sourceview5 = "0.10.0" +uuid = { version = "1.0", features = ["v4", "serde"] } wmap-parser = "1.0.0" -wmap-renderer = "1.0.0" +wmap-renderer = "1.1.0" [build-dependencies] relm4-icons-build = "0.10.0" diff --git a/src/actions.rs b/src/actions.rs index efb9f53..4d8523c 100644 --- a/src/actions.rs +++ b/src/actions.rs @@ -1,5 +1,7 @@ use std::path::PathBuf; +use crate::preferences::UserPreferences; + #[derive(Debug, Clone, Copy)] pub enum ImageFormat { Png, @@ -22,7 +24,7 @@ impl ImageFormat { } } -#[derive(Debug)] +#[derive(Debug, Clone)] pub enum Action { ChangeOrientation, StageTypeSelected(usize), @@ -40,4 +42,10 @@ pub enum Action { SaveToFile { path: PathBuf, close_after: bool }, MarkInitialized, CloseWindow, + + // Preferences + ShowPreferences, + PreferencesChanged(UserPreferences), + PreferencesWindowClosed, + ReloadPreferences, } diff --git a/src/file_registry.rs b/src/file_registry.rs index 25aba3f..6ebf986 100644 --- a/src/file_registry.rs +++ b/src/file_registry.rs @@ -5,6 +5,7 @@ use std::cell::RefCell; use std::path::PathBuf; +use relm4::ComponentController; use relm4::component::Controller; use relm4::gtk; @@ -50,3 +51,12 @@ pub fn unregister_file(path: &PathBuf) { files.retain(|(file_path, _)| file_path != path); }); } + +/// Broadcasts a message to all windows. +pub fn broadcast_to_all_windows(message: crate::actions::Action) { + WINDOW_CONTROLLERS.with_borrow(|controllers| { + for controller in controllers { + controller.sender().emit(message.clone()); + } + }); +} diff --git a/src/handlers/export.rs b/src/handlers/export.rs index 7438ef9..45e980e 100644 --- a/src/handlers/export.rs +++ b/src/handlers/export.rs @@ -9,12 +9,12 @@ use wmap_renderer::{render_to_png, render_to_svg}; pub fn export_to_file(model: &AppModel, path: PathBuf, format: ImageFormat) { let result: Result<(), String> = match format { ImageFormat::Png => { - render_to_png(&model.map, model.stage_type, &model.render_configuration) + render_to_png(&model.map, &model.stage_type, &model.render_configuration) .map_err(|e| e.to_string()) .and_then(|data| std::fs::write(&path, data).map_err(|e| e.to_string())) } ImageFormat::Svg => { - render_to_svg(&model.map, model.stage_type, &model.render_configuration) + render_to_svg(&model.map, &model.stage_type, &model.render_configuration) .map_err(|e| e.to_string()) .and_then(|data| std::fs::write(&path, data).map_err(|e| e.to_string())) } diff --git a/src/handlers/view.rs b/src/handlers/view.rs index 64aeb98..57f526a 100644 --- a/src/handlers/view.rs +++ b/src/handlers/view.rs @@ -14,7 +14,7 @@ pub fn change_orientation(model: &mut AppModel) { /// Updates the selected stage type from dropdown selection. pub fn stage_type_selected(model: &mut AppModel, index: usize) { if index < ALL_STAGE_TYPES.len() { - model.stage_type = ALL_STAGE_TYPES[index]; + model.stage_type = ALL_STAGE_TYPES[index].clone(); model.update_image(); } } diff --git a/src/main.rs b/src/main.rs index 2162845..6a7bd34 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,8 +1,12 @@ +// Suppress warnings from relm4 view! macro internals +#![allow(unused_assignments)] + mod actions; mod constants; mod dialogs; mod file_registry; mod handlers; +mod preferences; mod stages; mod icon_names { @@ -47,6 +51,7 @@ relm4::new_stateless_action!( ); relm4::new_stateless_action!(ZoomInAction, WindowActionGroup, "zoom-in"); relm4::new_stateless_action!(ZoomOutAction, WindowActionGroup, "zoom-out"); +relm4::new_stateless_action!(PreferencesAction, WindowActionGroup, "preferences"); /// Macro to reduce boilerplate when registering actions with accelerators. macro_rules! register_action { @@ -136,6 +141,14 @@ fn setup_actions(window: gtk::Window, sender: ComponentSender) { Action::CloseWindow, &["w"] ); + register_action!( + action_group, + application, + sender, + PreferencesAction, + Action::ShowPreferences, + &["comma"] + ); action_group.register_for_widget(&window); } @@ -150,6 +163,7 @@ struct AppModel { stage_type_list: gtk::StringList, stage_type: StageType, source: sourceview5::Buffer, + source_view: sourceview5::View, map: Map, zoom: f64, render_configuration: Configuration, @@ -162,12 +176,15 @@ struct AppModel { pending_load_events: RefCell, initialized: bool, window: gtk::Window, + preferences: preferences::UserPreferences, + preferences_window: Option>, + editor_css_provider: gtk::CssProvider, } impl AppModel { fn update_image(&mut self) { if let Ok(surface) = - render_to_surface(&self.map, self.stage_type, &self.render_configuration) + render_to_surface(&self.map, &self.stage_type, &self.render_configuration) { self.surface_width = (surface.width() as f64 * self.zoom).round() as i32; self.surface_height = (surface.height() as f64 * self.zoom).round() as i32; @@ -217,6 +234,48 @@ impl AppModel { self.source.set_text(content); } + fn apply_preferences(&mut self) { + // Apply editor preferences + self.source_view + .set_wrap_mode(if self.preferences.soft_wrap_lines { + gtk::WrapMode::Word + } else { + gtk::WrapMode::None + }); + + // Build CSS for editor font + let css = if self.preferences.use_custom_editor_font { + self.source_view.set_monospace(false); + format!( + "textview {{ font-family: \"{}\"; font-size: {}pt; }}", + self.preferences.custom_editor_font_name, self.preferences.editor_font_size as i32 + ) + } else { + // Use monospace but still apply font size + self.source_view.set_monospace(true); + format!( + "textview {{ font-size: {}pt; }}", + self.preferences.editor_font_size as i32 + ) + }; + + // Update the CSS provider (reusing the same one avoids accumulation) + self.editor_css_provider.load_from_data(&css); + + // Apply render configuration + self.render_configuration.options.smart_label_positioning = + self.preferences.use_smart_label_positioning; + self.render_configuration.options.show_background = self.preferences.show_map_background; + + // Apply map font + if self.preferences.use_custom_font { + self.render_configuration.theme.fonts.face = self.preferences.custom_font_name.clone(); + } else { + // Reset to default font + self.render_configuration.theme.fonts.face = String::from("sans-serif"); + } + } + pub fn open_new_window(file_path: Option) { let controller = AppModel::builder() .launch(AppInit { @@ -297,9 +356,9 @@ impl SimpleComponent for AppModel { #[wrap(Some)] set_start_child = >k::ScrolledWindow { - sourceview5::View { + #[local_ref] + source_view -> sourceview5::View { set_expand: true, - set_monospace: true, set_buffer: Some(&model.source) } }, @@ -380,6 +439,9 @@ impl SimpleComponent for AppModel { "Zoom In" => ZoomInAction, "Zoom Out" => ZoomOutAction, }, + section! { + "Preferences" => PreferencesAction, + }, } } @@ -422,13 +484,24 @@ impl SimpleComponent for AppModel { setup_actions(root.clone(), sender.clone()); + // Create source view + let source_view = sourceview5::View::with_buffer(&source); + source_view.set_monospace(true); + + // Create CSS provider for editor font styling + let editor_css_provider = gtk::CssProvider::new(); + source_view.style_context().add_provider( + &editor_css_provider, + gtk::STYLE_PROVIDER_PRIORITY_APPLICATION, + ); + let drawing_area = gtk::DrawingArea::new(); let map = parse(&initial_content); let mut render_configuration = Configuration::default(); let stage_type = StageType::Activities; render_configuration.options.smart_label_positioning = true; let surface = - render_to_surface(&map, stage_type, &render_configuration).unwrap_or_else(|_| { + render_to_surface(&map, &stage_type, &render_configuration).unwrap_or_else(|_| { ImageSurface::create(cairo::Format::ARgb32, 1, 1) .expect("Failed to create fallback surface") }); @@ -437,11 +510,15 @@ impl SimpleComponent for AppModel { file_registry::register_file(path, &root); } + // Load preferences + let prefs = preferences::storage::load(); + let mut model = AppModel { orientation: gtk::Orientation::Horizontal, stage_type_list, stage_type, source, + source_view: source_view.clone(), map, zoom: init.zoom, render_configuration, @@ -454,7 +531,13 @@ impl SimpleComponent for AppModel { pending_load_events: RefCell::new(0), initialized: false, window: root.clone(), + preferences: prefs, + preferences_window: None, + editor_css_provider, }; + + // Apply preferences + model.apply_preferences(); model.update_image(); { @@ -528,6 +611,43 @@ impl SimpleComponent for AppModel { // Internal actions Action::MarkInitialized => self.initialized = true, + + // Preferences + Action::ShowPreferences => { + if self.preferences_window.is_none() { + let controller = preferences::PreferencesWindow::builder() + .transient_for(&self.window) + .launch(self.preferences.clone()) + .forward(sender.input_sender(), |output| match output { + preferences::window::PreferencesOutput::PreferencesChanged(prefs) => { + Action::PreferencesChanged(prefs) + } + preferences::window::PreferencesOutput::Closed => { + Action::PreferencesWindowClosed + } + }); + self.preferences_window = Some(controller); + } + if let Some(ref controller) = self.preferences_window { + controller.widget().present(); + } + } + Action::PreferencesChanged(prefs) => { + self.preferences = prefs; + self.apply_preferences(); + self.update_image(); + // Broadcast to all other windows to reload preferences + file_registry::broadcast_to_all_windows(Action::ReloadPreferences); + } + Action::PreferencesWindowClosed => { + self.preferences_window = None; + } + Action::ReloadPreferences => { + // Reload preferences from disk (another window changed them) + self.preferences = preferences::storage::load(); + self.apply_preferences(); + self.update_image(); + } } } } diff --git a/src/preferences/mod.rs b/src/preferences/mod.rs new file mode 100644 index 0000000..900869f --- /dev/null +++ b/src/preferences/mod.rs @@ -0,0 +1,22 @@ +// Copyright (C) 2024 Rubén Beltrán del Río + +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +// You should have received a copy of the GNU General Public License +// along with this program. If not, see https://map.tranquil.systems. + +pub mod models; +pub mod pages; +pub mod storage; +pub mod window; + +pub use models::UserPreferences; +pub use window::PreferencesWindow; diff --git a/src/preferences/models.rs b/src/preferences/models.rs new file mode 100644 index 0000000..2cf4665 --- /dev/null +++ b/src/preferences/models.rs @@ -0,0 +1,266 @@ +// Copyright (C) 2024 Rubén Beltrán del Río + +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +// You should have received a copy of the GNU General Public License +// along with this program. If not, see https://map.tranquil.systems. + +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +/// Stage labels for custom stages (matches Swift Stage struct) +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)] +pub struct Stage { + pub i: String, + pub ii: String, + pub iii: String, + pub iv: String, +} + +/// Custom stage definition (matches Swift CustomStage) +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct CustomStage { + pub id: Uuid, + pub name: String, + pub stage: Stage, +} + +impl Default for CustomStage { + fn default() -> Self { + Self { + id: Uuid::new_v4(), + name: "Custom Stage".to_string(), + stage: Stage::default(), + } + } +} + +impl CustomStage { + pub fn cynefin_default() -> Self { + Self { + id: Uuid::new_v4(), + name: "Cynefin".to_string(), + stage: Stage { + i: "Chaotic".to_string(), + ii: "Complex".to_string(), + iii: "Complicated".to_string(), + iv: "Clear".to_string(), + }, + } + } + + pub fn placeholder_default() -> Self { + Self { + id: Uuid::new_v4(), + name: "New Stage".to_string(), + stage: Stage { + i: "Stage I".to_string(), + ii: "Stage II".to_string(), + iii: "Stage III".to_string(), + iv: "Stage IV".to_string(), + }, + } + } +} + +/// Template for new maps (matches Swift Template) +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct Template { + pub id: Uuid, + pub name: String, + pub content: String, + #[serde(default)] + pub is_default: bool, +} + +impl Default for Template { + fn default() -> Self { + Self { + id: Uuid::new_v4(), + name: "Untitled".to_string(), + content: String::new(), + is_default: false, + } + } +} + +impl Template { + pub fn new(name: String, content: String, is_default: bool) -> Self { + Self { + id: Uuid::new_v4(), + name, + content, + is_default, + } + } +} + +/// JSON import/export format (matches Swift UserPreferencesJSON) +/// All fields optional to support partial imports +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct UserPreferencesJson { + // Map preferences + pub show_map_background: Option, + pub use_custom_font: Option, + pub custom_font_name: Option, + pub default_export_format: Option, + pub use_smart_label_positioning: Option, + + // Editor preferences + pub use_custom_editor_font: Option, + pub custom_editor_font_name: Option, + pub editor_font_size: Option, + pub soft_wrap_lines: Option, + + // Templates and stages + pub map_templates: Option>, + pub custom_stages: Option>, + + // UI preferences + pub view_style: Option, + pub zoom: Option, +} + +/// Main preferences struct stored in app +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct UserPreferences { + // Map preferences + pub show_map_background: bool, + pub use_custom_font: bool, + pub custom_font_name: String, + pub default_export_format: String, + pub use_smart_label_positioning: bool, + + // Editor preferences + pub use_custom_editor_font: bool, + pub custom_editor_font_name: String, + pub editor_font_size: f64, + pub soft_wrap_lines: bool, + + // Templates and stages + pub map_templates: Vec