Skip to main content

any_dialog/
lib.rs

1//! Simple desktop dialogs via whatever dialog helper the system
2//! already has: `kdialog` or `zenity` on Linux, `osascript` on
3//! macOS, or PowerShell on Windows. The crate links no GUI
4//! toolkit, has zero dependencies, and uses only the standard
5//! library.
6//!
7//! [`Dialogs::new`] probes `PATH` for a helper, preferring the
8//! operating system's own bundled tool on macOS and Windows, and
9//! every method on the handle renders through the helper it
10//! found. If no helper exists, calls fail with
11//! [`Error::NoBackend`]; check [`Dialogs::available`] first if
12//! that matters.
13//!
14//! Prompt methods return `Ok(None)` when the user cancels, so a
15//! cancel is a value to handle, not an error:
16//!
17//! ```no_run
18//! use any_dialog::Dialogs;
19//!
20//! fn main() -> Result<(), any_dialog::Error> {
21//!     let dialogs = Dialogs::new();
22//!     if let Some(name) = dialogs.prompt_text("Example", "Your name:", None)? {
23//!         println!("hello, {name}");
24//!     }
25//!     Ok(())
26//! }
27//! ```
28//!
29//! Every call blocks until the user answers, so callers should
30//! run them on a worker thread rather than on a UI or event-loop
31//! thread.
32
33mod backends;
34mod error;
35mod outcome;
36mod request;
37
38pub use backends::Backend;
39pub use error::Error;
40pub use request::{FileFilter, FileFilterBuilder};
41
42use std::path::{Path, PathBuf};
43use std::process::{Command, Stdio};
44
45use backends::{argv, classify, decode_choices, env};
46use outcome::Outcome;
47use request::{Level, Request};
48
49/// A handle to the best dialog backend found on this machine.
50#[derive(Clone, Debug)]
51pub struct Dialogs {
52    backend: Option<(Backend, PathBuf)>,
53}
54
55impl Dialogs {
56    /// Probe `PATH` for a backend, in the backends module's probe
57    /// order.
58    pub fn new() -> Dialogs {
59        Dialogs {
60            backend: backends::probe(),
61        }
62    }
63
64    /// Build a handle from explicit executable paths, bypassing `PATH`.
65    /// Primarily useful for tests. The first path present wins, in the
66    /// order kdialog, zenity, osascript, powershell.
67    pub fn with_paths(
68        kdialog: Option<PathBuf>,
69        zenity: Option<PathBuf>,
70        osascript: Option<PathBuf>,
71        powershell: Option<PathBuf>,
72    ) -> Dialogs {
73        let backend = kdialog
74            .map(|path| (Backend::Kdialog, path))
75            .or_else(|| zenity.map(|path| (Backend::Zenity, path)))
76            .or_else(|| osascript.map(|path| (Backend::Osascript, path)))
77            .or_else(|| powershell.map(|path| (Backend::Powershell, path)));
78        Dialogs { backend }
79    }
80
81    /// Is there a backend to drive?
82    pub fn available(&self) -> bool {
83        self.backend.is_some()
84    }
85
86    /// Which backend this handle drives, if any. Mostly useful
87    /// for logging and diagnostics: the dialogs themselves do
88    /// not depend on which helper renders them.
89    pub fn backend(&self) -> Option<Backend> {
90        self.backend.as_ref().map(|(backend, _)| *backend)
91    }
92
93    /// Ask for a line of text. `Ok(None)` means the user cancelled.
94    pub fn prompt_text(
95        &self,
96        title: &str,
97        label: &str,
98        initial: Option<&str>,
99    ) -> Result<Option<String>, Error> {
100        self.run(Request::Text {
101            title,
102            label,
103            initial,
104        })
105    }
106
107    /// Ask for a password or other secret. The text is hidden while
108    /// typed. `Ok(None)` means the user cancelled.
109    pub fn prompt_password(&self, title: &str, label: &str) -> Result<Option<String>, Error> {
110        self.run(Request::Password { title, label })
111    }
112
113    /// Ask the user to pick one of `choices`. `Ok(None)` means they cancelled.
114    #[allow(dead_code)] // part of the liftable adapter API; the tray has no picker yet
115    pub fn prompt_choice(
116        &self,
117        title: &str,
118        label: &str,
119        choices: &[&str],
120        current: Option<&str>,
121    ) -> Result<Option<String>, Error> {
122        self.run(Request::Choice {
123            title,
124            label,
125            choices,
126            current,
127        })
128    }
129
130    /// Ask the user to pick any number of `choices`, starting with
131    /// `current` checked. `Ok(None)` means they cancelled;
132    /// `Ok(Some(vec![]))` means they confirmed with nothing picked.
133    pub fn prompt_choices(
134        &self,
135        title: &str,
136        label: &str,
137        choices: &[&str],
138        current: &[&str],
139    ) -> Result<Option<Vec<String>>, Error> {
140        let backend = self.backend.as_ref().map(|(backend, _)| *backend);
141        let request = Request::Choices {
142            title,
143            label,
144            choices,
145            current,
146        };
147        match self.run_completed(request)? {
148            // A run that answered implies a backend was there to
149            // answer, so the decode always has its backend.
150            Completed::Answered(Some(text)) => Ok(Some(decode_choices(
151                backend.expect("an answered run has a backend"),
152                &text,
153                choices,
154            ))),
155            Completed::Answered(None) => Ok(Some(Vec::new())),
156            Completed::Cancelled => Ok(None),
157        }
158    }
159
160    /// Ask the user to pick a color, starting from `initial` (a
161    /// `#rrggbb` string) where the backend supports one. The
162    /// answer is normalized to lowercase `#rrggbb`. `Ok(None)`
163    /// means the user cancelled.
164    pub fn prompt_color(
165        &self,
166        title: &str,
167        initial: Option<&str>,
168    ) -> Result<Option<String>, Error> {
169        match self.run(Request::Color { title, initial })? {
170            Some(raw) => Ok(Some(normalize_color(&raw))),
171            None => Ok(None),
172        }
173    }
174
175    /// Ask the user to pick a file to open. `start` is where the
176    /// dialog opens: a directory, or a file whose folder it opens
177    /// on. `Ok(None)` means they cancelled.
178    pub fn prompt_open_file(
179        &self,
180        title: &str,
181        start: Option<&Path>,
182        filters: &[FileFilter],
183    ) -> Result<Option<PathBuf>, Error> {
184        self.run(Request::OpenFile {
185            title,
186            start,
187            filters,
188            multiple: false,
189        })
190        .map(|answer| answer.map(PathBuf::from))
191    }
192
193    /// Ask the user to pick any number of files to open, starting
194    /// at `start`. `Ok(None)` means they cancelled.
195    pub fn prompt_open_files(
196        &self,
197        title: &str,
198        start: Option<&Path>,
199        filters: &[FileFilter],
200    ) -> Result<Option<Vec<PathBuf>>, Error> {
201        let request = Request::OpenFile {
202            title,
203            start,
204            filters,
205            multiple: true,
206        };
207        match self.run_completed(request)? {
208            Completed::Answered(Some(text)) => Ok(Some(text.lines().map(PathBuf::from).collect())),
209            Completed::Answered(None) => Ok(Some(Vec::new())),
210            Completed::Cancelled => Ok(None),
211        }
212    }
213
214    /// Ask the user to pick a file to save to. `start` may name a
215    /// suggested file, whose name pre-fills the dialog where the
216    /// backend supports it. Native overwrite confirmation is left
217    /// to the dialog itself. `Ok(None)` means they cancelled.
218    pub fn prompt_save_file(
219        &self,
220        title: &str,
221        start: Option<&Path>,
222        filters: &[FileFilter],
223    ) -> Result<Option<PathBuf>, Error> {
224        self.run(Request::SaveFile {
225            title,
226            start,
227            filters,
228        })
229        .map(|answer| answer.map(PathBuf::from))
230    }
231
232    /// Ask the user to pick a folder, starting at `start`.
233    /// `Ok(None)` means they cancelled.
234    pub fn prompt_folder(
235        &self,
236        title: &str,
237        start: Option<&Path>,
238    ) -> Result<Option<PathBuf>, Error> {
239        self.run(Request::Folder { title, start })
240            .map(|answer| answer.map(PathBuf::from))
241    }
242
243    /// Show an informational message with a single dismiss button.
244    pub fn show_info(&self, title: &str, message: &str) -> Result<(), Error> {
245        self.run(Request::Message {
246            title,
247            message,
248            level: Level::Info,
249        })
250        .map(|_| ())
251    }
252
253    /// Show a warning message with a single dismiss button.
254    pub fn show_warning(&self, title: &str, message: &str) -> Result<(), Error> {
255        self.run(Request::Message {
256            title,
257            message,
258            level: Level::Warning,
259        })
260        .map(|_| ())
261    }
262
263    /// Show an error message with a single dismiss button.
264    pub fn show_error(&self, title: &str, message: &str) -> Result<(), Error> {
265        self.run(Request::Message {
266            title,
267            message,
268            level: Level::Error,
269        })
270        .map(|_| ())
271    }
272
273    /// Ask a yes/no question. `Ok(true)` is yes; `Ok(false)` is no,
274    /// and also covers the dialog being dismissed without answering,
275    /// which most backends cannot tell apart from no.
276    pub fn confirm(&self, title: &str, message: &str) -> Result<bool, Error> {
277        match self.run_completed(Request::Confirm { title, message })? {
278            Completed::Answered(Some(text)) => Ok(text == "yes"),
279            Completed::Answered(None) => Ok(true),
280            Completed::Cancelled => Ok(false),
281        }
282    }
283
284    fn run(&self, request: Request<'_>) -> Result<Option<String>, Error> {
285        match self.run_completed(request)? {
286            Completed::Answered(text) => Ok(text),
287            Completed::Cancelled => Ok(None),
288        }
289    }
290
291    fn run_completed(&self, request: Request<'_>) -> Result<Completed, Error> {
292        let Some((backend, program)) = &self.backend else {
293            return Err(Error::NoBackend);
294        };
295
296        let mut command = Command::new(program);
297        command.args(argv(*backend, &request)).stdin(Stdio::null());
298        for (name, value) in env(*backend, &request) {
299            command.env(name, value);
300        }
301        // powershell.exe is a console application. Spawned from
302        // a GUI process (one with no console of its own, such as
303        // an Explorer-launched exe), Windows would give it a
304        // console window of its own, visible next to the dialog.
305        // CREATE_NO_WINDOW suppresses that; the child's stdio is
306        // piped, so it loses nothing it uses. Console parents
307        // (terminals, the probe) are unaffected in practice: the
308        // child simply does not share their console either, and
309        // it never writes to one.
310        #[cfg(windows)]
311        {
312            use std::os::windows::process::CommandExt;
313            const CREATE_NO_WINDOW: u32 = 0x0800_0000;
314            command.creation_flags(CREATE_NO_WINDOW);
315        }
316
317        let output = command.output().map_err(|source| Error::Spawn {
318            program: program.clone(),
319            source,
320        })?;
321
322        match classify(
323            *backend,
324            output.status.code(),
325            &String::from_utf8_lossy(&output.stdout),
326            &String::from_utf8_lossy(&output.stderr),
327        ) {
328            Outcome::Answered(text) => Ok(Completed::Answered(text)),
329            Outcome::Cancelled => Ok(Completed::Cancelled),
330            Outcome::Failed => Err(Error::Exit {
331                program: program.clone(),
332                status: output.status,
333            }),
334        }
335    }
336}
337
338/// A dialog run that finished: either it was answered (with whatever
339/// text the backend produced, if any) or it was cancelled. Failures
340/// travel as `Err` instead, so this never carries [`Outcome::Failed`].
341enum Completed {
342    Answered(Option<String>),
343    Cancelled,
344}
345
346/// Normalize a helper's color answer to lowercase `#rrggbb`.
347/// kdialog, osascript, and PowerShell already answer in hex;
348/// zenity answers in CSS `rgb()` / `rgba()` form. Anything
349/// unrecognized passes through trimmed and lowercased, so a
350/// helper changing its answer format degrades visibly instead of
351/// silently corrupting the value.
352fn normalize_color(raw: &str) -> String {
353    let raw = raw.trim().to_lowercase();
354    let hex = raw.strip_prefix('#');
355    if hex.is_some_and(|hex| hex.len() == 6 && hex.bytes().all(|byte| byte.is_ascii_hexdigit())) {
356        return raw;
357    }
358    let inner = raw
359        .strip_prefix("rgba(")
360        .or_else(|| raw.strip_prefix("rgb("))
361        .and_then(|rest| rest.strip_suffix(')'));
362    if let Some(inner) = inner {
363        let parts: Vec<&str> = inner.split(',').collect();
364        let channel = |index: usize| {
365            parts
366                .get(index)
367                .and_then(|part| part.trim().parse::<u32>().ok())
368                .map(|value| value.min(255))
369        };
370        if let (Some(r), Some(g), Some(b)) = (channel(0), channel(1), channel(2)) {
371            return format!("#{r:02x}{g:02x}{b:02x}");
372        }
373    }
374    raw
375}
376
377impl Default for Dialogs {
378    fn default() -> Self {
379        Dialogs::new()
380    }
381}
382
383#[cfg(test)]
384mod tests {
385    use super::*;
386
387    #[test]
388    fn color_answers_normalize_to_lowercase_hex() {
389        assert_eq!(normalize_color("#FF0080\n"), "#ff0080");
390        assert_eq!(normalize_color("rgb(255, 0, 128)"), "#ff0080");
391        assert_eq!(normalize_color("rgba(0, 128, 255, 0.5)"), "#0080ff");
392        // Unrecognized formats pass through, visibly degraded
393        // rather than silently rewritten.
394        assert_eq!(normalize_color("surprise"), "surprise");
395    }
396
397    #[test]
398    fn explicit_paths_choose_the_backend() {
399        assert!(!Dialogs::with_paths(None, None, None, None).available());
400        assert!(
401            Dialogs::with_paths(Some(PathBuf::from("/bin/kdialog")), None, None, None).available()
402        );
403        assert!(
404            Dialogs::with_paths(None, Some(PathBuf::from("/bin/zenity")), None, None).available()
405        );
406        assert!(
407            Dialogs::with_paths(None, None, Some(PathBuf::from("/usr/bin/osascript")), None)
408                .available()
409        );
410        assert!(
411            Dialogs::with_paths(
412                None,
413                None,
414                None,
415                Some(PathBuf::from("/windows/system32/powershell.exe"))
416            )
417            .available()
418        );
419        assert_eq!(
420            Dialogs::with_paths(None, Some(PathBuf::from("/bin/zenity")), None, None).backend(),
421            Some(Backend::Zenity)
422        );
423        assert_eq!(Dialogs::with_paths(None, None, None, None).backend(), None);
424    }
425}