// Map, wardley map editor for linux
// Copyright (C) 2026 Rubén Beltrán del Río
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
//! Dialog helper functions for file operations and error display.
use std::path::PathBuf;
use gtk::prelude::*;
use relm4::{ComponentSender, gtk};
use crate::AppModel;
use crate::actions::{Action, ImageFormat};
use crate::constants;
use crate::file_registry;
use crate::tr;
/// 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));
filter.add_mime_type(constants::MIME_TYPE);
filter.set_name(Some(&tr!("dialog.file_type.wmap")));
filter
}
/// Shows an error dialog with the given title and message.
pub fn show_error(window: >k::Window, title: &str, message: &str) {
let dialog = gtk::MessageDialog::new(
Some(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();
}
/// Shows a file open dialog and handles the response.
pub fn show_open_dialog(
window: >k::Window,
is_empty_document: bool,
sender: ComponentSender,
) {
let dialog = gtk::FileChooserDialog::new(
Some(&tr!("dialog.open.title")),
Some(window),
gtk::FileChooserAction::Open,
&[
(&tr!("dialog.action.cancel"), gtk::ResponseType::Cancel),
(&tr!("dialog.action.open"), gtk::ResponseType::Accept),
],
);
dialog.add_filter(&create_file_filter());
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) = file_registry::find_window_for_file(&path) {
existing_window.present();
return;
}
if is_empty_document {
sender.input(Action::LoadFile(path));
} else {
AppModel::open_new_window(Some(path), None);
}
}
});
dialog.present();
}
/// Shows a file save dialog and handles the response.
pub fn show_save_dialog(
window: >k::Window,
current_file: Option<&PathBuf>,
close_after: bool,
sender: ComponentSender,
) {
let dialog = gtk::FileChooserDialog::new(
Some(&tr!("dialog.save.title")),
Some(window),
gtk::FileChooserAction::Save,
&[
(&tr!("dialog.action.cancel"), gtk::ResponseType::Cancel),
(&tr!("dialog.action.save"), gtk::ResponseType::Accept),
],
);
dialog.add_filter(&create_file_filter());
let default_filename = "map.wmap";
if let Some(current_path) = current_file {
if let Some(parent) = current_path.parent() {
let folder = gtk::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(default_filename));
}
} else {
dialog.set_current_name(default_filename);
}
dialog.connect_response(move |dialog, response| {
dialog.close();
if response == gtk::ResponseType::Accept
&& let Some(file) = dialog.file()
&& let Some(path) = file.path()
{
sender.input(Action::SaveToFile { path, close_after });
}
});
dialog.present();
}
/// Shows a dialog asking whether to save unsaved changes before closing.
pub fn show_unsaved_changes_dialog(
window: >k::Window,
current_file: Option,
sender: ComponentSender,
) {
let dialog = gtk::MessageDialog::new(
Some(window),
gtk::DialogFlags::MODAL | gtk::DialogFlags::DESTROY_WITH_PARENT,
gtk::MessageType::Warning,
gtk::ButtonsType::None,
tr!("dialog.unsaved_changes.message"),
);
dialog.set_title(Some(&tr!("dialog.unsaved_changes.title")));
dialog.add_button(&tr!("dialog.action.dont_save"), gtk::ResponseType::Reject);
dialog.add_button(&tr!("dialog.action.cancel"), gtk::ResponseType::Cancel);
dialog.add_button(&tr!("dialog.action.save"), gtk::ResponseType::Accept);
let window = window.clone();
dialog.connect_response(move |dialog, response| {
dialog.close();
match response {
gtk::ResponseType::Accept => {
if current_file.is_some() {
sender.input(Action::Save { close_after: true });
} else {
sender.input(Action::SaveAs { close_after: true });
}
}
gtk::ResponseType::Reject => {
if let Some(ref path) = current_file {
file_registry::unregister_file(path);
}
window.destroy();
}
_ => {}
}
});
dialog.show();
}
/// Creates a file filter for an image format.
fn create_image_filter(format: ImageFormat) -> gtk::FileFilter {
let filter = gtk::FileFilter::new();
filter.add_pattern(&format!("*.{}", format.extension()));
filter.add_mime_type(format.mime_type());
let name = match format {
ImageFormat::Png => tr!("dialog.file_type.png"),
ImageFormat::Svg => tr!("dialog.file_type.svg"),
};
filter.set_name(Some(&name));
filter
}
/// Shows an export image dialog and handles the response.
pub fn show_export_dialog(
window: >k::Window,
current_file: Option<&PathBuf>,
sender: ComponentSender,
) {
let dialog = gtk::FileChooserDialog::new(
Some(&tr!("dialog.export.title")),
Some(window),
gtk::FileChooserAction::Save,
&[
(&tr!("dialog.action.cancel"), gtk::ResponseType::Cancel),
(&tr!("dialog.action.export"), gtk::ResponseType::Accept),
],
);
let png_filter = create_image_filter(ImageFormat::Png);
let svg_filter = create_image_filter(ImageFormat::Svg);
dialog.add_filter(&png_filter);
dialog.add_filter(&svg_filter);
// Set default filename based on current file
let base_name = current_file
.as_ref()
.and_then(|p| p.file_stem())
.and_then(|s| s.to_str())
.unwrap_or("map")
.to_string();
if let Some(current_path) = current_file
&& let Some(parent) = current_path.parent()
{
let folder = gtk::gio::File::for_path(parent);
dialog.set_current_folder(Some(&folder)).ok();
}
dialog.set_current_name(&format!("{base_name}.png"));
// Update extension when filter changes
{
let png_filter_ref = png_filter.clone();
let base_name_clone = base_name.clone();
dialog.connect_notify_local(Some("filter"), move |dialog, _| {
let ext = if dialog.filter().as_ref() == Some(&png_filter_ref) {
"png"
} else {
"svg"
};
dialog.set_current_name(&format!("{base_name_clone}.{ext}"));
});
}
let png_filter_clone = png_filter.clone();
dialog.connect_response(move |dialog, response| {
dialog.close();
if response == gtk::ResponseType::Accept
&& let Some(file) = dialog.file()
&& let Some(path) = file.path()
{
// Determine format from selected filter or file extension
let format = if dialog.filter().as_ref() == Some(&png_filter_clone) {
ImageFormat::Png
} else if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
match ext.to_lowercase().as_str() {
"svg" => ImageFormat::Svg,
_ => ImageFormat::Png,
}
} else {
ImageFormat::Png
};
// Ensure correct extension
let path = if path.extension().is_none() {
path.with_extension(format.extension())
} else {
path
};
sender.input(Action::ExportToFile { path, format });
}
});
dialog.present();
}