aboutsummaryrefslogtreecommitdiff
path: root/src/components/preferences_window.rs
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/preferences_window.rs
parent4dec344a7bcbf1d7d0bbbf985109df816470ecc6 (diff)
Use actual components for preference pages
Diffstat (limited to 'src/components/preferences_window.rs')
-rw-r--r--src/components/preferences_window.rs455
1 files changed, 455 insertions, 0 deletions
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));
+ }
+ });
+ }
+}