aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorRubén Beltrán del Río <jj@r.bdr.sh>2026-01-16 08:26:35 +0100
committerRubén Beltrán del Río <jj@r.bdr.sh>2026-01-16 11:57:13 +0100
commit81cd06b3334f2f91f6a1ab62a28e11c7d0c677eb (patch)
treef0b0cf37b406eda2dec8db4382aeb5f606cd1f0d /src
parent4211b2ae06777d5bbe8261a1ab5c0dd057829a35 (diff)
Add preferences
Diffstat (limited to 'src')
-rw-r--r--src/actions.rs10
-rw-r--r--src/file_registry.rs10
-rw-r--r--src/handlers/export.rs4
-rw-r--r--src/handlers/view.rs2
-rw-r--r--src/main.rs128
-rw-r--r--src/preferences/mod.rs22
-rw-r--r--src/preferences/models.rs266
-rw-r--r--src/preferences/pages/editor.rs163
-rw-r--r--src/preferences/pages/general.rs155
-rw-r--r--src/preferences/pages/map.rs187
-rw-r--r--src/preferences/pages/mod.rs20
-rw-r--r--src/preferences/pages/stages.rs252
-rw-r--r--src/preferences/pages/templates.rs312
-rw-r--r--src/preferences/storage.rs99
-rw-r--r--src/preferences/window.rs334
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 = &gtk::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(&gtk::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(&gtk::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(&gtk::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: &gtk::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: &gtk::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(&gtk::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(&gtk::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(&gtk::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::<&gtk::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 = &gtk::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);
+ }
+}