diff options
| -rw-r--r-- | src/constants.rs | 9 | ||||
| -rw-r--r-- | src/dialogs.rs | 4 | ||||
| -rw-r--r-- | src/handlers/file.rs | 100 | ||||
| -rw-r--r-- | src/handlers/mod.rs | 3 | ||||
| -rw-r--r-- | src/handlers/view.rs | 20 | ||||
| -rw-r--r-- | src/handlers/zoom.rs | 25 | ||||
| -rw-r--r-- | src/main.rs | 278 | ||||
| -rw-r--r-- | src/stages.rs | 48 |
8 files changed, 320 insertions, 167 deletions
diff --git a/src/constants.rs b/src/constants.rs index d163b45..1677828 100644 --- a/src/constants.rs +++ b/src/constants.rs @@ -1,7 +1,16 @@ +// Zoom configuration pub const MIN_ZOOM: f64 = 0.1; pub const MAX_ZOOM: f64 = 2.0; pub const ZOOM_STEP: f64 = 0.1; +// File handling pub const FILE_EXTENSION: &str = "wmap"; pub const MIME_TYPE: &str = "application/vnd.wmap"; + +// Application pub const APP_NAME: &str = "Map"; +pub const APP_ID: &str = "systems.tranquil.map"; + +// Window defaults +pub const DEFAULT_WINDOW_WIDTH: i32 = 300; +pub const DEFAULT_WINDOW_HEIGHT: i32 = 100; diff --git a/src/dialogs.rs b/src/dialogs.rs index cb3f191..6b906ed 100644 --- a/src/dialogs.rs +++ b/src/dialogs.rs @@ -5,12 +5,12 @@ use std::path::PathBuf; use gtk::prelude::*; use relm4::{ComponentSender, gtk}; +use crate::AppModel; use crate::actions::Action; use crate::constants; use crate::file_registry; -use crate::AppModel; -/// Creates a file filter for .wmap files. +/// Creates a file filter for .wmap files in file pickers. pub fn create_file_filter() -> gtk::FileFilter { let filter = gtk::FileFilter::new(); filter.add_pattern(&format!("*.{}", constants::FILE_EXTENSION)); diff --git a/src/handlers/file.rs b/src/handlers/file.rs new file mode 100644 index 0000000..55eaffa --- /dev/null +++ b/src/handlers/file.rs @@ -0,0 +1,100 @@ +use std::path::PathBuf; + +use gtk::prelude::*; +use relm4::{ComponentSender, gtk}; + +use crate::AppModel; +use crate::actions::Action; +use crate::constants; +use crate::dialogs; +use crate::file_registry; + +/// Loads a file into the current document, updating the file registry. +pub fn load_file(model: &mut AppModel, path: PathBuf) { + match std::fs::read_to_string(&path) { + Ok(content) => { + unregister_current_file(model); + model.set_source_text(&content); + file_registry::register_file(&path, &model.window); + model.current_file = Some(path); + model.modified = false; + } + Err(error) => { + dialogs::show_error(&model.window, "Error Opening File", &error.to_string()); + } + } +} + +/// Saves the document to the specified path, optionally closing after. +pub fn save_to_file(model: &mut AppModel, path: PathBuf, close_after: bool) { + let content = model.source_text(); + let path = ensure_wmap_extension(path); + + match std::fs::write(&path, content.as_str()) { + Ok(()) => { + if close_after { + file_registry::unregister_file(&path); + model.modified = false; + model.window.close(); + } else { + update_file_registration(model, &path); + model.current_file = Some(path); + model.modified = false; + } + } + Err(error) => { + dialogs::show_error(&model.window, "Error Saving File", &error.to_string()); + } + } +} + +/// Handles window close, prompting for unsaved changes if needed. +pub fn close_window(model: &mut AppModel, sender: &ComponentSender<AppModel>) { + if model.modified { + dialogs::show_unsaved_changes_dialog( + &model.window, + model.current_file.clone(), + sender.clone(), + ); + } else { + unregister_current_file(model); + model.window.destroy(); + } +} + +/// Dispatches save action based on whether file already has a path. +pub fn save(model: &AppModel, close_after: bool, sender: &ComponentSender<AppModel>) { + if let Some(path) = model.current_file.clone() { + sender.input(Action::SaveToFile { path, close_after }); + } else { + sender.input(Action::SaveAs { close_after }); + } +} + +// === Helper Functions === + +/// Unregisters the current file from the registry if one is open. +fn unregister_current_file(model: &AppModel) { + if let Some(ref path) = model.current_file { + file_registry::unregister_file(path); + } +} + +/// Updates file registration when saving to a new path. +fn update_file_registration(model: &mut AppModel, new_path: &PathBuf) { + if model.current_file.as_ref() != Some(new_path) { + unregister_current_file(model); + file_registry::register_file(new_path, &model.window); + } +} + +/// Ensures the path has the .wmap extension. +fn ensure_wmap_extension(path: PathBuf) -> PathBuf { + if path.extension().is_none() + || path.extension().unwrap_or_default() != constants::FILE_EXTENSION + { + path.with_extension(constants::FILE_EXTENSION) + } else { + path + } +} diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs new file mode 100644 index 0000000..7d7cfec --- /dev/null +++ b/src/handlers/mod.rs @@ -0,0 +1,3 @@ +pub mod file; +pub mod view; +pub mod zoom; diff --git a/src/handlers/view.rs b/src/handlers/view.rs new file mode 100644 index 0000000..64aeb98 --- /dev/null +++ b/src/handlers/view.rs @@ -0,0 +1,20 @@ +use relm4::gtk; + +use crate::AppModel; +use crate::stages::ALL_STAGE_TYPES; + +/// Toggles between horizontal and vertical layout orientation. +pub fn change_orientation(model: &mut AppModel) { + model.orientation = match model.orientation { + gtk::Orientation::Horizontal => gtk::Orientation::Vertical, + _ => gtk::Orientation::Horizontal, + }; +} + +/// Updates the selected stage type from dropdown selection. +pub fn stage_type_selected(model: &mut AppModel, index: usize) { + if index < ALL_STAGE_TYPES.len() { + model.stage_type = ALL_STAGE_TYPES[index]; + model.update_image(); + } +} diff --git a/src/handlers/zoom.rs b/src/handlers/zoom.rs new file mode 100644 index 0000000..3acbb2c --- /dev/null +++ b/src/handlers/zoom.rs @@ -0,0 +1,25 @@ +use crate::AppModel; +use crate::constants; + +/// Sets the zoom level and updates the rendered image. +pub fn zoom(model: &mut AppModel, level: f64) { + model.zoom = level; + model.update_image(); +} + +/// Decreases zoom by one step if above minimum. +pub fn zoom_out(model: &mut AppModel) { + let new_zoom = model.zoom - constants::ZOOM_STEP; + if new_zoom >= constants::MIN_ZOOM { + model.zoom = new_zoom; + model.update_image(); + } +} + +/// Increases zoom by one step if below maximum. +pub fn zoom_in(model: &mut AppModel) { + if model.zoom < constants::MAX_ZOOM { + model.zoom += constants::ZOOM_STEP; + model.update_image(); + } +} diff --git a/src/main.rs b/src/main.rs index 4a659e9..30f2381 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,6 +2,7 @@ mod actions; mod constants; mod dialogs; mod file_registry; +mod handlers; mod stages; mod icon_names { @@ -17,8 +18,8 @@ use crate::icon_names::shipped::{ ZOOM_OUT_REGULAR, }; use cairo::ImageSurface; -use gtk::prelude::*; use gtk::glib; +use gtk::prelude::*; use relm4::{ Component, ComponentController, ComponentParts, ComponentSender, RelmApp, RelmWidgetExt, SimpleComponent, @@ -27,7 +28,7 @@ use relm4::{ }; use sourceview5::LanguageManager; use sourceview5::prelude::{BufferExt, TextBufferExt, TextViewExt}; -use stages::all_stages; +use stages::ALL_STAGE_TYPES; use wmap_parser::{Map, parse}; use wmap_renderer::{Configuration, StageType, render_to_surface}; @@ -39,7 +40,11 @@ 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!( + ChangeOrientationAction, + WindowActionGroup, + "change-orientation" +); relm4::new_stateless_action!(ZoomInAction, WindowActionGroup, "zoom-in"); relm4::new_stateless_action!(ZoomOutAction, WindowActionGroup, "zoom-out"); @@ -59,15 +64,78 @@ fn setup_actions(window: gtk::Window, sender: ComponentSender<AppModel>) { let application = relm4::main_application(); let mut action_group = RelmActionGroup::<WindowActionGroup>::new(); - register_action!(action_group, application, sender, ZoomInAction, Action::ZoomIn, &["<Primary>equal", "<Primary>plus"]); - register_action!(action_group, application, sender, ZoomOutAction, Action::ZoomOut, &["<Primary>minus"]); - register_action!(action_group, application, sender, ChangeOrientationAction, Action::ChangeOrientation, &["<Primary>l"]); - register_action!(action_group, application, sender, ExportImageAction, Action::ExportImage, &["<Primary>e"]); - register_action!(action_group, application, sender, NewAction, Action::New, &["<Primary>n"]); - register_action!(action_group, application, sender, SaveAction, Action::Save { close_after: false }, &["<Primary>s"]); - register_action!(action_group, application, sender, SaveAsAction, Action::SaveAs { close_after: false }, &["<Primary><Shift>s"]); - register_action!(action_group, application, sender, OpenAction, Action::Open, &["<Primary>o"]); - register_action!(action_group, application, sender, CloseAction, Action::CloseWindow, &["<Primary>w"]); + register_action!( + action_group, + application, + sender, + ZoomInAction, + Action::ZoomIn, + &["<Primary>equal", "<Primary>plus"] + ); + register_action!( + action_group, + application, + sender, + ZoomOutAction, + Action::ZoomOut, + &["<Primary>minus"] + ); + register_action!( + action_group, + application, + sender, + ChangeOrientationAction, + Action::ChangeOrientation, + &["<Primary>l"] + ); + register_action!( + action_group, + application, + sender, + ExportImageAction, + Action::ExportImage, + &["<Primary>e"] + ); + register_action!( + action_group, + application, + sender, + NewAction, + Action::New, + &["<Primary>n"] + ); + register_action!( + action_group, + application, + sender, + SaveAction, + Action::Save { close_after: false }, + &["<Primary>s"] + ); + register_action!( + action_group, + application, + sender, + SaveAsAction, + Action::SaveAs { close_after: false }, + &["<Primary><Shift>s"] + ); + register_action!( + action_group, + application, + sender, + OpenAction, + Action::Open, + &["<Primary>o"] + ); + register_action!( + action_group, + application, + sender, + CloseAction, + Action::CloseWindow, + &["<Primary>w"] + ); action_group.register_for_widget(&window); } @@ -177,8 +245,8 @@ impl SimpleComponent for AppModel { gtk::Window { #[watch] set_title: Some(&model.window_title()), - set_default_width: 300, - set_default_height: 100, + set_default_width: constants::DEFAULT_WINDOW_WIDTH, + set_default_height: constants::DEFAULT_WINDOW_HEIGHT, gtk::Box { set_orientation: gtk::Orientation::Vertical, @@ -320,7 +388,7 @@ impl SimpleComponent for AppModel { root: Self::Root, sender: ComponentSender<Self>, ) -> ComponentParts<Self> { - let stage_types: Vec<&str> = all_stages() + let stage_types: Vec<&str> = ALL_STAGE_TYPES .iter() .map(|stage_type| stage_type.name()) .collect(); @@ -345,10 +413,12 @@ impl SimpleComponent for AppModel { source.set_text(&initial_content); - let sender_for_source = sender.clone(); - source.connect_changed(move |_| { - sender_for_source.input(Action::SourceChanged); - }); + { + let sender = sender.clone(); + source.connect_changed(move |_| { + sender.input(Action::SourceChanged); + }); + } setup_actions(root.clone(), sender.clone()); @@ -387,16 +457,20 @@ impl SimpleComponent for AppModel { }; model.update_image(); - let sender_for_init = sender.clone(); - glib::idle_add_local_once(move || { - sender_for_init.input(Action::MarkInitialized); - }); + { + let sender = sender.clone(); + glib::idle_add_local_once(move || { + sender.input(Action::MarkInitialized); + }); + } - let sender_for_close = sender.clone(); - root.connect_close_request(move |_| { - sender_for_close.input(Action::CloseWindow); - glib::Propagation::Stop - }); + { + let sender = sender.clone(); + root.connect_close_request(move |_| { + sender.input(Action::CloseWindow); + glib::Propagation::Stop + }); + } let widgets = view_output!(); @@ -405,19 +479,11 @@ impl SimpleComponent for AppModel { fn update(&mut self, message: Self::Input, sender: ComponentSender<Self>) { match message { - Action::ChangeOrientation => { - self.orientation = match self.orientation { - gtk::Orientation::Horizontal => gtk::Orientation::Vertical, - _ => gtk::Orientation::Horizontal, - } - } - Action::StageTypeSelected(index) => { - let all_stages = all_stages(); - if index < all_stages.len() { - self.stage_type = all_stages[index]; - } - self.update_image(); - } + // View actions + Action::ChangeOrientation => handlers::view::change_orientation(self), + Action::StageTypeSelected(index) => handlers::view::stage_type_selected(self, index), + + // Source changes Action::SourceChanged => { let pending = *self.pending_load_events.borrow(); if pending > 0 { @@ -428,117 +494,47 @@ impl SimpleComponent for AppModel { self.map = parse(&self.source_text()); self.update_image(); } - Action::Zoom(new_zoom) => { - self.zoom = new_zoom; - self.update_image(); - } - Action::ZoomOut => { - if self.zoom - constants::ZOOM_STEP >= constants::MIN_ZOOM { - self.zoom -= constants::ZOOM_STEP; - self.update_image(); - } - } - Action::ZoomIn => { - if self.zoom < constants::MAX_ZOOM { - self.zoom += constants::ZOOM_STEP; - self.update_image(); - } - } - Action::ExportImage => {} - Action::New => { - Self::open_new_window(None); - } - Action::Open => { - dialogs::show_open_dialog(&self.window, self.is_empty_document(), sender); - } - Action::Save { close_after } => { - if let Some(path) = self.current_file.clone() { - sender.input(Action::SaveToFile { path, close_after }); - } else { - sender.input(Action::SaveAs { close_after }); - } - } - Action::SaveAs { close_after } => { - dialogs::show_save_dialog( - &self.window, - self.current_file.clone(), - close_after, - sender, - ); - } - Action::LoadFile(path) => { - match std::fs::read_to_string(&path) { - Ok(content) => { - if let Some(ref old_path) = self.current_file { - file_registry::unregister_file(old_path); - } - self.set_source_text(&content); - file_registry::register_file(&path, &self.window); - self.current_file = Some(path); - self.modified = false; - } - Err(error) => { - dialogs::show_error(&self.window, "Error Opening File", &error.to_string()); - } - } - } - Action::SaveToFile { path, close_after } => { - let content = self.source_text(); - let path = if path.extension().is_none() - || path.extension().unwrap_or_default() != constants::FILE_EXTENSION - { - path.with_extension(constants::FILE_EXTENSION) - } else { - path - }; + // Zoom actions + Action::Zoom(level) => handlers::zoom::zoom(self, level), + Action::ZoomIn => handlers::zoom::zoom_in(self), + Action::ZoomOut => handlers::zoom::zoom_out(self), - match std::fs::write(&path, content.as_str()) { - Ok(()) => { - if close_after { - file_registry::unregister_file(&path); - self.modified = false; - self.window.close(); - } else { - if self.current_file.as_ref() != Some(&path) { - if let Some(ref old_path) = self.current_file { - file_registry::unregister_file(old_path); - } - file_registry::register_file(&path, &self.window); - } - self.current_file = Some(path); - self.modified = false; - } - } - Err(error) => { - dialogs::show_error(&self.window, "Error Saving File", &error.to_string()); - } - } + // Export (not yet implemented) + Action::ExportImage => { + // TODO: Implement image export functionality + // - Show save dialog with PNG/SVG format picker + // - Default filename: current file name with .png or .svg extension + // - Call renderer's render_to_png() or render_to_svg() based on selection } - Action::MarkInitialized => { - self.initialized = true; + + // File actions + Action::New => Self::open_new_window(None), + Action::Open => { + dialogs::show_open_dialog(&self.window, self.is_empty_document(), sender) } - Action::CloseWindow => { - if self.modified { - dialogs::show_unsaved_changes_dialog( - &self.window, - self.current_file.clone(), - sender, - ); - } else { - if let Some(ref path) = self.current_file { - file_registry::unregister_file(path); - } - self.window.destroy(); - } + Action::Save { close_after } => handlers::file::save(self, close_after, &sender), + Action::SaveAs { close_after } => dialogs::show_save_dialog( + &self.window, + self.current_file.clone(), + 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), + + // Internal actions + Action::MarkInitialized => self.initialized = true, } } } fn main() { relm4_icons::initialize_icons(icon_names::GRESOURCE_BYTES, icon_names::RESOURCE_PREFIX); - let application = RelmApp::new("systems.tranquil.map"); + let application = RelmApp::new(constants::APP_ID); application.run::<AppModel>(AppInit { zoom: 1.0, file_path: None, diff --git a/src/stages.rs b/src/stages.rs index 0ff0adf..278cd92 100644 --- a/src/stages.rs +++ b/src/stages.rs @@ -1,26 +1,26 @@ use wmap_renderer::StageType; -pub fn all_stages() -> &'static [StageType] { - &[ - StageType::Activities, - StageType::Practice, - StageType::Data, - StageType::Knowledge, - StageType::Ubiquity, - StageType::Certainty, - StageType::PublicationTypes, - StageType::Market, - StageType::KnowledgeManagement, - StageType::MarketPerception, - StageType::UserPerception, - StageType::PerceptionInIndustry, - StageType::FocusOfValue, - StageType::Understanding, - StageType::Comparison, - StageType::Failure, - StageType::MarketAction, - StageType::Efficiency, - StageType::DecisionDrivers, - StageType::Behavior, - ] -} +/// All available stage types for rendering Wardley Maps. +/// Order determines dropdown menu order. +pub const ALL_STAGE_TYPES: &[StageType] = &[ + StageType::Activities, + StageType::Practice, + StageType::Data, + StageType::Knowledge, + StageType::Ubiquity, + StageType::Certainty, + StageType::PublicationTypes, + StageType::Market, + StageType::KnowledgeManagement, + StageType::MarketPerception, + StageType::UserPerception, + StageType::PerceptionInIndustry, + StageType::FocusOfValue, + StageType::Understanding, + StageType::Comparison, + StageType::Failure, + StageType::MarketAction, + StageType::Efficiency, + StageType::DecisionDrivers, + StageType::Behavior, +]; |