diff --git a/apps/wordcraft/src/apple_events.rs b/apps/wordcraft/src/apple_events.rs index 64f534392..0eae596a9 100644 --- a/apps/wordcraft/src/apple_events.rs +++ b/apps/wordcraft/src/apple_events.rs @@ -43,7 +43,7 @@ pub fn poll(inbox: &Inbox, app: &mut WordApp, ctx: &egui::Context) { match e { Event::Open(paths) => { for p in paths.iter().filter_map(|p| p.to_str()) { - match app.run("file.open", serde_json::json!({"path": p})) { + match app.open_file(p, None) { // Leave Backstage, as opening from the Open dialog does. Ok(_) => app.ui.backstage = false, Err(e) => { diff --git a/crates/ui-egui/src/backstage.rs b/crates/ui-egui/src/backstage.rs index 771cd1376..8b3a5084a 100644 --- a/crates/ui-egui/src/backstage.rs +++ b/crates/ui-egui/src/backstage.rs @@ -564,8 +564,8 @@ pub(crate) fn forget_save_as_error(ctx: &egui::Context) { /// Open a file from the Recent list. A file that can't be opened (deleted, moved, unreadable) /// leaves File open so the list can say why, rather than closing it on a greyed-out window. -fn open_recent(app: &mut WordApp, path: &str) -> Result<(), String> { - app.run("file.open", json!({"path": path}))?; +pub(crate) fn open_recent(app: &mut WordApp, path: &str) -> Result<(), String> { + app.open_file(path, None)?; app.ui.backstage = false; app.canvas.want_focus = true; Ok(()) diff --git a/crates/ui-egui/src/documents.rs b/crates/ui-egui/src/documents.rs index 601dd3c8f..c843e19d2 100644 --- a/crates/ui-egui/src/documents.rs +++ b/crates/ui-egui/src/documents.rs @@ -102,6 +102,22 @@ impl WordApp { seen } + /// Open the file at `path` for the window showing document `into` (`None`: the active + /// document's window). Every way of opening a file comes through here, so each follows the + /// same rule: an untouched blank document is replaced in its own window, else the file opens + /// in a window of its own (or its window comes forward if it is already open). A file a + /// dialog picked arrives while any window draws, so the document it was asked from is made + /// active again first. + pub fn open_file(&mut self, path: &str, into: Option) -> Result { + if let Some(id) = into + && id != self.session.document_id() + && self.session.is_open(id) + { + self.show_document(id)?; + } + self.run("file.open", json!({"path": path})) + } + /// Show open document `id` in the window (View › Window › Switch Windows, quitting). pub(crate) fn show_document(&mut self, id: u64) -> Result<(), String> { self.session.activate(id).map_err(|e| e.to_string())?; diff --git a/crates/ui-egui/src/file_browser.rs b/crates/ui-egui/src/file_browser.rs index 502d6a3aa..5c5765506 100644 --- a/crates/ui-egui/src/file_browser.rs +++ b/crates/ui-egui/src/file_browser.rs @@ -286,7 +286,7 @@ impl WordApp { FileDialogRequest::Save { name: self.default_save_name(), dir: None }, AfterPick::SaveAs { after: crate::file_dialogs::AfterSave::Nothing, document: self.session.document_id() }, ), - _ => (FileDialogRequest::Open { purpose: "document".into() }, AfterPick::OpenDocument), + _ => (FileDialogRequest::Open { purpose: "document".into() }, AfterPick::OpenDocument { document: self.session.document_id() }), }; self.show_file_browser(req, after, false); } @@ -529,7 +529,7 @@ mod tests { std::fs::create_dir(dir.join("drafts")).unwrap(); std::fs::write(dir.join("drafts").join("old.docx"), b"x").unwrap(); let req = FileDialogRequest::Save { name: "Document1.docx".into(), dir: None }; - let mut b = FileBrowser::new(req, AfterPick::OpenDocument, dir.clone(), false); + let mut b = FileBrowser::new(req, AfterPick::OpenDocument { document: 0 }, dir.clone(), false); b.name = "drafts".into(); assert_eq!(b.accept(), Step::Stay); assert_eq!(b.dir, dir.join("drafts")); @@ -577,7 +577,8 @@ mod tests { fn an_empty_folder_says_there_are_no_items() { let dir = scratch("empty_state"); let mut a = WordApp::new(Session::new(wordcraft_doc::Document::new()), Services::default()); - let mut b = FileBrowser::new(FileDialogRequest::Open { purpose: "document".into() }, AfterPick::OpenDocument, dir.clone(), false); + let mut b = + FileBrowser::new(FileDialogRequest::Open { purpose: "document".into() }, AfterPick::OpenDocument { document: 0 }, dir.clone(), false); b.navigate(dir.clone()); assert!(matches!(&b.listing, Ok(l) if l.entries.is_empty()), "the folder starts empty"); a.file_browser.open = Some(Box::new(b)); diff --git a/crates/ui-egui/src/file_dialogs.rs b/crates/ui-egui/src/file_dialogs.rs index 2ccb8530f..7f375e8c1 100644 --- a/crates/ui-egui/src/file_dialogs.rs +++ b/crates/ui-egui/src/file_dialogs.rs @@ -56,8 +56,8 @@ pub fn open_filters(purpose: &str) -> &'static [(&'static str, &'static [&'stati /// What to do with the path picked in a dialog. pub(crate) enum AfterPick { - /// Open the picked document. - OpenDocument, + /// Open the picked document from the window showing `document` ([`WordApp::open_file`]). + OpenDocument { document: u64 }, /// Insert the picked picture, or replace the pending Change Picture target. Picture, /// Save the document there, then carry on. @@ -242,9 +242,9 @@ impl WordApp { /// Do what the dialog was for with the picked path (`None`: the user cancelled). pub(crate) fn finish_pick(&mut self, after: AfterPick, picked: Option) -> Result { match after { - AfterPick::OpenDocument => { + AfterPick::OpenDocument { document } => { let Some(path) = picked else { return Ok(json!({"cancelled": true})) }; - let r = self.run("file.open", json!({"path": path})); + let r = self.open_file(&path, Some(document)); self.ui.backstage = false; r } @@ -450,7 +450,8 @@ impl WordApp { f("document"); return; } - let _ = self.ask_file(FileDialogRequest::Open { purpose: "document".into() }, AfterPick::OpenDocument); + let _ = + self.ask_file(FileDialogRequest::Open { purpose: "document".into() }, AfterPick::OpenDocument { document: self.session.document_id() }); } pub(crate) fn pick_picture(&mut self) { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index cb1af01b3..971674b4e 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -2479,7 +2479,7 @@ impl WordApp { } else if wordcraft_fonts::is_font_file_name(&lp) { self.run("font.add", json!({"path": path})) } else { - self.run("file.open", json!({"path": path})) + self.open_file(&path, None) }; } } diff --git a/crates/ui-egui/src/windows.rs b/crates/ui-egui/src/windows.rs index b83a5e207..0031b2085 100644 --- a/crates/ui-egui/src/windows.rs +++ b/crates/ui-egui/src/windows.rs @@ -1251,4 +1251,66 @@ mod tests { a.run("ui.saveChanges", json!({"answer": "dontSave"})).unwrap(); assert!(a.quit_requested, "one prompt for the document"); } + + /// Two windows, the second (focused) on an untouched blank document: an app whose host + /// answers file dialogs only when the test does, plus the dialog's answer channel. + fn blank_beside_a_document() -> (egui::Context, WordApp, Shown, u64) { + let shown: Shown = std::rc::Rc::default(); + let log = shown.clone(); + let services = Services { + file_dialog: Some(Box::new(move |_| { + let (tx, rx) = std::sync::mpsc::channel(); + log.borrow_mut().push(tx); + rx + })), + ..Default::default() + }; + let ctx = backend(); + let mut a = WordApp::new(Session::new(wordcraft_doc::Document::new()), services); + frames(&ctx, &mut a, 2); + a.run("file.open", json!({"path": docx_on_disk("first", "First").to_string_lossy()})).unwrap(); + a.run("file.new", json!({})).unwrap(); + frames(&ctx, &mut a, 2); + assert!(a.window_count() == 2 && a.session.is_untouched_blank()); + let blank = a.windows.shown(); + (ctx, a, shown, blank) + } + + type Shown = std::rc::Rc>>>; + + /// Each way of opening a file replaces an untouched blank document in its own window, even + /// when the file arrives while another window draws: File › Open, ⌘O and the File page's + /// Browse (an asynchronous dialog), the File page's and Start screen's recent list, and + /// Finder (between frames, in the focused window). + #[test] + fn every_open_replaces_an_untouched_blank_document_in_its_window() { + let second = docx_on_disk("second", "Second"); + let path = second.to_string_lossy().to_string(); + let opens: [(&str, fn(&mut WordApp, &egui::Context, &Shown, &str)); 3] = [ + ("dialog", |a, ctx, shown, path| { + a.run("ui.backstage", json!({"value": true, "page": "open"})).unwrap(); + a.run("ui.openFileDialog", json!({})).unwrap(); + frames(ctx, a, 2); + let tx = shown.borrow_mut().pop().unwrap(); + tx.send(Ok(vec![path.to_string()])).unwrap(); + frames(ctx, a, 3); + }), + ("recent", |a, ctx, _, path| { + crate::backstage::open_recent(a, path).unwrap(); + frames(ctx, a, 2); + }), + ("finder", |a, ctx, _, path| { + a.open_file(path, None).unwrap(); + frames(ctx, a, 2); + }), + ]; + for (how, open) in opens { + let (ctx, mut a, shown, blank) = blank_beside_a_document(); + open(&mut a, &ctx, &shown, &path); + assert_eq!(a.window_count(), 2, "{how}: no third window"); + assert!(a.enter_window(blank), "{how}: the blank document's window is still open"); + assert_eq!(text(&a), "Second", "{how}: it shows the opened file"); + } + std::fs::remove_dir_all(second.parent().unwrap()).ok(); + } }