use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Ask {
OpenProject,
SaveProject,
OpenArchive,
SavePdf,
SaveGeoJson,
SaveCsv,
}
impl Ask {
pub(crate) fn is_open(self) -> bool {
matches!(self, Self::OpenProject | Self::OpenArchive)
}
fn title(self) -> &'static str {
match self {
Self::OpenProject => "Open project",
Self::SaveProject => "Save project as",
Self::OpenArchive => "Open tile archive",
Self::SavePdf => "Export PDF to",
Self::SaveGeoJson => "Export GeoJSON to",
Self::SaveCsv => "Export CSV to",
}
}
fn verb(self) -> &'static str {
if self.is_open() { "Open" } else { "Save" }
}
fn filter(self) -> (&'static str, &'static [&'static str]) {
match self {
Self::OpenProject | Self::SaveProject => ("OxiGIS project", &["json"]),
Self::OpenArchive => ("Tile archives", &["pmtiles", "mbtiles"]),
Self::SavePdf => ("PDF document", &["pdf"]),
Self::SaveGeoJson => ("GeoJSON", &["geojson", "json"]),
Self::SaveCsv => ("Comma-separated values", &["csv"]),
}
}
fn hint(self) -> &'static str {
match self {
Self::OpenProject => "The .oxigis.json project to open",
Self::SaveProject => "Where to write the project (.oxigis.json)",
Self::OpenArchive => "The .pmtiles or .mbtiles archive to open",
Self::SavePdf => "Where to write the exported page (.pdf)",
Self::SaveGeoJson => "Where to write the features (.geojson)",
Self::SaveCsv => "Where to write the rows (.csv)",
}
}
}
pub(crate) const NATIVE_DIALOGS: bool = cfg!(any(windows, target_os = "macos"));
#[cfg(any(windows, target_os = "macos"))]
pub(crate) fn ask_native(
ask: Ask,
suggested_name: &str,
directory: Option<&Path>,
) -> Option<PathBuf> {
let (label, extensions) = ask.filter();
let mut dialog = rfd::FileDialog::new()
.set_title(ask.title())
.add_filter(label, extensions);
if let Some(directory) = directory.filter(|dir| dir.is_dir()) {
dialog = dialog.set_directory(directory);
}
if ask.is_open() {
dialog.pick_file()
} else {
dialog.set_file_name(suggested_name).save_file()
}
}
#[cfg(not(any(windows, target_os = "macos")))]
pub(crate) fn ask_native(
_ask: Ask,
_suggested_name: &str,
_directory: Option<&Path>,
) -> Option<PathBuf> {
None
}
pub(crate) struct PathPrompt {
ask: Ask,
buffer: String,
base: PathBuf,
error: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum PromptOutcome {
Pending,
Accepted(PathBuf),
Cancelled,
}
impl PathPrompt {
pub(crate) fn new(ask: Ask, suggested_name: &str, base: PathBuf) -> Self {
let buffer = if ask.is_open() {
String::new()
} else {
base.join(suggested_name).display().to_string()
};
Self {
ask,
buffer,
base,
error: None,
}
}
pub(crate) fn ask(&self) -> Ask {
self.ask
}
pub(crate) fn reopen_with_error(&mut self, error: impl Into<String>) {
self.error = Some(error.into());
}
pub(crate) fn show(&mut self, ctx: &egui::Context) -> PromptOutcome {
let mut outcome = PromptOutcome::Pending;
egui::Window::new(self.ask.title())
.collapsible(false)
.resizable(false)
.anchor(egui::Align2::CENTER_CENTER, egui::Vec2::ZERO)
.show(ctx, |ui| {
ui.label(self.ask.hint());
let entry = ui.add(
egui::TextEdit::singleline(&mut self.buffer)
.desired_width(480.0)
.hint_text(self.ask.hint()),
);
match resolve(&self.buffer, &self.base) {
Some(resolved) => {
ui.weak(resolved.display().to_string());
}
None => {
ui.weak("\u{2014}");
}
}
if let Some(error) = &self.error {
ui.colored_label(egui::Color32::from_rgb(220, 80, 80), error);
}
ui.weak(
"This platform has no Pure-Rust system file dialog, so OxiGIS asks here \
instead. Drag-and-drop still works for data files.",
);
ui.separator();
let submitted =
entry.lost_focus() && ui.input(|input| input.key_pressed(egui::Key::Enter));
ui.horizontal(|ui| {
if ui.button(self.ask.verb()).clicked() || submitted {
match validate(&self.buffer, self.ask, &self.base) {
Ok(path) => outcome = PromptOutcome::Accepted(path),
Err(error) => self.error = Some(error),
}
}
if ui.button("Cancel").clicked() {
outcome = PromptOutcome::Cancelled;
}
});
});
if outcome == PromptOutcome::Pending
&& ctx.input(|input| input.key_pressed(egui::Key::Escape))
{
outcome = PromptOutcome::Cancelled;
}
outcome
}
}
pub(crate) fn resolve(input: &str, base: &Path) -> Option<PathBuf> {
let trimmed = input.trim();
if trimmed.is_empty() {
return None;
}
let expanded = expand_home(trimmed);
if expanded.is_absolute() {
return Some(expanded);
}
Some(base.join(expanded))
}
fn expand_home(input: &str) -> PathBuf {
let Some(rest) = input.strip_prefix('~') else {
return PathBuf::from(input);
};
if !(rest.is_empty() || rest.starts_with('/') || rest.starts_with('\\')) {
return PathBuf::from(input);
}
let Some(home) = home_dir() else {
return PathBuf::from(input);
};
let rest = rest.trim_start_matches(['/', '\\']);
if rest.is_empty() {
home
} else {
home.join(rest)
}
}
pub(crate) fn validate(input: &str, ask: Ask, base: &Path) -> Result<PathBuf, String> {
let Some(path) = resolve(input, base) else {
return Err("Type a file path first.".to_string());
};
if ask.is_open() {
if !path.exists() {
return Err(format!("{} does not exist.", path.display()));
}
if path.is_dir() {
return Err(format!("{} is a directory, not a file.", path.display()));
}
return Ok(path);
}
if path.is_dir() {
return Err(format!(
"{} is a directory — give the file a name too.",
path.display()
));
}
match path.parent() {
None => Err(format!("{} is not a file to write.", path.display())),
Some(parent) if parent.as_os_str().is_empty() || parent.is_dir() => Ok(path),
Some(parent) => Err(format!("The folder {} does not exist.", parent.display())),
}
}
pub(crate) fn home_dir() -> Option<PathBuf> {
for key in ["HOME", "USERPROFILE"] {
if let Some(value) = std::env::var_os(key)
&& !value.is_empty()
{
return Some(PathBuf::from(value));
}
}
None
}
pub(crate) fn default_directory(last_used: Option<&Path>) -> PathBuf {
if let Some(directory) = last_used.filter(|dir| dir.is_dir()) {
return directory.to_path_buf();
}
if let Ok(directory) = std::env::current_dir() {
return directory;
}
home_dir().unwrap_or_else(|| PathBuf::from("."))
}
#[cfg(test)]
mod tests {
use super::*;
fn scratch_dir(label: &str) -> PathBuf {
let stamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |elapsed| elapsed.as_nanos());
let path = std::env::temp_dir().join(format!("oxigis-dialog-{label}-{stamp}"));
std::fs::create_dir_all(&path).expect("the scratch directory is creatable");
path
}
#[test]
fn a_relative_path_resolves_against_the_base_and_an_absolute_one_does_not() {
let base = PathBuf::from("/data/projects");
assert_eq!(
resolve("city.oxigis.json", &base),
Some(PathBuf::from("/data/projects/city.oxigis.json")),
);
let absolute = if cfg!(windows) {
r"C:\x.json"
} else {
"/x.json"
};
assert_eq!(resolve(absolute, &base), Some(PathBuf::from(absolute)));
assert_eq!(resolve(" ", &base), None, "an empty box has no path");
}
#[test]
fn a_leading_tilde_expands_and_a_bare_one_is_the_home_directory() {
let Some(home) = home_dir() else {
return; };
let base = PathBuf::from("/elsewhere");
assert_eq!(resolve("~", &base), Some(home.clone()));
assert_eq!(
resolve("~/maps/a.json", &base),
Some(home.join("maps/a.json"))
);
assert_eq!(
resolve("~someone/a.json", &base),
Some(base.join("~someone/a.json")),
);
}
#[test]
fn opening_refuses_what_is_not_there_and_saving_refuses_a_missing_folder() {
let dir = scratch_dir("validate");
let file = dir.join("cities.oxigis.json");
std::fs::write(&file, b"{}").expect("the fixture is writable");
assert_eq!(
validate("cities.oxigis.json", Ask::OpenProject, &dir),
Ok(file.clone()),
);
let missing = validate("absent.oxigis.json", Ask::OpenProject, &dir)
.expect_err("an absent file cannot be opened");
assert!(missing.contains("does not exist"), "{missing}");
let is_dir =
validate(".", Ask::OpenProject, &dir).expect_err("a directory is not a project");
assert!(is_dir.contains("directory"), "{is_dir}");
assert_eq!(
validate("fresh.oxigis.json", Ask::SaveProject, &dir),
Ok(dir.join("fresh.oxigis.json")),
);
let no_folder = validate("nope/fresh.oxigis.json", Ask::SaveProject, &dir)
.expect_err("the folder is not there");
assert!(no_folder.contains("does not exist"), "{no_folder}");
let blank = validate(" ", Ask::SavePdf, &dir).expect_err("an empty box is refused");
assert!(blank.contains("file path"), "{blank}");
let onto_dir =
validate(".", Ask::SaveProject, &dir).expect_err("a directory is not a file to write");
assert!(onto_dir.contains("directory"), "{onto_dir}");
let _removed = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_save_prompt_is_seeded_with_the_suggested_name_and_an_open_prompt_is_blank() {
let base = PathBuf::from("/data");
let save = PathPrompt::new(Ask::SaveProject, "city.oxigis.json", base.clone());
assert_eq!(save.ask(), Ask::SaveProject);
assert_eq!(
resolve(&save.buffer, &base),
Some(base.join("city.oxigis.json")),
);
let open = PathPrompt::new(Ask::OpenProject, "ignored", base.clone());
assert!(
open.buffer.is_empty(),
"there is nothing to suggest opening"
);
}
#[test]
fn the_default_directory_falls_back_rather_than_failing() {
let dir = scratch_dir("default");
assert_eq!(default_directory(Some(&dir)), dir);
let gone = dir.join("removed");
assert_ne!(
default_directory(Some(&gone)),
gone,
"a directory that is not there is not where a dialog should open",
);
assert!(!default_directory(None).as_os_str().is_empty());
let _removed = std::fs::remove_dir_all(&dir);
}
#[test]
fn every_ask_names_itself_and_filters_for_its_own_extensions() {
for ask in [
Ask::OpenProject,
Ask::SaveProject,
Ask::OpenArchive,
Ask::SavePdf,
] {
assert!(!ask.title().is_empty());
assert!(!ask.hint().is_empty());
let (label, extensions) = ask.filter();
assert!(!label.is_empty());
assert!(!extensions.is_empty(), "{label} filters nothing");
assert!(
extensions.iter().all(|ext| !ext.starts_with('.')),
"rfd wants bare extensions",
);
}
assert_eq!(Ask::OpenArchive.verb(), "Open");
assert_eq!(Ask::SavePdf.verb(), "Save");
assert!(Ask::OpenProject.is_open());
assert!(!Ask::SaveProject.is_open());
}
}