| ... | @@ -1,11 +1,13 @@ | ... | @@ -1,11 +1,13 @@ |
| 1 | //! Linux, on X11 and Wayland: window controls drawn in the title bar, dialogs through zenity | 1 | //! Linux, on X11 and Wayland: window controls drawn in the title bar, files chosen and opened |
| 2 | //! or kdialog, the colour scheme from the XDG settings portal, and text conventions from | 2 | //! through the XDG desktop portal, other dialogs through zenity or kdialog, the colour scheme |
| 3 | //! the C library's locale. | 3 | //! from the settings portal, and text conventions from the C library's locale. |
| 4 | | 4 | |
| 5 | use canvas::date::DateField; | 5 | use canvas::date::DateField; |
| 6 | use std::{ | 6 | use std::{ |
| | 7 | collections::HashMap, |
| 7 | error::Error, | 8 | error::Error, |
| 8 | ffi::CStr, | 9 | ffi::CStr, |
| | 10 | os::unix::ffi::OsStringExt, |
| 9 | path::PathBuf, | 11 | path::PathBuf, |
| 10 | process::Command, | 12 | process::Command, |
| 11 | sync::{ | 13 | sync::{ |
| ... | @@ -23,6 +25,7 @@ use winit::{ | ... | @@ -23,6 +25,7 @@ use winit::{ |
| 23 | }, | 25 | }, |
| 24 | window::{ResizeDirection, Theme, Window, WindowAttributes}, | 26 | window::{ResizeDirection, Theme, Window, WindowAttributes}, |
| 25 | }; | 27 | }; |
| | 28 | use zbus::zvariant::{OwnedObjectPath, OwnedValue, Value}; |
| 26 | | 29 | |
| 27 | /// The title bar's leading margin; the window controls sit at its trailing end. | 30 | /// The title bar's leading margin; the window controls sit at its trailing end. |
| 28 | pub const LEADING: f32 = 8.0; | 31 | pub const LEADING: f32 = 8.0; |
| ... | @@ -884,8 +887,7 @@ fn portal_color_scheme() -> Option<bool> { | ... | @@ -884,8 +887,7 @@ fn portal_color_scheme() -> Option<bool> { |
| 884 | } | 887 | } |
| 885 | | 888 | |
| 886 | /// Setting `key` in `namespace` from the settings portal. | 889 | /// Setting `key` in `namespace` from the settings portal. |
| 887 | fn portal_setting(namespace: &str, key: &str) -> Option<zbus::zvariant::OwnedValue> { | 890 | fn portal_setting(namespace: &str, key: &str) -> Option<OwnedValue> { |
| 888 | use zbus::zvariant::{OwnedValue, Value}; | | |
| 889 | // A missing portal must not hold up the window for D-Bus's 25 s default. | 891 | // A missing portal must not hold up the window for D-Bus's 25 s default. |
| 890 | let connection = zbus::blocking::connection::Builder::session() | 892 | let connection = zbus::blocking::connection::Builder::session() |
| 891 | .ok()? | 893 | .ok()? |
| ... | @@ -931,7 +933,7 @@ pub fn show_file(file: &std::path::Path) { | ... | @@ -931,7 +933,7 @@ pub fn show_file(file: &std::path::Path) { |
| 931 | if shown.is_err() | 933 | if shown.is_err() |
| 932 | && let Some(folder) = file.parent() | 934 | && let Some(folder) = file.parent() |
| 933 | { | 935 | { |
| 934 | let _ = Command::new("xdg-open").arg(folder).spawn(); | 936 | reveal(folder); |
| 935 | } | 937 | } |
| 936 | } | 938 | } |
| 937 | | 939 | |
| ... | @@ -1302,22 +1304,107 @@ fn picked(asked: Result<Option<String>, &str>) -> Option<PathBuf> { | ... | @@ -1302,22 +1304,107 @@ fn picked(asked: Result<Option<String>, &str>) -> Option<PathBuf> { |
| 1302 | | 1304 | |
| 1303 | /// Asks for a file to insert, one of `types` (extensions) unless empty, titled `title`. | 1305 | /// Asks for a file to insert, one of `types` (extensions) unless empty, titled `title`. |
| 1304 | pub fn pick_file(title: &str, types: &[&str]) -> Option<PathBuf> { | 1306 | pub fn pick_file(title: &str, types: &[&str]) -> Option<PathBuf> { |
| | 1307 | let globs: Vec<_> = types.iter().map(|kind| format!("*.{kind}")).collect(); |
| 1305 | let patterns = match types { | 1308 | let patterns = match types { |
| 1306 | [] => "*".to_owned(), | 1309 | [] => "*".to_owned(), |
| 1307 | types => types | 1310 | _ => globs.join(" "), |
| 1308 | .iter() | | |
| 1309 | .map(|kind| format!("*.{kind}")) | | |
| 1310 | .collect::<Vec<_>>() | | |
| 1311 | .join(" "), | | |
| 1312 | }; | 1311 | }; |
| 1313 | picked(dialog( | 1312 | let mut options = HashMap::new(); |
| | 1313 | if !globs.is_empty() { |
| | 1314 | options.insert("filters", filters(&patterns, &globs)); |
| | 1315 | } |
| | 1316 | choose( |
| | 1317 | title, |
| | 1318 | "OpenFile", |
| | 1319 | options, |
| 1314 | [ | 1320 | [ |
| 1315 | "--file-selection", | 1321 | "--file-selection", |
| 1316 | &format!("--title={title}"), | 1322 | &format!("--title={title}"), |
| 1317 | &format!("--file-filter={patterns}"), | 1323 | &format!("--file-filter={patterns}"), |
| 1318 | ], | 1324 | ], |
| 1319 | ["--getopenfilename", ".", &patterns, "--title", title], | 1325 | ["--getopenfilename", ".", &patterns, "--title", title], |
| 1320 | )) | 1326 | ) |
| | 1327 | } |
| | 1328 | |
| | 1329 | /// A file chooser's `filters` option: one filter, `name`, of `globs`. |
| | 1330 | fn filters(name: &str, globs: &[String]) -> Value<'static> { |
| | 1331 | let globs: Vec<_> = globs.iter().map(|glob| (0_u32, glob.clone())).collect(); |
| | 1332 | Value::from(vec![(name.to_owned(), globs)]) |
| | 1333 | } |
| | 1334 | |
| | 1335 | /// The file the file chooser portal answers `method` with, titled `title`, or where no portal |
| | 1336 | /// answers, zenity's or kdialog's; None when cancelled. |
| | 1337 | fn choose<const Z: usize, const K: usize>( |
| | 1338 | title: &str, |
| | 1339 | method: &str, |
| | 1340 | mut options: HashMap<&str, Value>, |
| | 1341 | zenity: [&str; Z], |
| | 1342 | kdialog: [&str; K], |
| | 1343 | ) -> Option<PathBuf> { |
| | 1344 | let chosen = portal("org.freedesktop.portal.FileChooser") |
| | 1345 | .map_err(Into::into) |
| | 1346 | .and_then(|chooser| { |
| | 1347 | portal_request(&chooser, |token| { |
| | 1348 | options.insert("handle_token", Value::from(token.to_owned())); |
| | 1349 | chooser.call(method, &("", title, &options)) |
| | 1350 | }) |
| | 1351 | }); |
| | 1352 | let results = match chosen { |
| | 1353 | Ok(results) => results?, |
| | 1354 | Err(error) => { |
| | 1355 | eprintln!("The file chooser portal failed: {error}"); |
| | 1356 | return picked(dialog(zenity, kdialog)); |
| | 1357 | } |
| | 1358 | }; |
| | 1359 | let uris: Vec<String> = results.get("uris")?.try_clone().ok()?.try_into().ok()?; |
| | 1360 | let path = uris.first()?.strip_prefix("file://")?; |
| | 1361 | Some(std::ffi::OsString::from_vec(crate::paste::percent_decode(path)).into()) |
| | 1362 | } |
| | 1363 | |
| | 1364 | /// The desktop portal's `interface`, whose calls give up after five seconds. |
| | 1365 | pub(crate) fn portal(interface: &'static str) -> zbus::Result<zbus::blocking::Proxy<'static>> { |
| | 1366 | let connection = zbus::blocking::connection::Builder::session()? |
| | 1367 | .method_timeout(Duration::from_secs(5)) |
| | 1368 | .build()?; |
| | 1369 | zbus::blocking::Proxy::new( |
| | 1370 | &connection, |
| | 1371 | "org.freedesktop.portal.Desktop", |
| | 1372 | "/org/freedesktop/portal/desktop", |
| | 1373 | interface, |
| | 1374 | ) |
| | 1375 | } |
| | 1376 | |
| | 1377 | /// Asks `portal` through `call`, given the token for its options' `handle_token`, then waits |
| | 1378 | /// on the dialog it opens: its results, or None when cancelled. |
| | 1379 | pub(crate) fn portal_request( |
| | 1380 | portal: &zbus::blocking::Proxy, |
| | 1381 | call: impl FnOnce(&str) -> zbus::Result<OwnedObjectPath>, |
| | 1382 | ) -> Result<Option<HashMap<String, OwnedValue>>, Box<dyn std::error::Error>> { |
| | 1383 | static REQUESTS: AtomicU32 = AtomicU32::new(0); |
| | 1384 | // A request's path follows from the sender and the token, so its answer can be awaited |
| | 1385 | // before it is asked for. |
| | 1386 | let sender = portal |
| | 1387 | .connection() |
| | 1388 | .unique_name() |
| | 1389 | .ok_or("No D-Bus name")? |
| | 1390 | .trim_start_matches(':') |
| | 1391 | .replace('.', "_"); |
| | 1392 | let token = format!( |
| | 1393 | "snowbound{}_{}", |
| | 1394 | std::process::id(), |
| | 1395 | REQUESTS.fetch_add(1, Ordering::Relaxed) |
| | 1396 | ); |
| | 1397 | let request = zbus::blocking::Proxy::new( |
| | 1398 | portal.connection(), |
| | 1399 | "org.freedesktop.portal.Desktop", |
| | 1400 | format!("/org/freedesktop/portal/desktop/request/{sender}/{token}"), |
| | 1401 | "org.freedesktop.portal.Request", |
| | 1402 | )?; |
| | 1403 | let mut answers = request.receive_signal("Response")?; |
| | 1404 | call(&token)?; |
| | 1405 | let answer = answers.next().ok_or("The portal did not answer")?; |
| | 1406 | let (response, results): (u32, HashMap<String, OwnedValue>) = answer.body().deserialize()?; |
| | 1407 | Ok((response == 0).then_some(results)) |
| 1321 | } | 1408 | } |
| 1322 | | 1409 | |
| 1323 | /// None: fontique falls back through fontconfig's coverage, which reaches every installed font. | 1410 | /// None: fontique falls back through fontconfig's coverage, which reaches every installed font. |
| ... | @@ -1340,10 +1427,30 @@ pub fn open_file(path: &std::path::Path) { | ... | @@ -1340,10 +1427,30 @@ pub fn open_file(path: &std::path::Path) { |
| 1340 | reveal(path); | 1427 | reveal(path); |
| 1341 | } | 1428 | } |
| 1342 | | 1429 | |
| 1343 | /// Opens `target`, a folder or a link's URL, with the desktop's handler. | 1430 | /// Opens `target`, a file, a folder or a link's URL, with the desktop's handler: through the |
| | 1431 | /// OpenURI portal, or xdg-open where no portal answers. |
| 1344 | pub fn reveal(target: impl AsRef<std::ffi::OsStr>) { | 1432 | pub fn reveal(target: impl AsRef<std::ffi::OsStr>) { |
| 1345 | let target = target.as_ref(); | 1433 | let target = target.as_ref(); |
| 1346 | if let Err(error) = Command::new("xdg-open").arg(target).spawn() { | 1434 | let opened = portal("org.freedesktop.portal.OpenURI") |
| | 1435 | .map_err(Into::into) |
| | 1436 | .and_then( |
| | 1437 | |portal| -> Result<OwnedObjectPath, Box<dyn std::error::Error>> { |
| | 1438 | let options = HashMap::<&str, Value>::new(); |
| | 1439 | Ok(match target.to_str() { |
| | 1440 | Some(uri) if !uri.starts_with('/') => { |
| | 1441 | portal.call("OpenURI", &("", uri, &options))? |
| | 1442 | } |
| | 1443 | // The portal opens local files by descriptor only. |
| | 1444 | _ => { |
| | 1445 | let file = std::fs::File::open(target)?; |
| | 1446 | portal.call("OpenFile", &("", zbus::zvariant::Fd::from(&file), &options))? |
| | 1447 | } |
| | 1448 | }) |
| | 1449 | }, |
| | 1450 | ); |
| | 1451 | if opened.is_err() |
| | 1452 | && let Err(error) = Command::new("xdg-open").arg(target).spawn() |
| | 1453 | { |
| 1347 | eprintln!("Cannot open {}: {error}", target.display()); | 1454 | eprintln!("Cannot open {}: {error}", target.display()); |
| 1348 | } | 1455 | } |
| 1349 | } | 1456 | } |
| ... | @@ -1377,30 +1484,43 @@ pub fn confirm(message: &str, detail: &str, cancel: &str, action: &str) -> bool | ... | @@ -1377,30 +1484,43 @@ pub fn confirm(message: &str, detail: &str, cancel: &str, action: &str) -> bool |
| 1377 | /// Asks for a notebook's table of contents or a section file, titled `title`; None when | 1484 | /// Asks for a notebook's table of contents or a section file, titled `title`; None when |
| 1378 | /// cancelled or when no tool can ask. | 1485 | /// cancelled or when no tool can ask. |
| 1379 | pub fn pick_notebook(title: &str) -> Option<PathBuf> { | 1486 | pub fn pick_notebook(title: &str) -> Option<PathBuf> { |
| 1380 | picked(dialog( | 1487 | let name = "OneNote notebooks, sections and packages"; |
| | 1488 | let globs = ["*.onetoc2", "*.one", "*.onepkg"].map(String::from); |
| | 1489 | choose( |
| | 1490 | title, |
| | 1491 | "OpenFile", |
| | 1492 | HashMap::from([("filters", filters(name, &globs))]), |
| 1381 | [ | 1493 | [ |
| 1382 | "--file-selection", | 1494 | "--file-selection", |
| 1383 | &format!("--title={title}"), | 1495 | &format!("--title={title}"), |
| 1384 | "--file-filter=OneNote notebooks, sections and packages | *.onetoc2 *.one *.onepkg", | 1496 | &format!("--file-filter={name} | {}", globs.join(" ")), |
| 1385 | ], | | |
| 1386 | [ | | |
| 1387 | "--getopenfilename", | | |
| 1388 | ".", | | |
| 1389 | "*.onetoc2 *.one *.onepkg", | | |
| 1390 | "--title", | | |
| 1391 | title, | | |
| 1392 | ], | 1497 | ], |
| 1393 | )) | 1498 | ["--getopenfilename", ".", &globs.join(" "), "--title", title], |
| | 1499 | ) |
| 1394 | } | 1500 | } |
| 1395 | | 1501 | |
| 1396 | /// Asks where to put something named `name` by default; the dialog names its own button. | 1502 | /// Asks where to put something named `name` by default, in `folder` where given, with |
| | 1503 | /// `action` on the button where the portal asks. |
| 1397 | pub fn pick_new( | 1504 | pub fn pick_new( |
| 1398 | title: &str, | 1505 | title: &str, |
| 1399 | name: &str, | 1506 | name: &str, |
| 1400 | _action: &str, | 1507 | action: &str, |
| 1401 | _folder: Option<&std::path::Path>, | 1508 | folder: Option<&std::path::Path>, |
| 1402 | ) -> Option<PathBuf> { | 1509 | ) -> Option<PathBuf> { |
| 1403 | picked(dialog( | 1510 | use std::os::unix::ffi::OsStrExt; |
| | 1511 | let mut options = HashMap::from([ |
| | 1512 | ("current_name", Value::from(name)), |
| | 1513 | ("accept_label", Value::from(action)), |
| | 1514 | ]); |
| | 1515 | if let Some(folder) = folder { |
| | 1516 | let mut bytes = folder.as_os_str().as_bytes().to_vec(); |
| | 1517 | bytes.push(0); |
| | 1518 | options.insert("current_folder", Value::from(bytes)); |
| | 1519 | } |
| | 1520 | choose( |
| | 1521 | title, |
| | 1522 | "SaveFile", |
| | 1523 | options, |
| 1404 | [ | 1524 | [ |
| 1405 | "--file-selection", | 1525 | "--file-selection", |
| 1406 | "--save", | 1526 | "--save", |
| ... | @@ -1408,7 +1528,7 @@ pub fn pick_new( | ... | @@ -1408,7 +1528,7 @@ pub fn pick_new( |
| 1408 | &format!("--filename={name}"), | 1528 | &format!("--filename={name}"), |
| 1409 | ], | 1529 | ], |
| 1410 | ["--getsavefilename", name, "--title", title], | 1530 | ["--getsavefilename", name, "--title", title], |
| 1411 | )) | 1531 | ) |
| 1412 | } | 1532 | } |
| 1413 | | 1533 | |
| 1414 | /// Tells the user something they asked for could not be done: `message`, then what to do. | 1534 | /// Tells the user something they asked for could not be done: `message`, then what to do. |