mirror of
https://github.com/storytold/wordcraft.git
synced 2026-10-24 10:27:44 -06:00
#2999: a file dialog that "cancels" at once asks again without a parent, then shows the built-in browser (#3030)
* #2999: a file dialog that "cancels" at once asks again without a parent, then shows the built-in browser rfd folds the xdg-desktop-portal's failure response into "cancelled", so on desktops whose FileChooser portal fails to show a dialog, Save As, Export and Open did nothing and logged nothing. A cancel quicker than anyone can make one (300 ms) is now taken as that failure: the dialog is asked for once more without the parent window (GridCraft, which passes none, works on the reporter's machine), and if that too comes back at once the app shows its built-in browser. Each step logs a warning. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012WEhZXSxzS8orX7f9J3qpr * #2999: review — the quick-cancel retry runs only on Linux and the BSDs; Windows and macOS answer a quick cancel as a cancel Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012WEhZXSxzS8orX7f9J3qpr --------- Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
e0ff4b0c3d
commit
6950c22d73
@@ -139,6 +139,7 @@ Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target
|
||||
- 2026-10-15: Find opens Navigation on Results; editing the query restores Results after manual Headings/Pages navigation. Typed and selected-text searches retain document contents (#2096).
|
||||
|
||||
| Date | What landed |
|
||||
| 2026-10-19 | Save As, Export and Open no longer do nothing on Linux desktops whose file chooser portal fails to show its dialog (#2999): a "cancel" quicker than anyone could make one asks again without the parent window, then opens the built-in file browser. |
|
||||
| 2026-10-18 | Clearing a font query with no matches keeps Recently Used Fonts visible at the top of a scrollable list. The initial scroll position no longer skips its heading; measured keyboard scrolling is unchanged (#2068). |
|
||||
| 2026-10-17 | Edit Recipient List finds records (#1576): a Find in list box hides rows with no cell containing the text (any case) and counts the matches, and "Show only records that will be merged" hides the rows the filter leaves out; both only change the view. Rows are striped, and edits and removes go to the right record however the grid is narrowed |
|
||||
| 2026-10-14 | DOCX row table border exceptions render and survive saving. Explicit cell borders win; a row exception does not change other rows. |
|
||||
|
||||
@@ -12,6 +12,12 @@
|
||||
//! thread first checks that one of them is there and otherwise answers with an error, and the app
|
||||
//! shows its built-in file browser. `WORDCRAFT_FILE_DIALOG=builtin` always uses the built-in one.
|
||||
//!
|
||||
//! On Linux and the BSDs, rfd reports a portal that failed to show its dialog as a cancel (#2999).
|
||||
//! There a cancel quicker than anyone could dismiss a dialog is taken as that failure: the dialog
|
||||
//! is asked for once more without a parent window, and if that also comes back at once the
|
||||
//! built-in browser shows. Windows and macOS always show their dialog, so a quick cancel there is
|
||||
//! the person's and is answered as one.
|
||||
//!
|
||||
//! [`Services::file_dialog`]: wordcraft_ui_egui::Services::file_dialog
|
||||
|
||||
use std::future::Future;
|
||||
@@ -54,28 +60,22 @@ impl Launcher {
|
||||
pub fn show(&self, parent: &eframe::Frame) {
|
||||
while let Ok((req, answer)) = self.queue.try_recv() {
|
||||
// Built here, on the UI thread: macOS shows the panel as a sheet on the window now.
|
||||
let dialog = rfd::AsyncFileDialog::new().set_parent(parent);
|
||||
let picking: Picking = match req {
|
||||
FileDialogRequest::Open { purpose } => {
|
||||
Box::pin(async move { open_filters(dialog, &purpose).pick_file().await.into_iter().collect() })
|
||||
}
|
||||
FileDialogRequest::OpenMany { purpose } => {
|
||||
Box::pin(async move { open_filters(dialog, &purpose).pick_files().await.unwrap_or_default() })
|
||||
}
|
||||
FileDialogRequest::Save { name } => {
|
||||
let mut dialog = dialog.set_file_name(name.clone());
|
||||
for (label, exts) in save_filters(&name) {
|
||||
dialog = dialog.add_filter(label, &exts);
|
||||
}
|
||||
Box::pin(async move { dialog.save_file().await.into_iter().collect() })
|
||||
}
|
||||
};
|
||||
let picking = picker(rfd::AsyncFileDialog::new().set_parent(parent), &req);
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
let unparented = picker(rfd::AsyncFileDialog::new(), &req);
|
||||
let ctx = self.ctx.clone();
|
||||
let waiting = std::thread::Builder::new().name("file dialog".into()).spawn(move || {
|
||||
// The picker only starts when first polled: without a system dialog it never does.
|
||||
// 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 => Ok(pollster::block_on(picking).iter().map(|f| f.path().to_string_lossy().to_string()).collect()),
|
||||
None => {
|
||||
// Only the portal folds a failure into "cancelled" (#2999).
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
let picked = pick(picking, unparented, TOO_QUICK);
|
||||
#[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 _ = answer.send(picked);
|
||||
ctx.request_repaint();
|
||||
@@ -88,6 +88,53 @@ impl Launcher {
|
||||
}
|
||||
}
|
||||
|
||||
/// The picker for `req` on `dialog`.
|
||||
fn picker(dialog: rfd::AsyncFileDialog, req: &FileDialogRequest) -> Picking {
|
||||
match req.clone() {
|
||||
FileDialogRequest::Open { purpose } => Box::pin(async move { open_filters(dialog, &purpose).pick_file().await.into_iter().collect() }),
|
||||
FileDialogRequest::OpenMany { purpose } => Box::pin(async move { open_filters(dialog, &purpose).pick_files().await.unwrap_or_default() }),
|
||||
FileDialogRequest::Save { name } => {
|
||||
let mut dialog = dialog.set_file_name(name.clone());
|
||||
for (label, exts) in save_filters(&name) {
|
||||
dialog = dialog.add_filter(label, &exts);
|
||||
}
|
||||
Box::pin(async move { dialog.save_file().await.into_iter().collect() })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Quicker than anyone can see a dialog and dismiss it.
|
||||
// ponytail: a timing heuristic, as rfd folds the portal's failure code into "cancelled". A portal
|
||||
// failing slower than this still reads as a cancel; ask rfd for the response code if it matters.
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
const TOO_QUICK: std::time::Duration = std::time::Duration::from_millis(300);
|
||||
|
||||
/// Wait for `picking`. Nothing picked within `too_quick` is a portal that failed to show its
|
||||
/// dialog (#2999), not a person cancelling: try `unparented`, as a portal may fail on the parent
|
||||
/// window; if that too comes back at once, an error, so the app shows its built-in browser.
|
||||
/// Linux and the BSDs only (built everywhere for its test).
|
||||
#[cfg(any(test, all(unix, not(target_os = "macos"))))]
|
||||
fn pick<T>(
|
||||
picking: impl Future<Output = Vec<T>>,
|
||||
unparented: impl Future<Output = Vec<T>>,
|
||||
too_quick: std::time::Duration,
|
||||
) -> Result<Vec<T>, String> {
|
||||
fn timed<T>(f: impl Future<Output = Vec<T>>, too_quick: std::time::Duration) -> (Vec<T>, bool) {
|
||||
let start = std::time::Instant::now();
|
||||
let picked = pollster::block_on(f);
|
||||
let quick = picked.is_empty() && start.elapsed() < too_quick;
|
||||
(picked, quick)
|
||||
}
|
||||
match timed(picking, too_quick) {
|
||||
(_, true) => log::warn!("the system file dialog closed at once; asking again without a parent window"),
|
||||
(picked, false) => return Ok(picked),
|
||||
}
|
||||
match timed(unparented, too_quick) {
|
||||
(_, true) => Err("the system file dialog closed at once".into()),
|
||||
(picked, false) => Ok(picked),
|
||||
}
|
||||
}
|
||||
|
||||
/// The file types Open offers (shared with the built-in browser).
|
||||
fn open_filters(mut d: rfd::AsyncFileDialog, purpose: &str) -> rfd::AsyncFileDialog {
|
||||
for (name, exts) in wordcraft_ui_egui::open_filters(purpose) {
|
||||
@@ -198,7 +245,24 @@ fn format_name(ext: &str) -> String {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{save_exts, save_filters};
|
||||
use super::{pick, save_exts, save_filters};
|
||||
use std::time::Duration;
|
||||
|
||||
/// #2999: a dialog that "cancels" at once is a portal that failed: asked again without a
|
||||
/// parent, then the built-in browser. A pick, or a cancel after a while, is the answer.
|
||||
#[test]
|
||||
fn an_instant_cancel_retries_then_falls_back() {
|
||||
let quick = Duration::from_millis(200);
|
||||
let slow_cancel = || async {
|
||||
std::thread::sleep(Duration::from_millis(250));
|
||||
Vec::<u8>::new()
|
||||
};
|
||||
assert_eq!(pick(async { vec![1u8] }, async { vec![2] }, quick), Ok(vec![1]));
|
||||
assert_eq!(pick(async { vec![] }, async { vec![2u8] }, quick), Ok(vec![2]), "asked again without a parent");
|
||||
assert!(pick(async { Vec::<u8>::new() }, async { vec![] }, quick).is_err(), "the built-in browser");
|
||||
assert_eq!(pick(slow_cancel(), async { vec![2u8] }, quick), Ok(vec![]), "a real cancel isn't asked again");
|
||||
assert_eq!(pick(async { vec![] }, slow_cancel(), quick), Ok(vec![]), "the second dialog cancelled for real");
|
||||
}
|
||||
|
||||
/// #1399: the Save dialog's type list has one named entry per format, the suggested one first,
|
||||
/// every savable extension exactly once.
|
||||
|
||||
Reference in New Issue
Block a user