mod constants; mod stages; mod icon_names { include!(concat!(env!("OUT_DIR"), "/icon_names.rs")); } use std::cell::RefCell; use std::path::PathBuf; 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::prelude::*; use gtk::{gio, glib}; use relm4::component::Controller; 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_stages; use wmap_parser::{Map, parse}; use wmap_renderer::{Configuration, StageType, render_to_surface}; // Thread-local storage for window controllers and open file paths // GTK is single-threaded so this is safe thread_local! { static WINDOW_CONTROLLERS: RefCell>> = const { RefCell::new(Vec::new()) }; static OPEN_FILES: RefCell> = const { RefCell::new(Vec::new()) }; } struct AppInit { zoom: f64, file_path: Option, } struct AppModel { orientation: gtk::Orientation, stage_type_list: gtk::StringList, stage_type: StageType, source: sourceview5::Buffer, 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, // Counter for pending load events to ignore initialized: bool, // Flag to prevent modified during widget initialization window: gtk::Window, } #[derive(Debug)] enum AppAction { ChangeOrientation, StageTypeSelected(usize), SourceChanged, Zoom(f64), ZoomOut, ZoomIn, ExportImage, New, Open, Save{close: bool}, SaveAs{close: bool}, LoadFile(PathBuf), SaveToFile{path: PathBuf, close: bool}, MarkInitialized, CloseWindow, } impl AppModel { fn update_image(&mut self) { if let Ok(surface) = render_to_surface(&self.map, self.stage_type, &self.render_configuration) { self.surface_width = (surface.width() as f64 * self.zoom).round() as i32; self.surface_height = (surface.height() as f64 * 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 filename = self .current_file .as_ref() .and_then(|path| path.file_name()) .and_then(|name| name.to_str()) .unwrap_or("Untitled"); 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(&self.source.start_iter(), &self.source.end_iter(), false) .is_empty() } fn create_file_filter() -> gtk::FileFilter { let filter = gtk::FileFilter::new(); filter.add_pattern(&format!("*.{}", constants::FILE_EXTENSION)); filter.add_mime_type(constants::MIME_TYPE); filter.set_name(Some("Wardley Map Files")); filter } fn show_error_dialog(&self, title: &str, message: &str) { let dialog = gtk::MessageDialog::new( Some(&self.window), gtk::DialogFlags::MODAL | gtk::DialogFlags::DESTROY_WITH_PARENT, gtk::MessageType::Error, gtk::ButtonsType::Ok, message, ); dialog.set_title(Some(title)); dialog.connect_response(|dialog, _| { dialog.close(); }); dialog.show(); } fn open_new_window(file_path: Option) { let controller = AppModel::builder() .launch(AppInit { zoom: 1.0, file_path, }) .detach(); let app = relm4::main_application(); let window = controller.widget(); app.add_window(window); window.present(); // Store the controller to keep the component alive WINDOW_CONTROLLERS.with_borrow_mut(|controllers| { controllers.push(controller); }); } fn set_source_text(&self, content: &str) { // Increment counter - SourceChanged will decrement it and skip marking modified *self.pending_load_events.borrow_mut() += 1; self.source.set_text(content); // Don't decrement here - let SourceChanged handle it when the event arrives } fn get_window_for_file(path: &PathBuf) -> Option { OPEN_FILES.with_borrow(|files| { files .iter() .find(|(p, _)| p == path) .map(|(_, window)| window.clone()) }) } fn register_open_file(path: &PathBuf, window: >k::Window) { OPEN_FILES.with_borrow_mut(|files| { if !files.iter().any(|(p, _)| p == path) { files.push((path.clone(), window.clone())); } }); } fn unregister_open_file(path: &PathBuf) { OPEN_FILES.with_borrow_mut(|files| { files.retain(|(p, _)| p != path); }); } /* * Sets up actions and keybinds / accelerators. Buttons and menu items * should all use actions. */ fn setup_actions(root: gtk::Window, sender: ComponentSender) { let app = relm4::main_application(); let mut action_group = RelmActionGroup::::new(); let cloned_sender = sender.clone(); let zoom_in: RelmAction = RelmAction::new_stateless(move |_| { cloned_sender.input(AppAction::ZoomIn); }); action_group.add_action(zoom_in); app.set_accelerators_for_action::(&["equal", "plus"]); let cloned_sender = sender.clone(); let zoom_out: RelmAction = RelmAction::new_stateless(move |_| { cloned_sender.input(AppAction::ZoomOut); }); app.set_accelerators_for_action::(&["minus"]); action_group.add_action(zoom_out); let cloned_sender = sender.clone(); let change_orientation: RelmAction = RelmAction::new_stateless(move |_| { cloned_sender.input(AppAction::ChangeOrientation); }); app.set_accelerators_for_action::(&["l"]); action_group.add_action(change_orientation); let cloned_sender = sender.clone(); let export_image: RelmAction = RelmAction::new_stateless(move |_| { cloned_sender.input(AppAction::ExportImage); }); app.set_accelerators_for_action::(&["e"]); action_group.add_action(export_image); let cloned_sender = sender.clone(); let new: RelmAction = RelmAction::new_stateless(move |_| { cloned_sender.input(AppAction::New); }); app.set_accelerators_for_action::(&["n"]); action_group.add_action(new); let cloned_sender = sender.clone(); let save: RelmAction = RelmAction::new_stateless(move |_| { cloned_sender.input(AppAction::Save{close: false}); }); app.set_accelerators_for_action::(&["s"]); action_group.add_action(save); let cloned_sender = sender.clone(); let save_as: RelmAction = RelmAction::new_stateless(move |_| { cloned_sender.input(AppAction::SaveAs{close: false}); }); app.set_accelerators_for_action::(&["s"]); action_group.add_action(save_as); let cloned_sender = sender.clone(); let open: RelmAction = RelmAction::new_stateless(move |_| { cloned_sender.input(AppAction::Open); }); app.set_accelerators_for_action::(&["o"]); action_group.add_action(open); let cloned_sender = sender.clone(); let close: RelmAction = RelmAction::new_stateless(move |_| { cloned_sender.input(AppAction::CloseWindow); }); app.set_accelerators_for_action::(&["w"]); action_group.add_action(close); action_group.register_for_widget(&root); } } #[relm4::component] impl SimpleComponent for AppModel { /// The type of the messages that this component can receive. type Input = AppAction; /// The type of the messages that this component can send. type Output = (); /// The type of data with which this component will be initialized. type Init = AppInit; view! { #[root] gtk::Window { #[watch] set_title: Some(&model.window_title()), set_default_width: 300, set_default_height: 100, 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("Change Orientation") { #[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("Export as Image") { 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(AppAction::StageTypeSelected(selected as usize)); }, }, gtk::MenuButton { set_icon_name: MENU_LARGE, #[wrap(Some)] set_popover = >k::PopoverMenu::from_model(Some(&main_menu)) { add_child: (&popover_child, "my_widget"), } } } }, gtk::Paned { #[watch] set_orientation: model.orientation, set_expand: true, #[wrap(Some)] set_start_child = >k::ScrolledWindow { sourceview5::View { set_expand: true, set_monospace: 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("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(AppAction::Zoom(scale.value())); } }, gtk::Button::with_label("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::: &(), }, } } } }, popover_child = gtk::Spinner { set_spinning: true, } } menu! { main_menu: { custom: "Map", "New Map" => NewAction, "Open..." => OpenAction, "Save" => SaveAction, "Save As..." => SaveAsAction, "Close" => CloseAction, "Export Map as Image" => ExportImageAction, section!{ match model.orientation { gtk::Orientation::Horizontal => "Use Vertical Layout", _ => "Use Horizontal Layout" } => ChangeOrientationAction, }, section! { "Zoom In" => ZoomInAction, "Zoom Out" => ZoomOutAction, }, } } /// Initialize the UI and model. fn init( init: Self::Init, root: Self::Root, sender: ComponentSender, ) -> ComponentParts { let stage_types: Vec<&str> = all_stages() .iter() .map(|stage_type| stage_type.name()) .collect(); let stage_type_list = gtk::StringList::new(&stage_types); let source = sourceview5::Buffer::new(None); // Enable syntax highlighting source.set_highlight_syntax(true); // Set default language let language_manager = LanguageManager::default(); if let Some(language) = language_manager.language("wmap") { source.set_language(Some(&language)); } // Load file content if a file path was provided 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 { (String::new(), None) }; // Set initial text BEFORE connecting the change handler // This prevents marking the document as modified on initial load source.set_text(&initial_content); // Connect change handler AFTER setting initial text let cloned_sender = sender.clone(); source.connect_changed(move |_| { cloned_sender.input(AppAction::SourceChanged); }); /* Attach Actions ----------------------------------------------------*/ AppModel::setup_actions(root.clone(), sender.clone()); /* Initialize Model --------------------------------------------------*/ 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, &render_configuration).unwrap_or_else(|_| { ImageSurface::create(cairo::Format::ARgb32, 1, 1) .expect("Failed to create fallback surface") }); // Register open file if we have one if let Some(ref path) = current_file { Self::register_open_file(path, &root); } let mut model = AppModel { orientation: gtk::Orientation::Horizontal, stage_type_list, stage_type, source, 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(), }; model.update_image(); // Schedule MarkInitialized to be sent after widget setup is complete let sender_clone = sender.clone(); glib::idle_add_local_once(move || { sender_clone.input(AppAction::MarkInitialized); }); // Connect close-request to handle unsaved changes let sender_clone = sender.clone(); root.connect_close_request(move |_| { sender_clone.input(AppAction::CloseWindow); glib::Propagation::Stop }); let widgets = view_output!(); ComponentParts { model, widgets } } fn update(&mut self, message: Self::Input, sender: ComponentSender) { match message { AppAction::ChangeOrientation => { self.orientation = match self.orientation { gtk::Orientation::Horizontal => gtk::Orientation::Vertical, _ => gtk::Orientation::Horizontal, } } AppAction::StageTypeSelected(index) => { let all_stages = all_stages(); if index < all_stages.len() { self.stage_type = all_stages[index]; } self.update_image(); } AppAction::SourceChanged => { // Check if this is from a programmatic load let pending = *self.pending_load_events.borrow(); if pending > 0 { *self.pending_load_events.borrow_mut() -= 1; } else if self.initialized { // Only mark as modified if initialized and not from a load self.modified = true; } let text = self.source .text(&self.source.start_iter(), &self.source.end_iter(), false); self.map = parse(&text); self.update_image(); } AppAction::Zoom(new_zoom) => { self.zoom = new_zoom; self.update_image(); } AppAction::ZoomOut => { if self.zoom - constants::ZOOM_STEP >= constants::MIN_ZOOM { self.zoom -= constants::ZOOM_STEP; self.update_image(); } } AppAction::ZoomIn => { if self.zoom < constants::MAX_ZOOM { self.zoom += constants::ZOOM_STEP; self.update_image(); } } AppAction::ExportImage => {} AppAction::New => { Self::open_new_window(None); } AppAction::Open => { let dialog = gtk::FileChooserDialog::new( Some("Open Map"), Some(&self.window), gtk::FileChooserAction::Open, &[ ("Cancel", gtk::ResponseType::Cancel), ("Open", gtk::ResponseType::Accept), ], ); dialog.add_filter(&Self::create_file_filter()); let sender_clone = sender.clone(); let is_empty = self.is_empty_document(); dialog.connect_response(move |dialog, response| { dialog.close(); if response == gtk::ResponseType::Accept && let Some(file) = dialog.file() && let Some(path) = file.path() { // Check if file is already open - if so, focus that window if let Some(existing_window) = Self::get_window_for_file(&path) { existing_window.present(); return; } if is_empty { sender_clone.input(AppAction::LoadFile(path)); } else { Self::open_new_window(Some(path)); } } }); dialog.present(); } AppAction::Save{close} => { if let Some(path) = self.current_file.clone() { sender.input(AppAction::SaveToFile{path, close}); } else { sender.input(AppAction::SaveAs{close}); } } AppAction::SaveAs{close} => { let dialog = gtk::FileChooserDialog::new( Some("Save Map As"), Some(&self.window), gtk::FileChooserAction::Save, &[ ("Cancel", gtk::ResponseType::Cancel), ("Save", gtk::ResponseType::Accept), ], ); dialog.add_filter(&Self::create_file_filter()); if let Some(ref current_path) = self.current_file { if let Some(parent) = current_path.parent() { let folder = gio::File::for_path(parent); dialog.set_current_folder(Some(&folder)).ok(); } if let Some(filename) = current_path.file_name() { dialog.set_current_name(filename.to_str().unwrap_or("map.wmap")); } } else { dialog.set_current_name("map.wmap"); } let sender_clone = sender.clone(); dialog.connect_response(move |dialog, response| { dialog.close(); if response == gtk::ResponseType::Accept && let Some(file) = dialog.file() && let Some(path) = file.path() { sender_clone.input(AppAction::SaveToFile{path, close}); } }); dialog.present(); } AppAction::LoadFile(path) => { match std::fs::read_to_string(&path) { Ok(content) => { // Unregister old file if any if let Some(ref old_path) = self.current_file { Self::unregister_open_file(old_path); } self.set_source_text(&content); Self::register_open_file(&path, &self.window); self.current_file = Some(path); self.modified = false; } Err(error) => { self.show_error_dialog("Error Opening File", &error.to_string()); } } } AppAction::SaveToFile{path, close} => { let content = self.source .text(&self.source.start_iter(), &self.source.end_iter(), false); // Ensure the file has the correct extension let path = if path.extension().is_none() || path.extension().unwrap_or_default() != constants::FILE_EXTENSION { path.with_extension(constants::FILE_EXTENSION) } else { path }; match std::fs::write(&path, content.as_str()) { Ok(()) => { if close { Self::unregister_open_file(&path); self.modified = false; self.window.close(); } else { // Update file registration if path changed if self.current_file.as_ref() != Some(&path) { if let Some(ref old_path) = self.current_file { Self::unregister_open_file(old_path); } Self::register_open_file(&path, &self.window); } self.current_file = Some(path); self.modified = false; } } Err(error) => { self.show_error_dialog("Error Saving File", &error.to_string()); } } } AppAction::MarkInitialized => { self.initialized = true; } AppAction::CloseWindow => { if self.modified { // Show confirmation dialog let dialog = gtk::MessageDialog::new( Some(&self.window), gtk::DialogFlags::MODAL | gtk::DialogFlags::DESTROY_WITH_PARENT, gtk::MessageType::Warning, gtk::ButtonsType::None, "Save changes before closing?", ); dialog.set_title(Some("Unsaved Changes")); dialog.add_button("Don't Save", gtk::ResponseType::Reject); dialog.add_button("Cancel", gtk::ResponseType::Cancel); dialog.add_button("Save", gtk::ResponseType::Accept); let window = self.window.clone(); let current_file = self.current_file.clone(); let sender_clone = sender.clone(); dialog.connect_response(move |dialog, response| { dialog.close(); match response { // Save / Save As and Close. gtk::ResponseType::Accept => { if current_file.is_some() { sender_clone.input(AppAction::Save{close: true}); } else { sender_clone.input(AppAction::SaveAs{close: true}); } } // Don't save, just close gtk::ResponseType::Reject => { if let Some(ref path) = current_file { Self::unregister_open_file(path); } window.destroy(); } // Cancel - do nothing, keep window open _ => {} } }); dialog.show(); } else { if let Some(ref path) = self.current_file { Self::unregister_open_file(path); } self.window.destroy(); } } } } } /* ACTIONS ********************************************************************/ relm4::new_action_group!(WindowActionGroup, "window"); /* Map File */ 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"); /* Layout */ relm4::new_stateless_action!( ChangeOrientationAction, WindowActionGroup, "change-orientation" ); /* Map View */ relm4::new_stateless_action!(ZoomInAction, WindowActionGroup, "zoom-in"); relm4::new_stateless_action!(ZoomOutAction, WindowActionGroup, "zoom-out"); /* MAIN ***********************************************************************/ fn main() { relm4_icons::initialize_icons(icon_names::GRESOURCE_BYTES, icon_names::RESOURCE_PREFIX); let app = RelmApp::new("systems.tranquil.map"); app.run::(AppInit { zoom: 1.0, file_path: None, }); }