From f0c2aa56db4f3d91b99d0ac66f91a6c53f66f4cf Mon Sep 17 00:00:00 2001 From: clover caruso Date: Fri, 2 Oct 2026 01:24:45 -0700 Subject: [PATCH] fix: Linux asks with its own dialogs instead of zenity or kdialog Questions, alerts and the page's date and time are now Snowbound's own dialogs in the window on every Linux desktop, and the file chooser falls back from the desktop portal to Snowbound's own rather than to zenity or kdialog. The same dialog looks and answers the same everywhere, and none of them depends on a tool the desktop may not ship. Assisted-by: claude-opus-5.5 --- arc/platforms.md | 6 +- arc/ui.md | 2 +- crates/snowbound/linux/README.md | 5 +- crates/snowbound/src/dialog_linux.rs | 5 +- crates/snowbound/src/linux.rs | 184 ++++----------------------- 5 files changed, 36 insertions(+), 166 deletions(-) diff --git a/arc/platforms.md b/arc/platforms.md index 5048ff83801a40e8abbc3d19f3ab0d84bd4dcf67..52d167b6a3235559e31cdba73976fa27e4a09960 100644 --- a/arc/platforms.md +++ b/arc/platforms.md @@ -77,9 +77,9 @@ keyboard, the toolbar and the macOS menu bar all run commands from it. popover menus on GNOME, shown and hidden at once as GTK 4 does, and Breeze's on KDE, faded as KWin fades popups and scaled by Plasma's animation speed. The desktop is read once from `XDG_CURRENT_DESKTOP`; elsewhere the kit's own. -- The desktop portal provides the file pickers, zenity or kdialog the alerts, - and where neither can, Snowbound asks with the kit's own dialogs in the - window. No dialog waits on the event loop's thread. The XDG settings portal +- The desktop portal provides the file pickers; alerts, questions and the page + date and time are the kit's own dialogs in the window, as is the file picker + where no portal answers. No dialog waits on the event loop's thread. The XDG settings portal provides the colour scheme. Text conventions come from the C library's locale. Fontconfig is loaded at run time, so builds need no headers for it, and so is Enchant, which checks spelling with whatever dictionaries its diff --git a/arc/ui.md b/arc/ui.md index 760c75ee2544003b1fc5c24c622dc4fa63ce4dbb..c336e10f3dc702fa9b3b6b4175e5be26bec6abbf 100644 --- a/arc/ui.md +++ b/arc/ui.md @@ -49,7 +49,7 @@ look and move as the desktop's own (see [platforms](platforms.md)). The platform still owns what it's best at and what people expect to be native. That means file pickers, alerts and date pickers (AppKit's sheets on -macOS; the portal, zenity or kdialog on Linux, else the kit's own), none of +macOS; the portal on Linux, which otherwise asks with the kit's own), none of which blocks the window, the caret and selection colours, each platform's editing chords, the keychain, the traffic lights and window frames. On iOS the split goes further (see [platforms](platforms.md)). diff --git a/crates/snowbound/linux/README.md b/crates/snowbound/linux/README.md index 3664a45d8d0f5f575e798ed971c441c6f10e8800..3563ef1e9f10afce4139fe04013cd4d39d4998da 100644 --- a/crates/snowbound/linux/README.md +++ b/crates/snowbound/linux/README.md @@ -41,9 +41,8 @@ Snowbound. EGL. `WGPU_BACKEND=gl ./bin/snowbound ...` forces OpenGL. - fontconfig, and X11 or Wayland with libxkbcommon. - The desktop's portal (xdg-desktop-portal) for choosing, printing and opening - files. Questions, alerts and the page date and time use zenity or kdialog - where installed. Where none of these is, Snowbound asks, and chooses files, - with its own dialogs. + files. Without it, Snowbound chooses files with its own dialog, as it always + asks questions and shows alerts. ## Known limits diff --git a/crates/snowbound/src/dialog_linux.rs b/crates/snowbound/src/dialog_linux.rs index 42871f13d5d8d7d2566e2bf241a231545c824444..5434d0024643590f2f6d81ac01182a62daa0dc52 100644 --- a/crates/snowbound/src/dialog_linux.rs +++ b/crates/snowbound/src/dialog_linux.rs @@ -1,6 +1,5 @@ -//! Snowbound's own dialogs, drawn with the interface kit in the window, where no desktop -//! portal, zenity or kdialog can ask, as on GNOME's image-based systems or a bare X11 -//! desktop: messages, questions, a line of text, and a file chooser. +//! Snowbound's own dialogs on Linux, drawn with the interface kit in the window: messages, +//! questions and a line of text, and a file chooser where no desktop portal answers. use crate::{Reply, State, UserEvent, art}; use accesskit::Role; diff --git a/crates/snowbound/src/linux.rs b/crates/snowbound/src/linux.rs index 78aee94627ac8b197ba77c3305f0a8612be3e9db..84cce0188c05e08e6e35fdfcea9ec8663ff9e69a 100644 --- a/crates/snowbound/src/linux.rs +++ b/crates/snowbound/src/linux.rs @@ -1,5 +1,5 @@ //! Linux, on X11 and Wayland: window controls drawn in the title bar, files chosen and opened -//! through the XDG desktop portal, other dialogs through zenity, kdialog or Snowbound's own, +//! through the XDG desktop portal, other dialogs drawn in the window, //! the colour scheme from the settings portal, and text conventions from the C library's //! locale. @@ -9,7 +9,7 @@ use std::{ cell::UnsafeCell, collections::HashMap, error::Error, - ffi::{CStr, CString, OsStr, OsString, c_char, c_void}, + ffi::{CStr, CString, OsString, c_char, c_void}, os::unix::ffi::OsStringExt, path::PathBuf, process::Command, @@ -1390,59 +1390,21 @@ pub fn date_text(filetime: u64) -> [String; 2] { date_labels(&local_time(filetime)) } -/// Asks for the page's date or time with the desktop's dialog tool, or else as text in -/// Snowbound's own dialog. +/// Asks for the page's date or time as text. pub fn edit_date( timestamp: u64, field: DateField, title: &str, reply: Reply>, ) { - let title = title.to_owned(); + let tm = local_time(timestamp); + let (detail, text) = match field { + DateField::Date => ("Date, as year-month-day:", format(&tm, c"%Y-%m-%d")), + DateField::Time => ("Time, as hours and minutes:", format(&tm, c"%H:%M")), + }; let reply = reply .map(move |answer: String| changed_date(timestamp, local_time(timestamp), field, &answer)); - std::thread::spawn(move || { - let tm = local_time(timestamp); - let (asked, detail, value) = match field { - DateField::Date => ( - dialog( - &[ - "--calendar", - &format!("--title={title}"), - "--text=", - &format!("--day={}", tm.tm_mday), - &format!("--month={}", tm.tm_mon + 1), - &format!("--year={}", tm.tm_year + 1900), - "--date-format=%Y-%m-%d", - ], - &["--calendar", &title, "--dateformat", "yyyy-MM-dd"], - ), - "Date, as year-month-day:", - format(&tm, c"%Y-%m-%d"), - ), - DateField::Time => { - let current = format(&tm, c"%H:%M"); - let asked = dialog( - &[ - "--entry", - &format!("--title={title}"), - "--text=Time, as hours and minutes:", - &format!("--entry-text={current}"), - ], - &["--inputbox", "Time, as hours and minutes:", ¤t], - ); - (asked, "Time, as hours and minutes:", current) - } - }; - match asked { - Ok(Some(answer)) => reply.send(answer), - Ok(None) => {} - Err(_) => { - let ask = Ask::Entry { text: value, reply }; - crate::dialog::show(title, detail.to_owned(), ask); - } - } - }); + crate::dialog::show(title.into(), detail.into(), Ask::Entry { text, reply }); } /// `timestamp` with `field` changed to what the user typed or chose, `answer`. @@ -1499,22 +1461,6 @@ fn merge_date( Ok((updated, date_labels(&tm))) } -/// Runs zenity, or kdialog where zenity is missing, waiting for the answer: None when -/// cancelled, and an error when neither is installed. -fn dialog( - zenity: &[impl AsRef], - kdialog: &[impl AsRef], -) -> std::io::Result> { - let output = Command::new("zenity") - .args(zenity) - .output() - .or_else(|_| Command::new("kdialog").args(kdialog).output())?; - Ok(output - .status - .success() - .then(|| String::from_utf8_lossy(&output.stdout).trim().to_owned())) -} - /// Asks for a file to insert, one of `types` (extensions) unless empty, titled `title`. pub fn pick_file(title: &str, types: &[&str], reply: Reply) { let globs: Vec<_> = types.iter().map(|kind| format!("*.{kind}")).collect(); @@ -1531,12 +1477,6 @@ pub fn pick_file(title: &str, types: &[&str], reply: Reply) { title, "OpenFile", options, - &[ - "--file-selection", - &format!("--title={title}"), - &format!("--file-filter={patterns}"), - ], - &["--getopenfilename", ".", &patterns, "--title", title], move |reply| Ask::File { folder: crate::dialog::start_folder(), types, @@ -1554,21 +1494,16 @@ fn filters(name: &str, globs: &[String]) -> Value<'static> { Value::from(vec![(name.to_owned(), globs)]) } -/// Replies with the file the file chooser portal answers `method` with, titled `title`; -/// where no portal answers, zenity's or kdialog's, given their arguments; and where neither -/// is installed, Snowbound's `own`. +/// Replies with the file the file chooser portal answers `method` with, titled `title`, or +/// where no portal answers, Snowbound's `own`. fn choose( title: &str, method: &'static str, mut options: HashMap<&'static str, Value<'static>>, - zenity: &[&str], - kdialog: &[&str], own: impl FnOnce(Reply) -> Ask + Send + 'static, reply: Reply, ) { let title = title.to_owned(); - let [zenity, kdialog]: [Vec; 2] = - [zenity, kdialog].map(|args| args.iter().map(|arg| arg.to_string()).collect()); std::thread::spawn(move || { let chosen = portal("org.freedesktop.portal.FileChooser") .map_err(Into::into) @@ -1578,20 +1513,18 @@ fn choose( chooser.call(method, &("", &title, &options)) }) }); - let path = match chosen { - Ok(results) => results.and_then(|results| { - let uris: Vec = results.get("uris")?.try_clone().ok()?.try_into().ok()?; - let path = uris.first()?.strip_prefix("file://")?; - Some(OsString::from_vec(crate::paste::percent_decode(path)).into()) - }), + let results = match chosen { + Ok(results) => results, Err(error) => { eprintln!("The file chooser portal failed: {error}"); - match dialog(&zenity, &kdialog) { - Ok(path) => path.filter(|path| !path.is_empty()).map(PathBuf::from), - Err(_) => return crate::dialog::show(title, String::new(), own(reply)), - } + return crate::dialog::show(title, String::new(), own(reply)); } }; + let path = results.and_then(|results| { + let uris: Vec = results.get("uris")?.try_clone().ok()?.try_into().ok()?; + let path = uris.first()?.strip_prefix("file://")?; + Some(OsString::from_vec(crate::paste::percent_decode(path)).into()) + }); if let Some(path) = path { reply.send(path); } @@ -1692,39 +1625,14 @@ pub fn reveal(target: impl AsRef) { } } -/// Asks whether to go ahead with `action`, through zenity or kdialog where installed, or -/// else Snowbound's own dialog. +/// Asks whether to go ahead with `action`, offering `cancel` first. pub fn confirm(message: &str, detail: &str, cancel: &str, action: &str, reply: Reply<()>) { - let [message, detail, cancel, action] = [message, detail, cancel, action].map(str::to_owned); - std::thread::spawn(move || { - let status = Command::new("zenity") - .args([ - "--question", - &format!("--title={message}"), - &format!("--text={detail}"), - &format!("--ok-label={action}"), - &format!("--cancel-label={cancel}"), - ]) - .status() - .or_else(|_| { - Command::new("kdialog") - .args(["--warningcontinuecancel", &detail, "--title", &message]) - .args(["--continue-label", &action]) - .status() - }); - match status { - Ok(status) if status.success() => reply.send(()), - Ok(_) => {} - Err(_) => { - let ask = Ask::Question { - cancel, - action, - reply, - }; - crate::dialog::show(message, detail, ask); - } - } - }); + let ask = Ask::Question { + cancel: cancel.into(), + action: action.into(), + reply, + }; + crate::dialog::show(message.into(), detail.into(), ask); } /// Asks for a notebook's table of contents or a section file, titled `title`. @@ -1736,12 +1644,6 @@ pub fn pick_notebook(title: &str, reply: Reply) { title, "OpenFile", HashMap::from([("filters", filters(name, &globs))]), - &[ - "--file-selection", - &format!("--title={title}"), - &format!("--file-filter={name} | {}", globs.join(" ")), - ], - &["--getopenfilename", ".", &globs.join(" "), "--title", title], move |reply| Ask::File { folder: crate::dialog::start_folder(), types: types.map(String::from).to_vec(), @@ -1780,13 +1682,6 @@ pub fn pick_new( title, "SaveFile", options, - &[ - "--file-selection", - "--save", - &format!("--title={title}"), - &format!("--filename={name}"), - ], - &["--getsavefilename", name, "--title", title], move |reply| Ask::File { folder, types: Vec::new(), @@ -1800,35 +1695,12 @@ pub fn pick_new( /// Tells the user something they asked for could not be done: `message`, then what to do. pub fn alert(message: &str, detail: &str) { - show(["--warning", "--sorry"], message, detail); + crate::dialog::show(message.into(), detail.into(), Ask::Message); } /// Tells the user how something they asked for turned out. pub fn inform(message: &str, detail: &str) { - show(["--info", "--msgbox"], message, detail); -} - -/// Shows `message` and `detail` in zenity's or else kdialog's dialog of the `kinds`, or else -/// in Snowbound's own. -fn show([zenity, kdialog]: [&'static str; 2], message: &str, detail: &str) { - let (title, text) = (message.to_owned(), detail.to_owned()); - std::thread::spawn(move || { - let shown = Command::new("zenity") - .args([ - zenity, - &format!("--title={title}"), - &format!("--text={text}"), - ]) - .status() - .or_else(|_| { - Command::new("kdialog") - .args([kdialog, &text, "--title", &title]) - .status() - }); - if shown.is_err() { - crate::dialog::show(title, text, Ask::Message); - } - }); + alert(message, detail); } #[cfg(test)] -- 2.54.0