aboutsummaryrefslogtreecommitdiff
path: root/src/components
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/components
parent4dec344a7bcbf1d7d0bbbf985109df816470ecc6 (diff)
Use actual components for preference pages
Diffstat (limited to 'src/components')
-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.rs27
-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
13 files changed, 2038 insertions, 2 deletions
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/components/preference_pages/mod.rs b/src/components/preference_pages/mod.rs
new file mode 100644
index 0000000..9e37bcb
--- /dev/null
+++ b/src/components/preference_pages/mod.rs
@@ -0,0 +1,27 @@
+// 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/>.
+
+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,
+ }
+ }
+}