1use crate::CapabilitySupport;
8use std::{
9 collections::HashSet,
10 fmt,
11 path::{Path, PathBuf},
12 sync::Arc,
13};
14
15#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
18pub struct FileDialogRequestId(u64);
19
20impl FileDialogRequestId {
21 #[must_use]
22 pub const fn new(value: u64) -> Self {
23 Self(value)
24 }
25
26 #[must_use]
27 pub const fn get(self) -> u64 {
28 self.0
29 }
30}
31
32#[derive(Clone, Debug, PartialEq, Eq, Hash)]
36pub struct DocumentDescriptor {
37 path: PathBuf,
38}
39
40impl DocumentDescriptor {
41 #[must_use]
42 pub fn new(path: impl Into<PathBuf>) -> Self {
43 Self { path: path.into() }
44 }
45
46 #[must_use]
47 pub fn path(&self) -> &Path {
48 &self.path
49 }
50
51 #[must_use]
52 pub fn into_path(self) -> PathBuf {
53 self.path
54 }
55}
56
57impl From<PathBuf> for DocumentDescriptor {
58 fn from(value: PathBuf) -> Self {
59 Self::new(value)
60 }
61}
62
63impl From<DocumentDescriptor> for PathBuf {
64 fn from(value: DocumentDescriptor) -> Self {
65 value.into_path()
66 }
67}
68
69#[derive(Clone, Debug, Default, PartialEq, Eq)]
73pub struct DocumentActivation {
74 documents: Arc<[DocumentDescriptor]>,
75}
76
77impl DocumentActivation {
78 #[must_use]
79 pub fn new(documents: impl IntoIterator<Item = DocumentDescriptor>) -> Self {
80 Self {
81 documents: documents.into_iter().collect::<Vec<_>>().into(),
82 }
83 }
84
85 #[must_use]
86 pub fn from_paths(paths: impl IntoIterator<Item = impl Into<PathBuf>>) -> Self {
87 Self::new(
88 paths
89 .into_iter()
90 .map(|path| DocumentDescriptor::new(path.into())),
91 )
92 }
93
94 #[must_use]
95 pub fn documents(&self) -> &[DocumentDescriptor] {
96 &self.documents
97 }
98
99 #[must_use]
100 pub fn is_empty(&self) -> bool {
101 self.documents.is_empty()
102 }
103}
104
105#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
108pub struct FileDialogFilterId(String);
109
110impl FileDialogFilterId {
111 pub fn new(value: impl Into<String>) -> Result<Self, FileDialogValidationError> {
112 let value = value.into();
113 if !valid_identifier(&value) {
114 return Err(FileDialogValidationError::InvalidFilterId(value));
115 }
116 Ok(Self(value))
117 }
118
119 #[must_use]
120 pub fn as_str(&self) -> &str {
121 &self.0
122 }
123}
124
125impl fmt::Display for FileDialogFilterId {
126 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
127 formatter.write_str(&self.0)
128 }
129}
130
131#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
133pub struct FileExtension(String);
134
135impl FileExtension {
136 pub fn new(value: impl AsRef<str>) -> Result<Self, FileDialogValidationError> {
137 let original = value.as_ref();
138 let normalized = original.trim().trim_start_matches('.').to_ascii_lowercase();
139 if normalized.is_empty()
140 || normalized.chars().any(|character| {
141 character.is_control()
142 || character.is_whitespace()
143 || matches!(character, '/' | '\\' | '*' | '?' | '[' | ']')
144 })
145 {
146 return Err(FileDialogValidationError::InvalidExtension(
147 original.to_owned(),
148 ));
149 }
150 Ok(Self(normalized))
151 }
152
153 #[must_use]
154 pub fn as_str(&self) -> &str {
155 &self.0
156 }
157}
158
159#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
161pub enum FileContentTypeKind {
162 Mime,
164 TypeIdentifier,
167}
168
169#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
173pub struct FileContentType {
174 value: String,
175 kind: FileContentTypeKind,
176}
177
178impl FileContentType {
179 pub fn new(value: impl AsRef<str>) -> Result<Self, FileDialogValidationError> {
180 let original = value.as_ref();
181 let trimmed = original.trim();
182 if trimmed.is_empty()
183 || trimmed.chars().any(|character| {
184 character.is_control() || character.is_whitespace() || character == '\0'
185 })
186 {
187 return Err(FileDialogValidationError::InvalidContentType(
188 original.to_owned(),
189 ));
190 }
191 let (normalized, kind) = if trimmed.contains('/') {
192 let mut parts = trimmed.split('/');
193 let Some(kind) = parts.next() else {
194 unreachable!()
195 };
196 let Some(subtype) = parts.next() else {
197 return Err(FileDialogValidationError::InvalidContentType(
198 original.to_owned(),
199 ));
200 };
201 if parts.next().is_some() || kind.is_empty() || subtype.is_empty() {
202 return Err(FileDialogValidationError::InvalidContentType(
203 original.to_owned(),
204 ));
205 }
206 (trimmed.to_ascii_lowercase(), FileContentTypeKind::Mime)
207 } else {
208 (trimmed.to_owned(), FileContentTypeKind::TypeIdentifier)
209 };
210 Ok(Self {
211 value: normalized,
212 kind,
213 })
214 }
215
216 #[must_use]
217 pub fn as_str(&self) -> &str {
218 &self.value
219 }
220
221 #[must_use]
222 pub const fn kind(&self) -> FileContentTypeKind {
223 self.kind
224 }
225}
226
227#[derive(Clone, Debug, PartialEq, Eq)]
229pub struct FileDialogFilter {
230 id: FileDialogFilterId,
231 label: Option<String>,
232 extensions: Arc<[FileExtension]>,
233 content_types: Arc<[FileContentType]>,
234}
235
236impl FileDialogFilter {
237 pub fn new(
238 id: FileDialogFilterId,
239 label: Option<String>,
240 extensions: impl IntoIterator<Item = FileExtension>,
241 content_types: impl IntoIterator<Item = FileContentType>,
242 ) -> Result<Self, FileDialogValidationError> {
243 if label
244 .as_ref()
245 .is_some_and(|label| label.trim().is_empty() || label.chars().any(char::is_control))
246 {
247 return Err(FileDialogValidationError::InvalidFilterLabel);
248 }
249 let extensions = deduplicate(extensions);
250 let content_types = deduplicate(content_types);
251 if extensions.is_empty() && content_types.is_empty() {
252 return Err(FileDialogValidationError::EmptyFilter(id));
253 }
254 Ok(Self {
255 id,
256 label,
257 extensions: extensions.into(),
258 content_types: content_types.into(),
259 })
260 }
261
262 pub fn extensions(
263 id: impl Into<String>,
264 label: impl Into<String>,
265 extensions: impl IntoIterator<Item = impl AsRef<str>>,
266 ) -> Result<Self, FileDialogValidationError> {
267 let extensions = extensions
268 .into_iter()
269 .map(FileExtension::new)
270 .collect::<Result<Vec<_>, _>>()?;
271 Self::new(
272 FileDialogFilterId::new(id)?,
273 Some(label.into()),
274 extensions,
275 [],
276 )
277 }
278
279 pub fn content_types(
280 id: impl Into<String>,
281 label: impl Into<String>,
282 content_types: impl IntoIterator<Item = impl AsRef<str>>,
283 ) -> Result<Self, FileDialogValidationError> {
284 let content_types = content_types
285 .into_iter()
286 .map(FileContentType::new)
287 .collect::<Result<Vec<_>, _>>()?;
288 Self::new(
289 FileDialogFilterId::new(id)?,
290 Some(label.into()),
291 [],
292 content_types,
293 )
294 }
295
296 #[must_use]
297 pub const fn id(&self) -> &FileDialogFilterId {
298 &self.id
299 }
300
301 #[must_use]
302 pub fn label(&self) -> Option<&str> {
303 self.label.as_deref()
304 }
305
306 #[must_use]
307 pub fn extensions_list(&self) -> &[FileExtension] {
308 &self.extensions
309 }
310
311 #[must_use]
312 pub fn content_types_list(&self) -> &[FileContentType] {
313 &self.content_types
314 }
315}
316
317fn deduplicate<T>(values: impl IntoIterator<Item = T>) -> Vec<T>
318where
319 T: Clone + Eq + std::hash::Hash,
320{
321 let mut seen = HashSet::new();
322 values
323 .into_iter()
324 .filter(|value| seen.insert(value.clone()))
325 .collect()
326}
327
328fn valid_identifier(value: &str) -> bool {
329 !value.is_empty()
330 && value.trim() == value
331 && !value
332 .chars()
333 .any(|character| character.is_control() || character.is_whitespace())
334}
335
336#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
337pub enum FileDialogValidationError {
338 #[error("invalid file-dialog filter id {0:?}")]
339 InvalidFilterId(String),
340 #[error("file-dialog filter label must be non-empty printable text")]
341 InvalidFilterLabel,
342 #[error("invalid file extension {0:?}")]
343 InvalidExtension(String),
344 #[error("invalid file content type {0:?}")]
345 InvalidContentType(String),
346 #[error("file-dialog filter {0} has no extensions or content types")]
347 EmptyFilter(FileDialogFilterId),
348 #[error("duplicate file-dialog filter id {0}")]
349 DuplicateFilterId(FileDialogFilterId),
350 #[error("suggested file name must be a single non-empty file name")]
351 InvalidSuggestedFileName,
352 #[error("file-dialog option {0:?} does not apply to this dialog kind")]
353 OptionNotApplicable(FileDialogOption),
354}
355
356#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
358pub enum FileDialogKind {
359 OpenFile,
360 OpenFiles,
361 SaveFile,
362 SelectFolder,
363}
364
365#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
367pub enum FileDialogOption {
368 ExtensionFilters,
369 MimeTypeFilters,
370 TypeIdentifierFilters,
371 SuggestedFileName,
372 SuggestedDirectory,
373 ParentWindow,
374}
375
376#[derive(Clone, Debug, Default, PartialEq, Eq)]
378pub struct FileDialogOptions {
379 title: Option<String>,
380 filters: Arc<[FileDialogFilter]>,
381 suggested_file_name: Option<String>,
382 suggested_directory: Option<PathBuf>,
383}
384
385impl FileDialogOptions {
386 #[must_use]
387 pub fn new() -> Self {
388 Self::default()
389 }
390
391 #[must_use]
392 pub fn title(mut self, title: impl Into<String>) -> Self {
393 self.title = Some(title.into());
394 self
395 }
396
397 pub fn filters(
398 mut self,
399 filters: impl IntoIterator<Item = FileDialogFilter>,
400 ) -> Result<Self, FileDialogValidationError> {
401 let filters = filters.into_iter().collect::<Vec<_>>();
402 let mut ids = HashSet::with_capacity(filters.len());
403 for filter in &filters {
404 if !ids.insert(filter.id.clone()) {
405 return Err(FileDialogValidationError::DuplicateFilterId(
406 filter.id.clone(),
407 ));
408 }
409 }
410 self.filters = filters.into();
411 Ok(self)
412 }
413
414 pub fn suggested_file_name(
415 mut self,
416 name: impl Into<String>,
417 ) -> Result<Self, FileDialogValidationError> {
418 let name = name.into();
419 if name.trim().is_empty()
420 || name.chars().any(char::is_control)
421 || name.contains(['/', '\\'])
422 || matches!(name.as_str(), "." | "..")
423 {
424 return Err(FileDialogValidationError::InvalidSuggestedFileName);
425 }
426 self.suggested_file_name = Some(name);
427 Ok(self)
428 }
429
430 #[must_use]
431 pub fn suggested_directory(mut self, directory: impl Into<PathBuf>) -> Self {
432 self.suggested_directory = Some(directory.into());
433 self
434 }
435
436 #[must_use]
437 pub fn title_value(&self) -> Option<&str> {
438 self.title.as_deref()
439 }
440
441 #[must_use]
442 pub fn filters_value(&self) -> &[FileDialogFilter] {
443 &self.filters
444 }
445
446 #[must_use]
447 pub fn suggested_file_name_value(&self) -> Option<&str> {
448 self.suggested_file_name.as_deref()
449 }
450
451 #[must_use]
452 pub fn suggested_directory_value(&self) -> Option<&Path> {
453 self.suggested_directory.as_deref()
454 }
455}
456
457#[derive(Clone, Debug, PartialEq, Eq)]
459pub struct FileDialogRequest {
460 pub kind: FileDialogKind,
461 pub options: FileDialogOptions,
462}
463
464impl FileDialogRequest {
465 pub fn new(
466 kind: FileDialogKind,
467 options: FileDialogOptions,
468 ) -> Result<Self, FileDialogValidationError> {
469 if kind == FileDialogKind::SelectFolder {
470 for filter in options.filters.iter() {
471 if !filter.extensions.is_empty() {
472 return Err(FileDialogValidationError::OptionNotApplicable(
473 FileDialogOption::ExtensionFilters,
474 ));
475 }
476 if let Some(content_type) = filter.content_types.first() {
477 let option = match content_type.kind() {
478 FileContentTypeKind::Mime => FileDialogOption::MimeTypeFilters,
479 FileContentTypeKind::TypeIdentifier => {
480 FileDialogOption::TypeIdentifierFilters
481 }
482 };
483 return Err(FileDialogValidationError::OptionNotApplicable(option));
484 }
485 }
486 }
487 if kind != FileDialogKind::SaveFile && options.suggested_file_name.is_some() {
488 return Err(FileDialogValidationError::OptionNotApplicable(
489 FileDialogOption::SuggestedFileName,
490 ));
491 }
492 Ok(Self { kind, options })
493 }
494}
495
496#[derive(Clone, Debug, PartialEq, Eq)]
498pub enum FileDialogSelection {
499 Documents(DocumentActivation),
500 Folder(PathBuf),
501}
502
503#[derive(Clone, Debug, PartialEq, Eq)]
506pub enum FileDialogOutcome {
507 Selected(FileDialogSelection),
508 Cancelled,
509}
510
511#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
512pub enum FileDialogError {
513 #[error("invalid file-dialog request: {0}")]
514 InvalidRequest(FileDialogValidationError),
515 #[error("file-dialog parent window closed")]
516 ParentClosed,
517 #[error("file-dialog application service stopped")]
518 ApplicationStopped,
519 #[error("file-dialog operation {0:?} is unsupported")]
520 UnsupportedOperation(FileDialogKind),
521 #[error("file-dialog option {0:?} is unsupported")]
522 UnsupportedOption(FileDialogOption),
523 #[error("file-dialog backend returned a result incompatible with its request")]
524 InvalidResult,
525 #[error("file-dialog backend failed: {0}")]
526 Backend(String),
527}
528
529impl From<FileDialogValidationError> for FileDialogError {
530 fn from(value: FileDialogValidationError) -> Self {
531 Self::InvalidRequest(value)
532 }
533}
534
535#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
539pub struct FileDialogCapabilities {
540 pub open_file: CapabilitySupport,
541 pub open_files: CapabilitySupport,
542 pub save_file: CapabilitySupport,
543 pub select_folder: CapabilitySupport,
544 pub extension_filters: CapabilitySupport,
545 pub mime_type_filters: CapabilitySupport,
546 pub type_identifier_filters: CapabilitySupport,
547 pub suggested_file_name: CapabilitySupport,
548 pub suggested_directory: CapabilitySupport,
549 pub parent_window: CapabilitySupport,
550}
551
552impl FileDialogCapabilities {
553 #[must_use]
554 pub const fn all(support: CapabilitySupport) -> Self {
555 Self {
556 open_file: support,
557 open_files: support,
558 save_file: support,
559 select_folder: support,
560 extension_filters: support,
561 mime_type_filters: support,
562 type_identifier_filters: support,
563 suggested_file_name: support,
564 suggested_directory: support,
565 parent_window: support,
566 }
567 }
568
569 #[must_use]
570 pub const fn operation(self, kind: FileDialogKind) -> CapabilitySupport {
571 match kind {
572 FileDialogKind::OpenFile => self.open_file,
573 FileDialogKind::OpenFiles => self.open_files,
574 FileDialogKind::SaveFile => self.save_file,
575 FileDialogKind::SelectFolder => self.select_folder,
576 }
577 }
578
579 pub fn validate_request(&self, request: &FileDialogRequest) -> Result<(), FileDialogError> {
580 if self.operation(request.kind) != CapabilitySupport::Supported {
581 return Err(FileDialogError::UnsupportedOperation(request.kind));
582 }
583 if self.parent_window != CapabilitySupport::Supported {
584 return Err(FileDialogError::UnsupportedOption(
585 FileDialogOption::ParentWindow,
586 ));
587 }
588 if request
589 .options
590 .filters
591 .iter()
592 .any(|filter| !filter.extensions.is_empty())
593 && self.extension_filters != CapabilitySupport::Supported
594 {
595 return Err(FileDialogError::UnsupportedOption(
596 FileDialogOption::ExtensionFilters,
597 ));
598 }
599 for content_type in request
600 .options
601 .filters
602 .iter()
603 .flat_map(|filter| filter.content_types.iter())
604 {
605 let (support, option) = match content_type.kind() {
606 FileContentTypeKind::Mime => {
607 (self.mime_type_filters, FileDialogOption::MimeTypeFilters)
608 }
609 FileContentTypeKind::TypeIdentifier => (
610 self.type_identifier_filters,
611 FileDialogOption::TypeIdentifierFilters,
612 ),
613 };
614 if support != CapabilitySupport::Supported {
615 return Err(FileDialogError::UnsupportedOption(option));
616 }
617 }
618 if request.options.suggested_file_name.is_some()
619 && self.suggested_file_name != CapabilitySupport::Supported
620 {
621 return Err(FileDialogError::UnsupportedOption(
622 FileDialogOption::SuggestedFileName,
623 ));
624 }
625 if request.options.suggested_directory.is_some()
626 && self.suggested_directory != CapabilitySupport::Supported
627 {
628 return Err(FileDialogError::UnsupportedOption(
629 FileDialogOption::SuggestedDirectory,
630 ));
631 }
632 Ok(())
633 }
634}