Skip to main content

incular_platform/
file_dialog.rs

1//! Portable contracts for native file and folder dialogs.
2//!
3//! Native window handles and native dialog objects deliberately do not appear
4//! here. A desktop adapter receives these values, associates the dialog with
5//! the requesting native window, and returns only paths/document descriptors.
6
7use crate::CapabilitySupport;
8use std::{
9    collections::HashSet,
10    fmt,
11    path::{Path, PathBuf},
12    sync::Arc,
13};
14
15/// Stable runtime identity for one file-dialog request. It is unrelated to a
16/// native dialog object or operating-system handle.
17#[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/// One file/document path selected by the operating system or delivered by an
33/// application activation. The path is preserved exactly; Incular does not
34/// canonicalize it or take ownership of document persistence.
35#[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/// Application-level open-document value intentionally shared with the future
70/// OS activation pipeline. It transports intent only; Incular does not create a
71/// document model, load bytes, or choose a persistence policy.
72#[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/// Stable semantic identity for a file filter. Native display labels are
106/// presentation only and never determine filter identity.
107#[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/// Normalized file extension without a leading dot.
132#[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/// Semantic family of a file content-type identifier.
160#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
161pub enum FileContentTypeKind {
162    /// Internet media type such as `image/png`.
163    Mime,
164    /// Platform-neutral representation of a UTI-like/native type identifier
165    /// such as `public.jpeg`.
166    TypeIdentifier,
167}
168
169/// Semantic content type accepted by a file filter. MIME types are normalized
170/// to ASCII lowercase because MIME type/subtype tokens are case-insensitive;
171/// UTI-like identifiers are preserved verbatim apart from surrounding space.
172#[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/// One semantic filter with optional native-facing presentation text.
228#[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/// Native file-dialog operation requested by application code.
382#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
383pub enum FileDialogKind {
384    OpenFile,
385    OpenFiles,
386    SaveFile,
387    SelectFolder,
388}
389
390/// Semantically important optional behavior a backend may support or reject.
391#[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/// Shared options for native file-dialog operations.
402#[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/// Fully validated, native-handle-free dialog request.
483#[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/// Portable result payload from a native file dialog.
522#[derive(Clone, Debug, PartialEq, Eq)]
523pub enum FileDialogSelection {
524    Documents(DocumentActivation),
525    Folder(PathBuf),
526}
527
528/// User-visible completion of a native file dialog. User cancellation is a
529/// normal outcome rather than a backend error.
530#[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/// Detailed native-dialog capabilities. Keeping options independent prevents a
579/// backend from claiming full semantic support merely because it can show a
580/// basic file picker.
581#[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}