diff options
| author | Rubén Beltrán del Río <jj@r.bdr.sh> | 2026-03-27 16:10:46 +0100 |
|---|---|---|
| committer | Rubén Beltrán del Río <jj@r.bdr.sh> | 2026-03-30 13:39:14 +0200 |
| commit | d01a4df546b553128400f6c68eefa8368b4113a8 (patch) | |
| tree | d290ca78b1215fb450ec7850a74b79fd0ae9707c /src/components/preference_pages | |
| parent | 4dec344a7bcbf1d7d0bbbf985109df816470ecc6 (diff) | |
Use actual components for preference pages
Diffstat (limited to 'src/components/preference_pages')
| -rw-r--r-- | src/components/preference_pages/editor.rs | 223 | ||||
| -rw-r--r-- | src/components/preference_pages/general.rs | 84 | ||||
| -rw-r--r-- | src/components/preference_pages/map.rs | 231 | ||||
| -rw-r--r-- | src/components/preference_pages/mod.rs | 27 | ||||
| -rw-r--r-- | src/components/preference_pages/stages.rs | 208 | ||||
| -rw-r--r-- | src/components/preference_pages/templates.rs | 443 |
6 files changed, 1216 insertions, 0 deletions
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: >k::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: >k::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() -> >k::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() -> >k::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() { + >k::Label { + #[watch] + set_label: &model.selected_name, + add_css_class: "heading", + set_hexpand: true, + set_halign: gtk::Align::Start, + } + } else { + >k::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()); + } +} |