// Map, wardley map editor for linux
// Copyright (C) 2026 Rubén Beltrán del Río
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
use crate::tr;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
/// Stage labels for custom stages
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
pub struct Stage {
pub i: String,
pub ii: String,
pub iii: String,
pub iv: String,
}
/// Custom stage definition
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct CustomStage {
pub id: Uuid,
pub name: String,
pub stage: Stage,
}
impl Default for CustomStage {
fn default() -> Self {
Self {
id: Uuid::new_v4(),
name: "Custom Stage".to_string(),
stage: Stage::default(),
}
}
}
impl CustomStage {
pub fn cynefin_default() -> Self {
Self {
id: Uuid::new_v4(),
name: tr!("Cynefin"),
stage: Stage {
i: tr!("Chaotic"),
ii: tr!("Complex"),
iii: tr!("Complicated"),
iv: tr!("Clear"),
},
}
}
pub fn placeholder_default() -> Self {
Self {
id: Uuid::new_v4(),
name: tr!("New Stage"),
stage: Stage {
i: tr!("Stage I"),
ii: tr!("Stage II"),
iii: tr!("Stage III"),
iv: tr!("Stage IV"),
},
}
}
}
/// Template for new maps
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct Template {
pub id: Uuid,
pub name: String,
pub content: String,
#[serde(default)]
pub is_default: bool,
}
impl Default for Template {
fn default() -> Self {
Self {
id: Uuid::new_v4(),
name: "Untitled".to_string(),
content: String::new(),
is_default: false,
}
}
}
impl Template {
pub fn new(name: String, content: String, is_default: bool) -> Self {
Self {
id: Uuid::new_v4(),
name,
content,
is_default,
}
}
}
/// JSON import/export format
/// All fields optional to support partial imports
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct UserPreferencesJson {
// Map preferences
pub show_map_background: Option,
pub use_custom_font: Option,
pub custom_font_name: Option,
pub default_export_format: Option,
pub use_smart_label_positioning: Option,
// Editor preferences
pub use_custom_editor_font: Option,
pub custom_editor_font_name: Option,
pub editor_font_size: Option,
pub soft_wrap_lines: Option,
// Templates and stages
pub map_templates: Option>,
pub custom_stages: Option>,
// UI preferences
pub view_style: Option,
pub zoom: Option,
}
/// Main preferences struct stored in app
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
#[allow(clippy::struct_excessive_bools)] // These are independent boolean preferences, not a state machine
pub struct UserPreferences {
// Map preferences
pub show_map_background: bool,
pub use_custom_font: bool,
pub custom_font_name: String,
pub default_export_format: String,
pub use_smart_label_positioning: bool,
// Editor preferences
pub use_custom_editor_font: bool,
pub custom_editor_font_name: String,
pub editor_font_size: f64,
pub soft_wrap_lines: bool,
// Templates and stages
pub map_templates: Vec,
pub custom_stages: Vec,
// UI preferences
pub view_style: String,
pub zoom: f64,
}
impl Default for UserPreferences {
fn default() -> Self {
Self {
show_map_background: true,
use_custom_font: false,
custom_font_name: "Sans".to_string(),
default_export_format: "png".to_string(),
use_smart_label_positioning: true,
use_custom_editor_font: false,
custom_editor_font_name: "Monospace".to_string(),
editor_font_size: 14.0,
soft_wrap_lines: false,
map_templates: vec![
Template::new(
tr!("Example Map"),
tr!(r"Client (39, 0) [x]
Consumer (55, 10) [x]
Client -- Consumer
Find out about (65, 15)
Test printer (42, 20)
Consumer -- Test printer
Consumer -- Find out about
Microsite (60, 30)
Find out about -- Microsite
[Group] Consumer, Test printer, Find out about, Microsite
[Note] (63, 10) Team, Settlers
Testing Application (34, 35)
Microsite -- Testing Application
Test Printer -- Testing Application
FinDev (30, 50)
Testing Application -- FinDev
Microsite -- Findev
[Group] Testing Application, FinDev
[Note] (24, 41.5) Team, Pioneers
[Evolution] Testing Application +15
[Evolution] FinDev +30
Platform [now] (50, 60) [triangle]
Platform [then] (71, 60)
Platform [now] -> Platform [then]
[Inertia] Platform [now]
FinDev -- Platform [then]
[Group] Platform
[Note] (74, 60) Team, Town Planners
Distribution (65, 70)
Brochure (60, 75)
Distribution -- Brochure
[Evolution] Distribution +20
Testing Application -- Distribution
Distribution -- Brochure
[Group] Distribution, Brochure
[Note] (65, 75) Team, Town Planners
Large Format Printer (45, 75)
Testing Application -- Large Format Printer
[Group] Large Format Printer
[Note] (42, 78.5) Team, Settlers
[i] 20
[ii] 40
[iii] 70"),
true,
),
Template::new(tr!("Empty"), String::new(), false),
],
custom_stages: vec![CustomStage::cynefin_default()],
view_style: "horizontal".to_string(),
zoom: 1.0,
}
}
}
impl UserPreferences {
/// Convert to JSON export format
pub fn to_json(&self) -> UserPreferencesJson {
UserPreferencesJson {
show_map_background: Some(self.show_map_background),
use_custom_font: Some(self.use_custom_font),
custom_font_name: Some(self.custom_font_name.clone()),
default_export_format: Some(self.default_export_format.clone()),
use_smart_label_positioning: Some(self.use_smart_label_positioning),
use_custom_editor_font: Some(self.use_custom_editor_font),
custom_editor_font_name: Some(self.custom_editor_font_name.clone()),
editor_font_size: Some(self.editor_font_size),
soft_wrap_lines: Some(self.soft_wrap_lines),
map_templates: Some(self.map_templates.clone()),
custom_stages: Some(self.custom_stages.clone()),
view_style: Some(self.view_style.clone()),
zoom: Some(self.zoom),
}
}
/// Update from JSON import (partial updates supported)
pub fn update_from_json(&mut self, json: &UserPreferencesJson) {
if let Some(v) = json.show_map_background {
self.show_map_background = v;
}
if let Some(ref v) = json.use_custom_font {
self.use_custom_font = *v;
}
if let Some(ref v) = json.custom_font_name {
self.custom_font_name.clone_from(v);
}
if let Some(ref v) = json.default_export_format {
self.default_export_format.clone_from(v);
}
if let Some(v) = json.use_smart_label_positioning {
self.use_smart_label_positioning = v;
}
if let Some(ref v) = json.use_custom_editor_font {
self.use_custom_editor_font = *v;
}
if let Some(ref v) = json.custom_editor_font_name {
self.custom_editor_font_name.clone_from(v);
}
if let Some(v) = json.editor_font_size {
self.editor_font_size = v;
}
if let Some(v) = json.soft_wrap_lines {
self.soft_wrap_lines = v;
}
if let Some(ref v) = json.map_templates {
self.map_templates.clone_from(v);
}
if let Some(ref v) = json.custom_stages {
self.custom_stages.clone_from(v);
}
if let Some(ref v) = json.view_style {
self.view_style.clone_from(v);
}
if let Some(v) = json.zoom {
self.zoom = v;
}
}
/// Get the default template
#[allow(dead_code)]
pub fn default_template(&self) -> Option<&Template> {
self.map_templates.iter().find(|t| t.is_default)
}
/// Set a template as default (unsets others)
pub fn set_default_template(&mut self, id: Uuid) {
for template in &mut self.map_templates {
template.is_default = template.id == id;
}
}
}
/// Which stage label to update (Roman numerals I-IV)
#[derive(Debug, Clone, Copy)]
pub enum StageLabel {
I,
Ii,
Iii,
Iv,
}