// 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 . 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, editor_page: Controller, map_page: Controller, stages_page: Controller, templates_page: Controller, } #[relm4::component(pub)] impl SimpleComponent for PreferencesWindow { type Init = UserPreferences; type Input = PreferencesAction; type Output = Action; view! { adw::Window { set_title: Some(&tr!("Preferences")), 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!("General"), SETTINGS] = model.general_page.widget(), add_titled_with_icon[Some("editor"), &tr!("Editor"), BUILD] = model.editor_page.widget(), add_titled_with_icon[Some("map"), &tr!("Map"), MAP] = model.map_page.widget(), add_titled_with_icon[Some("stages"), &tr!("Stages"), AGENDA] = model.stages_page.widget(), add_titled_with_icon[Some("templates"), &tr!("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, ) -> ComponentParts { 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) { 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.save_and_notify(&sender); Self::sync_ui_from_preferences(&sender); } } PreferencesAction::ExportToFile(path) => { let _ = storage::export_to_file(&self.preferences, &path); } PreferencesAction::ResetToDefaults => { self.preferences = UserPreferences::default(); self.save_and_notify(&sender); Self::sync_ui_from_preferences(&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.clone_from(&custom_font); 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 .clone_from(&default_export_format); 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.handle_set_custom_editor_font_name(font_name, &sender); } PreferencesAction::SetEditorFontSize(font_size) => { self.handle_set_editor_font_size(font_size, &sender); } PreferencesAction::SetSoftWrapLines(soft_wrap_lines) => { self.handle_set_soft_wrap_lines(soft_wrap_lines, &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) { let _ = storage::save(&self.preferences); sender .output(Action::PreferencesChanged(self.preferences.clone())) .ok(); } // This is a lazy placeholder. I'd love if these values were easy // to cascade, maybe they can but I don't know how yet. Anyway, // closing and re-opening the window works just as well. fn sync_ui_from_preferences(sender: &ComponentSender) { sender.output(Action::ReloadPreferencesWindow).ok(); } fn handle_add_template(&mut self, name: String, sender: &ComponentSender) { 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.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, ) { 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, ) { 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.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) { 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.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, ) { 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, ) { 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); } } fn handle_set_soft_wrap_lines( &mut self, soft_wrap_lines: bool, sender: &ComponentSender, ) { self.preferences.soft_wrap_lines = soft_wrap_lines; self.editor_page .emit(preference_pages::EditorAction::SetSoftWrap(soft_wrap_lines)); self.save_and_notify(sender); } fn handle_set_editor_font_size(&mut self, font_size: f64, sender: &ComponentSender) { self.preferences.editor_font_size = font_size; self.editor_page .emit(preference_pages::EditorAction::SetFontSize(font_size)); self.save_and_notify(sender); } fn handle_set_custom_editor_font_name( &mut self, font_name: String, sender: &ComponentSender, ) { self.preferences .custom_editor_font_name .clone_from(&font_name); self.editor_page .emit(preference_pages::EditorAction::SetCustomFont(font_name)); 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::.to_value()), ); root.add_breakpoint(breakpoint); } fn show_export_dialog(sender: &ComponentSender, window: &adw::Window) { let filter = FileFilter::new(); filter.add_pattern("*.json"); filter.set_name(Some(&tr!("JSON File"))); let filters = gtk::gio::ListStore::new::(); filters.append(&filter); let dialog = gtk::FileDialog::builder() .title(tr!("Export Preferences")) .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, window: &adw::Window) { let filter = FileFilter::new(); filter.add_pattern("*.json"); filter.set_name(Some(&tr!("JSON File"))); let filters = gtk::gio::ListStore::new::(); filters.append(&filter); let dialog = gtk::FileDialog::builder() .title(tr!("Import Preferences")) .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)); } }); } }