authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-02 01:24:45-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-02 01:59:00-07:00
logf0c2aa56db4f3d91b99d0ac66f91a6c53f66f4cf
tree5d4b00b9e080e3c667872a73a1348e75ff57ed6b
parent42499fefe59cff82e17b0f6ec64b92bf090a6a78
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

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

5 files changed, 36 insertions(+), 166 deletions(-)

arc/platforms.md+3-3
......@@ -77,9 +77,9 @@ keyboard, the toolbar and the macOS menu bar all run commands from it.
7777 popover menus on GNOME, shown and hidden at once as GTK 4 does, and Breeze's
7878 on KDE, faded as KWin fades popups and scaled by Plasma's animation speed.
7979 The desktop is read once from `XDG_CURRENT_DESKTOP`; elsewhere the kit's own.
80- The desktop portal provides the file pickers, zenity or kdialog the alerts,
81 and where neither can, Snowbound asks with the kit's own dialogs in the
82 window. No dialog waits on the event loop's thread. The XDG settings portal
80- The desktop portal provides the file pickers; alerts, questions and the page
81 date and time are the kit's own dialogs in the window, as is the file picker
82 where no portal answers. No dialog waits on the event loop's thread. The XDG settings portal
8383 provides the colour scheme. Text conventions come from the C library's
8484 locale. Fontconfig is loaded at run time, so builds need no headers for it,
8585 and so is Enchant, which checks spelling with whatever dictionaries its
arc/ui.md+1-1
......@@ -49,7 +49,7 @@ look and move as the desktop's own (see [platforms](platforms.md)).
4949
5050The platform still owns what it's best at and what people expect to be
5151native. That means file pickers, alerts and date pickers (AppKit's sheets on
52macOS; the portal, zenity or kdialog on Linux, else the kit's own), none of
52macOS; the portal on Linux, which otherwise asks with the kit's own), none of
5353which blocks the window, the caret and selection colours, each platform's
5454editing chords, the keychain, the traffic lights and window frames. On iOS the
5555split goes further (see [platforms](platforms.md)).
crates/snowbound/linux/README.md+2-3
......@@ -41,9 +41,8 @@ Snowbound.
4141 EGL. `WGPU_BACKEND=gl ./bin/snowbound ...` forces OpenGL.
4242- fontconfig, and X11 or Wayland with libxkbcommon.
4343- The desktop's portal (xdg-desktop-portal) for choosing, printing and opening
44 files. Questions, alerts and the page date and time use zenity or kdialog
45 where installed. Where none of these is, Snowbound asks, and chooses files,
46 with its own dialogs.
44 files. Without it, Snowbound chooses files with its own dialog, as it always
45 asks questions and shows alerts.
4746
4847## Known limits
4948
crates/snowbound/src/dialog_linux.rs+2-3
......@@ -1,6 +1,5 @@
1//! Snowbound's own dialogs, drawn with the interface kit in the window, where no desktop
2//! portal, zenity or kdialog can ask, as on GNOME's image-based systems or a bare X11
3//! desktop: messages, questions, a line of text, and a file chooser.
1//! Snowbound's own dialogs on Linux, drawn with the interface kit in the window: messages,
2//! questions and a line of text, and a file chooser where no desktop portal answers.
43
54use crate::{Reply, State, UserEvent, art};
65use accesskit::Role;
crates/snowbound/src/linux.rs+28-156
......@@ -1,5 +1,5 @@
11//! Linux, on X11 and Wayland: window controls drawn in the title bar, files chosen and opened
2//! through the XDG desktop portal, other dialogs through zenity, kdialog or Snowbound's own,
2//! through the XDG desktop portal, other dialogs drawn in the window,
33//! the colour scheme from the settings portal, and text conventions from the C library's
44//! locale.
55
......@@ -9,7 +9,7 @@ use std::{
99 cell::UnsafeCell,
1010 collections::HashMap,
1111 error::Error,
12 ffi::{CStr, CString, OsStr, OsString, c_char, c_void},
12 ffi::{CStr, CString, OsString, c_char, c_void},
1313 os::unix::ffi::OsStringExt,
1414 path::PathBuf,
1515 process::Command,
......@@ -1390,59 +1390,21 @@ pub fn date_text(filetime: u64) -> [String; 2] {
13901390 date_labels(&local_time(filetime))
13911391}
13921392
1393/// Asks for the page's date or time with the desktop's dialog tool, or else as text in
1394/// Snowbound's own dialog.
1393/// Asks for the page's date or time as text.
13951394pub fn edit_date(
13961395 timestamp: u64,
13971396 field: DateField,
13981397 title: &str,
13991398 reply: Reply<Result<(u64, [String; 2]), &'static str>>,
14001399) {
1401 let title = title.to_owned();
1400 let tm = local_time(timestamp);
1401 let (detail, text) = match field {
1402 DateField::Date => ("Date, as year-month-day:", format(&tm, c"%Y-%m-%d")),
1403 DateField::Time => ("Time, as hours and minutes:", format(&tm, c"%H:%M")),
1404 };
14021405 let reply = reply
14031406 .map(move |answer: String| changed_date(timestamp, local_time(timestamp), field, &answer));
1404 std::thread::spawn(move || {
1405 let tm = local_time(timestamp);
1406 let (asked, detail, value) = match field {
1407 DateField::Date => (
1408 dialog(
1409 &[
1410 "--calendar",
1411 &format!("--title={title}"),
1412 "--text=",
1413 &format!("--day={}", tm.tm_mday),
1414 &format!("--month={}", tm.tm_mon + 1),
1415 &format!("--year={}", tm.tm_year + 1900),
1416 "--date-format=%Y-%m-%d",
1417 ],
1418 &["--calendar", &title, "--dateformat", "yyyy-MM-dd"],
1419 ),
1420 "Date, as year-month-day:",
1421 format(&tm, c"%Y-%m-%d"),
1422 ),
1423 DateField::Time => {
1424 let current = format(&tm, c"%H:%M");
1425 let asked = dialog(
1426 &[
1427 "--entry",
1428 &format!("--title={title}"),
1429 "--text=Time, as hours and minutes:",
1430 &format!("--entry-text={current}"),
1431 ],
1432 &["--inputbox", "Time, as hours and minutes:", &current],
1433 );
1434 (asked, "Time, as hours and minutes:", current)
1435 }
1436 };
1437 match asked {
1438 Ok(Some(answer)) => reply.send(answer),
1439 Ok(None) => {}
1440 Err(_) => {
1441 let ask = Ask::Entry { text: value, reply };
1442 crate::dialog::show(title, detail.to_owned(), ask);
1443 }
1444 }
1445 });
1407 crate::dialog::show(title.into(), detail.into(), Ask::Entry { text, reply });
14461408}
14471409
14481410/// `timestamp` with `field` changed to what the user typed or chose, `answer`.
......@@ -1499,22 +1461,6 @@ fn merge_date(
14991461 Ok((updated, date_labels(&tm)))
15001462}
15011463
1502/// Runs zenity, or kdialog where zenity is missing, waiting for the answer: None when
1503/// cancelled, and an error when neither is installed.
1504fn dialog(
1505 zenity: &[impl AsRef<OsStr>],
1506 kdialog: &[impl AsRef<OsStr>],
1507) -> std::io::Result<Option<String>> {
1508 let output = Command::new("zenity")
1509 .args(zenity)
1510 .output()
1511 .or_else(|_| Command::new("kdialog").args(kdialog).output())?;
1512 Ok(output
1513 .status
1514 .success()
1515 .then(|| String::from_utf8_lossy(&output.stdout).trim().to_owned()))
1516}
1517
15181464/// Asks for a file to insert, one of `types` (extensions) unless empty, titled `title`.
15191465pub fn pick_file(title: &str, types: &[&str], reply: Reply<PathBuf>) {
15201466 let globs: Vec<_> = types.iter().map(|kind| format!("*.{kind}")).collect();
......@@ -1531,12 +1477,6 @@ pub fn pick_file(title: &str, types: &[&str], reply: Reply<PathBuf>) {
15311477 title,
15321478 "OpenFile",
15331479 options,
1534 &[
1535 "--file-selection",
1536 &format!("--title={title}"),
1537 &format!("--file-filter={patterns}"),
1538 ],
1539 &["--getopenfilename", ".", &patterns, "--title", title],
15401480 move |reply| Ask::File {
15411481 folder: crate::dialog::start_folder(),
15421482 types,
......@@ -1554,21 +1494,16 @@ fn filters(name: &str, globs: &[String]) -> Value<'static> {
15541494 Value::from(vec![(name.to_owned(), globs)])
15551495}
15561496
1557/// Replies with the file the file chooser portal answers `method` with, titled `title`;
1558/// where no portal answers, zenity's or kdialog's, given their arguments; and where neither
1559/// is installed, Snowbound's `own`.
1497/// Replies with the file the file chooser portal answers `method` with, titled `title`, or
1498/// where no portal answers, Snowbound's `own`.
15601499fn choose(
15611500 title: &str,
15621501 method: &'static str,
15631502 mut options: HashMap<&'static str, Value<'static>>,
1564 zenity: &[&str],
1565 kdialog: &[&str],
15661503 own: impl FnOnce(Reply<PathBuf>) -> Ask + Send + 'static,
15671504 reply: Reply<PathBuf>,
15681505) {
15691506 let title = title.to_owned();
1570 let [zenity, kdialog]: [Vec<String>; 2] =
1571 [zenity, kdialog].map(|args| args.iter().map(|arg| arg.to_string()).collect());
15721507 std::thread::spawn(move || {
15731508 let chosen = portal("org.freedesktop.portal.FileChooser")
15741509 .map_err(Into::into)
......@@ -1578,20 +1513,18 @@ fn choose(
15781513 chooser.call(method, &("", &title, &options))
15791514 })
15801515 });
1581 let path = match chosen {
1582 Ok(results) => results.and_then(|results| {
1583 let uris: Vec<String> = results.get("uris")?.try_clone().ok()?.try_into().ok()?;
1584 let path = uris.first()?.strip_prefix("file://")?;
1585 Some(OsString::from_vec(crate::paste::percent_decode(path)).into())
1586 }),
1516 let results = match chosen {
1517 Ok(results) => results,
15871518 Err(error) => {
15881519 eprintln!("The file chooser portal failed: {error}");
1589 match dialog(&zenity, &kdialog) {
1590 Ok(path) => path.filter(|path| !path.is_empty()).map(PathBuf::from),
1591 Err(_) => return crate::dialog::show(title, String::new(), own(reply)),
1592 }
1520 return crate::dialog::show(title, String::new(), own(reply));
15931521 }
15941522 };
1523 let path = results.and_then(|results| {
1524 let uris: Vec<String> = results.get("uris")?.try_clone().ok()?.try_into().ok()?;
1525 let path = uris.first()?.strip_prefix("file://")?;
1526 Some(OsString::from_vec(crate::paste::percent_decode(path)).into())
1527 });
15951528 if let Some(path) = path {
15961529 reply.send(path);
15971530 }
......@@ -1692,39 +1625,14 @@ pub fn reveal(target: impl AsRef<std::ffi::OsStr>) {
16921625 }
16931626}
16941627
1695/// Asks whether to go ahead with `action`, through zenity or kdialog where installed, or
1696/// else Snowbound's own dialog.
1628/// Asks whether to go ahead with `action`, offering `cancel` first.
16971629pub fn confirm(message: &str, detail: &str, cancel: &str, action: &str, reply: Reply<()>) {
1698 let [message, detail, cancel, action] = [message, detail, cancel, action].map(str::to_owned);
1699 std::thread::spawn(move || {
1700 let status = Command::new("zenity")
1701 .args([
1702 "--question",
1703 &format!("--title={message}"),
1704 &format!("--text={detail}"),
1705 &format!("--ok-label={action}"),
1706 &format!("--cancel-label={cancel}"),
1707 ])
1708 .status()
1709 .or_else(|_| {
1710 Command::new("kdialog")
1711 .args(["--warningcontinuecancel", &detail, "--title", &message])
1712 .args(["--continue-label", &action])
1713 .status()
1714 });
1715 match status {
1716 Ok(status) if status.success() => reply.send(()),
1717 Ok(_) => {}
1718 Err(_) => {
1719 let ask = Ask::Question {
1720 cancel,
1721 action,
1722 reply,
1723 };
1724 crate::dialog::show(message, detail, ask);
1725 }
1726 }
1727 });
1630 let ask = Ask::Question {
1631 cancel: cancel.into(),
1632 action: action.into(),
1633 reply,
1634 };
1635 crate::dialog::show(message.into(), detail.into(), ask);
17281636}
17291637
17301638/// 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<PathBuf>) {
17361644 title,
17371645 "OpenFile",
17381646 HashMap::from([("filters", filters(name, &globs))]),
1739 &[
1740 "--file-selection",
1741 &format!("--title={title}"),
1742 &format!("--file-filter={name} | {}", globs.join(" ")),
1743 ],
1744 &["--getopenfilename", ".", &globs.join(" "), "--title", title],
17451647 move |reply| Ask::File {
17461648 folder: crate::dialog::start_folder(),
17471649 types: types.map(String::from).to_vec(),
......@@ -1780,13 +1682,6 @@ pub fn pick_new(
17801682 title,
17811683 "SaveFile",
17821684 options,
1783 &[
1784 "--file-selection",
1785 "--save",
1786 &format!("--title={title}"),
1787 &format!("--filename={name}"),
1788 ],
1789 &["--getsavefilename", name, "--title", title],
17901685 move |reply| Ask::File {
17911686 folder,
17921687 types: Vec::new(),
......@@ -1800,35 +1695,12 @@ pub fn pick_new(
18001695
18011696/// Tells the user something they asked for could not be done: `message`, then what to do.
18021697pub fn alert(message: &str, detail: &str) {
1803 show(["--warning", "--sorry"], message, detail);
1698 crate::dialog::show(message.into(), detail.into(), Ask::Message);
18041699}
18051700
18061701/// Tells the user how something they asked for turned out.
18071702pub fn inform(message: &str, detail: &str) {
1808 show(["--info", "--msgbox"], message, detail);
1809}
1810
1811/// Shows `message` and `detail` in zenity's or else kdialog's dialog of the `kinds`, or else
1812/// in Snowbound's own.
1813fn show([zenity, kdialog]: [&'static str; 2], message: &str, detail: &str) {
1814 let (title, text) = (message.to_owned(), detail.to_owned());
1815 std::thread::spawn(move || {
1816 let shown = Command::new("zenity")
1817 .args([
1818 zenity,
1819 &format!("--title={title}"),
1820 &format!("--text={text}"),
1821 ])
1822 .status()
1823 .or_else(|_| {
1824 Command::new("kdialog")
1825 .args([kdialog, &text, "--title", &title])
1826 .status()
1827 });
1828 if shown.is_err() {
1829 crate::dialog::show(title, text, Ask::Message);
1830 }
1831 });
1703 alert(message, detail);
18321704}
18331705
18341706#[cfg(test)]