aboutsummaryrefslogtreecommitdiff
path: root/src/main.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/main.rs')
-rw-r--r--src/main.rs436
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: &gtk::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,
+ });
}