aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorRubén Beltrán del Río <jj@r.bdr.sh>2026-03-27 16:10:46 +0100
committerRubén Beltrán del Río <jj@r.bdr.sh>2026-03-30 13:39:14 +0200
commitd01a4df546b553128400f6c68eefa8368b4113a8 (patch)
treed290ca78b1215fb450ec7850a74b79fd0ae9707c /src
parent4dec344a7bcbf1d7d0bbbf985109df816470ecc6 (diff)
Use actual components for preference pages
Diffstat (limited to 'src')
-rw-r--r--src/actions.rs36
-rw-r--r--src/components/footer.rs18
-rw-r--r--src/components/header.rs4
-rw-r--r--src/components/mod.rs5
-rw-r--r--src/components/preference_pages/editor.rs223
-rw-r--r--src/components/preference_pages/general.rs84
-rw-r--r--src/components/preference_pages/map.rs231
-rw-r--r--src/components/preference_pages/mod.rs (renamed from src/preferences/pages/mod.rs)16
-rw-r--r--src/components/preference_pages/stages.rs208
-rw-r--r--src/components/preference_pages/templates.rs443
-rw-r--r--src/components/preferences_window.rs455
-rw-r--r--src/components/stage_form.rs141
-rw-r--r--src/components/switch.rs79
-rw-r--r--src/components/template_row.rs122
-rw-r--r--src/handlers/preferences.rs12
-rw-r--r--src/main.rs5
-rw-r--r--src/preferences/mod.rs3
-rw-r--r--src/preferences/models.rs9
-rw-r--r--src/preferences/pages/editor.rs222
-rw-r--r--src/preferences/pages/general.rs144
-rw-r--r--src/preferences/pages/map.rs212
-rw-r--r--src/preferences/pages/stages.rs253
-rw-r--r--src/preferences/pages/templates.rs349
-rw-r--r--src/preferences/window.rs371
-rw-r--r--src/ui_helpers.rs34
25 files changed, 2074 insertions, 1605 deletions
diff --git a/src/actions.rs b/src/actions.rs
index 09b7d4b..e5ea0e5 100644
--- a/src/actions.rs
+++ b/src/actions.rs
@@ -18,6 +18,7 @@ use std::path::PathBuf;
use wmap_renderer::StageType;
use crate::preferences::UserPreferences;
+use crate::preferences::models::StageLabel;
#[derive(Debug, Clone, Copy)]
pub enum ImageFormat {
@@ -74,3 +75,38 @@ pub enum Action {
PreferencesWindowClosed,
ReloadPreferences,
}
+
+#[derive(Debug, Clone)]
+pub enum PreferencesAction {
+ // 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),
+ SetTemplateName(uuid::Uuid, String),
+ SetDefaultTemplate(uuid::Uuid),
+
+ // Custom stages
+ AddCustomStage,
+ RemoveCustomStage(uuid::Uuid),
+ SetCustomStageName(uuid::Uuid, String),
+ SetCustomStageLabel(uuid::Uuid, StageLabel, String),
+}
diff --git a/src/components/footer.rs b/src/components/footer.rs
index 1c6581d..a7999be 100644
--- a/src/components/footer.rs
+++ b/src/components/footer.rs
@@ -1,3 +1,19 @@
+// Map, wardley map editor for linux
+// Copyright (C) 2026 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 Affero 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 Affero General Public License for more details.
+
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see <http://www.gnu.org/licenses/>.
+
use gtk::prelude::*;
use relm4::actions::ActionablePlus;
use relm4::prelude::*;
@@ -59,7 +75,7 @@ impl SimpleComponent for Footer {
set_value_pos: gtk::PositionType::Left,
set_width_request: 100,
connect_value_changed[sender] => move |scale| {
- let _ = sender.output(Action::Zoom(scale.value()));
+ sender.output(Action::Zoom(scale.value())).ok();
}
},
diff --git a/src/components/header.rs b/src/components/header.rs
index 7977a5b..f41d6a1 100644
--- a/src/components/header.rs
+++ b/src/components/header.rs
@@ -140,7 +140,9 @@ impl SimpleComponent for Header {
if index < self.available_stage_types.len()
&& let Some(stage_type) = self.available_stage_types.get(index)
{
- let _ = sender.output(Action::StageTypeSelected(stage_type.clone()));
+ sender
+ .output(Action::StageTypeSelected(stage_type.clone()))
+ .ok();
}
}
}
diff --git a/src/components/mod.rs b/src/components/mod.rs
index 1194b9f..93cceea 100644
--- a/src/components/mod.rs
+++ b/src/components/mod.rs
@@ -15,3 +15,8 @@
// along with this program. If not, see <http://www.gnu.org/licenses/>.
pub mod footer;
pub mod header;
+pub mod preference_pages;
+pub mod preferences_window;
+pub mod stage_form;
+pub mod switch;
+pub mod template_row;
diff --git a/src/components/preference_pages/editor.rs b/src/components/preference_pages/editor.rs
new file mode 100644
index 0000000..28a8302
--- /dev/null
+++ b/src/components/preference_pages/editor.rs
@@ -0,0 +1,223 @@
+// Map, wardley map editor for linux
+// Copyright (C) 2026 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 Affero 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 Affero General Public License for more details.
+
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+use gtk::prelude::*;
+use relm4::prelude::*;
+
+use crate::tr;
+
+use crate::actions::PreferencesAction;
+use crate::components::switch;
+
+const DEFAULT_FONT_SIZE: f64 = 14.0;
+
+pub struct EditorInit {
+ pub font_size: f64,
+ pub soft_wrap: bool,
+ pub use_custom_font: bool,
+ pub custom_font: String,
+}
+pub struct Editor {
+ soft_wrap_switch: Controller<switch::Switch>,
+ custom_font_switch: Controller<switch::Switch>,
+ soft_wrap: bool,
+ use_custom_font: bool,
+ font_size: f64,
+ custom_font: String,
+}
+#[derive(Debug, Clone)]
+pub enum EditorAction {
+ SetFontSize(f64),
+ SetSoftWrap(bool),
+ SetUseCustomFont(bool),
+ SetCustomFont(String),
+
+ // Internal
+ ChangeFontSize(f64),
+ ChangeSoftWrap(bool),
+ ChangeUseCustomFont(bool),
+ ChangeFontDescription(gtk::FontDialogButton),
+}
+
+#[relm4::component(pub)]
+impl SimpleComponent for Editor {
+ type Init = EditorInit;
+ type Input = EditorAction;
+ type Output = PreferencesAction;
+ view! {
+ gtk::Box {
+ set_orientation: gtk::Orientation::Vertical,
+ set_margin_all: 20,
+ set_spacing: 20,
+ set_valign: gtk::Align::Start,
+
+ gtk::Box {
+ set_orientation: gtk::Orientation::Vertical,
+ set_spacing: 10,
+
+ gtk::Label {
+ set_label: &tr!("preferences.editor.font_size.title"),
+ add_css_class: "heading",
+ set_halign: gtk::Align::Start,
+ },
+
+ // TODO: Remove magic numbers.
+ gtk::Scale::with_range(gtk::Orientation::Horizontal, 7.0, 21.0, 1.0) {
+ #[watch]
+ #[block_signal(size_handler)]
+ set_value: model.font_size,
+ set_draw_value: true,
+ set_hexpand: true,
+
+ set_format_value_func => |_, value| {
+ #[allow(clippy::cast_possible_truncation)]
+ #[allow(clippy::float_cmp)]
+ if value == DEFAULT_FONT_SIZE {
+ format!(
+ "{} ({})",
+ value as i32,
+ tr!("preferences.editor.default_font_size")
+ )
+ } else {
+ format!("{}", value as i32)
+ }
+ },
+
+ connect_value_changed[sender] => move |scale| {
+ sender.input(EditorAction::ChangeFontSize(scale.value()));
+ } @size_handler,
+ }
+ },
+
+ gtk::Label {
+ set_label: &tr!("preferences.editor.editor_style.title"),
+ add_css_class: "heading",
+ set_halign: gtk::Align::Start,
+ set_margin_top: 10,
+ },
+
+ model.soft_wrap_switch.widget(),
+ model.custom_font_switch.widget(),
+
+ gtk::Box {
+ set_orientation: gtk::Orientation::Horizontal,
+ set_spacing: 10,
+
+ gtk::Label {
+ set_label: &tr!("preferences.editor.font"),
+ },
+
+ gtk::FontDialogButton::new(Some(gtk::FontDialog::new())) {
+ #[watch]
+ #[block_signal(font_handler)]
+ set_font_desc: &gtk::pango::FontDescription::from_string(&format!(
+ "{} {}",
+ model.custom_font, model.font_size as i32
+ )),
+ #[watch]
+ set_sensitive: model.use_custom_font,
+ set_hexpand: true,
+ set_use_font: true,
+ set_use_size: false,
+
+ connect_font_desc_notify[sender] => move |button| {
+ sender.input(EditorAction::ChangeFontDescription(button.clone()));
+ } @font_handler,
+ }
+ }
+ }
+ }
+
+ fn init(
+ init: Self::Init,
+ root: Self::Root,
+ sender: ComponentSender<Self>,
+ ) -> ComponentParts<Self> {
+ let soft_wrap_switch = switch::Switch::builder()
+ .launch(switch::SwitchInit {
+ label: tr!("preferences.editor.editor_style.soft_wrap_lines"),
+ state: init.soft_wrap,
+ })
+ .forward(sender.input_sender(), move |output| match output {
+ switch::SwitchOutput::SetState(state) => EditorAction::ChangeSoftWrap(state),
+ });
+
+ let custom_font_switch = switch::Switch::builder()
+ .launch(switch::SwitchInit {
+ label: tr!("preferences.editor.editor_style.use_custom_font"),
+ state: init.use_custom_font,
+ })
+ .forward(sender.input_sender(), move |output| match output {
+ switch::SwitchOutput::SetState(state) => EditorAction::ChangeUseCustomFont(state),
+ });
+
+ let model = Self {
+ font_size: init.font_size,
+ soft_wrap: init.soft_wrap,
+ use_custom_font: init.use_custom_font,
+ custom_font: init.custom_font,
+ soft_wrap_switch,
+ custom_font_switch,
+ };
+
+ let widgets = view_output!();
+ ComponentParts { model, widgets }
+ }
+
+ fn update(&mut self, message: Self::Input, sender: ComponentSender<Self>) {
+ match message {
+ EditorAction::SetFontSize(font_size) => self.font_size = font_size,
+ EditorAction::SetSoftWrap(soft_wrap) => self.soft_wrap = soft_wrap,
+ EditorAction::SetUseCustomFont(use_custom_font) => {
+ self.use_custom_font = use_custom_font
+ }
+ EditorAction::SetCustomFont(custom_font) => self.custom_font = custom_font,
+
+ EditorAction::ChangeFontSize(size) => {
+ sender
+ .output(PreferencesAction::SetEditorFontSize(size))
+ .ok();
+ }
+ EditorAction::ChangeFontDescription(button) => {
+ if let Some(font) = button.font_desc() {
+ if let Some(family) = font.family() {
+ sender
+ .output(PreferencesAction::SetCustomEditorFontName(
+ family.to_string(),
+ ))
+ .ok();
+ }
+ let size = font.size() / gtk::pango::SCALE;
+ if size > 0 {
+ sender
+ .output(PreferencesAction::SetEditorFontSize(f64::from(size)))
+ .ok();
+ }
+ }
+ }
+ EditorAction::ChangeSoftWrap(state) => {
+ sender
+ .output(PreferencesAction::SetSoftWrapLines(state))
+ .ok();
+ }
+ EditorAction::ChangeUseCustomFont(state) => {
+ sender
+ .output(PreferencesAction::SetUseCustomEditorFont(state))
+ .ok();
+ }
+ }
+ }
+}
diff --git a/src/components/preference_pages/general.rs b/src/components/preference_pages/general.rs
new file mode 100644
index 0000000..351c4a5
--- /dev/null
+++ b/src/components/preference_pages/general.rs
@@ -0,0 +1,84 @@
+// Map, wardley map editor for linux
+// Copyright (C) 2026 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 Affero 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 Affero General Public License for more details.
+
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+use gtk::prelude::*;
+use relm4::prelude::*;
+
+use crate::tr;
+
+use crate::actions::PreferencesAction;
+
+pub struct General;
+
+#[relm4::component(pub)]
+impl SimpleComponent for General {
+ type Init = ();
+ type Input = ();
+ type Output = PreferencesAction;
+
+ view! {
+ gtk::Box {
+ set_orientation: gtk::Orientation::Vertical,
+ set_margin_all: 20,
+ set_spacing: 20,
+ set_valign: gtk::Align::Start,
+
+ gtk::Label {
+ set_label: &tr!("preferences.general.preferences.title"),
+ add_css_class: "heading",
+ set_halign: gtk::Align::Start,
+ },
+ gtk::Box {
+ set_orientation: gtk::Orientation::Horizontal,
+ set_spacing: 10,
+
+ gtk::Button::with_label(&tr!("preferences.general.preferences.export")) {
+ connect_clicked => {
+ sender.output(PreferencesAction::Export).ok();
+ }
+ },
+ gtk::Button::with_label(&tr!("preferences.general.preferences.import")) {
+ connect_clicked => {
+ sender.output(PreferencesAction::Import).ok();
+ }
+ },
+ gtk::Button::with_label(&tr!("preferences.general.preferences.reset")) {
+ add_css_class: "destructive-action",
+ connect_clicked => {
+ sender.output(PreferencesAction::ResetToDefaults).ok();
+ }
+ },
+ },
+
+ gtk::Label {
+ set_label: &tr!("preferences.general.explanation"),
+ add_css_class: "dim-label",
+ set_halign: gtk::Align::Start,
+ set_wrap: true
+ },
+ }
+ }
+
+ fn init(
+ _init: Self::Init,
+ root: Self::Root,
+ sender: ComponentSender<Self>,
+ ) -> ComponentParts<Self> {
+ let model = Self {};
+ let widgets = view_output!();
+ ComponentParts { model, widgets }
+ }
+}
diff --git a/src/components/preference_pages/map.rs b/src/components/preference_pages/map.rs
new file mode 100644
index 0000000..abeabf9
--- /dev/null
+++ b/src/components/preference_pages/map.rs
@@ -0,0 +1,231 @@
+// Map, wardley map editor for linux
+// Copyright (C) 2026 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 Affero 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 Affero General Public License for more details.
+
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+use gtk::prelude::*;
+use relm4::prelude::*;
+
+use crate::tr;
+
+use crate::actions::PreferencesAction;
+use crate::components::switch;
+
+pub struct MapInit {
+ pub show_background: bool,
+ pub use_smart_label_positioning: bool,
+ pub use_custom_font: bool,
+ pub custom_font: String,
+ pub default_export_format: String,
+}
+pub struct Map {
+ show_background_switch: Controller<switch::Switch>,
+ smart_label_positioning_switch: Controller<switch::Switch>,
+ custom_font_switch: Controller<switch::Switch>,
+ show_background: bool,
+ use_smart_label_positioning: bool,
+ use_custom_font: bool,
+ custom_font: String,
+ default_export_format: String,
+}
+#[derive(Debug, Clone)]
+pub enum MapAction {
+ SetShowBackground(bool),
+ SetUseCustomFont(bool),
+ SetUseSmartLabelPositioning(bool),
+ SetCustomFont(String),
+ SetDefaultExportFormat(String),
+
+ // Internal
+ ChangeShowBackground(bool),
+ ChangeUseCustomFont(bool),
+ ChangeUseSmartLabelPositioning(bool),
+ ChangeFontDescription(gtk::FontDialogButton),
+ ChangeDefaultExportFormat(String),
+}
+
+#[relm4::component(pub)]
+impl SimpleComponent for Map {
+ type Init = MapInit;
+ type Input = MapAction;
+ type Output = PreferencesAction;
+ view! {
+ gtk::Box {
+ set_orientation: gtk::Orientation::Vertical,
+ set_margin_all: 20,
+ set_spacing: 20,
+ set_valign: gtk::Align::Start,
+
+ gtk::Label {
+ set_label: &tr!("preferences.map.map_style.title"),
+ add_css_class: "heading",
+ set_halign: gtk::Align::Start,
+ },
+ model.show_background_switch.widget(),
+ model.smart_label_positioning_switch.widget(),
+ model.custom_font_switch.widget(),
+
+ gtk::Box {
+ set_orientation: gtk::Orientation::Horizontal,
+ set_spacing: 10,
+
+ gtk::Label {
+ set_label: &tr!("preferences.map.font"),
+ },
+
+ gtk::FontDialogButton::new(Some(gtk::FontDialog::new())) {
+ #[watch]
+ #[block_signal(font_handler)]
+ set_font_desc: &gtk::pango::FontDescription::from_string(&model.custom_font),
+ #[watch]
+ set_sensitive: model.use_custom_font,
+ set_hexpand: true,
+ set_use_font: true,
+ set_use_size: false,
+
+ connect_font_desc_notify[sender] => move |button| {
+ sender.input(MapAction::ChangeFontDescription(button.clone()));
+ } @font_handler,
+ }
+ },
+
+ gtk::Label {
+ set_label: &tr!("preferences.map.export.title"),
+ add_css_class: "heading",
+ set_halign: gtk::Align::Start,
+ set_margin_top: 10,
+ },
+
+ gtk::Box {
+ set_orientation: gtk::Orientation::Horizontal,
+ set_spacing: 10,
+
+ gtk::Label {
+ set_label: &tr!("preferences.map.export.default_format"),
+ },
+ gtk::DropDown::new(Some(gtk::StringList::new(&[
+ &tr!("export_formats.png"),
+ &tr!("export_formats.svg"),
+ ])), None::<gtk::Expression>) {
+ #[watch]
+ #[block_signal(export_handler)]
+ set_selected: match model.default_export_format.as_str() {
+ "svg" => 1,
+ _ => 0
+ },
+ connect_selected_notify[sender] => move |dropdown| {
+ let format = match dropdown.selected() {
+ 1 => "svg",
+ _ => "png",
+ };
+ sender.input(MapAction::ChangeDefaultExportFormat(format.to_string()));
+ } @export_handler,
+ }
+ },
+ }
+ }
+
+ fn init(
+ init: Self::Init,
+ root: Self::Root,
+ sender: ComponentSender<Self>,
+ ) -> ComponentParts<Self> {
+ let show_background_switch = switch::Switch::builder()
+ .launch(switch::SwitchInit {
+ label: tr!("preferences.map.map_style.show_background"),
+ state: init.show_background,
+ })
+ .forward(sender.input_sender(), move |output| match output {
+ switch::SwitchOutput::SetState(state) => MapAction::ChangeShowBackground(state),
+ });
+
+ let custom_font_switch = switch::Switch::builder()
+ .launch(switch::SwitchInit {
+ label: tr!("preferences.map.map_style.use_custom_font"),
+ state: init.use_custom_font,
+ })
+ .forward(sender.input_sender(), move |output| match output {
+ switch::SwitchOutput::SetState(state) => MapAction::ChangeUseCustomFont(state),
+ });
+
+ let smart_label_positioning_switch = switch::Switch::builder()
+ .launch(switch::SwitchInit {
+ label: tr!("preferences.map.map_style.use_smart_label_positioning"),
+ state: init.use_smart_label_positioning,
+ })
+ .forward(sender.input_sender(), move |output| match output {
+ switch::SwitchOutput::SetState(state) => {
+ MapAction::ChangeUseSmartLabelPositioning(state)
+ }
+ });
+
+ let model = Self {
+ show_background: init.show_background,
+ use_smart_label_positioning: init.use_smart_label_positioning,
+ use_custom_font: init.use_custom_font,
+ custom_font: init.custom_font,
+ default_export_format: init.default_export_format,
+ show_background_switch,
+ smart_label_positioning_switch,
+ custom_font_switch,
+ };
+
+ let widgets = view_output!();
+ ComponentParts { model, widgets }
+ }
+
+ fn update(&mut self, message: Self::Input, sender: ComponentSender<Self>) {
+ match message {
+ MapAction::SetUseCustomFont(use_custom_font) => self.use_custom_font = use_custom_font,
+ MapAction::SetShowBackground(show_background) => self.show_background = show_background,
+ MapAction::SetUseSmartLabelPositioning(use_smart_label_positioning) => {
+ self.use_smart_label_positioning = use_smart_label_positioning
+ }
+ MapAction::SetCustomFont(custom_font) => self.custom_font = custom_font,
+ MapAction::SetDefaultExportFormat(default_export_format) => {
+ self.default_export_format = default_export_format
+ }
+
+ MapAction::ChangeFontDescription(button) => {
+ if let Some(font) = button.font_desc()
+ && let Some(family) = font.family()
+ {
+ sender
+ .output(PreferencesAction::SetCustomFontName(family.to_string()))
+ .ok();
+ }
+ }
+ MapAction::ChangeShowBackground(state) => {
+ sender
+ .output(PreferencesAction::SetShowMapBackground(state))
+ .ok();
+ }
+ MapAction::ChangeUseCustomFont(state) => {
+ sender
+ .output(PreferencesAction::SetUseCustomFont(state))
+ .ok();
+ }
+ MapAction::ChangeUseSmartLabelPositioning(state) => {
+ sender
+ .output(PreferencesAction::SetUseSmartLabelPositioning(state))
+ .ok();
+ }
+ MapAction::ChangeDefaultExportFormat(format) => {
+ sender
+ .output(PreferencesAction::SetDefaultExportFormat(format))
+ .ok();
+ }
+ }
+ }
+}
diff --git a/src/preferences/pages/mod.rs b/src/components/preference_pages/mod.rs
index 2ace921..9e37bcb 100644
--- a/src/preferences/pages/mod.rs
+++ b/src/components/preference_pages/mod.rs
@@ -14,8 +14,14 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
-pub mod editor;
-pub mod general;
-pub mod map;
-pub mod stages;
-pub mod templates;
+mod editor;
+mod general;
+mod map;
+mod stages;
+mod templates;
+
+pub use editor::{Editor, EditorAction, EditorInit};
+pub use general::General;
+pub use map::{Map, MapAction, MapInit};
+pub use stages::{Stages, StagesAction};
+pub use templates::{Templates, TemplatesAction, TemplatesInit};
diff --git a/src/components/preference_pages/stages.rs b/src/components/preference_pages/stages.rs
new file mode 100644
index 0000000..9dc2fd6
--- /dev/null
+++ b/src/components/preference_pages/stages.rs
@@ -0,0 +1,208 @@
+// Map, wardley map editor for linux
+// Copyright (C) 2026 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 Affero 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 Affero General Public License for more details.
+
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+use gtk::prelude::*;
+use relm4::prelude::*;
+use uuid::Uuid;
+
+use crate::tr;
+
+use crate::actions::PreferencesAction;
+use crate::components::stage_form;
+use crate::preferences::models::{CustomStage, StageLabel};
+
+pub struct Stages {
+ stage_forms: FactoryVecDeque<stage_form::StageForm>,
+}
+#[derive(Debug, Clone)]
+pub enum StagesAction {
+ CustomStageAdded(CustomStage),
+ CustomStageUpdated(CustomStage),
+ CustomStageRemoved(Uuid),
+
+ // Internal
+ AddCustomStage,
+ ChangeName(Uuid, String),
+ ChangeLabel(Uuid, StageLabel, String),
+ RemoveCustomStage(Uuid),
+}
+
+#[relm4::component(pub)]
+impl SimpleComponent for Stages {
+ type Init = Vec<CustomStage>;
+ type Input = StagesAction;
+ type Output = PreferencesAction;
+ view! {
+ gtk::Box {
+ set_orientation: gtk::Orientation::Vertical,
+ set_margin_all: 20,
+ set_spacing: 10,
+ set_valign: gtk::Align::Start,
+
+ gtk::Label {
+ set_label: &tr!("preferences.stages.title"),
+ add_css_class: "heading",
+ set_halign: gtk::Align::Start,
+ },
+ gtk::Label {
+ set_label: &tr!("preferences.stages.explanation"),
+ add_css_class: "dim-label",
+ set_halign: gtk::Align::Start,
+ set_wrap: true,
+ set_margin_bottom: 10,
+ },
+
+ gtk::ScrolledWindow {
+ set_vexpand: true,
+ set_min_content_height: 200,
+
+ gtk::Box {
+ set_orientation: gtk::Orientation::Vertical,
+ set_spacing: 10,
+ gtk::Box {
+ set_orientation: gtk::Orientation::Horizontal,
+ set_margin_start: 10,
+ set_spacing: 5,
+ set_hexpand: true,
+ add_css_class: "dim-label",
+
+ gtk::Label {
+ set_label: &tr!("preferences.stages.column.name"),
+ set_xalign: 0.0,
+ set_hexpand: true,
+ set_width_chars: 15,
+ },
+ gtk::Label {
+ set_label: &tr!("preferences.stages.column.stage_i"),
+ set_xalign: 0.0,
+ set_hexpand: true,
+ set_width_chars: 12,
+ },
+ gtk::Label {
+ set_label: &tr!("preferences.stages.column.stage_ii"),
+ set_xalign: 0.0,
+ set_hexpand: true,
+ set_width_chars: 12,
+ },
+ gtk::Label {
+ set_label: &tr!("preferences.stages.column.stage_iii"),
+ set_xalign: 0.0,
+ set_hexpand: true,
+ set_width_chars: 12,
+ },
+ gtk::Label {
+ set_label: &tr!("preferences.stages.column.stage_iv"),
+ set_xalign: 0.0,
+ set_hexpand: true,
+ set_width_chars: 12,
+ },
+ gtk::Box {
+ set_orientation: gtk::Orientation::Horizontal,
+ set_spacing: 0,
+ set_width_request: 32
+ }
+ },
+ model.stage_forms.widget() -> &gtk::ListBox {
+ set_selection_mode: gtk::SelectionMode::None,
+ add_css_class: "boxed-list",
+ }
+ },
+ },
+
+ gtk::Box {
+ set_orientation: gtk::Orientation::Horizontal,
+ set_spacing: 10,
+ set_halign: gtk::Align::End,
+ gtk::Button::from_icon_name("list-add-symbolic") {
+ set_tooltip_text: Some(&tr!("preferences.stages.help.add")),
+ connect_clicked => StagesAction::AddCustomStage,
+ }
+ },
+ }
+ }
+
+ fn init(
+ init: Self::Init,
+ root: Self::Root,
+ sender: ComponentSender<Self>,
+ ) -> ComponentParts<Self> {
+ let mut stage_forms = FactoryVecDeque::builder()
+ .launch(gtk::ListBox::default())
+ .forward(sender.input_sender(), |output| match output {
+ stage_form::StageFormOutput::SetName(id, name) => {
+ StagesAction::ChangeName(id, name)
+ }
+ stage_form::StageFormOutput::SetLabel(id, label, value) => {
+ StagesAction::ChangeLabel(id, label, value)
+ }
+ stage_form::StageFormOutput::Remove(id) => StagesAction::RemoveCustomStage(id),
+ });
+
+ {
+ let mut guard = stage_forms.guard();
+ for stage in init {
+ guard.push_back(stage);
+ }
+ }
+
+ let model = Self { stage_forms };
+
+ let widgets = view_output!();
+ ComponentParts { model, widgets }
+ }
+
+ fn update(&mut self, message: Self::Input, sender: ComponentSender<Self>) {
+ match message {
+ StagesAction::CustomStageAdded(stage) => {
+ self.stage_forms.guard().push_back(stage);
+ }
+ StagesAction::CustomStageUpdated(stage) => {
+ if let Some(form) = self
+ .stage_forms
+ .guard()
+ .iter_mut()
+ .find(|s| s.stage.id == stage.id)
+ {
+ form.stage.name = stage.name;
+ form.stage.stage = stage.stage;
+ }
+ }
+ StagesAction::CustomStageRemoved(id) => {
+ let mut guard = self.stage_forms.guard();
+ let index = guard.iter().position(|s| s.stage.id == id);
+ if let Some(index) = index {
+ guard.remove(index);
+ }
+ }
+ StagesAction::AddCustomStage => {
+ sender.output(PreferencesAction::AddCustomStage).ok();
+ }
+ StagesAction::RemoveCustomStage(id) => {
+ sender.output(PreferencesAction::RemoveCustomStage(id)).ok();
+ }
+ StagesAction::ChangeName(id, name) => {
+ sender
+ .output(PreferencesAction::SetCustomStageName(id, name))
+ .ok();
+ }
+ StagesAction::ChangeLabel(id, label, value) => {
+ sender
+ .output(PreferencesAction::SetCustomStageLabel(id, label, value))
+ .ok();
+ }
+ }
+ }
+}
diff --git a/src/components/preference_pages/templates.rs b/src/components/preference_pages/templates.rs
new file mode 100644
index 0000000..5652816
--- /dev/null
+++ b/src/components/preference_pages/templates.rs
@@ -0,0 +1,443 @@
+// Map, wardley map editor for linux
+// Copyright (C) 2026 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 Affero 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 Affero General Public License for more details.
+
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+use adw::prelude::*;
+use relm4::prelude::*;
+use sourceview5::LanguageManager;
+use sourceview5::prelude::*;
+use uuid::Uuid;
+
+use crate::tr;
+
+use crate::actions::PreferencesAction;
+use crate::components::template_row;
+use crate::preferences::models::Template;
+
+pub struct TemplatesInit {
+ pub templates: Vec<Template>,
+ pub window: adw::Window,
+}
+
+pub struct Templates {
+ template_rows: FactoryVecDeque<template_row::TemplateRow>,
+ selected_template: Option<Template>,
+ selected_name: String,
+ source_view: sourceview5::View,
+ source: sourceview5::Buffer,
+ is_collapsed: bool,
+ is_showing_content: bool,
+}
+
+#[derive(Debug, Clone)]
+pub enum TemplatesAction {
+ TemplateAdded(Template),
+ TemplateUpdated(Template),
+ TemplateRemoved(Uuid),
+ UpdateAllTemplates(Vec<Template>),
+
+ // Routed Via Outputs
+ ChangeName(DynamicIndex, String),
+ ChangeDefaultTemplate(DynamicIndex),
+ Activate(DynamicIndex),
+ Focus(DynamicIndex),
+
+ // Internal
+ Deactivate,
+ SelectTemplate(usize),
+ RemoveTemplate,
+ SourceChanged(String),
+ Collapse,
+ Expand,
+}
+
+#[relm4::component(pub)]
+impl SimpleComponent for Templates {
+ type Init = TemplatesInit;
+ type Input = TemplatesAction;
+ type Output = PreferencesAction;
+ view! {
+ #[name = "split_view"]
+ adw::NavigationSplitView {
+ set_collapsed: true,
+ #[watch]
+ set_show_content: model.is_showing_content,
+
+ connect_show_content_notify[sender] => move |nav| {
+ if !nav.shows_content() {
+ sender.input(TemplatesAction::Deactivate);
+ }
+ },
+
+ #[wrap(Some)]
+ set_sidebar = &adw::NavigationPage {
+ set_title: &tr!("preferences.templates.title"),
+ gtk::Box {
+ set_orientation: gtk::Orientation::Vertical,
+ set_margin_all: 20,
+ set_spacing: 10,
+
+ gtk::Label {
+ set_label: &tr!("preferences.templates.title"),
+ add_css_class: "heading",
+ set_halign: gtk::Align::Start,
+ set_margin_start: 5,
+ },
+
+ model.template_rows.widget() -> &gtk::ListBox {
+ set_selection_mode: gtk::SelectionMode::Single,
+ add_css_class: "boxed-list",
+ connect_row_selected[sender] => move |_, row| {
+ if let Some(row) = row {
+ let index = row.index() as usize;
+ sender.input(TemplatesAction::SelectTemplate(index));
+ }
+ }
+ },
+
+ gtk::Box {
+ set_spacing: 5,
+ set_halign: gtk::Align::End,
+
+ gtk::Button::from_icon_name("list-remove-symbolic") {
+ set_tooltip_text: Some(&tr!("preferences.templates.help.remove")),
+
+ connect_clicked[sender] => move |_| {
+ sender.input(TemplatesAction::RemoveTemplate);
+ }
+ },
+
+ gtk::Button::from_icon_name("list-add-symbolic") {
+ set_tooltip_text: Some(&tr!("preferences.templates.help.add")),
+
+ connect_clicked[sender] => move |_| {
+ sender.output(PreferencesAction::AddTemplate(tr!(
+ "dialog.template.default_label"
+ ))).ok();
+ },
+ },
+ },
+ },
+ },
+
+ #[wrap(Some)]
+ set_content = &adw::NavigationPage {
+ set_title: &model.selected_name,
+ gtk::Box {
+ set_orientation: gtk::Orientation::Vertical,
+ set_spacing: 0,
+
+ adw::HeaderBar {
+ set_show_end_title_buttons: false,
+ set_show_start_title_buttons: false,
+ #[wrap(Some)]
+ set_title_widget = if model.template_rows.len() > 0 || !model.selected_template.is_some() {
+ &gtk::Label {
+ #[watch]
+ set_label: &model.selected_name,
+ add_css_class: "heading",
+ set_hexpand: true,
+ set_halign: gtk::Align::Start,
+ }
+ } else {
+ &gtk::Label {
+ #[watch]
+ set_label: "",
+ add_css_class: "heading",
+ set_hexpand: true,
+ set_halign: gtk::Align::Start,
+ }
+ }
+ },
+
+ gtk::Box {
+ set_orientation: gtk::Orientation::Vertical,
+ set_spacing: 5,
+ set_hexpand: true,
+
+ if model.template_rows.len() > 0 && model.selected_template.is_some() {
+ gtk::ScrolledWindow {
+ set_vexpand: true,
+ set_hexpand: true,
+ #[local_ref]
+ source_view -> sourceview5::View {
+ set_expand: true,
+ set_buffer: Some(&model.source),
+ set_top_margin: 8,
+ set_bottom_margin: 8,
+ set_left_margin: 8,
+ set_right_margin: 8
+ }
+ }
+ } else {
+ gtk::Label {
+ set_label: &tr!("preferences.templates.empty"),
+ add_css_class: "dim-label",
+ set_hexpand: true,
+ set_vexpand: true,
+ }
+ }
+
+ },
+ },
+ },
+ }
+ }
+
+ fn init(
+ init: Self::Init,
+ root: Self::Root,
+ sender: ComponentSender<Self>,
+ ) -> ComponentParts<Self> {
+ let mut template_rows = FactoryVecDeque::builder()
+ .launch(gtk::ListBox::default())
+ .forward(sender.input_sender(), |output| match output {
+ template_row::TemplateRowOutput::SetName(index, name) => {
+ TemplatesAction::ChangeName(index, name)
+ }
+ template_row::TemplateRowOutput::SetDefault(index) => {
+ TemplatesAction::ChangeDefaultTemplate(index)
+ }
+ template_row::TemplateRowOutput::Activate(index) => {
+ TemplatesAction::Activate(index)
+ }
+ template_row::TemplateRowOutput::Focus(index) => TemplatesAction::Focus(index),
+ });
+
+ let first = init.templates.first();
+ let selected_template = first.map_or(None, |t| Some(t.clone()));
+ let source = Self::create_source_buffer(first, &sender);
+
+ {
+ let mut guard = template_rows.guard();
+ for template in init.templates {
+ guard.push_back(template);
+ }
+ }
+
+ let source_view = Self::init_source_view(&source);
+ let selected_name = selected_template.clone().map_or("".to_string(), |t| t.name);
+
+ let model = Self {
+ selected_template,
+ selected_name,
+ template_rows,
+ source_view: source_view.clone(),
+ source,
+ is_collapsed: false,
+ is_showing_content: false,
+ };
+
+ let widgets = view_output!();
+ Self::init_breakpoints(&init.window, &widgets, &sender);
+ ComponentParts { model, widgets }
+ }
+
+ fn update(&mut self, message: Self::Input, sender: ComponentSender<Self>) {
+ match message {
+ TemplatesAction::TemplateAdded(template) => {
+ self.template_rows.guard().push_back(template);
+ }
+ TemplatesAction::TemplateUpdated(template) => {
+ if let Some(row) = self
+ .template_rows
+ .guard()
+ .iter_mut()
+ .find(|s| s.template.id == template.id)
+ {
+ row.template.name = template.name;
+ row.template.is_default = template.is_default;
+ row.template.content = template.content;
+ }
+ }
+ TemplatesAction::TemplateRemoved(id) => {
+ let mut guard = self.template_rows.guard();
+ let index = guard.iter().position(|s| s.template.id == id);
+ if let Some(index) = index {
+ guard.remove(index);
+ if self
+ .selected_template
+ .as_ref()
+ .map_or(false, |t| id == t.id)
+ {
+ guard.drop();
+ self.select_template(0, &sender);
+ }
+ }
+ }
+ TemplatesAction::RemoveTemplate => {
+ if let Some(template) = &self.selected_template {
+ sender
+ .output(PreferencesAction::RemoveTemplate(template.id))
+ .ok();
+ }
+ }
+ TemplatesAction::UpdateAllTemplates(templates) => {
+ let mut guard = self.template_rows.guard();
+ guard.clear();
+ for template in templates {
+ guard.push_back(template);
+ }
+ guard.drop();
+ let index = if let Some(template) = &self.selected_template {
+ self.template_rows
+ .guard()
+ .iter()
+ .position(|s| s.template.id == template.id)
+ .map_or(0, |i| i)
+ } else {
+ 0
+ };
+ self.select_template(index, &sender);
+ }
+
+ TemplatesAction::ChangeName(index, name) => {
+ if let Some(row) = self.template_rows.get(index.current_index()) {
+ sender
+ .output(PreferencesAction::SetTemplateName(row.template.id, name))
+ .ok();
+ }
+ }
+ TemplatesAction::ChangeDefaultTemplate(index) => {
+ let index = index.current_index();
+ if let Some(row) = self.template_rows.get(index) {
+ sender
+ .output(PreferencesAction::SetDefaultTemplate(row.template.id))
+ .ok();
+ }
+ self.select_template(index, &sender);
+ }
+ TemplatesAction::Activate(index) => {
+ self.select_template(index.current_index(), &sender);
+ self.is_showing_content = true;
+ }
+ TemplatesAction::Deactivate => {
+ self.is_showing_content = false;
+ }
+ TemplatesAction::Focus(index) => {
+ self.select_template(index.current_index(), &sender);
+ }
+ TemplatesAction::SelectTemplate(index) => {
+ self.select_template(index, &sender);
+ }
+ TemplatesAction::SourceChanged(source) => {
+ if let Some(template) = &self.selected_template {
+ sender
+ .output(PreferencesAction::SetTemplateContent(template.id, source))
+ .ok();
+ }
+ }
+ TemplatesAction::Collapse => {
+ self.is_collapsed = true;
+ self.template_rows
+ .broadcast(template_row::TemplateRowAction::ShowArrow)
+ }
+ TemplatesAction::Expand => {
+ self.is_collapsed = false;
+ self.is_showing_content = false;
+ self.template_rows
+ .broadcast(template_row::TemplateRowAction::HideArrow)
+ }
+ }
+ }
+}
+
+impl Templates {
+ // Initializes the breakpoint logic
+ fn init_breakpoints(
+ root: &adw::Window,
+ widgets: &TemplatesWidgets,
+ sender: &ComponentSender<Self>,
+ ) {
+ let breakpoint = adw::Breakpoint::new(adw::BreakpointCondition::new_length(
+ adw::BreakpointConditionLengthType::MinWidth,
+ 550.0,
+ adw::LengthUnit::Sp,
+ ));
+ breakpoint.add_setter(&widgets.split_view, "collapsed", Some(&false.to_value()));
+ breakpoint.add_setter(
+ &widgets.source_view,
+ "show-line-numbers",
+ Some(&true.to_value()),
+ );
+ {
+ let sender = sender.clone();
+ breakpoint.connect_apply(move |_| {
+ sender.input(TemplatesAction::Expand);
+ });
+ }
+ {
+ let sender = sender.clone();
+ breakpoint.connect_unapply(move |_| {
+ sender.input(TemplatesAction::Collapse);
+ });
+ }
+ root.add_breakpoint(breakpoint);
+ }
+
+ fn create_source_buffer(
+ template: Option<&Template>,
+ sender: &ComponentSender<Self>,
+ ) -> sourceview5::Buffer {
+ let source = sourceview5::Buffer::new(None);
+ source.set_highlight_syntax(true);
+
+ let language_manager = LanguageManager::default();
+ if let Some(language) = language_manager.language("wmap") {
+ source.set_language(Some(&language));
+ }
+
+ if let Some(template) = template {
+ source.set_text(&template.content);
+ }
+
+ {
+ let sender = sender.clone();
+ source.connect_changed(move |buffer| {
+ let start = buffer.start_iter();
+ let end = buffer.end_iter();
+ let content = buffer.text(&start, &end, false).to_string();
+ sender.input(TemplatesAction::SourceChanged(content));
+ });
+ }
+
+ source
+ }
+
+ fn init_source_view(source: &sourceview5::Buffer) -> sourceview5::View {
+ let source_view = sourceview5::View::with_buffer(source);
+ source_view.set_monospace(true);
+ source_view.add_css_class("map-editor");
+ source_view.set_show_line_numbers(false);
+ source_view.set_width_request(-1);
+ source_view.set_tab_width(2);
+ source_view
+ }
+
+ fn select_template(&mut self, index: usize, sender: &ComponentSender<Self>) {
+ let guard = self.template_rows.guard();
+ if let Some(row) = guard.get(index) {
+ self.selected_template = Some(row.template.clone());
+ self.selected_name = row.template.clone().name;
+ self.source = Self::create_source_buffer(Some(&row.template), &sender);
+ self.source_view.set_buffer(Some(&self.source));
+ }
+ guard.drop();
+
+ let widget = self.template_rows.widget();
+ let potential_row_widget = widget.row_at_index(index as i32);
+ widget.select_row(potential_row_widget.as_ref());
+ }
+}
diff --git a/src/components/preferences_window.rs b/src/components/preferences_window.rs
new file mode 100644
index 0000000..ed18ba5
--- /dev/null
+++ b/src/components/preferences_window.rs
@@ -0,0 +1,455 @@
+// Map, wardley map editor for linux
+// Copyright (C) 2026 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 Affero 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 Affero General Public License for more details.
+
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see <http://www.gnu.org/licenses/>.
+use crate::icon_names::shipped::{AGENDA, BUILD, MAP, PAPYRUS, SETTINGS};
+use adw::prelude::*;
+use relm4::prelude::*;
+use std::convert::identity;
+
+use gtk::FileFilter;
+
+use crate::actions::{Action, PreferencesAction};
+use crate::components::preference_pages;
+use crate::preferences::models::StageLabel;
+use crate::preferences::models::{CustomStage, Template, UserPreferences};
+use crate::preferences::storage;
+use crate::tr;
+
+pub struct PreferencesWindow {
+ preferences: UserPreferences,
+ window: adw::Window,
+ // Widget references for pages
+ general_page: Controller<preference_pages::General>,
+ editor_page: Controller<preference_pages::Editor>,
+ map_page: Controller<preference_pages::Map>,
+ stages_page: Controller<preference_pages::Stages>,
+ templates_page: Controller<preference_pages::Templates>,
+}
+
+#[relm4::component(pub)]
+impl SimpleComponent for PreferencesWindow {
+ type Init = UserPreferences;
+ type Input = PreferencesAction;
+ type Output = Action;
+
+ view! {
+ adw::Window {
+ set_title: Some(&tr!("preferences.title")),
+ set_default_width: 700,
+ set_default_height: 500,
+ set_modal: true,
+ set_resizable: true,
+
+ connect_close_request[sender] => move |_| {
+ sender.output(Action::PreferencesWindowClosed).ok();
+ gtk::glib::Propagation::Proceed
+ },
+
+ adw::ToolbarView {
+ #[name = "header_bar"]
+ add_top_bar = &adw::HeaderBar {
+ #[wrap(Some)]
+ set_title_widget = &adw::ViewSwitcher {
+ set_stack: Some(&stack),
+ },
+ },
+
+ #[name = "stack"]
+ adw::ViewStack {
+ set_vexpand: true,
+
+ add_titled_with_icon[Some("general"), &tr!("preferences.menu.general"), SETTINGS] = model.general_page.widget(),
+ add_titled_with_icon[Some("editor"), &tr!("preferences.menu.editor"), BUILD] = model.editor_page.widget(),
+ add_titled_with_icon[Some("map"), &tr!("preferences.menu.map"), MAP] = model.map_page.widget(),
+ add_titled_with_icon[Some("stages"), &tr!("preferences.menu.stages"), AGENDA] = model.stages_page.widget(),
+ add_titled_with_icon[Some("templates"), &tr!("preferences.menu.templates"), PAPYRUS] = model.templates_page.widget(),
+ },
+
+ #[name = "switcher_bar"]
+ add_bottom_bar = &adw::ViewSwitcherBar {
+ set_stack: Some(&stack),
+ },
+ },
+ }
+ }
+
+ fn init(
+ init: Self::Init,
+ root: Self::Root,
+ sender: ComponentSender<Self>,
+ ) -> ComponentParts<Self> {
+ let general_page = preference_pages::General::builder()
+ .launch(())
+ .forward(sender.input_sender(), identity);
+ let editor_page = preference_pages::Editor::builder()
+ .launch(preference_pages::EditorInit {
+ font_size: init.editor_font_size,
+ soft_wrap: init.soft_wrap_lines,
+ use_custom_font: init.use_custom_editor_font,
+ custom_font: init.custom_editor_font_name.clone(),
+ })
+ .forward(sender.input_sender(), identity);
+ let map_page = preference_pages::Map::builder()
+ .launch(preference_pages::MapInit {
+ show_background: init.show_map_background,
+ use_custom_font: init.use_custom_font,
+ use_smart_label_positioning: init.use_smart_label_positioning,
+ custom_font: init.custom_font_name.clone(),
+ default_export_format: init.default_export_format.clone(),
+ })
+ .forward(sender.input_sender(), identity);
+ let stages_page = preference_pages::Stages::builder()
+ .launch(init.custom_stages.clone())
+ .forward(sender.input_sender(), identity);
+ let templates_page = preference_pages::Templates::builder()
+ .launch(preference_pages::TemplatesInit {
+ templates: init.map_templates.clone(),
+ window: root.clone(),
+ })
+ .forward(sender.input_sender(), identity);
+
+ let model = PreferencesWindow {
+ preferences: init,
+ window: root.clone(),
+ general_page,
+ editor_page,
+ map_page,
+ stages_page,
+ templates_page,
+ };
+
+ let widgets = view_output!();
+
+ Self::init_breakpoints(&root, &widgets);
+ ComponentParts { model, widgets }
+ }
+
+ fn update(&mut self, message: Self::Input, sender: ComponentSender<Self>) {
+ match message {
+ PreferencesAction::Export => {
+ Self::show_export_dialog(sender, &self.window);
+ }
+ PreferencesAction::Import => {
+ Self::show_import_dialog(sender, &self.window);
+ }
+ PreferencesAction::ImportFromFile(path) => {
+ if storage::import_from_file(&mut self.preferences, &path).is_ok() {
+ self.sync_ui_from_preferences();
+ self.save_and_notify(&sender);
+ }
+ }
+ PreferencesAction::ExportToFile(path) => {
+ let _ = storage::export_to_file(&self.preferences, &path);
+ }
+ PreferencesAction::ResetToDefaults => {
+ self.preferences = UserPreferences::default();
+ self.sync_ui_from_preferences();
+ self.save_and_notify(&sender);
+ }
+ PreferencesAction::SetShowMapBackground(show_background) => {
+ self.preferences.show_map_background = show_background;
+ self.map_page
+ .emit(preference_pages::MapAction::SetShowBackground(
+ show_background,
+ ));
+ self.save_and_notify(&sender);
+ }
+ PreferencesAction::SetUseCustomFont(use_custom_font) => {
+ self.preferences.use_custom_font = use_custom_font;
+ self.map_page
+ .emit(preference_pages::MapAction::SetUseCustomFont(
+ use_custom_font,
+ ));
+ self.save_and_notify(&sender);
+ }
+ PreferencesAction::SetCustomFontName(custom_font) => {
+ self.preferences.custom_font_name = custom_font.clone();
+ self.map_page
+ .emit(preference_pages::MapAction::SetCustomFont(custom_font));
+ self.save_and_notify(&sender);
+ }
+ PreferencesAction::SetDefaultExportFormat(default_export_format) => {
+ self.preferences.default_export_format = default_export_format.clone();
+ self.map_page
+ .emit(preference_pages::MapAction::SetDefaultExportFormat(
+ default_export_format,
+ ));
+ self.save_and_notify(&sender);
+ }
+ PreferencesAction::SetUseSmartLabelPositioning(use_smart_label_positioning) => {
+ self.preferences.use_smart_label_positioning = use_smart_label_positioning;
+ self.map_page
+ .emit(preference_pages::MapAction::SetUseSmartLabelPositioning(
+ use_smart_label_positioning,
+ ));
+ self.save_and_notify(&sender);
+ }
+ PreferencesAction::SetUseCustomEditorFont(use_custom_font) => {
+ self.preferences.use_custom_editor_font = use_custom_font;
+ self.editor_page
+ .emit(preference_pages::EditorAction::SetUseCustomFont(
+ use_custom_font,
+ ));
+ self.save_and_notify(&sender);
+ }
+ PreferencesAction::SetCustomEditorFontName(font_name) => {
+ self.preferences.custom_editor_font_name = font_name.clone();
+ self.editor_page
+ .emit(preference_pages::EditorAction::SetCustomFont(font_name));
+ self.save_and_notify(&sender);
+ }
+ PreferencesAction::SetEditorFontSize(font_size) => {
+ self.preferences.editor_font_size = font_size;
+ self.editor_page
+ .emit(preference_pages::EditorAction::SetFontSize(font_size));
+ self.save_and_notify(&sender);
+ }
+ PreferencesAction::SetSoftWrapLines(soft_wrap_lines) => {
+ self.preferences.soft_wrap_lines = soft_wrap_lines;
+ self.editor_page
+ .emit(preference_pages::EditorAction::SetSoftWrap(soft_wrap_lines));
+ self.save_and_notify(&sender);
+ }
+ PreferencesAction::AddTemplate(name) => self.handle_add_template(name, &sender),
+ PreferencesAction::RemoveTemplate(id) => self.handle_remove_template(id, &sender),
+ PreferencesAction::SetTemplateContent(id, content) => {
+ self.handle_set_template_content(id, content, &sender);
+ }
+ PreferencesAction::SetTemplateName(id, name) => {
+ self.handle_set_template_name(id, name, &sender);
+ }
+ PreferencesAction::SetDefaultTemplate(id) => {
+ self.handle_set_default_template(id, &sender);
+ }
+ PreferencesAction::AddCustomStage => self.handle_add_custom_stage(&sender),
+ PreferencesAction::RemoveCustomStage(id) => {
+ self.handle_remove_custom_stage(id, &sender);
+ }
+ PreferencesAction::SetCustomStageName(id, name) => {
+ self.handle_set_custom_stage_name(id, name, &sender);
+ }
+ PreferencesAction::SetCustomStageLabel(id, label, value) => {
+ self.handle_set_custom_stage_label(id, label, value, &sender);
+ }
+ }
+ }
+}
+
+impl PreferencesWindow {
+ fn save_and_notify(&self, sender: &ComponentSender<Self>) {
+ let _ = storage::save(&self.preferences);
+ sender
+ .output(Action::PreferencesChanged(self.preferences.clone()))
+ .ok();
+ }
+
+ // TODO: Figure out how to best do this with messages.
+ fn sync_ui_from_preferences(&mut self) {}
+
+ fn handle_add_template(&mut self, name: String, sender: &ComponentSender<Self>) {
+ let template = Template::new(name, String::new(), false);
+ self.preferences.map_templates.push(template.clone());
+ self.templates_page
+ .emit(preference_pages::TemplatesAction::TemplateAdded(template));
+ self.save_and_notify(sender);
+ }
+
+ fn handle_remove_template(&mut self, id: uuid::Uuid, sender: &ComponentSender<Self>) {
+ self.preferences.map_templates.retain(|t| t.id != id);
+ self.templates_page
+ .emit(preference_pages::TemplatesAction::TemplateRemoved(id));
+ self.save_and_notify(sender);
+ }
+
+ fn handle_set_template_content(
+ &mut self,
+ id: uuid::Uuid,
+ content: String,
+ sender: &ComponentSender<Self>,
+ ) {
+ if let Some(template) = self
+ .preferences
+ .map_templates
+ .iter_mut()
+ .find(|t| t.id == id)
+ {
+ template.content = content;
+ self.templates_page
+ .emit(preference_pages::TemplatesAction::TemplateUpdated(
+ template.clone(),
+ ));
+ self.save_and_notify(sender);
+ }
+ }
+
+ fn handle_set_template_name(
+ &mut self,
+ id: uuid::Uuid,
+ name: String,
+ sender: &ComponentSender<Self>,
+ ) {
+ if let Some(template) = self
+ .preferences
+ .map_templates
+ .iter_mut()
+ .find(|t| t.id == id)
+ {
+ template.name = name;
+ self.templates_page
+ .emit(preference_pages::TemplatesAction::TemplateUpdated(
+ template.clone(),
+ ));
+ self.save_and_notify(sender);
+ }
+ }
+
+ fn handle_set_default_template(&mut self, id: uuid::Uuid, sender: &ComponentSender<Self>) {
+ self.preferences.set_default_template(id);
+ self.templates_page
+ .emit(preference_pages::TemplatesAction::UpdateAllTemplates(
+ self.preferences.map_templates.clone(),
+ ));
+ self.save_and_notify(&sender);
+ }
+
+ fn handle_add_custom_stage(&mut self, sender: &ComponentSender<Self>) {
+ let stage = CustomStage::placeholder_default();
+ self.preferences.custom_stages.push(stage.clone());
+ self.stages_page
+ .emit(preference_pages::StagesAction::CustomStageAdded(stage));
+ self.save_and_notify(sender);
+ }
+
+ fn handle_remove_custom_stage(&mut self, id: uuid::Uuid, sender: &ComponentSender<Self>) {
+ self.preferences.custom_stages.retain(|s| s.id != id);
+ self.stages_page
+ .emit(preference_pages::StagesAction::CustomStageRemoved(id));
+ self.save_and_notify(sender);
+ }
+
+ fn handle_set_custom_stage_name(
+ &mut self,
+ id: uuid::Uuid,
+ name: String,
+ sender: &ComponentSender<Self>,
+ ) {
+ if let Some(stage) = self
+ .preferences
+ .custom_stages
+ .iter_mut()
+ .find(|s| s.id == id)
+ {
+ stage.name = name;
+ self.stages_page
+ .emit(preference_pages::StagesAction::CustomStageUpdated(
+ stage.clone(),
+ ));
+ self.save_and_notify(sender);
+ }
+ }
+
+ fn handle_set_custom_stage_label(
+ &mut self,
+ id: uuid::Uuid,
+ label: StageLabel,
+ value: String,
+ sender: &ComponentSender<Self>,
+ ) {
+ 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.stages_page
+ .emit(preference_pages::StagesAction::CustomStageUpdated(
+ stage.clone(),
+ ));
+ self.save_and_notify(sender);
+ }
+ }
+
+ // Initializes the breakpoint logic
+ fn init_breakpoints(root: &adw::Window, widgets: &PreferencesWindowWidgets) {
+ let breakpoint = adw::Breakpoint::new(adw::BreakpointCondition::new_length(
+ adw::BreakpointConditionLengthType::MaxWidth,
+ 550.0,
+ adw::LengthUnit::Sp,
+ ));
+ breakpoint.add_setter(&widgets.switcher_bar, "reveal", Some(&true.to_value()));
+ breakpoint.add_setter(
+ &widgets.header_bar,
+ "title-widget",
+ Some(&None::<gtk::Widget>.to_value()),
+ );
+ root.add_breakpoint(breakpoint);
+ }
+ fn show_export_dialog(sender: ComponentSender<Self>, window: &adw::Window) {
+ let filter = FileFilter::new();
+ filter.add_pattern("*.json");
+ filter.set_name(Some(&tr!("dialog.file_type.json")));
+
+ let filters = gtk::gio::ListStore::new::<gtk::FileFilter>();
+ filters.append(&filter);
+
+ let dialog = gtk::FileDialog::builder()
+ .title(tr!("dialog.export_preferences.title"))
+ .modal(true)
+ .filters(&filters)
+ .initial_name("preferences.json")
+ .build();
+
+ let sender = sender.clone();
+ let window = window.clone();
+ gtk::glib::spawn_future_local(async move {
+ if let Ok(file) = dialog.save_future(Some(&window)).await
+ && let Some(path) = file.path()
+ {
+ sender.input(PreferencesAction::ExportToFile(path));
+ }
+ });
+ }
+
+ fn show_import_dialog(sender: ComponentSender<Self>, window: &adw::Window) {
+ let filter = FileFilter::new();
+ filter.add_pattern("*.json");
+ filter.set_name(Some(&tr!("dialog.file_type.json")));
+
+ let filters = gtk::gio::ListStore::new::<gtk::FileFilter>();
+ filters.append(&filter);
+
+ let dialog = gtk::FileDialog::builder()
+ .title(tr!("dialog.import_preferences.title"))
+ .modal(true)
+ .filters(&filters)
+ .build();
+
+ let sender = sender.clone();
+ let window = window.clone();
+ gtk::glib::spawn_future_local(async move {
+ if let Ok(file) = dialog.open_future(Some(&window)).await
+ && let Some(path) = file.path()
+ {
+ sender.input(PreferencesAction::ImportFromFile(path));
+ }
+ });
+ }
+}
diff --git a/src/components/stage_form.rs b/src/components/stage_form.rs
new file mode 100644
index 0000000..cb4c40f
--- /dev/null
+++ b/src/components/stage_form.rs
@@ -0,0 +1,141 @@
+// Map, wardley map editor for linux
+// Copyright (C) 2026 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 Affero 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 Affero General Public License for more details.
+
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+use gtk::prelude::*;
+use relm4::prelude::*;
+use uuid::Uuid;
+
+use crate::preferences::models::{CustomStage, StageLabel};
+use crate::tr;
+
+#[derive(Debug)]
+pub struct StageForm {
+ pub stage: CustomStage,
+}
+
+#[derive(Debug)]
+pub enum StageFormAction {
+ SetName(String),
+ SetLabel(StageLabel, String),
+ Remove,
+}
+
+#[derive(Debug)]
+pub enum StageFormOutput {
+ SetName(Uuid, String),
+ SetLabel(Uuid, StageLabel, String),
+ Remove(Uuid),
+}
+
+#[relm4::factory(pub)]
+impl FactoryComponent for StageForm {
+ type Init = CustomStage;
+ type Input = StageFormAction;
+ type Output = StageFormOutput;
+ type CommandOutput = ();
+ type ParentWidget = gtk::ListBox;
+
+ view! {
+ #[root]
+ gtk::ListBoxRow {
+ gtk::Box {
+ set_orientation: gtk::Orientation::Horizontal,
+ set_margin_all: 5,
+ set_spacing: 5,
+
+ gtk::Entry {
+ set_text: &self.stage.name,
+ set_width_chars: 15,
+ connect_changed[sender] => move |entry| {
+ sender.input(StageFormAction::SetName(
+ entry.text().to_string()
+ ));
+ },
+ },
+ gtk::Entry {
+ set_text: &self.stage.stage.i,
+ set_width_chars: 12,
+ connect_changed[sender] => move |entry| {
+ sender.input(StageFormAction::SetLabel(
+ StageLabel::I,
+ entry.text().to_string()
+ ));
+ },
+ },
+ gtk::Entry {
+ set_text: &self.stage.stage.ii,
+ set_width_chars: 12,
+ connect_changed[sender] => move |entry| {
+ sender.input(StageFormAction::SetLabel(
+ StageLabel::Ii,
+ entry.text().to_string()
+ ));
+ },
+ },
+ gtk::Entry {
+ set_text: &self.stage.stage.iii,
+ set_width_chars: 12,
+ connect_changed[sender] => move |entry| {
+ sender.input(StageFormAction::SetLabel(
+ StageLabel::Iii,
+ entry.text().to_string()
+ ));
+ },
+ },
+ gtk::Entry {
+ set_text: &self.stage.stage.iv,
+ set_width_chars: 12,
+ connect_changed[sender] => move |entry| {
+ sender.input(StageFormAction::SetLabel(
+ StageLabel::Iv,
+ entry.text().to_string()
+ ));
+ },
+ },
+
+ gtk::Button::from_icon_name("list-remove-symbolic") {
+ add_css_class: "flat",
+ set_tooltip_text: Some(&tr!("preferences.stages.help.remove")),
+ connect_clicked[sender] => move |_| {
+ sender.input(StageFormAction::Remove);
+ }
+ }
+ }
+ }
+ }
+
+ fn init_model(stage: Self::Init, _index: &DynamicIndex, _sender: FactorySender<Self>) -> Self {
+ Self { stage }
+ }
+
+ fn update(&mut self, message: Self::Input, sender: FactorySender<Self>) {
+ match message {
+ StageFormAction::SetName(name) => {
+ sender
+ .output(StageFormOutput::SetName(self.stage.id, name))
+ .ok();
+ }
+ StageFormAction::SetLabel(label, value) => {
+ sender
+ .output(StageFormOutput::SetLabel(self.stage.id, label, value))
+ .ok();
+ }
+ StageFormAction::Remove => {
+ sender.output(StageFormOutput::Remove(self.stage.id)).ok();
+ }
+ }
+ }
+}
diff --git a/src/components/switch.rs b/src/components/switch.rs
new file mode 100644
index 0000000..8308407
--- /dev/null
+++ b/src/components/switch.rs
@@ -0,0 +1,79 @@
+// Map, wardley map editor for linux
+// Copyright (C) 2026 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 Affero 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 Affero General Public License for more details.
+
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+use gtk::prelude::*;
+use relm4::prelude::*;
+
+pub struct Switch {
+ state: bool,
+ label: String,
+}
+
+pub struct SwitchInit {
+ pub state: bool,
+ pub label: String,
+}
+
+#[derive(Debug, Clone)]
+pub enum SwitchOutput {
+ SetState(bool),
+}
+
+#[relm4::component(pub)]
+impl SimpleComponent for Switch {
+ type Init = SwitchInit;
+ type Input = ();
+ type Output = SwitchOutput;
+
+ view! {
+ gtk::Box {
+ set_orientation: gtk::Orientation::Horizontal,
+ set_spacing: 10,
+
+ gtk::Label {
+ #[watch]
+ set_label: &model.label
+ },
+
+ gtk::Box {
+ set_orientation: gtk::Orientation::Horizontal,
+ set_hexpand: true,
+ },
+
+ gtk::Switch {
+ set_active: model.state,
+ connect_state_set => move |_, state| {
+ sender.output(SwitchOutput::SetState(state)).ok();
+ gtk::glib::Propagation::Proceed
+ }
+ }
+ }
+ }
+
+ fn init(
+ init: Self::Init,
+ root: Self::Root,
+ sender: ComponentSender<Self>,
+ ) -> ComponentParts<Self> {
+ let model = Self {
+ label: init.label,
+ state: init.state,
+ };
+ let widgets = view_output!();
+
+ ComponentParts { model, widgets }
+ }
+}
diff --git a/src/components/template_row.rs b/src/components/template_row.rs
new file mode 100644
index 0000000..959c392
--- /dev/null
+++ b/src/components/template_row.rs
@@ -0,0 +1,122 @@
+// Map, wardley map editor for linux
+// Copyright (C) 2026 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 Affero 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 Affero General Public License for more details.
+
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+use gtk::prelude::*;
+use relm4::prelude::*;
+
+use crate::icon_names::shipped::RIGHT;
+use crate::preferences::models::Template;
+use crate::tr;
+
+#[derive(Debug)]
+pub struct TemplateRow {
+ pub template: Template,
+ focus_controller: gtk::EventControllerFocus,
+ is_arrow_shown: bool,
+}
+
+#[derive(Debug, Clone)]
+pub enum TemplateRowAction {
+ ShowArrow,
+ HideArrow,
+}
+
+#[derive(Debug)]
+pub enum TemplateRowOutput {
+ SetDefault(DynamicIndex),
+ SetName(DynamicIndex, String),
+ Focus(DynamicIndex),
+ Activate(DynamicIndex),
+}
+
+#[relm4::factory(pub)]
+impl FactoryComponent for TemplateRow {
+ type Init = Template;
+ type Input = TemplateRowAction;
+ type Output = TemplateRowOutput;
+ type CommandOutput = ();
+ type ParentWidget = gtk::ListBox;
+
+ view! {
+ #[root]
+ gtk::ListBoxRow {
+ set_focus_on_click: true,
+ gtk::Box {
+ set_orientation: gtk::Orientation::Horizontal,
+ set_margin_all: 5,
+ set_spacing: 5,
+
+ gtk::Button {
+ add_css_class: "flat",
+ #[watch]
+ set_icon_name: if self.template.is_default { "radio-checked-symbolic" } else { "radio-symbolic" },
+ #[watch]
+ set_tooltip_text: Some(&if self.template.is_default { tr!("preferences.templates.help.default") } else { tr!("preferences.templates.help.set_as_default") }),
+ connect_clicked[sender, index] => move |_| {
+ sender.output(TemplateRowOutput::SetDefault(index.clone())).ok();
+ }
+ },
+
+ #[name="entry"]
+ gtk::Entry {
+ set_text: &self.template.name,
+ set_hexpand: true,
+ connect_changed[sender, index] => move |entry| {
+ sender.output(TemplateRowOutput::SetName(
+ index.clone(),
+ entry.text().to_string()
+ )).ok();
+ },
+ add_controller: self.focus_controller.clone()
+ },
+
+ gtk::Button::from_icon_name(RIGHT) {
+ set_tooltip_text: Some(&tr!("preferences.templates.help.activate")),
+ #[watch]
+ set_visible: self.is_arrow_shown,
+
+ connect_clicked[sender, index] => move |_| {
+ sender.output(TemplateRowOutput::Activate(index.clone())).ok();
+ }
+ }
+ },
+ }
+ }
+
+ fn init_model(template: Self::Init, index: &DynamicIndex, sender: FactorySender<Self>) -> Self {
+ let focus_controller = gtk::EventControllerFocus::new();
+
+ {
+ let sender = sender.clone();
+ let index = index.clone();
+ focus_controller.connect_enter(move |_| {
+ sender.output(TemplateRowOutput::Focus(index.clone())).ok();
+ });
+ }
+ Self {
+ template,
+ focus_controller,
+ is_arrow_shown: true,
+ }
+ }
+
+ fn update(&mut self, message: Self::Input, _sender: FactorySender<Self>) {
+ match message {
+ TemplateRowAction::ShowArrow => self.is_arrow_shown = true,
+ TemplateRowAction::HideArrow => self.is_arrow_shown = false,
+ }
+ }
+}
diff --git a/src/handlers/preferences.rs b/src/handlers/preferences.rs
index 89a1ef5..9fb3638 100644
--- a/src/handlers/preferences.rs
+++ b/src/handlers/preferences.rs
@@ -13,13 +13,16 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
+use std::convert::identity;
+
use adw::prelude::*;
use relm4::prelude::*;
use crate::actions::Action;
use crate::components::header::HeaderAction;
+use crate::components::preferences_window::PreferencesWindow;
use crate::file_registry;
-use crate::preferences::{PreferencesWindow, UserPreferences, storage, window};
+use crate::preferences::{UserPreferences, storage};
use crate::AppModel;
@@ -29,12 +32,7 @@ pub fn show(model: &mut AppModel, sender: &ComponentSender<AppModel>) {
let controller = PreferencesWindow::builder()
.transient_for(&model.window)
.launch(model.preferences.clone())
- .forward(sender.input_sender(), |output| match output {
- window::PreferencesOutput::PreferencesChanged(preferences) => {
- Action::PreferencesChanged(preferences)
- }
- window::PreferencesOutput::Closed => Action::PreferencesWindowClosed,
- });
+ .forward(sender.input_sender(), identity);
model.preferences_window = Some(controller);
}
if let Some(ref controller) = model.preferences_window {
diff --git a/src/main.rs b/src/main.rs
index 5d0b88e..06c4943 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -24,7 +24,6 @@ mod handlers;
mod i18n;
mod preferences;
mod stages;
-mod ui_helpers;
mod icon_names {
#![allow(clippy::doc_markdown)] // Generated code from relm4_icons_build
@@ -57,7 +56,7 @@ macro_rules! register_action {
($group:expr, $app:expr, $sender:expr, $action_type:ty, $message:expr, $accelerators:expr) => {{
let sender = $sender.clone();
let action: RelmAction<$action_type> = RelmAction::new_stateless(move |_| {
- let _ = sender.input($message);
+ sender.input($message);
});
$group.add_action(action.clone());
$app.set_accelerators_for_action::<$action_type>($accelerators);
@@ -131,7 +130,7 @@ struct AppModel {
initialized: bool,
window: adw::Window,
preferences: preferences::UserPreferences,
- preferences_window: Option<relm4::Controller<preferences::PreferencesWindow>>,
+ preferences_window: Option<Controller<components::preferences_window::PreferencesWindow>>,
editor_css_provider: gtk::CssProvider,
horizontal_layout_enabled: bool,
diff --git a/src/preferences/mod.rs b/src/preferences/mod.rs
index b369e88..6dd21b2 100644
--- a/src/preferences/mod.rs
+++ b/src/preferences/mod.rs
@@ -15,9 +15,6 @@
// along with this program. If not, see <http://www.gnu.org/licenses/>.
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
index a595fc2..93f3fa0 100644
--- a/src/preferences/models.rs
+++ b/src/preferences/models.rs
@@ -267,3 +267,12 @@ impl UserPreferences {
}
}
}
+
+/// Which stage label to update (Roman numerals I-IV)
+#[derive(Debug, Clone, Copy)]
+pub enum StageLabel {
+ I,
+ Ii,
+ Iii,
+ Iv,
+}
diff --git a/src/preferences/pages/editor.rs b/src/preferences/pages/editor.rs
deleted file mode 100644
index c0e9b41..0000000
--- a/src/preferences/pages/editor.rs
+++ /dev/null
@@ -1,222 +0,0 @@
-// Map, wardley map editor for linux
-// Copyright (C) 2026 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 Affero 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 Affero General Public License for more details.
-
-// You should have received a copy of the GNU Affero General Public License
-// along with this program. If not, see <http://www.gnu.org/licenses/>.
-
-use gtk::prelude::*;
-use relm4::Sender;
-use relm4::gtk;
-
-use crate::tr;
-use crate::ui_helpers::create_switch_row;
-
-use super::super::models::UserPreferences;
-use super::super::window::PreferencesInput;
-
-const DEFAULT_FONT_SIZE: f64 = 14.0;
-
-pub struct EditorPage {
- container: gtk::Box,
- font_size_scale: gtk::Scale,
- soft_wrap_switch: gtk::Switch,
- custom_font_switch: gtk::Switch,
- font_button: gtk::FontDialogButton,
-}
-
-impl EditorPage {
- pub fn new(preferences: &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);
-
- let (font_size_box, font_size_scale) = Self::create_font_size_section(preferences);
- container.append(&font_size_box);
-
- let (soft_wrap_switch, custom_font_switch) =
- Self::create_style_section(&container, preferences);
-
- let font_button = Self::create_font_button(preferences);
- container.append(&Self::create_font_row(&font_button));
-
- Self::connect_signals(
- sender,
- &font_size_scale,
- &soft_wrap_switch,
- &custom_font_switch,
- &font_button,
- );
-
- Self {
- container,
- font_size_scale,
- soft_wrap_switch,
- custom_font_switch,
- font_button,
- }
- }
-
- fn create_font_size_section(preferences: &UserPreferences) -> (gtk::Box, gtk::Scale) {
- let label = gtk::Label::new(Some(&tr!("preferences.editor.font_size.title")));
- label.add_css_class("heading");
- label.set_halign(gtk::Align::Start);
-
- let scale = gtk::Scale::with_range(gtk::Orientation::Horizontal, 7.0, 21.0, 1.0);
- scale.set_value(preferences.editor_font_size);
- scale.set_draw_value(true);
- scale.set_hexpand(true);
- #[allow(clippy::cast_possible_truncation)]
- scale.set_format_value_func(|_, value| {
- #[allow(clippy::float_cmp)]
- if value == DEFAULT_FONT_SIZE {
- format!(
- "{} ({})",
- value as i32,
- tr!("preferences.editor.default_font_size")
- )
- } else {
- format!("{}", value as i32)
- }
- });
-
- let vbox = gtk::Box::new(gtk::Orientation::Vertical, 10);
- vbox.append(&label);
- vbox.append(&scale);
-
- (vbox, scale)
- }
-
- fn create_style_section(
- container: &gtk::Box,
- preferences: &UserPreferences,
- ) -> (gtk::Switch, gtk::Switch) {
- let label = gtk::Label::new(Some(&tr!("preferences.editor.editor_style.title")));
- label.add_css_class("heading");
- label.set_halign(gtk::Align::Start);
- label.set_margin_top(10);
- container.append(&label);
-
- let soft_wrap_row = create_switch_row(
- &tr!("preferences.editor.editor_style.soft_wrap_lines"),
- preferences.soft_wrap_lines,
- );
- container.append(&soft_wrap_row.0);
-
- let custom_font_row = create_switch_row(
- &tr!("preferences.editor.editor_style.use_custom_font"),
- preferences.use_custom_editor_font,
- );
- container.append(&custom_font_row.0);
-
- (soft_wrap_row.1, custom_font_row.1)
- }
-
- fn create_font_button(preferences: &UserPreferences) -> gtk::FontDialogButton {
- let font_dialog = gtk::FontDialog::new();
- let font_button = gtk::FontDialogButton::new(Some(font_dialog));
- #[allow(clippy::cast_possible_truncation)]
- let font_desc = gtk::pango::FontDescription::from_string(&format!(
- "{} {}",
- preferences.custom_editor_font_name, preferences.editor_font_size as i32
- ));
- font_button.set_font_desc(&font_desc);
- font_button.set_sensitive(preferences.use_custom_editor_font);
- font_button.set_hexpand(true);
- font_button.set_use_font(true);
- font_button.set_use_size(false);
- font_button
- }
-
- fn create_font_row(font_button: &gtk::FontDialogButton) -> gtk::Box {
- let row = gtk::Box::new(gtk::Orientation::Horizontal, 10);
- row.append(&gtk::Label::new(Some(&tr!("preferences.editor.font"))));
- row.append(font_button);
- row
- }
-
- fn connect_signals(
- sender: &Sender<PreferencesInput>,
- font_size_scale: &gtk::Scale,
- soft_wrap_switch: &gtk::Switch,
- custom_font_switch: &gtk::Switch,
- font_button: &gtk::FontDialogButton,
- ) {
- let s = sender.clone();
- let fb = font_button.clone();
- font_size_scale.connect_value_changed(move |scale| {
- s.emit(PreferencesInput::SetEditorFontSize(scale.value()));
- Self::update_font_button_size(&fb, scale.value());
- });
-
- let s = sender.clone();
- soft_wrap_switch.connect_state_set(move |_, state| {
- s.emit(PreferencesInput::SetSoftWrapLines(state));
- gtk::glib::Propagation::Proceed
- });
-
- let s = sender.clone();
- let scale = font_size_scale.clone();
- font_button.connect_font_desc_notify(move |button| {
- if let Some(font) = button.font_desc() {
- if let Some(family) = font.family() {
- s.emit(PreferencesInput::SetCustomEditorFontName(
- family.to_string(),
- ));
- }
- let size = font.size() / gtk::pango::SCALE;
- if size > 0 {
- scale.set_value(f64::from(size));
- }
- }
- });
-
- let s = sender.clone();
- let fb = font_button.clone();
- custom_font_switch.connect_state_set(move |_, state| {
- fb.set_sensitive(state);
- s.emit(PreferencesInput::SetUseCustomEditorFont(state));
- gtk::glib::Propagation::Proceed
- });
- }
-
- fn update_font_button_size(font_button: &gtk::FontDialogButton, size: f64) {
- if let Some(mut font_desc) = font_button.font_desc() {
- #[allow(clippy::cast_possible_truncation)]
- font_desc.set_size(size as i32 * gtk::pango::SCALE);
- font_button.set_font_desc(&font_desc);
- }
- }
-
- pub fn widget(&self) -> gtk::Box {
- self.container.clone()
- }
-
- pub fn sync_from(&self, preferences: &UserPreferences) {
- self.font_size_scale.set_value(preferences.editor_font_size);
- self.soft_wrap_switch
- .set_active(preferences.soft_wrap_lines);
- self.custom_font_switch
- .set_active(preferences.use_custom_editor_font);
- self.font_button
- .set_sensitive(preferences.use_custom_editor_font);
- #[allow(clippy::cast_possible_truncation)]
- let font_desc = gtk::pango::FontDescription::from_string(&format!(
- "{} {}",
- preferences.custom_editor_font_name, preferences.editor_font_size as i32
- ));
- self.font_button.set_font_desc(&font_desc);
- }
-}
diff --git a/src/preferences/pages/general.rs b/src/preferences/pages/general.rs
deleted file mode 100644
index e65ede6..0000000
--- a/src/preferences/pages/general.rs
+++ /dev/null
@@ -1,144 +0,0 @@
-// Map, wardley map editor for linux
-// Copyright (C) 2026 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 Affero 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 Affero General Public License for more details.
-
-// You should have received a copy of the GNU Affero General Public License
-// along with this program. If not, see <http://www.gnu.org/licenses/>.
-
-use gtk::FileFilter;
-use gtk::prelude::*;
-use relm4::Sender;
-use relm4::{adw, gtk};
-
-use crate::tr;
-
-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(&tr!("preferences.general.preferences.title")));
- 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(&tr!("preferences.general.preferences.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(&tr!("preferences.general.preferences.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(&tr!("preferences.general.preferences.reset"));
- 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(&tr!("preferences.general.explanation")));
- 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: &adw::Window) {
- let filter = FileFilter::new();
- filter.add_pattern("*.json");
- filter.set_name(Some(&tr!("dialog.file_type.json")));
-
- let filters = gtk::gio::ListStore::new::<gtk::FileFilter>();
- filters.append(&filter);
-
- let dialog = gtk::FileDialog::builder()
- .title(tr!("dialog.export_preferences.title"))
- .modal(true)
- .filters(&filters)
- .initial_name("preferences.json")
- .build();
-
- let sender = sender.clone();
- let window = window.clone();
- gtk::glib::spawn_future_local(async move {
- if let Ok(file) = dialog.save_future(Some(&window)).await
- && let Some(path) = file.path()
- {
- sender.emit(PreferencesInput::ExportToFile(path));
- }
- });
-}
-
-pub fn show_import_dialog(sender: &Sender<PreferencesInput>, window: &adw::Window) {
- let filter = FileFilter::new();
- filter.add_pattern("*.json");
- filter.set_name(Some(&tr!("dialog.file_type.json")));
-
- let filters = gtk::gio::ListStore::new::<gtk::FileFilter>();
- filters.append(&filter);
-
- let dialog = gtk::FileDialog::builder()
- .title(tr!("dialog.import_preferences.title"))
- .modal(true)
- .filters(&filters)
- .build();
-
- let sender = sender.clone();
- let window = window.clone();
- gtk::glib::spawn_future_local(async move {
- if let Ok(file) = dialog.open_future(Some(&window)).await
- && let Some(path) = file.path()
- {
- sender.emit(PreferencesInput::ImportFromFile(path));
- }
- });
-}
diff --git a/src/preferences/pages/map.rs b/src/preferences/pages/map.rs
deleted file mode 100644
index 5645a59..0000000
--- a/src/preferences/pages/map.rs
+++ /dev/null
@@ -1,212 +0,0 @@
-// Map, wardley map editor for linux
-// Copyright (C) 2026 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 Affero 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 Affero General Public License for more details.
-
-// You should have received a copy of the GNU Affero General Public License
-// along with this program. If not, see <http://www.gnu.org/licenses/>.
-
-use relm4::Sender;
-use relm4::gtk;
-use sourceview5::prelude::*;
-
-use crate::tr;
-use crate::ui_helpers::create_switch_row;
-
-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::FontDialogButton,
- export_format_dropdown: gtk::DropDown,
-}
-
-impl MapPage {
- pub fn new(preferences: &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);
-
- let (show_background_switch, smart_positioning_switch, custom_font_switch, font_button) =
- Self::init_style_section(&container, preferences, sender);
-
- let export_format_dropdown = Self::init_export_section(&container, preferences, sender);
-
- Self {
- container,
- show_background_switch,
- smart_positioning_switch,
- custom_font_switch,
- font_button,
- export_format_dropdown,
- }
- }
-
- fn init_style_section(
- container: &gtk::Box,
- preferences: &UserPreferences,
- sender: &Sender<PreferencesInput>,
- ) -> (gtk::Switch, gtk::Switch, gtk::Switch, gtk::FontDialogButton) {
- let style_label = gtk::Label::new(Some(&tr!("preferences.map.map_style.title")));
- style_label.add_css_class("heading");
- style_label.set_halign(gtk::Align::Start);
- container.append(&style_label);
-
- // Show Background
- let background_row = create_switch_row(
- &tr!("preferences.map.map_style.show_background"),
- preferences.show_map_background,
- );
- let show_background_switch = background_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(&background_row.0);
-
- // Smart Label Positioning
- let smart_row = create_switch_row(
- &tr!("preferences.map.map_style.use_smart_label_positioning"),
- preferences.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(
- &tr!("preferences.map.map_style.use_custom_font"),
- preferences.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(&tr!("preferences.map.font"))));
-
- let font_dialog = gtk::FontDialog::new();
- let font_button = gtk::FontDialogButton::new(Some(font_dialog));
- let font_desc = gtk::pango::FontDescription::from_string(&preferences.custom_font_name);
- font_button.set_font_desc(&font_desc);
- font_button.set_sensitive(preferences.use_custom_font);
- font_button.set_hexpand(true);
- font_button.set_use_font(true);
- {
- let sender = sender.clone();
- font_button.connect_font_desc_notify(move |button| {
- if let Some(font) = button.font_desc()
- && let Some(family) = font.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
- });
- }
-
- (
- show_background_switch,
- smart_positioning_switch,
- custom_font_switch,
- font_button,
- )
- }
-
- fn init_export_section(
- container: &gtk::Box,
- preferences: &UserPreferences,
- sender: &Sender<PreferencesInput>,
- ) -> gtk::DropDown {
- let export_label = gtk::Label::new(Some(&tr!("preferences.map.export.title")));
- 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(&tr!(
- "preferences.map.export.default_format"
- ))));
-
- let formats =
- gtk::StringList::new(&[&tr!("export_formats.png"), &tr!("export_formats.svg")]);
- let export_format_dropdown = gtk::DropDown::new(Some(formats), None::<gtk::Expression>);
- let selected = match preferences.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);
-
- export_format_dropdown
- }
-
- pub fn widget(&self) -> gtk::Box {
- self.container.clone()
- }
-
- pub fn sync_from(&self, preferences: &UserPreferences) {
- self.show_background_switch
- .set_active(preferences.show_map_background);
- self.smart_positioning_switch
- .set_active(preferences.use_smart_label_positioning);
- self.custom_font_switch
- .set_active(preferences.use_custom_font);
- self.font_button.set_sensitive(preferences.use_custom_font);
- let font_desc = gtk::pango::FontDescription::from_string(&preferences.custom_font_name);
- self.font_button.set_font_desc(&font_desc);
-
- let selected = match preferences.default_export_format.as_str() {
- "svg" => 1,
- _ => 0,
- };
- self.export_format_dropdown.set_selected(selected);
- }
-}
diff --git a/src/preferences/pages/stages.rs b/src/preferences/pages/stages.rs
deleted file mode 100644
index 4bd24e1..0000000
--- a/src/preferences/pages/stages.rs
+++ /dev/null
@@ -1,253 +0,0 @@
-// Map, wardley map editor for linux
-// Copyright (C) 2026 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 Affero 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 Affero General Public License for more details.
-
-// You should have received a copy of the GNU Affero General Public License
-// along with this program. If not, see <http://www.gnu.org/licenses/>.
-
-use gtk::prelude::*;
-use relm4::Sender;
-use relm4::gtk;
-use uuid::Uuid;
-
-use crate::tr;
-
-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(preferences: &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(&tr!("preferences.stages.title")));
- header_label.add_css_class("heading");
- header_label.set_halign(gtk::Align::Start);
- container.append(&header_label);
-
- let help_label = gtk::Label::new(Some(&tr!("preferences.stages.explanation")));
- 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(&tr!("preferences.stages.column.name")));
- name_header.set_width_chars(15);
- name_header.set_xalign(0.0);
- header_row.append(&name_header);
-
- let first_header = gtk::Label::new(Some(&tr!("preferences.stages.column.stage_i")));
- first_header.set_width_chars(12);
- first_header.set_xalign(0.0);
- header_row.append(&first_header);
-
- let second_header = gtk::Label::new(Some(&tr!("preferences.stages.column.stage_ii")));
- second_header.set_width_chars(12);
- second_header.set_xalign(0.0);
- header_row.append(&second_header);
-
- let third_header = gtk::Label::new(Some(&tr!("preferences.stages.column.stage_iii")));
- third_header.set_width_chars(12);
- third_header.set_xalign(0.0);
- header_row.append(&third_header);
-
- let fourth_header = gtk::Label::new(Some(&tr!("preferences.stages.column.stage_iv")));
- fourth_header.set_width_chars(12);
- fourth_header.set_xalign(0.0);
- header_row.append(&fourth_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(&tr!("preferences.stages.help.add")));
- {
- 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(preferences);
- page
- }
-
- pub fn widget(&self) -> gtk::Box {
- self.container.clone()
- }
-
- pub fn refresh(&self, preferences: &UserPreferences) {
- // Clear existing rows
- while let Some(child) = self.list_box.first_child() {
- self.list_box.remove(&child);
- }
- self.populate_stages(preferences);
- }
-
- fn populate_stages(&self, preferences: &UserPreferences) {
- for stage in &preferences.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 first_entry = gtk::Entry::new();
- first_entry.set_text(&stage.i);
- first_entry.set_width_chars(12);
- {
- let sender = self.sender.clone();
- first_entry.connect_changed(move |entry| {
- sender.emit(PreferencesInput::SetCustomStageLabel(
- id,
- StageLabel::I,
- entry.text().to_string(),
- ));
- });
- }
- hbox.append(&first_entry);
-
- // Stage II entry
- let second_entry = gtk::Entry::new();
- second_entry.set_text(&stage.ii);
- second_entry.set_width_chars(12);
- {
- let sender = self.sender.clone();
- second_entry.connect_changed(move |entry| {
- sender.emit(PreferencesInput::SetCustomStageLabel(
- id,
- StageLabel::Ii,
- entry.text().to_string(),
- ));
- });
- }
- hbox.append(&second_entry);
-
- // Stage III entry
- let third_entry = gtk::Entry::new();
- third_entry.set_text(&stage.iii);
- third_entry.set_width_chars(12);
- {
- let sender = self.sender.clone();
- third_entry.connect_changed(move |entry| {
- sender.emit(PreferencesInput::SetCustomStageLabel(
- id,
- StageLabel::Iii,
- entry.text().to_string(),
- ));
- });
- }
- hbox.append(&third_entry);
-
- // Stage IV entry
- let fourth_entry = gtk::Entry::new();
- fourth_entry.set_text(&stage.iv);
- fourth_entry.set_width_chars(12);
- {
- let sender = self.sender.clone();
- fourth_entry.connect_changed(move |entry| {
- sender.emit(PreferencesInput::SetCustomStageLabel(
- id,
- StageLabel::Iv,
- entry.text().to_string(),
- ));
- });
- }
- hbox.append(&fourth_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(&tr!("preferences.stages.help.remove")));
- {
- 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
deleted file mode 100644
index 029b3e9..0000000
--- a/src/preferences/pages/templates.rs
+++ /dev/null
@@ -1,349 +0,0 @@
-// Map, wardley map editor for linux
-// Copyright (C) 2026 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 Affero 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 Affero General Public License for more details.
-
-// You should have received a copy of the GNU Affero General Public License
-// along with this program. If not, see <http://www.gnu.org/licenses/>.
-
-use std::cell::RefCell;
-use std::rc::Rc;
-
-use relm4::Sender;
-use relm4::gtk;
-use sourceview5::prelude::*;
-use uuid::Uuid;
-
-use crate::tr;
-
-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(&tr!("preferences.templates.title")));
- 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(&tr!("preferences.templates.help.add")));
- {
- let sender = sender.clone();
- add_button.connect_clicked(move |_| {
- sender.emit(PreferencesInput::AddTemplate(tr!(
- "dialog.template.default_label"
- )));
- });
- }
- button_box.append(&add_button);
-
- let remove_button = gtk::Button::from_icon_name("list-remove-symbolic");
- remove_button.set_tooltip_text(Some(&tr!("preferences.templates.help.remove")));
- {
- 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(&tr!("preferences.templates.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) {
- // Check if we need to recreate rows (templates added/removed/reordered)
- let current_row_count = {
- let mut count = 0;
- let mut child = self.list_box.first_child();
- while child.is_some() {
- count += 1;
- child = child.and_then(|c| c.next_sibling());
- }
- count
- };
-
- let needs_recreate = current_row_count != prefs.map_templates.len();
-
- if needs_recreate {
- // Clear existing rows
- while let Some(child) = self.list_box.first_child() {
- self.list_box.remove(&child);
- }
- self.populate_templates(prefs);
- } else {
- // Just update existing rows in place
- let mut child = self.list_box.first_child();
- let mut index = 0;
- while let Some(row) = child {
- if let Some(template) = prefs.map_templates.get(index) {
- TemplatesPage::update_template_row(&row, template);
- }
- child = row.next_sibling();
- index += 1;
- }
- }
-
- // 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: usize = row.index().try_into().unwrap_or(0);
- if let Some(template) = templates.get(index) {
- *selected_id.borrow_mut() = Some(template.id);
- editor_buffer.set_text(&template.content);
- }
- }
- });
- }
- }
-
- fn update_template_row(row: &gtk::Widget, template: &Template) {
- if let Some(list_row) = row.downcast_ref::<gtk::ListBoxRow>()
- && let Some(hbox) = list_row.child().and_then(|c| c.downcast::<gtk::Box>().ok())
- {
- let mut child = hbox.first_child();
- let mut widget_index = 0;
-
- while let Some(widget) = child {
- if widget_index == 0 {
- // Update default button
- if let Some(button) = widget.downcast_ref::<gtk::Button>() {
- if template.is_default {
- button.set_icon_name("emblem-default-symbolic");
- button
- .set_tooltip_text(Some(&tr!("preferences.templates.help.default")));
- } else {
- button.set_icon_name("radio-symbolic");
- button.set_tooltip_text(Some(&tr!(
- "preferences.templates.help.set_as_default"
- )));
- }
- }
- } else if widget_index == 1 {
- // Update name entry
- if let Some(entry) = widget.downcast_ref::<gtk::Entry>() {
- // Only update if different to avoid cursor reset
- if entry.text() != template.name {
- entry.set_text(&template.name);
- }
- }
- }
- child = widget.next_sibling();
- widget_index += 1;
- }
- }
- }
-
- 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(&tr!("preferences.templates.help.default")));
- } else {
- default_button.set_icon_name("radio-symbolic");
- default_button
- .set_tooltip_text(Some(&tr!("preferences.templates.help.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 (editable)
- let name_entry = gtk::Entry::new();
- name_entry.set_text(&template.name);
- name_entry.set_hexpand(true);
- {
- let sender = self.sender.clone();
- let id = template.id;
- name_entry.connect_changed(move |entry| {
- sender.emit(PreferencesInput::SetTemplateName(
- id,
- entry.text().to_string(),
- ));
- });
- }
- hbox.append(&name_entry);
-
- row.set_child(Some(&hbox));
- row
- }
-}
diff --git a/src/preferences/window.rs b/src/preferences/window.rs
deleted file mode 100644
index 9d58b95..0000000
--- a/src/preferences/window.rs
+++ /dev/null
@@ -1,371 +0,0 @@
-// Map, wardley map editor for linux
-// Copyright (C) 2026 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 Affero 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 Affero General Public License for more details.
-
-// You should have received a copy of the GNU Affero General Public License
-// along with this program. If not, see <http://www.gnu.org/licenses/>.
-
-// Suppress warnings from relm4 view! macro internals
-#![allow(unused_assignments)]
-
-use std::path::PathBuf;
-
-use crate::icon_names::shipped::{AGENDA, BUILD, MAP, PAPYRUS, SETTINGS};
-use gtk::prelude::*;
-use relm4::{ComponentParts, ComponentSender, SimpleComponent, adw, gtk};
-
-use crate::tr;
-
-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),
- 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)]
-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: adw::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! {
- adw::Window {
- set_title: Some(&tr!("preferences.title")),
- 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
- },
-
- adw::ToolbarView {
- add_top_bar = &adw::HeaderBar {
- #[wrap(Some)]
- set_title_widget = &adw::ViewSwitcher {
- set_stack: Some(&stack),
- },
- },
-
- #[name = "stack"]
- adw::ViewStack {
- set_vexpand: true,
-
- add_titled_with_icon[Some("general"), &tr!("preferences.menu.general"), SETTINGS] = &model.general_page.widget() -> gtk::Box {},
- add_titled_with_icon[Some("editor"), &tr!("preferences.menu.editor"), BUILD] = &model.editor_page.widget() -> gtk::Box {},
- add_titled_with_icon[Some("map"), &tr!("preferences.menu.map"), MAP] = &model.map_page.widget() -> gtk::Box {},
- add_titled_with_icon[Some("stages"), &tr!("preferences.menu.stages"), AGENDA] = &model.stages_page.widget() -> gtk::Box {},
- add_titled_with_icon[Some("templates"), &tr!("preferences.menu.templates"), PAPYRUS] = &model.templates_page.widget() -> gtk::Box {},
- },
- },
- }
- }
-
- fn init(
- preferences: 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(&preferences, &sender.input_sender().clone());
- let map_page = map::MapPage::new(&preferences, &sender.input_sender().clone());
- let stages_page = stages::StagesPage::new(&preferences, sender.input_sender().clone());
- let templates_page =
- templates::TemplatesPage::new(&preferences, sender.input_sender().clone());
-
- let model = PreferencesWindow {
- preferences,
- 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 {
- 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);
- }
- 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);
- }
- 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);
- }
- PreferencesInput::AddTemplate(name) => self.handle_add_template(name, &sender),
- PreferencesInput::RemoveTemplate(id) => self.handle_remove_template(id, &sender),
- PreferencesInput::SetTemplateContent(id, content) => {
- self.handle_set_template_content(id, content, &sender);
- }
- PreferencesInput::SetTemplateName(id, name) => {
- self.handle_set_template_name(id, name, &sender);
- }
- PreferencesInput::SetDefaultTemplate(id) => {
- self.preferences.set_default_template(id);
- self.templates_page.refresh(&self.preferences);
- self.save_and_notify(&sender);
- }
- PreferencesInput::AddCustomStage => self.handle_add_custom_stage(&sender),
- PreferencesInput::RemoveCustomStage(id) => {
- self.handle_remove_custom_stage(id, &sender);
- }
- PreferencesInput::SetCustomStageName(id, name) => {
- self.handle_set_custom_stage_name(id, name, &sender);
- }
- PreferencesInput::SetCustomStageLabel(id, label, value) => {
- self.handle_set_custom_stage_label(id, label, value, &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);
- }
-
- fn handle_add_template(&mut self, name: String, sender: &ComponentSender<Self>) {
- 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);
- }
-
- fn handle_remove_template(&mut self, id: uuid::Uuid, sender: &ComponentSender<Self>) {
- self.preferences.map_templates.retain(|t| t.id != id);
- self.templates_page.refresh(&self.preferences);
- self.save_and_notify(sender);
- }
-
- fn handle_set_template_content(
- &mut self,
- id: uuid::Uuid,
- content: String,
- sender: &ComponentSender<Self>,
- ) {
- if let Some(template) = self
- .preferences
- .map_templates
- .iter_mut()
- .find(|t| t.id == id)
- {
- template.content = content;
- self.save_and_notify(sender);
- }
- }
-
- fn handle_set_template_name(
- &mut self,
- id: uuid::Uuid,
- name: String,
- sender: &ComponentSender<Self>,
- ) {
- 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);
- }
- }
-
- fn handle_add_custom_stage(&mut self, sender: &ComponentSender<Self>) {
- let stage = super::models::CustomStage::placeholder_default();
- self.preferences.custom_stages.push(stage);
- self.stages_page.refresh(&self.preferences);
- self.save_and_notify(sender);
- }
-
- fn handle_remove_custom_stage(&mut self, id: uuid::Uuid, sender: &ComponentSender<Self>) {
- self.preferences.custom_stages.retain(|s| s.id != id);
- self.stages_page.refresh(&self.preferences);
- self.save_and_notify(sender);
- }
-
- fn handle_set_custom_stage_name(
- &mut self,
- id: uuid::Uuid,
- name: String,
- sender: &ComponentSender<Self>,
- ) {
- if let Some(stage) = self
- .preferences
- .custom_stages
- .iter_mut()
- .find(|s| s.id == id)
- {
- stage.name = name;
- self.save_and_notify(sender);
- }
- }
-
- fn handle_set_custom_stage_label(
- &mut self,
- id: uuid::Uuid,
- label: StageLabel,
- value: String,
- sender: &ComponentSender<Self>,
- ) {
- 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);
- }
- }
-}
diff --git a/src/ui_helpers.rs b/src/ui_helpers.rs
deleted file mode 100644
index 6737c62..0000000
--- a/src/ui_helpers.rs
+++ /dev/null
@@ -1,34 +0,0 @@
-// Map, wardley map editor for linux
-// Copyright (C) 2026 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 Affero 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 Affero General Public License for more details.
-
-// You should have received a copy of the GNU Affero General Public License
-// along with this program. If not, see <http://www.gnu.org/licenses/>.
-
-use gtk::prelude::*;
-use relm4::gtk;
-
-/// Creates a horizontal row with a label on the left and a switch on the right.
-pub 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)
-}