// 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 gtk::prelude::*; use relm4::{ Component, ComponentController, ComponentParts, ComponentSender, RelmApp, RelmWidgetExt, SimpleComponent, actions::{AccelsPlus, ActionablePlus, RelmAction, RelmActionGroup}, gtk, }; use sourceview5::LanguageManager; use sourceview5::prelude::{BufferExt, TextBufferExt, TextViewExt}; 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!( "textview {{ 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!( "textview {{ font-size: {}pt; }}", self.preferences.editor_font_size as i32 ) }; self.editor_css_provider.load_from_data(&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.layout.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) } }, #[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.style_context().add_provider( &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, }); }