Skip to main content

incular_runtime/
file_dialogs.rs

1//! Window-owned asynchronous native file-dialog requests.
2use crate::request_channel::{RequestChannel, RequestReceiver};
3
4use crate::tasks::RuntimeWake;
5use incular_platform::{
6    CapabilitySupport, DocumentDescriptor, FileContentTypeKind, FileDialogCapabilities,
7    FileDialogError, FileDialogKind, FileDialogOption, FileDialogOptions, FileDialogOutcome,
8    FileDialogRequest as PortableRequest, FileDialogRequestId, FileDialogSelection,
9    PlatformCapabilities, WindowId,
10};
11use std::{
12    collections::VecDeque,
13    future::Future,
14    marker::PhantomData,
15    path::PathBuf,
16    pin::Pin,
17    sync::{
18        Arc, RwLock,
19        atomic::{AtomicBool, Ordering},
20        mpsc,
21    },
22    task::{Context, Poll},
23};
24use tokio::sync::oneshot;
25
26type FileDialogResult = Result<FileDialogOutcome, FileDialogError>;
27
28/// Native-handle-free request passed from the runtime to a desktop host.
29#[derive(Clone, Debug, PartialEq, Eq)]
30pub struct NativeFileDialogRequest {
31    pub request_id: FileDialogRequestId,
32    pub window_id: WindowId,
33    pub request: PortableRequest,
34}
35
36/// Completion returned by a desktop host on the application's UI/event-loop
37/// thread. Late completions are rejected by request/window identity.
38#[derive(Clone, Debug, PartialEq, Eq)]
39pub struct NativeFileDialogCompletion {
40    pub request_id: FileDialogRequestId,
41    pub window_id: WindowId,
42    pub result: FileDialogResult,
43}
44
45#[derive(Clone, Copy, Debug, PartialEq, Eq)]
46pub enum FileDialogCompletionStatus {
47    Completed,
48    InvalidResult,
49    UnknownRequest,
50    TargetMismatch,
51}
52
53/// Cloneable, window-associated application service for native file dialogs.
54/// Requests contain no native parent handle; the desktop host resolves the
55/// [`WindowId`] to the real parent only when it starts the native dialog.
56#[derive(Clone)]
57pub struct FileDialogService {
58    window_id: WindowId,
59    bridge: Arc<FileDialogBridge>,
60    capabilities: Arc<RwLock<PlatformCapabilities>>,
61}
62
63impl std::fmt::Debug for FileDialogService {
64    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
65        formatter
66            .debug_tuple("FileDialogService")
67            .field(&self.window_id)
68            .finish()
69    }
70}
71
72impl FileDialogService {
73    pub(crate) fn new(
74        window_id: WindowId,
75        bridge: Arc<FileDialogBridge>,
76        capabilities: Arc<RwLock<PlatformCapabilities>>,
77    ) -> Self {
78        Self {
79            window_id,
80            bridge,
81            capabilities,
82        }
83    }
84
85    #[must_use]
86    pub const fn window_id(&self) -> WindowId {
87        self.window_id
88    }
89
90    #[must_use]
91    pub fn capabilities(&self) -> FileDialogCapabilities {
92        self.capabilities
93            .read()
94            .expect("window capability snapshot lock")
95            .application_services
96            .file_dialogs
97    }
98
99    /// Opens one native file picker and resolves to `None` when the user
100    /// cancels it. Backend failures and lifecycle invalidation remain errors.
101    pub async fn open_file(
102        &self,
103        options: FileDialogOptions,
104    ) -> Result<Option<DocumentDescriptor>, FileDialogError> {
105        self.request_open_file(options)?.await
106    }
107
108    /// Opens a native multi-file picker, preserving the backend's selection
109    /// order exactly. User cancellation resolves to `None`.
110    pub async fn open_files(
111        &self,
112        options: FileDialogOptions,
113    ) -> Result<Option<Vec<DocumentDescriptor>>, FileDialogError> {
114        self.request_open_files(options)?.await
115    }
116
117    /// Opens a native save picker. Incular returns the selected destination
118    /// path only; it never creates, truncates, or writes the file.
119    pub async fn save_file(
120        &self,
121        options: FileDialogOptions,
122    ) -> Result<Option<DocumentDescriptor>, FileDialogError> {
123        self.request_save_file(options)?.await
124    }
125
126    /// Opens a native folder picker and resolves to `None` on user cancel.
127    pub async fn select_folder(
128        &self,
129        options: FileDialogOptions,
130    ) -> Result<Option<PathBuf>, FileDialogError> {
131        self.request_select_folder(options)?.await
132    }
133
134    /// Creates an explicit request handle for callers that need request
135    /// identity, manual polling, or cancellation-by-drop before awaiting.
136    pub fn request_open_file(
137        &self,
138        options: FileDialogOptions,
139    ) -> Result<FileDialogRequest<Option<DocumentDescriptor>>, FileDialogError> {
140        self.request(FileDialogKind::OpenFile, options, map_single_document)
141    }
142
143    /// Explicit-handle form of [`Self::open_files`].
144    pub fn request_open_files(
145        &self,
146        options: FileDialogOptions,
147    ) -> Result<FileDialogRequest<Option<Vec<DocumentDescriptor>>>, FileDialogError> {
148        self.request(FileDialogKind::OpenFiles, options, map_multiple_documents)
149    }
150
151    /// Explicit-handle form of [`Self::save_file`].
152    pub fn request_save_file(
153        &self,
154        options: FileDialogOptions,
155    ) -> Result<FileDialogRequest<Option<DocumentDescriptor>>, FileDialogError> {
156        self.request(FileDialogKind::SaveFile, options, map_single_document)
157    }
158
159    /// Explicit-handle form of [`Self::select_folder`].
160    pub fn request_select_folder(
161        &self,
162        options: FileDialogOptions,
163    ) -> Result<FileDialogRequest<Option<PathBuf>>, FileDialogError> {
164        self.request(FileDialogKind::SelectFolder, options, map_folder)
165    }
166
167    fn request<T>(
168        &self,
169        kind: FileDialogKind,
170        options: FileDialogOptions,
171        map: fn(FileDialogResult) -> Result<T, FileDialogError>,
172    ) -> Result<FileDialogRequest<T>, FileDialogError> {
173        let request = PortableRequest::new(kind, options).map_err(FileDialogError::from)?;
174        let raw = self.bridge.request(self.window_id, request)?;
175        Ok(FileDialogRequest {
176            raw,
177            map,
178            marker: PhantomData,
179        })
180    }
181}
182
183fn map_single_document(
184    result: FileDialogResult,
185) -> Result<Option<DocumentDescriptor>, FileDialogError> {
186    match result? {
187        FileDialogOutcome::Cancelled => Ok(None),
188        FileDialogOutcome::Selected(FileDialogSelection::Documents(activation))
189            if activation.documents().len() == 1 =>
190        {
191            Ok(activation.documents().first().cloned())
192        }
193        FileDialogOutcome::Selected(_) => Err(FileDialogError::InvalidResult),
194    }
195}
196
197fn map_multiple_documents(
198    result: FileDialogResult,
199) -> Result<Option<Vec<DocumentDescriptor>>, FileDialogError> {
200    match result? {
201        FileDialogOutcome::Cancelled => Ok(None),
202        FileDialogOutcome::Selected(FileDialogSelection::Documents(activation))
203            if !activation.is_empty() =>
204        {
205            Ok(Some(activation.documents().to_vec()))
206        }
207        FileDialogOutcome::Selected(_) => Err(FileDialogError::InvalidResult),
208    }
209}
210
211fn map_folder(result: FileDialogResult) -> Result<Option<PathBuf>, FileDialogError> {
212    match result? {
213        FileDialogOutcome::Cancelled => Ok(None),
214        FileDialogOutcome::Selected(FileDialogSelection::Folder(path)) => Ok(Some(path)),
215        FileDialogOutcome::Selected(_) => Err(FileDialogError::InvalidResult),
216    }
217}
218
219/// Awaitable typed result for one native file dialog.
220///
221/// Dropping this handle detaches the caller from result delivery. If the native
222/// dialog is already active, the runtime keeps that modal slot occupied until
223/// its backend completion arrives rather than opening a second dialog over it.
224#[must_use = "file-dialog requests must be awaited, inspected, or explicitly dropped"]
225pub struct FileDialogRequest<T> {
226    raw: RawFileDialogRequest,
227    map: fn(FileDialogResult) -> Result<T, FileDialogError>,
228    marker: PhantomData<T>,
229}
230
231impl<T> Unpin for FileDialogRequest<T> {}
232
233impl<T> FileDialogRequest<T> {
234    #[must_use]
235    pub const fn id(&self) -> FileDialogRequestId {
236        self.raw.id
237    }
238
239    pub fn try_result(&mut self) -> Option<Result<T, FileDialogError>> {
240        self.raw.try_result().map(self.map)
241    }
242}
243
244impl<T> Future for FileDialogRequest<T> {
245    type Output = Result<T, FileDialogError>;
246
247    fn poll(mut self: Pin<&mut Self>, context: &mut Context<'_>) -> Poll<Self::Output> {
248        match Pin::new(&mut self.as_mut().get_mut().raw).poll(context) {
249            Poll::Ready(result) => Poll::Ready((self.map)(result)),
250            Poll::Pending => Poll::Pending,
251        }
252    }
253}
254
255struct RawFileDialogRequest {
256    id: FileDialogRequestId,
257    receiver: RequestReceiver<FileDialogResult>,
258}
259impl RawFileDialogRequest {
260    fn try_result(&mut self) -> Option<FileDialogResult> {
261        self.receiver
262            .try_result(|| Err(FileDialogError::ApplicationStopped))
263    }
264}
265impl Future for RawFileDialogRequest {
266    type Output = FileDialogResult;
267    fn poll(mut self: Pin<&mut Self>, context: &mut Context<'_>) -> Poll<Self::Output> {
268        self.receiver
269            .poll_result(context, || Err(FileDialogError::ApplicationStopped))
270    }
271}
272
273pub(crate) struct QueuedFileDialogRequest {
274    pub(crate) request_id: FileDialogRequestId,
275    pub(crate) window_id: WindowId,
276    pub(crate) request: PortableRequest,
277    pub(crate) sender: oneshot::Sender<FileDialogResult>,
278    pub(crate) cancelled: Arc<AtomicBool>,
279}
280
281pub(crate) struct FileDialogBridge {
282    channel: RequestChannel<QueuedFileDialogRequest>,
283    cancellation_sender: mpsc::Sender<FileDialogRequestId>,
284}
285impl FileDialogBridge {
286    pub(crate) fn new(
287        sender: mpsc::Sender<QueuedFileDialogRequest>,
288        cancellation_sender: mpsc::Sender<FileDialogRequestId>,
289    ) -> Self {
290        Self {
291            channel: RequestChannel::new(sender),
292            cancellation_sender,
293        }
294    }
295    fn request(
296        self: &Arc<Self>,
297        window_id: WindowId,
298        request: PortableRequest,
299    ) -> Result<RawFileDialogRequest, FileDialogError> {
300        self.channel
301            .request(|id| {
302                let request_id = FileDialogRequestId::new(id);
303                let (sender, receiver) = oneshot::channel();
304                let cancelled = Arc::new(AtomicBool::new(false));
305                let queued = QueuedFileDialogRequest {
306                    request_id,
307                    window_id,
308                    request,
309                    sender,
310                    cancelled: cancelled.clone(),
311                };
312                let bridge = self.clone();
313                let receiver = RequestReceiver::new(receiver, move || {
314                    cancelled.store(true, Ordering::Release);
315                    bridge.cancel(request_id);
316                });
317                (
318                    queued,
319                    RawFileDialogRequest {
320                        id: request_id,
321                        receiver,
322                    },
323                )
324            })
325            .map_err(|_| FileDialogError::ApplicationStopped)
326    }
327    fn cancel(&self, id: FileDialogRequestId) {
328        self.channel.cancel(&self.cancellation_sender, id);
329    }
330    pub(crate) fn set_wake(&self, wake: Arc<dyn RuntimeWake>) {
331        self.channel.set_wake(wake);
332    }
333    pub(crate) fn stop(&self) {
334        self.channel.stop();
335    }
336}
337
338/// Deterministic in-memory host for headless integration tests and custom
339/// embedders. Scripted outcomes are consumed FIFO and the adapter records the
340/// exact portable requests it received.
341#[derive(Debug)]
342pub struct MemoryFileDialogAdapter {
343    capabilities: FileDialogCapabilities,
344    scripted: VecDeque<FileDialogResult>,
345    observed: Vec<NativeFileDialogRequest>,
346}
347
348impl Default for MemoryFileDialogAdapter {
349    fn default() -> Self {
350        Self {
351            capabilities: FileDialogCapabilities::all(CapabilitySupport::Supported),
352            scripted: VecDeque::new(),
353            observed: Vec::new(),
354        }
355    }
356}
357
358impl MemoryFileDialogAdapter {
359    #[must_use]
360    pub const fn capabilities(&self) -> FileDialogCapabilities {
361        self.capabilities
362    }
363
364    #[must_use]
365    pub fn with_capabilities(mut self, capabilities: FileDialogCapabilities) -> Self {
366        self.capabilities = capabilities;
367        self
368    }
369
370    pub fn push_result(&mut self, result: FileDialogResult) {
371        self.scripted.push_back(result);
372    }
373
374    pub fn respond(&mut self, request: NativeFileDialogRequest) -> NativeFileDialogCompletion {
375        let result = self
376            .capabilities
377            .validate_request(&request.request)
378            .map(|()| {
379                self.scripted
380                    .pop_front()
381                    .unwrap_or(Ok(FileDialogOutcome::Cancelled))
382            })
383            .unwrap_or_else(Err);
384        self.observed.push(request.clone());
385        NativeFileDialogCompletion {
386            request_id: request.request_id,
387            window_id: request.window_id,
388            result,
389        }
390    }
391
392    #[must_use]
393    pub fn observed(&self) -> &[NativeFileDialogRequest] {
394        &self.observed
395    }
396}
397
398pub(crate) fn explicitly_unsupported_request(
399    capabilities: FileDialogCapabilities,
400    request: &PortableRequest,
401) -> Option<FileDialogError> {
402    if capabilities.operation(request.kind) == CapabilitySupport::Unsupported {
403        return Some(FileDialogError::UnsupportedOperation(request.kind));
404    }
405    if capabilities.parent_window == CapabilitySupport::Unsupported {
406        return Some(FileDialogError::UnsupportedOption(
407            FileDialogOption::ParentWindow,
408        ));
409    }
410    if request
411        .options
412        .filters_value()
413        .iter()
414        .any(|filter| !filter.extensions_list().is_empty())
415        && capabilities.extension_filters == CapabilitySupport::Unsupported
416    {
417        return Some(FileDialogError::UnsupportedOption(
418            FileDialogOption::ExtensionFilters,
419        ));
420    }
421    for content_type in request
422        .options
423        .filters_value()
424        .iter()
425        .flat_map(|filter| filter.content_types_list())
426    {
427        let (support, option) = match content_type.kind() {
428            FileContentTypeKind::Mime => (
429                capabilities.mime_type_filters,
430                FileDialogOption::MimeTypeFilters,
431            ),
432            FileContentTypeKind::TypeIdentifier => (
433                capabilities.type_identifier_filters,
434                FileDialogOption::TypeIdentifierFilters,
435            ),
436        };
437        if support == CapabilitySupport::Unsupported {
438            return Some(FileDialogError::UnsupportedOption(option));
439        }
440    }
441    if request.options.suggested_file_name_value().is_some()
442        && capabilities.suggested_file_name == CapabilitySupport::Unsupported
443    {
444        return Some(FileDialogError::UnsupportedOption(
445            FileDialogOption::SuggestedFileName,
446        ));
447    }
448    if request.options.suggested_directory_value().is_some()
449        && capabilities.suggested_directory == CapabilitySupport::Unsupported
450    {
451        return Some(FileDialogError::UnsupportedOption(
452            FileDialogOption::SuggestedDirectory,
453        ));
454    }
455    None
456}
457
458pub(crate) fn result_matches_request(
459    request: &PortableRequest,
460    outcome: &FileDialogOutcome,
461) -> bool {
462    match (request.kind, outcome) {
463        (_, FileDialogOutcome::Cancelled) => true,
464        (
465            FileDialogKind::OpenFile | FileDialogKind::SaveFile,
466            FileDialogOutcome::Selected(FileDialogSelection::Documents(activation)),
467        ) => activation.documents().len() == 1,
468        (
469            FileDialogKind::OpenFiles,
470            FileDialogOutcome::Selected(FileDialogSelection::Documents(activation)),
471        ) => !activation.is_empty(),
472        (
473            FileDialogKind::SelectFolder,
474            FileDialogOutcome::Selected(FileDialogSelection::Folder(_)),
475        ) => true,
476        _ => false,
477    }
478}