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....@@ -77,9 +77,9 @@ keyboard, the toolbar and the macOS menu bar all run commands from it.
77 popover menus on GNOME, shown and hidden at once as GTK 4 does, and Breeze's77 popover menus on GNOME, shown and hidden at once as GTK 4 does, and Breeze's
78 on KDE, faded as KWin fades popups and scaled by Plasma's animation speed.78 on KDE, faded as KWin fades popups and scaled by Plasma's animation speed.
79 The desktop is read once from `XDG_CURRENT_DESKTOP`; elsewhere the kit's own.79 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,80- The desktop portal provides the file pickers; alerts, questions and the page
81 and where neither can, Snowbound asks with the kit's own dialogs in the81 date and time are the kit's own dialogs in the window, as is the file picker
82 window. No dialog waits on the event loop's thread. The XDG settings portal82 where no portal answers. No dialog waits on the event loop's thread. The XDG settings portal
83 provides the colour scheme. Text conventions come from the C library's83 provides the colour scheme. Text conventions come from the C library's
84 locale. Fontconfig is loaded at run time, so builds need no headers for it,84 locale. Fontconfig is loaded at run time, so builds need no headers for it,
85 and so is Enchant, which checks spelling with whatever dictionaries its85 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))....@@ -49,7 +49,7 @@ look and move as the desktop's own (see [platforms](platforms.md)).
4949
50The platform still owns what it's best at and what people expect to be50The platform still owns what it's best at and what people expect to be
51native. That means file pickers, alerts and date pickers (AppKit's sheets on51native. 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 of52macOS; the portal on Linux, which otherwise asks with the kit's own), none of
53which blocks the window, the caret and selection colours, each platform's53which blocks the window, the caret and selection colours, each platform's
54editing chords, the keychain, the traffic lights and window frames. On iOS the54editing chords, the keychain, the traffic lights and window frames. On iOS the
55split goes further (see [platforms](platforms.md)).55split goes further (see [platforms](platforms.md)).
crates/snowbound/linux/README.md+2-3
...@@ -41,9 +41,8 @@ Snowbound....@@ -41,9 +41,8 @@ Snowbound.
41 EGL. `WGPU_BACKEND=gl ./bin/snowbound ...` forces OpenGL.41 EGL. `WGPU_BACKEND=gl ./bin/snowbound ...` forces OpenGL.
42- fontconfig, and X11 or Wayland with libxkbcommon.42- fontconfig, and X11 or Wayland with libxkbcommon.
43- The desktop's portal (xdg-desktop-portal) for choosing, printing and opening43- 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 kdialog44 files. Without it, Snowbound chooses files with its own dialog, as it always
45 where installed. Where none of these is, Snowbound asks, and chooses files,45 asks questions and shows alerts.
46 with its own dialogs.
4746
48## Known limits47## Known limits
4948
crates/snowbound/src/dialog_linux.rs+2-3
...@@ -1,6 +1,5 @@...@@ -1,6 +1,5 @@
1//! Snowbound's own dialogs, drawn with the interface kit in the window, where no desktop1//! Snowbound's own dialogs on Linux, drawn with the interface kit in the window: messages,
2//! portal, zenity or kdialog can ask, as on GNOME's image-based systems or a bare X112//! questions and a line of text, and a file chooser where no desktop portal answers.
3//! desktop: messages, questions, a line of text, and a file chooser.
43
5use crate::{Reply, State, UserEvent, art};4use crate::{Reply, State, UserEvent, art};
6use accesskit::Role;5use accesskit::Role;
crates/snowbound/src/linux.rs+28-156
...@@ -1,5 +1,5 @@...@@ -1,5 +1,5 @@
1//! Linux, on X11 and Wayland: window controls drawn in the title bar, files chosen and opened1//! 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,
3//! the colour scheme from the settings portal, and text conventions from the C library's3//! the colour scheme from the settings portal, and text conventions from the C library's
4//! locale.4//! locale.
55
...@@ -9,7 +9,7 @@ use std::{...@@ -9,7 +9,7 @@ use std::{
9 cell::UnsafeCell,9 cell::UnsafeCell,
10 collections::HashMap,10 collections::HashMap,
11 error::Error,11 error::Error,
12 ffi::{CStr, CString, OsStr, OsString, c_char, c_void},12 ffi::{CStr, CString, OsString, c_char, c_void},
13 os::unix::ffi::OsStringExt,13 os::unix::ffi::OsStringExt,
14 path::PathBuf,14 path::PathBuf,
15 process::Command,15 process::Command,
...@@ -1390,59 +1390,21 @@ pub fn date_text(filetime: u64) -> [String; 2] {...@@ -1390,59 +1390,21 @@ pub fn date_text(filetime: u64) -> [String; 2] {
1390 date_labels(&local_time(filetime))1390 date_labels(&local_time(filetime))
1391}1391}
13921392
1393/// Asks for the page's date or time with the desktop's dialog tool, or else as text in1393/// Asks for the page's date or time as text.
1394/// Snowbound's own dialog.
1395pub fn edit_date(1394pub fn edit_date(
1396 timestamp: u64,1395 timestamp: u64,
1397 field: DateField,1396 field: DateField,
1398 title: &str,1397 title: &str,
1399 reply: Reply<Result<(u64, [String; 2]), &'static str>>,1398 reply: Reply<Result<(u64, [String; 2]), &'static str>>,
1400) {1399) {
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 };
1402 let reply = reply1405 let reply = reply
1403 .map(move |answer: String| changed_date(timestamp, local_time(timestamp), field, &answer));1406 .map(move |answer: String| changed_date(timestamp, local_time(timestamp), field, &answer));
1404 std::thread::spawn(move || {1407 crate::dialog::show(title.into(), detail.into(), Ask::Entry { text, reply });
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 });
1446}1408}
14471409
1448/// `timestamp` with `field` changed to what the user typed or chose, `answer`.1410/// `timestamp` with `field` changed to what the user typed or chose, `answer`.
...@@ -1499,22 +1461,6 @@ fn merge_date(...@@ -1499,22 +1461,6 @@ fn merge_date(
1499 Ok((updated, date_labels(&tm)))1461 Ok((updated, date_labels(&tm)))
1500}1462}
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
1518/// Asks for a file to insert, one of `types` (extensions) unless empty, titled `title`.1464/// Asks for a file to insert, one of `types` (extensions) unless empty, titled `title`.
1519pub fn pick_file(title: &str, types: &[&str], reply: Reply<PathBuf>) {1465pub fn pick_file(title: &str, types: &[&str], reply: Reply<PathBuf>) {
1520 let globs: Vec<_> = types.iter().map(|kind| format!("*.{kind}")).collect();1466 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>) {...@@ -1531,12 +1477,6 @@ pub fn pick_file(title: &str, types: &[&str], reply: Reply<PathBuf>) {
1531 title,1477 title,
1532 "OpenFile",1478 "OpenFile",
1533 options,1479 options,
1534 &[
1535 "--file-selection",
1536 &format!("--title={title}"),
1537 &format!("--file-filter={patterns}"),
1538 ],
1539 &["--getopenfilename", ".", &patterns, "--title", title],
1540 move |reply| Ask::File {1480 move |reply| Ask::File {
1541 folder: crate::dialog::start_folder(),1481 folder: crate::dialog::start_folder(),
1542 types,1482 types,
...@@ -1554,21 +1494,16 @@ fn filters(name: &str, globs: &[String]) -> Value<'static> {...@@ -1554,21 +1494,16 @@ fn filters(name: &str, globs: &[String]) -> Value<'static> {
1554 Value::from(vec![(name.to_owned(), globs)])1494 Value::from(vec![(name.to_owned(), globs)])
1555}1495}
15561496
1557/// Replies with the file the file chooser portal answers `method` with, titled `title`;1497/// Replies with the file the file chooser portal answers `method` with, titled `title`, or
1558/// where no portal answers, zenity's or kdialog's, given their arguments; and where neither1498/// where no portal answers, Snowbound's `own`.
1559/// is installed, Snowbound's `own`.
1560fn choose(1499fn choose(
1561 title: &str,1500 title: &str,
1562 method: &'static str,1501 method: &'static str,
1563 mut options: HashMap<&'static str, Value<'static>>,1502 mut options: HashMap<&'static str, Value<'static>>,
1564 zenity: &[&str],
1565 kdialog: &[&str],
1566 own: impl FnOnce(Reply<PathBuf>) -> Ask + Send + 'static,1503 own: impl FnOnce(Reply<PathBuf>) -> Ask + Send + 'static,
1567 reply: Reply<PathBuf>,1504 reply: Reply<PathBuf>,
1568) {1505) {
1569 let title = title.to_owned();1506 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());
1572 std::thread::spawn(move || {1507 std::thread::spawn(move || {
1573 let chosen = portal("org.freedesktop.portal.FileChooser")1508 let chosen = portal("org.freedesktop.portal.FileChooser")
1574 .map_err(Into::into)1509 .map_err(Into::into)
...@@ -1578,20 +1513,18 @@ fn choose(...@@ -1578,20 +1513,18 @@ fn choose(
1578 chooser.call(method, &("", &title, &options))1513 chooser.call(method, &("", &title, &options))
1579 })1514 })
1580 });1515 });
1581 let path = match chosen {1516 let results = match chosen {
1582 Ok(results) => results.and_then(|results| {1517 Ok(results) => 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 }),
1587 Err(error) => {1518 Err(error) => {
1588 eprintln!("The file chooser portal failed: {error}");1519 eprintln!("The file chooser portal failed: {error}");
1589 match dialog(&zenity, &kdialog) {1520 return crate::dialog::show(title, String::new(), own(reply));
1590 Ok(path) => path.filter(|path| !path.is_empty()).map(PathBuf::from),
1591 Err(_) => return crate::dialog::show(title, String::new(), own(reply)),
1592 }
1593 }1521 }
1594 };1522 };
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 });
1595 if let Some(path) = path {1528 if let Some(path) = path {
1596 reply.send(path);1529 reply.send(path);
1597 }1530 }
...@@ -1692,39 +1625,14 @@ pub fn reveal(target: impl AsRef<std::ffi::OsStr>) {...@@ -1692,39 +1625,14 @@ pub fn reveal(target: impl AsRef<std::ffi::OsStr>) {
1692 }1625 }
1693}1626}
16941627
1695/// Asks whether to go ahead with `action`, through zenity or kdialog where installed, or1628/// Asks whether to go ahead with `action`, offering `cancel` first.
1696/// else Snowbound's own dialog.
1697pub fn confirm(message: &str, detail: &str, cancel: &str, action: &str, reply: Reply<()>) {1629pub 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);1630 let ask = Ask::Question {
1699 std::thread::spawn(move || {1631 cancel: cancel.into(),
1700 let status = Command::new("zenity")1632 action: action.into(),
1701 .args([1633 reply,
1702 "--question",1634 };
1703 &format!("--title={message}"),1635 crate::dialog::show(message.into(), detail.into(), ask);
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 });
1728}1636}
17291637
1730/// Asks for a notebook's table of contents or a section file, titled `title`.1638/// 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>) {...@@ -1736,12 +1644,6 @@ pub fn pick_notebook(title: &str, reply: Reply<PathBuf>) {
1736 title,1644 title,
1737 "OpenFile",1645 "OpenFile",
1738 HashMap::from([("filters", filters(name, &globs))]),1646 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],
1745 move |reply| Ask::File {1647 move |reply| Ask::File {
1746 folder: crate::dialog::start_folder(),1648 folder: crate::dialog::start_folder(),
1747 types: types.map(String::from).to_vec(),1649 types: types.map(String::from).to_vec(),
...@@ -1780,13 +1682,6 @@ pub fn pick_new(...@@ -1780,13 +1682,6 @@ pub fn pick_new(
1780 title,1682 title,
1781 "SaveFile",1683 "SaveFile",
1782 options,1684 options,
1783 &[
1784 "--file-selection",
1785 "--save",
1786 &format!("--title={title}"),
1787 &format!("--filename={name}"),
1788 ],
1789 &["--getsavefilename", name, "--title", title],
1790 move |reply| Ask::File {1685 move |reply| Ask::File {
1791 folder,1686 folder,
1792 types: Vec::new(),1687 types: Vec::new(),
...@@ -1800,35 +1695,12 @@ pub fn pick_new(...@@ -1800,35 +1695,12 @@ pub fn pick_new(
18001695
1801/// Tells the user something they asked for could not be done: `message`, then what to do.1696/// Tells the user something they asked for could not be done: `message`, then what to do.
1802pub fn alert(message: &str, detail: &str) {1697pub fn alert(message: &str, detail: &str) {
1803 show(["--warning", "--sorry"], message, detail);1698 crate::dialog::show(message.into(), detail.into(), Ask::Message);
1804}1699}
18051700
1806/// Tells the user how something they asked for turned out.1701/// Tells the user how something they asked for turned out.
1807pub fn inform(message: &str, detail: &str) {1702pub fn inform(message: &str, detail: &str) {
1808 show(["--info", "--msgbox"], message, detail);1703 alert(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 });
1832}1704}
18331705
1834#[cfg(test)]1706#[cfg(test)]