From ab6492a9f9b8a65121fcf10ff5a44660bd063af1 Mon Sep 17 00:00:00 2001 From: Rubén Beltrán del Río Date: Thu, 15 Jan 2026 19:45:33 +0100 Subject: Extract some logic from main --- src/main.rs | 481 ++++++++++++++---------------------------------------------- 1 file changed, 109 insertions(+), 372 deletions(-) (limited to 'src/main.rs') diff --git a/src/main.rs b/src/main.rs index 7d1905a..4a659e9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4 +1,7 @@ +mod actions; mod constants; +mod dialogs; +mod file_registry; mod stages; mod icon_names { @@ -8,14 +11,14 @@ mod icon_names { 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::prelude::*; -use gtk::{gio, glib}; -use relm4::component::Controller; +use gtk::glib; use relm4::{ Component, ComponentController, ComponentParts, ComponentSender, RelmApp, RelmWidgetExt, SimpleComponent, @@ -28,11 +31,45 @@ 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()) }; +// 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"); + +/// 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: gtk::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"]); + + action_group.register_for_widget(&window); } struct AppInit { @@ -54,30 +91,11 @@ struct AppModel { 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 + pending_load_events: RefCell, + initialized: bool, 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) = @@ -88,7 +106,6 @@ impl AppModel { self.surface = surface; let zoom = self.zoom; - let surface_clone = self.surface.clone(); self.drawing_area .set_draw_func(move |_, cr, _width, _height| { @@ -119,38 +136,20 @@ impl AppModel { } 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() + self.current_file.is_none() && !self.modified && self.source_text().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 source_text(&self) -> glib::GString { + self.source + .text(&self.source.start_iter(), &self.source.end_iter(), false) } - 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 set_source_text(&self, content: &str) { + *self.pending_load_events.borrow_mut() += 1; + self.source.set_text(content); } - fn open_new_window(file_path: Option) { + pub fn open_new_window(file_path: Option) { let controller = AppModel::builder() .launch(AppInit { zoom: 1.0, @@ -158,130 +157,19 @@ impl AppModel { }) .detach(); - let app = relm4::main_application(); + let application = relm4::main_application(); let window = controller.widget(); - app.add_window(window); + application.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); + file_registry::store_controller(controller); } } #[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 Input = Action; type Output = (); - /// The type of data with which this component will be initialized. type Init = AppInit; view! { @@ -323,15 +211,13 @@ impl SimpleComponent for AppModel { set_selected: 0, connect_selected_notify[sender] => move |dropdown| { let selected = dropdown.selected(); - sender.input(AppAction::StageTypeSelected(selected as usize)); + sender.input(Action::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"), - } + set_popover = >k::PopoverMenu::from_model(Some(&main_menu)) {} } } }, @@ -391,7 +277,7 @@ impl SimpleComponent for AppModel { set_value_pos: gtk::PositionType::Left, set_width_request: 100, connect_value_changed[sender] => move |scale| { - sender.input(AppAction::Zoom(scale.value())); + sender.input(Action::Zoom(scale.value())); } }, @@ -405,15 +291,11 @@ impl SimpleComponent for AppModel { } } } - }, - popover_child = gtk::Spinner { - set_spinning: true, } } menu! { main_menu: { - custom: "Map", "New Map" => NewAction, "Open..." => OpenAction, "Save" => SaveAction, @@ -433,7 +315,6 @@ impl SimpleComponent for AppModel { } } - /// Initialize the UI and model. fn init( init: Self::Init, root: Self::Root, @@ -446,17 +327,13 @@ impl SimpleComponent for AppModel { 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())), @@ -466,20 +343,14 @@ impl SimpleComponent for AppModel { (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(); + let sender_for_source = sender.clone(); source.connect_changed(move |_| { - cloned_sender.input(AppAction::SourceChanged); + sender_for_source.input(Action::SourceChanged); }); - /* Attach Actions ----------------------------------------------------*/ - AppModel::setup_actions(root.clone(), sender.clone()); - - /* Initialize Model --------------------------------------------------*/ + setup_actions(root.clone(), sender.clone()); let drawing_area = gtk::DrawingArea::new(); let map = parse(&initial_content); @@ -492,9 +363,8 @@ impl SimpleComponent for AppModel { .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); + file_registry::register_file(path, &root); } let mut model = AppModel { @@ -517,16 +387,14 @@ impl SimpleComponent for AppModel { }; model.update_image(); - // Schedule MarkInitialized to be sent after widget setup is complete - let sender_clone = sender.clone(); + let sender_for_init = sender.clone(); glib::idle_add_local_once(move || { - sender_clone.input(AppAction::MarkInitialized); + sender_for_init.input(Action::MarkInitialized); }); - // Connect close-request to handle unsaved changes - let sender_clone = sender.clone(); + let sender_for_close = sender.clone(); root.connect_close_request(move |_| { - sender_clone.input(AppAction::CloseWindow); + sender_for_close.input(Action::CloseWindow); glib::Propagation::Stop }); @@ -537,155 +405,86 @@ impl SimpleComponent for AppModel { fn update(&mut self, message: Self::Input, sender: ComponentSender) { match message { - AppAction::ChangeOrientation => { + Action::ChangeOrientation => { self.orientation = match self.orientation { gtk::Orientation::Horizontal => gtk::Orientation::Vertical, _ => gtk::Orientation::Horizontal, } } - AppAction::StageTypeSelected(index) => { + Action::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 + Action::SourceChanged => { 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.map = parse(&self.source_text()); self.update_image(); } - AppAction::Zoom(new_zoom) => { + Action::Zoom(new_zoom) => { self.zoom = new_zoom; self.update_image(); } - AppAction::ZoomOut => { + Action::ZoomOut => { if self.zoom - constants::ZOOM_STEP >= constants::MIN_ZOOM { self.zoom -= constants::ZOOM_STEP; self.update_image(); } } - AppAction::ZoomIn => { + Action::ZoomIn => { if self.zoom < constants::MAX_ZOOM { self.zoom += constants::ZOOM_STEP; self.update_image(); } } - AppAction::ExportImage => {} - AppAction::New => { + Action::ExportImage => {} + Action::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(); + Action::Open => { + dialogs::show_open_dialog(&self.window, self.is_empty_document(), sender); } - AppAction::Save{close} => { + Action::Save { close_after } => { if let Some(path) = self.current_file.clone() { - sender.input(AppAction::SaveToFile{path, close}); + sender.input(Action::SaveToFile { path, close_after }); } else { - sender.input(AppAction::SaveAs{close}); + sender.input(Action::SaveAs { close_after }); } } - 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), - ], + Action::SaveAs { close_after } => { + dialogs::show_save_dialog( + &self.window, + self.current_file.clone(), + close_after, + sender, ); - 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) => { + Action::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); + file_registry::unregister_file(old_path); } self.set_source_text(&content); - Self::register_open_file(&path, &self.window); + file_registry::register_file(&path, &self.window); self.current_file = Some(path); self.modified = false; } Err(error) => { - self.show_error_dialog("Error Opening File", &error.to_string()); + dialogs::show_error(&self.window, "Error Opening File", &error.to_string()); } } } - AppAction::SaveToFile{path, close} => { - let content = - self.source - .text(&self.source.start_iter(), &self.source.end_iter(), false); + Action::SaveToFile { path, close_after } => { + let content = self.source_text(); - // Ensure the file has the correct extension let path = if path.extension().is_none() || path.extension().unwrap_or_default() != constants::FILE_EXTENSION { @@ -696,76 +495,39 @@ impl SimpleComponent for AppModel { match std::fs::write(&path, content.as_str()) { Ok(()) => { - if close { - Self::unregister_open_file(&path); + if close_after { + file_registry::unregister_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); + file_registry::unregister_file(old_path); } - Self::register_open_file(&path, &self.window); + file_registry::register_file(&path, &self.window); } self.current_file = Some(path); self.modified = false; } } Err(error) => { - self.show_error_dialog("Error Saving File", &error.to_string()); + dialogs::show_error(&self.window, "Error Saving File", &error.to_string()); } } } - AppAction::MarkInitialized => { + Action::MarkInitialized => { self.initialized = true; } - AppAction::CloseWindow => { + Action::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?", + dialogs::show_unsaved_changes_dialog( + &self.window, + self.current_file.clone(), + sender, ); - 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); + file_registry::unregister_file(path); } self.window.destroy(); } @@ -774,35 +536,10 @@ impl SimpleComponent for AppModel { } } -/* 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 { + let application = RelmApp::new("systems.tranquil.map"); + application.run::(AppInit { zoom: 1.0, file_path: None, }); -- cgit