From 0bef593ba467e3b239c9db01860d43630cc937ee Mon Sep 17 00:00:00 2001 From: Michael Aaron Murphy Date: Mon, 22 Jan 2024 08:08:45 +0100 Subject: [PATCH] feat!(dialog): refactor and support rfd as file_chooser provider --- Cargo.toml | 56 +++--- examples/open-dialog/Cargo.toml | 7 +- examples/open-dialog/src/main.rs | 112 +++++------ justfile | 4 +- src/dialog/file_chooser/mod.rs | 322 ++++++++++++------------------- src/dialog/file_chooser/open.rs | 299 ++++++++++++++++++++++++---- src/dialog/file_chooser/save.rs | 175 +++++++++++++---- src/dialog/mod.rs | 3 +- src/lib.rs | 2 +- 9 files changed, 618 insertions(+), 362 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 7c7b0b0..bd51014 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,18 +11,27 @@ name = "cosmic" a11y = ["iced/a11y", "iced_accessibility"] # Builds support for animated images animated-image = ["image", "dep:async-fs", "tokio?/io-util", "tokio?/fs"] +# XXX Use "a11y"; which is causing a panic currently +applet = ["wayland", "tokio", "cosmic-panel-config", "ron"] +applet-token = [] +# Use the cosmic-settings-daemon for config handling +dbus-config = ["cosmic-config/dbus", "dep:zbus", "cosmic-settings-daemon"] # Debug features debug = ["iced/debug"] # Enables pipewire support in ashpd, if ashpd is enabled pipewire = ["ashpd?/pipewire"] # Enables process spawning helper -process = ["nix"] +process = ["dep:nix"] +# Use rfd for file dialogs +rfd = ["dep:rfd"] # Enables keycode serialization serde-keycode = ["iced_core/serde"] +# Prevents multiple separate process instances. +single-instance = ["dep:zbus", "serde", "ron"] # smol async runtime smol = ["iced/smol", "zbus?/async-io"] # Tokio async runtime -tokio = ["dep:tokio", "ashpd?/tokio", "iced/tokio", "zbus?/tokio"] +tokio = ["dep:tokio", "ashpd?/tokio", "iced/tokio", "rfd?/tokio", "zbus?/tokio"] # Wayland window support wayland = [ "ashpd?/wayland", @@ -42,35 +51,30 @@ winit_tokio = ["winit", "tokio"] winit_wgpu = ["winit", "wgpu"] # Enables XDG portal integrations xdg-portal = ["ashpd"] -# XXX Use "a11y"; which is causing a panic currently -applet = ["wayland", "tokio", "cosmic-panel-config", "ron"] -applet-token = [] -single-instance = ["dep:zbus", "serde", "ron"] -dbus-config = ["cosmic-config/dbus", "dep:zbus", "cosmic-settings-daemon"] [dependencies] apply = "0.3.0" -derive_setters = "0.1.5" -lazy_static = "1.4.0" -palette = "0.7.3" -tokio = { version = "1.24.2", optional = true } -cctk = { git = "https://github.com/pop-os/cosmic-protocols", package = "cosmic-client-toolkit", rev = "e65fa5e", optional = true } -slotmap = "1.0.6" -fraction = "0.14.0" -cosmic-config = { path = "cosmic-config" } -tracing = "0.1" -image = { version = "0.24.6", optional = true } -thiserror = "1.0.44" +ashpd = { version = "0.6.8", default-features = false, optional = true } async-fs = { version = "2.1", optional = true } -ashpd = { version = "0.6.0", default-features = false, optional = true } -url = "2.4.0" -unicode-segmentation = "1.6" -css-color = "0.2.5" -nix = { version = "0.27", features = ["process"], optional = true } -zbus = {version = "3.14.1", default-features = false, optional = true} -serde = { version = "1.0.180", optional = true } +cctk = { git = "https://github.com/pop-os/cosmic-protocols", package = "cosmic-client-toolkit", rev = "e65fa5e", optional = true } +cosmic-config = { path = "cosmic-config" } cosmic-settings-daemon = { git = "https://github.com/pop-os/dbus-settings-bindings", branch = "cosmic-settings-daemon", optional = true } - +css-color = "0.2.5" +derive_setters = "0.1.5" +fraction = "0.14.0" +image = { version = "0.24.6", optional = true } +lazy_static = "1.4.0" +nix = { version = "0.27", features = ["process"], optional = true } +palette = "0.7.3" +rfd = { version = "0.13.0", optional = true } +serde = { version = "1.0.180", optional = true } +slotmap = "1.0.6" +thiserror = "1.0.44" +tokio = { version = "1.24.2", optional = true } +tracing = "0.1" +unicode-segmentation = "1.6" +url = "2.4.0" +zbus = {version = "3.14.1", default-features = false, optional = true} [target.'cfg(unix)'.dependencies] freedesktop-icons = "0.2.4" diff --git a/examples/open-dialog/Cargo.toml b/examples/open-dialog/Cargo.toml index 7e1a52d..1cd4035 100644 --- a/examples/open-dialog/Cargo.toml +++ b/examples/open-dialog/Cargo.toml @@ -3,6 +3,11 @@ name = "open-dialog" version = "0.1.0" edition = "2021" +[features] +default = ["xdg-portal"] +rfd = ["libcosmic/rfd"] +xdg-portal = ["libcosmic/xdg-portal"] + [dependencies] apply = "0.3.0" tokio = { version = "1.31", features = ["full"] } @@ -13,4 +18,4 @@ url = "2.4.0" [dependencies.libcosmic] path = "../../" default-features = false -features = ["debug", "wayland", "tokio", "xdg-portal"] +features = ["debug", "wayland", "tokio"] diff --git a/examples/open-dialog/src/main.rs b/examples/open-dialog/src/main.rs index f14379f..f8b9536 100644 --- a/examples/open-dialog/src/main.rs +++ b/examples/open-dialog/src/main.rs @@ -9,6 +9,7 @@ use cosmic::dialog::file_chooser::{self, FileFilter}; use cosmic::iced_core::Length; use cosmic::widget::button; use cosmic::{executor, iced, ApplicationExt, Element}; +use std::sync::Arc; use tokio::io::AsyncReadExt; use url::Url; @@ -26,12 +27,11 @@ fn main() -> Result<(), Box> { /// Messages that are used specifically by our [`App`]. #[derive(Clone, Debug)] pub enum Message { + Cancelled, CloseError, - DialogClosed, - DialogInit(file_chooser::Sender), - DialogOpened, Error(String), FileRead(Url, String), + OpenError(Arc), OpenFile, Selected(Url), } @@ -39,7 +39,6 @@ pub enum Message { /// The [`App`] stores application-specific state. pub struct App { core: Core, - open_sender: Option, file_contents: String, selected_file: Option, error_status: Option, @@ -70,7 +69,6 @@ impl cosmic::Application for App { fn init(core: Core, _input: Self::Flags) -> (Self, Command) { let mut app = App { core, - open_sender: None, file_contents: String::new(), selected_file: None, error_status: None, @@ -90,41 +88,10 @@ impl cosmic::Application for App { vec![button::suggested("Open").on_press(Message::OpenFile).into()] } - fn subscription(&self) -> cosmic::iced_futures::Subscription { - // Creates a subscription for handling open dialogs. - file_chooser::subscription(|response| match response { - file_chooser::Message::Closed => Message::DialogClosed, - file_chooser::Message::Opened => Message::DialogOpened, - file_chooser::Message::Selected(files) => match files.uris().first() { - Some(file) => Message::Selected(file.to_owned()), - None => Message::DialogClosed, - }, - file_chooser::Message::Init(sender) => Message::DialogInit(sender), - file_chooser::Message::Err(why) => { - let mut source: &dyn std::error::Error = &why; - let mut string = format!("open dialog subscription errored\n cause: {source}"); - - while let Some(new_source) = source.source() { - string.push_str(&format!("\n cause: {new_source}")); - source = new_source; - } - - Message::Error(string) - } - }) - } - fn update(&mut self, message: Self::Message) -> Command { match message { - Message::DialogClosed => { - eprintln!("dialog closed"); - } - - Message::DialogOpened => { - if let Some(sender) = self.open_sender.as_mut() { - eprintln!("requesting selection"); - return sender.response().map(|_| cosmic::app::Message::None); - } + Message::Cancelled => { + eprintln!("open file dialog cancelled"); } Message::FileRead(url, contents) => { @@ -178,29 +145,30 @@ impl cosmic::Application for App { // Creates a new open dialog. Message::OpenFile => { - if let Some(sender) = self.open_sender.as_mut() { - if let Some(dialog) = file_chooser::open_file() { - eprintln!("opening new dialog"); + return cosmic::command::future(async move { + eprintln!("opening new dialog"); - return dialog - // Sets title of the dialog window. - .title("Choose a file".into()) - // Sets the label of the accept button. - .accept_label("_Open".into()) - // Exclude directories from file selection. - .include_directories(false) - // Defines whether to block the main window while requesting input. - .modal(false) - // Only accept one file as input. - .multiple_files(false) - // Accept only plain text files - .filter(FileFilter::new("Text files").mimetype("text/plain")) - // Emits the dialog to our sender - .create(sender) - // Ignores the output because it's empty. - .map(|_| cosmic::app::message::none()); + #[cfg(feature = "rfd")] + let filter = FileFilter::new("Text files").extension("txt"); + + #[cfg(feature = "xdg-portal")] + let filter = FileFilter::new("Text files").glob("*.txt"); + + let dialog = file_chooser::open::Dialog::new() + // Sets title of the dialog window. + .title("Choose a file") + // Accept only plain text files + .filter(filter); + + match dialog.open_file().await { + Ok(response) => Message::Selected(response.url().to_owned()), + + Err(file_chooser::Error::Cancelled) => Message::Cancelled, + + Err(why) => Message::OpenError(Arc::new(why)), } - } + }) + .map(cosmic::app::Message::App); } // Displays an error in the application's warning bar. @@ -208,15 +176,24 @@ impl cosmic::Application for App { self.error_status = Some(why); } - // Closes the warning bar, if it was shown. - Message::CloseError => { - self.error_status = None; + // Displays an error in the application's warning bar. + Message::OpenError(why) => { + if let Some(why) = Arc::into_inner(why) { + let mut source: &dyn std::error::Error = &why; + let mut string = + format!("open dialog subscription errored\n cause: {source}"); + + while let Some(new_source) = source.source() { + string.push_str(&format!("\n cause: {new_source}")); + source = new_source; + } + + self.error_status = Some(string); + } } - // The open dialog. subscription provides this on register. - Message::DialogInit(sender) => { - eprintln!("dialog subscription enabled"); - self.open_sender = Some(sender); + Message::CloseError => { + self.error_status = None; } } @@ -232,7 +209,8 @@ impl cosmic::Application for App { .on_close(Message::CloseError) .into(), ); - content.push(iced::widget::vertical_space(Length::Fixed(12.0)).into()) + + content.push(iced::widget::vertical_space(Length::Fixed(12.0)).into()); } content.push(if self.selected_file.is_none() { diff --git a/justfile b/justfile index 18e06b5..dcddf7e 100644 --- a/justfile +++ b/justfile @@ -11,10 +11,10 @@ check-examples *args: done check-wayland *args: - cargo clippy --no-deps --features="wayland,tokio" {{args}} -- {{clippy_args}} + cargo clippy --no-deps --features="wayland,tokio,xdg-portal" {{args}} -- {{clippy_args}} check-winit *args: - cargo clippy --no-deps --features="winit,tokio" {{args}} -- {{clippy_args}} + cargo clippy --no-deps --features="winit,tokio,xdg-portal" {{args}} -- {{clippy_args}} # Runs a check with JSON message format for IDE integration check-json: (check '--message-format=json') diff --git a/src/dialog/file_chooser/mod.rs b/src/dialog/file_chooser/mod.rs index ccb15c4..0f328e3 100644 --- a/src/dialog/file_chooser/mod.rs +++ b/src/dialog/file_chooser/mod.rs @@ -2,219 +2,147 @@ // SPDX-License-Identifier: MPL-2.0 //! Dialogs for opening and save files. +//! +//! # Features +//! +//! - On Linux, the `xdg-portal` feature will use XDG Portal dialogs. +//! - Alternatively, `rfd` can be used for platform support beyond Linux. +//! +//! # Open a file +//! +//! ```no_run +//! cosmic::command::future(async { +//! use cosmic::dialog::file_chooser; +//! +//! let dialog = file_chooser::open::Dialog::new() +//! .title("Choose a file"); +//! +//! match dialog.open_file().await { +//! Ok(response) => println!("selected to open {:?}", response.url()), +//! +//! Err(file_chooser::Error::Cancelled) => (), +//! +//! Err(why) => eprintln!("error selecting file to open: {why:?}") +//! } +//! }); +//! ``` +//! +//! # Open multiple files +//! +//! ```no_run +//! cosmic::command::future(async { +//! use cosmic::dialog::file_chooser; +//! +//! let dialog = file_chooser::open::Dialog::new() +//! .title("Choose multiple files"); +//! +//! match dialog.open_files().await { +//! Ok(response) => println!("selected to open {:?}", response.urls()), +//! +//! Err(file_chooser::Error::Cancelled) => (), +//! +//! Err(why) => eprintln!("error selecting file(s) to open: {why:?}") +//! } +//! }); +//! ``` +//! +//! # Open a folder +//! +//! ```no_run +//! cosmic::command::future(async { +//! use cosmic::dialog::file_chooser; +//! +//! let dialog = file_chooser::open::Dialog::new() +//! .title("Choose a folder"); +//! +//! match dialog.open_folder().await { +//! Ok(response) => println!("selected to open {:?}", response.url()), +//! +//! Err(file_chooser::Error::Cancelled) => (), +//! +//! Err(why) => eprintln!("error selecting folder to open: {why:?}") +//! } +//! }); +//! ``` +//! +//! # Open multiple folders +//! +//! ```no_run +//! cosmic::command::future(async { +//! use cosmic::dialog::file_chooser; +//! +//! let dialog = file_chooser::open::Dialog::new() +//! .title("Choose a folder"); +//! +//! match dialog.open_folders().await { +//! Ok(response) => println!("selected to open {:?}", response.urls()), +//! +//! Err(file_chooser::Error::Cancelled) => (), +//! +//! Err(why) => eprintln!("error selecting folder(s) to open: {why:?}") +//! } +//! }); +//! ``` +/// Open file dialog. pub mod open; + +/// Save file dialog. pub mod save; -pub use ashpd::desktop::file_chooser::{Choice, FileFilter, SelectedFiles}; -use iced::futures::{channel, SinkExt, StreamExt}; -use iced::{Command, Subscription}; -use std::sync::atomic::{AtomicBool, Ordering}; +#[cfg(feature = "xdg-portal")] +pub use ashpd::desktop::file_chooser::{Choice, FileFilter}; + use thiserror::Error; -/// Prevents duplicate file chooser dialog requests. -static OPENED: AtomicBool = AtomicBool::new(false); - -/// Whether a file chooser dialog is currently active. -fn dialog_active() -> bool { - OPENED.load(Ordering::Relaxed) +/// A file filter, to limit the available file choices to certain extensions. +#[cfg(feature = "rfd")] +#[must_use] +pub struct FileFilter { + description: String, + extensions: Vec, } -/// Sets the existence of a file chooser dialog. -fn dialog_active_set(value: bool) { - OPENED.store(value, Ordering::SeqCst); -} - -/// Creates an [`open::Dialog`] if no other file chooser exists. -pub fn open_file() -> Option { - if dialog_active() { - None - } else { - Some(open::Dialog::new()) - } -} - -/// Creates a [`save::Dialog`] if no other file chooser exists. -pub fn save_file() -> Option { - if dialog_active() { - None - } else { - Some(save::Dialog::new()) - } -} - -/// Creates a subscription for file chooser events. -pub fn subscription(handle: H) -> Subscription -where - M: Send + 'static, - H: Fn(Message) -> M + Send + Sync + 'static, -{ - let type_id = std::any::TypeId::of::>(); - - iced::subscription::channel(type_id, 1, move |output| async move { - let mut state = Handler { - active: None, - handle, - output, - }; - - loop { - let (sender, mut receiver) = channel::mpsc::channel(1); - - state.emit(Message::Init(Sender(sender))).await; - - while let Some(request) = receiver.next().await { - match request { - Request::Close => state.close().await, - - Request::Open(dialog) => { - state.open(dialog).await; - dialog_active_set(false); - } - - Request::Save(dialog) => { - state.save(dialog).await; - dialog_active_set(false); - } - - Request::Response => state.response().await, - } - } +#[cfg(feature = "rfd")] +impl FileFilter { + pub fn new(description: impl Into) -> Self { + Self { + description: description.into(), + extensions: Vec::new(), } - }) + } + + pub fn extension(mut self, extension: impl Into) -> Self { + self.extensions.push(extension.into()); + self + } } /// Errors that my occur when interacting with the file chooser subscription #[derive(Debug, Error)] pub enum Error { + #[error("dialog request cancelled")] + Cancelled, #[error("dialog close failed")] - Close(#[source] ashpd::Error), - #[error("dialog open failed")] - Open(#[source] ashpd::Error), + Close(#[source] DialogError), + #[error("open dialog failed")] + Open(#[source] DialogError), #[error("dialog response failed")] - Response(#[source] ashpd::Error), + Response(#[source] DialogError), + #[error("save dialog failed")] + Save(#[source] DialogError), + #[error("could not set directory")] + SetDirectory(#[source] DialogError), + #[error("could not set absolute path for file name")] + SetAbsolutePath(#[source] DialogError), + #[error("path from dialog was not absolute")] + UrlAbsolute, } -/// Requests for the file chooser subscription -enum Request { - Close, - Open(open::Dialog), - Save(save::Dialog), - Response, -} +#[cfg(feature = "xdg-portal")] +pub type DialogError = ashpd::Error; -/// Messages from the file chooser subscription. -pub enum Message { - Closed, - Err(Error), - Init(Sender), - Opened, - Selected(SelectedFiles), -} - -/// Sends requests to the file chooser subscription. -#[derive(Clone, Debug)] -pub struct Sender(channel::mpsc::Sender); - -impl Sender { - /// Creates a [`Command`] that closes a file chooser dialog. - pub fn close(&mut self) -> Command<()> { - let mut sender = self.0.clone(); - - crate::command::future(async move { - let _res = sender.send(Request::Close).await; - () - }) - } - - /// Creates a [`Command`] that opens the file chooser. - pub fn open(&mut self, dialog: open::Dialog) -> Command<()> { - dialog_active_set(true); - let mut sender = self.0.clone(); - - crate::command::future(async move { - let _res = sender.send(Request::Open(dialog)).await; - () - }) - } - - /// Creates a [`Command`] that requests the response from a file chooser dialog. - pub fn response(&mut self) -> Command<()> { - let mut sender = self.0.clone(); - - crate::command::future(async move { - let _res = sender.send(Request::Response).await; - () - }) - } - - /// Creates a [`Command`] that opens a new save file dialog. - pub fn save(&mut self, dialog: save::Dialog) -> Command<()> { - dialog_active_set(true); - let mut sender = self.0.clone(); - - crate::command::future(async move { - let _res = sender.send(Request::Save(dialog)).await; - () - }) - } -} - -struct Handler M> { - active: Option>, - handle: Handle, - output: channel::mpsc::Sender, -} - -impl M> Handler { - /// Emits close request if there is an active dialog request. - async fn close(&mut self) { - if let Some(request) = self.active.take() { - if let Err(why) = request.close().await { - self.emit(Message::Err(Error::Close(why))).await; - } - } - } - - async fn emit(&mut self, response: Message) { - let _res = self.output.send((self.handle)(response)).await; - } - - /// Creates a new dialog, and closes any prior active dialogs. - async fn open(&mut self, dialog: open::Dialog) { - let response = match open::create(dialog).await { - Ok(request) => { - self.active = Some(request); - Message::Opened - } - Err(why) => Message::Err(Error::Open(why)), - }; - - self.emit(response).await; - } - - /// Collects selected files from the active dialog. - async fn response(&mut self) { - if let Some(request) = self.active.as_ref() { - let response = match request.response() { - Ok(selected) => Message::Selected(selected), - Err(why) => Message::Err(Error::Response(why)), - }; - - self.emit(response).await; - } - } - - /// Creates a new dialog, and closes any prior active dialogs. - async fn save(&mut self, dialog: save::Dialog) { - let response = match save::create(dialog).await { - Ok(request) => { - self.active = Some(request); - Message::Opened - } - Err(why) => Message::Err(Error::Open(why)), - }; - - self.emit(response).await; - } -} +#[cfg(feature = "rfd")] +#[derive(Debug, Error)] +#[error("no file selected")] +pub struct DialogError {} diff --git a/src/dialog/file_chooser/open.rs b/src/dialog/file_chooser/open.rs index 20d4176..80e5ffb 100644 --- a/src/dialog/file_chooser/open.rs +++ b/src/dialog/file_chooser/open.rs @@ -6,85 +6,318 @@ //! Check out the [open-dialog](https://github.com/pop-os/libcosmic/tree/master/examples/open-dialog) //! example in our repository. -use derive_setters::Setters; -use iced::Command; +#[cfg(feature = "xdg-portal")] +pub use portal::{file, files, folder, folders, FileResponse, MultiFileResponse}; -/// A builder for an open file dialog, passed as a request by a [`Sender`] -#[derive(Setters)] +#[cfg(feature = "rfd")] +pub use rust_fd::{file, files, folder, folders, FileResponse, MultiFileResponse}; + +use super::Error; +use std::path::PathBuf; + +/// A builder for an open file dialog +#[derive(derive_setters::Setters)] #[must_use] pub struct Dialog { /// The label for the dialog's window title. + #[setters(into)] title: String, /// The label for the accept button. Mnemonic underlines are allowed. - #[setters(strip_option)] + #[cfg(feature = "xdg-portal")] + #[setters(skip)] accept_label: Option, - /// Whether to select for folders instead of files. Default is to select files. - include_directories: bool, + /// Sets the starting directory of the dialog. + #[setters(into, strip_option)] + #[allow(dead_code)] // TODO: ashpd does not expose this yet + directory: Option, + + /// Set starting file name of the dialog. + #[setters(into, strip_option)] + #[allow(dead_code)] // TODO: ashpd does not expose this yet + file_name: Option, /// Modal dialogs require user input before continuing the program. + #[cfg(feature = "xdg-portal")] + #[setters(skip)] modal: bool, - /// Whether to allow selection of multiple files. Default is no. - multiple_files: bool, - /// Adds a list of choices. + #[cfg(feature = "xdg-portal")] + #[setters(skip)] choices: Vec, /// Specifies the default file filter. - #[setters(into)] + #[cfg(feature = "xdg-portal")] + #[setters(skip)] current_filter: Option, /// A collection of file filters. - filters: Vec, + #[setters(skip)] + pub(self) filters: Vec, } impl Dialog { - pub(super) const fn new() -> Self { + pub const fn new() -> Self { Self { title: String::new(), + #[cfg(feature = "xdg-portal")] accept_label: None, - include_directories: false, + directory: None, + file_name: None, + #[cfg(feature = "xdg-portal")] modal: true, - multiple_files: false, + #[cfg(feature = "xdg-portal")] current_filter: None, + #[cfg(feature = "xdg-portal")] choices: Vec::new(), filters: Vec::new(), } } - /// Creates a [`Command`] which opens the dialog. - pub fn create(self, sender: &mut super::Sender) -> Command<()> { - sender.open(self) + /// The label for the accept button. Mnemonic underlines are allowed. + #[cfg(feature = "xdg-portal")] + pub fn accept_label(mut self, label: impl Into) -> Self { + self.accept_label = Some(label.into()); + self } /// Adds a choice. + #[cfg(feature = "xdg-portal")] pub fn choice(mut self, choice: impl Into) -> Self { self.choices.push(choice.into()); self } + /// Specifies the default file filter. + #[cfg(feature = "xdg-portal")] + pub fn current_filter(mut self, filter: impl Into) -> Self { + self.current_filter = Some(filter.into()); + self + } + /// Adds a files filter. pub fn filter(mut self, filter: impl Into) -> Self { self.filters.push(filter.into()); self } + + /// Modal dialogs require user input before continuing the program. + #[cfg(feature = "xdg-portal")] + pub fn modal(mut self, modal: bool) -> Self { + self.modal = modal; + self + } + + /// Create an open file dialog. + pub async fn open_file(self) -> Result { + file(self).await + } + + /// Create an open file dialog with multiple file select. + pub async fn open_files(self) -> Result { + files(self).await + } + + /// Create an open folder dialog. + pub async fn open_folder(self) -> Result { + folder(self).await + } + + /// Create an open folder dialog with multi file select. + pub async fn open_folders(self) -> Result { + folders(self).await + } } -/// Creates a new file dialog, and begins to await its responses. -pub(super) async fn create( - dialog: Dialog, -) -> ashpd::Result> { - ashpd::desktop::file_chooser::OpenFileRequest::default() - .title(Some(dialog.title.as_str())) - .accept_label(dialog.accept_label.as_deref()) - .directory(dialog.include_directories) - .modal(dialog.modal) - .multiple(dialog.multiple_files) - .choices(dialog.choices) - .filters(dialog.filters) - .current_filter(dialog.current_filter) - .send() - .await +#[cfg(feature = "xdg-portal")] +mod portal { + use super::Dialog; + use crate::dialog::file_chooser::Error; + use ashpd::desktop::file_chooser::SelectedFiles; + use url::Url; + + fn error_or_cancel(error: ashpd::Error) -> Error { + if let ashpd::Error::Response(ashpd::desktop::ResponseError::Cancelled) = error { + Error::Cancelled + } else { + Error::Open(error) + } + } + + /// Creates a new file dialog, and begins to await its responses. + #[cfg(feature = "xdg-portal")] + pub async fn create( + dialog: super::Dialog, + folders: bool, + multiple: bool, + ) -> Result, Error> { + // TODO: Set window identifier + ashpd::desktop::file_chooser::OpenFileRequest::default() + .title(Some(dialog.title.as_str())) + .accept_label(dialog.accept_label.as_deref()) + .directory(folders) + .modal(dialog.modal) + .multiple(multiple) + .choices(dialog.choices) + .filters(dialog.filters) + .current_filter(dialog.current_filter) + .send() + .await + .map_err(error_or_cancel) + } + + fn file_response( + request: ashpd::desktop::Request, + ) -> Result { + request + .response() + .map(FileResponse) + .map_err(error_or_cancel) + } + + fn multi_file_response( + request: ashpd::desktop::Request, + ) -> Result { + request + .response() + .map(MultiFileResponse) + .map_err(error_or_cancel) + } + + pub async fn file(dialog: Dialog) -> Result { + file_response(create(dialog, false, false).await?) + } + + pub async fn files(dialog: Dialog) -> Result { + multi_file_response(create(dialog, false, true).await?) + } + + pub async fn folder(dialog: Dialog) -> Result { + file_response(create(dialog, true, false).await?) + } + + pub async fn folders(dialog: Dialog) -> Result { + multi_file_response(create(dialog, true, true).await?) + } + + /// A dialog response containing the selected file or folder. + pub struct FileResponse(pub SelectedFiles); + + impl FileResponse { + pub fn choices(&self) -> &[(String, String)] { + self.0.choices() + } + + pub fn url(&self) -> &Url { + self.0.uris().first().expect("no files selected") + } + } + + /// A dialog response containing the selected file(s) or folder(s). + pub struct MultiFileResponse(pub SelectedFiles); + + impl MultiFileResponse { + pub fn choices(&self) -> &[(String, String)] { + self.0.choices() + } + + pub fn urls(&self) -> &[Url] { + self.0.uris() + } + } +} + +#[cfg(feature = "rfd")] +mod rust_fd { + use super::Dialog; + use crate::dialog::file_chooser::Error; + use url::Url; + + pub fn create(dialog: Dialog) -> rfd::AsyncFileDialog { + let mut builder = rfd::AsyncFileDialog::new().set_title(dialog.title); + + if let Some(directory) = dialog.directory { + builder = builder.set_directory(directory); + } + + if let Some(file_name) = dialog.file_name { + builder = builder.set_file_name(file_name); + } + + for filter in dialog.filters { + builder = builder.add_filter(filter.description, &filter.extensions); + } + + builder + } + + fn file_response(request: Option) -> Result { + if let Some(handle) = request { + let url = Url::from_file_path(handle.path()).map_err(|_| Error::UrlAbsolute)?; + + return Ok(FileResponse(url)); + } + + Err(Error::Cancelled) + } + + fn multi_file_response( + request: Option>, + ) -> Result { + if let Some(handles) = request { + let mut urls = Vec::with_capacity(handles.len()); + + for handle in &handles { + urls.push(Url::from_file_path(handle.path()).map_err(|()| Error::UrlAbsolute)?); + } + + return Ok(MultiFileResponse(urls)); + } + + Err(Error::Cancelled) + } + + pub async fn file(dialog: Dialog) -> Result { + file_response(create(dialog).pick_file().await) + } + + pub async fn files(dialog: Dialog) -> Result { + multi_file_response(create(dialog).pick_files().await) + } + + pub async fn folder(dialog: Dialog) -> Result { + file_response(create(dialog).pick_folder().await) + } + + pub async fn folders(dialog: Dialog) -> Result { + multi_file_response(create(dialog).pick_folders().await) + } + + /// A dialog response containing the selected file or folder. + pub struct FileResponse(Url); + + impl FileResponse { + pub fn choices(&self) -> &[(String, String)] { + &[] + } + + pub fn url(&self) -> &Url { + &self.0 + } + } + + /// A dialog response containing the selected file(s) or folder(s). + pub struct MultiFileResponse(Vec); + + impl MultiFileResponse { + pub fn choices(&self) -> &[(String, String)] { + &[] + } + + pub fn urls(&self) -> &[Url] { + &self.0 + } + } } diff --git a/src/dialog/file_chooser/save.rs b/src/dialog/file_chooser/save.rs index 0729b0f..63c0734 100644 --- a/src/dialog/file_chooser/save.rs +++ b/src/dialog/file_chooser/save.rs @@ -6,94 +6,201 @@ //! Check out the [open-dialog](https://github.com/pop-os/libcosmic/tree/master/examples/open-dialog) //! example in our repository. -use derive_setters::Setters; -use iced::Command; -use std::path::{Path, PathBuf}; +#[cfg(feature = "xdg-portal")] +pub use portal::{file, Response}; -/// A builder for an save file dialog, passed as a request by a [`Sender`] -#[derive(Setters)] +#[cfg(feature = "rfd")] +pub use rust_fd::{file, Response}; + +use super::Error; +use std::path::PathBuf; + +/// A builder for an save file dialog. +#[derive(derive_setters::Setters)] #[must_use] pub struct Dialog { /// The label for the dialog's window title. title: String, /// The label for the accept button. Mnemonic underlines are allowed. - #[setters(strip_option)] + #[cfg(feature = "xdg-portal")] + #[setters(skip)] accept_label: Option, /// Modal dialogs require user input before continuing the program. + #[cfg(feature = "xdg-portal")] + #[setters(skip)] modal: bool, - /// Sets the current file name. + /// Set starting file name of the dialog. #[setters(strip_option)] - current_name: Option, + file_name: Option, - /// Sets the current folder. + /// Sets the starting directory of the dialog. #[setters(strip_option)] - current_folder: Option, + directory: Option, /// Sets the absolute path of the file - #[setters(strip_option)] + #[cfg(feature = "xdg-portal")] + #[setters(skip)] current_file: Option, /// Adds a list of choices. + #[cfg(feature = "xdg-portal")] + #[setters(skip)] choices: Vec, /// Specifies the default file filter. - #[setters(into)] + #[cfg(feature = "xdg-portal")] + #[setters(skip)] current_filter: Option, /// A collection of file filters. + #[setters(skip)] filters: Vec, } impl Dialog { - pub(super) const fn new() -> Self { + pub const fn new() -> Self { Self { title: String::new(), + #[cfg(feature = "xdg-portal")] accept_label: None, + #[cfg(feature = "xdg-portal")] modal: true, - current_name: None, - current_folder: None, + file_name: None, + directory: None, + #[cfg(feature = "xdg-portal")] current_file: None, + #[cfg(feature = "xdg-portal")] current_filter: None, + #[cfg(feature = "xdg-portal")] choices: Vec::new(), filters: Vec::new(), } } - /// Creates a [`Command`] which opens the dialog. - pub fn create(self, sender: &mut super::Sender) -> Command<()> { - sender.save(self) + /// The label for the accept button. Mnemonic underlines are allowed. + #[cfg(feature = "xdg-portal")] + pub fn accept_label(mut self, label: impl Into) -> Self { + self.accept_label = Some(label.into()); + self } /// Adds a choice. + #[cfg(feature = "xdg-portal")] pub fn choice(mut self, choice: impl Into) -> Self { self.choices.push(choice.into()); self } + /// Set the current file filter. + #[cfg(feature = "xdg-portal")] + pub fn current_filter(mut self, filter: impl Into) -> Self { + self.current_filter = Some(filter.into()); + self + } + /// Adds a files filter. pub fn filter(mut self, filter: impl Into) -> Self { self.filters.push(filter.into()); self } + + /// Modal dialogs require user input before continuing the program. + #[cfg(feature = "xdg-portal")] + pub fn modal(mut self, modal: bool) -> Self { + self.modal = modal; + self + } + + /// Create a save file dialog request. + pub async fn save_file(self) -> Result { + file(self).await + } } -/// Creates a new file dialog, and begins to await its responses. -pub(super) async fn create( - dialog: Dialog, -) -> ashpd::Result> { - ashpd::desktop::file_chooser::SaveFileRequest::default() - .title(Some(dialog.title.as_str())) - .accept_label(dialog.accept_label.as_deref()) - .modal(dialog.modal) - .choices(dialog.choices) - .filters(dialog.filters) - .current_filter(dialog.current_filter) - .current_name(dialog.current_name.as_deref()) - .current_folder::<&Path>(dialog.current_folder.as_deref())? - .current_file::<&Path>(dialog.current_file.as_deref())? - .send() - .await +#[cfg(feature = "xdg-portal")] +mod portal { + use super::Dialog; + use crate::dialog::file_chooser::Error; + use ashpd::desktop::file_chooser::SelectedFiles; + use std::path::Path; + use url::Url; + + /// Create a save file dialog request. + pub async fn file(dialog: Dialog) -> Result { + ashpd::desktop::file_chooser::SaveFileRequest::default() + .title(Some(dialog.title.as_str())) + .accept_label(dialog.accept_label.as_deref()) + .modal(dialog.modal) + .choices(dialog.choices) + .filters(dialog.filters) + .current_filter(dialog.current_filter) + .current_name(dialog.file_name.as_deref()) + .current_folder::<&Path>(dialog.directory.as_deref()) + .map_err(Error::SetDirectory)? + .current_file::<&Path>(dialog.current_file.as_deref()) + .map_err(Error::SetAbsolutePath)? + .send() + .await + .map_err(Error::Save)? + .response() + .map_err(Error::Save) + .map(Response) + } + + /// A dialog response containing the selected file or folder. + pub struct Response(pub SelectedFiles); + + impl Response { + pub fn choices(&self) -> &[(String, String)] { + self.0.choices() + } + + pub fn url(&self) -> Option<&Url> { + self.0.uris().first() + } + } +} + +#[cfg(feature = "rfd")] +mod rust_fd { + use super::Dialog; + use crate::dialog::file_chooser::Error; + use url::Url; + + /// Create a save file dialog request. + pub async fn file(dialog: Dialog) -> Result { + let mut request = rfd::AsyncFileDialog::new().set_title(dialog.title); + + if let Some(directory) = dialog.directory { + request = request.set_directory(directory); + } + + if let Some(file_name) = dialog.file_name { + request = request.set_file_name(file_name); + } + + for filter in dialog.filters { + request = request.add_filter(filter.description, &filter.extensions); + } + + if let Some(handle) = request.save_file().await { + let url = Url::from_file_path(handle.path()).map_err(|_| Error::UrlAbsolute)?; + + return Ok(Response(Some(url))); + } + + Ok(Response(None)) + } + + /// A dialog response containing the selected file or folder. + pub struct Response(Option); + + impl Response { + pub fn url(&self) -> Option<&Url> { + self.0.as_ref() + } + } } diff --git a/src/dialog/mod.rs b/src/dialog/mod.rs index dc75309..66b3cec 100644 --- a/src/dialog/mod.rs +++ b/src/dialog/mod.rs @@ -3,6 +3,7 @@ //! Create dialogs for retrieving user input. -pub use ashpd::WindowIdentifier; +#[cfg(feature = "xdg-portal")] +pub use ashpd; pub mod file_chooser; diff --git a/src/lib.rs b/src/lib.rs index b00ad82..258b4e2 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -29,7 +29,7 @@ pub mod command; pub use cosmic_config; pub use cosmic_theme; -#[cfg(feature = "xdg-portal")] +#[cfg(any(feature = "xdg-portal", feature = "rfd"))] pub mod dialog; pub mod executor;