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 | |
| parent | 4dec344a7bcbf1d7d0bbbf985109df816470ecc6 (diff) | |
Use actual components for preference pages
Diffstat (limited to 'src')
25 files changed, 2074 insertions, 1605 deletions
diff --git a/src/actions.rs b/src/actions.rs index 09b7d4b..e5ea0e5 100644 --- a/src/actions.rs +++ b/src/actions.rs @@ -18,6 +18,7 @@ use std::path::PathBuf; use wmap_renderer::StageType; use crate::preferences::UserPreferences; +use crate::preferences::models::StageLabel; #[derive(Debug, Clone, Copy)] pub enum ImageFormat { @@ -74,3 +75,38 @@ pub enum Action { PreferencesWindowClosed, ReloadPreferences, } + +#[derive(Debug, Clone)] +pub enum PreferencesAction { + // General page actions + Export, + Import, + ImportFromFile(PathBuf), + ExportToFile(PathBuf), + ResetToDefaults, + + // Preference updates from pages + SetShowMapBackground(bool), + SetUseCustomFont(bool), + SetCustomFontName(String), + SetDefaultExportFormat(String), + SetUseSmartLabelPositioning(bool), + + SetUseCustomEditorFont(bool), + SetCustomEditorFontName(String), + SetEditorFontSize(f64), + SetSoftWrapLines(bool), + + // Templates + AddTemplate(String), + RemoveTemplate(uuid::Uuid), + SetTemplateContent(uuid::Uuid, String), + SetTemplateName(uuid::Uuid, String), + SetDefaultTemplate(uuid::Uuid), + + // Custom stages + AddCustomStage, + RemoveCustomStage(uuid::Uuid), + SetCustomStageName(uuid::Uuid, String), + SetCustomStageLabel(uuid::Uuid, StageLabel, String), +} diff --git a/src/components/footer.rs b/src/components/footer.rs index 1c6581d..a7999be 100644 --- a/src/components/footer.rs +++ b/src/components/footer.rs @@ -1,3 +1,19 @@ +// 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::actions::ActionablePlus; use relm4::prelude::*; @@ -59,7 +75,7 @@ impl SimpleComponent for Footer { set_value_pos: gtk::PositionType::Left, set_width_request: 100, connect_value_changed[sender] => move |scale| { - let _ = sender.output(Action::Zoom(scale.value())); + sender.output(Action::Zoom(scale.value())).ok(); } }, diff --git a/src/components/header.rs b/src/components/header.rs index 7977a5b..f41d6a1 100644 --- a/src/components/header.rs +++ b/src/components/header.rs @@ -140,7 +140,9 @@ impl SimpleComponent for Header { if index < self.available_stage_types.len() && let Some(stage_type) = self.available_stage_types.get(index) { - let _ = sender.output(Action::StageTypeSelected(stage_type.clone())); + sender + .output(Action::StageTypeSelected(stage_type.clone())) + .ok(); } } } diff --git a/src/components/mod.rs b/src/components/mod.rs index 1194b9f..93cceea 100644 --- a/src/components/mod.rs +++ b/src/components/mod.rs @@ -15,3 +15,8 @@ // along with this program. If not, see <http://www.gnu.org/licenses/>. pub mod footer; pub mod header; +pub mod preference_pages; +pub mod preferences_window; +pub mod stage_form; +pub mod switch; +pub mod template_row; 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/preferences/pages/mod.rs b/src/components/preference_pages/mod.rs index 2ace921..9e37bcb 100644 --- a/src/preferences/pages/mod.rs +++ b/src/components/preference_pages/mod.rs @@ -14,8 +14,14 @@ // 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/>. -pub mod editor; -pub mod general; -pub mod map; -pub mod stages; -pub mod templates; +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()); + } +} diff --git a/src/components/preferences_window.rs b/src/components/preferences_window.rs new file mode 100644 index 0000000..ed18ba5 --- /dev/null +++ b/src/components/preferences_window.rs @@ -0,0 +1,455 @@ +// Map, wardley map editor for linux +// Copyright (C) 2026 Rubén Beltrán del Río + +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. + +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see <http://www.gnu.org/licenses/>. +use crate::icon_names::shipped::{AGENDA, BUILD, MAP, PAPYRUS, SETTINGS}; +use adw::prelude::*; +use relm4::prelude::*; +use std::convert::identity; + +use gtk::FileFilter; + +use crate::actions::{Action, PreferencesAction}; +use crate::components::preference_pages; +use crate::preferences::models::StageLabel; +use crate::preferences::models::{CustomStage, Template, UserPreferences}; +use crate::preferences::storage; +use crate::tr; + +pub struct PreferencesWindow { + preferences: UserPreferences, + window: adw::Window, + // Widget references for pages + general_page: Controller<preference_pages::General>, + editor_page: Controller<preference_pages::Editor>, + map_page: Controller<preference_pages::Map>, + stages_page: Controller<preference_pages::Stages>, + templates_page: Controller<preference_pages::Templates>, +} + +#[relm4::component(pub)] +impl SimpleComponent for PreferencesWindow { + type Init = UserPreferences; + type Input = PreferencesAction; + type Output = Action; + + view! { + adw::Window { + set_title: Some(&tr!("preferences.title")), + set_default_width: 700, + set_default_height: 500, + set_modal: true, + set_resizable: true, + + connect_close_request[sender] => move |_| { + sender.output(Action::PreferencesWindowClosed).ok(); + gtk::glib::Propagation::Proceed + }, + + adw::ToolbarView { + #[name = "header_bar"] + add_top_bar = &adw::HeaderBar { + #[wrap(Some)] + set_title_widget = &adw::ViewSwitcher { + set_stack: Some(&stack), + }, + }, + + #[name = "stack"] + adw::ViewStack { + set_vexpand: true, + + add_titled_with_icon[Some("general"), &tr!("preferences.menu.general"), SETTINGS] = model.general_page.widget(), + add_titled_with_icon[Some("editor"), &tr!("preferences.menu.editor"), BUILD] = model.editor_page.widget(), + add_titled_with_icon[Some("map"), &tr!("preferences.menu.map"), MAP] = model.map_page.widget(), + add_titled_with_icon[Some("stages"), &tr!("preferences.menu.stages"), AGENDA] = model.stages_page.widget(), + add_titled_with_icon[Some("templates"), &tr!("preferences.menu.templates"), PAPYRUS] = model.templates_page.widget(), + }, + + #[name = "switcher_bar"] + add_bottom_bar = &adw::ViewSwitcherBar { + set_stack: Some(&stack), + }, + }, + } + } + + fn init( + init: Self::Init, + root: Self::Root, + sender: ComponentSender<Self>, + ) -> ComponentParts<Self> { + let general_page = preference_pages::General::builder() + .launch(()) + .forward(sender.input_sender(), identity); + let editor_page = preference_pages::Editor::builder() + .launch(preference_pages::EditorInit { + font_size: init.editor_font_size, + soft_wrap: init.soft_wrap_lines, + use_custom_font: init.use_custom_editor_font, + custom_font: init.custom_editor_font_name.clone(), + }) + .forward(sender.input_sender(), identity); + let map_page = preference_pages::Map::builder() + .launch(preference_pages::MapInit { + show_background: init.show_map_background, + use_custom_font: init.use_custom_font, + use_smart_label_positioning: init.use_smart_label_positioning, + custom_font: init.custom_font_name.clone(), + default_export_format: init.default_export_format.clone(), + }) + .forward(sender.input_sender(), identity); + let stages_page = preference_pages::Stages::builder() + .launch(init.custom_stages.clone()) + .forward(sender.input_sender(), identity); + let templates_page = preference_pages::Templates::builder() + .launch(preference_pages::TemplatesInit { + templates: init.map_templates.clone(), + window: root.clone(), + }) + .forward(sender.input_sender(), identity); + + let model = PreferencesWindow { + preferences: init, + window: root.clone(), + general_page, + editor_page, + map_page, + stages_page, + templates_page, + }; + + let widgets = view_output!(); + + Self::init_breakpoints(&root, &widgets); + ComponentParts { model, widgets } + } + + fn update(&mut self, message: Self::Input, sender: ComponentSender<Self>) { + match message { + PreferencesAction::Export => { + Self::show_export_dialog(sender, &self.window); + } + PreferencesAction::Import => { + Self::show_import_dialog(sender, &self.window); + } + PreferencesAction::ImportFromFile(path) => { + if storage::import_from_file(&mut self.preferences, &path).is_ok() { + self.sync_ui_from_preferences(); + self.save_and_notify(&sender); + } + } + PreferencesAction::ExportToFile(path) => { + let _ = storage::export_to_file(&self.preferences, &path); + } + PreferencesAction::ResetToDefaults => { + self.preferences = UserPreferences::default(); + self.sync_ui_from_preferences(); + self.save_and_notify(&sender); + } + PreferencesAction::SetShowMapBackground(show_background) => { + self.preferences.show_map_background = show_background; + self.map_page + .emit(preference_pages::MapAction::SetShowBackground( + show_background, + )); + self.save_and_notify(&sender); + } + PreferencesAction::SetUseCustomFont(use_custom_font) => { + self.preferences.use_custom_font = use_custom_font; + self.map_page + .emit(preference_pages::MapAction::SetUseCustomFont( + use_custom_font, + )); + self.save_and_notify(&sender); + } + PreferencesAction::SetCustomFontName(custom_font) => { + self.preferences.custom_font_name = custom_font.clone(); + self.map_page + .emit(preference_pages::MapAction::SetCustomFont(custom_font)); + self.save_and_notify(&sender); + } + PreferencesAction::SetDefaultExportFormat(default_export_format) => { + self.preferences.default_export_format = default_export_format.clone(); + self.map_page + .emit(preference_pages::MapAction::SetDefaultExportFormat( + default_export_format, + )); + self.save_and_notify(&sender); + } + PreferencesAction::SetUseSmartLabelPositioning(use_smart_label_positioning) => { + self.preferences.use_smart_label_positioning = use_smart_label_positioning; + self.map_page + .emit(preference_pages::MapAction::SetUseSmartLabelPositioning( + use_smart_label_positioning, + )); + self.save_and_notify(&sender); + } + PreferencesAction::SetUseCustomEditorFont(use_custom_font) => { + self.preferences.use_custom_editor_font = use_custom_font; + self.editor_page + .emit(preference_pages::EditorAction::SetUseCustomFont( + use_custom_font, + )); + self.save_and_notify(&sender); + } + PreferencesAction::SetCustomEditorFontName(font_name) => { + self.preferences.custom_editor_font_name = font_name.clone(); + self.editor_page + .emit(preference_pages::EditorAction::SetCustomFont(font_name)); + self.save_and_notify(&sender); + } + PreferencesAction::SetEditorFontSize(font_size) => { + self.preferences.editor_font_size = font_size; + self.editor_page + .emit(preference_pages::EditorAction::SetFontSize(font_size)); + self.save_and_notify(&sender); + } + PreferencesAction::SetSoftWrapLines(soft_wrap_lines) => { + self.preferences.soft_wrap_lines = soft_wrap_lines; + self.editor_page + .emit(preference_pages::EditorAction::SetSoftWrap(soft_wrap_lines)); + self.save_and_notify(&sender); + } + PreferencesAction::AddTemplate(name) => self.handle_add_template(name, &sender), + PreferencesAction::RemoveTemplate(id) => self.handle_remove_template(id, &sender), + PreferencesAction::SetTemplateContent(id, content) => { + self.handle_set_template_content(id, content, &sender); + } + PreferencesAction::SetTemplateName(id, name) => { + self.handle_set_template_name(id, name, &sender); + } + PreferencesAction::SetDefaultTemplate(id) => { + self.handle_set_default_template(id, &sender); + } + PreferencesAction::AddCustomStage => self.handle_add_custom_stage(&sender), + PreferencesAction::RemoveCustomStage(id) => { + self.handle_remove_custom_stage(id, &sender); + } + PreferencesAction::SetCustomStageName(id, name) => { + self.handle_set_custom_stage_name(id, name, &sender); + } + PreferencesAction::SetCustomStageLabel(id, label, value) => { + self.handle_set_custom_stage_label(id, label, value, &sender); + } + } + } +} + +impl PreferencesWindow { + fn save_and_notify(&self, sender: &ComponentSender<Self>) { + let _ = storage::save(&self.preferences); + sender + .output(Action::PreferencesChanged(self.preferences.clone())) + .ok(); + } + + // TODO: Figure out how to best do this with messages. + fn sync_ui_from_preferences(&mut self) {} + + fn handle_add_template(&mut self, name: String, sender: &ComponentSender<Self>) { + let template = Template::new(name, String::new(), false); + self.preferences.map_templates.push(template.clone()); + self.templates_page + .emit(preference_pages::TemplatesAction::TemplateAdded(template)); + self.save_and_notify(sender); + } + + fn handle_remove_template(&mut self, id: uuid::Uuid, sender: &ComponentSender<Self>) { + self.preferences.map_templates.retain(|t| t.id != id); + self.templates_page + .emit(preference_pages::TemplatesAction::TemplateRemoved(id)); + self.save_and_notify(sender); + } + + fn handle_set_template_content( + &mut self, + id: uuid::Uuid, + content: String, + sender: &ComponentSender<Self>, + ) { + if let Some(template) = self + .preferences + .map_templates + .iter_mut() + .find(|t| t.id == id) + { + template.content = content; + self.templates_page + .emit(preference_pages::TemplatesAction::TemplateUpdated( + template.clone(), + )); + self.save_and_notify(sender); + } + } + + fn handle_set_template_name( + &mut self, + id: uuid::Uuid, + name: String, + sender: &ComponentSender<Self>, + ) { + if let Some(template) = self + .preferences + .map_templates + .iter_mut() + .find(|t| t.id == id) + { + template.name = name; + self.templates_page + .emit(preference_pages::TemplatesAction::TemplateUpdated( + template.clone(), + )); + self.save_and_notify(sender); + } + } + + fn handle_set_default_template(&mut self, id: uuid::Uuid, sender: &ComponentSender<Self>) { + self.preferences.set_default_template(id); + self.templates_page + .emit(preference_pages::TemplatesAction::UpdateAllTemplates( + self.preferences.map_templates.clone(), + )); + self.save_and_notify(&sender); + } + + fn handle_add_custom_stage(&mut self, sender: &ComponentSender<Self>) { + let stage = CustomStage::placeholder_default(); + self.preferences.custom_stages.push(stage.clone()); + self.stages_page + .emit(preference_pages::StagesAction::CustomStageAdded(stage)); + self.save_and_notify(sender); + } + + fn handle_remove_custom_stage(&mut self, id: uuid::Uuid, sender: &ComponentSender<Self>) { + self.preferences.custom_stages.retain(|s| s.id != id); + self.stages_page + .emit(preference_pages::StagesAction::CustomStageRemoved(id)); + self.save_and_notify(sender); + } + + fn handle_set_custom_stage_name( + &mut self, + id: uuid::Uuid, + name: String, + sender: &ComponentSender<Self>, + ) { + if let Some(stage) = self + .preferences + .custom_stages + .iter_mut() + .find(|s| s.id == id) + { + stage.name = name; + self.stages_page + .emit(preference_pages::StagesAction::CustomStageUpdated( + stage.clone(), + )); + self.save_and_notify(sender); + } + } + + fn handle_set_custom_stage_label( + &mut self, + id: uuid::Uuid, + label: StageLabel, + value: String, + sender: &ComponentSender<Self>, + ) { + if let Some(stage) = self + .preferences + .custom_stages + .iter_mut() + .find(|s| s.id == id) + { + match label { + StageLabel::I => stage.stage.i = value, + StageLabel::Ii => stage.stage.ii = value, + StageLabel::Iii => stage.stage.iii = value, + StageLabel::Iv => stage.stage.iv = value, + } + self.stages_page + .emit(preference_pages::StagesAction::CustomStageUpdated( + stage.clone(), + )); + self.save_and_notify(sender); + } + } + + // Initializes the breakpoint logic + fn init_breakpoints(root: &adw::Window, widgets: &PreferencesWindowWidgets) { + let breakpoint = adw::Breakpoint::new(adw::BreakpointCondition::new_length( + adw::BreakpointConditionLengthType::MaxWidth, + 550.0, + adw::LengthUnit::Sp, + )); + breakpoint.add_setter(&widgets.switcher_bar, "reveal", Some(&true.to_value())); + breakpoint.add_setter( + &widgets.header_bar, + "title-widget", + Some(&None::<gtk::Widget>.to_value()), + ); + root.add_breakpoint(breakpoint); + } + fn show_export_dialog(sender: ComponentSender<Self>, window: &adw::Window) { + let filter = FileFilter::new(); + filter.add_pattern("*.json"); + filter.set_name(Some(&tr!("dialog.file_type.json"))); + + let filters = gtk::gio::ListStore::new::<gtk::FileFilter>(); + filters.append(&filter); + + let dialog = gtk::FileDialog::builder() + .title(tr!("dialog.export_preferences.title")) + .modal(true) + .filters(&filters) + .initial_name("preferences.json") + .build(); + + let sender = sender.clone(); + let window = window.clone(); + gtk::glib::spawn_future_local(async move { + if let Ok(file) = dialog.save_future(Some(&window)).await + && let Some(path) = file.path() + { + sender.input(PreferencesAction::ExportToFile(path)); + } + }); + } + + fn show_import_dialog(sender: ComponentSender<Self>, window: &adw::Window) { + let filter = FileFilter::new(); + filter.add_pattern("*.json"); + filter.set_name(Some(&tr!("dialog.file_type.json"))); + + let filters = gtk::gio::ListStore::new::<gtk::FileFilter>(); + filters.append(&filter); + + let dialog = gtk::FileDialog::builder() + .title(tr!("dialog.import_preferences.title")) + .modal(true) + .filters(&filters) + .build(); + + let sender = sender.clone(); + let window = window.clone(); + gtk::glib::spawn_future_local(async move { + if let Ok(file) = dialog.open_future(Some(&window)).await + && let Some(path) = file.path() + { + sender.input(PreferencesAction::ImportFromFile(path)); + } + }); + } +} diff --git a/src/components/stage_form.rs b/src/components/stage_form.rs new file mode 100644 index 0000000..cb4c40f --- /dev/null +++ b/src/components/stage_form.rs @@ -0,0 +1,141 @@ +// 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::preferences::models::{CustomStage, StageLabel}; +use crate::tr; + +#[derive(Debug)] +pub struct StageForm { + pub stage: CustomStage, +} + +#[derive(Debug)] +pub enum StageFormAction { + SetName(String), + SetLabel(StageLabel, String), + Remove, +} + +#[derive(Debug)] +pub enum StageFormOutput { + SetName(Uuid, String), + SetLabel(Uuid, StageLabel, String), + Remove(Uuid), +} + +#[relm4::factory(pub)] +impl FactoryComponent for StageForm { + type Init = CustomStage; + type Input = StageFormAction; + type Output = StageFormOutput; + type CommandOutput = (); + type ParentWidget = gtk::ListBox; + + view! { + #[root] + gtk::ListBoxRow { + gtk::Box { + set_orientation: gtk::Orientation::Horizontal, + set_margin_all: 5, + set_spacing: 5, + + gtk::Entry { + set_text: &self.stage.name, + set_width_chars: 15, + connect_changed[sender] => move |entry| { + sender.input(StageFormAction::SetName( + entry.text().to_string() + )); + }, + }, + gtk::Entry { + set_text: &self.stage.stage.i, + set_width_chars: 12, + connect_changed[sender] => move |entry| { + sender.input(StageFormAction::SetLabel( + StageLabel::I, + entry.text().to_string() + )); + }, + }, + gtk::Entry { + set_text: &self.stage.stage.ii, + set_width_chars: 12, + connect_changed[sender] => move |entry| { + sender.input(StageFormAction::SetLabel( + StageLabel::Ii, + entry.text().to_string() + )); + }, + }, + gtk::Entry { + set_text: &self.stage.stage.iii, + set_width_chars: 12, + connect_changed[sender] => move |entry| { + sender.input(StageFormAction::SetLabel( + StageLabel::Iii, + entry.text().to_string() + )); + }, + }, + gtk::Entry { + set_text: &self.stage.stage.iv, + set_width_chars: 12, + connect_changed[sender] => move |entry| { + sender.input(StageFormAction::SetLabel( + StageLabel::Iv, + entry.text().to_string() + )); + }, + }, + + gtk::Button::from_icon_name("list-remove-symbolic") { + add_css_class: "flat", + set_tooltip_text: Some(&tr!("preferences.stages.help.remove")), + connect_clicked[sender] => move |_| { + sender.input(StageFormAction::Remove); + } + } + } + } + } + + fn init_model(stage: Self::Init, _index: &DynamicIndex, _sender: FactorySender<Self>) -> Self { + Self { stage } + } + + fn update(&mut self, message: Self::Input, sender: FactorySender<Self>) { + match message { + StageFormAction::SetName(name) => { + sender + .output(StageFormOutput::SetName(self.stage.id, name)) + .ok(); + } + StageFormAction::SetLabel(label, value) => { + sender + .output(StageFormOutput::SetLabel(self.stage.id, label, value)) + .ok(); + } + StageFormAction::Remove => { + sender.output(StageFormOutput::Remove(self.stage.id)).ok(); + } + } + } +} diff --git a/src/components/switch.rs b/src/components/switch.rs new file mode 100644 index 0000000..8308407 --- /dev/null +++ b/src/components/switch.rs @@ -0,0 +1,79 @@ +// 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::*; + +pub struct Switch { + state: bool, + label: String, +} + +pub struct SwitchInit { + pub state: bool, + pub label: String, +} + +#[derive(Debug, Clone)] +pub enum SwitchOutput { + SetState(bool), +} + +#[relm4::component(pub)] +impl SimpleComponent for Switch { + type Init = SwitchInit; + type Input = (); + type Output = SwitchOutput; + + view! { + gtk::Box { + set_orientation: gtk::Orientation::Horizontal, + set_spacing: 10, + + gtk::Label { + #[watch] + set_label: &model.label + }, + + gtk::Box { + set_orientation: gtk::Orientation::Horizontal, + set_hexpand: true, + }, + + gtk::Switch { + set_active: model.state, + connect_state_set => move |_, state| { + sender.output(SwitchOutput::SetState(state)).ok(); + gtk::glib::Propagation::Proceed + } + } + } + } + + fn init( + init: Self::Init, + root: Self::Root, + sender: ComponentSender<Self>, + ) -> ComponentParts<Self> { + let model = Self { + label: init.label, + state: init.state, + }; + let widgets = view_output!(); + + ComponentParts { model, widgets } + } +} diff --git a/src/components/template_row.rs b/src/components/template_row.rs new file mode 100644 index 0000000..959c392 --- /dev/null +++ b/src/components/template_row.rs @@ -0,0 +1,122 @@ +// 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::icon_names::shipped::RIGHT; +use crate::preferences::models::Template; +use crate::tr; + +#[derive(Debug)] +pub struct TemplateRow { + pub template: Template, + focus_controller: gtk::EventControllerFocus, + is_arrow_shown: bool, +} + +#[derive(Debug, Clone)] +pub enum TemplateRowAction { + ShowArrow, + HideArrow, +} + +#[derive(Debug)] +pub enum TemplateRowOutput { + SetDefault(DynamicIndex), + SetName(DynamicIndex, String), + Focus(DynamicIndex), + Activate(DynamicIndex), +} + +#[relm4::factory(pub)] +impl FactoryComponent for TemplateRow { + type Init = Template; + type Input = TemplateRowAction; + type Output = TemplateRowOutput; + type CommandOutput = (); + type ParentWidget = gtk::ListBox; + + view! { + #[root] + gtk::ListBoxRow { + set_focus_on_click: true, + gtk::Box { + set_orientation: gtk::Orientation::Horizontal, + set_margin_all: 5, + set_spacing: 5, + + gtk::Button { + add_css_class: "flat", + #[watch] + set_icon_name: if self.template.is_default { "radio-checked-symbolic" } else { "radio-symbolic" }, + #[watch] + set_tooltip_text: Some(&if self.template.is_default { tr!("preferences.templates.help.default") } else { tr!("preferences.templates.help.set_as_default") }), + connect_clicked[sender, index] => move |_| { + sender.output(TemplateRowOutput::SetDefault(index.clone())).ok(); + } + }, + + #[name="entry"] + gtk::Entry { + set_text: &self.template.name, + set_hexpand: true, + connect_changed[sender, index] => move |entry| { + sender.output(TemplateRowOutput::SetName( + index.clone(), + entry.text().to_string() + )).ok(); + }, + add_controller: self.focus_controller.clone() + }, + + gtk::Button::from_icon_name(RIGHT) { + set_tooltip_text: Some(&tr!("preferences.templates.help.activate")), + #[watch] + set_visible: self.is_arrow_shown, + + connect_clicked[sender, index] => move |_| { + sender.output(TemplateRowOutput::Activate(index.clone())).ok(); + } + } + }, + } + } + + fn init_model(template: Self::Init, index: &DynamicIndex, sender: FactorySender<Self>) -> Self { + let focus_controller = gtk::EventControllerFocus::new(); + + { + let sender = sender.clone(); + let index = index.clone(); + focus_controller.connect_enter(move |_| { + sender.output(TemplateRowOutput::Focus(index.clone())).ok(); + }); + } + Self { + template, + focus_controller, + is_arrow_shown: true, + } + } + + fn update(&mut self, message: Self::Input, _sender: FactorySender<Self>) { + match message { + TemplateRowAction::ShowArrow => self.is_arrow_shown = true, + TemplateRowAction::HideArrow => self.is_arrow_shown = false, + } + } +} diff --git a/src/handlers/preferences.rs b/src/handlers/preferences.rs index 89a1ef5..9fb3638 100644 --- a/src/handlers/preferences.rs +++ b/src/handlers/preferences.rs @@ -13,13 +13,16 @@ // 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 std::convert::identity; + use adw::prelude::*; use relm4::prelude::*; use crate::actions::Action; use crate::components::header::HeaderAction; +use crate::components::preferences_window::PreferencesWindow; use crate::file_registry; -use crate::preferences::{PreferencesWindow, UserPreferences, storage, window}; +use crate::preferences::{UserPreferences, storage}; use crate::AppModel; @@ -29,12 +32,7 @@ pub fn show(model: &mut AppModel, sender: &ComponentSender<AppModel>) { let controller = PreferencesWindow::builder() .transient_for(&model.window) .launch(model.preferences.clone()) - .forward(sender.input_sender(), |output| match output { - window::PreferencesOutput::PreferencesChanged(preferences) => { - Action::PreferencesChanged(preferences) - } - window::PreferencesOutput::Closed => Action::PreferencesWindowClosed, - }); + .forward(sender.input_sender(), identity); model.preferences_window = Some(controller); } if let Some(ref controller) = model.preferences_window { diff --git a/src/main.rs b/src/main.rs index 5d0b88e..06c4943 100644 --- a/src/main.rs +++ b/src/main.rs @@ -24,7 +24,6 @@ mod handlers; mod i18n; mod preferences; mod stages; -mod ui_helpers; mod icon_names { #![allow(clippy::doc_markdown)] // Generated code from relm4_icons_build @@ -57,7 +56,7 @@ macro_rules! register_action { ($group:expr, $app:expr, $sender:expr, $action_type:ty, $message:expr, $accelerators:expr) => {{ let sender = $sender.clone(); let action: RelmAction<$action_type> = RelmAction::new_stateless(move |_| { - let _ = sender.input($message); + sender.input($message); }); $group.add_action(action.clone()); $app.set_accelerators_for_action::<$action_type>($accelerators); @@ -131,7 +130,7 @@ struct AppModel { initialized: bool, window: adw::Window, preferences: preferences::UserPreferences, - preferences_window: Option<relm4::Controller<preferences::PreferencesWindow>>, + preferences_window: Option<Controller<components::preferences_window::PreferencesWindow>>, editor_css_provider: gtk::CssProvider, horizontal_layout_enabled: bool, diff --git a/src/preferences/mod.rs b/src/preferences/mod.rs index b369e88..6dd21b2 100644 --- a/src/preferences/mod.rs +++ b/src/preferences/mod.rs @@ -15,9 +15,6 @@ // along with this program. If not, see <http://www.gnu.org/licenses/>. pub mod models; -pub mod pages; pub mod storage; -pub mod window; pub use models::UserPreferences; -pub use window::PreferencesWindow; diff --git a/src/preferences/models.rs b/src/preferences/models.rs index a595fc2..93f3fa0 100644 --- a/src/preferences/models.rs +++ b/src/preferences/models.rs @@ -267,3 +267,12 @@ impl UserPreferences { } } } + +/// Which stage label to update (Roman numerals I-IV) +#[derive(Debug, Clone, Copy)] +pub enum StageLabel { + I, + Ii, + Iii, + Iv, +} diff --git a/src/preferences/pages/editor.rs b/src/preferences/pages/editor.rs deleted file mode 100644 index c0e9b41..0000000 --- a/src/preferences/pages/editor.rs +++ /dev/null @@ -1,222 +0,0 @@ -// 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::Sender; -use relm4::gtk; - -use crate::tr; -use crate::ui_helpers::create_switch_row; - -use super::super::models::UserPreferences; -use super::super::window::PreferencesInput; - -const DEFAULT_FONT_SIZE: f64 = 14.0; - -pub struct EditorPage { - container: gtk::Box, - font_size_scale: gtk::Scale, - soft_wrap_switch: gtk::Switch, - custom_font_switch: gtk::Switch, - font_button: gtk::FontDialogButton, -} - -impl EditorPage { - pub fn new(preferences: &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); - - let (font_size_box, font_size_scale) = Self::create_font_size_section(preferences); - container.append(&font_size_box); - - let (soft_wrap_switch, custom_font_switch) = - Self::create_style_section(&container, preferences); - - let font_button = Self::create_font_button(preferences); - container.append(&Self::create_font_row(&font_button)); - - Self::connect_signals( - sender, - &font_size_scale, - &soft_wrap_switch, - &custom_font_switch, - &font_button, - ); - - Self { - container, - font_size_scale, - soft_wrap_switch, - custom_font_switch, - font_button, - } - } - - fn create_font_size_section(preferences: &UserPreferences) -> (gtk::Box, gtk::Scale) { - let label = gtk::Label::new(Some(&tr!("preferences.editor.font_size.title"))); - label.add_css_class("heading"); - label.set_halign(gtk::Align::Start); - - let scale = gtk::Scale::with_range(gtk::Orientation::Horizontal, 7.0, 21.0, 1.0); - scale.set_value(preferences.editor_font_size); - scale.set_draw_value(true); - scale.set_hexpand(true); - #[allow(clippy::cast_possible_truncation)] - scale.set_format_value_func(|_, value| { - #[allow(clippy::float_cmp)] - if value == DEFAULT_FONT_SIZE { - format!( - "{} ({})", - value as i32, - tr!("preferences.editor.default_font_size") - ) - } else { - format!("{}", value as i32) - } - }); - - let vbox = gtk::Box::new(gtk::Orientation::Vertical, 10); - vbox.append(&label); - vbox.append(&scale); - - (vbox, scale) - } - - fn create_style_section( - container: >k::Box, - preferences: &UserPreferences, - ) -> (gtk::Switch, gtk::Switch) { - let label = gtk::Label::new(Some(&tr!("preferences.editor.editor_style.title"))); - label.add_css_class("heading"); - label.set_halign(gtk::Align::Start); - label.set_margin_top(10); - container.append(&label); - - let soft_wrap_row = create_switch_row( - &tr!("preferences.editor.editor_style.soft_wrap_lines"), - preferences.soft_wrap_lines, - ); - container.append(&soft_wrap_row.0); - - let custom_font_row = create_switch_row( - &tr!("preferences.editor.editor_style.use_custom_font"), - preferences.use_custom_editor_font, - ); - container.append(&custom_font_row.0); - - (soft_wrap_row.1, custom_font_row.1) - } - - fn create_font_button(preferences: &UserPreferences) -> gtk::FontDialogButton { - let font_dialog = gtk::FontDialog::new(); - let font_button = gtk::FontDialogButton::new(Some(font_dialog)); - #[allow(clippy::cast_possible_truncation)] - let font_desc = gtk::pango::FontDescription::from_string(&format!( - "{} {}", - preferences.custom_editor_font_name, preferences.editor_font_size as i32 - )); - font_button.set_font_desc(&font_desc); - font_button.set_sensitive(preferences.use_custom_editor_font); - font_button.set_hexpand(true); - font_button.set_use_font(true); - font_button.set_use_size(false); - font_button - } - - fn create_font_row(font_button: >k::FontDialogButton) -> gtk::Box { - let row = gtk::Box::new(gtk::Orientation::Horizontal, 10); - row.append(>k::Label::new(Some(&tr!("preferences.editor.font")))); - row.append(font_button); - row - } - - fn connect_signals( - sender: &Sender<PreferencesInput>, - font_size_scale: >k::Scale, - soft_wrap_switch: >k::Switch, - custom_font_switch: >k::Switch, - font_button: >k::FontDialogButton, - ) { - let s = sender.clone(); - let fb = font_button.clone(); - font_size_scale.connect_value_changed(move |scale| { - s.emit(PreferencesInput::SetEditorFontSize(scale.value())); - Self::update_font_button_size(&fb, scale.value()); - }); - - let s = sender.clone(); - soft_wrap_switch.connect_state_set(move |_, state| { - s.emit(PreferencesInput::SetSoftWrapLines(state)); - gtk::glib::Propagation::Proceed - }); - - let s = sender.clone(); - let scale = font_size_scale.clone(); - font_button.connect_font_desc_notify(move |button| { - if let Some(font) = button.font_desc() { - if let Some(family) = font.family() { - s.emit(PreferencesInput::SetCustomEditorFontName( - family.to_string(), - )); - } - let size = font.size() / gtk::pango::SCALE; - if size > 0 { - scale.set_value(f64::from(size)); - } - } - }); - - let s = sender.clone(); - let fb = font_button.clone(); - custom_font_switch.connect_state_set(move |_, state| { - fb.set_sensitive(state); - s.emit(PreferencesInput::SetUseCustomEditorFont(state)); - gtk::glib::Propagation::Proceed - }); - } - - fn update_font_button_size(font_button: >k::FontDialogButton, size: f64) { - if let Some(mut font_desc) = font_button.font_desc() { - #[allow(clippy::cast_possible_truncation)] - font_desc.set_size(size as i32 * gtk::pango::SCALE); - font_button.set_font_desc(&font_desc); - } - } - - pub fn widget(&self) -> gtk::Box { - self.container.clone() - } - - pub fn sync_from(&self, preferences: &UserPreferences) { - self.font_size_scale.set_value(preferences.editor_font_size); - self.soft_wrap_switch - .set_active(preferences.soft_wrap_lines); - self.custom_font_switch - .set_active(preferences.use_custom_editor_font); - self.font_button - .set_sensitive(preferences.use_custom_editor_font); - #[allow(clippy::cast_possible_truncation)] - let font_desc = gtk::pango::FontDescription::from_string(&format!( - "{} {}", - preferences.custom_editor_font_name, preferences.editor_font_size as i32 - )); - self.font_button.set_font_desc(&font_desc); - } -} diff --git a/src/preferences/pages/general.rs b/src/preferences/pages/general.rs deleted file mode 100644 index e65ede6..0000000 --- a/src/preferences/pages/general.rs +++ /dev/null @@ -1,144 +0,0 @@ -// 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::FileFilter; -use gtk::prelude::*; -use relm4::Sender; -use relm4::{adw, gtk}; - -use crate::tr; - -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(&tr!("preferences.general.preferences.title"))); - 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(&tr!("preferences.general.preferences.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(&tr!("preferences.general.preferences.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(&tr!("preferences.general.preferences.reset")); - 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(&tr!("preferences.general.explanation"))); - 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: &adw::Window) { - let filter = FileFilter::new(); - filter.add_pattern("*.json"); - filter.set_name(Some(&tr!("dialog.file_type.json"))); - - let filters = gtk::gio::ListStore::new::<gtk::FileFilter>(); - filters.append(&filter); - - let dialog = gtk::FileDialog::builder() - .title(tr!("dialog.export_preferences.title")) - .modal(true) - .filters(&filters) - .initial_name("preferences.json") - .build(); - - let sender = sender.clone(); - let window = window.clone(); - gtk::glib::spawn_future_local(async move { - if let Ok(file) = dialog.save_future(Some(&window)).await - && let Some(path) = file.path() - { - sender.emit(PreferencesInput::ExportToFile(path)); - } - }); -} - -pub fn show_import_dialog(sender: &Sender<PreferencesInput>, window: &adw::Window) { - let filter = FileFilter::new(); - filter.add_pattern("*.json"); - filter.set_name(Some(&tr!("dialog.file_type.json"))); - - let filters = gtk::gio::ListStore::new::<gtk::FileFilter>(); - filters.append(&filter); - - let dialog = gtk::FileDialog::builder() - .title(tr!("dialog.import_preferences.title")) - .modal(true) - .filters(&filters) - .build(); - - let sender = sender.clone(); - let window = window.clone(); - gtk::glib::spawn_future_local(async move { - if let Ok(file) = dialog.open_future(Some(&window)).await - && let Some(path) = file.path() - { - sender.emit(PreferencesInput::ImportFromFile(path)); - } - }); -} diff --git a/src/preferences/pages/map.rs b/src/preferences/pages/map.rs deleted file mode 100644 index 5645a59..0000000 --- a/src/preferences/pages/map.rs +++ /dev/null @@ -1,212 +0,0 @@ -// 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 relm4::Sender; -use relm4::gtk; -use sourceview5::prelude::*; - -use crate::tr; -use crate::ui_helpers::create_switch_row; - -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::FontDialogButton, - export_format_dropdown: gtk::DropDown, -} - -impl MapPage { - pub fn new(preferences: &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); - - let (show_background_switch, smart_positioning_switch, custom_font_switch, font_button) = - Self::init_style_section(&container, preferences, sender); - - let export_format_dropdown = Self::init_export_section(&container, preferences, sender); - - Self { - container, - show_background_switch, - smart_positioning_switch, - custom_font_switch, - font_button, - export_format_dropdown, - } - } - - fn init_style_section( - container: >k::Box, - preferences: &UserPreferences, - sender: &Sender<PreferencesInput>, - ) -> (gtk::Switch, gtk::Switch, gtk::Switch, gtk::FontDialogButton) { - let style_label = gtk::Label::new(Some(&tr!("preferences.map.map_style.title"))); - style_label.add_css_class("heading"); - style_label.set_halign(gtk::Align::Start); - container.append(&style_label); - - // Show Background - let background_row = create_switch_row( - &tr!("preferences.map.map_style.show_background"), - preferences.show_map_background, - ); - let show_background_switch = background_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(&background_row.0); - - // Smart Label Positioning - let smart_row = create_switch_row( - &tr!("preferences.map.map_style.use_smart_label_positioning"), - preferences.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( - &tr!("preferences.map.map_style.use_custom_font"), - preferences.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(>k::Label::new(Some(&tr!("preferences.map.font")))); - - let font_dialog = gtk::FontDialog::new(); - let font_button = gtk::FontDialogButton::new(Some(font_dialog)); - let font_desc = gtk::pango::FontDescription::from_string(&preferences.custom_font_name); - font_button.set_font_desc(&font_desc); - font_button.set_sensitive(preferences.use_custom_font); - font_button.set_hexpand(true); - font_button.set_use_font(true); - { - let sender = sender.clone(); - font_button.connect_font_desc_notify(move |button| { - if let Some(font) = button.font_desc() - && let Some(family) = font.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 - }); - } - - ( - show_background_switch, - smart_positioning_switch, - custom_font_switch, - font_button, - ) - } - - fn init_export_section( - container: >k::Box, - preferences: &UserPreferences, - sender: &Sender<PreferencesInput>, - ) -> gtk::DropDown { - let export_label = gtk::Label::new(Some(&tr!("preferences.map.export.title"))); - 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(>k::Label::new(Some(&tr!( - "preferences.map.export.default_format" - )))); - - let formats = - gtk::StringList::new(&[&tr!("export_formats.png"), &tr!("export_formats.svg")]); - let export_format_dropdown = gtk::DropDown::new(Some(formats), None::<gtk::Expression>); - let selected = match preferences.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); - - export_format_dropdown - } - - pub fn widget(&self) -> gtk::Box { - self.container.clone() - } - - pub fn sync_from(&self, preferences: &UserPreferences) { - self.show_background_switch - .set_active(preferences.show_map_background); - self.smart_positioning_switch - .set_active(preferences.use_smart_label_positioning); - self.custom_font_switch - .set_active(preferences.use_custom_font); - self.font_button.set_sensitive(preferences.use_custom_font); - let font_desc = gtk::pango::FontDescription::from_string(&preferences.custom_font_name); - self.font_button.set_font_desc(&font_desc); - - let selected = match preferences.default_export_format.as_str() { - "svg" => 1, - _ => 0, - }; - self.export_format_dropdown.set_selected(selected); - } -} diff --git a/src/preferences/pages/stages.rs b/src/preferences/pages/stages.rs deleted file mode 100644 index 4bd24e1..0000000 --- a/src/preferences/pages/stages.rs +++ /dev/null @@ -1,253 +0,0 @@ -// 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::Sender; -use relm4::gtk; -use uuid::Uuid; - -use crate::tr; - -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(preferences: &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(&tr!("preferences.stages.title"))); - header_label.add_css_class("heading"); - header_label.set_halign(gtk::Align::Start); - container.append(&header_label); - - let help_label = gtk::Label::new(Some(&tr!("preferences.stages.explanation"))); - 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(&tr!("preferences.stages.column.name"))); - name_header.set_width_chars(15); - name_header.set_xalign(0.0); - header_row.append(&name_header); - - let first_header = gtk::Label::new(Some(&tr!("preferences.stages.column.stage_i"))); - first_header.set_width_chars(12); - first_header.set_xalign(0.0); - header_row.append(&first_header); - - let second_header = gtk::Label::new(Some(&tr!("preferences.stages.column.stage_ii"))); - second_header.set_width_chars(12); - second_header.set_xalign(0.0); - header_row.append(&second_header); - - let third_header = gtk::Label::new(Some(&tr!("preferences.stages.column.stage_iii"))); - third_header.set_width_chars(12); - third_header.set_xalign(0.0); - header_row.append(&third_header); - - let fourth_header = gtk::Label::new(Some(&tr!("preferences.stages.column.stage_iv"))); - fourth_header.set_width_chars(12); - fourth_header.set_xalign(0.0); - header_row.append(&fourth_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(&tr!("preferences.stages.help.add"))); - { - 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(preferences); - page - } - - pub fn widget(&self) -> gtk::Box { - self.container.clone() - } - - pub fn refresh(&self, preferences: &UserPreferences) { - // Clear existing rows - while let Some(child) = self.list_box.first_child() { - self.list_box.remove(&child); - } - self.populate_stages(preferences); - } - - fn populate_stages(&self, preferences: &UserPreferences) { - for stage in &preferences.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 first_entry = gtk::Entry::new(); - first_entry.set_text(&stage.i); - first_entry.set_width_chars(12); - { - let sender = self.sender.clone(); - first_entry.connect_changed(move |entry| { - sender.emit(PreferencesInput::SetCustomStageLabel( - id, - StageLabel::I, - entry.text().to_string(), - )); - }); - } - hbox.append(&first_entry); - - // Stage II entry - let second_entry = gtk::Entry::new(); - second_entry.set_text(&stage.ii); - second_entry.set_width_chars(12); - { - let sender = self.sender.clone(); - second_entry.connect_changed(move |entry| { - sender.emit(PreferencesInput::SetCustomStageLabel( - id, - StageLabel::Ii, - entry.text().to_string(), - )); - }); - } - hbox.append(&second_entry); - - // Stage III entry - let third_entry = gtk::Entry::new(); - third_entry.set_text(&stage.iii); - third_entry.set_width_chars(12); - { - let sender = self.sender.clone(); - third_entry.connect_changed(move |entry| { - sender.emit(PreferencesInput::SetCustomStageLabel( - id, - StageLabel::Iii, - entry.text().to_string(), - )); - }); - } - hbox.append(&third_entry); - - // Stage IV entry - let fourth_entry = gtk::Entry::new(); - fourth_entry.set_text(&stage.iv); - fourth_entry.set_width_chars(12); - { - let sender = self.sender.clone(); - fourth_entry.connect_changed(move |entry| { - sender.emit(PreferencesInput::SetCustomStageLabel( - id, - StageLabel::Iv, - entry.text().to_string(), - )); - }); - } - hbox.append(&fourth_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(&tr!("preferences.stages.help.remove"))); - { - 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 deleted file mode 100644 index 029b3e9..0000000 --- a/src/preferences/pages/templates.rs +++ /dev/null @@ -1,349 +0,0 @@ -// 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 std::cell::RefCell; -use std::rc::Rc; - -use relm4::Sender; -use relm4::gtk; -use sourceview5::prelude::*; -use uuid::Uuid; - -use crate::tr; - -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(&tr!("preferences.templates.title"))); - 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(&tr!("preferences.templates.help.add"))); - { - let sender = sender.clone(); - add_button.connect_clicked(move |_| { - sender.emit(PreferencesInput::AddTemplate(tr!( - "dialog.template.default_label" - ))); - }); - } - button_box.append(&add_button); - - let remove_button = gtk::Button::from_icon_name("list-remove-symbolic"); - remove_button.set_tooltip_text(Some(&tr!("preferences.templates.help.remove"))); - { - 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(&tr!("preferences.templates.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) { - // Check if we need to recreate rows (templates added/removed/reordered) - let current_row_count = { - let mut count = 0; - let mut child = self.list_box.first_child(); - while child.is_some() { - count += 1; - child = child.and_then(|c| c.next_sibling()); - } - count - }; - - let needs_recreate = current_row_count != prefs.map_templates.len(); - - if needs_recreate { - // Clear existing rows - while let Some(child) = self.list_box.first_child() { - self.list_box.remove(&child); - } - self.populate_templates(prefs); - } else { - // Just update existing rows in place - let mut child = self.list_box.first_child(); - let mut index = 0; - while let Some(row) = child { - if let Some(template) = prefs.map_templates.get(index) { - TemplatesPage::update_template_row(&row, template); - } - child = row.next_sibling(); - index += 1; - } - } - - // 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: usize = row.index().try_into().unwrap_or(0); - if let Some(template) = templates.get(index) { - *selected_id.borrow_mut() = Some(template.id); - editor_buffer.set_text(&template.content); - } - } - }); - } - } - - fn update_template_row(row: >k::Widget, template: &Template) { - if let Some(list_row) = row.downcast_ref::<gtk::ListBoxRow>() - && let Some(hbox) = list_row.child().and_then(|c| c.downcast::<gtk::Box>().ok()) - { - let mut child = hbox.first_child(); - let mut widget_index = 0; - - while let Some(widget) = child { - if widget_index == 0 { - // Update default button - if let Some(button) = widget.downcast_ref::<gtk::Button>() { - if template.is_default { - button.set_icon_name("emblem-default-symbolic"); - button - .set_tooltip_text(Some(&tr!("preferences.templates.help.default"))); - } else { - button.set_icon_name("radio-symbolic"); - button.set_tooltip_text(Some(&tr!( - "preferences.templates.help.set_as_default" - ))); - } - } - } else if widget_index == 1 { - // Update name entry - if let Some(entry) = widget.downcast_ref::<gtk::Entry>() { - // Only update if different to avoid cursor reset - if entry.text() != template.name { - entry.set_text(&template.name); - } - } - } - child = widget.next_sibling(); - widget_index += 1; - } - } - } - - 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(&tr!("preferences.templates.help.default"))); - } else { - default_button.set_icon_name("radio-symbolic"); - default_button - .set_tooltip_text(Some(&tr!("preferences.templates.help.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 (editable) - let name_entry = gtk::Entry::new(); - name_entry.set_text(&template.name); - name_entry.set_hexpand(true); - { - let sender = self.sender.clone(); - let id = template.id; - name_entry.connect_changed(move |entry| { - sender.emit(PreferencesInput::SetTemplateName( - id, - entry.text().to_string(), - )); - }); - } - hbox.append(&name_entry); - - row.set_child(Some(&hbox)); - row - } -} diff --git a/src/preferences/window.rs b/src/preferences/window.rs deleted file mode 100644 index 9d58b95..0000000 --- a/src/preferences/window.rs +++ /dev/null @@ -1,371 +0,0 @@ -// 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/>. - -// Suppress warnings from relm4 view! macro internals -#![allow(unused_assignments)] - -use std::path::PathBuf; - -use crate::icon_names::shipped::{AGENDA, BUILD, MAP, PAPYRUS, SETTINGS}; -use gtk::prelude::*; -use relm4::{ComponentParts, ComponentSender, SimpleComponent, adw, gtk}; - -use crate::tr; - -use super::models::UserPreferences; -use super::pages::{editor, general, map, stages, templates}; -use super::storage; - -/// Messages for preferences window -#[derive(Debug)] -pub enum PreferencesInput { - // General page actions - Export, - Import, - ImportFromFile(PathBuf), - ExportToFile(PathBuf), - ResetToDefaults, - - // Preference updates from pages - SetShowMapBackground(bool), - SetUseCustomFont(bool), - SetCustomFontName(String), - SetDefaultExportFormat(String), - SetUseSmartLabelPositioning(bool), - - SetUseCustomEditorFont(bool), - SetCustomEditorFontName(String), - SetEditorFontSize(f64), - SetSoftWrapLines(bool), - - // Templates - AddTemplate(String), - RemoveTemplate(uuid::Uuid), - SetTemplateContent(uuid::Uuid, String), - SetTemplateName(uuid::Uuid, String), - SetDefaultTemplate(uuid::Uuid), - - // Custom stages - AddCustomStage, - RemoveCustomStage(uuid::Uuid), - SetCustomStageName(uuid::Uuid, String), - SetCustomStageLabel(uuid::Uuid, StageLabel, String), - - // Close - Close, -} - -/// Which stage label to update (Roman numerals I-IV) -#[derive(Debug, Clone, Copy)] -pub enum StageLabel { - I, - Ii, - Iii, - Iv, -} - -/// Output messages to parent app -#[derive(Debug)] -pub enum PreferencesOutput { - PreferencesChanged(UserPreferences), - Closed, -} - -pub struct PreferencesWindow { - preferences: UserPreferences, - window: adw::Window, - // Widget references for pages - general_page: general::GeneralPage, - editor_page: editor::EditorPage, - map_page: map::MapPage, - stages_page: stages::StagesPage, - templates_page: templates::TemplatesPage, -} - -#[relm4::component(pub)] -impl SimpleComponent for PreferencesWindow { - type Init = UserPreferences; - type Input = PreferencesInput; - type Output = PreferencesOutput; - - view! { - adw::Window { - set_title: Some(&tr!("preferences.title")), - set_default_width: 700, - set_default_height: 500, - set_modal: true, - set_resizable: true, - - connect_close_request[sender] => move |_| { - sender.input(PreferencesInput::Close); - gtk::glib::Propagation::Proceed - }, - - adw::ToolbarView { - add_top_bar = &adw::HeaderBar { - #[wrap(Some)] - set_title_widget = &adw::ViewSwitcher { - set_stack: Some(&stack), - }, - }, - - #[name = "stack"] - adw::ViewStack { - set_vexpand: true, - - add_titled_with_icon[Some("general"), &tr!("preferences.menu.general"), SETTINGS] = &model.general_page.widget() -> gtk::Box {}, - add_titled_with_icon[Some("editor"), &tr!("preferences.menu.editor"), BUILD] = &model.editor_page.widget() -> gtk::Box {}, - add_titled_with_icon[Some("map"), &tr!("preferences.menu.map"), MAP] = &model.map_page.widget() -> gtk::Box {}, - add_titled_with_icon[Some("stages"), &tr!("preferences.menu.stages"), AGENDA] = &model.stages_page.widget() -> gtk::Box {}, - add_titled_with_icon[Some("templates"), &tr!("preferences.menu.templates"), PAPYRUS] = &model.templates_page.widget() -> gtk::Box {}, - }, - }, - } - } - - fn init( - preferences: Self::Init, - root: Self::Root, - sender: ComponentSender<Self>, - ) -> ComponentParts<Self> { - let general_page = general::GeneralPage::new(&sender.input_sender().clone()); - let editor_page = editor::EditorPage::new(&preferences, &sender.input_sender().clone()); - let map_page = map::MapPage::new(&preferences, &sender.input_sender().clone()); - let stages_page = stages::StagesPage::new(&preferences, sender.input_sender().clone()); - let templates_page = - templates::TemplatesPage::new(&preferences, sender.input_sender().clone()); - - let model = PreferencesWindow { - preferences, - window: root.clone(), - general_page, - editor_page, - map_page, - stages_page, - templates_page, - }; - - let widgets = view_output!(); - - ComponentParts { model, widgets } - } - - fn update(&mut self, message: Self::Input, sender: ComponentSender<Self>) { - match message { - PreferencesInput::Export => { - general::show_export_dialog(sender.input_sender(), &self.window); - } - PreferencesInput::Import => { - general::show_import_dialog(sender.input_sender(), &self.window); - } - PreferencesInput::ImportFromFile(path) => { - if storage::import_from_file(&mut self.preferences, &path).is_ok() { - self.sync_ui_from_preferences(); - self.save_and_notify(&sender); - } - } - PreferencesInput::ExportToFile(path) => { - let _ = storage::export_to_file(&self.preferences, &path); - } - PreferencesInput::ResetToDefaults => { - self.preferences = UserPreferences::default(); - self.sync_ui_from_preferences(); - self.save_and_notify(&sender); - } - PreferencesInput::SetShowMapBackground(v) => { - self.preferences.show_map_background = v; - self.save_and_notify(&sender); - } - PreferencesInput::SetUseCustomFont(v) => { - self.preferences.use_custom_font = v; - self.save_and_notify(&sender); - } - PreferencesInput::SetCustomFontName(v) => { - self.preferences.custom_font_name = v; - self.save_and_notify(&sender); - } - PreferencesInput::SetDefaultExportFormat(v) => { - self.preferences.default_export_format = v; - self.save_and_notify(&sender); - } - PreferencesInput::SetUseSmartLabelPositioning(v) => { - self.preferences.use_smart_label_positioning = v; - self.save_and_notify(&sender); - } - PreferencesInput::SetUseCustomEditorFont(v) => { - self.preferences.use_custom_editor_font = v; - self.save_and_notify(&sender); - } - PreferencesInput::SetCustomEditorFontName(v) => { - self.preferences.custom_editor_font_name = v; - self.save_and_notify(&sender); - } - PreferencesInput::SetEditorFontSize(v) => { - self.preferences.editor_font_size = v; - self.save_and_notify(&sender); - } - PreferencesInput::SetSoftWrapLines(v) => { - self.preferences.soft_wrap_lines = v; - self.save_and_notify(&sender); - } - PreferencesInput::AddTemplate(name) => self.handle_add_template(name, &sender), - PreferencesInput::RemoveTemplate(id) => self.handle_remove_template(id, &sender), - PreferencesInput::SetTemplateContent(id, content) => { - self.handle_set_template_content(id, content, &sender); - } - PreferencesInput::SetTemplateName(id, name) => { - self.handle_set_template_name(id, name, &sender); - } - PreferencesInput::SetDefaultTemplate(id) => { - self.preferences.set_default_template(id); - self.templates_page.refresh(&self.preferences); - self.save_and_notify(&sender); - } - PreferencesInput::AddCustomStage => self.handle_add_custom_stage(&sender), - PreferencesInput::RemoveCustomStage(id) => { - self.handle_remove_custom_stage(id, &sender); - } - PreferencesInput::SetCustomStageName(id, name) => { - self.handle_set_custom_stage_name(id, name, &sender); - } - PreferencesInput::SetCustomStageLabel(id, label, value) => { - self.handle_set_custom_stage_label(id, label, value, &sender); - } - PreferencesInput::Close => { - sender.output(PreferencesOutput::Closed).ok(); - } - } - } -} - -impl PreferencesWindow { - fn save_and_notify(&self, sender: &ComponentSender<Self>) { - let _ = storage::save(&self.preferences); - sender - .output(PreferencesOutput::PreferencesChanged( - self.preferences.clone(), - )) - .ok(); - } - - fn sync_ui_from_preferences(&mut self) { - self.editor_page.sync_from(&self.preferences); - self.map_page.sync_from(&self.preferences); - self.stages_page.refresh(&self.preferences); - self.templates_page.refresh(&self.preferences); - } - - fn handle_add_template(&mut self, name: String, sender: &ComponentSender<Self>) { - let template = super::models::Template::new(name, String::new(), false); - self.preferences.map_templates.push(template); - self.templates_page.refresh(&self.preferences); - self.save_and_notify(sender); - } - - fn handle_remove_template(&mut self, id: uuid::Uuid, sender: &ComponentSender<Self>) { - self.preferences.map_templates.retain(|t| t.id != id); - self.templates_page.refresh(&self.preferences); - self.save_and_notify(sender); - } - - fn handle_set_template_content( - &mut self, - id: uuid::Uuid, - content: String, - sender: &ComponentSender<Self>, - ) { - if let Some(template) = self - .preferences - .map_templates - .iter_mut() - .find(|t| t.id == id) - { - template.content = content; - self.save_and_notify(sender); - } - } - - fn handle_set_template_name( - &mut self, - id: uuid::Uuid, - name: String, - sender: &ComponentSender<Self>, - ) { - if let Some(template) = self - .preferences - .map_templates - .iter_mut() - .find(|t| t.id == id) - { - template.name = name; - self.templates_page.refresh(&self.preferences); - self.save_and_notify(sender); - } - } - - fn handle_add_custom_stage(&mut self, sender: &ComponentSender<Self>) { - let stage = super::models::CustomStage::placeholder_default(); - self.preferences.custom_stages.push(stage); - self.stages_page.refresh(&self.preferences); - self.save_and_notify(sender); - } - - fn handle_remove_custom_stage(&mut self, id: uuid::Uuid, sender: &ComponentSender<Self>) { - self.preferences.custom_stages.retain(|s| s.id != id); - self.stages_page.refresh(&self.preferences); - self.save_and_notify(sender); - } - - fn handle_set_custom_stage_name( - &mut self, - id: uuid::Uuid, - name: String, - sender: &ComponentSender<Self>, - ) { - if let Some(stage) = self - .preferences - .custom_stages - .iter_mut() - .find(|s| s.id == id) - { - stage.name = name; - self.save_and_notify(sender); - } - } - - fn handle_set_custom_stage_label( - &mut self, - id: uuid::Uuid, - label: StageLabel, - value: String, - sender: &ComponentSender<Self>, - ) { - if let Some(stage) = self - .preferences - .custom_stages - .iter_mut() - .find(|s| s.id == id) - { - match label { - StageLabel::I => stage.stage.i = value, - StageLabel::Ii => stage.stage.ii = value, - StageLabel::Iii => stage.stage.iii = value, - StageLabel::Iv => stage.stage.iv = value, - } - self.save_and_notify(sender); - } - } -} diff --git a/src/ui_helpers.rs b/src/ui_helpers.rs deleted file mode 100644 index 6737c62..0000000 --- a/src/ui_helpers.rs +++ /dev/null @@ -1,34 +0,0 @@ -// 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::gtk; - -/// Creates a horizontal row with a label on the left and a switch on the right. -pub fn create_switch_row(label: &str, initial_state: bool) -> (gtk::Box, gtk::Switch) { - let row = gtk::Box::new(gtk::Orientation::Horizontal, 10); - row.append(>k::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) -} |