mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-13 14:02:45 +00:00
Merge branch 'main' into fix/6205-image-copy-paste
This commit is contained in:
8 files changed
+1111
-11
No files matched your search
+2
-1
@@ -8,7 +8,7 @@
|
||||
|
||||
**Stage: alpha** · next: beta, ~15 points (60% → 75% ready for real work) and ~150–230 h away
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** trivial (Vertical scrollbar track: clicking above the elevator moves one screen up and clicking below it moves one screen down, as in Word (#5853)); previously trivial (progress log: #4501 refactor regressions restored: per-section header grid, ODT default font, link tooltip, #4683); previously trivial (progress log: the window moves by its title bar behind a modal dialog, #5124); previously trivial (progress log: `convert --strict` and `render --strict` refuse a silently incomplete PDF or PNG (#6005, #6008)); ; trivial (progress log: ODT columns stating only their shares); previously trivial (progress log: ODT font size for every script); previously trivial (progress log: ODT text turning off what its style turns on); previously trivial (progress log: edited ODT paragraphs keep custom styles with an outline level); previously trivial (progress log: ODT pictures keep the size their file gives); previously trivial (progress log: ODT frames keep the page they are anchored to); previously trivial (pointer zoom stretches page rasters until it stops); previously trivial (dark-theme interface text on Linux no longer looks semi-bold); previously trivial (Control/MCP #2596 review: partial-write outcomes, path-bearing image exports, accept-thread startup cleanup and malformed UTF-8 handling); previously trivial (progress log: a turned inline picture whose file keeps no room for the turn keeps its line to its frame); previously trivial (progress log: the Arabic interface's Styles gallery reads from the right (#5566)); previously trivial (progress log: ODT pictures offered in several formats); previously trivial (progress log: ODT heading text of another size); previously trivial (Protected typing boundary lookup counts preceding objects once and indexes editable permission IDs (#3614); caret movement and enforcement policy are unchanged; previously trivial: progress log: Custom document properties dialog, #2982; previously trivial: progress log: View › New Window; previously trivial: progress log: interface translations come from storytold/craft-i18n through the `craft-i18n` crate; previously trivial: progress log: disabled-control reasons translated in every interface language, #2678; previously trivial: progress log: mail merge record navigation and record ranges for printing, #3028; previously trivial: selection bar for right-to-left paragraphs and table rows; previously trivial: group connector rerouting preserves rotated and flipped free or unresolved endpoints under nonuniform group scaling; readiness estimates unchanged; previously trivial: progress log: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: progress log: a relationship the markup names without the prefix is read as it wrote, and an ordinary `id` names no part; previously trivial: progress log: a document with many relationships opens in the time its part takes, and an id the text only mentions is not a reference, #3411; previously trivial: progress log: read only and comments-only protection grey out edits; previously trivial: progress log: an empty paragraph with a hidden mark shows nothing, not even its list number, #3982; previously trivial: progress log: "NaN" in the Font dialog's Size box no longer closes the app, #3663; previously trivial: progress log: a table cell span from a `.json` file no longer overflows, #3637; previously trivial: progress log: a click in the footer while editing the header moves there; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Slovenian interface catalog, 2,614 entries; previously trivial: Insert Table of Figures dialog for any caption label, #3013; previously trivial: progress log: Home › Underline drop-down, #2738; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: progress log: the scrollbar page tip stays on one line, #3341; previously trivial: progress log: forms protection greys out the control buttons; previously trivial: progress log: a document with many content controls saves in the time the controls take, #13; previously trivial: progress log: Word signature lines kept on save; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: progress log: closing the header returns to the body selection, #3331; previously trivial: progress log: saving to a NAS share mounted by GVFS, #3003; previously trivial: progress log: a picture dragged near the bottom of a page lands there, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: progress log: inline pictures drag to an insertion cursor and stay inline, #2233, #2234; previously trivial: progress log: a text file saved over itself closes without asking, #2376; previLine truncated
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** trivial (progress log: Save As on Linux: the desktop portal's Save dialog reports the file type picked, updating the extension so saving as PDF writes a .pdf, #6180); previously trivial (Vertical scrollbar track: clicking above the elevator moves one screen up and clicking below it moves one screen down, as in Word (#5853)); previously trivial (progress log: #4501 refactor regressions restored: per-section header grid, ODT default font, link tooltip, #4683); previously trivial (progress log: the window moves by its title bar behind a modal dialog, #5124); previously trivial (progress log: `convert --strict` and `render --strict` refuse a silently incomplete PDF or PNG (#6005, #6008)); ; trivial (progress log: ODT columns stating only their shares); previously trivial (progress log: ODT font size for every script); previously trivial (progress log: ODT text turning off what its style turns on); previously trivial (progress log: edited ODT paragraphs keep custom styles with an outline level); previously trivial (progress log: ODT pictures keep the size their file gives); previously trivial (progress log: ODT frames keep the page they are anchored to); previously trivial (pointer zoom stretches page rasters until it stops); previously trivial (dark-theme interface text on Linux no longer looks semi-bold); previously trivial (Control/MCP #2596 review: partial-write outcomes, path-bearing image exports, accept-thread startup cleanup and malformed UTF-8 handling); previously trivial (progress log: a turned inline picture whose file keeps no room for the turn keeps its line to its frame); previously trivial (progress log: the Arabic interface's Styles gallery reads from the right (#5566)); previously trivial (progress log: ODT pictures offered in several formats); previously trivial (progress log: ODT heading text of another size); previously trivial (Protected typing boundary lookup counts preceding objects once and indexes editable permission IDs (#3614); caret movement and enforcement policy are unchanged; previously trivial: progress log: Custom document properties dialog, #2982; previously trivial: progress log: View › New Window; previously trivial: progress log: interface translations come from storytold/craft-i18n through the `craft-i18n` crate; previously trivial: progress log: disabled-control reasons translated in every interface language, #2678; previously trivial: progress log: mail merge record navigation and record ranges for printing, #3028; previously trivial: selection bar for right-to-left paragraphs and table rows; previously trivial: group connector rerouting preserves rotated and flipped free or unresolved endpoints under nonuniform group scaling; readiness estimates unchanged; previously trivial: progress log: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: progress log: a relationship the markup names without the prefix is read as it wrote, and an ordinary `id` names no part; previously trivial: progress log: a document with many relationships opens in the time its part takes, and an id the text only mentions is not a reference, #3411; previously trivial: progress log: read only and comments-only protection grey out edits; previously trivial: progress log: an empty paragraph with a hidden mark shows nothing, not even its list number, #3982; previously trivial: progress log: "NaN" in the Font dialog's Size box no longer closes the app, #3663; previously trivial: progress log: a table cell span from a `.json` file no longer overflows, #3637; previously trivial: progress log: a click in the footer while editing the header moves there; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Slovenian interface catalog, 2,614 entries; previously trivial: Insert Table of Figures dialog for any caption label, #3013; previously trivial: progress log: Home › Underline drop-down, #2738; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: progress log: the scrollbar page tip stays on one line, #3341; previously trivial: progress log: forms protection greys out the control buttons; previously trivial: progress log: a document with many content controls saves in the time the controls take, #13; previously trivial: progress log: Word signature lines kept on save; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: progress log: closing the header returns to the body selection, #3331; previously trivial: progress log: saving to a NAS share mounted by GVFS, #3003; previously trivial: progress log: a picture dragged near the bottom of a page lands there, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: progress lLine truncated
|
||||
|
||||
>
|
||||
> **Recipient CSV update:** 2026-10-27 · Complete quoted header records choose the delimiter (#4249); detection and regressions follow the current mailings module split. Recipient limits and readiness estimates are unchanged.
|
||||
@@ -911,6 +911,7 @@ MATH glyphs that reference the same construction now share its immutable variant
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-10 | trivial | Save As on Linux: the desktop portal's Save dialog reports the file type picked, updating the extension so saving as PDF writes a .pdf (#6180) |
|
||||
| 2026-11-04 | trivial | Progress log: #4501 refactor regressions restored (#4683) |
|
||||
| 2026-11-06 | trivial | Progress log: edited ODT paragraphs keep custom styles with an outline level (gap 19) |
|
||||
| 2026-10-31 | trivial | Control/MCP #2596 review fixes; readiness estimates unchanged |
|
||||
|
||||
@@ -23,6 +23,8 @@
|
||||
use std::future::Future;
|
||||
use std::pin::Pin;
|
||||
use std::sync::mpsc::{Receiver, Sender, channel};
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use wordcraft_ui_egui::{FileDialogAnswer, FileDialogRequest};
|
||||
|
||||
@@ -32,6 +34,59 @@ type Request = (FileDialogRequest, Sender<FileDialogAnswer>);
|
||||
/// The `Services::file_dialog` hook.
|
||||
pub type Hook = Box<dyn Fn(FileDialogRequest) -> Receiver<FileDialogAnswer>>;
|
||||
|
||||
/// Cancels the portal Save dialog the launcher is waiting on, if any.
|
||||
///
|
||||
/// The portal's dialog lives outside the app's window, so a portal that stops answering cannot be
|
||||
/// dismissed from WordCraft. Giving the dialog up (File › Open › Browse in WordCraft…) cancels the
|
||||
/// request: the waiting thread closes it and reports a cancellation, and the built-in browser
|
||||
/// opens instead. A no-op where Save As doesn't ask the portal (macOS and Windows).
|
||||
#[derive(Clone, Default)]
|
||||
pub struct CancelHandle {
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
armed: Arc<Mutex<Option<Armed>>>,
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
next_id: Arc<std::sync::atomic::AtomicU64>,
|
||||
}
|
||||
|
||||
/// The token a request installed in [`CancelHandle`], with the identity that owns it.
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
struct Armed {
|
||||
id: u64,
|
||||
token: crate::portal_save::CancelToken,
|
||||
}
|
||||
|
||||
impl CancelHandle {
|
||||
/// Cancels the pending portal request, if there is one.
|
||||
pub fn cancel(&self) {
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
{
|
||||
let mut slot = self.armed.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if let Some(armed) = slot.take() {
|
||||
armed.token.cancel();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Arms the handle for the portal request about to wait, returning its token and identity.
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
fn arm(&self) -> Armed {
|
||||
let id = self.next_id.fetch_add(1, std::sync::atomic::Ordering::Relaxed).wrapping_add(1);
|
||||
let token = crate::portal_save::CancelToken::new();
|
||||
*self.armed.lock().unwrap_or_else(|e| e.into_inner()) = Some(Armed { id, token: token.clone() });
|
||||
Armed { id, token }
|
||||
}
|
||||
|
||||
/// Disarms the handle once the request with `id` has answered, leaving a newer request's
|
||||
/// token alone: an abandoned request finishing late must not make the next one uncancellable.
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
fn disarm(&self, id: u64) {
|
||||
let mut slot = self.armed.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if slot.as_ref().is_some_and(|armed| armed.id == id) {
|
||||
*slot = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A picker's answer, waited for off the UI thread.
|
||||
type Picking = Pin<Box<dyn Future<Output = Vec<rfd::FileHandle>> + Send>>;
|
||||
|
||||
@@ -39,6 +94,7 @@ type Picking = Pin<Box<dyn Future<Output = Vec<rfd::FileHandle>> + Send>>;
|
||||
pub struct Launcher {
|
||||
queue: Receiver<Request>,
|
||||
ctx: egui::Context,
|
||||
cancel: CancelHandle,
|
||||
}
|
||||
|
||||
/// The hook for [`Services::file_dialog`](wordcraft_ui_egui::Services::file_dialog) and the
|
||||
@@ -51,10 +107,15 @@ pub fn hook(ctx: &egui::Context) -> (Hook, Launcher) {
|
||||
let _ = queue_tx.send((req, answer));
|
||||
rx
|
||||
});
|
||||
(hook, Launcher { queue, ctx: ctx.clone() })
|
||||
(hook, Launcher { queue, ctx: ctx.clone(), cancel: CancelHandle::default() })
|
||||
}
|
||||
|
||||
impl Launcher {
|
||||
/// A handle that cancels the portal Save dialog the launcher is waiting on, if any.
|
||||
pub fn cancel_handle(&self) -> CancelHandle {
|
||||
self.cancel.clone()
|
||||
}
|
||||
|
||||
/// Show the queued dialogs with the window as their parent, so the portal (or the system)
|
||||
/// keeps them on top of it. Call after every pass that may ask for one.
|
||||
pub fn show(&self, parent: &eframe::Frame) {
|
||||
@@ -63,26 +124,90 @@ impl Launcher {
|
||||
let picking = picker(rfd::AsyncFileDialog::new().set_parent(parent), &req);
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
let unparented = picker(rfd::AsyncFileDialog::new(), &req);
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
let parent_id = {
|
||||
use eframe::wgpu::rwh::HasWindowHandle;
|
||||
crate::portal_save::parent_id(parent.window_handle().ok().map(|h| h.as_raw()))
|
||||
};
|
||||
// Armed before the thread starts, so giving the dialog up cannot race its start.
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
let portal_arm = matches!(&req, FileDialogRequest::Save { .. }).then(|| self.cancel.arm());
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
let portal_id = portal_arm.as_ref().map(|armed| armed.id);
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
let cancel_handle = self.cancel.clone();
|
||||
let ctx = self.ctx.clone();
|
||||
let waiting = std::thread::Builder::new().name("file dialog".into()).spawn(move || {
|
||||
// The pickers only start when first polled: without a system dialog they never do.
|
||||
let picked = match no_system_dialog() {
|
||||
Some(reason) => Err(reason),
|
||||
None => {
|
||||
// Only the portal folds a failure into "cancelled" (#2999).
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
let picked = pick(picking, unparented, TOO_QUICK);
|
||||
{
|
||||
if let FileDialogRequest::Save { name, dir } = &req {
|
||||
let cancel = portal_arm.as_ref().map(|armed| armed.token.clone());
|
||||
let portal_req = crate::portal_save::SaveRequest {
|
||||
parent: parent_id,
|
||||
name: Some(name.clone()),
|
||||
suggested: Some(name.clone()),
|
||||
folder: dir.clone().map(std::path::PathBuf::from),
|
||||
filters: save_filters(name),
|
||||
current: Some(0),
|
||||
cancel,
|
||||
timeout: None,
|
||||
};
|
||||
let portal_res = zbus::blocking::Connection::session()
|
||||
.map_err(|e| e.to_string())
|
||||
.and_then(|conn| crate::portal_save::save_as(&conn, portal_req));
|
||||
match portal_res {
|
||||
Ok(Some(path)) => Ok(vec![path.to_string_lossy().into_owned()]),
|
||||
Ok(None) => Ok(Vec::new()),
|
||||
Err(e) if crate::portal_save::is_cancelled_error(&e) => Ok(Vec::new()),
|
||||
Err(e) if crate::portal_save::is_ambiguous_filter_error(&e) => {
|
||||
log::warn!("portal save format ambiguous ({e}); cancelling save without explicit format choice");
|
||||
Ok(Vec::new())
|
||||
}
|
||||
Err(e) => {
|
||||
// Giving the dialog up cancels the request. A portal that then
|
||||
// fails with another error must not open the rfd dialog, which
|
||||
// nothing would be waiting for.
|
||||
if portal_arm.as_ref().is_some_and(|armed| armed.token.is_cancelled()) {
|
||||
log::info!("portal save dialog given up ({e})");
|
||||
Ok(Vec::new())
|
||||
} else {
|
||||
log::info!("portal save dialog unavailable ({e}), using rfd");
|
||||
let picked = pick(picking, unparented, TOO_QUICK);
|
||||
picked.map(|f| f.iter().map(|f| f.path().to_string_lossy().to_string()).collect())
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Only the portal folds a failure into "cancelled" (#2999).
|
||||
let picked = pick(picking, unparented, TOO_QUICK);
|
||||
picked.map(|f| f.iter().map(|f| f.path().to_string_lossy().to_string()).collect())
|
||||
}
|
||||
}
|
||||
#[cfg(not(all(unix, not(target_os = "macos"))))]
|
||||
let picked: Result<_, String> = Ok(pollster::block_on(picking));
|
||||
picked.map(|f| f.iter().map(|f| f.path().to_string_lossy().to_string()).collect())
|
||||
{
|
||||
let picked: Result<_, String> = Ok(pollster::block_on(picking));
|
||||
picked.map(|f| f.iter().map(|f| f.path().to_string_lossy().to_string()).collect())
|
||||
}
|
||||
}
|
||||
};
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
if let Some(armed) = &portal_arm {
|
||||
cancel_handle.disarm(armed.id);
|
||||
}
|
||||
let _ = answer.send(picked);
|
||||
ctx.request_repaint();
|
||||
});
|
||||
// The answer's sender went with the closure, so the app sees the dialog as cancelled.
|
||||
if let Err(e) = waiting {
|
||||
log::error!("couldn't wait for the file dialog: {e}");
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
if let Some(id) = portal_id {
|
||||
self.cancel.disarm(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -222,8 +347,9 @@ fn save_exts(name: &str) -> Vec<String> {
|
||||
/// The "Save as type" list (#1399): one named entry per format, the suggested one first (see
|
||||
/// [`save_exts`] for why the order matters).
|
||||
///
|
||||
/// rfd doesn't report which entry the user picked, so if they choose "PDF" but leave "name.docx"
|
||||
/// in the name box, the extension in the name wins, as the format is chosen by extension.
|
||||
/// On Linux and the BSDs, [`crate::portal_save`] asks the portal directly over D-Bus and gives a
|
||||
/// name the dialog left alone the chosen type's extension (#6180). When falling back to `rfd`, the
|
||||
/// extension in the name wins, as `rfd` doesn't report which entry the user picked.
|
||||
fn save_filters(name: &str) -> Vec<(String, Vec<String>)> {
|
||||
save_exts(name).into_iter().map(|e| (format!("{} (*.{e})", format_name(&e)), vec![e])).collect()
|
||||
}
|
||||
@@ -274,6 +400,37 @@ mod tests {
|
||||
assert_eq!(pick(async { vec![] }, slow_cancel(), quick), Ok(vec![]), "the second dialog cancelled for real");
|
||||
}
|
||||
|
||||
/// Giving the portal Save dialog up cancels the request the launcher armed, and a request
|
||||
/// that has already answered is not affected by a later cancel.
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
#[test]
|
||||
fn cancelling_a_pending_portal_request_marks_its_token() {
|
||||
let handle = super::CancelHandle::default();
|
||||
let armed = handle.arm();
|
||||
assert!(!armed.token.is_cancelled());
|
||||
handle.cancel();
|
||||
assert!(armed.token.is_cancelled());
|
||||
|
||||
let answered = handle.arm();
|
||||
handle.disarm(answered.id);
|
||||
handle.cancel();
|
||||
assert!(!answered.token.is_cancelled(), "an answered request is no longer cancelled");
|
||||
}
|
||||
|
||||
/// A request that finishes late must not disarm the token of a request started after it:
|
||||
/// otherwise the newer Save dialog would become uncancellable.
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
#[test]
|
||||
fn a_late_answer_keeps_the_newer_requests_token() {
|
||||
let handle = super::CancelHandle::default();
|
||||
let abandoned = handle.arm();
|
||||
let newer = handle.arm();
|
||||
handle.disarm(abandoned.id);
|
||||
handle.cancel();
|
||||
assert!(newer.token.is_cancelled(), "the newer request is still armed");
|
||||
assert!(!abandoned.token.is_cancelled(), "only the armed request is cancelled");
|
||||
}
|
||||
|
||||
/// #1399: the Save dialog's type list has one named entry per format, the suggested one first,
|
||||
/// every savable extension exactly once.
|
||||
#[test]
|
||||
|
||||
@@ -21,6 +21,8 @@ mod file_dialogs;
|
||||
#[cfg(any(target_os = "windows", target_os = "macos", test))]
|
||||
mod graphics;
|
||||
mod logging;
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
mod portal_save;
|
||||
mod print;
|
||||
mod region;
|
||||
#[cfg(target_os = "macos")]
|
||||
@@ -367,9 +369,10 @@ fn write_prefs(path: &std::path::Path, bytes: &[u8]) -> std::io::Result<()> {
|
||||
/// through the system spooler (`print`), the system clipboard's text, formatted copies,
|
||||
/// pictures and format names (`clipboard`, #45, #584, #687, #833), the system's animation setting
|
||||
/// (`animations`), and making WordCraft the default app for Word documents (`default_apps`, #295).
|
||||
fn services(file_dialog: file_dialogs::Hook, ctx: &egui::Context) -> Services {
|
||||
fn services(file_dialog: file_dialogs::Hook, cancel_file_dialog: file_dialogs::CancelHandle, ctx: &egui::Context) -> Services {
|
||||
Services {
|
||||
file_dialog: Some(file_dialog),
|
||||
cancel_file_dialog: Some(Box::new(move || cancel_file_dialog.cancel())),
|
||||
printers: Some(print::discover()),
|
||||
print_native: Some(print::submit()),
|
||||
cancel_print: Some(print::cancel()),
|
||||
@@ -613,9 +616,10 @@ fn main() -> eframe::Result {
|
||||
let region = region::system();
|
||||
let doc = if sample { wordcraft_engine::sample::sample_document() } else { wordcraft_engine::blank_document(®ion) };
|
||||
let (file_dialog, dialogs) = file_dialogs::hook(&cc.egui_ctx);
|
||||
let cancel_file_dialog = dialogs.cancel_handle();
|
||||
let mut session = Session::new(doc);
|
||||
session.system_region = region;
|
||||
let mut app = WordApp::new(session, services(file_dialog, &cc.egui_ctx));
|
||||
let mut app = WordApp::new(session, services(file_dialog, cancel_file_dialog, &cc.egui_ctx));
|
||||
app.set_startup_update_check(true);
|
||||
load_prefs(&mut app);
|
||||
// After the preferences, so the reason it gives is in the interface language.
|
||||
|
||||
@@ -0,0 +1,906 @@
|
||||
//! File › Save As through the XDG desktop portal on Linux and BSDs (#6180).
|
||||
//!
|
||||
//! The GTK file chooser behind the portal keeps the typed name when the user picks another file
|
||||
//! type, and `rfd` 0.17 reads only the file name from the portal's answer, so `Document1.docx`
|
||||
//! with PDF picked in the dropdown came back as `Document1.docx` and was saved as a Word document.
|
||||
//! The portal's answer does say which type was picked (`current_filter`), so Save As asks the portal
|
||||
//! itself over D-Bus via `zbus` and gives a name the dialog left alone that type's extension
|
||||
//! ([`with_chosen_type`]). `rfd` stays the fallback (zenity, or no portal), and this can go once an
|
||||
//! `rfd` release reads the picked type (upstream PolyMeilex/rfd#341).
|
||||
//!
|
||||
//! The dialog is attached to the window on X11 (`x11:<xid>`). On Wayland it has no parent: naming the
|
||||
//! window to the portal needs an xdg-foreign handle, which takes `unsafe` code the workspace forbids.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
use std::time::Duration;
|
||||
|
||||
use zbus::blocking::{MessageIterator, Proxy};
|
||||
use zbus::zvariant::{OwnedObjectPath, OwnedValue, Value};
|
||||
|
||||
/// One file type in the dialog: its label and extensions (`("PDF (*.pdf)", ["pdf"])`).
|
||||
pub type Filter = (String, Vec<String>);
|
||||
|
||||
/// Quicker than anyone can see a dialog and dismiss it (#2999).
|
||||
const TOO_QUICK: Duration = Duration::from_millis(300);
|
||||
|
||||
/// A thread-safe cancellation token to signal cancellation of an ongoing save request.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct CancelToken(std::sync::Arc<std::sync::atomic::AtomicBool>);
|
||||
|
||||
impl CancelToken {
|
||||
/// Creates a new cancellation token in the active (not cancelled) state.
|
||||
pub fn new() -> Self {
|
||||
Self(std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)))
|
||||
}
|
||||
|
||||
/// Signals that the active save request should be cancelled.
|
||||
pub fn cancel(&self) {
|
||||
self.0.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Returns `true` if cancellation has been requested.
|
||||
pub fn is_cancelled(&self) -> bool {
|
||||
self.0.load(std::sync::atomic::Ordering::Relaxed)
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq for CancelToken {
|
||||
/// Compares two cancellation tokens by their current cancellation status.
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.is_cancelled() == other.is_cancelled()
|
||||
}
|
||||
}
|
||||
|
||||
impl Eq for CancelToken {}
|
||||
|
||||
/// A Save dialog request to pass to the desktop portal.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct SaveRequest {
|
||||
/// The portal's parent window identifier (`x11:<xid>`), or empty.
|
||||
pub parent: String,
|
||||
/// The name the dialog shows: the suggested file name (`Document1.docx`).
|
||||
pub name: Option<String>,
|
||||
/// The suggested file's name (`Document1.docx`): a name that comes back with its extension still
|
||||
/// takes the extension of the type picked ([`with_chosen_type`]).
|
||||
pub suggested: Option<String>,
|
||||
/// The initial folder to open in, if any.
|
||||
pub folder: Option<PathBuf>,
|
||||
/// The list of filters offered in the dropdown.
|
||||
pub filters: Vec<Filter>,
|
||||
/// The index of the filter the dialog starts on.
|
||||
pub current: Option<usize>,
|
||||
/// Optional cancellation token allowing the caller to cancel a pending save request.
|
||||
pub cancel: Option<CancelToken>,
|
||||
/// Optional timeout after which a request waiting for a response is considered stalled.
|
||||
pub timeout: Option<Duration>,
|
||||
}
|
||||
|
||||
/// What the portal dialog returned: the path, as typed, and the filter chosen in the dropdown.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Saved {
|
||||
/// The filesystem path chosen in the dialog.
|
||||
pub path: PathBuf,
|
||||
/// The filter chosen in the dropdown, if reported by the portal: `(label, extensions)`.
|
||||
pub filter: Option<Filter>,
|
||||
}
|
||||
|
||||
/// The portal's parent window identifier for `window`: `x11:<xid>` on X11, empty elsewhere.
|
||||
///
|
||||
/// On Wayland, exporting an xdg-foreign handle requires `unsafe` code forbidden by workspace rules
|
||||
/// (`#![forbid(unsafe_code)]`, Rust Rule 1), so Wayland dialogs remain unparented, matching
|
||||
/// PhotoCraft and VectorCraft.
|
||||
pub fn parent_id(window: Option<eframe::wgpu::rwh::RawWindowHandle>) -> String {
|
||||
use eframe::wgpu::rwh::RawWindowHandle;
|
||||
match window {
|
||||
Some(RawWindowHandle::Xlib(h)) => format!("x11:{:x}", h.window),
|
||||
Some(RawWindowHandle::Xcb(h)) => format!("x11:{:x}", h.window.get()),
|
||||
_ => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Converts a filter tuple into the portal's wire format: label and a list of `(0, "*.ext")` glob pairs.
|
||||
fn wire(filter: &Filter) -> (String, Vec<(u32, String)>) {
|
||||
let globs = filter.1.iter().map(|e| (0u32, if e.is_empty() || e == "*" { "*".to_string() } else { format!("*.{e}") })).collect();
|
||||
(filter.0.clone(), globs)
|
||||
}
|
||||
|
||||
/// The `SaveFile` options dictionary for `req`, answering on `token`.
|
||||
fn options<'a>(req: &'a SaveRequest, token: &'a str) -> HashMap<&'static str, Value<'a>> {
|
||||
let mut o: HashMap<&'static str, Value<'a>> = HashMap::from([("handle_token", Value::from(token)), ("modal", Value::from(true))]);
|
||||
if let Some(name) = &req.name {
|
||||
o.insert("current_name", Value::from(name.as_str()));
|
||||
}
|
||||
if let Some(folder) = &req.folder {
|
||||
// A byte string with its terminating NUL, as the portal specifies.
|
||||
use std::os::unix::ffi::OsStrExt;
|
||||
let mut bytes = folder.as_os_str().as_bytes().to_vec();
|
||||
bytes.push(0);
|
||||
o.insert("current_folder", Value::from(bytes));
|
||||
}
|
||||
if !req.filters.is_empty() {
|
||||
let wired = req.filters.iter().map(wire).collect::<Vec<_>>();
|
||||
o.insert("filters", Value::from(wired));
|
||||
}
|
||||
if let Some(f) = req.current.and_then(|i| req.filters.get(i)).or_else(|| req.filters.first()) {
|
||||
o.insert("current_filter", Value::from(wire(f)));
|
||||
}
|
||||
o
|
||||
}
|
||||
|
||||
/// Reads the portal's `Response`: code 0 with the first `uris` entry. 1 is Cancel, and 2 a dialog
|
||||
/// closed some other way (e.g. Esc in GNOME): no file either. When code 2 arrives quicker than
|
||||
/// [`TOO_QUICK`], the portal failed to show its dialog (#2999) and an error is returned so the
|
||||
/// caller can fall back.
|
||||
fn answer(code: u32, results: &HashMap<String, OwnedValue>, elapsed: Duration) -> Result<Option<Saved>, String> {
|
||||
match code {
|
||||
0 => {}
|
||||
1 => return Ok(None),
|
||||
2 if elapsed < TOO_QUICK => return Err("the save dialog closed at once".to_string()),
|
||||
2 => return Ok(None),
|
||||
_ => return Err(format!("the save dialog failed (portal response {code})")),
|
||||
}
|
||||
let uris: Vec<String> = results
|
||||
.get("uris")
|
||||
.ok_or_else(|| "no file in the portal's answer".to_string())?
|
||||
.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_else(|| "no file in the portal's answer".to_string())?;
|
||||
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 val = v.try_clone().ok()?;
|
||||
if let Ok((label, globs)) = <(String, Vec<(u32, String)>)>::try_from(val.clone()) {
|
||||
let mut exts = Vec::new();
|
||||
for (_, g) in globs {
|
||||
let s = g.trim_start_matches('*').trim_start_matches('.');
|
||||
if !s.is_empty() && s != "*" {
|
||||
exts.push(s.to_ascii_lowercase());
|
||||
}
|
||||
}
|
||||
if exts.is_empty() {
|
||||
exts.extend(extract_exts_from_label(&label));
|
||||
}
|
||||
Some((label, exts))
|
||||
} else if let Ok((label, exts)) = <(String, Vec<String>)>::try_from(val.clone()) {
|
||||
Some((label, exts.into_iter().map(|s| s.to_ascii_lowercase()).collect()))
|
||||
} else if let Ok(label) = String::try_from(val) {
|
||||
let exts = extract_exts_from_label(&label);
|
||||
Some((label, exts))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
Ok(Some(Saved { path, filter }))
|
||||
}
|
||||
|
||||
/// Extracts file extensions from a filter label such as `"PDF (*.pdf)"` or `"*.pdf"`.
|
||||
fn extract_exts_from_label(label: &str) -> Vec<String> {
|
||||
let text = if let Some(inside) = label.split('(').nth(1).and_then(|s| s.split(')').next()) { inside } else { label };
|
||||
text.split(|c: char| c == ';' || c == ',' || c.is_whitespace())
|
||||
.filter_map(|part| {
|
||||
let s = part.trim().trim_start_matches('*').trim_start_matches('.');
|
||||
if s.is_empty() || s == "*" || s.contains('/') || s.contains('\\') { None } else { Some(s.to_ascii_lowercase()) }
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Converts a `file://` URI from the portal into a local `PathBuf`.
|
||||
/// Returns `None` for non-local URIs or invalid percent-encoding.
|
||||
pub fn file_uri_path(uri: &str) -> Option<PathBuf> {
|
||||
let rest = uri.strip_prefix("file://")?;
|
||||
// Only local files: `file:///…` or `file://localhost/…`.
|
||||
let rest = rest.strip_prefix("localhost").unwrap_or(rest);
|
||||
if !rest.starts_with('/') {
|
||||
return None;
|
||||
}
|
||||
let bytes = rest.as_bytes();
|
||||
let mut out = Vec::with_capacity(bytes.len());
|
||||
let mut i = 0;
|
||||
while i < bytes.len() {
|
||||
if bytes.get(i) == Some(&b'%') {
|
||||
let hex = bytes.get(i + 1..i + 3).and_then(|h| std::str::from_utf8(h).ok()).and_then(|h| u8::from_str_radix(h, 16).ok())?;
|
||||
out.push(hex);
|
||||
i = i.saturating_add(3);
|
||||
} else if let Some(&b) = bytes.get(i) {
|
||||
out.push(b);
|
||||
i = i.saturating_add(1);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
use std::os::unix::ffi::OsStringExt;
|
||||
Some(PathBuf::from(std::ffi::OsString::from_vec(out)))
|
||||
}
|
||||
|
||||
/// Returns the index of the chosen filter in `filters` by matching the picked index, or the first filter
|
||||
/// if only one was offered.
|
||||
fn chosen_type(filters: &[Filter], picked: Option<usize>) -> Option<usize> {
|
||||
match picked {
|
||||
Some(i) if i < filters.len() => Some(i),
|
||||
None if filters.len() == 1 => Some(0),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns `path` with the extension of the file type picked in the save dialog (`chosen`),
|
||||
/// where the dialog left the suggested name's extension alone (#6180).
|
||||
///
|
||||
/// The GTK file chooser keeps the typed name when another type is picked. Only a name that
|
||||
/// still has the extension the dialog opened with (`opened_with`, e.g. `Document1.docx` with
|
||||
/// PDF picked) or none gets the type's extension, as PolyMeilex/rfd#341 does. An extension the
|
||||
/// user explicitly typed that WordCraft saves (e.g. `art.txt` with PDF untouched) is kept,
|
||||
/// and so does one of the type's own extensions. Any extension that WordCraft does not save
|
||||
/// (e.g. `scan.2024`) is treated as part of the stem, and the type's extension is added.
|
||||
pub fn with_chosen_type(path: &Path, opened_with: Option<&str>, chosen: &[String]) -> PathBuf {
|
||||
let Some(first) = chosen.first() else { return path.to_path_buf() };
|
||||
let ext = path.extension().and_then(|e| e.to_str()).map(str::to_ascii_lowercase);
|
||||
let opened_ext = opened_with
|
||||
.and_then(|n| Path::new(n).extension())
|
||||
.and_then(|e| e.to_str())
|
||||
.map(str::to_ascii_lowercase)
|
||||
.filter(|e| wordcraft_engine::io::SAVE_EXTS.contains(&e.as_str()));
|
||||
match ext {
|
||||
Some(ext) if chosen.contains(&ext) => path.to_path_buf(),
|
||||
Some(ext) if opened_ext.as_ref() == Some(&ext) => path.with_extension(first),
|
||||
Some(ext) if wordcraft_engine::io::SAVE_EXTS.contains(&ext.as_str()) => path.to_path_buf(),
|
||||
_ => {
|
||||
let mut name = path.as_os_str().to_owned();
|
||||
name.push(".");
|
||||
name.push(first);
|
||||
PathBuf::from(name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true if `path` exists as a file, directory, or symlink on disk.
|
||||
fn entry_exists(path: &Path) -> bool {
|
||||
match path.symlink_metadata() {
|
||||
Ok(_) => true,
|
||||
Err(e) => e.kind() != std::io::ErrorKind::NotFound,
|
||||
}
|
||||
}
|
||||
|
||||
/// Closes the active portal request handle by calling `org.freedesktop.portal.Request.Close`.
|
||||
pub fn close_request(conn: &zbus::blocking::Connection, portal: Option<&str>, handle: &OwnedObjectPath) {
|
||||
const DESKTOP: &str = "org.freedesktop.portal.Desktop";
|
||||
const REQUEST: &str = "org.freedesktop.portal.Request";
|
||||
let dest = portal.unwrap_or(DESKTOP);
|
||||
if let Ok(proxy) = Proxy::new(conn, dest, handle.as_str(), REQUEST) {
|
||||
let _ = proxy.call_method("Close", &());
|
||||
}
|
||||
}
|
||||
|
||||
/// RAII guard that closes an open portal request handle if dropped before being disarmed.
|
||||
struct RequestGuard<'a> {
|
||||
conn: &'a zbus::blocking::Connection,
|
||||
portal: Option<String>,
|
||||
handle: Option<OwnedObjectPath>,
|
||||
}
|
||||
|
||||
impl<'a> RequestGuard<'a> {
|
||||
/// Creates a new guard for the given request `handle`.
|
||||
fn new(conn: &'a zbus::blocking::Connection, portal: Option<String>, handle: OwnedObjectPath) -> Self {
|
||||
Self { conn, portal, handle: Some(handle) }
|
||||
}
|
||||
|
||||
/// Disarms the guard when a response has been successfully received, preventing `Close` from being called on drop.
|
||||
fn disarm(&mut self) {
|
||||
self.handle = None;
|
||||
}
|
||||
|
||||
/// Returns a reference to the request handle, if still active.
|
||||
fn handle(&self) -> Option<&OwnedObjectPath> {
|
||||
self.handle.as_ref()
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> Drop for RequestGuard<'a> {
|
||||
/// Closes the active portal request handle when dropped unless disarmed.
|
||||
fn drop(&mut self) {
|
||||
if let Some(handle) = self.handle.take() {
|
||||
close_request(self.conn, self.portal.as_deref(), &handle);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Asks the XDG desktop portal on `conn` for the `SaveFile` dialog of `req` and waits for its answer.
|
||||
pub fn save_file(conn: &zbus::blocking::Connection, req: &SaveRequest) -> Result<Option<Saved>, String> {
|
||||
const DESKTOP: &str = "org.freedesktop.portal.Desktop";
|
||||
const REQUEST: &str = "org.freedesktop.portal.Request";
|
||||
static NEXT: AtomicU32 = AtomicU32::new(0);
|
||||
let token = format!("wordcraft_save_{}_{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed));
|
||||
|
||||
// Listen before asking, on every request path: a portal older than version 0.9 ignores
|
||||
// `handle_token` and answers on the request path it returns, and its answer can come before
|
||||
// the call's reply. The stream keeps every `Response` from the time it is registered.
|
||||
let dbus = zbus::blocking::fdo::DBusProxy::new(conn).map_err(|e| e.to_string())?;
|
||||
let rule = zbus::MatchRule::builder()
|
||||
.msg_type(zbus::message::Type::Signal)
|
||||
.sender(DESKTOP)
|
||||
.and_then(|b| b.interface(REQUEST))
|
||||
.and_then(|b| b.member("Response"))
|
||||
.map_err(|e| e.to_string())?
|
||||
.build();
|
||||
dbus.add_match_rule(rule).map_err(|e| e.to_string())?;
|
||||
|
||||
let owner_rule = zbus::MatchRule::builder()
|
||||
.msg_type(zbus::message::Type::Signal)
|
||||
.sender("org.freedesktop.DBus")
|
||||
.and_then(|b| b.interface("org.freedesktop.DBus"))
|
||||
.and_then(|b| b.member("NameOwnerChanged"))
|
||||
.map_err(|e| e.to_string())?
|
||||
.build();
|
||||
dbus.add_match_rule(owner_rule).map_err(|e| e.to_string())?;
|
||||
|
||||
let responses = MessageIterator::from(conn);
|
||||
let chooser = Proxy::new(conn, DESKTOP, "/org/freedesktop/portal/desktop", "org.freedesktop.portal.FileChooser").map_err(|e| e.to_string())?;
|
||||
|
||||
let start = std::time::Instant::now();
|
||||
let call = chooser.call_method("SaveFile", &(req.parent.as_str(), "Save As", options(req, &token))).map_err(|e| e.to_string())?;
|
||||
let handle: OwnedObjectPath = call.body().deserialize().map_err(|e| e.to_string())?;
|
||||
let portal = call.header().sender().map(|s| s.as_str().to_string());
|
||||
|
||||
let mut guard = RequestGuard::new(conn, portal.clone(), handle);
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let _listener = std::thread::Builder::new().name("portal save listener".into()).spawn(move || {
|
||||
for msg in responses.filter_map(Result::ok) {
|
||||
if tx.send(msg).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let reply = loop {
|
||||
if req.cancel.as_ref().is_some_and(CancelToken::is_cancelled) {
|
||||
return Err(ERR_CANCELLED.to_string());
|
||||
}
|
||||
match rx.recv_timeout(Duration::from_millis(100)) {
|
||||
Ok(m) => {
|
||||
let header = m.header();
|
||||
if header.path().map(|p| p.as_str()) == guard.handle().map(|h| h.as_str()) && header.sender().map(|s| s.as_str()) == portal.as_deref()
|
||||
{
|
||||
break Ok(m);
|
||||
}
|
||||
#[allow(clippy::collapsible_if)]
|
||||
if header.interface().map(|i| i.as_str()) == Some("org.freedesktop.DBus")
|
||||
&& header.member().map(|m| m.as_str()) == Some("NameOwnerChanged")
|
||||
{
|
||||
if let Ok((name, old_owner, "")) = m.body().deserialize::<(&str, &str, &str)>() {
|
||||
if (name == DESKTOP || portal.as_deref() == Some(name)) && (portal.is_none() || portal.as_deref() == Some(old_owner)) {
|
||||
break Err("the desktop portal terminated while the save dialog was open".to_string());
|
||||
}
|
||||
if name.starts_with("org.freedesktop.impl.portal.desktop") {
|
||||
break Err("the desktop portal backend terminated while the save dialog was open".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
|
||||
if req.timeout.is_some_and(|t| start.elapsed() >= t) {
|
||||
break Err("the save dialog request timed out".to_string());
|
||||
}
|
||||
}
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
|
||||
break Err("the save dialog closed without an answer".to_string());
|
||||
}
|
||||
}
|
||||
}?;
|
||||
|
||||
guard.disarm();
|
||||
let elapsed = start.elapsed();
|
||||
let (code, results): (u32, HashMap<String, OwnedValue>) = reply.body().deserialize().map_err(|e| e.to_string())?;
|
||||
answer(code, &results, elapsed)
|
||||
}
|
||||
|
||||
/// Returns true if `path` is located in the XDG document portal (`/run/user/<uid>/doc/…`, or
|
||||
/// `/run/flatpak/doc/…` inside a Flatpak sandbox).
|
||||
fn is_document_portal(path: &Path) -> bool {
|
||||
let s = path.to_string_lossy();
|
||||
((s.starts_with("/run/user/") || s.starts_with("/var/run/user/")) && s.contains("/doc/")) || s.starts_with("/run/flatpak/doc/")
|
||||
}
|
||||
|
||||
/// Error message returned when a pending save request was cancelled by the caller.
|
||||
pub const ERR_CANCELLED: &str = "the save dialog request was cancelled";
|
||||
|
||||
/// Returns true if `err` represents a save request cancelled by the caller (not a cancel in the
|
||||
/// dialog itself, which is `Ok(None)`).
|
||||
pub fn is_cancelled_error(err: &str) -> bool {
|
||||
err == ERR_CANCELLED
|
||||
}
|
||||
|
||||
/// Error message returned when the portal dialog does not report the selected filter and
|
||||
/// the saved filename does not explicitly specify a distinct format.
|
||||
pub const ERR_AMBIGUOUS_FILTER: &str = "the save dialog did not report which file type was picked";
|
||||
|
||||
/// Returns true if `err` represents an ambiguous filter error from the desktop portal.
|
||||
pub fn is_ambiguous_filter_error(err: &str) -> bool {
|
||||
err == ERR_AMBIGUOUS_FILTER
|
||||
}
|
||||
|
||||
/// Finds the index of the filter in `filters` matching `saved_filter`, first by exact label,
|
||||
/// then by unambiguous match against the filter's extensions.
|
||||
fn match_filter(filters: &[Filter], saved_filter: Option<&Filter>) -> Option<usize> {
|
||||
let (label, exts) = saved_filter?;
|
||||
if let Some(pos) = filters.iter().position(|(l, _)| l == label) {
|
||||
return Some(pos);
|
||||
}
|
||||
if !exts.is_empty() {
|
||||
let matching: Vec<usize> = filters
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, (_, f_exts))| exts.iter().any(|e| f_exts.iter().any(|fe| fe.eq_ignore_ascii_case(e))))
|
||||
.map(|(i, _)| i)
|
||||
.collect();
|
||||
if matching.len() == 1 {
|
||||
return matching.first().copied();
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Returns true if the portal did not provide a unique matching filter when multiple filters were offered
|
||||
/// and the user did not explicitly type a filename with a distinct supported format extension.
|
||||
pub fn is_ambiguous_filter(saved: &Saved, req: &SaveRequest) -> bool {
|
||||
if req.filters.len() <= 1 {
|
||||
return false;
|
||||
}
|
||||
if match_filter(&req.filters, saved.filter.as_ref()).is_some() {
|
||||
return false;
|
||||
}
|
||||
let ext = saved.path.extension().and_then(|e| e.to_str()).map(str::to_ascii_lowercase);
|
||||
let suggested_ext = req
|
||||
.suggested
|
||||
.as_deref()
|
||||
.and_then(|n| Path::new(n).extension())
|
||||
.and_then(|e| e.to_str())
|
||||
.map(str::to_ascii_lowercase)
|
||||
.filter(|e| wordcraft_engine::io::SAVE_EXTS.contains(&e.as_str()));
|
||||
match ext {
|
||||
Some(ext) if wordcraft_engine::io::SAVE_EXTS.contains(&ext.as_str()) => suggested_ext.as_ref() == Some(&ext),
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Save As on Linux and BSDs: the portal's dialog, with the file name given the extension of the
|
||||
/// file type picked in the dropdown. When that changes the name to one that already exists on disk,
|
||||
/// the dialog opens again under the new name, so the portal asks before replacing it (it asked only
|
||||
/// about the typed name).
|
||||
pub fn save_as(conn: &zbus::blocking::Connection, mut req: SaveRequest) -> Result<Option<PathBuf>, String> {
|
||||
loop {
|
||||
let Some(saved) = save_file(conn, &req)? else { return Ok(None) };
|
||||
if is_ambiguous_filter(&saved, &req) {
|
||||
return Err(ERR_AMBIGUOUS_FILTER.to_string());
|
||||
}
|
||||
let picked = match_filter(&req.filters, saved.filter.as_ref());
|
||||
let exts: &[String] = match chosen_type(&req.filters, picked) {
|
||||
Some(i) => req.filters.get(i).map(|(_, e)| e.as_slice()).unwrap_or(&[]),
|
||||
None => &[],
|
||||
};
|
||||
let path = with_chosen_type(&saved.path, req.suggested.as_deref(), exts);
|
||||
let needs_reopen = entry_exists(&path) || (path != saved.path && is_document_portal(&saved.path));
|
||||
if path == saved.path || !needs_reopen {
|
||||
return Ok(Some(path));
|
||||
}
|
||||
req.name = path.file_name().map(|n| n.to_string_lossy().into_owned());
|
||||
req.suggested = req.name.clone();
|
||||
req.folder = path.parent().map(Path::to_path_buf);
|
||||
req.current = picked;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// #6180: the file type picked in the save dialog gives the name its extension where the
|
||||
/// dialog left the one it opened with, and only there.
|
||||
#[test]
|
||||
fn the_picked_file_type_gives_the_name_its_extension() {
|
||||
let exts = |e: &[&str]| e.iter().map(|s| s.to_string()).collect::<Vec<_>>();
|
||||
let (pdf, docx) = (exts(&["pdf"]), exts(&["docx", "docm"]));
|
||||
let opened = Some("Document1.docx");
|
||||
let cases = [
|
||||
// PDF picked, the name as the dialog opened (or renamed, its extension kept).
|
||||
("/docs/Document1.docx", opened, &pdf, "/docs/Document1.pdf"),
|
||||
("/docs/letter.docx", opened, &pdf, "/docs/letter.pdf"),
|
||||
("/docs/LETTER.DOCX", opened, &pdf, "/docs/LETTER.pdf"),
|
||||
// No extension: takes the picked type's extension.
|
||||
("/docs/report", opened, &pdf, "/docs/report.pdf"),
|
||||
// An extension WordCraft doesn't save is part of the name.
|
||||
("/docs/scan.2024", opened, &pdf, "/docs/scan.2024.pdf"),
|
||||
// An opened extension WordCraft doesn't save (e.g. .5 in Report 1.5) is kept as part of the stem.
|
||||
("/docs/Report 1.5", Some("Report 1.5"), &docx, "/docs/Report 1.5.docx"),
|
||||
// Typed, an extension WordCraft saves is the user's: kept, whatever the type says.
|
||||
("/docs/notes.txt", opened, &pdf, "/docs/notes.txt"),
|
||||
("/docs/page.html", opened, &pdf, "/docs/page.html"),
|
||||
("/docs/file.rtf", opened, &pdf, "/docs/file.rtf"),
|
||||
// Already the type's: kept.
|
||||
("/docs/letter.pdf", opened, &pdf, "/docs/letter.pdf"),
|
||||
("/docs/letter.PDF", opened, &pdf, "/docs/letter.PDF"),
|
||||
// The dialog opened on a .dotx template: that is the extension the type replaces.
|
||||
("/docs/memo.dotx", Some("memo.dotx"), &pdf, "/docs/memo.pdf"),
|
||||
("/docs/doc.docx", Some("memo.dotx"), &pdf, "/docs/doc.docx"),
|
||||
// Word document picked for a file named without extension.
|
||||
("/docs/contract", opened, &docx, "/docs/contract.docx"),
|
||||
];
|
||||
for (typed, opened_with, chosen, want) in cases {
|
||||
assert_eq!(
|
||||
with_chosen_type(Path::new(typed), opened_with, chosen),
|
||||
PathBuf::from(want),
|
||||
"failed for typed={typed}, opened_with={opened_with:?}"
|
||||
);
|
||||
}
|
||||
// No type picked: the name as typed is kept.
|
||||
assert_eq!(with_chosen_type(Path::new("/docs/report.docx"), opened, &[]), PathBuf::from("/docs/report.docx"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_uris_become_local_paths() {
|
||||
assert_eq!(file_uri_path("file:///home/me/Document1.docx"), Some(PathBuf::from("/home/me/Document1.docx")));
|
||||
assert_eq!(file_uri_path("file:///home/me/A%20b%C3%A9.pdf"), Some(PathBuf::from("/home/me/A bé.pdf")));
|
||||
assert_eq!(file_uri_path("file://localhost/tmp/x.pdf"), Some(PathBuf::from("/tmp/x.pdf")));
|
||||
assert_eq!(file_uri_path("file:///tmp/a%ZZ.pdf"), None);
|
||||
assert_eq!(file_uri_path("file:///tmp/a%"), None);
|
||||
assert_eq!(file_uri_path("file:///tmp/a%2"), None);
|
||||
assert_eq!(file_uri_path("sftp://host/x.pdf"), None);
|
||||
assert_eq!(file_uri_path("file://host/x.pdf"), None);
|
||||
assert_eq!(file_uri_path("http://example.com/doc.pdf"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn x11_windows_are_named_to_the_portal() {
|
||||
use eframe::wgpu::rwh::{RawWindowHandle, XlibWindowHandle};
|
||||
assert_eq!(parent_id(Some(RawWindowHandle::Xlib(XlibWindowHandle::new(0x3a0000f)))), "x11:3a0000f");
|
||||
assert_eq!(parent_id(None), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn answer_parses_portal_response_or_errors_on_quick_failure() {
|
||||
let mut results = HashMap::new();
|
||||
let uris: Vec<String> = vec!["file:///tmp/doc.docx".to_string()];
|
||||
results.insert("uris".to_string(), OwnedValue::try_from(Value::from(uris)).unwrap());
|
||||
let globs: Vec<(u32, String)> = vec![(0u32, "*.docx".to_string())];
|
||||
let filter: (String, Vec<(u32, String)>) = ("Word Document (*.docx)".to_string(), globs);
|
||||
results.insert("current_filter".to_string(), OwnedValue::try_from(Value::from(filter)).unwrap());
|
||||
|
||||
// Code 0 with filter
|
||||
let ans = answer(0, &results, Duration::from_millis(1000)).unwrap();
|
||||
assert_eq!(
|
||||
ans,
|
||||
Some(Saved { path: PathBuf::from("/tmp/doc.docx"), filter: Some(("Word Document (*.docx)".to_string(), vec!["docx".to_string()])) })
|
||||
);
|
||||
|
||||
// Code 0 without filter
|
||||
let mut results_no_filter = HashMap::new();
|
||||
results_no_filter.insert("uris".to_string(), results.get("uris").unwrap().clone());
|
||||
let ans_no_filter = answer(0, &results_no_filter, Duration::from_millis(1000)).unwrap();
|
||||
assert_eq!(ans_no_filter, Some(Saved { path: PathBuf::from("/tmp/doc.docx"), filter: None }));
|
||||
|
||||
// Code 1 (Cancel)
|
||||
assert_eq!(answer(1, &results, Duration::from_millis(50)).unwrap(), None);
|
||||
|
||||
// Code 2 quick (< TOO_QUICK)
|
||||
assert!(answer(2, &results, Duration::from_millis(100)).is_err());
|
||||
|
||||
// Code 2 slow (>= TOO_QUICK)
|
||||
assert_eq!(answer(2, &results, Duration::from_millis(500)).unwrap(), None);
|
||||
|
||||
// Code 3 (failure)
|
||||
assert!(answer(3, &results, Duration::from_millis(1000)).is_err());
|
||||
}
|
||||
|
||||
struct KillOnDrop(std::process::Child);
|
||||
|
||||
impl Drop for KillOnDrop {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.0.kill();
|
||||
let _ = self.0.wait();
|
||||
}
|
||||
}
|
||||
|
||||
/// Configuration for spawning a mock portal daemon.
|
||||
struct MockPortalConfig {
|
||||
code: u32,
|
||||
delay_reply: Option<Duration>,
|
||||
exit_after_reply: bool,
|
||||
send_response: bool,
|
||||
close_called: Option<std::sync::Arc<std::sync::atomic::AtomicBool>>,
|
||||
emit_backend_owner_loss: bool,
|
||||
}
|
||||
|
||||
impl Default for MockPortalConfig {
|
||||
fn default() -> Self {
|
||||
Self { code: 0, delay_reply: None, exit_after_reply: false, send_response: true, close_called: None, emit_backend_owner_loss: false }
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawns a mock portal daemon that answers `SaveFile` and handles `Close` according to `config`.
|
||||
fn mock_portal_with_config(config: MockPortalConfig) -> Option<(zbus::blocking::Connection, KillOnDrop)> {
|
||||
use std::io::BufRead as _;
|
||||
|
||||
let daemon = std::process::Command::new("dbus-daemon")
|
||||
.args(["--session", "--nofork", "--print-address=1"])
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.spawn()
|
||||
.ok()?;
|
||||
let mut guard = KillOnDrop(daemon);
|
||||
let mut address = String::new();
|
||||
std::io::BufReader::new(guard.0.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 incoming = zbus::blocking::MessageIterator::from(&server);
|
||||
let thread_address = address.clone();
|
||||
std::thread::spawn(move || {
|
||||
for msg in incoming {
|
||||
let Ok(msg) = msg else { break };
|
||||
let header = msg.header();
|
||||
if header.message_type() != zbus::message::Type::MethodCall {
|
||||
continue;
|
||||
}
|
||||
if header.member().map(|m| m.as_str()) == Some("Close") {
|
||||
if let Some(flag) = &config.close_called {
|
||||
flag.store(true, Ordering::Relaxed);
|
||||
}
|
||||
let _ = server.reply(&header, &());
|
||||
continue;
|
||||
}
|
||||
if 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 Some(sender) = header.sender().map(|s| s.as_str().trim_start_matches(':').replace('.', "_")) else { break };
|
||||
let token = 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 config.exit_after_reply {
|
||||
let _ = server.reply(&header, &handle);
|
||||
drop(server);
|
||||
break;
|
||||
}
|
||||
|
||||
if config.emit_backend_owner_loss {
|
||||
let _ = server.reply(&header, &handle);
|
||||
let addr = thread_address.clone();
|
||||
std::thread::spawn(move || {
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
if let Ok(backend) = zbus::blocking::connection::Builder::address(addr.as_str())
|
||||
.and_then(|b| b.name("org.freedesktop.impl.portal.desktop.gtk"))
|
||||
.and_then(|b| b.build())
|
||||
{
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
drop(backend);
|
||||
}
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if config.send_response {
|
||||
let to = header.sender().cloned().map(zbus::names::BusName::from);
|
||||
let results: HashMap<&str, Value<'_>> = HashMap::new();
|
||||
let _ = server.emit_signal(to, path.as_str(), "org.freedesktop.portal.Request", "Response", &(config.code, &results));
|
||||
}
|
||||
|
||||
if let Some(delay) = config.delay_reply {
|
||||
std::thread::sleep(delay);
|
||||
}
|
||||
if server.reply(&header, &handle).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
let client = zbus::blocking::connection::Builder::address(address.as_str()).ok()?.build().ok()?;
|
||||
Some((client, guard))
|
||||
}
|
||||
|
||||
/// Spawns a mock portal daemon that answers `SaveFile`.
|
||||
/// If `delay_reply` is set, it emits the Response signal immediately and delays the method
|
||||
/// reply by that duration. If `exit_after_reply` is set, it replies to `SaveFile` and exits
|
||||
/// without sending a `Response`.
|
||||
fn mock_portal(code: u32, delay_reply: Option<Duration>, exit_after_reply: bool) -> Option<(zbus::blocking::Connection, KillOnDrop)> {
|
||||
mock_portal_with_config(MockPortalConfig {
|
||||
code,
|
||||
delay_reply,
|
||||
exit_after_reply,
|
||||
send_response: true,
|
||||
close_called: None,
|
||||
emit_backend_owner_loss: false,
|
||||
})
|
||||
}
|
||||
|
||||
/// A delayed SaveFile method reply with an already-queued response code 2 measures elapsed time
|
||||
/// from before calling SaveFile, so it is treated as a user cancellation rather than a quick
|
||||
/// portal failure that re-opens another dialog.
|
||||
#[test]
|
||||
fn delayed_method_reply_with_queued_cancel_is_not_treated_as_quick_failure() {
|
||||
let Some((client, _daemon)) = mock_portal(2, Some(Duration::from_millis(350)), false) else {
|
||||
eprintln!("no dbus-daemon: skipped");
|
||||
return;
|
||||
};
|
||||
let req = SaveRequest {
|
||||
name: Some("test.docx".to_string()),
|
||||
suggested: Some("test.docx".to_string()),
|
||||
filters: vec![("Word Document (*.docx)".to_string(), vec!["docx".to_string()])],
|
||||
..Default::default()
|
||||
};
|
||||
let res = save_file(&client, &req);
|
||||
assert_eq!(res, Ok(None));
|
||||
}
|
||||
|
||||
/// If the desktop portal terminates while a save dialog is open (e.g. crashing after replying
|
||||
/// to SaveFile), save_file detects NameOwnerChanged and returns an error immediately.
|
||||
#[test]
|
||||
fn portal_terminating_while_open_returns_error() {
|
||||
let Some((client, _daemon)) = mock_portal(0, None, true) else {
|
||||
eprintln!("no dbus-daemon: skipped");
|
||||
return;
|
||||
};
|
||||
let req = SaveRequest {
|
||||
name: Some("test.docx".to_string()),
|
||||
suggested: Some("test.docx".to_string()),
|
||||
filters: vec![("Word Document (*.docx)".to_string(), vec!["docx".to_string()])],
|
||||
..Default::default()
|
||||
};
|
||||
let res = save_file(&client, &req);
|
||||
assert!(res.is_err(), "portal termination returns an error so rfd fallback runs: got {res:?}");
|
||||
}
|
||||
|
||||
/// If the desktop portal backend implementation (e.g. GTK, GNOME, KDE) terminates while the
|
||||
/// dialog is open, save_file detects NameOwnerChanged on the implementation service and returns
|
||||
/// an error immediately so the rfd fallback can run.
|
||||
#[test]
|
||||
fn portal_backend_terminating_while_open_returns_error() {
|
||||
let Some((client, _daemon)) =
|
||||
mock_portal_with_config(MockPortalConfig { emit_backend_owner_loss: true, send_response: false, ..Default::default() })
|
||||
else {
|
||||
eprintln!("no dbus-daemon: skipped");
|
||||
return;
|
||||
};
|
||||
let req = SaveRequest {
|
||||
name: Some("test.docx".to_string()),
|
||||
suggested: Some("test.docx".to_string()),
|
||||
filters: vec![("Word Document (*.docx)".to_string(), vec!["docx".to_string()])],
|
||||
..Default::default()
|
||||
};
|
||||
let res = save_file(&client, &req);
|
||||
assert!(res.is_err(), "backend termination returns an error so rfd fallback runs: got {res:?}");
|
||||
assert!(res.as_ref().unwrap_err().contains("backend terminated"), "expected backend termination error: got {res:?}");
|
||||
}
|
||||
|
||||
/// When a save request is cancelled via `CancelToken`, save_file aborts waiting for the response,
|
||||
/// calls Close on the portal request object path, and returns a cancellation error immediately.
|
||||
#[test]
|
||||
fn save_file_cancellation_closes_request_and_returns_error() {
|
||||
let close_called = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
let Some((client, _daemon)) =
|
||||
mock_portal_with_config(MockPortalConfig { send_response: false, close_called: Some(close_called.clone()), ..Default::default() })
|
||||
else {
|
||||
eprintln!("no dbus-daemon: skipped");
|
||||
return;
|
||||
};
|
||||
let cancel = CancelToken::new();
|
||||
let cancel_thread = cancel.clone();
|
||||
std::thread::spawn(move || {
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
cancel_thread.cancel();
|
||||
});
|
||||
let req = SaveRequest {
|
||||
name: Some("test.docx".to_string()),
|
||||
suggested: Some("test.docx".to_string()),
|
||||
filters: vec![("Word Document (*.docx)".to_string(), vec!["docx".to_string()])],
|
||||
cancel: Some(cancel),
|
||||
..Default::default()
|
||||
};
|
||||
let res = save_file(&client, &req);
|
||||
assert!(res.is_err(), "cancelled request returns error: got {res:?}");
|
||||
assert!(res.as_ref().unwrap_err().contains("cancelled"), "expected cancellation error: got {res:?}");
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
assert!(close_called.load(Ordering::Relaxed), "Close must be called on portal request handle");
|
||||
}
|
||||
|
||||
/// When a save request times out waiting for a stalled portal backend response, save_file aborts,
|
||||
/// calls Close on the portal request object path, and returns a timeout error.
|
||||
#[test]
|
||||
fn save_file_timeout_closes_request_and_returns_error() {
|
||||
let close_called = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
let Some((client, _daemon)) =
|
||||
mock_portal_with_config(MockPortalConfig { send_response: false, close_called: Some(close_called.clone()), ..Default::default() })
|
||||
else {
|
||||
eprintln!("no dbus-daemon: skipped");
|
||||
return;
|
||||
};
|
||||
let req = SaveRequest {
|
||||
name: Some("test.docx".to_string()),
|
||||
suggested: Some("test.docx".to_string()),
|
||||
filters: vec![("Word Document (*.docx)".to_string(), vec!["docx".to_string()])],
|
||||
timeout: Some(Duration::from_millis(50)),
|
||||
..Default::default()
|
||||
};
|
||||
let res = save_file(&client, &req);
|
||||
assert!(res.is_err(), "timed out request returns error: got {res:?}");
|
||||
assert!(res.as_ref().unwrap_err().contains("timed out"), "expected timeout error: got {res:?}");
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
assert!(close_called.load(Ordering::Relaxed), "Close must be called on portal request handle");
|
||||
}
|
||||
|
||||
/// `entry_exists` detects existing files and dangling symlinks, distinguishing them from
|
||||
/// absent entries.
|
||||
#[test]
|
||||
fn entry_exists_detects_dangling_symlinks() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let target = dir.path().join("nonexistent_target.txt");
|
||||
let link = dir.path().join("dangling_link.txt");
|
||||
#[cfg(unix)]
|
||||
{
|
||||
std::os::unix::fs::symlink(&target, &link).unwrap();
|
||||
assert!(!link.exists(), "target does not exist so exists() is false");
|
||||
assert!(entry_exists(&link), "symlink entry exists so entry_exists() is true");
|
||||
}
|
||||
assert!(!entry_exists(&target), "nonexistent file does not exist");
|
||||
}
|
||||
|
||||
/// `is_document_portal` identifies paths within the XDG document portal mount, including the
|
||||
/// `/run/flatpak/doc` mount a Flatpak sandbox sees.
|
||||
#[test]
|
||||
fn is_document_portal_detects_portal_fuse_paths() {
|
||||
assert!(is_document_portal(Path::new("/run/user/1000/doc/1234abcd/test.docx")));
|
||||
assert!(is_document_portal(Path::new("/var/run/user/1000/doc/by-app/app/test.docx")));
|
||||
assert!(is_document_portal(Path::new("/run/flatpak/doc/1234abcd/test.docx")));
|
||||
assert!(!is_document_portal(Path::new("/home/user/Documents/test.docx")));
|
||||
assert!(!is_document_portal(Path::new("/tmp/doc/test.docx")));
|
||||
assert!(!is_document_portal(Path::new("/run/flatpak/other/test.docx")));
|
||||
}
|
||||
|
||||
/// When the portal omits `current_filter`, ambiguous saves (unchanged suggested extension or bare name)
|
||||
/// are rejected, while an explicitly typed different format is accepted.
|
||||
#[test]
|
||||
fn ambiguous_filters_require_explicit_format() {
|
||||
let exts = |e: &[&str]| e.iter().map(|s| s.to_string()).collect::<Vec<_>>();
|
||||
let filters = vec![("Word Document (*.docx)".into(), exts(&["docx"])), ("PDF (*.pdf)".into(), exts(&["pdf"]))];
|
||||
let req = SaveRequest { filters: filters.clone(), suggested: Some("Document1.docx".into()), ..Default::default() };
|
||||
|
||||
// Reported filter with matching label is not ambiguous.
|
||||
let with_filter = Saved { path: PathBuf::from("/docs/Document1.docx"), filter: Some(("Word Document (*.docx)".into(), exts(&["docx"]))) };
|
||||
assert_eq!(match_filter(&filters, with_filter.filter.as_ref()), Some(0));
|
||||
assert!(!is_ambiguous_filter(&with_filter, &req));
|
||||
|
||||
// Reported filter with unmatched label but unambiguous extension (e.g. KDE *.pdf) is not ambiguous.
|
||||
let kde_pdf_filter = Saved { path: PathBuf::from("/docs/Document1.docx"), filter: Some(("*.pdf".into(), exts(&["pdf"]))) };
|
||||
assert_eq!(match_filter(&filters, kde_pdf_filter.filter.as_ref()), Some(1));
|
||||
assert!(!is_ambiguous_filter(&kde_pdf_filter, &req));
|
||||
|
||||
// Reported filter with unrecognized extensions and unchanged suggested extension is ambiguous.
|
||||
let unknown_filter = Saved { path: PathBuf::from("/docs/Document1.docx"), filter: Some(("Custom (*.xyz)".into(), exts(&["xyz"]))) };
|
||||
assert_eq!(match_filter(&filters, unknown_filter.filter.as_ref()), None);
|
||||
assert!(is_ambiguous_filter(&unknown_filter, &req));
|
||||
|
||||
// Multiple filters matching same extension is ambiguous when label doesn't match.
|
||||
let duplicate_filters = vec![("Plain Text (*.txt)".into(), exts(&["txt"])), ("Text Document (*.txt)".into(), exts(&["txt"]))];
|
||||
let dup_filter = ("*.txt".to_string(), exts(&["txt"]));
|
||||
assert_eq!(match_filter(&duplicate_filters, Some(&dup_filter)), None);
|
||||
|
||||
// Missing filter with unchanged suggested extension is ambiguous.
|
||||
let unchanged = Saved { path: PathBuf::from("/docs/Document1.docx"), filter: None };
|
||||
assert!(is_ambiguous_filter(&unchanged, &req));
|
||||
|
||||
// Missing filter with bare filename is ambiguous.
|
||||
let bare = Saved { path: PathBuf::from("/docs/Document1"), filter: None };
|
||||
assert!(is_ambiguous_filter(&bare, &req));
|
||||
|
||||
// Missing filter but user explicitly typed a different format is NOT ambiguous.
|
||||
let explicit = Saved { path: PathBuf::from("/docs/Document1.pdf"), filter: None };
|
||||
assert!(!is_ambiguous_filter(&explicit, &req));
|
||||
|
||||
// Single filter offered is never ambiguous even if filter is omitted.
|
||||
let single_req =
|
||||
SaveRequest { filters: vec![("PDF (*.pdf)".into(), exts(&["pdf"]))], suggested: Some("Document1.docx".into()), ..Default::default() };
|
||||
assert!(!is_ambiguous_filter(&unchanged, &single_req));
|
||||
}
|
||||
}
|
||||
@@ -174,6 +174,9 @@ impl WordApp {
|
||||
/// Treat the system dialog the host is showing as cancelled, e.g. one that never showed up.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) fn give_up_file_dialog(&mut self) {
|
||||
if let Some(cancel) = &self.services.cancel_file_dialog {
|
||||
cancel();
|
||||
}
|
||||
if let Some(d) = self.file_dialog.take() {
|
||||
let _ = self.finish_pick(d.after, None);
|
||||
}
|
||||
|
||||
@@ -252,6 +252,27 @@ fn answer_all(shown: &Shown, picked: &[&str]) {
|
||||
tx.send(Ok(picked.iter().map(|p| p.to_string()).collect())).unwrap();
|
||||
}
|
||||
|
||||
/// Giving the system dialog up (File › Open › Browse in WordCraft…) tells the host to cancel its
|
||||
/// waiting request, so a portal that stopped answering cannot keep Save As stuck.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn giving_up_a_file_dialog_cancels_the_host_request() {
|
||||
let cancelled = Rc::new(std::cell::Cell::new(false));
|
||||
let flag = cancelled.clone();
|
||||
let services = Services { cancel_file_dialog: Some(Box::new(move || flag.set(true))), ..Default::default() };
|
||||
let mut a = WordApp::new(Session::new(wordcraft_doc::Document::new()), services);
|
||||
let (tx, rx) = channel();
|
||||
a.file_dialog = Some(PendingDialog {
|
||||
rx,
|
||||
req: FileDialogRequest::Save { name: "Document1.docx".into(), dir: None },
|
||||
after: AfterPick::Export { ext: "pdf".into() },
|
||||
});
|
||||
a.give_up_file_dialog();
|
||||
assert!(cancelled.get(), "the host is told to stop waiting");
|
||||
assert!(a.file_dialog.is_none(), "the pending dialog is taken as cancelled");
|
||||
drop(tx);
|
||||
}
|
||||
|
||||
fn typed(a: &mut WordApp) {
|
||||
a.run("text.insert", json!({"text": UNSAVED})).unwrap();
|
||||
assert!(a.session.dirty);
|
||||
|
||||
@@ -21,6 +21,10 @@ pub struct Services {
|
||||
/// sender counts as cancelled. An `Err` says no system dialog could be shown, and the app shows
|
||||
/// its built-in file browser instead (#576). The app shows one dialog at a time ([`file_dialogs`]).
|
||||
pub file_dialog: Option<Box<dyn Fn(FileDialogRequest) -> std::sync::mpsc::Receiver<FileDialogAnswer>>>,
|
||||
/// Desktop: stop waiting for the system file dialog the host is showing, if it can (a portal
|
||||
/// that never answers). The app calls it when it gives the dialog up (File › Open › Browse in
|
||||
/// WordCraft…), so the host closes the request and answers as cancelled.
|
||||
pub cancel_file_dialog: Option<Box<dyn Fn()>>,
|
||||
/// Web: open a file picker; the file arrives later through `inbox`.
|
||||
pub open_async: Option<Box<dyn Fn(&str)>>,
|
||||
/// Web: files (name, bytes) delivered asynchronously (picker, drag and drop).
|
||||
|
||||
+5
-1
@@ -1,7 +1,11 @@
|
||||
# UI parity with Microsoft Word
|
||||
|
||||
fix/6205-image-copy-paste
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-12 · **Change:** trivial (clipboard: a copied picture reaches the system clipboard as PNG, with HTML where the host writes it, while a copy of only shapes stays in WordCraft, #6205); previously trivial (Vertical scrollbar track: clicking above the elevator moves one screen up and clicking below it moves one screen down, as in Word (#5853)); previously trivial (a press the canvas handles, such as Ctrl+click on a link, no longer also shows the link tooltip that frame, #4683); previously trivial (the window moves by its title bar behind a modal dialog, as in Word for Mac, #5124); previously trivial (pointer zoom stretches page rasters until it stops); previously trivial (dark-theme interface text on Linux and the BSDs no longer looks semi-bold); previously trivial (the document's scroll bars show wherever the pointer is, not only at the window's edge, #5746); previously trivial (Protected typing boundary lookup counts preceding objects once and indexes editable permission IDs (#3614); caret movement and enforcement policy are unchanged; previously trivial: Custom Document Properties dialog from File › Info › Properties › Advanced Properties…, #2982; previously trivial: View › Window › New Window opens another window on the document, each with its own caret and view; previously trivial: mail merge: First Record, Last Record and a record number box in Preview Results; Finish & Merge asks which records to merge or print, #3028; previously trivial: selection bar for right-to-left paragraphs and table rows; previously trivial: read only and comments-only protection grey out edits; previously trivial: the Font dialog previews "NaN" or "inf" in the Size box at 12 pt, #3663; previously trivial: File › Options disables the User name field while Restrict Editing is active; previously trivial: while editing a header or footer, a click in the other one edits it there without scrolling; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Insert Table of Figures dialog: any caption label, #3013; previously trivial: Home › Underline drop-down shows its lines, #2738; previously trivial: the text context menu includes the formatting mini toolbar, #2959; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: the scrollbar page tip stays on one line as the page number grows, #3341; previously trivial: File › Home and New: templates and recent documents are buttons named for screen readers, #3176; previously trivial: forms protection greys out the Developer tab's control buttons and Properties; previously trivial: AltGr doesn't open KeyTips, #2388; previously trivial: closing the header or footer puts the caret back where it was in the body, #3331; previously trivial: Format Page Numbers dialog: number format and Continue from previous section / Start at, #1113; previously trivial: inline pictures drag to a dashed insertion cursor and stay inline, #2233, #2234; previously trivial: Save Changes prompt: a text file saved over itself closes, #2376; previously trivial: the equation being edited is framed as in Word: an outline as tall as its line, a grip, an options strip that opens its menu, and its current argument shaded; previously trivial: the table handles show while the caret is in the table, #2428; previously trivial: legacy menu keys, #1697; previously trivial: Shape Fill › Gradient and the Format Shape pane, #1500; previously trivial: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: the text pointer over the page is drawn black or white on Windows, #1746; previously trivial: a click in the header/footer band from the body no longer scrolls to the body text, #2103; previously trivial: File › Recover follows the app theme, #3243; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: dragging the vertical scrollbar names the page at the top of the window, #2551; previously trivial: Add Font imports every selected font file, #1789); previously trivial (dragging an In Line with Text object shows a bold insertion cursor, as in Word, instead of a faint dashed one, #4195); previously trivial (the shapes, pictures and text boxes inside a Drawing Canvas are picked, moved and resized on their own, #2637); previously trivial (Shape Format › Size shows the interface unit and takes any typed unit, #4196); previously trivial (over a turned picture, shape or text box the resize arrows turn with it, #3932); previously trivial (the status bar's count is words by default and can be characters with or without spaces, chosen from its right-click menu or File › Options › Display and kept between runs, #2797); previously triviaLine truncated
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** trivial (Save As on Linux: the desktop portal's Save dialog reports the file type picked, updating the extension so saving as PDF writes a .pdf, #6180); previously trivial (Vertical scrollbar track: clicking above the elevator moves one screen up and clicking below it moves one screen down, as in Word (#5853)); previously trivial (a press the canvas handles, such as Ctrl+click on a link, no longer also shows the link tooltip that frame, #4683); previously trivial (the window moves by its title bar behind a modal dialog, as in Word for Mac, #5124); previously trivial (pointer zoom stretches page rasters until it stops); previously trivial (dark-theme interface text on Linux and the BSDs no longer looks semi-bold); previously trivial (the document's scroll bars show wherever the pointer is, not only at the window's edge, #5746); previously trivial (Protected typing boundary lookup counts preceding objects once and indexes editable permission IDs (#3614); caret movement and enforcement policy are unchanged; previously trivial: Custom Document Properties dialog from File › Info › Properties › Advanced Properties…, #2982; previously trivial: View › Window › New Window opens another window on the document, each with its own caret and view; previously trivial: mail merge: First Record, Last Record and a record number box in Preview Results; Finish & Merge asks which records to merge or print, #3028; previously trivial: selection bar for right-to-left paragraphs and table rows; previously trivial: read only and comments-only protection grey out edits; previously trivial: the Font dialog previews "NaN" or "inf" in the Size box at 12 pt, #3663; previously trivial: File › Options disables the User name field while Restrict Editing is active; previously trivial: while editing a header or footer, a click in the other one edits it there without scrolling; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Insert Table of Figures dialog: any caption label, #3013; previously trivial: Home › Underline drop-down shows its lines, #2738; previously trivial: the text context menu includes the formatting mini toolbar, #2959; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: the scrollbar page tip stays on one line as the page number grows, #3341; previously trivial: File › Home and New: templates and recent documents are buttons named for screen readers, #3176; previously trivial: forms protection greys out the Developer tab's control buttons and Properties; previously trivial: AltGr doesn't open KeyTips, #2388; previously trivial: closing the header or footer puts the caret back where it was in the body, #3331; previously trivial: Format Page Numbers dialog: number format and Continue from previous section / Start at, #1113; previously trivial: inline pictures drag to a dashed insertion cursor and stay inline, #2233, #2234; previously trivial: Save Changes prompt: a text file saved over itself closes, #2376; previously trivial: the equation being edited is framed as in Word: an outline as tall as its line, a grip, an options strip that opens its menu, and its current argument shaded; previously trivial: the table handles show while the caret is in the table, #2428; previously trivial: legacy menu keys, #1697; previously trivial: Shape Fill › Gradient and the Format Shape pane, #1500; previously trivial: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: the text pointer over the page is drawn black or white on Windows, #1746; previously trivial: a click in the header/footer band from the body no longer scrolls to the body text, #2103; previously trivial: File › Recover follows the app theme, #3243; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: dragging the vertical scrollbar names the page at the top of the window, #2551; previously trivial: Add Font imports every selected font file, #1789); previously trivial (dragging an In Line with Text object shows a bold insertion cursor, as in Word, instead of a faint dashed one, #4195); previously trivial (the shapes, pictures and text boxes inside a Drawing Canvas are picked, moved and resized on their own, #2637); previously trivial (Shape Format › Size shows the interface unit and takes any typed unit, #4196); previously trivial (over a turned picture, shape or text box the resize arrows turn with it, #3932); previously trivial (the status bar's count is words by default and can be characters with or without spaces, chosen from its right-click menu or File › Options › Display and kept between runs, #2797); previously trivial (Linux dispLine truncated
|
||||
main
|
||||
|
||||
> **Page Setup update:** 2026-11-07 · Page Setup number boxes keep long input inside the field, including accepted margin values after focus leaves (#5650); accepted ranges and readiness estimates are unchanged.
|
||||
|
||||
Recovery discovery no longer interrupts an explicitly created or opened document. Older copies remain in File › Recover, whose buttons use the interface palette. See [local recovery](recovery.md).
|
||||
@@ -70,7 +74,7 @@ Document title opens Rename Document; the account badge opens Options › Genera
|
||||
| Interface size (File › Options › General: Auto, which keeps the system display scale and shows the size it draws at, or an absolute 75–300 %, so "100%" is one screen pixel per point; `ui.interfaceSize`) (#475, #822). A size from before #822 (on top of the display scale) becomes the absolute size it drew at. On the web the browser's zoom is part of the display scale, so a chosen size cancels browser zoom (Ctrl+plus/minus no longer resize the interface); Auto follows it | ✅ | system display scaling only | 100% | — |
|
||||
| Animation effects (File › Options › Accessibility: Use system setting, On, Off; `ui.animations`). Off: a wheel notch scrolls at once; Ctrl+wheel zooms in whole steps, each drawn finished in its own frame (a notch at once; touchpad pinches and high-resolution wheels in steps of about 10% rather than gliding); menus, buttons and the scroll to the caret neither fade nor glide | ✅ "Use system setting" reads Windows (Animation effects), macOS (Reduce motion), the Linux/BSD desktops (settings portal: the cross-desktop reduced-motion preference, GNOME's switch, KDE's animation speed; the GTK and KDE settings files where no portal answers) and browsers (`prefers-reduced-motion`), and follows a change while running. Run on Windows and against a stand-in portal on Linux; not yet tried on a Mac or a real GNOME or KDE session | not compared | 90% | 0.5–1 |
|
||||
| Focus and Full Screen (#1678, #980) | View › Focus fills the screen with the document on black, with a slim bar whose "⋯" drops the ribbon over the page; Full Screen View (`view.fullScreenView`, any key in Customize Keyboard) shows the pages alone with Search at the top; the two turn each other off. View › Window › Full Screen (`view.fullScreen`, F11, ⌃⌘F on macOS) fills the screen with the whole window, ribbon and all. Esc leaves Focus or Full Screen View, then Full Screen, once nothing nearer takes it; leaving gives back the earlier window size; the window manager's or macOS's own full screen keeps them in step. Ctrl+F1 (⌘⌥R on macOS) collapses or expands the ribbon; a shortcut of your own on Ctrl+F1 wins. Web: Full Screen off (the browser's own F11); Focus and Full Screen View hide the chrome only | Focus, Toggle Full Screen View (Alt, V, U, #1697), Word for Mac's View › Enter Full Screen, ⌘⌥R to collapse or expand the ribbon (Ctrl+F1 on Windows) | 85% | 0.5 |
|
||||
| File dialogs (Open, Save As, Export, Insert Picture) | system dialog, without blocking the window (#94), its Save As type list naming every format with the document's own first (#1399); Linux/BSD without a file chooser portal or zenity: WordCraft's own browser, also File › Open › Browse in WordCraft… (#576) | system dialog | 95% | — |
|
||||
| File dialogs (Open, Save As, Export, Insert Picture) | system dialog, without blocking the window (#94), its Save As type list naming every format with the document's own first (#1399); Linux/BSD: the desktop portal's Save dialog reports the file type picked, updating the extension so saving as PDF writes a PDF (#6180); Linux/BSD without a file chooser portal or zenity: WordCraft's own browser, also File › Open › Browse in WordCraft… (#576) | system dialog | 95% | — |
|
||||
| Linux display server (File › Options › Advanced: Automatic, Wayland, X11 (XWayland); `ui.displayServer`, #2184), kept in `ui.json` and applied at the next launch. `WORDCRAFT_WAYLAND` overrides it (`0`, `false`, `no` or `off` for X11, any other value for Wayland), and a choice that isn't available falls back to the other instead of failing to start; the log's `display:` line says which ran and why | ✅ Linux and the BSDs only; the choice is unit-tested, not yet tried on a real GNOME or KDE session | no equivalent (no Word for Linux) | 100% | 0 |
|
||||
| Platform conventions (macOS menus/traffic lights #255, Windows title bar, Linux CSD on Wayland #78) | partial | native | 70% | 3–5 |
|
||||
| Screen readers (VoiceOver, Narrator, Orca) | AccessKit on; the document canvas describes paragraphs, headings (level), list items, tables, links, pictures (alt text) and the caret and selection, for the pages on screen (#488); the ribbon's font name and size boxes, and the dropdown arrows beside them, say what they are (#3172); Windows Magnifier follows the caret, also onto an empty line after Enter or Shift+Enter (#1119); File › Home and New's templates and recent documents are buttons named by their caption or file name, with the path when two file names are the same (#3176); not yet tried with real screen readers | full | 50% | 5–8 |
|
||||
|
||||
Reference in new issue
Block a user