aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/constants.rs9
-rw-r--r--src/dialogs.rs4
-rw-r--r--src/handlers/file.rs100
-rw-r--r--src/handlers/mod.rs3
-rw-r--r--src/handlers/view.rs20
-rw-r--r--src/handlers/zoom.rs25
-rw-r--r--src/main.rs278
-rw-r--r--src/stages.rs48
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,
+];