// 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));
}
});
}
}