diff options
| author | Rubén Beltrán del Río <jj@r.bdr.sh> | 2026-01-15 12:19:52 +0100 |
|---|---|---|
| committer | Rubén Beltrán del Río <jj@r.bdr.sh> | 2026-01-15 19:44:59 +0100 |
| commit | 326fcd1b1770fcb597d716af2b722c8971453344 (patch) | |
| tree | 47aa30750586925f2c92a8746e43e77512515ccd /src/main.rs | |
| parent | 727dd5db0a123e57e3873a90705a90c849b95b77 (diff) | |
Add Document support
Diffstat (limited to 'src/main.rs')
| -rw-r--r-- | src/main.rs | 436 |
1 files changed, 410 insertions, 26 deletions
diff --git a/src/main.rs b/src/main.rs index 5539caf..7d1905a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,19 +5,41 @@ 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, + IMAGE_REGULAR, MENU_LARGE, SPLIT_HORIZONTAL_REGULAR, SPLIT_VERTICAL_REGULAR, ZOOM_IN_REGULAR, + ZOOM_OUT_REGULAR, }; use cairo::ImageSurface; use gtk::prelude::*; -use relm4::{ComponentParts, ComponentSender, RelmApp, RelmWidgetExt, SimpleComponent, gtk, actions::{AccelsPlus, ActionablePlus, RelmAction, RelmActionGroup}}; +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<Vec<Controller<AppModel>>> = const { RefCell::new(Vec::new()) }; + static OPEN_FILES: RefCell<Vec<(PathBuf, gtk::Window)>> = const { RefCell::new(Vec::new()) }; +} + +struct AppInit { + zoom: f64, + file_path: Option<PathBuf>, +} + struct AppModel { orientation: gtk::Orientation, stage_type_list: gtk::StringList, @@ -30,6 +52,11 @@ struct AppModel { surface_width: i32, surface_height: i32, drawing_area: gtk::DrawingArea, + current_file: Option<PathBuf>, + modified: bool, + pending_load_events: RefCell<u32>, // Counter for pending load events to ignore + initialized: bool, // Flag to prevent modified during widget initialization + window: gtk::Window, } #[derive(Debug)] @@ -40,7 +67,15 @@ enum AppAction { Zoom(f64), ZoomOut, ZoomIn, - ExportImage + ExportImage, + New, + Open, + Save{close: bool}, + SaveAs{close: bool}, + LoadFile(PathBuf), + SaveToFile{path: PathBuf, close: bool}, + MarkInitialized, + CloseWindow, } impl AppModel { @@ -68,6 +103,102 @@ impl AppModel { } } + 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<PathBuf>) { + 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<gtk::Window> { + 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. @@ -91,9 +222,10 @@ impl AppModel { action_group.add_action(zoom_out); let cloned_sender = sender.clone(); - let change_orientation: RelmAction<ChangeOrientationAction> = RelmAction::new_stateless(move |_| { - cloned_sender.input(AppAction::ChangeOrientation); - }); + let change_orientation: RelmAction<ChangeOrientationAction> = + RelmAction::new_stateless(move |_| { + cloned_sender.input(AppAction::ChangeOrientation); + }); app.set_accelerators_for_action::<ChangeOrientationAction>(&["<Primary>l"]); action_group.add_action(change_orientation); @@ -106,33 +238,38 @@ impl AppModel { let cloned_sender = sender.clone(); let new: RelmAction<NewAction> = RelmAction::new_stateless(move |_| { - println!("Not yet implemented!"); + cloned_sender.input(AppAction::New); }); app.set_accelerators_for_action::<NewAction>(&["<Primary>n"]); action_group.add_action(new); let cloned_sender = sender.clone(); let save: RelmAction<SaveAction> = RelmAction::new_stateless(move |_| { - println!("Not yet implemented!"); + cloned_sender.input(AppAction::Save{close: false}); }); app.set_accelerators_for_action::<SaveAction>(&["<Primary>s"]); action_group.add_action(save); let cloned_sender = sender.clone(); let save_as: RelmAction<SaveAsAction> = RelmAction::new_stateless(move |_| { - println!("Not yet implemented!"); + cloned_sender.input(AppAction::SaveAs{close: false}); }); app.set_accelerators_for_action::<SaveAsAction>(&["<Primary><Shift>s"]); action_group.add_action(save_as); let cloned_sender = sender.clone(); let open: RelmAction<OpenAction> = RelmAction::new_stateless(move |_| { - println!("Not yet implemented!"); + cloned_sender.input(AppAction::Open); }); app.set_accelerators_for_action::<OpenAction>(&["<Primary>o"]); action_group.add_action(open); + let cloned_sender = sender.clone(); + let close: RelmAction<CloseAction> = RelmAction::new_stateless(move |_| { + cloned_sender.input(AppAction::CloseWindow); + }); app.set_accelerators_for_action::<CloseAction>(&["<Primary>w"]); + action_group.add_action(close); action_group.register_for_widget(&root); } @@ -145,12 +282,13 @@ impl SimpleComponent for AppModel { /// 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 = f64; + type Init = AppInit; view! { #[root] gtk::Window { - set_title: Some("Map"), + #[watch] + set_title: Some(&model.window_title()), set_default_width: 300, set_default_height: 100, @@ -297,7 +435,7 @@ impl SimpleComponent for AppModel { /// Initialize the UI and model. fn init( - zoom: Self::Init, + init: Self::Init, root: Self::Root, sender: ComponentSender<Self>, ) -> ComponentParts<Self> { @@ -308,10 +446,6 @@ impl SimpleComponent for AppModel { let stage_type_list = gtk::StringList::new(&stage_types); let source = sourceview5::Buffer::new(None); - let cloned_sender = sender.clone(); - source.connect_changed(move |_| { - cloned_sender.input(AppAction::SourceChanged); - }); // Enable syntax highlighting source.set_highlight_syntax(true); @@ -321,17 +455,47 @@ impl SimpleComponent for AppModel { 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(""); + 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(); + 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, @@ -339,21 +503,39 @@ impl SimpleComponent for AppModel { stage_type, source, map, - zoom, + 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<Self>) { + fn update(&mut self, message: Self::Input, sender: ComponentSender<Self>) { match message { AppAction::ChangeOrientation => { self.orientation = match self.orientation { @@ -369,6 +551,14 @@ impl SimpleComponent for AppModel { 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); @@ -391,7 +581,194 @@ impl SimpleComponent for AppModel { self.update_image(); } } - AppAction::ExportImage => { + 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(); + } } } } @@ -410,7 +787,11 @@ relm4::new_stateless_action!(CloseAction, WindowActionGroup, "close"); relm4::new_stateless_action!(ExportImageAction, WindowActionGroup, "export-image"); /* Layout */ -relm4::new_stateless_action!(ChangeOrientationAction, WindowActionGroup, "change-orientation"); +relm4::new_stateless_action!( + ChangeOrientationAction, + WindowActionGroup, + "change-orientation" +); /* Map View */ relm4::new_stateless_action!(ZoomInAction, WindowActionGroup, "zoom-in"); @@ -420,6 +801,9 @@ relm4::new_stateless_action!(ZoomOutAction, WindowActionGroup, "zoom-out"); fn main() { relm4_icons::initialize_icons(icon_names::GRESOURCE_BYTES, icon_names::RESOURCE_PREFIX); - let app = RelmApp::new("relm4.test.simple_manual"); - app.run::<AppModel>(1.0); + let app = RelmApp::new("systems.tranquil.map"); + app.run::<AppModel>(AppInit { + zoom: 1.0, + file_path: None, + }); } |