diff --git a/Cargo.toml b/Cargo.toml index a5fddae..3298613 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -41,6 +41,10 @@ windows = { version = "0.44", features = [ ] } [target.'cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))'.dependencies] +# Make sure that this is in sync with zbus, to avoid duplicated deps +async-io = "1.3" +futures-util = { version = "0.3", default-features = false, features = ["io"] } + # XDG Desktop Portal ashpd = { version = "0.3", optional = true } urlencoding = { version = "2.1.0", optional = true } diff --git a/src/backend.rs b/src/backend.rs index 8e3e74a..c9c8599 100644 --- a/src/backend.rs +++ b/src/backend.rs @@ -3,6 +3,18 @@ use std::future::Future; use std::path::PathBuf; use std::pin::Pin; +#[cfg(all( + any( + target_os = "linux", + target_os = "freebsd", + target_os = "dragonfly", + target_os = "netbsd", + target_os = "openbsd" + ), + not(feature = "gtk3") +))] +mod linux; + #[cfg(all( any( target_os = "linux", diff --git a/src/backend/linux/child_stdout.rs b/src/backend/linux/child_stdout.rs new file mode 100644 index 0000000..f7593d7 --- /dev/null +++ b/src/backend/linux/child_stdout.rs @@ -0,0 +1,26 @@ +use std::{ + io, + pin::Pin, + task::{Context, Poll}, +}; + +use async_io::Async; +use futures_util::AsyncRead; + +pub struct ChildStdout(Async); + +impl ChildStdout { + pub fn new(stdout: std::process::ChildStdout) -> io::Result { + Async::new(stdout).map(Self) + } +} + +impl AsyncRead for ChildStdout { + fn poll_read( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &mut [u8], + ) -> Poll> { + Pin::new(&mut self.0).poll_read(cx, buf) + } +} diff --git a/src/backend/linux/mod.rs b/src/backend/linux/mod.rs new file mode 100644 index 0000000..c719238 --- /dev/null +++ b/src/backend/linux/mod.rs @@ -0,0 +1,2 @@ +mod child_stdout; +pub(crate) mod zenity; diff --git a/src/backend/linux/zenity.rs b/src/backend/linux/zenity.rs new file mode 100644 index 0000000..32a3b3f --- /dev/null +++ b/src/backend/linux/zenity.rs @@ -0,0 +1,273 @@ +use futures_util::AsyncReadExt; +use std::{ + error::Error, + fmt::Display, + path::PathBuf, + process::{Command, Stdio}, + time::Duration, +}; + +use super::child_stdout::ChildStdout; +use crate::{ + file_dialog::Filter, + message_dialog::{MessageButtons, MessageLevel}, + FileDialog, +}; + +#[derive(Debug)] +pub enum ZenityError { + Io(std::io::Error), + StdOutNotFound, +} + +impl Error for ZenityError {} + +impl Display for ZenityError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ZenityError::Io(io) => write!(f, "{io}"), + ZenityError::StdOutNotFound => write!(f, "Stdout not found"), + } + } +} + +impl From for ZenityError { + fn from(value: std::io::Error) -> Self { + Self::Io(value) + } +} + +pub type ZenityResult = Result; + +fn command() -> Command { + Command::new("zenity") +} + +fn add_filters(command: &mut Command, filters: &[Filter]) { + for f in filters.iter() { + command.arg("--file-filter"); + + let extensions: Vec<_> = f + .extensions + .iter() + .map(|ext| format!("*.{}", ext)) + .collect(); + + command.arg(format!("{} | {}", f.name, extensions.join(" "))); + } +} + +fn add_filename(command: &mut Command, file_name: &Option) { + if let Some(name) = file_name.as_ref() { + command.arg("--filename"); + command.arg(name); + } +} + +async fn run(mut command: Command) -> ZenityResult> { + let mut process = command.stdout(Stdio::piped()).spawn()?; + + let stdout = process.stdout.take().ok_or(ZenityError::StdOutNotFound)?; + let mut stdout = ChildStdout::new(stdout)?; + + let mut buffer = String::new(); + stdout.read_to_string(&mut buffer).await?; + + let status = loop { + if let Some(status) = process.try_wait()? { + break status; + } + + async_io::Timer::after(Duration::from_millis(1)).await; + }; + + Ok(if status.success() { Some(buffer) } else { None }) +} + +#[allow(unused)] +pub async fn pick_file(dialog: &FileDialog) -> ZenityResult> { + let mut command = command(); + command.arg("--file-selection"); + + add_filters(&mut command, &dialog.filters); + add_filename(&mut command, &dialog.file_name); + + run(command).await.map(|res| { + res.map(|buffer| { + let trimed = buffer.trim(); + trimed.into() + }) + }) +} + +#[allow(unused)] +pub async fn pick_files(dialog: &FileDialog) -> ZenityResult> { + let mut command = command(); + command.args(["--file-selection", "--multiple"]); + + add_filters(&mut command, &dialog.filters); + add_filename(&mut command, &dialog.file_name); + + run(command).await.map(|res| { + res.map(|buffer| { + let list = buffer.trim().split('|').map(PathBuf::from).collect(); + list + }) + .unwrap_or(Vec::new()) + }) +} + +#[allow(unused)] +pub async fn pick_folder(dialog: &FileDialog) -> ZenityResult> { + let mut command = command(); + command.args(["--file-selection", "--directory"]); + + add_filters(&mut command, &dialog.filters); + add_filename(&mut command, &dialog.file_name); + + run(command).await.map(|res| { + res.map(|buffer| { + let trimed = buffer.trim(); + trimed.into() + }) + }) +} + +#[allow(unused)] +pub async fn save_file(dialog: &FileDialog) -> ZenityResult> { + let mut command = command(); + command.args(["--file-selection", "--save", "--confirm-overwrite"]); + + add_filters(&mut command, &dialog.filters); + add_filename(&mut command, &dialog.file_name); + + run(command).await.map(|res| { + res.map(|buffer| { + let trimed = buffer.trim(); + trimed.into() + }) + }) +} + +pub async fn message( + level: &MessageLevel, + btns: &MessageButtons, + title: &str, + description: &str, +) -> ZenityResult { + let cmd = match level { + MessageLevel::Info => "--info", + MessageLevel::Warning => "--warning", + MessageLevel::Error => "--error", + }; + + let ok_label = match btns { + MessageButtons::Ok => None, + MessageButtons::OkCustom(ok) => Some(ok), + _ => None, + }; + + let mut command = command(); + command.args([cmd, "--title", title, "--text", description]); + + if let Some(ok) = ok_label { + command.args(["--ok-label", ok]); + } + + run(command).await.map(|res| res.is_some()) +} + +pub async fn question(btns: &MessageButtons, title: &str, description: &str) -> ZenityResult { + let labels = match btns { + MessageButtons::OkCancel => Some(("Ok", "Cancel")), + MessageButtons::YesNo => None, + MessageButtons::OkCancelCustom(ok, cancel) => Some((ok.as_str(), cancel.as_str())), + _ => None, + }; + + let mut command = command(); + command.args(["--question", "--title", title, "--text", description]); + + if let Some((ok, cancel)) = labels { + command.args(["--ok-label", ok]); + command.args(["--cancel-label", cancel]); + } + + run(command).await.map(|res| res.is_some()) +} + +#[cfg(test)] +mod tests { + use crate::FileDialog; + + #[test] + #[ignore] + fn message() { + async_io::block_on(super::message( + &crate::message_dialog::MessageLevel::Info, + &crate::message_dialog::MessageButtons::Ok, + "hi", + "me", + )) + .unwrap(); + async_io::block_on(super::message( + &crate::message_dialog::MessageLevel::Warning, + &crate::message_dialog::MessageButtons::Ok, + "hi", + "me", + )) + .unwrap(); + async_io::block_on(super::message( + &crate::message_dialog::MessageLevel::Error, + &crate::message_dialog::MessageButtons::Ok, + "hi", + "me", + )) + .unwrap(); + } + + #[test] + #[ignore] + fn question() { + async_io::block_on(super::question( + &crate::message_dialog::MessageButtons::OkCancel, + "hi", + "me", + )) + .unwrap(); + async_io::block_on(super::question( + &crate::message_dialog::MessageButtons::YesNo, + "hi", + "me", + )) + .unwrap(); + } + + #[test] + #[ignore] + fn pick_file() { + let path = async_io::block_on(super::pick_file(&FileDialog::default())).unwrap(); + dbg!(path); + } + + #[test] + #[ignore] + fn pick_files() { + let path = async_io::block_on(super::pick_files(&FileDialog::default())).unwrap(); + dbg!(path); + } + + #[test] + #[ignore] + fn pick_folder() { + let path = async_io::block_on(super::pick_folder(&FileDialog::default())).unwrap(); + dbg!(path); + } + + #[test] + #[ignore] + fn save_file() { + let path = async_io::block_on(super::save_file(&FileDialog::default())).unwrap(); + dbg!(path); + } +} diff --git a/src/backend/xdg_desktop_portal.rs b/src/backend/xdg_desktop_portal.rs index 3f0a685..c1afec3 100644 --- a/src/backend/xdg_desktop_portal.rs +++ b/src/backend/xdg_desktop_portal.rs @@ -2,7 +2,8 @@ use std::path::PathBuf; use crate::backend::DialogFutureType; use crate::file_dialog::Filter; -use crate::{FileDialog, FileHandle}; +use crate::message_dialog::MessageDialog; +use crate::{FileDialog, FileHandle, MessageButtons}; use ashpd::desktop::file_chooser::{ FileChooserProxy, FileFilter, OpenFileOptions, SaveFileOptions, @@ -256,3 +257,55 @@ impl AsyncFileSaveDialogImpl for FileDialog { }) } } + +use crate::backend::MessageDialogImpl; +impl MessageDialogImpl for MessageDialog { + fn show(self) -> bool { + block_on(self.show_async()) + } +} + +use crate::backend::AsyncMessageDialogImpl; +impl AsyncMessageDialogImpl for MessageDialog { + fn show_async(self) -> DialogFutureType { + Box::pin(async move { + match &self.buttons { + MessageButtons::Ok | MessageButtons::OkCustom(_) => { + let res = crate::backend::linux::zenity::message( + &self.level, + &self.buttons, + &self.title, + &self.description, + ) + .await; + + match res { + Ok(res) => res, + Err(err) => { + log::error!("Failed to open zenity dialog: {err}"); + false + } + } + } + MessageButtons::OkCancel + | MessageButtons::YesNo + | MessageButtons::OkCancelCustom(_, _) => { + let res = crate::backend::linux::zenity::question( + &self.buttons, + &self.title, + &self.description, + ) + .await; + + match res { + Ok(res) => res, + Err(err) => { + log::error!("Failed to open zenity dialog: {err}"); + false + } + } + } + } + }) + } +} diff --git a/src/lib.rs b/src/lib.rs index 624f194..89cf1cd 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -136,36 +136,5 @@ pub use file_dialog::FileDialog; pub use file_dialog::AsyncFileDialog; -#[cfg(any( - target_os = "windows", - target_os = "macos", - target_family = "wasm", - all( - any( - target_os = "linux", - target_os = "freebsd", - target_os = "dragonfly", - target_os = "netbsd", - target_os = "openbsd" - ), - feature = "gtk3" - ) -))] mod message_dialog; - -#[cfg(any( - target_os = "windows", - target_os = "macos", - target_family = "wasm", - all( - any( - target_os = "linux", - target_os = "freebsd", - target_os = "dragonfly", - target_os = "netbsd", - target_os = "openbsd" - ), - feature = "gtk3" - ) -))] pub use message_dialog::{AsyncMessageDialog, MessageButtons, MessageDialog, MessageLevel};