From 1705b6fe27d9f674ea28034f0178efabd9ccf89f Mon Sep 17 00:00:00 2001 From: Michael Aaron Murphy Date: Tue, 15 Aug 2023 10:58:46 +0200 Subject: [PATCH] feat(dialog): XDG portal integrations for open and save dialogs --- Cargo.toml | 32 +++- examples/application/src/main.rs | 37 +++- examples/open-dialog/Cargo.toml | 16 ++ examples/open-dialog/src/main.rs | 259 ++++++++++++++++++++++++++++ justfile | 8 +- src/{command.rs => command/mod.rs} | 4 +- src/dialog/mod.rs | 9 + src/dialog/open_file.rs | 252 +++++++++++++++++++++++++++ src/dialog/save_file.rs | 262 +++++++++++++++++++++++++++++ src/lib.rs | 3 + 10 files changed, 861 insertions(+), 21 deletions(-) create mode 100644 examples/open-dialog/Cargo.toml create mode 100644 examples/open-dialog/src/main.rs rename src/{command.rs => command/mod.rs} (96%) create mode 100644 src/dialog/mod.rs create mode 100644 src/dialog/open_file.rs create mode 100644 src/dialog/save_file.rs diff --git a/Cargo.toml b/Cargo.toml index f311ede..9e9dcbd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,17 +8,29 @@ name = "cosmic" [features] default = ["wayland", "tokio", "a11y"] -debug = ["iced/debug"] +# Accessibility support a11y = ["iced/a11y", "iced_accessibility"] -wayland = ["iced/wayland", "iced_sctk", "sctk"] -wgpu = ["iced/wgpu", "iced_wgpu"] -tokio = ["dep:tokio", "iced/tokio"] -smol = ["iced/smol"] -winit = ["iced/winit", "iced_winit"] -winit_tokio = ["iced/winit", "iced_winit", "tokio"] -winit_debug = ["iced/winit", "iced_winit", "debug"] -winit_wgpu = ["winit", "wgpu"] +# Builds support for animated images animated-image = ["image", "dep:async-fs", "tokio?/io-util", "tokio?/fs"] +# Debug features +debug = ["iced/debug"] +# Enables pipewire support in ashpd, if ashpd is enabled +pipewire = ["ashpd?/pipewire"] +# smol async runtime +smol = ["iced/smol"] +# Tokio async runtime +tokio = ["dep:tokio", "ashpd/tokio", "iced/tokio"] +# Wayland window support +wayland = ["ashpd?/wayland", "iced/wayland", "iced_sctk", "sctk"] +# Render with wgpu +wgpu = ["iced/wgpu", "iced_wgpu"] +# X11 window support via winit +winit = ["iced/winit", "iced_winit"] +winit_debug = ["iced/winit", "iced_winit", "debug"] +winit_tokio = ["iced/winit", "iced_winit", "tokio"] +winit_wgpu = ["winit", "wgpu"] +# Enables XDG portal integrations +xdg-portal = ["ashpd"] [dependencies] apply = "0.3.0" @@ -34,6 +46,8 @@ tracing = "0.1" image = { version = "0.24.6", optional = true } thiserror = "1.0.44" async-fs = { version = "1.6", optional = true } +ashpd = { version = "0.5.0", default-features = false, optional = true } +url = "2.4.0" [target.'cfg(unix)'.dependencies] freedesktop-icons = "0.2.2" diff --git a/examples/application/src/main.rs b/examples/application/src/main.rs index 7833201..591556c 100644 --- a/examples/application/src/main.rs +++ b/examples/application/src/main.rs @@ -1,27 +1,46 @@ // Copyright 2023 System76 // SPDX-License-Identifier: MPL-2.0 -//! Testing ground for improving COSMIC application API ergonomics. +//! Application API example use cosmic::app::{Command, Core, Settings}; use cosmic::widget::nav_bar; use cosmic::{executor, iced, ApplicationExt, Element}; +#[derive(Clone, Copy)] +pub enum Page { + Page1, + Page2, + Page3, + Page4, +} + +impl Page { + const fn as_str(self) -> &'static str { + match self { + Page::Page1 => "Page 1", + Page::Page2 => "Page 2", + Page::Page3 => "Page 3", + Page::Page4 => "Page 4", + } + } +} + /// Runs application with these settings #[rustfmt::skip] fn main() -> Result<(), Box> { let input = vec![ - ("Page 1".into(), "🖖 Hello from libcosmic.".into()), - ("Page 2".into(), "🌟 This is an example application.".into()), - ("Page 3".into(), "🚧 The libcosmic API is not stable yet.".into()), - ("Page 4".into(), "🚀 Copy the source code and experiment today!".into()), + (Page::Page1, "🖖 Hello from libcosmic.".into()), + (Page::Page2, "🌟 This is an example application.".into()), + (Page::Page3, "🚧 The libcosmic API is not stable yet.".into()), + (Page::Page4, "🚀 Copy the source code and experiment today!".into()), ]; let settings = Settings::default() .antialiasing(true) .client_decorations(true) .debug(false) - .default_icon_theme("Pop") + .default_icon_theme(Some("Pop".into())) .default_text_size(16.0) .scale_factor(1.0) .size((1024, 768)) @@ -48,7 +67,7 @@ impl cosmic::Application for App { type Executor = executor::Default; /// Argument received [`cosmic::Application::new`]. - type Flags = Vec<(String, String)>; + type Flags = Vec<(Page, String)>; /// Message type specific to our [`App`]. type Message = Message; @@ -68,7 +87,7 @@ impl cosmic::Application for App { let mut nav_model = nav_bar::Model::default(); for (title, content) in input { - nav_model.insert().text(title).data(content); + nav_model.insert().text(title.as_str()).data(content); } nav_model.activate_position(0); @@ -91,10 +110,12 @@ impl cosmic::Application for App { self.update_title() } + /// Handle application events here. fn update(&mut self, _message: Self::Message) -> Command { Command::none() } + /// Creates a view after each update. fn view(&self) -> Element { let page_content = self .nav_model diff --git a/examples/open-dialog/Cargo.toml b/examples/open-dialog/Cargo.toml new file mode 100644 index 0000000..71e32ac --- /dev/null +++ b/examples/open-dialog/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "open_dialog" +version = "0.1.0" +edition = "2021" + +[dependencies] +apply = "0.3.0" +tokio = { version = "1.31", features = ["full"] } +tracing = "0.1.37" +tracing-subscriber = "0.3.17" +url = "2.4.0" + +[dependencies.libcosmic] +path = "../../" +default-features = false +features = ["debug", "wayland", "tokio", "xdg-portal"] diff --git a/examples/open-dialog/src/main.rs b/examples/open-dialog/src/main.rs new file mode 100644 index 0000000..7c1cdeb --- /dev/null +++ b/examples/open-dialog/src/main.rs @@ -0,0 +1,259 @@ +// Copyright 2023 System76 +// SPDX-License-Identifier: MPL-2.0 + +//! An application which provides an open dialog + +use apply::Apply; +use cosmic::app::{Command, Core, Settings}; +use cosmic::dialog::{open_file, FileFilter}; +use cosmic::iced_core::Length; +use cosmic::{executor, iced, ApplicationExt, Element}; +use tokio::io::AsyncReadExt; +use url::Url; + +/// Runs application with these settings +#[rustfmt::skip] +fn main() -> Result<(), Box> { + let settings = Settings::default() + .size((1024, 768)); + + cosmic::app::run::(settings, ())?; + + Ok(()) +} + +/// Messages that are used specifically by our [`App`]. +#[derive(Clone, Debug)] +pub enum Message { + CloseError, + DialogClosed, + DialogInit(open_file::Sender), + DialogOpened, + Error(String), + FileRead(Url, String), + OpenFile, + Selected(Url), +} + +/// The [`App`] stores application-specific state. +pub struct App { + core: Core, + open_sender: Option, + file_contents: String, + selected_file: Option, + error_status: Option, +} + +/// Implement [`cosmic::Application`] to integrate with COSMIC. +impl cosmic::Application for App { + /// Default async executor to use with the app. + type Executor = executor::Default; + + /// Argument received [`cosmic::Application::new`]. + type Flags = (); + + /// Message type specific to our [`App`]. + type Message = Message; + + const APP_ID: &'static str = "org.cosmic.OpenDialogDemo"; + + fn core(&self) -> &Core { + &self.core + } + + fn core_mut(&mut self) -> &mut Core { + &mut self.core + } + + /// Creates the application, and optionally emits command on initialize. + 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, + }; + + let command = app.set_title("Open a file".into()); + + (app, command) + } + + fn header_end(&self) -> Vec> { + // Places a button the header to create open dialogs. + vec![cosmic::widget::button(cosmic::theme::Button::Primary) + .text("Open") + .on_press(Message::OpenFile) + .into()] + } + + fn subscription(&self) -> cosmic::iced_futures::Subscription { + // Creates a subscription for handling open dialogs. + open_file::subscription(|response| match response { + open_file::Message::Closed => Message::DialogClosed, + open_file::Message::Opened => Message::DialogOpened, + open_file::Message::Selected(files) => match files.uris().first() { + Some(file) => Message::Selected(file.to_owned()), + None => Message::DialogClosed, + }, + open_file::Message::Init(sender) => Message::DialogInit(sender), + open_file::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::FileRead(url, contents) => { + eprintln!("read file"); + self.selected_file = Some(url); + self.file_contents = contents; + } + + Message::Selected(url) => { + eprintln!("selected file"); + + // Take existing file contents buffer to reuse its allocation. + let mut contents = String::new(); + std::mem::swap(&mut contents, &mut self.file_contents); + + return Command::batch(vec![ + // Set the file's URL as the application title. + self.set_title(url.to_string()), + // Reads the selected file into memory. + cosmic::command::future(async move { + // Check if its a valid local file path. + let path = match url.scheme() { + "file" => url.path(), + other => { + return Message::Error(format!( + "{url} has unknown scheme: {other}" + )); + } + }; + + // Open the file by its path. + let mut file = match tokio::fs::File::open(path).await { + Ok(file) => file, + Err(why) => { + return Message::Error(format!("failed to open {path}: {why}")); + } + }; + + // Read the file into our contents buffer. + contents.clear(); + + if let Err(why) = file.read_to_string(&mut contents).await { + return Message::Error(format!("failed to read {path}: {why}")); + } + + contents.shrink_to_fit(); + + // Send this back to the application. + Message::FileRead(url, contents) + }) + .map(cosmic::app::message::app), + ]); + } + + // Creates a new open dialog. + Message::OpenFile => { + if let Some(sender) = self.open_sender.as_mut() { + if let Some(dialog) = open_file::builder() { + 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()); + } + } + } + + // Displays an error in the application's warning bar. + Message::Error(why) => { + self.error_status = Some(why); + } + + // Closes the warning bar, if it was shown. + Message::CloseError => { + self.error_status = None; + } + + // The open dialog. subscription provides this on register. + Message::DialogInit(sender) => { + eprintln!("dialog subscription enabled"); + self.open_sender = Some(sender); + } + } + + Command::none() + } + + fn view(&self) -> Element { + let mut content = Vec::new(); + + if let Some(error) = self.error_status.as_deref() { + content.push( + cosmic::widget::warning(error) + .on_close(Message::CloseError) + .into(), + ); + content.push(iced::widget::vertical_space(Length::Fixed(12.0)).into()) + } + + content.push(if self.selected_file.is_none() { + center(iced::widget::text("Choose a text file")) + } else { + cosmic::widget::text(&self.file_contents) + .apply(iced::widget::scrollable) + .width(iced::Length::Fill) + .into() + }); + + iced::widget::column(content).into() + } +} + +fn center<'a>(input: impl Into> + 'a) -> Element<'a, Message> { + iced::widget::container(input.into()) + .width(iced::Length::Fill) + .height(iced::Length::Fill) + .align_x(iced::alignment::Horizontal::Center) + .align_y(iced::alignment::Vertical::Center) + .into() +} diff --git a/justfile b/justfile index 8b01b8a..163ebce 100644 --- a/justfile +++ b/justfile @@ -1,10 +1,12 @@ +projects := 'application cosmic cosmic_sctk open_dialog' + # Check for errors and linter warnings check *args: cargo clippy --no-deps {{args}} -- -W clippy::pedantic cargo clippy --no-deps --no-default-features --features="winit,tokio" {{args}} -- -W clippy::pedantic - cargo check -p application {{args}} - cargo check -p cosmic {{args}} - cargo check -p cosmic_sctk {{args}} + for project in {{projects}}; do \ + cargo check -p ${project}; \ + done # Runs a check with JSON message format for IDE integration check-json: (check '--message-format=json') diff --git a/src/command.rs b/src/command/mod.rs similarity index 96% rename from src/command.rs rename to src/command/mod.rs index a4e416c..48300e2 100644 --- a/src/command.rs +++ b/src/command/mod.rs @@ -1,6 +1,8 @@ // Copyright 2023 System76 // SPDX-License-Identifier: MPL-2.0 +//! Create asynchronous actions to be performed in the background. + #[cfg(feature = "wayland")] use iced::window; use iced::Command; @@ -21,7 +23,7 @@ pub fn batch(commands: impl IntoIterator>) -> Command { Command::batch(commands) } -/// Yields a command which will run the future on the runtime executor. +/// Yields a command which will run the future on thet runtime executor. pub fn future(future: impl Future + Send + 'static) -> Command { Command::single(Action::Future(Box::pin(future))) } diff --git a/src/dialog/mod.rs b/src/dialog/mod.rs new file mode 100644 index 0000000..f4d8553 --- /dev/null +++ b/src/dialog/mod.rs @@ -0,0 +1,9 @@ +// Copyright 2023 System76 +// SPDX-License-Identifier: MPL-2.0 + +//! Create dialogs for retrieving user input. + +pub use ashpd::desktop::file_chooser::{Choice, FileFilter, SelectedFiles}; +pub use ashpd::WindowIdentifier; + +pub mod open_file; diff --git a/src/dialog/open_file.rs b/src/dialog/open_file.rs new file mode 100644 index 0000000..cbb92cf --- /dev/null +++ b/src/dialog/open_file.rs @@ -0,0 +1,252 @@ +// Copyright 2023 System76 +// SPDX-License-Identifier: MPL-2.0 + +//! Request to open files and/or directories. +//! +//! 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::futures::{channel, SinkExt, StreamExt}; +use iced::{Command, Subscription}; +use std::cell::Cell; +use thiserror::Error; + +thread_local! { + /// Prevents duplicate dialog open requests. + static OPENED: Cell = Cell::new(false); +} + +fn dialog_is_open() -> bool { + OPENED.with(Cell::get) +} + +/// Creates a [`Builder`] if no other open file dialog exists. +pub fn builder() -> Option { + if dialog_is_open() { + None + } else { + Some(Builder::new()) + } +} + +/// Creates a subscription for open file dialog events. +pub fn subscription(handle: fn(Message) -> M) -> Subscription { + let type_id = std::any::TypeId::of::>(); + + iced::subscription::channel(type_id, 1, move |output| async move { + let mut state = State { + 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; + OPENED.with(|last| last.set(false)); + } + + Request::Response => state.response().await, + } + } + } + }) +} + +/// Errors that my occur when interacting with an open file dialog subscription +#[derive(Debug, Error)] +pub enum Error { + #[error("dialog close failed")] + Close(#[source] ashpd::Error), + #[error("dialog open failed")] + Open(#[source] ashpd::Error), + #[error("dialog response failed")] + Response(#[source] ashpd::Error), +} + +/// Requests for an open file dialog subscription +enum Request { + Close, + Open(Builder), + Response, +} + +/// Messages from an open file dialog subscription. +pub enum Message { + Closed, + Err(Error), + Init(Sender), + Opened, + Selected(super::SelectedFiles), +} + +/// Sends requests to an open file dialog subscription. +#[derive(Clone, Debug)] +pub struct Sender(channel::mpsc::Sender); + +impl Sender { + /// Creates a [`Command`] that closes an active open file 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 a new open file dialog. + pub fn open(&mut self, dialog: Builder) -> Command<()> { + OPENED.with(|opened| opened.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 an active open file 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; + () + }) + } +} + +/// A builder for an open file dialog, passed as a request by a [`Sender`] +#[derive(Setters)] +#[must_use] +pub struct Builder { + /// The lab for the dialog's window title. + title: String, + + /// The label for the accept button. Mnemonic underlines are allowed. + #[setters(strip_option)] + accept_label: Option, + + /// Whether to select for folders instead of files. Default is to select files. + include_directories: bool, + + /// Modal dialogs require user input before continuing the program. + modal: bool, + + /// Whether to allow selection of multiple files. Default is no. + multiple_files: bool, + + /// Adds a list of choices. + choices: Vec, + + /// Specifies the default file filter. + #[setters(into)] + current_filter: Option, + + /// A collection of file filters. + filters: Vec, +} + +impl Builder { + const fn new() -> Self { + Self { + title: String::new(), + accept_label: None, + include_directories: false, + modal: true, + multiple_files: false, + current_filter: None, + choices: Vec::new(), + filters: Vec::new(), + } + } + + /// Creates a [`Command`] which opens the dialog. + pub fn create(self, sender: &mut Sender) -> Command<()> { + sender.open(self) + } + + /// Adds a choice. + pub fn choice(mut self, choice: impl Into) -> Self { + self.choices.push(choice.into()); + self + } + + /// Adds a files filter. + pub fn filter(mut self, filter: impl Into) -> Self { + self.filters.push(filter.into()); + self + } +} + +struct State { + active: Option>, + handle: fn(Message) -> M, + output: channel::mpsc::Sender, +} + +impl State { + /// 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: Builder) { + let response = match 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 file dialog, and begins to await its responses. +async fn create(dialog: Builder) -> 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 +} diff --git a/src/dialog/save_file.rs b/src/dialog/save_file.rs new file mode 100644 index 0000000..9da9c7d --- /dev/null +++ b/src/dialog/save_file.rs @@ -0,0 +1,262 @@ +// Copyright 2023 System76 +// SPDX-License-Identifier: MPL-2.0 + +//! Choose a location to save a file to. +//! +//! 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, Subscription}; +use iced::futures::{channel, SinkExt, StreamExt}; +use std::cell::Cell; +use std::path::PathBuf; +use std::time::Instant; +use thiserror::Error; + +thread_local! { + /// Prevents duplicate dialog open requests. + static OPENED: Cell = Cell::new(false); +} + +fn dialog_is_open() -> bool { + OPENED.with(Cell::get) +} + +/// Creates a [`Builder`] if no other save file dialog exists. +pub fn builder() -> Option { + if dialog_is_open() { + None + } else { + Some(Builder::new()) + } +} + +/// Creates a subscription for save file dialog events. +pub fn subscription(handle: fn(Message) -> M) -> Subscription { + let type_id = std::any::TypeId::of::>(); + + iced::subscription::channel(type_id, 1, move |output| async move { + let mut state = State { + 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; + OPENED.with(|last| last.set(false)); + }, + + Request::Response => state.response().await, + } + } + } + }) +} + +/// Errors that my occur when interacting with an save file dialog subscription +#[derive(Debug, Error)] +pub enum Error { + #[error("dialog close failed")] + Close(#[source] ashpd::Error), + #[error("dialog open failed")] + Open(#[source] ashpd::Error), + #[error("dialog response failed")] + Response(#[source] ashpd::Error), +} + +/// Requests for an save file dialog subscription +enum Request { + Close, + Open(Builder), + Response, +} + +/// Messages from an save file dialog subscription. +pub enum Message { + Closed, + Err(Error), + Init(Sender), + Opened, + Selected(super::SelectedFiles), +} + +/// Sends requests to an save file dialog subscription. +#[derive(Clone, Debug)] +pub struct Sender(channel::mpsc::Sender); + +impl Sender { + /// Creates a [`Command`] that closes an active save file 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 a new save file dialog. + pub fn open(&mut self, dialog: Builder) -> Command<()> { + OPENED.with(|opened| opened.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 an active save file 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; + () + }) + } +} + +/// A builder for an save file dialog, passed as a request by a [`Sender`] +#[derive(Setters)] +#[must_use] +pub struct Builder { + /// The lab for the dialog's window title. + title: String, + + /// The label for the accept button. Mnemonic underlines are allowed. + #[setters(strip_option)] + accept_label: Option, + + /// Modal dialogs require user input before continuing the program. + modal: bool, + + /// Sets the current file name. + #[setters(strip_option)] + current_name: Option, + + /// Sets the current folder. + #[setters(strip_option)] + current_folder: Option, + + /// Sets the absolute path of the file + #[setters(strip_option)] + current_file: Option, + + /// Adds a list of choices. + choices: Vec, + + /// Specifies the default file filter. + #[setters(into)] + current_filter: Option, + + /// A collection of file filters. + filters: Vec, +} + +impl Builder { + const fn new() -> Self { + Self { + title: String::new(), + accept_label: None, + modal: true, + current_name: None, + current_folder: None, + current_file: None, + current_filter: None, + choices: Vec::new(), + filters: Vec::new(), + } + } + + /// Creates a [`Command`] which opens the dialog. + pub fn create(self, sender: &mut Sender) -> Command<()> { + sender.open(self) + } + + /// Adds a choice. + pub fn choice(mut self, choice: impl Into) -> Self { + self.choices.push(choice.into()); + self + } + + /// Adds a files filter. + pub fn filter(mut self, filter: impl Into) -> Self { + self.filters.push(filter.into()); + self + } +} + +struct State { + active: Option>, + handle: fn(Message) -> M, + output: channel::mpsc::Sender, +} + +impl State { + /// 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: Builder) { + let response = match 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::Message(why)), + }; + + self.emit(response).await; + } + } +} + +/// Creates a new file dialog, and begins to await its responses. +async fn create(dialog: Builder) -> 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) + .current_folder(dialog.current_folder)? + .current_file(dialog.current_file)? + .send() + .await +} diff --git a/src/lib.rs b/src/lib.rs index 04b9405..576332e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -10,6 +10,9 @@ pub mod command; pub use cosmic_config; pub use cosmic_theme; +#[cfg(feature = "xdg-portal")] +pub mod dialog; + pub mod executor; #[cfg(feature = "tokio")] pub use executor::single::Executor as SingleThreadExecutor;