1mod 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#[derive(Clone, Debug)]
51pub struct Dialogs {
52 backend: Option<(Backend, PathBuf)>,
53}
54
55impl Dialogs {
56 pub fn new() -> Dialogs {
59 Dialogs {
60 backend: backends::probe(),
61 }
62 }
63
64 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 pub fn available(&self) -> bool {
83 self.backend.is_some()
84 }
85
86 pub fn backend(&self) -> Option<Backend> {
90 self.backend.as_ref().map(|(backend, _)| *backend)
91 }
92
93 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 pub fn prompt_password(&self, title: &str, label: &str) -> Result<Option<String>, Error> {
110 self.run(Request::Password { title, label })
111 }
112
113 #[allow(dead_code)] 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 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 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 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 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 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 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 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 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 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 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 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 #[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
338enum Completed {
342 Answered(Option<String>),
343 Cancelled,
344}
345
346fn 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 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}