// 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 .
// Suppress warnings from relm4 view! macro internals
#![allow(unused_assignments)]
mod actions;
mod constants;
mod dialogs;
mod file_registry;
mod handlers;
mod i18n;
mod preferences;
mod stages;
mod ui_helpers;
mod icon_names {
#![allow(clippy::doc_markdown)] // Generated code from relm4_icons_build
include!(concat!(env!("OUT_DIR"), "/icon_names.rs"));
}
use std::cell::RefCell;
use std::path::PathBuf;
use crate::actions::Action;
use crate::icon_names::shipped::{
IMAGE_REGULAR, MENU_LARGE, SPLIT_HORIZONTAL_REGULAR, SPLIT_VERTICAL_REGULAR, ZOOM_IN_REGULAR,
ZOOM_OUT_REGULAR,
};
use cairo::ImageSurface;
use gtk::gio;
use gtk::glib;
use relm4::{
Component, ComponentController, ComponentParts, ComponentSender, RelmApp, RelmWidgetExt,
SimpleComponent,
actions::{AccelsPlus, ActionablePlus, RelmAction, RelmActionGroup},
gtk,
};
use sourceview5::LanguageManager;
use sourceview5::prelude::*;
use stages::{ALL_STAGE_TYPES, LocalizedStageType};
use wmap_parser::{Map, parse};
use wmap_renderer::{Configuration, StageType, render_to_surface};
// Action group and action type declarations
relm4::new_action_group!(WindowActionGroup, "window");
relm4::new_stateless_action!(NewAction, WindowActionGroup, "new");
relm4::new_stateless_action!(OpenAction, WindowActionGroup, "open");
relm4::new_stateless_action!(SaveAction, WindowActionGroup, "save");
relm4::new_stateless_action!(SaveAsAction, WindowActionGroup, "save-as");
relm4::new_stateless_action!(CloseAction, WindowActionGroup, "close");
relm4::new_stateless_action!(ExportImageAction, WindowActionGroup, "export-image");
relm4::new_stateless_action!(
ChangeOrientationAction,
WindowActionGroup,
"change-orientation"
);
relm4::new_stateless_action!(ZoomInAction, WindowActionGroup, "zoom-in");
relm4::new_stateless_action!(ZoomOutAction, WindowActionGroup, "zoom-out");
relm4::new_stateless_action!(PreferencesAction, WindowActionGroup, "preferences");
struct NewFromTemplateAction;
impl relm4::actions::ActionName for NewFromTemplateAction {
type Group = WindowActionGroup;
type Target = String;
type State = ();
const NAME: &'static str = "new-from-template";
}
/// Macro to reduce boilerplate when registering actions with accelerators.
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 |_| {
sender.input($message);
});
$group.add_action(action);
$app.set_accelerators_for_action::<$action_type>($accelerators);
}};
}
fn setup_actions(window: >k::Window, sender: &ComponentSender) {
let application = relm4::main_application();
let mut action_group = RelmActionGroup::::new();
register_action!(
action_group,
application,
sender,
ZoomInAction,
Action::ZoomIn,
&["equal", "plus"]
);
register_action!(
action_group,
application,
sender,
ZoomOutAction,
Action::ZoomOut,
&["minus"]
);
register_action!(
action_group,
application,
sender,
ChangeOrientationAction,
Action::ChangeOrientation,
&["l"]
);
register_action!(
action_group,
application,
sender,
ExportImageAction,
Action::ExportImage,
&["e"]
);
register_action!(
action_group,
application,
sender,
NewAction,
Action::New,
&["n"]
);
register_action!(
action_group,
application,
sender,
SaveAction,
Action::Save { close_after: false },
&["s"]
);
register_action!(
action_group,
application,
sender,
SaveAsAction,
Action::SaveAs { close_after: false },
&["s"]
);
register_action!(
action_group,
application,
sender,
OpenAction,
Action::Open,
&["o"]
);
register_action!(
action_group,
application,
sender,
CloseAction,
Action::CloseWindow,
&["w"]
);
register_action!(
action_group,
application,
sender,
PreferencesAction,
Action::ShowPreferences,
&["comma"]
);
let sender = sender.clone();
let action: RelmAction =
RelmAction::new_with_target_value(move |_, template_id| {
sender.input(Action::NewFromTemplateById(template_id));
});
action_group.add_action(action);
action_group.register_for_widget(window);
}
struct AppInit {
zoom: f64,
file_path: Option,
initial_content: Option,
}
struct AppModel {
orientation: gtk::Orientation,
stage_type_list: gtk::StringList,
available_stage_types: Vec,
stage_type: StageType,
source: sourceview5::Buffer,
source_view: sourceview5::View,
map: Map,
zoom: f64,
render_configuration: Configuration,
surface: ImageSurface,
surface_width: i32,
surface_height: i32,
drawing_area: gtk::DrawingArea,
current_file: Option,
modified: bool,
pending_load_events: RefCell,
initialized: bool,
window: gtk::Window,
preferences: preferences::UserPreferences,
preferences_window: Option>,
editor_css_provider: gtk::CssProvider,
templates_menu: gio::Menu,
main_menu: gio::Menu,
}
impl AppModel {
#[allow(clippy::cast_possible_truncation)]
fn update_image(&mut self) {
if let Ok(surface) = render_to_surface(
&self.map,
&self.stage_type.to_localized(),
&self.render_configuration,
) {
self.surface_width = (f64::from(surface.width()) * self.zoom).round() as i32;
self.surface_height = (f64::from(surface.height()) * self.zoom).round() as i32;
self.surface = surface;
let zoom = self.zoom;
let surface_clone = self.surface.clone();
self.drawing_area
.set_draw_func(move |_, cr, _width, _height| {
cr.scale(zoom, zoom);
cr.set_source_surface(&surface_clone, 0.0, 0.0).ok();
cr.paint().ok();
});
self.drawing_area.set_content_width(self.surface_width);
self.drawing_area.set_content_height(self.surface_height);
self.drawing_area.queue_draw();
}
}
fn window_title(&self) -> String {
let default_name = tr!("document.untitled");
let filename = self
.current_file
.as_ref()
.and_then(|path| path.file_name())
.and_then(|name| name.to_str())
.unwrap_or(&default_name);
if self.modified {
format!("{}* - {}", filename, constants::APP_NAME)
} else {
format!("{} - {}", filename, constants::APP_NAME)
}
}
fn is_empty_document(&self) -> bool {
self.current_file.is_none() && !self.modified && self.source_text().is_empty()
}
fn source_text(&self) -> glib::GString {
self.source
.text(&self.source.start_iter(), &self.source.end_iter(), false)
}
fn set_source_text(&self, content: &str) {
*self.pending_load_events.borrow_mut() += 1;
self.source.set_text(content);
}
#[allow(clippy::cast_possible_truncation)]
fn apply_preferences(&mut self) {
self.source_view
.set_wrap_mode(if self.preferences.soft_wrap_lines {
gtk::WrapMode::Word
} else {
gtk::WrapMode::None
});
let css = if self.preferences.use_custom_editor_font {
self.source_view.set_monospace(false);
format!(
".map-editor {{ font-family: \"{}\"; font-size: {}pt; }}",
self.preferences.custom_editor_font_name, self.preferences.editor_font_size as i32
)
} else {
self.source_view.set_monospace(true);
format!(
".map-editor {{ font-size: {}pt; }}",
self.preferences.editor_font_size as i32
)
};
self.editor_css_provider.load_from_string(&css);
self.render_configuration.options.smart_label_positioning =
self.preferences.use_smart_label_positioning;
self.render_configuration.options.show_background = self.preferences.show_map_background;
if self.preferences.use_custom_font {
self.render_configuration.theme.fonts.face = self.preferences.custom_font_name.clone();
} else {
self.render_configuration.theme.fonts.face = String::from("sans-serif");
}
}
fn rebuild_stage_type_list(&mut self) {
let mut stage_types: Vec = ALL_STAGE_TYPES.to_vec();
for custom in &self.preferences.custom_stages {
stage_types.push(StageType::Custom {
name: custom.name.clone(),
i: custom.stage.i.clone(),
ii: custom.stage.ii.clone(),
iii: custom.stage.iii.clone(),
iv: custom.stage.iv.clone(),
});
}
while self.stage_type_list.n_items() > 0 {
self.stage_type_list.remove(0);
}
for stage_type in &stage_types {
self.stage_type_list.append(&stage_type.localized_name());
}
self.available_stage_types = stage_types;
}
fn rebuild_templates_menu(&self) {
self.templates_menu.remove_all();
for template in &self.preferences.map_templates {
self.templates_menu.append(
Some(&template.name),
Some(&format!("window.new-from-template::{}", template.id)),
);
}
}
pub fn open_new_window(file_path: Option, initial_content: Option) {
let controller = AppModel::builder()
.launch(AppInit {
zoom: 1.0,
file_path,
initial_content,
})
.detach();
let application = relm4::main_application();
let window = controller.widget();
application.add_window(window);
window.present();
file_registry::store_controller(controller);
}
fn init_stage_type_list() -> gtk::StringList {
let stage_types: Vec = ALL_STAGE_TYPES
.iter()
.map(stages::LocalizedStageType::localized_name)
.collect();
let stage_type_refs: Vec<&str> = stage_types
.iter()
.map(std::string::String::as_str)
.collect();
gtk::StringList::new(&stage_type_refs)
}
fn init_source_buffer(
init: &AppInit,
sender: &ComponentSender,
) -> (sourceview5::Buffer, String, Option) {
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));
}
let (initial_content, current_file) = if let Some(ref path) = init.file_path {
match std::fs::read_to_string(path) {
Ok(content) => (content, Some(path.clone())),
Err(_) => (String::new(), None),
}
} else {
(init.initial_content.clone().unwrap_or_default(), None)
};
source.set_text(&initial_content);
{
let sender = sender.clone();
source.connect_changed(move |_| {
sender.input(Action::SourceChanged);
});
}
(source, initial_content, current_file)
}
fn init_drawing_area(
initial_content: &str,
) -> (
gtk::DrawingArea,
Map,
Configuration,
StageType,
ImageSurface,
) {
let drawing_area = gtk::DrawingArea::new();
let map = parse(initial_content);
let mut render_configuration = Configuration::default();
let stage_type = StageType::Activities;
render_configuration.options.smart_label_positioning = true;
let surface = render_to_surface(&map, &stage_type.to_localized(), &render_configuration)
.or_else(|_| ImageSurface::create(cairo::Format::ARgb32, 1, 1))
.unwrap_or_else(|e| {
eprintln!("Fatal: Cairo failed to create surface: {e}");
std::process::exit(1);
});
(drawing_area, map, render_configuration, stage_type, surface)
}
fn init_main_menu(prefs: &preferences::UserPreferences) -> (gio::Menu, gio::Menu) {
let templates_menu = gio::Menu::new();
for template in &prefs.map_templates {
templates_menu.append(
Some(&template.name),
Some(&format!("window.new-from-template::{}", template.id)),
);
}
let main_menu = gio::Menu::new();
main_menu.append(Some(&tr!("command.file.new")), Some("window.new"));
main_menu.append_submenu(
Some(&tr!("command.file.new_from_template")),
&templates_menu,
);
main_menu.append(Some(&tr!("command.file.open")), Some("window.open"));
main_menu.append(Some(&tr!("command.file.save")), Some("window.save"));
main_menu.append(Some(&tr!("command.file.save_as")), Some("window.save-as"));
main_menu.append(Some(&tr!("command.file.close")), Some("window.close"));
main_menu.append(
Some(&tr!("command.file.export")),
Some("window.export-image"),
);
let layout_section = gio::Menu::new();
layout_section.append(
Some(&tr!("command.view.use_vertical_layout")),
Some("window.change-orientation"),
);
main_menu.append_section(None, &layout_section);
let zoom_section = gio::Menu::new();
zoom_section.append(Some(&tr!("command.view.zoom_in")), Some("window.zoom-in"));
zoom_section.append(Some(&tr!("command.view.zoom_out")), Some("window.zoom-out"));
main_menu.append_section(None, &zoom_section);
let prefs_section = gio::Menu::new();
prefs_section.append(
Some(&tr!("command.application.preferences")),
Some("window.preferences"),
);
main_menu.append_section(None, &prefs_section);
(templates_menu, main_menu)
}
}
#[relm4::component]
impl SimpleComponent for AppModel {
type Input = Action;
type Output = ();
type Init = AppInit;
view! {
#[root]
gtk::Window {
#[watch]
set_title: Some(&model.window_title()),
set_default_width: constants::DEFAULT_WINDOW_WIDTH,
set_default_height: constants::DEFAULT_WINDOW_HEIGHT,
gtk::Box {
set_orientation: gtk::Orientation::Vertical,
set_spacing: 0,
gtk::HeaderBar {
pack_start = >k::Box {
set_orientation: gtk::Orientation::Horizontal,
gtk::Button::with_label(&match model.orientation {
gtk::Orientation::Horizontal => tr!("command.view.use_vertical_layout"),
_ => tr!("command.view.use_horizontal_layout")
}) {
#[watch]
set_icon_name: match model.orientation {
gtk::Orientation::Horizontal => SPLIT_VERTICAL_REGULAR,
_ => SPLIT_HORIZONTAL_REGULAR
},
ActionablePlus::set_stateless_action::: &(),
}
},
pack_end = >k::Box {
set_orientation: gtk::Orientation::Horizontal,
gtk::Button::with_label(&tr!("command.file.export")) {
set_icon_name: IMAGE_REGULAR,
ActionablePlus::set_stateless_action::: &(),
},
gtk::DropDown {
set_model: Some(&model.stage_type_list),
set_show_arrow: true,
set_selected: 0,
connect_selected_notify[sender] => move |dropdown| {
let selected = dropdown.selected();
sender.input(Action::StageTypeSelected(selected as usize));
},
},
gtk::MenuButton {
set_icon_name: MENU_LARGE,
#[wrap(Some)]
set_popover = >k::PopoverMenu::from_model(Some(&model.main_menu)) {}
}
}
},
gtk::Paned {
#[watch]
set_orientation: model.orientation,
set_expand: true,
#[wrap(Some)]
set_start_child = >k::ScrolledWindow {
#[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
}
},
#[wrap(Some)]
set_end_child = >k::ScrolledWindow {
#[local_ref]
drawing_area -> gtk::DrawingArea {
#[watch]
set_content_width: model.surface_width,
#[watch]
set_content_height: model.surface_height
}
}
},
gtk::ActionBar {
pack_end = >k::Box {
set_orientation: gtk::Orientation::Horizontal,
gtk::Label {
#[watch]
set_label: &format!("{:.1}x", model.zoom),
set_margin_end: 5
},
gtk::Button::with_label(&tr!("command.view.zoom_out")) {
set_icon_name: ZOOM_OUT_REGULAR,
set_margin_end: 5,
add_css_class: "flat",
add_css_class: "zoom",
add_css_class: "zoom-out",
ActionablePlus::set_stateless_action::: &(),
},
gtk::Scale {
set_orientation: gtk::Orientation::Horizontal,
#[watch]
set_value: model.zoom,
set_draw_value: false,
set_range: (constants::MIN_ZOOM, constants::MAX_ZOOM),
set_increments: (constants::ZOOM_STEP, constants::ZOOM_STEP),
set_value_pos: gtk::PositionType::Left,
set_width_request: 100,
connect_value_changed[sender] => move |scale| {
sender.input(Action::Zoom(scale.value()));
}
},
gtk::Button::with_label(&tr!("command.view.zoom_in")) {
set_icon_name: ZOOM_IN_REGULAR,
add_css_class: "flat",
add_css_class: "zoom",
add_css_class: "zoom-in",
ActionablePlus::set_stateless_action::: &(),
},
}
}
}
}
}
fn init(
init: Self::Init,
root: Self::Root,
sender: ComponentSender,
) -> ComponentParts {
let stage_type_list = Self::init_stage_type_list();
let (source, initial_content, current_file) = Self::init_source_buffer(&init, &sender);
setup_actions(&root, &sender);
let source_view = sourceview5::View::with_buffer(&source);
source_view.set_monospace(true);
let editor_css_provider = gtk::CssProvider::new();
source_view.add_css_class("map-editor");
if let Some(display) = gtk::gdk::Display::default() {
gtk::style_context_add_provider_for_display(
&display,
&editor_css_provider,
gtk::STYLE_PROVIDER_PRIORITY_APPLICATION,
);
}
let (drawing_area, map, render_configuration, stage_type, surface) =
Self::init_drawing_area(&initial_content);
if let Some(ref path) = current_file {
file_registry::register_file(path, &root);
}
let prefs = preferences::storage::load();
let (templates_menu, main_menu) = Self::init_main_menu(&prefs);
let mut model = AppModel {
orientation: gtk::Orientation::Horizontal,
stage_type_list,
available_stage_types: ALL_STAGE_TYPES.to_vec(),
stage_type,
source,
source_view: source_view.clone(),
map,
zoom: init.zoom,
render_configuration,
surface,
surface_width: 0,
surface_height: 0,
drawing_area: drawing_area.clone(),
current_file,
modified: false,
pending_load_events: RefCell::new(0),
initialized: false,
window: root.clone(),
preferences: prefs,
preferences_window: None,
editor_css_provider,
templates_menu,
main_menu,
};
model.apply_preferences();
model.rebuild_stage_type_list();
model.update_image();
file_registry::register_sender(sender.input_sender().clone());
{
let sender = sender.clone();
glib::idle_add_local_once(move || {
sender.input(Action::MarkInitialized);
});
}
{
let sender = sender.clone();
root.connect_close_request(move |_| {
sender.input(Action::CloseWindow);
glib::Propagation::Stop
});
}
let widgets = view_output!();
ComponentParts { model, widgets }
}
fn update(&mut self, message: Self::Input, sender: ComponentSender) {
match message {
Action::ChangeOrientation => handlers::view::change_orientation(self),
Action::StageTypeSelected(index) => handlers::view::stage_type_selected(self, index),
Action::SourceChanged => {
let pending = *self.pending_load_events.borrow();
if pending > 0 {
*self.pending_load_events.borrow_mut() -= 1;
} else if self.initialized {
self.modified = true;
}
self.map = parse(&self.source_text());
self.update_image();
}
Action::Zoom(level) => handlers::zoom::zoom(self, level),
Action::ZoomIn => handlers::zoom::zoom_in(self),
Action::ZoomOut => handlers::zoom::zoom_out(self),
Action::ExportImage => {
dialogs::show_export_dialog(&self.window, self.current_file.as_ref(), sender);
}
Action::ExportToFile { path, format } => {
handlers::export::export_to_file(self, &path, format);
}
Action::New => {
let content = self
.preferences
.map_templates
.iter()
.find(|t| t.is_default)
.map(|t| t.content.clone());
Self::open_new_window(None, content);
}
Action::NewFromTemplateById(template_id) => {
if let Ok(id) = uuid::Uuid::parse_str(&template_id)
&& let Some(template) =
self.preferences.map_templates.iter().find(|t| t.id == id)
{
Self::open_new_window(None, Some(template.content.clone()));
}
}
Action::Open => {
dialogs::show_open_dialog(&self.window, self.is_empty_document(), sender);
}
Action::Save { close_after } => handlers::file::save(self, close_after, &sender),
Action::SaveAs { close_after } => dialogs::show_save_dialog(
&self.window,
self.current_file.as_ref(),
close_after,
sender,
),
Action::LoadFile(path) => handlers::file::load_file(self, path),
Action::SaveToFile { path, close_after } => {
handlers::file::save_to_file(self, path, close_after);
}
Action::CloseWindow => {
handlers::file::close_window(self, &sender);
}
Action::MarkInitialized => self.initialized = true,
Action::ShowPreferences => {
if self.preferences_window.is_none() {
let controller = preferences::PreferencesWindow::builder()
.transient_for(&self.window)
.launch(self.preferences.clone())
.forward(sender.input_sender(), |output| match output {
preferences::window::PreferencesOutput::PreferencesChanged(prefs) => {
Action::PreferencesChanged(prefs)
}
preferences::window::PreferencesOutput::Closed => {
Action::PreferencesWindowClosed
}
});
self.preferences_window = Some(controller);
}
if let Some(ref controller) = self.preferences_window {
controller.widget().present();
}
}
Action::PreferencesChanged(prefs) => {
self.preferences = prefs;
self.apply_preferences();
self.rebuild_stage_type_list();
self.rebuild_templates_menu();
self.update_image();
file_registry::broadcast_to_all_windows(&Action::ReloadPreferences);
}
Action::PreferencesWindowClosed => {
self.preferences_window = None;
}
Action::ReloadPreferences => {
self.preferences = preferences::storage::load();
self.apply_preferences();
self.rebuild_stage_type_list();
self.rebuild_templates_menu();
self.update_image();
}
}
}
}
fn main() {
i18n::init();
relm4_icons::initialize_icons(icon_names::GRESOURCE_BYTES, icon_names::RESOURCE_PREFIX);
let prefs = preferences::storage::load();
let initial_content = prefs
.map_templates
.iter()
.find(|t| t.is_default)
.map(|t| t.content.clone());
let application = RelmApp::new(constants::APP_ID);
application.run::(AppInit {
zoom: 1.0,
file_path: None,
initial_content,
});
}