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, 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/// Native file-dialog operation requested by application code.
357#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
358pub enum FileDialogKind {
359    OpenFile,
360    OpenFiles,
361    SaveFile,
362    SelectFolder,
363}
364
365/// Semantically important optional behavior a backend may support or reject.
366#[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/// Shared options for native file-dialog operations.
377#[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/// Fully validated, native-handle-free dialog request.
458#[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/// Portable result payload from a native file dialog.
497#[derive(Clone, Debug, PartialEq, Eq)]
498pub enum FileDialogSelection {
499    Documents(DocumentActivation),
500    Folder(PathBuf),
501}
502
503/// User-visible completion of a native file dialog. User cancellation is a
504/// normal outcome rather than a backend error.
505#[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/// Detailed native-dialog capabilities. Keeping options independent prevents a
536/// backend from claiming full semantic support merely because it can show a
537/// basic file picker.
538#[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}