use std::path::{Path, PathBuf};
use thiserror::Error;
#[derive(Clone, Debug, Error, PartialEq, Eq)]
#[error("invalid IGFD filter spec: {message}")]
pub struct IgfdFilterParseError {
message: String,
}
impl IgfdFilterParseError {
fn new(message: impl Into<String>) -> Self {
Self {
message: message.into(),
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum DialogMode {
OpenFile,
OpenFiles,
PickFolder,
SaveFile,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Backend {
Auto,
Native,
ImGui,
}
impl Default for Backend {
fn default() -> Self {
Backend::Auto
}
}
#[derive(Clone, Debug, Default)]
pub struct FileFilter {
pub name: String,
pub extensions: Vec<String>,
}
impl FileFilter {
pub fn new(name: impl Into<String>, exts: impl Into<Vec<String>>) -> Self {
let mut extensions: Vec<String> = exts.into();
for token in &mut extensions {
if is_regex_token(token) {
continue;
}
*token = token.to_lowercase();
}
Self {
name: name.into(),
extensions,
}
}
pub fn parse_igfd(spec: &str) -> Result<Vec<FileFilter>, IgfdFilterParseError> {
let spec = spec.trim();
if spec.is_empty() {
return Ok(Vec::new());
}
let parts = split_igfd_commas(spec);
let mut out: Vec<FileFilter> = Vec::new();
let mut loose_tokens: Vec<String> = Vec::new();
for part in parts {
let part = part.trim();
if part.is_empty() {
continue;
}
if let Some((label, inner)) = parse_igfd_collection(part)? {
if !loose_tokens.is_empty() {
out.push(FileFilter::new(
if out.is_empty() {
spec.to_string()
} else {
"Custom".to_string()
},
std::mem::take(&mut loose_tokens),
));
}
out.push(FileFilter::new(label, inner));
} else {
loose_tokens.push(part.to_string());
}
}
if !loose_tokens.is_empty() {
out.push(FileFilter::new(
if out.is_empty() {
spec.to_string()
} else {
"Custom".to_string()
},
loose_tokens,
));
}
Ok(out)
}
}
impl From<(&str, &[&str])> for FileFilter {
fn from(value: (&str, &[&str])) -> Self {
Self {
name: value.0.to_owned(),
extensions: value
.1
.iter()
.map(|s| {
if is_regex_token(s) {
(*s).to_string()
} else {
s.to_lowercase()
}
})
.collect(),
}
}
}
fn is_regex_token(token: &str) -> bool {
let t = token.trim();
t.starts_with("((") && t.ends_with("))") && t.len() >= 4
}
fn split_igfd_commas(input: &str) -> Vec<&str> {
let bytes = input.as_bytes();
let mut out: Vec<&str> = Vec::new();
let mut start = 0usize;
let mut brace_depth: i32 = 0;
let mut paren_depth: i32 = 0;
let mut i = 0usize;
while i < bytes.len() {
match bytes[i] {
b'{' => brace_depth += 1,
b'}' => brace_depth = (brace_depth - 1).max(0),
b'(' => paren_depth += 1,
b')' => paren_depth = (paren_depth - 1).max(0),
b',' if brace_depth == 0 && paren_depth == 0 => {
out.push(&input[start..i]);
start = i + 1;
}
_ => {}
}
i += 1;
}
out.push(&input[start..]);
out
}
fn parse_igfd_collection(
part: &str,
) -> Result<Option<(String, Vec<String>)>, IgfdFilterParseError> {
let bytes = part.as_bytes();
let mut brace_depth: i32 = 0;
let mut paren_depth: i32 = 0;
let mut open_idx: Option<usize> = None;
let mut close_idx: Option<usize> = None;
let mut i = 0usize;
while i < bytes.len() {
match bytes[i] {
b'{' if brace_depth == 0 && paren_depth == 0 => {
open_idx = Some(i);
brace_depth = 1;
}
b'{' => brace_depth += 1,
b'}' => {
brace_depth = (brace_depth - 1).max(0);
if brace_depth == 0 && open_idx.is_some() {
close_idx = Some(i);
break;
}
}
b'(' => paren_depth += 1,
b')' => paren_depth = (paren_depth - 1).max(0),
_ => {}
}
i += 1;
}
let Some(open) = open_idx else {
return Ok(None);
};
let Some(close) = close_idx else {
return Err(IgfdFilterParseError::new(
"unterminated '{' in filter collection",
));
};
let label = part[..open].trim();
if label.is_empty() {
return Err(IgfdFilterParseError::new(
"collection label is empty (expected 'Name{...}')",
));
}
let tail = part[close + 1..].trim();
if !tail.is_empty() {
return Err(IgfdFilterParseError::new(
"unexpected trailing characters after '}'",
));
}
let inner = part[open + 1..close].trim();
if inner.is_empty() {
return Err(IgfdFilterParseError::new(
"collection has no filters (empty '{...}')",
));
}
let mut tokens: Vec<String> = Vec::new();
for t in split_igfd_commas(inner) {
let t = t.trim();
if t.is_empty() {
continue;
}
tokens.push(t.to_string());
}
if tokens.is_empty() {
return Err(IgfdFilterParseError::new("collection has no filters"));
}
Ok(Some((label.to_string(), tokens)))
}
#[derive(Clone, Debug, Default)]
pub struct Selection {
pub paths: Vec<PathBuf>,
}
impl Selection {
pub fn is_empty(&self) -> bool {
self.paths.is_empty()
}
pub fn len(&self) -> usize {
self.paths.len()
}
pub fn paths(&self) -> &[PathBuf] {
&self.paths
}
pub fn into_paths(self) -> Vec<PathBuf> {
self.paths
}
pub fn file_path_name(&self) -> Option<&Path> {
self.paths.first().map(PathBuf::as_path)
}
pub fn file_name(&self) -> Option<&str> {
self.file_path_name()
.and_then(Path::file_name)
.and_then(|v| v.to_str())
}
pub fn selection_named_paths(&self) -> Vec<(String, PathBuf)> {
self.paths
.iter()
.map(|path| {
let name = path
.file_name()
.and_then(|v| v.to_str())
.map(ToOwned::to_owned)
.unwrap_or_else(|| path.display().to_string());
(name, path.clone())
})
.collect()
}
}
#[derive(Error, Debug)]
pub enum FileDialogError {
#[error("cancelled")]
Cancelled,
#[error("io error: {0}")]
Io(#[from] std::io::Error),
#[error("unsupported operation for backend")]
Unsupported,
#[error("invalid path: {0}")]
InvalidPath(String),
#[error("internal error: {0}")]
Internal(String),
#[error("validation blocked: {0}")]
ValidationBlocked(String),
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ExtensionPolicy {
KeepUser,
AddIfMissing,
ReplaceByFilter,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct SavePolicy {
pub confirm_overwrite: bool,
pub extension_policy: ExtensionPolicy,
}
impl Default for SavePolicy {
fn default() -> Self {
Self {
confirm_overwrite: true,
extension_policy: ExtensionPolicy::AddIfMissing,
}
}
}
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum ClickAction {
Select,
Navigate,
}
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum LayoutStyle {
Standard,
Minimal,
}
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum SortBy {
Name,
Type,
Extension,
Size,
Modified,
}
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum SortMode {
Natural,
Lexicographic,
}
impl Default for SortMode {
fn default() -> Self {
Self::Natural
}
}
#[derive(Clone, Debug)]
pub struct FileDialog {
pub(crate) backend: Backend,
pub(crate) mode: DialogMode,
pub(crate) start_dir: Option<PathBuf>,
pub(crate) default_name: Option<String>,
pub(crate) allow_multi: bool,
pub(crate) max_selection: Option<usize>,
pub(crate) filters: Vec<FileFilter>,
pub(crate) show_hidden: bool,
}
impl FileDialog {
pub fn new(mode: DialogMode) -> Self {
Self {
backend: Backend::Auto,
mode,
start_dir: None,
default_name: None,
allow_multi: matches!(mode, DialogMode::OpenFiles),
max_selection: None,
filters: Vec::new(),
show_hidden: false,
}
}
pub fn backend(mut self, backend: Backend) -> Self {
self.backend = backend;
self
}
pub fn directory(mut self, dir: impl Into<PathBuf>) -> Self {
self.start_dir = Some(dir.into());
self
}
pub fn default_file_name(mut self, name: impl Into<String>) -> Self {
self.default_name = Some(name.into());
self
}
pub fn multi_select(mut self, yes: bool) -> Self {
self.allow_multi = yes;
self
}
pub fn max_selection(mut self, max: usize) -> Self {
self.max_selection = if max == 0 { None } else { Some(max) };
if max == 1 {
self.allow_multi = false;
}
self
}
pub fn show_hidden(mut self, yes: bool) -> Self {
self.show_hidden = yes;
self
}
pub fn filter<F: Into<FileFilter>>(mut self, filter: F) -> Self {
self.filters.push(filter.into());
self
}
pub fn filters<I, F>(mut self, filters: I) -> Self
where
I: IntoIterator<Item = F>,
F: Into<FileFilter>,
{
self.filters.extend(filters.into_iter().map(Into::into));
self
}
pub fn filters_igfd(mut self, spec: impl AsRef<str>) -> Result<Self, IgfdFilterParseError> {
let parsed = FileFilter::parse_igfd(spec.as_ref())?;
self.filters.extend(parsed);
Ok(self)
}
pub(crate) fn effective_backend(&self) -> Backend {
match self.backend {
Backend::Native => Backend::Native,
Backend::ImGui => Backend::ImGui,
Backend::Auto => {
#[cfg(feature = "native-rfd")]
{
Backend::Native
}
#[cfg(not(feature = "native-rfd"))]
{
Backend::ImGui
}
}
}
}
}
#[cfg(not(feature = "native-rfd"))]
impl FileDialog {
pub fn open_blocking(self) -> Result<Selection, FileDialogError> {
Err(FileDialogError::Unsupported)
}
pub async fn open_async(self) -> Result<Selection, FileDialogError> {
Err(FileDialogError::Unsupported)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn file_filter_new_normalizes_extensions_to_lowercase_but_preserves_regex() {
let f = FileFilter::new(
"Images",
vec![
"PNG".to_string(),
"Jpg".to_string(),
"gif".to_string(),
"((\\p{Lu}+))".to_string(),
],
);
assert_eq!(f.extensions, vec!["png", "jpg", "gif", "((\\p{Lu}+))"]);
}
#[test]
fn parse_igfd_simple_list_becomes_single_filter() {
let v = FileFilter::parse_igfd(".cpp,.h,.hpp").unwrap();
assert_eq!(v.len(), 1);
assert_eq!(v[0].name, ".cpp,.h,.hpp");
assert_eq!(v[0].extensions, vec![".cpp", ".h", ".hpp"]);
}
#[test]
fn parse_igfd_collections_build_multiple_filters() {
let v = FileFilter::parse_igfd("C/C++{.c,.cpp,.h},Rust{.rs}").unwrap();
assert_eq!(v.len(), 2);
assert_eq!(v[0].name, "C/C++");
assert_eq!(v[0].extensions, vec![".c", ".cpp", ".h"]);
assert_eq!(v[1].name, "Rust");
assert_eq!(v[1].extensions, vec![".rs"]);
}
#[test]
fn parse_igfd_does_not_split_commas_inside_parentheses() {
let v = FileFilter::parse_igfd("C files(png, jpg){.png,.jpg}").unwrap();
assert_eq!(v.len(), 1);
assert_eq!(v[0].name, "C files(png, jpg)");
}
#[test]
fn parse_igfd_regex_token_can_contain_commas() {
let v = FileFilter::parse_igfd("Rx{((a,b)),.txt}").unwrap();
assert_eq!(v.len(), 1);
assert_eq!(v[0].extensions, vec!["((a,b))", ".txt"]);
}
#[test]
fn selection_convenience_accessors_for_single_path() {
let sel = Selection {
paths: vec![PathBuf::from("/tmp/demo.txt")],
};
assert!(!sel.is_empty());
assert_eq!(sel.len(), 1);
assert_eq!(sel.file_name(), Some("demo.txt"));
assert_eq!(sel.file_path_name(), Some(Path::new("/tmp/demo.txt")));
assert_eq!(sel.paths(), &[PathBuf::from("/tmp/demo.txt")]);
}
#[test]
fn selection_named_paths_for_multi_selection() {
let sel = Selection {
paths: vec![PathBuf::from("/a/one.txt"), PathBuf::from("/b/two.bin")],
};
let pairs = sel.selection_named_paths();
assert_eq!(pairs.len(), 2);
assert_eq!(pairs[0].0, "one.txt");
assert_eq!(pairs[0].1, PathBuf::from("/a/one.txt"));
assert_eq!(pairs[1].0, "two.bin");
assert_eq!(pairs[1].1, PathBuf::from("/b/two.bin"));
}
#[test]
fn selection_into_paths_moves_owned_paths() {
let sel = Selection {
paths: vec![PathBuf::from("a"), PathBuf::from("b")],
};
let out = sel.into_paths();
assert_eq!(out, vec![PathBuf::from("a"), PathBuf::from("b")]);
}
}