| ... | ... | @@ -1,11 +1,13 @@ |
| 1 | | //! Linux, on X11 and Wayland: window controls drawn in the title bar, dialogs through zenity |
| 2 | | //! or kdialog, the colour scheme from the XDG settings portal, and text conventions from |
| 3 | | //! the C library's locale. |
| 1 | //! 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 or kdialog, the colour scheme |
| 3 | //! from the settings portal, and text conventions from the C library's locale. |
| 4 | 4 | |
| 5 | 5 | use canvas::date::DateField; |
| 6 | 6 | use std::{ |
| 7 | collections::HashMap, |
| 7 | 8 | error::Error, |
| 8 | 9 | ffi::CStr, |
| 10 | os::unix::ffi::OsStringExt, |
| 9 | 11 | path::PathBuf, |
| 10 | 12 | process::Command, |
| 11 | 13 | sync::{ |
| ... | ... | @@ -23,6 +25,7 @@ use winit::{ |
| 23 | 25 | }, |
| 24 | 26 | window::{ResizeDirection, Theme, Window, WindowAttributes}, |
| 25 | 27 | }; |
| 28 | use zbus::zvariant::{OwnedObjectPath, OwnedValue, Value}; |
| 26 | 29 | |
| 27 | 30 | /// The title bar's leading margin; the window controls sit at its trailing end. |
| 28 | 31 | pub const LEADING: f32 = 8.0; |
| ... | ... | @@ -884,8 +887,7 @@ fn portal_color_scheme() -> Option<bool> { |
| 884 | 887 | } |
| 885 | 888 | |
| 886 | 889 | /// Setting `key` in `namespace` from the settings portal. |
| 887 | | fn portal_setting(namespace: &str, key: &str) -> Option<zbus::zvariant::OwnedValue> { |
| 888 | | use zbus::zvariant::{OwnedValue, Value}; |
| 890 | fn portal_setting(namespace: &str, key: &str) -> Option<OwnedValue> { |
| 889 | 891 | // A missing portal must not hold up the window for D-Bus's 25 s default. |
| 890 | 892 | let connection = zbus::blocking::connection::Builder::session() |
| 891 | 893 | .ok()? |
| ... | ... | @@ -931,7 +933,7 @@ pub fn show_file(file: &std::path::Path) { |
| 931 | 933 | if shown.is_err() |
| 932 | 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 | 1304 | |
| 1303 | 1305 | /// Asks for a file to insert, one of `types` (extensions) unless empty, titled `title`. |
| 1304 | 1306 | pub fn pick_file(title: &str, types: &[&str]) -> Option<PathBuf> { |
| 1307 | let globs: Vec<_> = types.iter().map(|kind| format!("*.{kind}")).collect(); |
| 1305 | 1308 | let patterns = match types { |
| 1306 | 1309 | [] => "*".to_owned(), |
| 1307 | | types => types |
| 1308 | | .iter() |
| 1309 | | .map(|kind| format!("*.{kind}")) |
| 1310 | | .collect::<Vec<_>>() |
| 1311 | | .join(" "), |
| 1310 | _ => globs.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 | 1321 | "--file-selection", |
| 1316 | 1322 | &format!("--title={title}"), |
| 1317 | 1323 | &format!("--file-filter={patterns}"), |
| 1318 | 1324 | ], |
| 1319 | 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 | 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 | 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 | 1432 | pub fn reveal(target: impl AsRef<std::ffi::OsStr>) { |
| 1345 | 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 | 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 | 1484 | /// Asks for a notebook's table of contents or a section file, titled `title`; None when |
| 1378 | 1485 | /// cancelled or when no tool can ask. |
| 1379 | 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 | 1494 | "--file-selection", |
| 1383 | 1495 | &format!("--title={title}"), |
| 1384 | | "--file-filter=OneNote notebooks, sections and packages | *.onetoc2 *.one *.onepkg", |
| 1385 | | ], |
| 1386 | | [ |
| 1387 | | "--getopenfilename", |
| 1388 | | ".", |
| 1389 | | "*.onetoc2 *.one *.onepkg", |
| 1390 | | "--title", |
| 1391 | | title, |
| 1496 | &format!("--file-filter={name} | {}", globs.join(" ")), |
| 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 | 1504 | pub fn pick_new( |
| 1398 | 1505 | title: &str, |
| 1399 | 1506 | name: &str, |
| 1400 | | _action: &str, |
| 1401 | | _folder: Option<&std::path::Path>, |
| 1507 | action: &str, |
| 1508 | folder: Option<&std::path::Path>, |
| 1402 | 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 | 1525 | "--file-selection", |
| 1406 | 1526 | "--save", |
| ... | ... | @@ -1408,7 +1528,7 @@ pub fn pick_new( |
| 1408 | 1528 | &format!("--filename={name}"), |
| 1409 | 1529 | ], |
| 1410 | 1530 | ["--getsavefilename", name, "--title", title], |
| 1411 | | )) |
| 1531 | ) |
| 1412 | 1532 | } |
| 1413 | 1533 | |
| 1414 | 1534 | /// Tells the user something they asked for could not be done: `message`, then what to do. |