ashpd/desktop/
file_chooser.rs

1//! The interface lets sandboxed applications ask the user for access to files
2//! outside the sandbox. The portal backend will present the user with a file
3//! chooser dialog.
4//!
5//! Wrapper of the DBus interface: [`org.freedesktop.portal.FileChooser`](https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.FileChooser.html).
6//!
7//! ### Examples
8//!
9//! #### Opening a file
10//!
11//! ```rust,no_run
12//! use ashpd::desktop::file_chooser::{Choice, FileFilter, SelectedFiles};
13//!
14//! async fn run() -> ashpd::Result<()> {
15//!     let files = SelectedFiles::open_file()
16//!         .title("open a file to read")
17//!         .accept_label("read")
18//!         .modal(true)
19//!         .multiple(true)
20//!         .choice(
21//!             Choice::new("encoding", "Encoding", "latin15")
22//!                 .insert("utf8", "Unicode (UTF-8)")
23//!                 .insert("latin15", "Western"),
24//!         )
25//!         // A trick to have a checkbox
26//!         .choice(Choice::boolean("re-encode", "Re-encode", false))
27//!         .filter(FileFilter::new("SVG Image").mimetype("image/svg+xml"))
28//!         .send()
29//!         .await?
30//!         .response()?;
31//!
32//!     println!("{:#?}", files);
33//!
34//!     Ok(())
35//! }
36//! ```
37//!
38//! #### Ask to save a file
39//!
40//! ```rust,no_run
41//! use ashpd::desktop::file_chooser::{FileFilter, SelectedFiles};
42//!
43//! async fn run() -> ashpd::Result<()> {
44//!     let files = SelectedFiles::save_file()
45//!         .title("open a file to write")
46//!         .accept_label("write")
47//!         .current_name("image.jpg")
48//!         .modal(true)
49//!         .filter(FileFilter::new("JPEG Image").glob("*.jpg"))
50//!         .send()
51//!         .await?
52//!         .response()?;
53//!
54//!     println!("{:#?}", files);
55//!
56//!     Ok(())
57//! }
58//! ```
59//!
60//! #### Ask to save multiple files
61//!
62//! ```rust,no_run
63//! use ashpd::desktop::file_chooser::SelectedFiles;
64//!
65//! async fn run() -> ashpd::Result<()> {
66//!     let files = SelectedFiles::save_files()
67//!         .title("open files to write")
68//!         .accept_label("write files")
69//!         .modal(true)
70//!         .current_folder("/home/bilelmoussaoui/Pictures")?
71//!         .files(&["test.jpg", "awesome.png"])?
72//!         .send()
73//!         .await?
74//!         .response()?;
75//!
76//!     println!("{:#?}", files);
77//!
78//!     Ok(())
79//! }
80//! ```
81
82use std::path::Path;
83
84use serde::{Deserialize, Serialize};
85use serde_repr::{Deserialize_repr, Serialize_repr};
86use zbus::zvariant::{DeserializeDict, SerializeDict, Type};
87
88use super::{HandleToken, Request};
89use crate::{proxy::Proxy, Error, FilePath, WindowIdentifier};
90
91#[derive(Clone, Serialize, Deserialize, Type, Debug, PartialEq)]
92/// A file filter, to limit the available file choices to a mimetype or a glob
93/// pattern.
94pub struct FileFilter(String, Vec<(FilterType, String)>);
95
96#[derive(Clone, Serialize_repr, Deserialize_repr, Debug, Type, PartialEq)]
97#[repr(u32)]
98enum FilterType {
99    GlobPattern = 0,
100    MimeType = 1,
101}
102
103impl FilterType {
104    /// Whether it is a mime type filter.
105    fn is_mimetype(&self) -> bool {
106        matches!(self, FilterType::MimeType)
107    }
108
109    /// Whether it is a glob pattern type filter.
110    fn is_pattern(&self) -> bool {
111        matches!(self, FilterType::GlobPattern)
112    }
113}
114
115impl FileFilter {
116    /// Create a new file filter
117    ///
118    /// # Arguments
119    ///
120    /// * `label` - user-visible name of the file filter.
121    pub fn new(label: &str) -> Self {
122        Self(label.to_owned(), vec![])
123    }
124
125    /// Adds a mime type to the file filter.
126    #[must_use]
127    pub fn mimetype(mut self, mimetype: &str) -> Self {
128        self.1.push((FilterType::MimeType, mimetype.to_owned()));
129        self
130    }
131
132    /// Adds a glob pattern to the file filter.
133    #[must_use]
134    pub fn glob(mut self, pattern: &str) -> Self {
135        self.1.push((FilterType::GlobPattern, pattern.to_owned()));
136        self
137    }
138}
139
140impl FileFilter {
141    /// The label of the filter.
142    pub fn label(&self) -> &str {
143        &self.0
144    }
145
146    /// List of mimetypes filters.
147    pub fn mimetype_filters(&self) -> Vec<&str> {
148        self.1
149            .iter()
150            .filter_map(|(type_, string)| type_.is_mimetype().then_some(string.as_str()))
151            .collect()
152    }
153
154    /// List of glob patterns filters.
155    pub fn pattern_filters(&self) -> Vec<&str> {
156        self.1
157            .iter()
158            .filter_map(|(type_, string)| type_.is_pattern().then_some(string.as_str()))
159            .collect()
160    }
161}
162
163#[derive(Clone, Serialize, Deserialize, Type, Debug)]
164/// Presents the user with a choice to select from or as a checkbox.
165pub struct Choice(String, String, Vec<(String, String)>, String);
166
167impl Choice {
168    /// Creates a checkbox choice.
169    ///
170    /// # Arguments
171    ///
172    /// * `id` - A unique identifier of the choice.
173    /// * `label` - user-visible name of the choice.
174    /// * `state` - the initial state value.
175    pub fn boolean(id: &str, label: &str, state: bool) -> Self {
176        Self::new(id, label, &state.to_string())
177    }
178
179    /// Creates a new choice.
180    ///
181    /// # Arguments
182    ///
183    /// * `id` - A unique identifier of the choice.
184    /// * `label` - user-visible name of the choice.
185    /// * `initial_selection` - the initially selected value.
186    pub fn new(id: &str, label: &str, initial_selection: &str) -> Self {
187        Self(
188            id.to_owned(),
189            label.to_owned(),
190            vec![],
191            initial_selection.to_owned(),
192        )
193    }
194
195    /// Adds a (key, value) as a choice.
196    #[must_use]
197    pub fn insert(mut self, key: &str, value: &str) -> Self {
198        self.2.push((key.to_owned(), value.to_owned()));
199        self
200    }
201
202    /// The choice's unique id
203    pub fn id(&self) -> &str {
204        &self.0
205    }
206
207    /// The user visible label of the choice.
208    pub fn label(&self) -> &str {
209        &self.1
210    }
211
212    /// Pairs of choices.
213    pub fn pairs(&self) -> Vec<(&str, &str)> {
214        self.2
215            .iter()
216            .map(|(x, y)| (x.as_str(), y.as_str()))
217            .collect::<Vec<_>>()
218    }
219
220    /// The initially selected value.
221    pub fn initial_selection(&self) -> &str {
222        &self.3
223    }
224}
225
226#[derive(SerializeDict, Type, Debug, Default)]
227#[zvariant(signature = "dict")]
228struct OpenFileOptions {
229    handle_token: HandleToken,
230    accept_label: Option<String>,
231    modal: Option<bool>,
232    multiple: Option<bool>,
233    directory: Option<bool>,
234    filters: Vec<FileFilter>,
235    current_filter: Option<FileFilter>,
236    choices: Option<Vec<Choice>>,
237    current_folder: Option<FilePath>,
238}
239
240#[derive(SerializeDict, Type, Debug, Default)]
241#[zvariant(signature = "dict")]
242struct SaveFileOptions {
243    handle_token: HandleToken,
244    accept_label: Option<String>,
245    modal: Option<bool>,
246    current_name: Option<String>,
247    current_folder: Option<FilePath>,
248    current_file: Option<FilePath>,
249    filters: Vec<FileFilter>,
250    current_filter: Option<FileFilter>,
251    choices: Option<Vec<Choice>>,
252}
253
254#[derive(SerializeDict, Type, Debug, Default)]
255#[zvariant(signature = "dict")]
256struct SaveFilesOptions {
257    handle_token: HandleToken,
258    accept_label: Option<String>,
259    modal: Option<bool>,
260    choices: Option<Vec<Choice>>,
261    current_folder: Option<FilePath>,
262    files: Option<Vec<FilePath>>,
263}
264
265#[derive(Debug, Type, DeserializeDict)]
266/// A response of [`OpenFileRequest`], [`SaveFileRequest`] or
267/// [`SaveFilesRequest`].
268#[zvariant(signature = "dict")]
269pub struct SelectedFiles {
270    uris: Vec<url::Url>,
271    choices: Option<Vec<(String, String)>>,
272}
273
274impl SelectedFiles {
275    /// Start an open file request.
276    pub fn open_file() -> OpenFileRequest {
277        OpenFileRequest::default()
278    }
279
280    /// Start a save file request.
281    pub fn save_file() -> SaveFileRequest {
282        SaveFileRequest::default()
283    }
284
285    /// Start a save files request.
286    pub fn save_files() -> SaveFilesRequest {
287        SaveFilesRequest::default()
288    }
289
290    /// The selected files uris.
291    pub fn uris(&self) -> &[url::Url] {
292        self.uris.as_slice()
293    }
294
295    /// The selected value of each choice as a tuple of (key, value)
296    pub fn choices(&self) -> &[(String, String)] {
297        self.choices.as_deref().unwrap_or_default()
298    }
299}
300
301#[doc(alias = "org.freedesktop.portal.FileChooser")]
302struct FileChooserProxy<'a>(Proxy<'a>);
303
304impl<'a> FileChooserProxy<'a> {
305    /// Create a new instance of [`FileChooserProxy`].
306    pub async fn new() -> Result<FileChooserProxy<'a>, Error> {
307        let proxy = Proxy::new_desktop("org.freedesktop.portal.FileChooser").await?;
308        Ok(Self(proxy))
309    }
310
311    pub async fn open_file(
312        &self,
313        identifier: Option<&WindowIdentifier>,
314        title: &str,
315        options: OpenFileOptions,
316    ) -> Result<Request<SelectedFiles>, Error> {
317        let identifier = identifier.map(|i| i.to_string()).unwrap_or_default();
318        self.0
319            .request(
320                &options.handle_token,
321                "OpenFile",
322                &(&identifier, title, &options),
323            )
324            .await
325    }
326
327    pub async fn save_file(
328        &self,
329        identifier: Option<&WindowIdentifier>,
330        title: &str,
331        options: SaveFileOptions,
332    ) -> Result<Request<SelectedFiles>, Error> {
333        let identifier = identifier.map(|i| i.to_string()).unwrap_or_default();
334        self.0
335            .request(
336                &options.handle_token,
337                "SaveFile",
338                &(&identifier, title, &options),
339            )
340            .await
341    }
342
343    pub async fn save_files(
344        &self,
345        identifier: Option<&WindowIdentifier>,
346        title: &str,
347        options: SaveFilesOptions,
348    ) -> Result<Request<SelectedFiles>, Error> {
349        let identifier = identifier.map(|i| i.to_string()).unwrap_or_default();
350        self.0
351            .request(
352                &options.handle_token,
353                "SaveFiles",
354                &(&identifier, title, &options),
355            )
356            .await
357    }
358}
359
360impl<'a> std::ops::Deref for FileChooserProxy<'a> {
361    type Target = zbus::Proxy<'a>;
362
363    fn deref(&self) -> &Self::Target {
364        &self.0
365    }
366}
367
368#[derive(Debug, Default)]
369#[doc(alias = "xdp_portal_open_file")]
370/// A [builder-pattern] type to open a file.
371///
372/// [builder-pattern]: https://doc.rust-lang.org/1.0.0/style/ownership/builders.html
373pub struct OpenFileRequest {
374    identifier: Option<WindowIdentifier>,
375    title: String,
376    options: OpenFileOptions,
377}
378
379impl OpenFileRequest {
380    #[must_use]
381    /// Sets a window identifier.
382    pub fn identifier(mut self, identifier: impl Into<Option<WindowIdentifier>>) -> Self {
383        self.identifier = identifier.into();
384        self
385    }
386
387    /// Sets a title for the file chooser dialog.
388    #[must_use]
389    pub fn title<'a>(mut self, title: impl Into<Option<&'a str>>) -> Self {
390        self.title = title.into().map(ToOwned::to_owned).unwrap_or_default();
391        self
392    }
393
394    /// Sets a user-visible string to the "accept" button.
395    #[must_use]
396    pub fn accept_label<'a>(mut self, accept_label: impl Into<Option<&'a str>>) -> Self {
397        self.options.accept_label = accept_label.into().map(ToOwned::to_owned);
398        self
399    }
400
401    /// Sets whether the dialog should be a modal.
402    #[must_use]
403    pub fn modal(mut self, modal: impl Into<Option<bool>>) -> Self {
404        self.options.modal = modal.into();
405        self
406    }
407
408    /// Sets whether to allow multiple files selection.
409    #[must_use]
410    pub fn multiple(mut self, multiple: impl Into<Option<bool>>) -> Self {
411        self.options.multiple = multiple.into();
412        self
413    }
414
415    /// Sets whether to select directories or not.
416    #[must_use]
417    pub fn directory(mut self, directory: impl Into<Option<bool>>) -> Self {
418        self.options.directory = directory.into();
419        self
420    }
421
422    /// Adds a files filter.
423    #[must_use]
424    pub fn filter(mut self, filter: FileFilter) -> Self {
425        self.options.filters.push(filter);
426        self
427    }
428
429    #[must_use]
430    /// Adds a list of files filters.
431    pub fn filters(mut self, filters: impl IntoIterator<Item = FileFilter>) -> Self {
432        self.options.filters = filters.into_iter().collect();
433        self
434    }
435
436    /// Specifies the default filter.
437    #[must_use]
438    pub fn current_filter(mut self, current_filter: impl Into<Option<FileFilter>>) -> Self {
439        self.options.current_filter = current_filter.into();
440        self
441    }
442
443    /// Adds a choice.
444    #[must_use]
445    pub fn choice(mut self, choice: Choice) -> Self {
446        self.options
447            .choices
448            .get_or_insert_with(Vec::new)
449            .push(choice);
450        self
451    }
452
453    #[must_use]
454    /// Adds a list of choices.
455    pub fn choices(mut self, choices: impl IntoIterator<Item = Choice>) -> Self {
456        self.options.choices = Some(choices.into_iter().collect());
457        self
458    }
459
460    /// Specifies the current folder path.
461    pub fn current_folder<P: AsRef<Path>>(
462        mut self,
463        current_folder: impl Into<Option<P>>,
464    ) -> Result<Self, crate::Error> {
465        self.options.current_folder = current_folder
466            .into()
467            .map(|c| FilePath::new(c))
468            .transpose()?;
469        Ok(self)
470    }
471
472    /// Send the request.
473    pub async fn send(self) -> Result<Request<SelectedFiles>, Error> {
474        let proxy = FileChooserProxy::new().await?;
475        proxy
476            .open_file(self.identifier.as_ref(), &self.title, self.options)
477            .await
478    }
479}
480
481#[derive(Debug, Default)]
482#[doc(alias = "xdp_portal_save_files")]
483/// A [builder-pattern] type to save multiple files.
484///
485/// [builder-pattern]: https://doc.rust-lang.org/1.0.0/style/ownership/builders.html
486pub struct SaveFilesRequest {
487    identifier: Option<WindowIdentifier>,
488    title: String,
489    options: SaveFilesOptions,
490}
491
492impl SaveFilesRequest {
493    #[must_use]
494    /// Sets a window identifier.
495    pub fn identifier(mut self, identifier: impl Into<Option<WindowIdentifier>>) -> Self {
496        self.identifier = identifier.into();
497        self
498    }
499
500    /// Sets a title for the file chooser dialog.
501    #[must_use]
502    pub fn title<'a>(mut self, title: impl Into<Option<&'a str>>) -> Self {
503        self.title = title.into().map(ToOwned::to_owned).unwrap_or_default();
504        self
505    }
506
507    /// Sets a user-visible string to the "accept" button.
508    #[must_use]
509    pub fn accept_label<'a>(mut self, accept_label: impl Into<Option<&'a str>>) -> Self {
510        self.options.accept_label = accept_label.into().map(ToOwned::to_owned);
511        self
512    }
513
514    /// Sets whether the dialog should be a modal.
515    #[must_use]
516    pub fn modal(mut self, modal: impl Into<Option<bool>>) -> Self {
517        self.options.modal = modal.into();
518        self
519    }
520
521    /// Adds a choice.
522    #[must_use]
523    pub fn choice(mut self, choice: Choice) -> Self {
524        self.options
525            .choices
526            .get_or_insert_with(Vec::new)
527            .push(choice);
528        self
529    }
530
531    #[must_use]
532    /// Adds a list of choices.
533    pub fn choices(mut self, choices: impl IntoIterator<Item = Choice>) -> Self {
534        self.options.choices = Some(choices.into_iter().collect());
535        self
536    }
537
538    /// Specifies the current folder path.
539    pub fn current_folder<P: AsRef<Path>>(
540        mut self,
541        current_folder: impl Into<Option<P>>,
542    ) -> Result<Self, crate::Error> {
543        self.options.current_folder = current_folder
544            .into()
545            .map(|c| FilePath::new(c))
546            .transpose()?;
547        Ok(self)
548    }
549
550    /// Sets a list of files to save.
551    pub fn files<P: IntoIterator<Item = impl AsRef<Path>>>(
552        mut self,
553        files: impl Into<Option<P>>,
554    ) -> Result<Self, crate::Error> {
555        self.options.files = files
556            .into()
557            .map(|files| files.into_iter().map(|s| FilePath::new(s)).collect())
558            .transpose()?;
559        Ok(self)
560    }
561
562    /// Send the request.
563    pub async fn send(self) -> Result<Request<SelectedFiles>, Error> {
564        let proxy = FileChooserProxy::new().await?;
565        proxy
566            .save_files(self.identifier.as_ref(), &self.title, self.options)
567            .await
568    }
569}
570
571#[derive(Debug, Default)]
572#[doc(alias = "xdp_portal_save_file")]
573/// A [builder-pattern] type to save a file.
574///
575/// [builder-pattern]: https://doc.rust-lang.org/1.0.0/style/ownership/builders.html
576pub struct SaveFileRequest {
577    identifier: Option<WindowIdentifier>,
578    title: String,
579    options: SaveFileOptions,
580}
581
582impl SaveFileRequest {
583    #[must_use]
584    /// Sets a window identifier.
585    pub fn identifier(mut self, identifier: impl Into<Option<WindowIdentifier>>) -> Self {
586        self.identifier = identifier.into();
587        self
588    }
589
590    /// Sets a title for the file chooser dialog.
591    #[must_use]
592    pub fn title<'a>(mut self, title: impl Into<Option<&'a str>>) -> Self {
593        self.title = title.into().map(ToOwned::to_owned).unwrap_or_default();
594        self
595    }
596
597    /// Sets a user-visible string to the "accept" button.
598    #[must_use]
599    pub fn accept_label<'a>(mut self, accept_label: impl Into<Option<&'a str>>) -> Self {
600        self.options.accept_label = accept_label.into().map(ToOwned::to_owned);
601        self
602    }
603
604    /// Sets whether the dialog should be a modal.
605    #[must_use]
606    pub fn modal(mut self, modal: impl Into<Option<bool>>) -> Self {
607        self.options.modal = modal.into();
608        self
609    }
610
611    /// Sets the current file name.
612    #[must_use]
613    pub fn current_name<'a>(mut self, current_name: impl Into<Option<&'a str>>) -> Self {
614        self.options.current_name = current_name.into().map(ToOwned::to_owned);
615        self
616    }
617
618    /// Sets the current folder.
619    pub fn current_folder<P: AsRef<Path>>(
620        mut self,
621        current_folder: impl Into<Option<P>>,
622    ) -> Result<Self, crate::Error> {
623        self.options.current_folder = current_folder
624            .into()
625            .map(|c| FilePath::new(c))
626            .transpose()?;
627        Ok(self)
628    }
629
630    /// Sets the absolute path of the file.
631    pub fn current_file<P: AsRef<Path>>(
632        mut self,
633        current_file: impl Into<Option<P>>,
634    ) -> Result<Self, crate::Error> {
635        self.options.current_file = current_file.into().map(|c| FilePath::new(c)).transpose()?;
636        Ok(self)
637    }
638
639    /// Adds a files filter.
640    #[must_use]
641    pub fn filter(mut self, filter: FileFilter) -> Self {
642        self.options.filters.push(filter);
643        self
644    }
645
646    #[must_use]
647    /// Adds a list of files filters.
648    pub fn filters(mut self, filters: impl IntoIterator<Item = FileFilter>) -> Self {
649        self.options.filters = filters.into_iter().collect();
650        self
651    }
652
653    /// Sets the default filter.
654    #[must_use]
655    pub fn current_filter(mut self, current_filter: impl Into<Option<FileFilter>>) -> Self {
656        self.options.current_filter = current_filter.into();
657        self
658    }
659
660    /// Adds a choice.
661    #[must_use]
662    pub fn choice(mut self, choice: Choice) -> Self {
663        self.options
664            .choices
665            .get_or_insert_with(Vec::new)
666            .push(choice);
667        self
668    }
669
670    #[must_use]
671    /// Adds a list of choices.
672    pub fn choices(mut self, choices: impl IntoIterator<Item = Choice>) -> Self {
673        self.options.choices = Some(choices.into_iter().collect());
674        self
675    }
676
677    /// Send the request.
678    pub async fn send(self) -> Result<Request<SelectedFiles>, Error> {
679        let proxy = FileChooserProxy::new().await?;
680        proxy
681            .save_file(self.identifier.as_ref(), &self.title, self.options)
682            .await
683    }
684}