aboutsummaryrefslogtreecommitdiff
path: root/src/preferences/pages
diff options
context:
space:
mode:
authorRubén Beltrán del Río <jj@r.bdr.sh>2026-01-16 08:26:35 +0100
committerRubén Beltrán del Río <jj@r.bdr.sh>2026-01-16 11:57:13 +0100
commit81cd06b3334f2f91f6a1ab62a28e11c7d0c677eb (patch)
treef0b0cf37b406eda2dec8db4382aeb5f606cd1f0d /src/preferences/pages
parent4211b2ae06777d5bbe8261a1ab5c0dd057829a35 (diff)
Add preferences
Diffstat (limited to 'src/preferences/pages')
-rw-r--r--src/preferences/pages/editor.rs163
-rw-r--r--src/preferences/pages/general.rs155
-rw-r--r--src/preferences/pages/map.rs187
-rw-r--r--src/preferences/pages/mod.rs20
-rw-r--r--src/preferences/pages/stages.rs252
-rw-r--r--src/preferences/pages/templates.rs312
6 files changed, 1089 insertions, 0 deletions
diff --git a/src/preferences/pages/editor.rs b/src/preferences/pages/editor.rs
new file mode 100644
index 0000000..5334a37
--- /dev/null
+++ b/src/preferences/pages/editor.rs
@@ -0,0 +1,163 @@
+// Copyright (C) 2024 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 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 General Public License for more details.
+
+// You should have received a copy of the GNU General Public License
+// along with this program. If not, see https://map.tranquil.systems.
+
+use gtk::prelude::*;
+use relm4::Sender;
+use relm4::gtk;
+
+use super::super::models::UserPreferences;
+use super::super::window::PreferencesInput;
+
+pub struct EditorPage {
+ container: gtk::Box,
+ font_size_spin: gtk::SpinButton,
+ soft_wrap_switch: gtk::Switch,
+ custom_font_switch: gtk::Switch,
+ font_button: gtk::FontButton,
+}
+
+impl EditorPage {
+ pub fn new(prefs: &UserPreferences, sender: Sender<PreferencesInput>) -> Self {
+ let container = gtk::Box::new(gtk::Orientation::Vertical, 20);
+ container.set_margin_top(20);
+ container.set_margin_bottom(20);
+ container.set_margin_start(20);
+ container.set_margin_end(20);
+ container.set_valign(gtk::Align::Start);
+
+ // Font Size section
+ let font_size_label = gtk::Label::new(Some("Font Size"));
+ font_size_label.add_css_class("heading");
+ font_size_label.set_halign(gtk::Align::Start);
+ container.append(&font_size_label);
+
+ let font_size_box = gtk::Box::new(gtk::Orientation::Horizontal, 10);
+ let font_size_spin = gtk::SpinButton::with_range(7.0, 21.0, 1.0);
+ font_size_spin.set_value(prefs.editor_font_size);
+ {
+ let sender = sender.clone();
+ font_size_spin.connect_value_changed(move |spin| {
+ sender.emit(PreferencesInput::SetEditorFontSize(spin.value()));
+ });
+ }
+ font_size_box.append(&font_size_spin);
+ font_size_box.append(&gtk::Label::new(Some("pt")));
+ container.append(&font_size_box);
+
+ // Editor Style section
+ let style_label = gtk::Label::new(Some("Editor Style"));
+ style_label.add_css_class("heading");
+ style_label.set_halign(gtk::Align::Start);
+ style_label.set_margin_top(10);
+ container.append(&style_label);
+
+ // Soft Wrap Lines
+ let soft_wrap_row = create_switch_row("Soft Wrap Lines", prefs.soft_wrap_lines);
+ let soft_wrap_switch = soft_wrap_row.1.clone();
+ {
+ let sender = sender.clone();
+ soft_wrap_switch.connect_state_set(move |_, state| {
+ sender.emit(PreferencesInput::SetSoftWrapLines(state));
+ gtk::glib::Propagation::Proceed
+ });
+ }
+ container.append(&soft_wrap_row.0);
+
+ // Use Custom Font
+ let custom_font_row =
+ create_switch_row("Use Custom Editor Font", prefs.use_custom_editor_font);
+ let custom_font_switch = custom_font_row.1.clone();
+ container.append(&custom_font_row.0);
+
+ // Font selection
+ let font_row = gtk::Box::new(gtk::Orientation::Horizontal, 10);
+ font_row.append(&gtk::Label::new(Some("Font")));
+
+ let font_button = gtk::FontButton::new();
+ font_button.set_font(&format!(
+ "{} {}",
+ prefs.custom_editor_font_name, prefs.editor_font_size as i32
+ ));
+ font_button.set_sensitive(prefs.use_custom_editor_font);
+ font_button.set_hexpand(true);
+ font_button.set_use_font(true);
+ {
+ let sender = sender.clone();
+ font_button.connect_font_set(move |btn| {
+ if let Some(font) = btn.font() {
+ // Extract font family from the font string
+ let font_desc = gtk::pango::FontDescription::from_string(&font);
+ if let Some(family) = font_desc.family() {
+ sender.emit(PreferencesInput::SetCustomEditorFontName(
+ family.to_string(),
+ ));
+ }
+ }
+ });
+ }
+ font_row.append(&font_button);
+ container.append(&font_row);
+
+ // Connect custom font switch to font button sensitivity
+ {
+ let font_button_clone = font_button.clone();
+ let sender = sender.clone();
+ custom_font_switch.connect_state_set(move |_, state| {
+ font_button_clone.set_sensitive(state);
+ sender.emit(PreferencesInput::SetUseCustomEditorFont(state));
+ gtk::glib::Propagation::Proceed
+ });
+ }
+
+ Self {
+ container,
+ font_size_spin,
+ soft_wrap_switch,
+ custom_font_switch,
+ font_button,
+ }
+ }
+
+ pub fn widget(&self) -> gtk::Box {
+ self.container.clone()
+ }
+
+ pub fn sync_from(&self, prefs: &UserPreferences) {
+ self.font_size_spin.set_value(prefs.editor_font_size);
+ self.soft_wrap_switch.set_active(prefs.soft_wrap_lines);
+ self.custom_font_switch
+ .set_active(prefs.use_custom_editor_font);
+ self.font_button.set_sensitive(prefs.use_custom_editor_font);
+ self.font_button.set_font(&format!(
+ "{} {}",
+ prefs.custom_editor_font_name, prefs.editor_font_size as i32
+ ));
+ }
+}
+
+fn create_switch_row(label: &str, initial_state: bool) -> (gtk::Box, gtk::Switch) {
+ let row = gtk::Box::new(gtk::Orientation::Horizontal, 10);
+ row.append(&gtk::Label::new(Some(label)));
+
+ let spacer = gtk::Box::new(gtk::Orientation::Horizontal, 0);
+ spacer.set_hexpand(true);
+ row.append(&spacer);
+
+ let switch = gtk::Switch::new();
+ switch.set_active(initial_state);
+ row.append(&switch);
+
+ (row, switch)
+}
diff --git a/src/preferences/pages/general.rs b/src/preferences/pages/general.rs
new file mode 100644
index 0000000..9c71bd4
--- /dev/null
+++ b/src/preferences/pages/general.rs
@@ -0,0 +1,155 @@
+// Copyright (C) 2024 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 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 General Public License for more details.
+
+// You should have received a copy of the GNU General Public License
+// along with this program. If not, see https://map.tranquil.systems.
+
+use gtk::FileFilter;
+use gtk::prelude::*;
+use relm4::Sender;
+use relm4::gtk;
+
+use super::super::window::PreferencesInput;
+
+pub struct GeneralPage {
+ container: gtk::Box,
+}
+
+impl GeneralPage {
+ pub fn new(sender: Sender<PreferencesInput>) -> Self {
+ let container = gtk::Box::new(gtk::Orientation::Vertical, 20);
+ container.set_margin_top(20);
+ container.set_margin_bottom(20);
+ container.set_margin_start(20);
+ container.set_margin_end(20);
+ container.set_valign(gtk::Align::Start);
+
+ // Preferences section
+ let prefs_label = gtk::Label::new(Some("Preferences"));
+ prefs_label.add_css_class("heading");
+ prefs_label.set_halign(gtk::Align::Start);
+ container.append(&prefs_label);
+
+ let button_box = gtk::Box::new(gtk::Orientation::Horizontal, 10);
+
+ // Export button
+ let export_button = gtk::Button::with_label("Export...");
+ {
+ let sender = sender.clone();
+ export_button.connect_clicked(move |_| {
+ sender.emit(PreferencesInput::Export);
+ });
+ }
+ button_box.append(&export_button);
+
+ // Import button
+ let import_button = gtk::Button::with_label("Import...");
+ {
+ let sender = sender.clone();
+ import_button.connect_clicked(move |_| {
+ sender.emit(PreferencesInput::Import);
+ });
+ }
+ button_box.append(&import_button);
+
+ // Reset button
+ let reset_button = gtk::Button::with_label("Reset to Defaults");
+ reset_button.add_css_class("destructive-action");
+ {
+ let sender = sender.clone();
+ reset_button.connect_clicked(move |_| {
+ sender.emit(PreferencesInput::ResetToDefaults);
+ });
+ }
+ button_box.append(&reset_button);
+
+ container.append(&button_box);
+
+ // Help text
+ let help_label = gtk::Label::new(Some(
+ "Export your preferences to share with others or back up your settings.\nImport preferences from a file to restore settings.",
+ ));
+ help_label.add_css_class("dim-label");
+ help_label.set_halign(gtk::Align::Start);
+ help_label.set_wrap(true);
+ container.append(&help_label);
+
+ Self { container }
+ }
+
+ pub fn widget(&self) -> gtk::Box {
+ self.container.clone()
+ }
+}
+
+pub fn show_export_dialog(sender: &Sender<PreferencesInput>, window: &gtk::Window) {
+ let filter = FileFilter::new();
+ filter.add_pattern("*.json");
+ filter.set_name(Some("JSON files"));
+
+ let dialog = gtk::FileChooserDialog::new(
+ Some("Export Preferences"),
+ Some(window),
+ gtk::FileChooserAction::Save,
+ &[
+ ("Cancel", gtk::ResponseType::Cancel),
+ ("Save", gtk::ResponseType::Accept),
+ ],
+ );
+ dialog.set_current_name("preferences.json");
+ dialog.add_filter(&filter);
+ dialog.set_modal(true);
+
+ let sender = sender.clone();
+ dialog.connect_response(move |dialog, response| {
+ if response == gtk::ResponseType::Accept
+ && let Some(file) = dialog.file()
+ && let Some(path) = file.path()
+ {
+ sender.emit(PreferencesInput::ExportToFile(path));
+ }
+ dialog.close();
+ });
+
+ dialog.present();
+}
+
+pub fn show_import_dialog(sender: &Sender<PreferencesInput>, window: &gtk::Window) {
+ let filter = FileFilter::new();
+ filter.add_pattern("*.json");
+ filter.set_name(Some("JSON files"));
+
+ let dialog = gtk::FileChooserDialog::new(
+ Some("Import Preferences"),
+ Some(window),
+ gtk::FileChooserAction::Open,
+ &[
+ ("Cancel", gtk::ResponseType::Cancel),
+ ("Open", gtk::ResponseType::Accept),
+ ],
+ );
+ dialog.add_filter(&filter);
+ dialog.set_modal(true);
+
+ let sender = sender.clone();
+ dialog.connect_response(move |dialog, response| {
+ if response == gtk::ResponseType::Accept
+ && let Some(file) = dialog.file()
+ && let Some(path) = file.path()
+ {
+ sender.emit(PreferencesInput::ImportFromFile(path));
+ }
+ dialog.close();
+ });
+
+ dialog.present();
+}
diff --git a/src/preferences/pages/map.rs b/src/preferences/pages/map.rs
new file mode 100644
index 0000000..f3537c0
--- /dev/null
+++ b/src/preferences/pages/map.rs
@@ -0,0 +1,187 @@
+// Copyright (C) 2024 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 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 General Public License for more details.
+
+// You should have received a copy of the GNU General Public License
+// along with this program. If not, see https://map.tranquil.systems.
+
+use gtk::prelude::*;
+use relm4::Sender;
+use relm4::gtk;
+
+use super::super::models::UserPreferences;
+use super::super::window::PreferencesInput;
+
+pub struct MapPage {
+ container: gtk::Box,
+ show_background_switch: gtk::Switch,
+ smart_positioning_switch: gtk::Switch,
+ custom_font_switch: gtk::Switch,
+ font_button: gtk::FontButton,
+ export_format_dropdown: gtk::DropDown,
+}
+
+impl MapPage {
+ pub fn new(prefs: &UserPreferences, sender: Sender<PreferencesInput>) -> Self {
+ let container = gtk::Box::new(gtk::Orientation::Vertical, 20);
+ container.set_margin_top(20);
+ container.set_margin_bottom(20);
+ container.set_margin_start(20);
+ container.set_margin_end(20);
+ container.set_valign(gtk::Align::Start);
+
+ // Map Style section
+ let style_label = gtk::Label::new(Some("Map Style"));
+ style_label.add_css_class("heading");
+ style_label.set_halign(gtk::Align::Start);
+ container.append(&style_label);
+
+ // Show Background
+ let bg_row = create_switch_row("Show Map Background", prefs.show_map_background);
+ let show_background_switch = bg_row.1.clone();
+ {
+ let sender = sender.clone();
+ show_background_switch.connect_state_set(move |_, state| {
+ sender.emit(PreferencesInput::SetShowMapBackground(state));
+ gtk::glib::Propagation::Proceed
+ });
+ }
+ container.append(&bg_row.0);
+
+ // Smart Label Positioning
+ let smart_row = create_switch_row(
+ "Use Smart Label Positioning",
+ prefs.use_smart_label_positioning,
+ );
+ let smart_positioning_switch = smart_row.1.clone();
+ {
+ let sender = sender.clone();
+ smart_positioning_switch.connect_state_set(move |_, state| {
+ sender.emit(PreferencesInput::SetUseSmartLabelPositioning(state));
+ gtk::glib::Propagation::Proceed
+ });
+ }
+ container.append(&smart_row.0);
+
+ // Use Custom Font
+ let custom_font_row = create_switch_row("Use Custom Font", prefs.use_custom_font);
+ let custom_font_switch = custom_font_row.1.clone();
+ container.append(&custom_font_row.0);
+
+ // Font selection
+ let font_row = gtk::Box::new(gtk::Orientation::Horizontal, 10);
+ font_row.append(&gtk::Label::new(Some("Font")));
+
+ let font_button = gtk::FontButton::new();
+ font_button.set_font(&prefs.custom_font_name);
+ font_button.set_sensitive(prefs.use_custom_font);
+ font_button.set_hexpand(true);
+ font_button.set_use_font(true);
+ {
+ let sender = sender.clone();
+ font_button.connect_font_set(move |btn| {
+ if let Some(font) = btn.font() {
+ let font_desc = gtk::pango::FontDescription::from_string(&font);
+ if let Some(family) = font_desc.family() {
+ sender.emit(PreferencesInput::SetCustomFontName(family.to_string()));
+ }
+ }
+ });
+ }
+ font_row.append(&font_button);
+ container.append(&font_row);
+
+ // Connect custom font switch to font button sensitivity
+ {
+ let font_button_clone = font_button.clone();
+ let sender = sender.clone();
+ custom_font_switch.connect_state_set(move |_, state| {
+ font_button_clone.set_sensitive(state);
+ sender.emit(PreferencesInput::SetUseCustomFont(state));
+ gtk::glib::Propagation::Proceed
+ });
+ }
+
+ // Export section
+ let export_label = gtk::Label::new(Some("Export"));
+ export_label.add_css_class("heading");
+ export_label.set_halign(gtk::Align::Start);
+ export_label.set_margin_top(10);
+ container.append(&export_label);
+
+ let export_row = gtk::Box::new(gtk::Orientation::Horizontal, 10);
+ export_row.append(&gtk::Label::new(Some("Default Export Format")));
+
+ let formats = gtk::StringList::new(&["PNG", "SVG"]);
+ let export_format_dropdown = gtk::DropDown::new(Some(formats), None::<gtk::Expression>);
+ let selected = match prefs.default_export_format.as_str() {
+ "svg" => 1,
+ _ => 0,
+ };
+ export_format_dropdown.set_selected(selected);
+ {
+ let sender = sender.clone();
+ export_format_dropdown.connect_selected_notify(move |dropdown| {
+ let format = match dropdown.selected() {
+ 1 => "svg",
+ _ => "png",
+ };
+ sender.emit(PreferencesInput::SetDefaultExportFormat(format.to_string()));
+ });
+ }
+ export_row.append(&export_format_dropdown);
+ container.append(&export_row);
+
+ Self {
+ container,
+ show_background_switch,
+ smart_positioning_switch,
+ custom_font_switch,
+ font_button,
+ export_format_dropdown,
+ }
+ }
+
+ pub fn widget(&self) -> gtk::Box {
+ self.container.clone()
+ }
+
+ pub fn sync_from(&self, prefs: &UserPreferences) {
+ self.show_background_switch
+ .set_active(prefs.show_map_background);
+ self.smart_positioning_switch
+ .set_active(prefs.use_smart_label_positioning);
+ self.custom_font_switch.set_active(prefs.use_custom_font);
+ self.font_button.set_sensitive(prefs.use_custom_font);
+ self.font_button.set_font(&prefs.custom_font_name);
+
+ let selected = match prefs.default_export_format.as_str() {
+ "svg" => 1,
+ _ => 0,
+ };
+ self.export_format_dropdown.set_selected(selected);
+ }
+}
+
+fn create_switch_row(label: &str, initial_state: bool) -> (gtk::Box, gtk::Switch) {
+ let row = gtk::Box::new(gtk::Orientation::Horizontal, 10);
+ row.append(&gtk::Label::new(Some(label)));
+
+ let spacer = gtk::Box::new(gtk::Orientation::Horizontal, 0);
+ spacer.set_hexpand(true);
+ row.append(&spacer);
+
+ let switch = gtk::Switch::new();
+ switch.set_active(initial_state);
+ row.append(&switch);
+
+ (row, switch)
+}
diff --git a/src/preferences/pages/mod.rs b/src/preferences/pages/mod.rs
new file mode 100644
index 0000000..3d2db71
--- /dev/null
+++ b/src/preferences/pages/mod.rs
@@ -0,0 +1,20 @@
+// Copyright (C) 2024 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 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 General Public License for more details.
+
+// You should have received a copy of the GNU General Public License
+// along with this program. If not, see https://map.tranquil.systems.
+
+pub mod editor;
+pub mod general;
+pub mod map;
+pub mod stages;
+pub mod templates;
diff --git a/src/preferences/pages/stages.rs b/src/preferences/pages/stages.rs
new file mode 100644
index 0000000..c7fa494
--- /dev/null
+++ b/src/preferences/pages/stages.rs
@@ -0,0 +1,252 @@
+// Copyright (C) 2024 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 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 General Public License for more details.
+
+// You should have received a copy of the GNU General Public License
+// along with this program. If not, see https://map.tranquil.systems.
+
+use gtk::prelude::*;
+use relm4::Sender;
+use relm4::gtk;
+use uuid::Uuid;
+
+use super::super::models::UserPreferences;
+use super::super::window::{PreferencesInput, StageLabel};
+
+pub struct StagesPage {
+ container: gtk::Box,
+ list_box: gtk::ListBox,
+ sender: Sender<PreferencesInput>,
+}
+
+impl StagesPage {
+ pub fn new(prefs: &UserPreferences, sender: Sender<PreferencesInput>) -> Self {
+ let container = gtk::Box::new(gtk::Orientation::Vertical, 10);
+ container.set_margin_top(20);
+ container.set_margin_bottom(20);
+ container.set_margin_start(20);
+ container.set_margin_end(20);
+
+ // Header
+ let header_label = gtk::Label::new(Some("Custom Stages"));
+ header_label.add_css_class("heading");
+ header_label.set_halign(gtk::Align::Start);
+ container.append(&header_label);
+
+ let help_label = gtk::Label::new(Some(
+ "Custom stages allow you to define your own evolution axis labels. These will be available for rendering when the renderer supports custom stages.",
+ ));
+ help_label.add_css_class("dim-label");
+ help_label.set_halign(gtk::Align::Start);
+ help_label.set_wrap(true);
+ help_label.set_margin_bottom(10);
+ container.append(&help_label);
+
+ // Column headers
+ let header_row = gtk::Box::new(gtk::Orientation::Horizontal, 5);
+ header_row.add_css_class("dim-label");
+
+ let name_header = gtk::Label::new(Some("Name"));
+ name_header.set_width_chars(15);
+ name_header.set_xalign(0.0);
+ header_row.append(&name_header);
+
+ let i_header = gtk::Label::new(Some("Stage I"));
+ i_header.set_width_chars(12);
+ i_header.set_xalign(0.0);
+ header_row.append(&i_header);
+
+ let ii_header = gtk::Label::new(Some("Stage II"));
+ ii_header.set_width_chars(12);
+ ii_header.set_xalign(0.0);
+ header_row.append(&ii_header);
+
+ let iii_header = gtk::Label::new(Some("Stage III"));
+ iii_header.set_width_chars(12);
+ iii_header.set_xalign(0.0);
+ header_row.append(&iii_header);
+
+ let iv_header = gtk::Label::new(Some("Stage IV"));
+ iv_header.set_width_chars(12);
+ iv_header.set_xalign(0.0);
+ header_row.append(&iv_header);
+
+ // Spacer for delete button column
+ let spacer = gtk::Box::new(gtk::Orientation::Horizontal, 0);
+ spacer.set_width_request(32);
+ header_row.append(&spacer);
+
+ container.append(&header_row);
+
+ // Scrollable list area
+ let scrolled = gtk::ScrolledWindow::new();
+ scrolled.set_vexpand(true);
+ scrolled.set_min_content_height(200);
+
+ let list_box = gtk::ListBox::new();
+ list_box.set_selection_mode(gtk::SelectionMode::None);
+ list_box.add_css_class("boxed-list");
+
+ scrolled.set_child(Some(&list_box));
+ container.append(&scrolled);
+
+ // Add button
+ let button_box = gtk::Box::new(gtk::Orientation::Horizontal, 10);
+ button_box.set_halign(gtk::Align::End);
+
+ let add_button = gtk::Button::from_icon_name("list-add-symbolic");
+ add_button.set_tooltip_text(Some("Add custom stage"));
+ {
+ let sender = sender.clone();
+ add_button.connect_clicked(move |_| {
+ sender.emit(PreferencesInput::AddCustomStage);
+ });
+ }
+ button_box.append(&add_button);
+ container.append(&button_box);
+
+ let page = Self {
+ container,
+ list_box,
+ sender,
+ };
+
+ page.populate_stages(prefs);
+ page
+ }
+
+ pub fn widget(&self) -> gtk::Box {
+ self.container.clone()
+ }
+
+ pub fn refresh(&self, prefs: &UserPreferences) {
+ // Clear existing rows
+ while let Some(child) = self.list_box.first_child() {
+ self.list_box.remove(&child);
+ }
+ self.populate_stages(prefs);
+ }
+
+ fn populate_stages(&self, prefs: &UserPreferences) {
+ for stage in &prefs.custom_stages {
+ let row = self.create_stage_row(stage.id, &stage.name, &stage.stage);
+ self.list_box.append(&row);
+ }
+ }
+
+ fn create_stage_row(
+ &self,
+ id: Uuid,
+ name: &str,
+ stage: &super::super::models::Stage,
+ ) -> gtk::ListBoxRow {
+ let row = gtk::ListBoxRow::new();
+ let hbox = gtk::Box::new(gtk::Orientation::Horizontal, 5);
+ hbox.set_margin_top(5);
+ hbox.set_margin_bottom(5);
+ hbox.set_margin_start(5);
+ hbox.set_margin_end(5);
+
+ // Name entry
+ let name_entry = gtk::Entry::new();
+ name_entry.set_text(name);
+ name_entry.set_width_chars(15);
+ {
+ let sender = self.sender.clone();
+ name_entry.connect_changed(move |entry| {
+ sender.emit(PreferencesInput::SetCustomStageName(
+ id,
+ entry.text().to_string(),
+ ));
+ });
+ }
+ hbox.append(&name_entry);
+
+ // Stage I entry
+ let i_entry = gtk::Entry::new();
+ i_entry.set_text(&stage.i);
+ i_entry.set_width_chars(12);
+ {
+ let sender = self.sender.clone();
+ i_entry.connect_changed(move |entry| {
+ sender.emit(PreferencesInput::SetCustomStageLabel(
+ id,
+ StageLabel::I,
+ entry.text().to_string(),
+ ));
+ });
+ }
+ hbox.append(&i_entry);
+
+ // Stage II entry
+ let ii_entry = gtk::Entry::new();
+ ii_entry.set_text(&stage.ii);
+ ii_entry.set_width_chars(12);
+ {
+ let sender = self.sender.clone();
+ ii_entry.connect_changed(move |entry| {
+ sender.emit(PreferencesInput::SetCustomStageLabel(
+ id,
+ StageLabel::II,
+ entry.text().to_string(),
+ ));
+ });
+ }
+ hbox.append(&ii_entry);
+
+ // Stage III entry
+ let iii_entry = gtk::Entry::new();
+ iii_entry.set_text(&stage.iii);
+ iii_entry.set_width_chars(12);
+ {
+ let sender = self.sender.clone();
+ iii_entry.connect_changed(move |entry| {
+ sender.emit(PreferencesInput::SetCustomStageLabel(
+ id,
+ StageLabel::III,
+ entry.text().to_string(),
+ ));
+ });
+ }
+ hbox.append(&iii_entry);
+
+ // Stage IV entry
+ let iv_entry = gtk::Entry::new();
+ iv_entry.set_text(&stage.iv);
+ iv_entry.set_width_chars(12);
+ {
+ let sender = self.sender.clone();
+ iv_entry.connect_changed(move |entry| {
+ sender.emit(PreferencesInput::SetCustomStageLabel(
+ id,
+ StageLabel::IV,
+ entry.text().to_string(),
+ ));
+ });
+ }
+ hbox.append(&iv_entry);
+
+ // Delete button
+ let delete_button = gtk::Button::from_icon_name("list-remove-symbolic");
+ delete_button.add_css_class("flat");
+ delete_button.set_tooltip_text(Some("Remove stage"));
+ {
+ let sender = self.sender.clone();
+ delete_button.connect_clicked(move |_| {
+ sender.emit(PreferencesInput::RemoveCustomStage(id));
+ });
+ }
+ hbox.append(&delete_button);
+
+ row.set_child(Some(&hbox));
+ row
+ }
+}
diff --git a/src/preferences/pages/templates.rs b/src/preferences/pages/templates.rs
new file mode 100644
index 0000000..2611896
--- /dev/null
+++ b/src/preferences/pages/templates.rs
@@ -0,0 +1,312 @@
+// Copyright (C) 2024 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 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 General Public License for more details.
+
+// You should have received a copy of the GNU General Public License
+// along with this program. If not, see https://map.tranquil.systems.
+
+use std::cell::RefCell;
+use std::rc::Rc;
+
+use relm4::Sender;
+use relm4::gtk;
+use sourceview5::prelude::*;
+use uuid::Uuid;
+
+use super::super::models::{Template, UserPreferences};
+use super::super::window::PreferencesInput;
+
+pub struct TemplatesPage {
+ container: gtk::Box,
+ list_box: gtk::ListBox,
+ #[allow(dead_code)]
+ editor: sourceview5::View,
+ editor_buffer: sourceview5::Buffer,
+ selected_id: Rc<RefCell<Option<Uuid>>>,
+ sender: Sender<PreferencesInput>,
+}
+
+impl TemplatesPage {
+ pub fn new(prefs: &UserPreferences, sender: Sender<PreferencesInput>) -> Self {
+ let container = gtk::Box::new(gtk::Orientation::Horizontal, 0);
+ container.set_margin_top(10);
+ container.set_margin_bottom(10);
+ container.set_margin_start(10);
+ container.set_margin_end(10);
+
+ let selected_id: Rc<RefCell<Option<Uuid>>> = Rc::new(RefCell::new(None));
+
+ // Left sidebar
+ let sidebar = gtk::Box::new(gtk::Orientation::Vertical, 5);
+ sidebar.set_width_request(200);
+
+ let sidebar_label = gtk::Label::new(Some("Templates"));
+ sidebar_label.add_css_class("heading");
+ sidebar_label.set_halign(gtk::Align::Start);
+ sidebar_label.set_margin_start(5);
+ sidebar.append(&sidebar_label);
+
+ // Template list
+ let scrolled = gtk::ScrolledWindow::new();
+ scrolled.set_vexpand(true);
+
+ let list_box = gtk::ListBox::new();
+ list_box.set_selection_mode(gtk::SelectionMode::Single);
+ list_box.add_css_class("boxed-list");
+
+ scrolled.set_child(Some(&list_box));
+ sidebar.append(&scrolled);
+
+ // Sidebar buttons
+ let button_box = gtk::Box::new(gtk::Orientation::Horizontal, 5);
+ button_box.set_halign(gtk::Align::End);
+ button_box.set_margin_top(5);
+
+ let add_button = gtk::Button::from_icon_name("list-add-symbolic");
+ add_button.set_tooltip_text(Some("Add template"));
+ {
+ let sender = sender.clone();
+ add_button.connect_clicked(move |_| {
+ // Show a simple dialog to get the template name
+ show_add_template_dialog(sender.clone());
+ });
+ }
+ button_box.append(&add_button);
+
+ let remove_button = gtk::Button::from_icon_name("list-remove-symbolic");
+ remove_button.set_tooltip_text(Some("Remove template"));
+ {
+ let sender = sender.clone();
+ let selected_id = selected_id.clone();
+ remove_button.connect_clicked(move |_| {
+ if let Some(id) = *selected_id.borrow() {
+ sender.emit(PreferencesInput::RemoveTemplate(id));
+ }
+ });
+ }
+ button_box.append(&remove_button);
+
+ sidebar.append(&button_box);
+ container.append(&sidebar);
+
+ // Separator
+ let separator = gtk::Separator::new(gtk::Orientation::Vertical);
+ separator.set_margin_start(10);
+ separator.set_margin_end(10);
+ container.append(&separator);
+
+ // Right side: editor
+ let editor_box = gtk::Box::new(gtk::Orientation::Vertical, 5);
+ editor_box.set_hexpand(true);
+
+ let editor_label = gtk::Label::new(Some("Template Content"));
+ editor_label.add_css_class("heading");
+ editor_label.set_halign(gtk::Align::Start);
+ editor_box.append(&editor_label);
+
+ let editor_scrolled = gtk::ScrolledWindow::new();
+ editor_scrolled.set_vexpand(true);
+ editor_scrolled.set_hexpand(true);
+
+ let editor_buffer = sourceview5::Buffer::new(None);
+ editor_buffer.set_highlight_syntax(true);
+
+ // Try to set wmap language
+ let language_manager = sourceview5::LanguageManager::default();
+ if let Some(language) = language_manager.language("wmap") {
+ editor_buffer.set_language(Some(&language));
+ }
+
+ let editor = sourceview5::View::with_buffer(&editor_buffer);
+ editor.set_monospace(true);
+ editor.set_show_line_numbers(true);
+ editor.set_tab_width(2);
+
+ // Connect buffer changes to save
+ {
+ let sender = sender.clone();
+ let selected_id = selected_id.clone();
+ editor_buffer.connect_changed(move |buffer| {
+ if let Some(id) = *selected_id.borrow() {
+ let start = buffer.start_iter();
+ let end = buffer.end_iter();
+ let content = buffer.text(&start, &end, false).to_string();
+ sender.emit(PreferencesInput::SetTemplateContent(id, content));
+ }
+ });
+ }
+
+ editor_scrolled.set_child(Some(&editor));
+ editor_box.append(&editor_scrolled);
+ container.append(&editor_box);
+
+ let page = Self {
+ container,
+ list_box,
+ editor,
+ editor_buffer,
+ selected_id,
+ sender,
+ };
+
+ page.populate_templates(prefs);
+
+ // Select first template if available
+ if let Some(first) = prefs.map_templates.first() {
+ *page.selected_id.borrow_mut() = Some(first.id);
+ page.editor_buffer.set_text(&first.content);
+ }
+
+ page
+ }
+
+ pub fn widget(&self) -> gtk::Box {
+ self.container.clone()
+ }
+
+ pub fn refresh(&self, prefs: &UserPreferences) {
+ // Clear existing rows
+ while let Some(child) = self.list_box.first_child() {
+ self.list_box.remove(&child);
+ }
+ self.populate_templates(prefs);
+
+ // Update editor if selected template still exists
+ let selected = *self.selected_id.borrow();
+ if let Some(id) = selected {
+ if let Some(template) = prefs.map_templates.iter().find(|t| t.id == id) {
+ // Only update if content differs to avoid cursor jumping
+ let current = {
+ let start = self.editor_buffer.start_iter();
+ let end = self.editor_buffer.end_iter();
+ self.editor_buffer.text(&start, &end, false).to_string()
+ };
+ if current != template.content {
+ self.editor_buffer.set_text(&template.content);
+ }
+ } else {
+ // Selected template was deleted, select first
+ if let Some(first) = prefs.map_templates.first() {
+ *self.selected_id.borrow_mut() = Some(first.id);
+ self.editor_buffer.set_text(&first.content);
+ } else {
+ *self.selected_id.borrow_mut() = None;
+ self.editor_buffer.set_text("");
+ }
+ }
+ }
+ }
+
+ fn populate_templates(&self, prefs: &UserPreferences) {
+ for template in &prefs.map_templates {
+ let row = self.create_template_row(template);
+ self.list_box.append(&row);
+ }
+
+ // Connect selection changed
+ {
+ let selected_id = self.selected_id.clone();
+ let editor_buffer = self.editor_buffer.clone();
+ let _sender = self.sender.clone();
+
+ // We need to store templates for the closure
+ let templates: Vec<Template> = prefs.map_templates.clone();
+
+ self.list_box.connect_row_selected(move |_, row| {
+ if let Some(row) = row {
+ let index = row.index() as usize;
+ if let Some(template) = templates.get(index) {
+ *selected_id.borrow_mut() = Some(template.id);
+ editor_buffer.set_text(&template.content);
+ }
+ }
+ });
+ }
+ }
+
+ fn create_template_row(&self, template: &Template) -> gtk::ListBoxRow {
+ let row = gtk::ListBoxRow::new();
+ let hbox = gtk::Box::new(gtk::Orientation::Horizontal, 10);
+ hbox.set_margin_top(8);
+ hbox.set_margin_bottom(8);
+ hbox.set_margin_start(8);
+ hbox.set_margin_end(8);
+
+ // Default indicator button
+ let default_button = gtk::Button::new();
+ if template.is_default {
+ default_button.set_icon_name("emblem-default-symbolic");
+ default_button.set_tooltip_text(Some("Default template"));
+ } else {
+ default_button.set_icon_name("radio-symbolic");
+ default_button.set_tooltip_text(Some("Set as default"));
+ }
+ default_button.add_css_class("flat");
+ {
+ let sender = self.sender.clone();
+ let id = template.id;
+ default_button.connect_clicked(move |_| {
+ sender.emit(PreferencesInput::SetDefaultTemplate(id));
+ });
+ }
+ hbox.append(&default_button);
+
+ // Template name
+ let name_label = gtk::Label::new(Some(&template.name));
+ name_label.set_hexpand(true);
+ name_label.set_xalign(0.0);
+ hbox.append(&name_label);
+
+ row.set_child(Some(&hbox));
+ row
+ }
+}
+
+fn show_add_template_dialog(sender: Sender<PreferencesInput>) {
+ let dialog = gtk::Dialog::with_buttons(
+ Some("New Template"),
+ None::<&gtk::Window>,
+ gtk::DialogFlags::MODAL | gtk::DialogFlags::DESTROY_WITH_PARENT,
+ &[
+ ("Cancel", gtk::ResponseType::Cancel),
+ ("Add", gtk::ResponseType::Accept),
+ ],
+ );
+ dialog.set_default_response(gtk::ResponseType::Accept);
+
+ let content = dialog.content_area();
+ content.set_margin_top(10);
+ content.set_margin_bottom(10);
+ content.set_margin_start(10);
+ content.set_margin_end(10);
+ content.set_spacing(10);
+
+ let label = gtk::Label::new(Some("Template name:"));
+ label.set_halign(gtk::Align::Start);
+ content.append(&label);
+
+ let entry = gtk::Entry::new();
+ entry.set_placeholder_text(Some("My Template"));
+ entry.set_activates_default(true);
+ content.append(&entry);
+
+ dialog.connect_response(move |dialog, response| {
+ if response == gtk::ResponseType::Accept {
+ let name = entry.text().to_string();
+ if !name.is_empty() {
+ sender.emit(PreferencesInput::AddTemplate(name));
+ }
+ }
+ dialog.close();
+ });
+
+ dialog.present();
+}