mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-25 10:17:02 -06:00
Merge pull request #2783 from dotCooCoo/fix/linux-save-picks-chosen-type
Save As: on Linux the file type picked in the save dialog decides the format
This commit is contained in:
Generated
+2
@@ -4536,6 +4536,7 @@ dependencies = [
|
||||
"vectorcraft-engine",
|
||||
"vectorcraft-text",
|
||||
"vectorcraft-ui-egui",
|
||||
"wayland-backend",
|
||||
"wayland-client",
|
||||
"wayland-protocols",
|
||||
"webbrowser",
|
||||
@@ -4543,6 +4544,7 @@ dependencies = [
|
||||
"winit",
|
||||
"winresource",
|
||||
"winsafe",
|
||||
"zbus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -75,7 +75,17 @@ pollster = "1"
|
||||
# Asks the Wayland compositor which GPU it draws with (linux-dmabuf feedback, src/compositor_gpu.rs),
|
||||
# so Automatic draws on that one. Both are already in the build through winit.
|
||||
wayland-client = "0.31"
|
||||
wayland-protocols = { version = "0.32", features = ["client"] }
|
||||
wayland-protocols = { version = "0.32", features = ["client", "unstable"] }
|
||||
# The save dialog's parent window on Wayland (src/portal_save.rs): an xdg-foreign handle (in
|
||||
# wayland-protocols' `unstable` set) made on the window's own connection. MIT, already in the build
|
||||
# through winit and rfd.
|
||||
wayland-backend = { version = "0.3", features = ["client_system"] }
|
||||
|
||||
# The save dialog's file type (src/portal_save.rs, #2679): the desktop portal over D-Bus, on the
|
||||
# systems where rfd shows the portal's dialog. MIT, already in the build through accesskit. Pinned
|
||||
# exactly: review the diff before bumping.
|
||||
[target.'cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))'.dependencies]
|
||||
zbus = "=5.19.0"
|
||||
|
||||
[build-dependencies]
|
||||
# Icon + VERSIONINFO for vectorcraft.exe; build.rs only uses it when targeting Windows.
|
||||
@@ -84,3 +94,5 @@ winresource = "0.1"
|
||||
[dev-dependencies]
|
||||
# Bound socket buffers in real loopback transport deadline regressions.
|
||||
socket2 = { workspace = true }
|
||||
# src/portal_save.rs's tests build and read the portal's D-Bus values on every system.
|
||||
zbus = "=5.19.0"
|
||||
|
||||
@@ -43,6 +43,8 @@ mod mac_screen_color;
|
||||
mod mac_window;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod open_documents;
|
||||
#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd", test))]
|
||||
mod portal_save;
|
||||
mod prefs_dir;
|
||||
mod printing;
|
||||
#[cfg(all(windows, not(target_vendor = "win7")))]
|
||||
@@ -359,10 +361,20 @@ fn make_folder(request: &PickRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Show `dialog` for `request` (on the calling thread, until it closes) → the paths picked.
|
||||
/// Show `dialog` for `request` (on the calling thread, until it closes) → the paths picked. On
|
||||
/// Linux and the BSDs a save dialog with two or more file types is the desktop portal's, over
|
||||
/// `parent`, and its answer also names the file type picked ([`portal_save`]); `dialog` shows when
|
||||
/// the portal can't.
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
fn show_dialog(dialog: rfd::FileDialog, request: &PickRequest) -> Vec<String> {
|
||||
#[cfg_attr(windows, allow(unused_variables))]
|
||||
fn show_dialog(dialog: rfd::FileDialog, request: &PickRequest, parent: &Parent) -> Vec<String> {
|
||||
make_folder(request);
|
||||
#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))]
|
||||
if let PickRequest::Save(pick) = request
|
||||
&& let Some(answer) = portal_save::pick(pick, parent.as_deref())
|
||||
{
|
||||
return answer;
|
||||
}
|
||||
let paths = match request {
|
||||
PickRequest::Open(_) => dialog.pick_file().into_iter().collect(),
|
||||
PickRequest::OpenMany => dialog.pick_files().unwrap_or_default(),
|
||||
@@ -375,7 +387,7 @@ fn show_dialog(dialog: rfd::FileDialog, request: &PickRequest) -> Vec<String> {
|
||||
/// Show the dialog for `request` over `parent` now → the paths picked.
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
fn pick_now(request: PickRequest, parent: &Parent) -> Vec<String> {
|
||||
show_dialog(file_dialog(&request, parent), &request)
|
||||
show_dialog(file_dialog(&request, parent), &request, parent)
|
||||
}
|
||||
|
||||
/// macOS shows no dialog in line: its modal loop would run inside winit's event handler, and an
|
||||
@@ -431,11 +443,12 @@ fn start_pick(request: PickRequest, parent: &Parent, ui: &egui::Context) -> Opti
|
||||
let dialog = file_dialog(&request, parent);
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let ui = ui.clone();
|
||||
let parent = parent.clone();
|
||||
std::thread::Builder::new()
|
||||
.name("file-dialog".into())
|
||||
.spawn(move || {
|
||||
// The app gone meanwhile has no use for the answer.
|
||||
let _ = tx.send(show_dialog(dialog, &request));
|
||||
let _ = tx.send(show_dialog(dialog, &request, &parent));
|
||||
ui.request_repaint();
|
||||
})
|
||||
.ok()?;
|
||||
|
||||
@@ -0,0 +1,529 @@
|
||||
//! Linux and the BSDs: the save dialog of a save with two or more file types (Save, Save As, Save a
|
||||
//! Copy, Save as Template; #2679), asked of the XDG desktop portal here
|
||||
//! (<https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.FileChooser.html>).
|
||||
//! rfd 0.17 sends no `current_filter` with `SaveFile` and reads only `uris` from the answer
|
||||
//! (PolyMeilex/rfd#341), and the GTK file chooser behind the portal keeps the typed name when the
|
||||
//! user picks another file type, so the type picked was lost. This call sends the first file type as
|
||||
//! `current_filter`, reads the one picked back, and answers its first extension after the path
|
||||
//! ([`vectorcraft_ui_egui::picks::PickAnswer`]); the app gives the name that type's extension.
|
||||
//! rfd shows the dialog when the portal can't (no session bus, no file chooser), with zenity as its
|
||||
//! own fallback. PhotoCraft's `portal_save.rs` (storytold/photocraft#6151) makes the same call.
|
||||
//!
|
||||
//! The dialog's parent is the window: `x11:<window id>` on X11, and on Wayland (Linux) a handle
|
||||
//! from the compositor's xdg-foreign protocol, as rfd names it (its `window_identifier`).
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use vectorcraft_ui_egui::FilePick;
|
||||
use zbus::zvariant::{OwnedValue, Value};
|
||||
|
||||
/// A file type in the dialog: its label and its extensions without the dot.
|
||||
type Filter = (String, Vec<String>);
|
||||
|
||||
/// A save dialog to show.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
struct SaveRequest {
|
||||
/// The portal's identifier of the parent window, or empty.
|
||||
parent: String,
|
||||
/// The suggested name; empty for none.
|
||||
name: String,
|
||||
folder: Option<String>,
|
||||
/// The file types, labeled in the interface's language; the dialog starts on the first.
|
||||
filters: Vec<Filter>,
|
||||
}
|
||||
|
||||
impl SaveRequest {
|
||||
/// The dialog for `pick` over the window `parent` names.
|
||||
fn new(pick: &FilePick, parent: String) -> Self {
|
||||
let lang = vectorcraft_ui_egui::i18n::current();
|
||||
let filters = pick
|
||||
.filters
|
||||
.iter()
|
||||
.map(|(label, exts)| {
|
||||
(vectorcraft_ui_egui::i18n::tr_ctx(lang, "file-filter", label).to_string(), exts.iter().map(|e| e.to_string()).collect())
|
||||
})
|
||||
.collect();
|
||||
Self { parent, name: pick.name.clone(), folder: pick.folder.clone(), filters }
|
||||
}
|
||||
}
|
||||
|
||||
/// What the dialog answered: the path, as typed, and the label of the file type picked.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
struct Saved {
|
||||
path: String,
|
||||
filter: Option<String>,
|
||||
}
|
||||
|
||||
/// `filter` as the portal takes it: its label and a `*.ext` pattern for each extension (`*` for
|
||||
/// every file), as rfd sends them.
|
||||
fn wire(filter: &Filter) -> (String, Vec<(u32, String)>) {
|
||||
let globs = filter.1.iter().map(|e| (0, if e.is_empty() || e == "*" { "*".to_string() } else { format!("*.{e}") })).collect();
|
||||
(filter.0.clone(), globs)
|
||||
}
|
||||
|
||||
/// The `SaveFile` options for `request`, answering on `token`.
|
||||
fn options<'a>(request: &'a SaveRequest, token: &'a str) -> HashMap<&'static str, Value<'a>> {
|
||||
let mut o = HashMap::from([("handle_token", Value::from(token))]);
|
||||
if !request.name.is_empty() {
|
||||
o.insert("current_name", Value::from(request.name.as_str()));
|
||||
}
|
||||
if let Some(folder) = &request.folder {
|
||||
// A byte string ending in NUL, as the portal specifies.
|
||||
let mut bytes = folder.as_bytes().to_vec();
|
||||
bytes.push(0);
|
||||
o.insert("current_folder", Value::from(bytes));
|
||||
}
|
||||
if let Some(first) = request.filters.first() {
|
||||
o.insert("filters", Value::from(request.filters.iter().map(wire).collect::<Vec<_>>()));
|
||||
o.insert("current_filter", Value::from(wire(first)));
|
||||
}
|
||||
o
|
||||
}
|
||||
|
||||
/// The portal's `Response`: code 0 with the first of its `uris` and the label of its
|
||||
/// `current_filter`. Any other code ends the dialog without a file (1 is Cancel, 2 a dialog closed
|
||||
/// some other way), as rfd reads it.
|
||||
fn answer(code: u32, results: &HashMap<String, OwnedValue>) -> Result<Option<Saved>, String> {
|
||||
if code != 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
let uris: Vec<String> = results
|
||||
.get("uris")
|
||||
.ok_or("no file in the portal's answer")?
|
||||
.try_clone()
|
||||
.map_err(|e| e.to_string())?
|
||||
.try_into()
|
||||
.map_err(|e: zbus::zvariant::Error| e.to_string())?;
|
||||
let uri = uris.first().ok_or("no file in the portal's answer")?;
|
||||
let path = file_uri_path(uri).ok_or_else(|| format!("not a local file: {uri}"))?;
|
||||
let filter = results.get("current_filter").and_then(|v| {
|
||||
let (label, _globs): (String, Vec<(u32, String)>) = v.try_clone().ok()?.try_into().ok()?;
|
||||
Some(label)
|
||||
});
|
||||
Ok(Some(Saved { path, filter }))
|
||||
}
|
||||
|
||||
/// `file:///home/me/A%20b.svg` → `/home/me/A b.svg`; `None` for a file elsewhere (`file://host/`)
|
||||
/// or another scheme.
|
||||
fn file_uri_path(uri: &str) -> Option<String> {
|
||||
let rest = uri.strip_prefix("file://")?;
|
||||
let rest = rest.strip_prefix("localhost").unwrap_or(rest);
|
||||
if !rest.starts_with('/') {
|
||||
return None;
|
||||
}
|
||||
let mut out = Vec::with_capacity(rest.len());
|
||||
let mut rest = rest.as_bytes();
|
||||
while let Some((&b, tail)) = rest.split_first() {
|
||||
let hex = tail.get(..2).filter(|h| b == b'%' && h.iter().all(u8::is_ascii_hexdigit));
|
||||
match hex.and_then(|h| std::str::from_utf8(h).ok()).and_then(|h| u8::from_str_radix(h, 16).ok()) {
|
||||
Some(decoded) => {
|
||||
out.push(decoded);
|
||||
rest = tail.get(2..).unwrap_or_default();
|
||||
}
|
||||
None => {
|
||||
out.push(b);
|
||||
rest = tail;
|
||||
}
|
||||
}
|
||||
}
|
||||
// `show_dialog` makes rfd's path a string with `to_string_lossy` too.
|
||||
Some(String::from_utf8_lossy(&out).into_owned())
|
||||
}
|
||||
|
||||
/// `saved` as the answer of the save dialog for `request`: the path, then the first extension of
|
||||
/// the file type picked, found by its label. Empty when the dialog was canceled.
|
||||
fn pick_answer(saved: Option<Saved>, request: &SaveRequest) -> Vec<String> {
|
||||
let Some(saved) = saved else { return Vec::new() };
|
||||
let picked = saved.filter.and_then(|label| request.filters.iter().find(|(l, _)| *l == label)).and_then(|(_, exts)| exts.first().cloned());
|
||||
std::iter::once(saved.path).chain(picked).collect()
|
||||
}
|
||||
|
||||
/// The portal's identifier of an X11 window.
|
||||
fn x11_parent(window: impl std::fmt::LowerHex) -> String {
|
||||
format!("x11:{window:x}")
|
||||
}
|
||||
|
||||
/// Show the portal's save dialog for `pick` over `window` and wait for it → the answer (see the
|
||||
/// module's comment). `None` when `pick` offers fewer than two file types, or the portal couldn't
|
||||
/// show the dialog; rfd shows it then.
|
||||
#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))]
|
||||
pub fn pick(pick: &FilePick, window: Option<&winit::window::Window>) -> Option<Vec<String>> {
|
||||
if pick.filters.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
// The export stays until the dialog has answered: the handle is valid while it lives.
|
||||
let (parent, _export) = window.map(parent).unwrap_or_default();
|
||||
let request = SaveRequest::new(pick, parent);
|
||||
let saved = zbus::blocking::Connection::session().map_err(|e| e.to_string()).and_then(|conn| save_file(&conn, &request));
|
||||
match saved {
|
||||
Ok(saved) => Some(pick_answer(saved, &request)),
|
||||
Err(e) => {
|
||||
log::info!("the portal's save dialog failed ({e}): rfd shows it");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Ask the portal on `conn` for the save dialog of `request` and wait for its answer.
|
||||
#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))]
|
||||
fn save_file(conn: &zbus::blocking::Connection, request: &SaveRequest) -> Result<Option<Saved>, String> {
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
|
||||
use zbus::blocking::Proxy;
|
||||
use zbus::zvariant::OwnedObjectPath;
|
||||
|
||||
const DESKTOP: &str = "org.freedesktop.portal.Desktop";
|
||||
const REQUEST: &str = "org.freedesktop.portal.Request";
|
||||
static NEXT: AtomicU32 = AtomicU32::new(0);
|
||||
let sender = conn.unique_name().ok_or("no name on the session bus")?.as_str().trim_start_matches(':').replace('.', "_");
|
||||
let token = format!("vectorcraft_save_{}_{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed));
|
||||
let path = format!("/org/freedesktop/portal/desktop/request/{sender}/{token}");
|
||||
// Listen before asking, so that an answer that comes at once is not missed.
|
||||
let mut replies = Proxy::new(conn, DESKTOP, path.as_str(), REQUEST).and_then(|p| p.receive_signal("Response")).map_err(|e| e.to_string())?;
|
||||
let chooser = Proxy::new(conn, DESKTOP, "/org/freedesktop/portal/desktop", "org.freedesktop.portal.FileChooser").map_err(|e| e.to_string())?;
|
||||
// The title is empty, as rfd sends it for a save.
|
||||
let handle: OwnedObjectPath = chooser.call("SaveFile", &(request.parent.as_str(), "", options(request, &token))).map_err(|e| e.to_string())?;
|
||||
// A portal older than version 0.9 ignores `handle_token` and answers on the request path it
|
||||
// returns.
|
||||
let reply = if handle.as_str() == path {
|
||||
replies.next()
|
||||
} else {
|
||||
Proxy::new(conn, DESKTOP, handle.as_str(), REQUEST).and_then(|p| p.receive_signal("Response")).map_err(|e| e.to_string())?.next()
|
||||
}
|
||||
.ok_or("the save dialog closed without an answer")?;
|
||||
let (code, results): (u32, HashMap<String, OwnedValue>) = reply.body().deserialize().map_err(|e| e.to_string())?;
|
||||
answer(code, &results)
|
||||
}
|
||||
|
||||
/// The portal's identifier of `window` when it is an X11 window.
|
||||
#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))]
|
||||
fn x11_window(window: &winit::window::Window) -> Option<String> {
|
||||
use winit::raw_window_handle::{HasWindowHandle as _, RawWindowHandle};
|
||||
match window.window_handle().ok()?.as_raw() {
|
||||
RawWindowHandle::Xlib(h) => Some(x11_parent(h.window)),
|
||||
RawWindowHandle::Xcb(h) => Some(x11_parent(h.window.get())),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The portal's identifier of `window`, and on Wayland the export that keeps it valid; empty when
|
||||
/// the window has neither an X11 id nor a Wayland handle.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn parent(window: &winit::window::Window) -> (String, Option<wayland::Export>) {
|
||||
if let Some(id) = x11_window(window) {
|
||||
return (id, None);
|
||||
}
|
||||
match wayland::export(window) {
|
||||
Some(export) => (format!("wayland:{}", export.handle), Some(export)),
|
||||
None => (String::new(), None),
|
||||
}
|
||||
}
|
||||
|
||||
/// The portal's identifier of `window`: its X11 id, else empty (no Wayland export is made off
|
||||
/// Linux).
|
||||
#[cfg(any(target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))]
|
||||
fn parent(window: &winit::window::Window) -> (String, Option<()>) {
|
||||
(x11_window(window).unwrap_or_default(), None)
|
||||
}
|
||||
|
||||
/// The Wayland window's handle for the portal: an xdg-foreign v2 export of its surface
|
||||
/// (<https://wayland.app/protocols/xdg-foreign-unstable-v2>), made as rfd's `window_identifier`
|
||||
/// makes it.
|
||||
#[cfg(target_os = "linux")]
|
||||
mod wayland {
|
||||
use wayland_client::globals::{GlobalListContents, registry_queue_init};
|
||||
use wayland_client::protocol::wl_registry::{self, WlRegistry};
|
||||
use wayland_client::protocol::wl_surface::WlSurface;
|
||||
use wayland_client::{Connection, Dispatch, Proxy, QueueHandle};
|
||||
use wayland_protocols::xdg::foreign::zv2::client::zxdg_exported_v2::{self, ZxdgExportedV2};
|
||||
use wayland_protocols::xdg::foreign::zv2::client::zxdg_exporter_v2::{self, ZxdgExporterV2};
|
||||
|
||||
/// A window exported for the portal: `handle` names it until this is dropped.
|
||||
pub struct Export {
|
||||
pub handle: String,
|
||||
exported: ZxdgExportedV2,
|
||||
conn: Connection,
|
||||
}
|
||||
|
||||
impl Drop for Export {
|
||||
fn drop(&mut self) {
|
||||
self.exported.destroy();
|
||||
let _ = self.conn.flush();
|
||||
}
|
||||
}
|
||||
|
||||
/// The events the export waits for.
|
||||
#[derive(Default)]
|
||||
struct State {
|
||||
handle: Option<String>,
|
||||
}
|
||||
|
||||
impl Dispatch<WlRegistry, GlobalListContents> for State {
|
||||
fn event(_: &mut Self, _: &WlRegistry, _: wl_registry::Event, _: &GlobalListContents, _: &Connection, _: &QueueHandle<Self>) {}
|
||||
}
|
||||
|
||||
impl Dispatch<ZxdgExporterV2, ()> for State {
|
||||
fn event(_: &mut Self, _: &ZxdgExporterV2, _: zxdg_exporter_v2::Event, _: &(), _: &Connection, _: &QueueHandle<Self>) {}
|
||||
}
|
||||
|
||||
impl Dispatch<ZxdgExportedV2, ()> for State {
|
||||
fn event(state: &mut Self, _: &ZxdgExportedV2, event: zxdg_exported_v2::Event, _: &(), _: &Connection, _: &QueueHandle<Self>) {
|
||||
if let zxdg_exported_v2::Event::Handle { handle } = event {
|
||||
state.handle = Some(handle);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Export `window`'s surface on the window's own Wayland connection, with an event queue of its
|
||||
/// own → `None` when the compositor has no xdg-foreign v2 or answers no handle.
|
||||
pub fn export(window: &winit::window::Window) -> Option<Export> {
|
||||
let (conn, surface) = surface(window)?;
|
||||
let (globals, mut queue) = registry_queue_init::<State>(&conn).ok()?;
|
||||
let qh = queue.handle();
|
||||
let exporter: ZxdgExporterV2 = globals.bind(&qh, 1..=1, ()).ok()?;
|
||||
let exported = exporter.export_toplevel(&surface, &qh, ());
|
||||
let mut state = State::default();
|
||||
let answered = queue.roundtrip(&mut state);
|
||||
exporter.destroy();
|
||||
match (answered, state.handle) {
|
||||
(Ok(_), Some(handle)) => Some(Export { handle, exported, conn }),
|
||||
_ => {
|
||||
exported.destroy();
|
||||
let _ = conn.flush();
|
||||
log::info!("the compositor gave no xdg-foreign handle: the save dialog has no parent window");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `window`'s Wayland connection and surface, from its raw handles.
|
||||
#[allow(unsafe_code)]
|
||||
fn surface(window: &winit::window::Window) -> Option<(Connection, WlSurface)> {
|
||||
use wayland_backend::sys::client::{Backend, ObjectId};
|
||||
use winit::raw_window_handle::{HasDisplayHandle as _, HasWindowHandle as _, RawDisplayHandle, RawWindowHandle};
|
||||
let (RawWindowHandle::Wayland(w), RawDisplayHandle::Wayland(d)) =
|
||||
(window.window_handle().ok()?.as_raw(), window.display_handle().ok()?.as_raw())
|
||||
else {
|
||||
return None;
|
||||
};
|
||||
// SAFETY: the display is winit's open `wl_display`, which outlives `window` (the caller
|
||||
// holds it until the export is dropped); a backend made from a foreign display does not
|
||||
// close it when dropped.
|
||||
let backend = unsafe { Backend::from_foreign_display(d.display.as_ptr().cast()) };
|
||||
let conn = Connection::from_backend(backend);
|
||||
// SAFETY: the pointer is the window's live `wl_surface` proxy on that display.
|
||||
let id = unsafe { ObjectId::from_ptr(WlSurface::interface(), w.surface.as_ptr().cast()) }.ok()?;
|
||||
let surface = WlSurface::from_id(&conn, id).ok()?;
|
||||
Some((conn, surface))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn request() -> SaveRequest {
|
||||
let pick = FilePick {
|
||||
name: "Untitled-1.vectorcraft".into(),
|
||||
folder: Some("/home/me/Art".into()),
|
||||
filters: vec![("VectorCraft", &["vectorcraft"]), ("SVG", &["svg"]), ("PDF-compatible .ai", &["ai"])],
|
||||
};
|
||||
SaveRequest::new(&pick, "x11:3a0000f".into())
|
||||
}
|
||||
|
||||
/// The dialog starts on the first file type (the document's format) and shows the suggested
|
||||
/// name in its folder.
|
||||
#[test]
|
||||
fn the_request_names_the_file_type_the_dialog_starts_on() {
|
||||
let request = request();
|
||||
let o = options(&request, "t1");
|
||||
assert_eq!(o.get("current_filter"), Some(&Value::from(("VectorCraft".to_string(), vec![(0u32, "*.vectorcraft".to_string())]))));
|
||||
let filters = vec![
|
||||
("VectorCraft".to_string(), vec![(0u32, "*.vectorcraft".to_string())]),
|
||||
("SVG".to_string(), vec![(0u32, "*.svg".to_string())]),
|
||||
("PDF-compatible .ai".to_string(), vec![(0u32, "*.ai".to_string())]),
|
||||
];
|
||||
assert_eq!(o.get("filters"), Some(&Value::from(filters)));
|
||||
assert_eq!(o.get("current_name"), Some(&Value::from("Untitled-1.vectorcraft")));
|
||||
assert_eq!(o.get("current_folder"), Some(&Value::from(b"/home/me/Art\0".to_vec())));
|
||||
assert_eq!(o.get("handle_token"), Some(&Value::from("t1")));
|
||||
let bare = SaveRequest { name: String::new(), folder: None, filters: Vec::new(), ..request };
|
||||
let o = options(&bare, "t2");
|
||||
assert_eq!(o.keys().copied().collect::<Vec<_>>(), ["handle_token"]);
|
||||
}
|
||||
|
||||
/// The portal's answer as a D-Bus dictionary: `uris` and `current_filter`.
|
||||
fn results(uri: &str, filter: Option<&str>) -> HashMap<String, OwnedValue> {
|
||||
let mut r = HashMap::new();
|
||||
r.insert("uris".to_string(), OwnedValue::try_from(Value::from(vec![uri.to_string()])).unwrap());
|
||||
if let Some(label) = filter {
|
||||
let picked = Value::from((label.to_string(), vec![(0u32, "*.ai".to_string())]));
|
||||
r.insert("current_filter".to_string(), OwnedValue::try_from(picked).unwrap());
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
/// The file type picked comes back with the path as typed, and its first extension follows
|
||||
/// the path in the answer.
|
||||
#[test]
|
||||
fn the_answer_carries_the_file_type_picked() {
|
||||
let request = request();
|
||||
let saved = answer(0, &results("file:///home/me/Art/poster", Some("PDF-compatible .ai"))).unwrap();
|
||||
assert_eq!(saved, Some(Saved { path: "/home/me/Art/poster".into(), filter: Some("PDF-compatible .ai".into()) }));
|
||||
assert_eq!(pick_answer(saved, &request), ["/home/me/Art/poster", "ai"]);
|
||||
// A portal that names no file type, or one the dialog did not offer: the path alone.
|
||||
let saved = answer(0, &results("file:///home/me/Art/poster.svg", None)).unwrap();
|
||||
assert_eq!(pick_answer(saved, &request), ["/home/me/Art/poster.svg"]);
|
||||
let saved = answer(0, &results("file:///home/me/Art/poster", Some("PNG"))).unwrap();
|
||||
assert_eq!(pick_answer(saved, &request), ["/home/me/Art/poster"]);
|
||||
}
|
||||
|
||||
/// Cancel (1) and a dialog closed another way (2) answer no file; an answer without a local file
|
||||
/// is an error, and rfd shows the dialog instead.
|
||||
#[test]
|
||||
fn a_canceled_dialog_answers_no_file() {
|
||||
let request = request();
|
||||
for code in [1, 2] {
|
||||
assert_eq!(answer(code, &HashMap::new()), Ok(None));
|
||||
assert!(pick_answer(None, &request).is_empty());
|
||||
}
|
||||
assert!(answer(0, &HashMap::new()).is_err());
|
||||
assert!(answer(0, &results("sftp://host/poster.svg", Some("SVG"))).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_uris_become_local_paths() {
|
||||
assert_eq!(file_uri_path("file:///home/me/Untitled-1.vectorcraft").as_deref(), Some("/home/me/Untitled-1.vectorcraft"));
|
||||
assert_eq!(file_uri_path("file:///home/me/A%20b%C3%A9.svg").as_deref(), Some("/home/me/A bé.svg"));
|
||||
assert_eq!(file_uri_path("file://localhost/tmp/x.pdf").as_deref(), Some("/tmp/x.pdf"));
|
||||
// A `%` without two hex digits after it stays as it is.
|
||||
assert_eq!(file_uri_path("file:///tmp/100%+1%2").as_deref(), Some("/tmp/100%+1%2"));
|
||||
assert_eq!(file_uri_path("file://host/x.svg"), None);
|
||||
assert_eq!(file_uri_path("sftp://host/x.svg"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn x11_windows_are_named_by_their_id() {
|
||||
assert_eq!(x11_parent(0x3a0000f), "x11:3a0000f");
|
||||
}
|
||||
|
||||
/// What the mock portal answers to each `SaveFile` call: the response code, and the file and
|
||||
/// the label of the file type "picked".
|
||||
#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))]
|
||||
type Script = Vec<(u32, Option<(&'static str, &'static str)>)>;
|
||||
|
||||
/// What each `SaveFile` call carried: `current_name` and the label of `current_filter`.
|
||||
#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))]
|
||||
type Seen = std::sync::Arc<std::sync::Mutex<Vec<(Option<String>, Option<String>)>>>;
|
||||
|
||||
/// A session bus of its own (`dbus-daemon`) with a mock `org.freedesktop.portal.FileChooser`
|
||||
/// that answers `SaveFile` from `script`, as PhotoCraft's portal tests run it. With `own_paths`
|
||||
/// it is a portal older than 0.9, which answers on a request path of its own. `None` when there
|
||||
/// is no `dbus-daemon` to run.
|
||||
#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))]
|
||||
fn mock_portal(script: Script, own_paths: bool) -> Option<(zbus::blocking::Connection, Seen, std::process::Child)> {
|
||||
use std::io::BufRead as _;
|
||||
|
||||
use zbus::zvariant::OwnedObjectPath;
|
||||
let mut daemon = std::process::Command::new("dbus-daemon")
|
||||
.args(["--session", "--nofork", "--print-address=1"])
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.spawn()
|
||||
.ok()?;
|
||||
let mut address = String::new();
|
||||
std::io::BufReader::new(daemon.stdout.take()?).read_line(&mut address).ok()?;
|
||||
let address = address.trim().to_string();
|
||||
let server =
|
||||
zbus::blocking::connection::Builder::address(address.as_str()).ok()?.name("org.freedesktop.portal.Desktop").ok()?.build().ok()?;
|
||||
let seen = Seen::default();
|
||||
let log = seen.clone();
|
||||
std::thread::spawn(move || {
|
||||
let mut script = script.into_iter();
|
||||
for msg in zbus::blocking::MessageIterator::from(&server) {
|
||||
let Ok(msg) = msg else { break };
|
||||
let header = msg.header();
|
||||
if header.message_type() != zbus::message::Type::MethodCall || header.member().map(|m| m.as_str()) != Some("SaveFile") {
|
||||
continue;
|
||||
}
|
||||
let Ok((_parent, _title, options)) = msg.body().deserialize::<(String, String, HashMap<String, OwnedValue>)>() else { break };
|
||||
let text = |key: &str| options.get(key).and_then(|v| String::try_from(v.try_clone().ok()?).ok());
|
||||
let filter = options.get("current_filter").and_then(|v| {
|
||||
let (label, _): (String, Vec<(u32, String)>) = v.try_clone().ok()?.try_into().ok()?;
|
||||
Some(label)
|
||||
});
|
||||
let calls = log.lock().map(|mut l| {
|
||||
l.push((text("current_name"), filter));
|
||||
l.len()
|
||||
});
|
||||
let Some(sender) = header.sender().map(|s| s.as_str().trim_start_matches(':').replace('.', "_")) else { break };
|
||||
let token = if own_paths { format!("old{}", calls.unwrap_or_default()) } else { text("handle_token").unwrap_or_default() };
|
||||
let path = format!("/org/freedesktop/portal/desktop/request/{sender}/{token}");
|
||||
let Ok(handle) = OwnedObjectPath::try_from(path.as_str()) else { break };
|
||||
if server.reply(&header, &handle).is_err() {
|
||||
break;
|
||||
}
|
||||
if own_paths {
|
||||
// As a person answers the dialog: after the caller listened on the path returned.
|
||||
std::thread::sleep(std::time::Duration::from_millis(300));
|
||||
}
|
||||
let (code, pick) = script.next().unwrap_or((2, None));
|
||||
let mut results: HashMap<&str, Value<'_>> = HashMap::new();
|
||||
if let Some((uri, label)) = pick {
|
||||
results.insert("uris", Value::from(vec![uri.to_string()]));
|
||||
results.insert("current_filter", Value::from((label.to_string(), vec![(0u32, "*.x".to_string())])));
|
||||
}
|
||||
let to = header.sender().cloned().map(zbus::names::BusName::from);
|
||||
if server.emit_signal(to, path.as_str(), "org.freedesktop.portal.Request", "Response", &(code, results)).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
let client = zbus::blocking::connection::Builder::address(address.as_str()).ok()?.build().ok()?;
|
||||
Some((client, seen, daemon))
|
||||
}
|
||||
|
||||
/// Through a real D-Bus: the call starts the dialog on the first file type with the suggested
|
||||
/// name, and the answer names the file and the type picked; Cancel answers no file.
|
||||
#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))]
|
||||
#[test]
|
||||
fn the_portal_is_asked_on_the_first_file_type_and_answers_the_one_picked() {
|
||||
let script = vec![(0, Some(("file:///home/me/Art/poster", "PDF-compatible .ai"))), (1, None)];
|
||||
let Some((conn, seen, mut daemon)) = mock_portal(script, false) else {
|
||||
eprintln!("no dbus-daemon: skipped");
|
||||
return;
|
||||
};
|
||||
let request = request();
|
||||
let saved = save_file(&conn, &request).unwrap();
|
||||
assert_eq!(pick_answer(saved, &request), ["/home/me/Art/poster", "ai"]);
|
||||
assert_eq!(save_file(&conn, &request), Ok(None));
|
||||
let seen = seen.lock().map(|s| s.clone()).unwrap_or_default();
|
||||
assert_eq!(seen.first(), Some(&(Some("Untitled-1.vectorcraft".to_string()), Some(request.filters[0].0.clone()))));
|
||||
let _ = daemon.kill();
|
||||
}
|
||||
|
||||
/// A portal older than 0.9 answers on the request path it returns, and the answer is awaited
|
||||
/// there.
|
||||
#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))]
|
||||
#[test]
|
||||
fn the_answer_is_awaited_on_the_path_the_portal_returns() {
|
||||
let Some((conn, _seen, mut daemon)) = mock_portal(vec![(0, Some(("file:///tmp/card", "SVG")))], true) else {
|
||||
eprintln!("no dbus-daemon: skipped");
|
||||
return;
|
||||
};
|
||||
let request = request();
|
||||
assert_eq!(pick_answer(save_file(&conn, &request).unwrap(), &request), ["/tmp/card", "svg"]);
|
||||
let _ = daemon.kill();
|
||||
}
|
||||
|
||||
/// The labels go to the dialog in the interface's language, which is how the answer is matched.
|
||||
#[test]
|
||||
fn the_file_types_are_labeled_as_the_other_dialogs_label_them() {
|
||||
let request = request();
|
||||
let labels: Vec<&str> = request.filters.iter().map(|(l, _)| l.as_str()).collect();
|
||||
let lang = vectorcraft_ui_egui::i18n::current();
|
||||
let expected: Vec<&str> =
|
||||
["VectorCraft", "SVG", "PDF-compatible .ai"].iter().map(|l| vectorcraft_ui_egui::i18n::tr_ctx(lang, "file-filter", l)).collect();
|
||||
assert_eq!(labels, expected);
|
||||
assert_eq!(request.parent, "x11:3a0000f");
|
||||
}
|
||||
}
|
||||
@@ -307,21 +307,22 @@ pub(crate) fn write_named(app: &mut VectorcraftApp, path: Option<String>, name:
|
||||
/// typed without one).
|
||||
fn pick_path(app: &mut VectorcraftApp, pick: &FilePick) -> Result<String, String> {
|
||||
let ext = fileio::extension(&pick.name);
|
||||
pick_finished(app, pick, |picked| with_extension(picked, &ext, |_| true))
|
||||
pick_finished(app, pick, |picked, _| with_extension(picked, &ext, |_| true))
|
||||
}
|
||||
|
||||
/// The suggested name made into a path by `finish` on the web (a download), else the save panel's
|
||||
/// choice made into one. When `finish` changed the choice (an extension added) and a file is
|
||||
/// there, which the panel did not ask about, a question asks before replacing it
|
||||
/// choice made into one, with the extension of the file type picked in the panel when it reports
|
||||
/// one ([`crate::picks::PickAnswer`]). When `finish` changed the choice (an extension added) and a
|
||||
/// file is there, which the panel did not ask about, a question asks before replacing it
|
||||
/// ([`crate::picks::ask_to_replace`]) and this answers "cancelled"; the question's OK runs what
|
||||
/// asked again.
|
||||
fn pick_finished(app: &mut VectorcraftApp, pick: &FilePick, finish: impl Fn(&str) -> String) -> Result<String, String> {
|
||||
fn pick_finished(app: &mut VectorcraftApp, pick: &FilePick, finish: impl Fn(&str, Option<&str>) -> String) -> Result<String, String> {
|
||||
if is_web(app) {
|
||||
return Ok(finish(&pick.name));
|
||||
return Ok(finish(&pick.name, None));
|
||||
}
|
||||
let picked = crate::picks::save(app, pick).ok_or("cancelled")?;
|
||||
let path = finish(&picked);
|
||||
if path != picked && crate::picks::ask_to_replace(app, crate::picks::PickRequest::Save(pick.clone()), &picked, &path) {
|
||||
let (picked, file_type) = crate::picks::save_with_type(app, pick).ok_or("cancelled")?;
|
||||
let path = finish(&picked, file_type.as_deref());
|
||||
if path != picked && crate::picks::ask_to_replace(app, crate::picks::PickRequest::Save(pick.clone()), &picked, file_type.as_deref(), &path) {
|
||||
return Err("cancelled".into());
|
||||
}
|
||||
Ok(path)
|
||||
@@ -399,6 +400,23 @@ fn with_save_extension(path: &str, f: &Format, mode: SaveMode) -> String {
|
||||
with_extension(path, f.extensions[0], |g| formats.contains(&g.id))
|
||||
}
|
||||
|
||||
/// [`with_save_extension`] for a save panel that reports the file type picked in it (`picked`,
|
||||
/// that type's extension; the XDG portal's panel on Linux and the BSDs), which then takes `f`'s
|
||||
/// place. A path that still ends in the extension of the name the panel suggested (`suggested`)
|
||||
/// gets the picked type's extension instead. Without a picked type, or with one the panel did not
|
||||
/// offer, [`with_save_extension`] with `f`.
|
||||
fn with_picked_type(path: &str, suggested: &str, picked: Option<&str>, f: &Format, mode: SaveMode) -> String {
|
||||
let formats = if mode == SaveMode::Template { TEMPLATE_FORMATS } else { SAVE_FORMATS };
|
||||
let Some(g) = picked.and_then(fileio::format).filter(|g| formats.contains(&g.id)) else {
|
||||
return with_save_extension(path, f, mode);
|
||||
};
|
||||
let has = std::path::Path::new(path).extension();
|
||||
if has.is_some() && has == std::path::Path::new(suggested).extension() {
|
||||
return std::path::Path::new(path).with_extension(g.extensions[0]).to_string_lossy().into_owned();
|
||||
}
|
||||
with_save_extension(path, g, mode)
|
||||
}
|
||||
|
||||
/// Write every file of an export (one per artboard, linked images) for the destination `path`.
|
||||
/// → the export's own files (without the linked images).
|
||||
fn write_encoded(write: Writer, doc: &vectorcraft_doc::Document, path: &str, enc: &fileio::Encoded) -> Result<Vec<String>, String> {
|
||||
@@ -445,7 +463,7 @@ pub fn save(app: &mut VectorcraftApp, mode: SaveMode, p: &Value, ask_options: bo
|
||||
_ => fileio::save_filters(first.format.id),
|
||||
};
|
||||
let pick = FilePick { name: first.name.clone(), folder: first.folder.clone(), filters };
|
||||
let picked = pick_finished(app, &pick, |picked| with_save_extension(picked, first.format, mode))?;
|
||||
let picked = pick_finished(app, &pick, |picked, file_type| with_picked_type(picked, &first.name, file_type, first.format, mode))?;
|
||||
// The picked file type wins over a `format` param.
|
||||
let mut q = if p.is_object() { p.clone() } else { json!({}) };
|
||||
if let Some(o) = q.as_object_mut() {
|
||||
|
||||
@@ -37,7 +37,9 @@ pub enum PickRequest {
|
||||
Folder,
|
||||
}
|
||||
|
||||
/// The paths a dialog shown off the UI thread picked (none: cancelled).
|
||||
/// The paths a dialog shown off the UI thread picked (none: cancelled). A save dialog that reports
|
||||
/// which of its file types was picked (the XDG portal's on Linux and the BSDs) answers that type's
|
||||
/// first extension after the path.
|
||||
pub type PickAnswer = Vec<String>;
|
||||
|
||||
/// Shows a dialog off the UI thread; its answer arrives on the receiver. `None` when it can't, and
|
||||
@@ -232,6 +234,13 @@ pub(crate) fn save(app: &mut VectorcraftApp, pick: &FilePick) -> Option<String>
|
||||
ask(app, PickRequest::Save(pick.clone()))?.into_iter().next()
|
||||
}
|
||||
|
||||
/// A path from a save dialog for `pick`, and the extension of the file type picked in it when the
|
||||
/// dialog reports one ([`PickAnswer`]).
|
||||
pub(crate) fn save_with_type(app: &mut VectorcraftApp, pick: &FilePick) -> Option<(String, Option<String>)> {
|
||||
let mut answer = ask(app, PickRequest::Save(pick.clone()))?.into_iter();
|
||||
Some((answer.next()?, answer.next()))
|
||||
}
|
||||
|
||||
/// A folder from the folder picker.
|
||||
pub(crate) fn folder(app: &mut VectorcraftApp) -> Option<String> {
|
||||
ask(app, PickRequest::Folder)?.into_iter().next()
|
||||
@@ -355,19 +364,21 @@ fn on_document(app: &mut VectorcraftApp, doc: Option<u64>) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// `picked`, a save dialog's answer to `request`, names `path`, another file, once the app gave it
|
||||
/// `picked`, a save dialog's answer to `request` (with `file_type`, the extension of the file type
|
||||
/// picked in it when the dialog reports one), names `path`, another file, once the app gave it
|
||||
/// an extension: the dialog checked only `picked` for an existing file, not `path`. When a file is
|
||||
/// at `path`, ask before replacing it, over the dialog open now → true while the question is open
|
||||
/// (nothing is written meanwhile). OK runs what asked again with the same answer, and `path` is
|
||||
/// written then ([`replace`]); Cancel writes nothing. Nothing is asked while that OK runs what
|
||||
/// asked, or outside an entry, which nothing could run again.
|
||||
pub(crate) fn ask_to_replace(app: &mut VectorcraftApp, request: PickRequest, picked: &str, path: &str) -> bool {
|
||||
pub(crate) fn ask_to_replace(app: &mut VectorcraftApp, request: PickRequest, picked: &str, file_type: Option<&str>, path: &str) -> bool {
|
||||
if app.picks.replace_ok.as_deref() == Some(path) || !app.services.exists.as_ref().is_some_and(|exists| exists(path)) {
|
||||
return false;
|
||||
}
|
||||
let Some(entry) = app.picks.entry.clone() else { return false };
|
||||
let doc = entry.document(app);
|
||||
app.picks.replacing = Some(Replacing { request, answer: vec![picked.to_string()], path: path.to_string(), entry, doc });
|
||||
let answer = std::iter::once(picked).chain(file_type).map(str::to_string).collect();
|
||||
app.picks.replacing = Some(Replacing { request, answer, path: path.to_string(), entry, doc });
|
||||
crate::dialogs::confirm::ask_replace(app, path, picked);
|
||||
true
|
||||
}
|
||||
|
||||
@@ -115,6 +115,91 @@ fn saves_add_the_format_extension_to_a_bare_name() {
|
||||
assert_eq!(last(&w), "/tmp/as.pdf");
|
||||
}
|
||||
|
||||
/// An app with a document whose save dialogs show off the UI thread, as on Linux.
|
||||
fn portal_app() -> (VectorcraftApp, crate::tests_picks::Shown, crate::tests_picks::Written) {
|
||||
let mut services = Services::default();
|
||||
let (shown, written) = crate::tests_picks::off_the_ui_thread(&mut services);
|
||||
let mut app = VectorcraftApp::new(Session::new(), services);
|
||||
app.run("file.new", json!({"width": 100, "height": 100})).unwrap();
|
||||
app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 30, "height": 20})).unwrap();
|
||||
(app, shown, written)
|
||||
}
|
||||
|
||||
/// Run `cmd`, whose save dialog answers `answer` (the path, then the extension of the file type
|
||||
/// picked, as the Linux portal's dialog answers) → the path the save goes on with: the one written,
|
||||
/// or the one the dialog that opens next holds (the options of SVG, PDF, and of native and .ai
|
||||
/// files on Save As; the question whether to replace a file).
|
||||
fn saved_from(app: &mut VectorcraftApp, shown: &crate::tests_picks::Shown, written: &Written, cmd: &str, answer: &[&str]) -> String {
|
||||
written.borrow_mut().clear();
|
||||
assert!(app.run(cmd, json!({})).is_err(), "{cmd} waits for its dialog");
|
||||
shown.answer(answer);
|
||||
crate::picks::poll(app, &egui::Context::default());
|
||||
match app.ui.dialog.as_ref() {
|
||||
Some(d) => d.str("path"),
|
||||
None => last(written),
|
||||
}
|
||||
}
|
||||
|
||||
/// The file type picked in a save dialog that reports it decides the format (#2679): a bare name, or
|
||||
/// one with the suggested name's extension, gets the picked type's extension; an extension typed for
|
||||
/// another save format stays as typed, and any other gets the picked type's added.
|
||||
#[test]
|
||||
fn the_file_type_picked_in_the_save_dialog_decides_the_format() {
|
||||
for (answer, expected) in [
|
||||
(&["/art/poster", "ai"][..], "/art/poster.ai"),
|
||||
(&["/art/Untitled-1.vectorcraft", "ai"], "/art/Untitled-1.ai"),
|
||||
(&["/art/poster.SVG", "ai"], "/art/poster.SVG"),
|
||||
(&["/art/poster.foo", "ai"], "/art/poster.foo.ai"),
|
||||
(&["/art/poster.ai", "vectorcraft"], "/art/poster.ai"),
|
||||
(&["/art/poster", "svg"], "/art/poster.svg"),
|
||||
// A dialog that doesn't say, as on Windows and macOS: the default format's extension.
|
||||
(&["/art/poster"], "/art/poster.vectorcraft"),
|
||||
] {
|
||||
let (mut app, shown, written) = portal_app();
|
||||
assert_eq!(saved_from(&mut app, &shown, &written, "file.save", answer), expected, "{answer:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Save As to the type picked asks for that format's options: the .ai options (Version first),
|
||||
/// not VectorCraft's.
|
||||
#[test]
|
||||
fn save_as_asks_for_the_options_of_the_file_type_picked() {
|
||||
let (mut app, shown, written) = portal_app();
|
||||
assert_eq!(saved_from(&mut app, &shown, &written, "file.saveAs", &["/art/poster", "ai"]), "/art/poster.ai");
|
||||
let d = app.ui.dialog.as_ref().unwrap();
|
||||
assert_eq!((d.kind.as_str(), d.str("format")), (dialogs::save_options::KIND, "ai".to_string()));
|
||||
}
|
||||
|
||||
/// A document saved as .ai lists .ai first: VectorCraft picked with a bare name or with the
|
||||
/// suggested name saves a native file. Save a Copy and Save as Template follow the type picked too.
|
||||
#[test]
|
||||
fn a_document_in_another_format_saves_as_the_type_picked() {
|
||||
let (mut app, shown, written) = portal_app();
|
||||
app.run("file.saveAs", json!({"path": "/art/a.ai", "options": {}})).unwrap();
|
||||
assert_eq!(*written.borrow(), ["/art/a.ai"]);
|
||||
assert_eq!(saved_from(&mut app, &shown, &written, "file.saveAs", &["/art/poster", "vectorcraft"]), "/art/poster.vectorcraft");
|
||||
dialogs::cancel(&mut app);
|
||||
assert_eq!(saved_from(&mut app, &shown, &written, "file.saveAs", &["/art/a.ai", "vectorcraft"]), "/art/a.vectorcraft");
|
||||
dialogs::cancel(&mut app);
|
||||
assert_eq!(saved_from(&mut app, &shown, &written, "file.saveCopy", &["/art/copy", "svg"]), "/art/copy.svg");
|
||||
dialogs::cancel(&mut app);
|
||||
assert_eq!(saved_from(&mut app, &shown, &written, "file.saveAsTemplate", &["/art/card", "ait"]), "/art/card.ait");
|
||||
}
|
||||
|
||||
/// The name the type picked gives names a file that is there, which the dialog did not ask about:
|
||||
/// the save asks first, and the question's OK writes that file in the picked format.
|
||||
#[test]
|
||||
fn a_name_given_the_picked_types_extension_asks_before_replacing() {
|
||||
let (mut app, shown, written) = portal_app();
|
||||
app.services.exists = Some(Box::new(|p: &str| p == "/art/poster.ai"));
|
||||
assert_eq!(saved_from(&mut app, &shown, &written, "file.save", &["/art/poster", "ai"]), "/art/poster.ai");
|
||||
assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some(dialogs::confirm::REPLACE));
|
||||
assert!(written.borrow().is_empty());
|
||||
dialogs::confirm(&mut app).unwrap();
|
||||
assert_eq!(*written.borrow(), ["/art/poster.ai"]);
|
||||
assert_eq!(app.session.active().unwrap().format, "ai");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exports_add_the_format_extension_to_a_bare_name() {
|
||||
let (mut app, w) = saving_app("/tmp/art");
|
||||
|
||||
@@ -690,6 +690,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
- `.vectorcraft` (lossless JSON, compressed, atomic saves, save down to v1), Save As with a format chooser, Save a Copy, Revert, templates, Data Recovery after a crash (browser tabs hold their copies with Web Locks too, so a background tab's copies are never taken for a crash's, and a tab repairs copies another removed when it resumes: #367), background save and export. The desktop app writes its warnings and errors (graphics adapter, a lost graphics device, panics the guard recovered from) to standard error and to `logs/vectorcraft.log` next to the preferences, the two previous runs kept and each file capped at 16 MiB, so a launch without a terminal still leaves something to attach to a bug report (#465). Native documents with a lot of path data (traced or generated art, in layers, symbols or patterns) are saved as compact JSON, about a fifth of the size; small documents stay pretty-printed (#1492). Saving to a network drive on macOS falls back to a plain fsync when the share refuses a full flush (#1625, part of #1620; not confirmed on a real share), and a save still succeeds when a folder refuses the temporary file, the rename over the document or copying permissions: the content is written elsewhere first and copied in, and a failed copy keeps the temporary file and names it (#1649, part of #1620). A document opened by a relative path finds its relatively linked images, and `vectorcraft-cli convert` lists what reading the input left out as `importWarnings` (#1606, fixes #1604).
|
||||
A name typed in the desktop save panel without the file type's extension gets it, and when a file of that name exists, which the panel did not ask about, Save, Save As, Save a Copy, Save as Template, the exports and every other write through a save panel ask before replacing it (dialog `replaceFile`; Cancel writes nothing).
|
||||
- macOS: the save sheet of Save As, Save a Copy, Save as Template and the first Save of a new document has a Format menu that lists each format the save can write, with the suggested file's format selected. Choosing a format changes the extension in the name field, and a name typed without an extension gets the chosen format's. A name typed with another listed extension is saved in that format. The menu is set up before the sheet opens; a save panel with one file type is shown by rfd as before (#866, `apps/vectorcraft/src/mac_save_format.rs`).
|
||||
- Linux and the BSDs: the save dialog of Save As, Save a Copy, Save as Template and the first Save of a new document is asked of the desktop portal, starting on the suggested file's type, and the file type picked in it decides the format. A name typed without an extension, or with the suggested file's, gets the picked type's extension; a name typed with another save format's extension (for Save as Template, a template format's) is saved in that format, and any other extension gets the picked type's added. rfd shows the dialog when the portal can't (#2679, `apps/vectorcraft/src/portal_save.rs`).
|
||||
- Save as Template writes a PDF-compatible `.ait` as well as a `.vctemplate`, which stays the default: the save panel and the web build's Save as Template dialog list both, and New from Template opens the `.ait` as an untitled document with its layers and art (`file.saveAsTemplate {format: "ait"}`, or a path ending in `.ait`).
|
||||
Save as Template always writes a file named with its template type's extension: `file.saveAsTemplate` saves a path ending in another format's extension (`card.png`) as `card.vctemplate` (or `card.ait`), the desktop save panel adds `.vctemplate` to a name typed with a Save As format's extension (`card.pdf.vctemplate`), and `file.saveAs` and `document.save` refuse `ait` with an error that names `file.saveAsTemplate`. A `.ait` is PDF-compatible only: `aiVersion: "7"` is an error for it, and the web build's dialog shows no Version row for it.
|
||||
Save as Template always writes a file named with its template type's extension: `file.saveAsTemplate` saves a path ending in another format's extension (`card.png`) as `card.vctemplate` (or `card.ait`), the desktop save panel adds `.vctemplate` to a name typed with a Save As format's extension (`card.pdf.vctemplate`), and `file.saveAs` and `document.save` refuse `ait` with an error that names `file.saveAsTemplate`.
|
||||
|
||||
Reference in New Issue
Block a user