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)]
337pub enum FileDialogValidationError {
338 InvalidFilterId(String),
339 InvalidFilterLabel,
340 InvalidExtension(String),
341 InvalidContentType(String),
342 EmptyFilter(FileDialogFilterId),
343 DuplicateFilterId(FileDialogFilterId),
344 InvalidSuggestedFileName,
345 OptionNotApplicable(FileDialogOption),
346}
347
348impl fmt::Display for FileDialogValidationError {
349 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
350 match self {
351 Self::InvalidFilterId(value) => {
352 write!(formatter, "invalid file-dialog filter id {value:?}")
353 }
354 Self::InvalidFilterLabel => {
355 formatter.write_str("file-dialog filter label must be non-empty printable text")
356 }
357 Self::InvalidExtension(value) => write!(formatter, "invalid file extension {value:?}"),
358 Self::InvalidContentType(value) => {
359 write!(formatter, "invalid file content type {value:?}")
360 }
361 Self::EmptyFilter(id) => write!(
362 formatter,
363 "file-dialog filter {id} has no extensions or content types"
364 ),
365 Self::DuplicateFilterId(id) => {
366 write!(formatter, "duplicate file-dialog filter id {id}")
367 }
368 Self::InvalidSuggestedFileName => {
369 formatter.write_str("suggested file name must be a single non-empty file name")
370 }
371 Self::OptionNotApplicable(option) => write!(
372 formatter,
373 "file-dialog option {option:?} does not apply to this dialog kind"
374 ),
375 }
376 }
377}
378
379impl std::error::Error for FileDialogValidationError {}
380
381#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
383pub enum FileDialogKind {
384 OpenFile,
385 OpenFiles,
386 SaveFile,
387 SelectFolder,
388}
389
390#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
392pub enum FileDialogOption {
393 ExtensionFilters,
394 MimeTypeFilters,
395 TypeIdentifierFilters,
396 SuggestedFileName,
397 SuggestedDirectory,
398 ParentWindow,
399}
400
401#[derive(Clone, Debug, Default, PartialEq, Eq)]
403pub struct FileDialogOptions {
404 title: Option<String>,
405 filters: Arc<[FileDialogFilter]>,
406 suggested_file_name: Option<String>,
407 suggested_directory: Option<PathBuf>,
408}
409
410impl FileDialogOptions {
411 #[must_use]
412 pub fn new() -> Self {
413 Self::default()
414 }
415
416 #[must_use]
417 pub fn title(mut self, title: impl Into<String>) -> Self {
418 self.title = Some(title.into());
419 self
420 }
421
422 pub fn filters(
423 mut self,
424 filters: impl IntoIterator<Item = FileDialogFilter>,
425 ) -> Result<Self, FileDialogValidationError> {
426 let filters = filters.into_iter().collect::<Vec<_>>();
427 let mut ids = HashSet::with_capacity(filters.len());
428 for filter in &filters {
429 if !ids.insert(filter.id.clone()) {
430 return Err(FileDialogValidationError::DuplicateFilterId(
431 filter.id.clone(),
432 ));
433 }
434 }
435 self.filters = filters.into();
436 Ok(self)
437 }
438
439 pub fn suggested_file_name(
440 mut self,
441 name: impl Into<String>,
442 ) -> Result<Self, FileDialogValidationError> {
443 let name = name.into();
444 if name.trim().is_empty()
445 || name.chars().any(char::is_control)
446 || name.contains(['/', '\\'])
447 || matches!(name.as_str(), "." | "..")
448 {
449 return Err(FileDialogValidationError::InvalidSuggestedFileName);
450 }
451 self.suggested_file_name = Some(name);
452 Ok(self)
453 }
454
455 #[must_use]
456 pub fn suggested_directory(mut self, directory: impl Into<PathBuf>) -> Self {
457 self.suggested_directory = Some(directory.into());
458 self
459 }
460
461 #[must_use]
462 pub fn title_value(&self) -> Option<&str> {
463 self.title.as_deref()
464 }
465
466 #[must_use]
467 pub fn filters_value(&self) -> &[FileDialogFilter] {
468 &self.filters
469 }
470
471 #[must_use]
472 pub fn suggested_file_name_value(&self) -> Option<&str> {
473 self.suggested_file_name.as_deref()
474 }
475
476 #[must_use]
477 pub fn suggested_directory_value(&self) -> Option<&Path> {
478 self.suggested_directory.as_deref()
479 }
480}
481
482#[derive(Clone, Debug, PartialEq, Eq)]
484pub struct FileDialogRequest {
485 pub kind: FileDialogKind,
486 pub options: FileDialogOptions,
487}
488
489impl FileDialogRequest {
490 pub fn new(
491 kind: FileDialogKind,
492 options: FileDialogOptions,
493 ) -> Result<Self, FileDialogValidationError> {
494 if kind == FileDialogKind::SelectFolder {
495 for filter in options.filters.iter() {
496 if !filter.extensions.is_empty() {
497 return Err(FileDialogValidationError::OptionNotApplicable(
498 FileDialogOption::ExtensionFilters,
499 ));
500 }
501 if let Some(content_type) = filter.content_types.first() {
502 let option = match content_type.kind() {
503 FileContentTypeKind::Mime => FileDialogOption::MimeTypeFilters,
504 FileContentTypeKind::TypeIdentifier => {
505 FileDialogOption::TypeIdentifierFilters
506 }
507 };
508 return Err(FileDialogValidationError::OptionNotApplicable(option));
509 }
510 }
511 }
512 if kind != FileDialogKind::SaveFile && options.suggested_file_name.is_some() {
513 return Err(FileDialogValidationError::OptionNotApplicable(
514 FileDialogOption::SuggestedFileName,
515 ));
516 }
517 Ok(Self { kind, options })
518 }
519}
520
521#[derive(Clone, Debug, PartialEq, Eq)]
523pub enum FileDialogSelection {
524 Documents(DocumentActivation),
525 Folder(PathBuf),
526}
527
528#[derive(Clone, Debug, PartialEq, Eq)]
531pub enum FileDialogOutcome {
532 Selected(FileDialogSelection),
533 Cancelled,
534}
535
536#[derive(Clone, Debug, PartialEq, Eq)]
537pub enum FileDialogError {
538 InvalidRequest(FileDialogValidationError),
539 ParentClosed,
540 ApplicationStopped,
541 UnsupportedOperation(FileDialogKind),
542 UnsupportedOption(FileDialogOption),
543 InvalidResult,
544 Backend(String),
545}
546
547impl fmt::Display for FileDialogError {
548 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
549 match self {
550 Self::InvalidRequest(error) => {
551 write!(formatter, "invalid file-dialog request: {error}")
552 }
553 Self::ParentClosed => formatter.write_str("file-dialog parent window closed"),
554 Self::ApplicationStopped => {
555 formatter.write_str("file-dialog application service stopped")
556 }
557 Self::UnsupportedOperation(kind) => {
558 write!(formatter, "file-dialog operation {kind:?} is unsupported")
559 }
560 Self::UnsupportedOption(option) => {
561 write!(formatter, "file-dialog option {option:?} is unsupported")
562 }
563 Self::InvalidResult => formatter
564 .write_str("file-dialog backend returned a result incompatible with its request"),
565 Self::Backend(message) => write!(formatter, "file-dialog backend failed: {message}"),
566 }
567 }
568}
569
570impl std::error::Error for FileDialogError {}
571
572impl From<FileDialogValidationError> for FileDialogError {
573 fn from(value: FileDialogValidationError) -> Self {
574 Self::InvalidRequest(value)
575 }
576}
577
578#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
582pub struct FileDialogCapabilities {
583 pub open_file: CapabilitySupport,
584 pub open_files: CapabilitySupport,
585 pub save_file: CapabilitySupport,
586 pub select_folder: CapabilitySupport,
587 pub extension_filters: CapabilitySupport,
588 pub mime_type_filters: CapabilitySupport,
589 pub type_identifier_filters: CapabilitySupport,
590 pub suggested_file_name: CapabilitySupport,
591 pub suggested_directory: CapabilitySupport,
592 pub parent_window: CapabilitySupport,
593}
594
595impl FileDialogCapabilities {
596 #[must_use]
597 pub const fn all(support: CapabilitySupport) -> Self {
598 Self {
599 open_file: support,
600 open_files: support,
601 save_file: support,
602 select_folder: support,
603 extension_filters: support,
604 mime_type_filters: support,
605 type_identifier_filters: support,
606 suggested_file_name: support,
607 suggested_directory: support,
608 parent_window: support,
609 }
610 }
611
612 #[must_use]
613 pub const fn operation(self, kind: FileDialogKind) -> CapabilitySupport {
614 match kind {
615 FileDialogKind::OpenFile => self.open_file,
616 FileDialogKind::OpenFiles => self.open_files,
617 FileDialogKind::SaveFile => self.save_file,
618 FileDialogKind::SelectFolder => self.select_folder,
619 }
620 }
621
622 pub fn validate_request(&self, request: &FileDialogRequest) -> Result<(), FileDialogError> {
623 if self.operation(request.kind) != CapabilitySupport::Supported {
624 return Err(FileDialogError::UnsupportedOperation(request.kind));
625 }
626 if self.parent_window != CapabilitySupport::Supported {
627 return Err(FileDialogError::UnsupportedOption(
628 FileDialogOption::ParentWindow,
629 ));
630 }
631 if request
632 .options
633 .filters
634 .iter()
635 .any(|filter| !filter.extensions.is_empty())
636 && self.extension_filters != CapabilitySupport::Supported
637 {
638 return Err(FileDialogError::UnsupportedOption(
639 FileDialogOption::ExtensionFilters,
640 ));
641 }
642 for content_type in request
643 .options
644 .filters
645 .iter()
646 .flat_map(|filter| filter.content_types.iter())
647 {
648 let (support, option) = match content_type.kind() {
649 FileContentTypeKind::Mime => {
650 (self.mime_type_filters, FileDialogOption::MimeTypeFilters)
651 }
652 FileContentTypeKind::TypeIdentifier => (
653 self.type_identifier_filters,
654 FileDialogOption::TypeIdentifierFilters,
655 ),
656 };
657 if support != CapabilitySupport::Supported {
658 return Err(FileDialogError::UnsupportedOption(option));
659 }
660 }
661 if request.options.suggested_file_name.is_some()
662 && self.suggested_file_name != CapabilitySupport::Supported
663 {
664 return Err(FileDialogError::UnsupportedOption(
665 FileDialogOption::SuggestedFileName,
666 ));
667 }
668 if request.options.suggested_directory.is_some()
669 && self.suggested_directory != CapabilitySupport::Supported
670 {
671 return Err(FileDialogError::UnsupportedOption(
672 FileDialogOption::SuggestedDirectory,
673 ));
674 }
675 Ok(())
676 }
677}