diff options
| author | Rubén Beltrán del Río <jj@r.bdr.sh> | 2026-01-16 08:26:35 +0100 |
|---|---|---|
| committer | Rubén Beltrán del Río <jj@r.bdr.sh> | 2026-01-16 11:57:13 +0100 |
| commit | 81cd06b3334f2f91f6a1ab62a28e11c7d0c677eb (patch) | |
| tree | f0b0cf37b406eda2dec8db4382aeb5f606cd1f0d /src | |
| parent | 4211b2ae06777d5bbe8261a1ab5c0dd057829a35 (diff) | |
Add preferences
Diffstat (limited to 'src')
| -rw-r--r-- | src/actions.rs | 10 | ||||
| -rw-r--r-- | src/file_registry.rs | 10 | ||||
| -rw-r--r-- | src/handlers/export.rs | 4 | ||||
| -rw-r--r-- | src/handlers/view.rs | 2 | ||||
| -rw-r--r-- | src/main.rs | 128 | ||||
| -rw-r--r-- | src/preferences/mod.rs | 22 | ||||
| -rw-r--r-- | src/preferences/models.rs | 266 | ||||
| -rw-r--r-- | src/preferences/pages/editor.rs | 163 | ||||
| -rw-r--r-- | src/preferences/pages/general.rs | 155 | ||||
| -rw-r--r-- | src/preferences/pages/map.rs | 187 | ||||
| -rw-r--r-- | src/preferences/pages/mod.rs | 20 | ||||
| -rw-r--r-- | src/preferences/pages/stages.rs | 252 | ||||
| -rw-r--r-- | src/preferences/pages/templates.rs | 312 | ||||
| -rw-r--r-- | src/preferences/storage.rs | 99 | ||||
| -rw-r--r-- | src/preferences/window.rs | 334 |
15 files changed, 1956 insertions, 8 deletions
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<AppModel>) { Action::CloseWindow, &["<Primary>w"] ); + register_action!( + action_group, + application, + sender, + PreferencesAction, + Action::ShowPreferences, + &["<Primary>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<u32>, initialized: bool, window: gtk::Window, + preferences: preferences::UserPreferences, + preferences_window: Option<relm4::Controller<preferences::PreferencesWindow>>, + 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<PathBuf>) { 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<bool>, + pub use_custom_font: Option<bool>, + pub custom_font_name: Option<String>, + pub default_export_format: Option<String>, + pub use_smart_label_positioning: Option<bool>, + + // Editor preferences + pub use_custom_editor_font: Option<bool>, + pub custom_editor_font_name: Option<String>, + pub editor_font_size: Option<f64>, + pub soft_wrap_lines: Option<bool>, + + // Templates and stages + pub map_templates: Option<Vec<Template>>, + pub custom_stages: Option<Vec<CustomStage>>, + + // UI preferences + pub view_style: Option<String>, + pub zoom: Option<f64>, +} + +/// 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<Template>, + pub custom_stages: Vec<CustomStage>, + + // UI preferences + pub view_style: String, + pub zoom: f64, +} + +impl Default for UserPreferences { + fn default() -> Self { + Self { + show_map_background: true, + use_custom_font: false, + custom_font_name: "Sans".to_string(), + default_export_format: "png".to_string(), + use_smart_label_positioning: true, + + use_custom_editor_font: false, + custom_editor_font_name: "Monospace".to_string(), + editor_font_size: 14.0, + soft_wrap_lines: false, + + map_templates: vec![ + Template::new( + "Default".to_string(), + "title My Map\n\nanchor Business [0.95, 0.63]\nanchor Public [0.95, 0.78]\ncomponent User Needs [0.9, 0.50]\n".to_string(), + true, + ), + Template::new("Empty".to_string(), String::new(), false), + ], + custom_stages: vec![CustomStage::cynefin_default()], + + view_style: "horizontal".to_string(), + zoom: 1.0, + } + } +} + +impl UserPreferences { + /// Convert to JSON export format + pub fn to_json(&self) -> UserPreferencesJson { + UserPreferencesJson { + show_map_background: Some(self.show_map_background), + use_custom_font: Some(self.use_custom_font), + custom_font_name: Some(self.custom_font_name.clone()), + default_export_format: Some(self.default_export_format.clone()), + use_smart_label_positioning: Some(self.use_smart_label_positioning), + use_custom_editor_font: Some(self.use_custom_editor_font), + custom_editor_font_name: Some(self.custom_editor_font_name.clone()), + editor_font_size: Some(self.editor_font_size), + soft_wrap_lines: Some(self.soft_wrap_lines), + map_templates: Some(self.map_templates.clone()), + custom_stages: Some(self.custom_stages.clone()), + view_style: Some(self.view_style.clone()), + zoom: Some(self.zoom), + } + } + + /// Update from JSON import (partial updates supported) + pub fn update_from_json(&mut self, json: &UserPreferencesJson) { + if let Some(v) = json.show_map_background { + self.show_map_background = v; + } + if let Some(ref v) = json.use_custom_font { + self.use_custom_font = *v; + } + if let Some(ref v) = json.custom_font_name { + self.custom_font_name = v.clone(); + } + if let Some(ref v) = json.default_export_format { + self.default_export_format = v.clone(); + } + if let Some(v) = json.use_smart_label_positioning { + self.use_smart_label_positioning = v; + } + if let Some(ref v) = json.use_custom_editor_font { + self.use_custom_editor_font = *v; + } + if let Some(ref v) = json.custom_editor_font_name { + self.custom_editor_font_name = v.clone(); + } + if let Some(v) = json.editor_font_size { + self.editor_font_size = v; + } + if let Some(v) = json.soft_wrap_lines { + self.soft_wrap_lines = v; + } + if let Some(ref v) = json.map_templates { + self.map_templates = v.clone(); + } + if let Some(ref v) = json.custom_stages { + self.custom_stages = v.clone(); + } + if let Some(ref v) = json.view_style { + self.view_style = v.clone(); + } + if let Some(v) = json.zoom { + self.zoom = v; + } + } + + /// Get the default template + #[allow(dead_code)] + pub fn default_template(&self) -> Option<&Template> { + self.map_templates.iter().find(|t| t.is_default) + } + + /// Set a template as default (unsets others) + pub fn set_default_template(&mut self, id: Uuid) { + for template in &mut self.map_templates { + template.is_default = template.id == id; + } + } +} diff --git a/src/preferences/pages/editor.rs b/src/preferences/pages/editor.rs new file mode 100644 index 0000000..5334a37 --- /dev/null +++ b/src/preferences/pages/editor.rs @@ -0,0 +1,163 @@ +// 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 gtk::prelude::*; +use relm4::Sender; +use relm4::gtk; + +use super::super::models::UserPreferences; +use super::super::window::PreferencesInput; + +pub struct EditorPage { + container: gtk::Box, + font_size_spin: gtk::SpinButton, + soft_wrap_switch: gtk::Switch, + custom_font_switch: gtk::Switch, + font_button: gtk::FontButton, +} + +impl EditorPage { + pub fn new(prefs: &UserPreferences, sender: Sender<PreferencesInput>) -> Self { + let container = gtk::Box::new(gtk::Orientation::Vertical, 20); + container.set_margin_top(20); + container.set_margin_bottom(20); + container.set_margin_start(20); + container.set_margin_end(20); + container.set_valign(gtk::Align::Start); + + // Font Size section + let font_size_label = gtk::Label::new(Some("Font Size")); + font_size_label.add_css_class("heading"); + font_size_label.set_halign(gtk::Align::Start); + container.append(&font_size_label); + + let font_size_box = gtk::Box::new(gtk::Orientation::Horizontal, 10); + let font_size_spin = gtk::SpinButton::with_range(7.0, 21.0, 1.0); + font_size_spin.set_value(prefs.editor_font_size); + { + let sender = sender.clone(); + font_size_spin.connect_value_changed(move |spin| { + sender.emit(PreferencesInput::SetEditorFontSize(spin.value())); + }); + } + font_size_box.append(&font_size_spin); + font_size_box.append(>k::Label::new(Some("pt"))); + container.append(&font_size_box); + + // Editor Style section + let style_label = gtk::Label::new(Some("Editor Style")); + style_label.add_css_class("heading"); + style_label.set_halign(gtk::Align::Start); + style_label.set_margin_top(10); + container.append(&style_label); + + // Soft Wrap Lines + let soft_wrap_row = create_switch_row("Soft Wrap Lines", prefs.soft_wrap_lines); + let soft_wrap_switch = soft_wrap_row.1.clone(); + { + let sender = sender.clone(); + soft_wrap_switch.connect_state_set(move |_, state| { + sender.emit(PreferencesInput::SetSoftWrapLines(state)); + gtk::glib::Propagation::Proceed + }); + } + container.append(&soft_wrap_row.0); + + // Use Custom Font + let custom_font_row = + create_switch_row("Use Custom Editor Font", prefs.use_custom_editor_font); + let custom_font_switch = custom_font_row.1.clone(); + container.append(&custom_font_row.0); + + // Font selection + let font_row = gtk::Box::new(gtk::Orientation::Horizontal, 10); + font_row.append(>k::Label::new(Some("Font"))); + + let font_button = gtk::FontButton::new(); + font_button.set_font(&format!( + "{} {}", + prefs.custom_editor_font_name, prefs.editor_font_size as i32 + )); + font_button.set_sensitive(prefs.use_custom_editor_font); + font_button.set_hexpand(true); + font_button.set_use_font(true); + { + let sender = sender.clone(); + font_button.connect_font_set(move |btn| { + if let Some(font) = btn.font() { + // Extract font family from the font string + let font_desc = gtk::pango::FontDescription::from_string(&font); + if let Some(family) = font_desc.family() { + sender.emit(PreferencesInput::SetCustomEditorFontName( + family.to_string(), + )); + } + } + }); + } + font_row.append(&font_button); + container.append(&font_row); + + // Connect custom font switch to font button sensitivity + { + let font_button_clone = font_button.clone(); + let sender = sender.clone(); + custom_font_switch.connect_state_set(move |_, state| { + font_button_clone.set_sensitive(state); + sender.emit(PreferencesInput::SetUseCustomEditorFont(state)); + gtk::glib::Propagation::Proceed + }); + } + + Self { + container, + font_size_spin, + soft_wrap_switch, + custom_font_switch, + font_button, + } + } + + pub fn widget(&self) -> gtk::Box { + self.container.clone() + } + + pub fn sync_from(&self, prefs: &UserPreferences) { + self.font_size_spin.set_value(prefs.editor_font_size); + self.soft_wrap_switch.set_active(prefs.soft_wrap_lines); + self.custom_font_switch + .set_active(prefs.use_custom_editor_font); + self.font_button.set_sensitive(prefs.use_custom_editor_font); + self.font_button.set_font(&format!( + "{} {}", + prefs.custom_editor_font_name, prefs.editor_font_size as i32 + )); + } +} + +fn create_switch_row(label: &str, initial_state: bool) -> (gtk::Box, gtk::Switch) { + let row = gtk::Box::new(gtk::Orientation::Horizontal, 10); + row.append(>k::Label::new(Some(label))); + + let spacer = gtk::Box::new(gtk::Orientation::Horizontal, 0); + spacer.set_hexpand(true); + row.append(&spacer); + + let switch = gtk::Switch::new(); + switch.set_active(initial_state); + row.append(&switch); + + (row, switch) +} diff --git a/src/preferences/pages/general.rs b/src/preferences/pages/general.rs new file mode 100644 index 0000000..9c71bd4 --- /dev/null +++ b/src/preferences/pages/general.rs @@ -0,0 +1,155 @@ +// 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 gtk::FileFilter; +use gtk::prelude::*; +use relm4::Sender; +use relm4::gtk; + +use super::super::window::PreferencesInput; + +pub struct GeneralPage { + container: gtk::Box, +} + +impl GeneralPage { + pub fn new(sender: Sender<PreferencesInput>) -> Self { + let container = gtk::Box::new(gtk::Orientation::Vertical, 20); + container.set_margin_top(20); + container.set_margin_bottom(20); + container.set_margin_start(20); + container.set_margin_end(20); + container.set_valign(gtk::Align::Start); + + // Preferences section + let prefs_label = gtk::Label::new(Some("Preferences")); + prefs_label.add_css_class("heading"); + prefs_label.set_halign(gtk::Align::Start); + container.append(&prefs_label); + + let button_box = gtk::Box::new(gtk::Orientation::Horizontal, 10); + + // Export button + let export_button = gtk::Button::with_label("Export..."); + { + let sender = sender.clone(); + export_button.connect_clicked(move |_| { + sender.emit(PreferencesInput::Export); + }); + } + button_box.append(&export_button); + + // Import button + let import_button = gtk::Button::with_label("Import..."); + { + let sender = sender.clone(); + import_button.connect_clicked(move |_| { + sender.emit(PreferencesInput::Import); + }); + } + button_box.append(&import_button); + + // Reset button + let reset_button = gtk::Button::with_label("Reset to Defaults"); + reset_button.add_css_class("destructive-action"); + { + let sender = sender.clone(); + reset_button.connect_clicked(move |_| { + sender.emit(PreferencesInput::ResetToDefaults); + }); + } + button_box.append(&reset_button); + + container.append(&button_box); + + // Help text + let help_label = gtk::Label::new(Some( + "Export your preferences to share with others or back up your settings.\nImport preferences from a file to restore settings.", + )); + help_label.add_css_class("dim-label"); + help_label.set_halign(gtk::Align::Start); + help_label.set_wrap(true); + container.append(&help_label); + + Self { container } + } + + pub fn widget(&self) -> gtk::Box { + self.container.clone() + } +} + +pub fn show_export_dialog(sender: &Sender<PreferencesInput>, window: >k::Window) { + let filter = FileFilter::new(); + filter.add_pattern("*.json"); + filter.set_name(Some("JSON files")); + + let dialog = gtk::FileChooserDialog::new( + Some("Export Preferences"), + Some(window), + gtk::FileChooserAction::Save, + &[ + ("Cancel", gtk::ResponseType::Cancel), + ("Save", gtk::ResponseType::Accept), + ], + ); + dialog.set_current_name("preferences.json"); + dialog.add_filter(&filter); + dialog.set_modal(true); + + let sender = sender.clone(); + dialog.connect_response(move |dialog, response| { + if response == gtk::ResponseType::Accept + && let Some(file) = dialog.file() + && let Some(path) = file.path() + { + sender.emit(PreferencesInput::ExportToFile(path)); + } + dialog.close(); + }); + + dialog.present(); +} + +pub fn show_import_dialog(sender: &Sender<PreferencesInput>, window: >k::Window) { + let filter = FileFilter::new(); + filter.add_pattern("*.json"); + filter.set_name(Some("JSON files")); + + let dialog = gtk::FileChooserDialog::new( + Some("Import Preferences"), + Some(window), + gtk::FileChooserAction::Open, + &[ + ("Cancel", gtk::ResponseType::Cancel), + ("Open", gtk::ResponseType::Accept), + ], + ); + dialog.add_filter(&filter); + dialog.set_modal(true); + + let sender = sender.clone(); + dialog.connect_response(move |dialog, response| { + if response == gtk::ResponseType::Accept + && let Some(file) = dialog.file() + && let Some(path) = file.path() + { + sender.emit(PreferencesInput::ImportFromFile(path)); + } + dialog.close(); + }); + + dialog.present(); +} diff --git a/src/preferences/pages/map.rs b/src/preferences/pages/map.rs new file mode 100644 index 0000000..f3537c0 --- /dev/null +++ b/src/preferences/pages/map.rs @@ -0,0 +1,187 @@ +// 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 gtk::prelude::*; +use relm4::Sender; +use relm4::gtk; + +use super::super::models::UserPreferences; +use super::super::window::PreferencesInput; + +pub struct MapPage { + container: gtk::Box, + show_background_switch: gtk::Switch, + smart_positioning_switch: gtk::Switch, + custom_font_switch: gtk::Switch, + font_button: gtk::FontButton, + export_format_dropdown: gtk::DropDown, +} + +impl MapPage { + pub fn new(prefs: &UserPreferences, sender: Sender<PreferencesInput>) -> Self { + let container = gtk::Box::new(gtk::Orientation::Vertical, 20); + container.set_margin_top(20); + container.set_margin_bottom(20); + container.set_margin_start(20); + container.set_margin_end(20); + container.set_valign(gtk::Align::Start); + + // Map Style section + let style_label = gtk::Label::new(Some("Map Style")); + style_label.add_css_class("heading"); + style_label.set_halign(gtk::Align::Start); + container.append(&style_label); + + // Show Background + let bg_row = create_switch_row("Show Map Background", prefs.show_map_background); + let show_background_switch = bg_row.1.clone(); + { + let sender = sender.clone(); + show_background_switch.connect_state_set(move |_, state| { + sender.emit(PreferencesInput::SetShowMapBackground(state)); + gtk::glib::Propagation::Proceed + }); + } + container.append(&bg_row.0); + + // Smart Label Positioning + let smart_row = create_switch_row( + "Use Smart Label Positioning", + prefs.use_smart_label_positioning, + ); + let smart_positioning_switch = smart_row.1.clone(); + { + let sender = sender.clone(); + smart_positioning_switch.connect_state_set(move |_, state| { + sender.emit(PreferencesInput::SetUseSmartLabelPositioning(state)); + gtk::glib::Propagation::Proceed + }); + } + container.append(&smart_row.0); + + // Use Custom Font + let custom_font_row = create_switch_row("Use Custom Font", prefs.use_custom_font); + let custom_font_switch = custom_font_row.1.clone(); + container.append(&custom_font_row.0); + + // Font selection + let font_row = gtk::Box::new(gtk::Orientation::Horizontal, 10); + font_row.append(>k::Label::new(Some("Font"))); + + let font_button = gtk::FontButton::new(); + font_button.set_font(&prefs.custom_font_name); + font_button.set_sensitive(prefs.use_custom_font); + font_button.set_hexpand(true); + font_button.set_use_font(true); + { + let sender = sender.clone(); + font_button.connect_font_set(move |btn| { + if let Some(font) = btn.font() { + let font_desc = gtk::pango::FontDescription::from_string(&font); + if let Some(family) = font_desc.family() { + sender.emit(PreferencesInput::SetCustomFontName(family.to_string())); + } + } + }); + } + font_row.append(&font_button); + container.append(&font_row); + + // Connect custom font switch to font button sensitivity + { + let font_button_clone = font_button.clone(); + let sender = sender.clone(); + custom_font_switch.connect_state_set(move |_, state| { + font_button_clone.set_sensitive(state); + sender.emit(PreferencesInput::SetUseCustomFont(state)); + gtk::glib::Propagation::Proceed + }); + } + + // Export section + let export_label = gtk::Label::new(Some("Export")); + export_label.add_css_class("heading"); + export_label.set_halign(gtk::Align::Start); + export_label.set_margin_top(10); + container.append(&export_label); + + let export_row = gtk::Box::new(gtk::Orientation::Horizontal, 10); + export_row.append(>k::Label::new(Some("Default Export Format"))); + + let formats = gtk::StringList::new(&["PNG", "SVG"]); + let export_format_dropdown = gtk::DropDown::new(Some(formats), None::<gtk::Expression>); + let selected = match prefs.default_export_format.as_str() { + "svg" => 1, + _ => 0, + }; + export_format_dropdown.set_selected(selected); + { + let sender = sender.clone(); + export_format_dropdown.connect_selected_notify(move |dropdown| { + let format = match dropdown.selected() { + 1 => "svg", + _ => "png", + }; + sender.emit(PreferencesInput::SetDefaultExportFormat(format.to_string())); + }); + } + export_row.append(&export_format_dropdown); + container.append(&export_row); + + Self { + container, + show_background_switch, + smart_positioning_switch, + custom_font_switch, + font_button, + export_format_dropdown, + } + } + + pub fn widget(&self) -> gtk::Box { + self.container.clone() + } + + pub fn sync_from(&self, prefs: &UserPreferences) { + self.show_background_switch + .set_active(prefs.show_map_background); + self.smart_positioning_switch + .set_active(prefs.use_smart_label_positioning); + self.custom_font_switch.set_active(prefs.use_custom_font); + self.font_button.set_sensitive(prefs.use_custom_font); + self.font_button.set_font(&prefs.custom_font_name); + + let selected = match prefs.default_export_format.as_str() { + "svg" => 1, + _ => 0, + }; + self.export_format_dropdown.set_selected(selected); + } +} + +fn create_switch_row(label: &str, initial_state: bool) -> (gtk::Box, gtk::Switch) { + let row = gtk::Box::new(gtk::Orientation::Horizontal, 10); + row.append(>k::Label::new(Some(label))); + + let spacer = gtk::Box::new(gtk::Orientation::Horizontal, 0); + spacer.set_hexpand(true); + row.append(&spacer); + + let switch = gtk::Switch::new(); + switch.set_active(initial_state); + row.append(&switch); + + (row, switch) +} diff --git a/src/preferences/pages/mod.rs b/src/preferences/pages/mod.rs new file mode 100644 index 0000000..3d2db71 --- /dev/null +++ b/src/preferences/pages/mod.rs @@ -0,0 +1,20 @@ +// 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 editor; +pub mod general; +pub mod map; +pub mod stages; +pub mod templates; diff --git a/src/preferences/pages/stages.rs b/src/preferences/pages/stages.rs new file mode 100644 index 0000000..c7fa494 --- /dev/null +++ b/src/preferences/pages/stages.rs @@ -0,0 +1,252 @@ +// 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 gtk::prelude::*; +use relm4::Sender; +use relm4::gtk; +use uuid::Uuid; + +use super::super::models::UserPreferences; +use super::super::window::{PreferencesInput, StageLabel}; + +pub struct StagesPage { + container: gtk::Box, + list_box: gtk::ListBox, + sender: Sender<PreferencesInput>, +} + +impl StagesPage { + pub fn new(prefs: &UserPreferences, sender: Sender<PreferencesInput>) -> Self { + let container = gtk::Box::new(gtk::Orientation::Vertical, 10); + container.set_margin_top(20); + container.set_margin_bottom(20); + container.set_margin_start(20); + container.set_margin_end(20); + + // Header + let header_label = gtk::Label::new(Some("Custom Stages")); + header_label.add_css_class("heading"); + header_label.set_halign(gtk::Align::Start); + container.append(&header_label); + + let help_label = gtk::Label::new(Some( + "Custom stages allow you to define your own evolution axis labels. These will be available for rendering when the renderer supports custom stages.", + )); + help_label.add_css_class("dim-label"); + help_label.set_halign(gtk::Align::Start); + help_label.set_wrap(true); + help_label.set_margin_bottom(10); + container.append(&help_label); + + // Column headers + let header_row = gtk::Box::new(gtk::Orientation::Horizontal, 5); + header_row.add_css_class("dim-label"); + + let name_header = gtk::Label::new(Some("Name")); + name_header.set_width_chars(15); + name_header.set_xalign(0.0); + header_row.append(&name_header); + + let i_header = gtk::Label::new(Some("Stage I")); + i_header.set_width_chars(12); + i_header.set_xalign(0.0); + header_row.append(&i_header); + + let ii_header = gtk::Label::new(Some("Stage II")); + ii_header.set_width_chars(12); + ii_header.set_xalign(0.0); + header_row.append(&ii_header); + + let iii_header = gtk::Label::new(Some("Stage III")); + iii_header.set_width_chars(12); + iii_header.set_xalign(0.0); + header_row.append(&iii_header); + + let iv_header = gtk::Label::new(Some("Stage IV")); + iv_header.set_width_chars(12); + iv_header.set_xalign(0.0); + header_row.append(&iv_header); + + // Spacer for delete button column + let spacer = gtk::Box::new(gtk::Orientation::Horizontal, 0); + spacer.set_width_request(32); + header_row.append(&spacer); + + container.append(&header_row); + + // Scrollable list area + let scrolled = gtk::ScrolledWindow::new(); + scrolled.set_vexpand(true); + scrolled.set_min_content_height(200); + + let list_box = gtk::ListBox::new(); + list_box.set_selection_mode(gtk::SelectionMode::None); + list_box.add_css_class("boxed-list"); + + scrolled.set_child(Some(&list_box)); + container.append(&scrolled); + + // Add button + let button_box = gtk::Box::new(gtk::Orientation::Horizontal, 10); + button_box.set_halign(gtk::Align::End); + + let add_button = gtk::Button::from_icon_name("list-add-symbolic"); + add_button.set_tooltip_text(Some("Add custom stage")); + { + let sender = sender.clone(); + add_button.connect_clicked(move |_| { + sender.emit(PreferencesInput::AddCustomStage); + }); + } + button_box.append(&add_button); + container.append(&button_box); + + let page = Self { + container, + list_box, + sender, + }; + + page.populate_stages(prefs); + page + } + + pub fn widget(&self) -> gtk::Box { + self.container.clone() + } + + pub fn refresh(&self, prefs: &UserPreferences) { + // Clear existing rows + while let Some(child) = self.list_box.first_child() { + self.list_box.remove(&child); + } + self.populate_stages(prefs); + } + + fn populate_stages(&self, prefs: &UserPreferences) { + for stage in &prefs.custom_stages { + let row = self.create_stage_row(stage.id, &stage.name, &stage.stage); + self.list_box.append(&row); + } + } + + fn create_stage_row( + &self, + id: Uuid, + name: &str, + stage: &super::super::models::Stage, + ) -> gtk::ListBoxRow { + let row = gtk::ListBoxRow::new(); + let hbox = gtk::Box::new(gtk::Orientation::Horizontal, 5); + hbox.set_margin_top(5); + hbox.set_margin_bottom(5); + hbox.set_margin_start(5); + hbox.set_margin_end(5); + + // Name entry + let name_entry = gtk::Entry::new(); + name_entry.set_text(name); + name_entry.set_width_chars(15); + { + let sender = self.sender.clone(); + name_entry.connect_changed(move |entry| { + sender.emit(PreferencesInput::SetCustomStageName( + id, + entry.text().to_string(), + )); + }); + } + hbox.append(&name_entry); + + // Stage I entry + let i_entry = gtk::Entry::new(); + i_entry.set_text(&stage.i); + i_entry.set_width_chars(12); + { + let sender = self.sender.clone(); + i_entry.connect_changed(move |entry| { + sender.emit(PreferencesInput::SetCustomStageLabel( + id, + StageLabel::I, + entry.text().to_string(), + )); + }); + } + hbox.append(&i_entry); + + // Stage II entry + let ii_entry = gtk::Entry::new(); + ii_entry.set_text(&stage.ii); + ii_entry.set_width_chars(12); + { + let sender = self.sender.clone(); + ii_entry.connect_changed(move |entry| { + sender.emit(PreferencesInput::SetCustomStageLabel( + id, + StageLabel::II, + entry.text().to_string(), + )); + }); + } + hbox.append(&ii_entry); + + // Stage III entry + let iii_entry = gtk::Entry::new(); + iii_entry.set_text(&stage.iii); + iii_entry.set_width_chars(12); + { + let sender = self.sender.clone(); + iii_entry.connect_changed(move |entry| { + sender.emit(PreferencesInput::SetCustomStageLabel( + id, + StageLabel::III, + entry.text().to_string(), + )); + }); + } + hbox.append(&iii_entry); + + // Stage IV entry + let iv_entry = gtk::Entry::new(); + iv_entry.set_text(&stage.iv); + iv_entry.set_width_chars(12); + { + let sender = self.sender.clone(); + iv_entry.connect_changed(move |entry| { + sender.emit(PreferencesInput::SetCustomStageLabel( + id, + StageLabel::IV, + entry.text().to_string(), + )); + }); + } + hbox.append(&iv_entry); + + // Delete button + let delete_button = gtk::Button::from_icon_name("list-remove-symbolic"); + delete_button.add_css_class("flat"); + delete_button.set_tooltip_text(Some("Remove stage")); + { + let sender = self.sender.clone(); + delete_button.connect_clicked(move |_| { + sender.emit(PreferencesInput::RemoveCustomStage(id)); + }); + } + hbox.append(&delete_button); + + row.set_child(Some(&hbox)); + row + } +} diff --git a/src/preferences/pages/templates.rs b/src/preferences/pages/templates.rs new file mode 100644 index 0000000..2611896 --- /dev/null +++ b/src/preferences/pages/templates.rs @@ -0,0 +1,312 @@ +// 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 std::cell::RefCell; +use std::rc::Rc; + +use relm4::Sender; +use relm4::gtk; +use sourceview5::prelude::*; +use uuid::Uuid; + +use super::super::models::{Template, UserPreferences}; +use super::super::window::PreferencesInput; + +pub struct TemplatesPage { + container: gtk::Box, + list_box: gtk::ListBox, + #[allow(dead_code)] + editor: sourceview5::View, + editor_buffer: sourceview5::Buffer, + selected_id: Rc<RefCell<Option<Uuid>>>, + sender: Sender<PreferencesInput>, +} + +impl TemplatesPage { + pub fn new(prefs: &UserPreferences, sender: Sender<PreferencesInput>) -> Self { + let container = gtk::Box::new(gtk::Orientation::Horizontal, 0); + container.set_margin_top(10); + container.set_margin_bottom(10); + container.set_margin_start(10); + container.set_margin_end(10); + + let selected_id: Rc<RefCell<Option<Uuid>>> = Rc::new(RefCell::new(None)); + + // Left sidebar + let sidebar = gtk::Box::new(gtk::Orientation::Vertical, 5); + sidebar.set_width_request(200); + + let sidebar_label = gtk::Label::new(Some("Templates")); + sidebar_label.add_css_class("heading"); + sidebar_label.set_halign(gtk::Align::Start); + sidebar_label.set_margin_start(5); + sidebar.append(&sidebar_label); + + // Template list + let scrolled = gtk::ScrolledWindow::new(); + scrolled.set_vexpand(true); + + let list_box = gtk::ListBox::new(); + list_box.set_selection_mode(gtk::SelectionMode::Single); + list_box.add_css_class("boxed-list"); + + scrolled.set_child(Some(&list_box)); + sidebar.append(&scrolled); + + // Sidebar buttons + let button_box = gtk::Box::new(gtk::Orientation::Horizontal, 5); + button_box.set_halign(gtk::Align::End); + button_box.set_margin_top(5); + + let add_button = gtk::Button::from_icon_name("list-add-symbolic"); + add_button.set_tooltip_text(Some("Add template")); + { + let sender = sender.clone(); + add_button.connect_clicked(move |_| { + // Show a simple dialog to get the template name + show_add_template_dialog(sender.clone()); + }); + } + button_box.append(&add_button); + + let remove_button = gtk::Button::from_icon_name("list-remove-symbolic"); + remove_button.set_tooltip_text(Some("Remove template")); + { + let sender = sender.clone(); + let selected_id = selected_id.clone(); + remove_button.connect_clicked(move |_| { + if let Some(id) = *selected_id.borrow() { + sender.emit(PreferencesInput::RemoveTemplate(id)); + } + }); + } + button_box.append(&remove_button); + + sidebar.append(&button_box); + container.append(&sidebar); + + // Separator + let separator = gtk::Separator::new(gtk::Orientation::Vertical); + separator.set_margin_start(10); + separator.set_margin_end(10); + container.append(&separator); + + // Right side: editor + let editor_box = gtk::Box::new(gtk::Orientation::Vertical, 5); + editor_box.set_hexpand(true); + + let editor_label = gtk::Label::new(Some("Template Content")); + editor_label.add_css_class("heading"); + editor_label.set_halign(gtk::Align::Start); + editor_box.append(&editor_label); + + let editor_scrolled = gtk::ScrolledWindow::new(); + editor_scrolled.set_vexpand(true); + editor_scrolled.set_hexpand(true); + + let editor_buffer = sourceview5::Buffer::new(None); + editor_buffer.set_highlight_syntax(true); + + // Try to set wmap language + let language_manager = sourceview5::LanguageManager::default(); + if let Some(language) = language_manager.language("wmap") { + editor_buffer.set_language(Some(&language)); + } + + let editor = sourceview5::View::with_buffer(&editor_buffer); + editor.set_monospace(true); + editor.set_show_line_numbers(true); + editor.set_tab_width(2); + + // Connect buffer changes to save + { + let sender = sender.clone(); + let selected_id = selected_id.clone(); + editor_buffer.connect_changed(move |buffer| { + if let Some(id) = *selected_id.borrow() { + let start = buffer.start_iter(); + let end = buffer.end_iter(); + let content = buffer.text(&start, &end, false).to_string(); + sender.emit(PreferencesInput::SetTemplateContent(id, content)); + } + }); + } + + editor_scrolled.set_child(Some(&editor)); + editor_box.append(&editor_scrolled); + container.append(&editor_box); + + let page = Self { + container, + list_box, + editor, + editor_buffer, + selected_id, + sender, + }; + + page.populate_templates(prefs); + + // Select first template if available + if let Some(first) = prefs.map_templates.first() { + *page.selected_id.borrow_mut() = Some(first.id); + page.editor_buffer.set_text(&first.content); + } + + page + } + + pub fn widget(&self) -> gtk::Box { + self.container.clone() + } + + pub fn refresh(&self, prefs: &UserPreferences) { + // Clear existing rows + while let Some(child) = self.list_box.first_child() { + self.list_box.remove(&child); + } + self.populate_templates(prefs); + + // Update editor if selected template still exists + let selected = *self.selected_id.borrow(); + if let Some(id) = selected { + if let Some(template) = prefs.map_templates.iter().find(|t| t.id == id) { + // Only update if content differs to avoid cursor jumping + let current = { + let start = self.editor_buffer.start_iter(); + let end = self.editor_buffer.end_iter(); + self.editor_buffer.text(&start, &end, false).to_string() + }; + if current != template.content { + self.editor_buffer.set_text(&template.content); + } + } else { + // Selected template was deleted, select first + if let Some(first) = prefs.map_templates.first() { + *self.selected_id.borrow_mut() = Some(first.id); + self.editor_buffer.set_text(&first.content); + } else { + *self.selected_id.borrow_mut() = None; + self.editor_buffer.set_text(""); + } + } + } + } + + fn populate_templates(&self, prefs: &UserPreferences) { + for template in &prefs.map_templates { + let row = self.create_template_row(template); + self.list_box.append(&row); + } + + // Connect selection changed + { + let selected_id = self.selected_id.clone(); + let editor_buffer = self.editor_buffer.clone(); + let _sender = self.sender.clone(); + + // We need to store templates for the closure + let templates: Vec<Template> = prefs.map_templates.clone(); + + self.list_box.connect_row_selected(move |_, row| { + if let Some(row) = row { + let index = row.index() as usize; + if let Some(template) = templates.get(index) { + *selected_id.borrow_mut() = Some(template.id); + editor_buffer.set_text(&template.content); + } + } + }); + } + } + + fn create_template_row(&self, template: &Template) -> gtk::ListBoxRow { + let row = gtk::ListBoxRow::new(); + let hbox = gtk::Box::new(gtk::Orientation::Horizontal, 10); + hbox.set_margin_top(8); + hbox.set_margin_bottom(8); + hbox.set_margin_start(8); + hbox.set_margin_end(8); + + // Default indicator button + let default_button = gtk::Button::new(); + if template.is_default { + default_button.set_icon_name("emblem-default-symbolic"); + default_button.set_tooltip_text(Some("Default template")); + } else { + default_button.set_icon_name("radio-symbolic"); + default_button.set_tooltip_text(Some("Set as default")); + } + default_button.add_css_class("flat"); + { + let sender = self.sender.clone(); + let id = template.id; + default_button.connect_clicked(move |_| { + sender.emit(PreferencesInput::SetDefaultTemplate(id)); + }); + } + hbox.append(&default_button); + + // Template name + let name_label = gtk::Label::new(Some(&template.name)); + name_label.set_hexpand(true); + name_label.set_xalign(0.0); + hbox.append(&name_label); + + row.set_child(Some(&hbox)); + row + } +} + +fn show_add_template_dialog(sender: Sender<PreferencesInput>) { + let dialog = gtk::Dialog::with_buttons( + Some("New Template"), + None::<>k::Window>, + gtk::DialogFlags::MODAL | gtk::DialogFlags::DESTROY_WITH_PARENT, + &[ + ("Cancel", gtk::ResponseType::Cancel), + ("Add", gtk::ResponseType::Accept), + ], + ); + dialog.set_default_response(gtk::ResponseType::Accept); + + let content = dialog.content_area(); + content.set_margin_top(10); + content.set_margin_bottom(10); + content.set_margin_start(10); + content.set_margin_end(10); + content.set_spacing(10); + + let label = gtk::Label::new(Some("Template name:")); + label.set_halign(gtk::Align::Start); + content.append(&label); + + let entry = gtk::Entry::new(); + entry.set_placeholder_text(Some("My Template")); + entry.set_activates_default(true); + content.append(&entry); + + dialog.connect_response(move |dialog, response| { + if response == gtk::ResponseType::Accept { + let name = entry.text().to_string(); + if !name.is_empty() { + sender.emit(PreferencesInput::AddTemplate(name)); + } + } + dialog.close(); + }); + + dialog.present(); +} diff --git a/src/preferences/storage.rs b/src/preferences/storage.rs new file mode 100644 index 0000000..889b503 --- /dev/null +++ b/src/preferences/storage.rs @@ -0,0 +1,99 @@ +// 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 std::fs; +use std::path::PathBuf; + +use crate::constants; + +use super::models::{UserPreferences, UserPreferencesJson}; + +/// Returns the XDG config directory path for preferences +pub fn config_dir() -> PathBuf { + dirs::config_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join(constants::APP_ID) +} + +/// Returns the preferences file path +pub fn preferences_path() -> PathBuf { + config_dir().join("preferences.json") +} + +/// Loads preferences from disk, returning defaults if not found +pub fn load() -> UserPreferences { + let path = preferences_path(); + + if path.exists() { + match fs::read_to_string(&path) { + Ok(content) => serde_json::from_str(&content).unwrap_or_default(), + Err(_) => UserPreferences::default(), + } + } else { + UserPreferences::default() + } +} + +/// Saves preferences to disk +pub fn save(prefs: &UserPreferences) -> Result<(), std::io::Error> { + let path = preferences_path(); + + // Ensure config directory exists + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + + let content = serde_json::to_string_pretty(prefs).map_err(std::io::Error::other)?; + + fs::write(path, content) +} + +/// Exports preferences to JSON string (for file export) +pub fn export_json(prefs: &UserPreferences) -> Option<String> { + let json_prefs = prefs.to_json(); + serde_json::to_string_pretty(&json_prefs).ok() +} + +/// Imports preferences from JSON string (partial updates supported) +/// Returns true on success, false on parse error +pub fn import_json(prefs: &mut UserPreferences, json: &str) -> bool { + match serde_json::from_str::<UserPreferencesJson>(json) { + Ok(imported) => { + prefs.update_from_json(&imported); + true + } + Err(_) => false, + } +} + +/// Saves preferences to a specific file path (for export dialog) +pub fn export_to_file(prefs: &UserPreferences, path: &PathBuf) -> Result<(), std::io::Error> { + let content = + export_json(prefs).ok_or_else(|| std::io::Error::other("Serialization failed"))?; + fs::write(path, content) +} + +/// Loads preferences from a specific file path (for import dialog) +pub fn import_from_file(prefs: &mut UserPreferences, path: &PathBuf) -> Result<(), std::io::Error> { + let content = fs::read_to_string(path)?; + if import_json(prefs, &content) { + Ok(()) + } else { + Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "Invalid preferences format", + )) + } +} diff --git a/src/preferences/window.rs b/src/preferences/window.rs new file mode 100644 index 0000000..d3874f2 --- /dev/null +++ b/src/preferences/window.rs @@ -0,0 +1,334 @@ +// Copyright (C) 2024 Rubén Beltrán del Río + +// Suppress warnings from relm4 view! macro internals +#![allow(unused_assignments)] + +// 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 std::path::PathBuf; + +use gtk::prelude::*; +use relm4::{ComponentParts, ComponentSender, SimpleComponent, gtk}; + +use super::models::UserPreferences; +use super::pages::{editor, general, map, stages, templates}; +use super::storage; + +/// Messages for preferences window +#[derive(Debug)] +pub enum PreferencesInput { + // General page actions + Export, + Import, + ImportFromFile(PathBuf), + ExportToFile(PathBuf), + ResetToDefaults, + + // Preference updates from pages + SetShowMapBackground(bool), + SetUseCustomFont(bool), + SetCustomFontName(String), + SetDefaultExportFormat(String), + SetUseSmartLabelPositioning(bool), + + SetUseCustomEditorFont(bool), + SetCustomEditorFontName(String), + SetEditorFontSize(f64), + SetSoftWrapLines(bool), + + // Templates + AddTemplate(String), + RemoveTemplate(uuid::Uuid), + SetTemplateContent(uuid::Uuid, String), + #[allow(dead_code)] + SetTemplateName(uuid::Uuid, String), + SetDefaultTemplate(uuid::Uuid), + + // Custom stages + AddCustomStage, + RemoveCustomStage(uuid::Uuid), + SetCustomStageName(uuid::Uuid, String), + SetCustomStageLabel(uuid::Uuid, StageLabel, String), + + // Close + Close, +} + +/// Which stage label to update (Roman numerals I-IV) +#[derive(Debug, Clone, Copy)] +#[allow(clippy::upper_case_acronyms)] +pub enum StageLabel { + I, + II, + III, + IV, +} + +/// Output messages to parent app +#[derive(Debug)] +pub enum PreferencesOutput { + PreferencesChanged(UserPreferences), + Closed, +} + +pub struct PreferencesWindow { + preferences: UserPreferences, + window: gtk::Window, + // Widget references for pages + general_page: general::GeneralPage, + editor_page: editor::EditorPage, + map_page: map::MapPage, + stages_page: stages::StagesPage, + templates_page: templates::TemplatesPage, +} + +#[relm4::component(pub)] +impl SimpleComponent for PreferencesWindow { + type Init = UserPreferences; + type Input = PreferencesInput; + type Output = PreferencesOutput; + + view! { + gtk::Window { + set_title: Some("Preferences"), + set_default_width: 700, + set_default_height: 500, + set_modal: true, + set_resizable: true, + + connect_close_request[sender] => move |_| { + sender.input(PreferencesInput::Close); + gtk::glib::Propagation::Proceed + }, + + gtk::Box { + set_orientation: gtk::Orientation::Vertical, + + gtk::HeaderBar { + #[wrap(Some)] + set_title_widget = >k::StackSwitcher { + set_stack: Some(&stack), + }, + }, + + #[name = "stack"] + gtk::Stack { + set_transition_type: gtk::StackTransitionType::SlideLeftRight, + set_vexpand: true, + + add_titled[Some("general"), "General"] = &model.general_page.widget() -> gtk::Box {}, + add_titled[Some("editor"), "Editor"] = &model.editor_page.widget() -> gtk::Box {}, + add_titled[Some("map"), "Map"] = &model.map_page.widget() -> gtk::Box {}, + add_titled[Some("stages"), "Stages"] = &model.stages_page.widget() -> gtk::Box {}, + add_titled[Some("templates"), "Templates"] = &model.templates_page.widget() -> gtk::Box {}, + }, + }, + } + } + + fn init( + prefs: Self::Init, + root: Self::Root, + sender: ComponentSender<Self>, + ) -> ComponentParts<Self> { + let general_page = general::GeneralPage::new(sender.input_sender().clone()); + let editor_page = editor::EditorPage::new(&prefs, sender.input_sender().clone()); + let map_page = map::MapPage::new(&prefs, sender.input_sender().clone()); + let stages_page = stages::StagesPage::new(&prefs, sender.input_sender().clone()); + let templates_page = templates::TemplatesPage::new(&prefs, sender.input_sender().clone()); + + let model = PreferencesWindow { + preferences: prefs, + window: root.clone(), + general_page, + editor_page, + map_page, + stages_page, + templates_page, + }; + + let widgets = view_output!(); + + ComponentParts { model, widgets } + } + + fn update(&mut self, message: Self::Input, sender: ComponentSender<Self>) { + match message { + // General page actions + PreferencesInput::Export => { + general::show_export_dialog(sender.input_sender(), &self.window); + } + PreferencesInput::Import => { + general::show_import_dialog(sender.input_sender(), &self.window); + } + PreferencesInput::ImportFromFile(path) => { + if storage::import_from_file(&mut self.preferences, &path).is_ok() { + self.sync_ui_from_preferences(); + self.save_and_notify(&sender); + } + } + PreferencesInput::ExportToFile(path) => { + let _ = storage::export_to_file(&self.preferences, &path); + } + PreferencesInput::ResetToDefaults => { + self.preferences = UserPreferences::default(); + self.sync_ui_from_preferences(); + self.save_and_notify(&sender); + } + + // Map preferences + PreferencesInput::SetShowMapBackground(v) => { + self.preferences.show_map_background = v; + self.save_and_notify(&sender); + } + PreferencesInput::SetUseCustomFont(v) => { + self.preferences.use_custom_font = v; + self.save_and_notify(&sender); + } + PreferencesInput::SetCustomFontName(v) => { + self.preferences.custom_font_name = v; + self.save_and_notify(&sender); + } + PreferencesInput::SetDefaultExportFormat(v) => { + self.preferences.default_export_format = v; + self.save_and_notify(&sender); + } + PreferencesInput::SetUseSmartLabelPositioning(v) => { + self.preferences.use_smart_label_positioning = v; + self.save_and_notify(&sender); + } + + // Editor preferences + PreferencesInput::SetUseCustomEditorFont(v) => { + self.preferences.use_custom_editor_font = v; + self.save_and_notify(&sender); + } + PreferencesInput::SetCustomEditorFontName(v) => { + self.preferences.custom_editor_font_name = v; + self.save_and_notify(&sender); + } + PreferencesInput::SetEditorFontSize(v) => { + self.preferences.editor_font_size = v; + self.save_and_notify(&sender); + } + PreferencesInput::SetSoftWrapLines(v) => { + self.preferences.soft_wrap_lines = v; + self.save_and_notify(&sender); + } + + // Templates + PreferencesInput::AddTemplate(name) => { + let template = super::models::Template::new(name, String::new(), false); + self.preferences.map_templates.push(template); + self.templates_page.refresh(&self.preferences); + self.save_and_notify(&sender); + } + PreferencesInput::RemoveTemplate(id) => { + self.preferences.map_templates.retain(|t| t.id != id); + self.templates_page.refresh(&self.preferences); + self.save_and_notify(&sender); + } + PreferencesInput::SetTemplateContent(id, content) => { + if let Some(template) = self + .preferences + .map_templates + .iter_mut() + .find(|t| t.id == id) + { + template.content = content; + self.save_and_notify(&sender); + } + } + PreferencesInput::SetTemplateName(id, name) => { + if let Some(template) = self + .preferences + .map_templates + .iter_mut() + .find(|t| t.id == id) + { + template.name = name; + self.templates_page.refresh(&self.preferences); + self.save_and_notify(&sender); + } + } + PreferencesInput::SetDefaultTemplate(id) => { + self.preferences.set_default_template(id); + self.templates_page.refresh(&self.preferences); + self.save_and_notify(&sender); + } + + // Custom stages + PreferencesInput::AddCustomStage => { + let stage = super::models::CustomStage::placeholder_default(); + self.preferences.custom_stages.push(stage); + self.stages_page.refresh(&self.preferences); + self.save_and_notify(&sender); + } + PreferencesInput::RemoveCustomStage(id) => { + self.preferences.custom_stages.retain(|s| s.id != id); + self.stages_page.refresh(&self.preferences); + self.save_and_notify(&sender); + } + PreferencesInput::SetCustomStageName(id, name) => { + if let Some(stage) = self + .preferences + .custom_stages + .iter_mut() + .find(|s| s.id == id) + { + stage.name = name; + self.save_and_notify(&sender); + } + } + PreferencesInput::SetCustomStageLabel(id, label, value) => { + if let Some(stage) = self + .preferences + .custom_stages + .iter_mut() + .find(|s| s.id == id) + { + match label { + StageLabel::I => stage.stage.i = value, + StageLabel::II => stage.stage.ii = value, + StageLabel::III => stage.stage.iii = value, + StageLabel::IV => stage.stage.iv = value, + } + self.save_and_notify(&sender); + } + } + + PreferencesInput::Close => { + sender.output(PreferencesOutput::Closed).ok(); + } + } + } +} + +impl PreferencesWindow { + fn save_and_notify(&self, sender: &ComponentSender<Self>) { + let _ = storage::save(&self.preferences); + sender + .output(PreferencesOutput::PreferencesChanged( + self.preferences.clone(), + )) + .ok(); + } + + fn sync_ui_from_preferences(&mut self) { + self.editor_page.sync_from(&self.preferences); + self.map_page.sync_from(&self.preferences); + self.stages_page.refresh(&self.preferences); + self.templates_page.refresh(&self.preferences); + } +} |