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 --- 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 +++++++++++++++++++++++++++++++++++++ 15 files changed, 1956 insertions(+), 8 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 (limited to 'src') 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