diff --git a/Cargo.lock b/Cargo.lock index 538b4542f..3887eda6e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -894,6 +894,15 @@ dependencies = [ "egui", ] +[[package]] +name = "craft-window" +version = "0.1.0" +source = "git+https://github.com/aiongg/craft-libs?rev=4fbdea5fcf86ba893d9bc03685d4531cfdd4efc2#4fbdea5fcf86ba893d9bc03685d4531cfdd4efc2" +dependencies = [ + "egui", + "wayland-client", +] + [[package]] name = "crc32fast" version = "1.5.2" @@ -5401,6 +5410,7 @@ version = "0.9.0" dependencies = [ "cfb", "craft-ui", + "craft-window", "egui", "egui_kittest", "image", diff --git a/apps/wordcraft/src/apple_events.rs b/apps/wordcraft/src/apple_events.rs index 71d0d05b4..64f534392 100644 --- a/apps/wordcraft/src/apple_events.rs +++ b/apps/wordcraft/src/apple_events.rs @@ -55,7 +55,12 @@ pub fn poll(inbox: &Inbox, app: &mut WordApp, ctx: &egui::Context) { } } } - Event::Quit => ctx.send_viewport_cmd(egui::ViewportCommand::Close), + // Quitting asks about every document with unsaved changes, each in its window; the + // app closes once that's done (`quit_requested`). + Event::Quit => { + app.quit(); + ctx.request_repaint(); + } } } } diff --git a/apps/wordcraft/src/main.rs b/apps/wordcraft/src/main.rs index 85410340d..094ad6c20 100644 --- a/apps/wordcraft/src/main.rs +++ b/apps/wordcraft/src/main.rs @@ -54,8 +54,9 @@ impl eframe::App for App { ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(size)); ctx.send_viewport_cmd(egui::ViewportCommand::OuterPosition(pos)); } + // The geometry saved for the next start is the active window's. let prev = self.0.ui.window; - self.0.ui.window = ctx.input(|i| WindowGeometry::track(prev, i.viewport(), i.viewport_rect().size())); + self.0.ui.window = ctx.input_for(self.0.active_viewport(), |i| WindowGeometry::track(prev, i.viewport(), i.viewport_rect().size())); // The first frame, once the monitor is known (as `take_rescue` waits for): maximize a // window that opened at its normal size so it never flashed on screen half-opened. let maximize = self.1.and_then(|g| g.maximize_after_open()).filter(|_| ctx.input(|i| i.viewport().monitor_size.is_some())); @@ -65,9 +66,11 @@ impl eframe::App for App { if let Some(command) = maximize { ctx.send_viewport_cmd(command); } - // The window's close button: with unsaved changes, ask first (`WordApp::close_requested`). + // The window's close button: with other windows open only this window closes (the app + // handles that while drawing it); the last one quits, asking first about unsaved changes + // (`WordApp::close_requested`). let closing = ctx.input(|i| i.viewport().close_requested()); - if closing && !self.0.close_requested() { + if closing && (self.0.closes_a_window() || !self.0.close_requested()) { ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose); } else if (closing || self.0.quit_requested) && !self.0.printing.busy() { // On macOS, letting eframe drop the window inside `-[NSApplication run]` aborts in @@ -397,6 +400,8 @@ fn main() -> eframe::Result { #[cfg(target_os = "macos")] graphics::configure_indirect_validation(&mut options, graphics::skips_indirect_validation(true, cfg!(target_arch = "x86_64"))); load_user_fonts(); + // Windows opened for further documents start like the first one, before its saved place. + let window_builder = options.viewport.clone(); let restored = saved_window(); if let Some(window) = restored { options.viewport = window.apply(options.viewport); @@ -447,6 +452,7 @@ fn main() -> eframe::Result { } } app.ui.window = restored; + app.set_window_builder(window_builder); app.integrated_titlebar = cfg!(target_os = "macos"); #[cfg(target_os = "macos")] if let Some(bar) = traffic_lights::title_bar() { diff --git a/crates/engine/src/session/documents.rs b/crates/engine/src/session/documents.rs index d302aa5ff..8d5c2fbbc 100644 --- a/crates/engine/src/session/documents.rs +++ b/crates/engine/src/session/documents.rs @@ -400,6 +400,14 @@ impl Session { Ok(()) } + /// Make document `id` the active one, as [`Session::activate`] does, but a Read Aloud + /// reading of another document carries on: for a front end that shows each document in a + /// window of its own, and makes each window's document active to draw it. An id that isn't + /// open is an error. + pub fn switch_document(&mut self, id: u64) -> Result<(), CmdError> { + self.switch_to(id) + } + /// Open `doc` as another document and make it active; the one that was active stays open. /// The new document starts as a replaced one does ([`Session::set_document`]): caret at the /// start, no undo history, not dirty, no path, an untitled name (`Document2`), and the view diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 48ed41219..65d3e1056 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -10,6 +10,8 @@ workspace = true [dependencies] craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "d27646a5f7c8b4fc85d9dd3213b8c88b58e0088d" } +# One native window per open document: the window list, the Window menu and the egui viewports. +craft-window = { git = "https://github.com/aiongg/craft-libs", rev = "4fbdea5fcf86ba893d9bc03685d4531cfdd4efc2", features = ["egui"] } wordcraft-geom = { workspace = true } wordcraft-doc = { workspace = true } unicode-bidi = { workspace = true } diff --git a/crates/ui-egui/src/backstage.rs b/crates/ui-egui/src/backstage.rs index 6c606dec6..cceb2b5fa 100644 --- a/crates/ui-egui/src/backstage.rs +++ b/crates/ui-egui/src/backstage.rs @@ -325,7 +325,7 @@ fn home_page(app: &mut WordApp, ui: &mut Ui) { fn new_page(app: &mut WordApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); heading(ui, "New"); - let id = egui::Id::new("backstage_template_search"); + let id = app.window_id("backstage_template_search"); let mut query = ui.ctx().data(|d| d.get_temp::(id)).unwrap_or_default(); let search = ui.add(egui::TextEdit::singleline(&mut query).hint_text(tl!("Search for templates")).desired_width(360.0)); if search.changed() { @@ -1073,14 +1073,15 @@ mod tests { assert!(h.query_by_label("Report").is_some(), "the built-in templates stay"); // The search covers both. - h.ctx.data_mut(|d| d.insert_temp(egui::Id::new("backstage_template_search"), "memo".to_string())); + let search = h.state().window_id("backstage_template_search"); + h.ctx.data_mut(|d| d.insert_temp(search, "memo".to_string())); h.run_steps(3); assert!(h.query_by_label("Team memo").is_some(), "search finds a personal template"); assert!(h.query_by_label("Contract").is_none() && h.query_by_label("Report").is_none()); - h.ctx.data_mut(|d| d.insert_temp(egui::Id::new("backstage_template_search"), "zzz".to_string())); + h.ctx.data_mut(|d| d.insert_temp(search, "zzz".to_string())); h.run_steps(3); assert!(h.query_by_label("No templates match your search.").is_some()); - h.ctx.data_mut(|d| d.insert_temp(egui::Id::new("backstage_template_search"), String::new())); + h.ctx.data_mut(|d| d.insert_temp(search, String::new())); h.run_steps(3); h.get_by_label("Team memo").click(); diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 2803ef2ef..112e08e90 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -49,7 +49,7 @@ fn err(e: impl std::fmt::Display) -> Outcome { } pub fn inspect(app: &mut WordApp, ctx: &egui::Context) -> Value { - let r = ctx.content_rect(); + let r = ctx.input_for(app.active_viewport(), |i| i.content_rect()); let caret = crate::canvas::caret_screen(app).map(|(p, h)| json!({"x": p.x, "y": p.y, "h": h})); json!({ "ui": app.prefs(), @@ -358,11 +358,11 @@ pub fn handle(app: &mut WordApp, ctx: &egui::Context, req: &ControlRequest) -> O } "ui.resize" => { let (Some(w), Some(h)) = (f("width"), f("height")) else { return err("missing width/height") }; - ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(egui::vec2(w, h))); + ctx.send_viewport_cmd_to(app.active_viewport(), egui::ViewportCommand::InnerSize(egui::vec2(w, h))); ok(json!({})) } "ui.focus" => { - ctx.send_viewport_cmd(egui::ViewportCommand::Focus); + ctx.send_viewport_cmd_to(app.active_viewport(), egui::ViewportCommand::Focus); app.canvas.want_focus = true; ok(json!({})) } diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 3a945bf07..4512a542d 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -3763,7 +3763,8 @@ const SEARCH_SUGGESTIONS: [&str; 3] = ["review.spelling", "review.wordCount", "f /// Search's "Find … in the document": the Navigation pane opens on its results for `text`. fn find_in_navigation_pane(app: &mut WordApp, ctx: &egui::Context, text: &str) { - ctx.data_mut(|d| d.insert_temp(egui::Id::new("nav_query"), text.to_string())); + let qid = app.window_id("nav_query"); + ctx.data_mut(|d| d.insert_temp(qid, text.to_string())); app.ui.nav_tab = "results".into(); let _ = app.run("view.navigationPane", json!({"value": true})); let _ = app.run("edit.find", json!({"text": text})); diff --git a/crates/ui-egui/src/documents.rs b/crates/ui-egui/src/documents.rs index a6b5344cb..5f8645ccf 100644 --- a/crates/ui-egui/src/documents.rs +++ b/crates/ui-egui/src/documents.rs @@ -1,10 +1,11 @@ -//! Several open documents in one window. The window shows the session's active document; what -//! it keeps about a document (AutoSave's file, comment drafts) follows the active one, and the -//! window's own state about the page (drags, menus, the composition…) starts afresh when the -//! document changes ([`WordApp::sync_document`]; per-window state is listed in +//! Several open documents. On the desktop each has a window of its own ([`crate::windows`]); the +//! web app shows them in one window, which shows the session's active document. What the app +//! keeps about a document (AutoSave's file, comment drafts) follows the active one. In one +//! window, the window's own state about the page (drags, menus, the composition…) starts afresh +//! when the document changes ([`WordApp::sync_document`]; per-window state is listed in //! [`crate::window_ui`]). View › Window › Switch Windows lists the open documents, File › Close -//! closes the shown one and shows the next, and quitting asks about every document with unsaved -//! changes, one after another. +//! closes the shown one, and quitting asks about every document with unsaved changes, one after +//! another, each in its window. use serde_json::{Value, json}; use wordcraft_engine::DocInfo; @@ -28,23 +29,22 @@ impl WordApp { /// dialogs and menus, drags, the input method's composition, the status message, caches /// keyed by revision. Its scroll position and zoom come back with it (the scroll area is per /// document; the zoom is in the session's view). Runs after every command and every frame. + /// With native windows ([`crate::windows`]) the window that shows the document comes to the + /// front instead, or a new one opens for it. pub(crate) fn sync_document(&mut self) { let id = self.session.document_id(); + if self.windows.native() { + self.prune_windows(); + } if id == self.shown_document { return; } - let left = std::mem::replace(&mut self.shown_document, id); - let leaving = Parked { autosave_path: self.autosave_path.take(), comment_drafts: std::mem::take(&mut self.comment_drafts) }; - if self.session.is_open(left) { - self.parked.insert(left, leaving); + if self.windows.native() && self.follow_in_windows(id) { + return; } - let back = self.parked.remove(&id); - let seen = back.is_some(); - let back = back.unwrap_or_default(); - self.autosave_path = back.autosave_path; - self.comment_drafts = back.comment_drafts; - let session = &self.session; - self.parked.retain(|d, _| session.is_open(*d)); + let left = std::mem::replace(&mut self.shown_document, id); + let seen = self.carry_document_state(left, id); + self.window_shows(id); self.canvas.show_other_document(seen); self.controls = Default::default(); @@ -76,6 +76,32 @@ impl WordApp { } } + /// The active document is `to` and no longer `from`: `from`'s own state is put aside (or + /// dropped, if it was closed or replaced) and `to`'s comes back. Makes `to` the session's + /// active document. Returns whether `to` had state put aside. + pub(crate) fn carry_document_state(&mut self, from: u64, to: u64) -> bool { + if self.session.document_id() != to + && let Err(e) = self.session.switch_document(to) + { + log::error!("couldn't show document {to}: {e}"); + } + if from == to { + return false; + } + let leaving = Parked { autosave_path: self.autosave_path.take(), comment_drafts: std::mem::take(&mut self.comment_drafts) }; + if self.session.is_open(from) { + self.parked.insert(from, leaving); + } + let back = self.parked.remove(&to); + let seen = back.is_some(); + let back = back.unwrap_or_default(); + self.autosave_path = back.autosave_path; + self.comment_drafts = back.comment_drafts; + let session = &self.session; + self.parked.retain(|d, _| session.is_open(*d)); + seen + } + /// 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())?; @@ -153,13 +179,18 @@ pub(crate) fn untitled_title(english: &str) -> Option { }) } -/// View › Window › Switch Windows: the open documents in the order they were opened, the shown -/// one marked, unsaved ones with the title bar's dot. Choosing one shows it. +/// View › Window › Switch Windows: the open windows (in one window, the open documents) in the +/// order they were opened, this one marked, unsaved ones with the title bar's dot. Choosing one +/// brings its window to the front (in one window, shows the document). pub(crate) fn switch_windows_menu(ui: &mut egui::Ui, app: &mut WordApp) { - for d in app.session.documents() { - let label = format!("{}{}", display_title(&d), if d.dirty { " •" } else { "" }); - if ui.add(egui::Button::selectable(d.active, label).min_size(egui::vec2(220.0, 0.0))).clicked() { - let _ = app.run("view.switchWindows", json!({"id": d.id})); + let entries: Vec<(u64, String, bool)> = if app.windows.native() { + app.switch_windows_entries() + } else { + app.session.documents().into_iter().map(|d| (d.id, format!("{}{}", display_title(&d), if d.dirty { " •" } else { "" }), d.active)).collect() + }; + for (doc, label, current) in entries { + if ui.add(egui::Button::selectable(current, label).min_size(egui::vec2(220.0, 0.0))).clicked() { + let _ = app.run("view.switchWindows", json!({"id": doc})); ui.close(); } } @@ -268,11 +299,12 @@ mod tests { assert!(a.close_requested()); } - /// A request an agent's command makes for a document that isn't shown never acts on the - /// shown one: Print doesn't open Backstage over it. + /// In one window (the web app), a request an agent's command makes for a document that isn't + /// shown never acts on the shown one: Print doesn't open Backstage over it. #[test] fn requests_for_a_document_not_shown_leave_the_window_alone() { let mut a = app(); + a.windows.single(); let first = a.session.document_id(); a.run("file.new", json!({})).unwrap(); a.session.with_document(first, |s| s.run("file.print", &json!({}))).unwrap().unwrap(); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 66ab2ea65..0f1ccf9b7 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -87,6 +87,7 @@ pub mod widgets; pub mod window_close; pub mod window_geometry; mod window_ui; +mod windows; pub mod wordart_menus; pub mod zotero; @@ -367,6 +368,9 @@ pub struct UiState { /// no longer applies (a contextual tab after the selection moved away, a tab hidden in Customize /// Ribbon). Word opens on Home every launch; see [`UiState::tab`]. pub const DEFAULT_TAB: &str = "Home"; + +/// ⌘Q, which only the Mac sends: quit. +const QUIT: egui::KeyboardShortcut = egui::KeyboardShortcut::new(egui::Modifiers::MAC_CMD, egui::Key::Q); /// Most recent documents kept (File › Options › Advanced offers 0–50, as Word does). pub const MAX_RECENT: usize = 50; /// Most custom dictionary words read back from `ui.json`. @@ -667,6 +671,10 @@ pub struct WordApp { shown_document: u64, /// The per-document state of the open documents not shown. parked: std::collections::HashMap, + /// The open windows ([`windows`]). + pub(crate) windows: windows::Windows, + /// The document the last print job was for: its window reports how the job went. + print_document: Option, } /// The answer to "Do you want to save changes?" (`ui.saveChanges`). @@ -797,6 +805,8 @@ impl WordApp { no_document: None, comment_drafts: Default::default(), parked: Default::default(), + windows: windows::Windows::new(shown_document), + print_document: None, canvas, controls, dialog, @@ -1442,9 +1452,10 @@ impl WordApp { self.quit_requested } - /// File › Close (Mod+W, Mod+F4) closes the shown document and keeps the window, as Word does - /// (#1712): the next open document shows, or, when it was the only one, no page, as after Esc - /// on the Start screen (#1129), and File › New or Open goes on from there. `{"quit": true}`, + /// File › Close (Mod+W, Mod+F4) closes the shown document. Its window closes too while other + /// windows are open, and the one focused before it comes forward (in the web app's one window, + /// the next document shows). The last window stays, as Word's does (#1712), with no page, as + /// after Esc on the Start screen (#1129), and File › New or Open goes on from there. `{"quit": true}`, /// from the window's close button, goes on quitting: the shown document's prompt has been /// answered, so it closes and the next document with unsaved changes is asked about /// ([`WordApp::ask_before_quit`]). With no document open there is nothing to close. Unsaved @@ -1465,21 +1476,23 @@ impl WordApp { self.quit_requested = true; return Ok(json!({"quit": true})); } - self.session.close_document(self.session.document_id()).map_err(|e| e.to_string())?; - self.sync_document(); + self.close_shown_document()?; return Ok(self.ask_before_quit()); } if !self.has_document() { return Ok(json!({"closed": false})); } - self.session.close_document(self.session.document_id()).map_err(|e| e.to_string())?; + self.close_shown_document()?; self.recovery_seen = true; - self.sync_document(); if last { self.no_document = Some(self.session.document_id()); self.session.release_untitled_name(); } - self.ui.backstage = false; + // The window that stays leaves the File page; another window that came forward keeps + // its own. + if last || !self.windows.native() { + self.ui.backstage = false; + } Ok(json!({"closed": true})) } @@ -1528,12 +1541,16 @@ impl WordApp { } /// Requests commands make of the UI (open a dialog…). - fn handle_request(&mut self, req: &Value) { - // Made for a document that isn't shown (an agent's command run there). + pub(crate) fn handle_request(&mut self, req: &Value) { + // Made for a document that isn't shown (an agent's command run there): its window handles + // it when it draws. if let Some(document) = req.get("document").and_then(Value::as_u64) && document != self.session.document_id() { - self.handle_request_for(document, req); + let app_wide = req.get("interfaceTheme").is_some() || req.get("toggle").and_then(Value::as_str) == Some("interfaceTheme"); + if app_wide || req.get("close").is_some() || !self.queue_for_window(document, req) { + self.handle_request_for(document, req); + } return; } // Ctrl+F: type straight into the Navigation pane's search box (#712). @@ -1581,10 +1598,10 @@ impl WordApp { } } - /// A request made for document `document`, which isn't shown: the interface theme is the - /// app's and changes; Close closes that document if it has nothing unsaved; the rest (dialogs, - /// panes, focus, scrolling) are about the window's page and are dropped, as the page shows - /// another document. + /// A request made for document `document`, which isn't shown and which no other window + /// handles: the interface theme is the app's and changes; Close closes that document if it + /// has nothing unsaved; the rest (dialogs, panes, focus, scrolling) are about a window's page + /// and are dropped, as no window shows the document. fn handle_request_for(&mut self, document: u64, req: &Value) { let theme_only = req.get("interfaceTheme").is_some() || req.get("toggle").and_then(Value::as_str) == Some("interfaceTheme"); if theme_only { @@ -1605,7 +1622,7 @@ impl WordApp { self.sync_document(); return; } - log::warn!("dropped a request for document {document}, which isn't shown: {req}"); + log::warn!("dropped a request for document {document}, which no window shows: {req}"); } /// Follow the desktop's light/dark choice while the app runs (Linux, where winit reports no @@ -1936,6 +1953,7 @@ impl WordApp { /// Submit the selected print job; the implementation lives beside its localized controls. fn print_to_system(&mut self, p: &Value) -> Result { + self.print_document = Some(self.session.document_id()); printing::submit(self, p) } @@ -2189,6 +2207,11 @@ impl WordApp { /// Terminal feedback also reaches the normal window when printing starts through control. pub fn poll_printing(&mut self, ctx: &egui::Context) { self.printing.poll(ctx); + self.print_feedback(); + } + + /// Say how the print job went, once, in the window showing the printed document. + fn print_feedback(&mut self) { if self.printing.message == self.print_feedback_seen { return; } @@ -2228,17 +2251,6 @@ impl WordApp { } options::sync(self); self.settle_async_saves(); - // SmartArt Text Pane (#2701): Tab demotes and Shift+Tab promote the focused item, as Word's - // pane does. Consuming the key stops Tab from moving the caret to the next row, and so does - // resetting the focus direction: the item keeps focus after its level changes. - if self.session.view.smart_art_pane && self.smart_art_row_focused && self.smart_art_item.is_some() { - let shift_tab = ctx.input_mut(|i| i.consume_key(egui::Modifiers::SHIFT, egui::Key::Tab)); - let tab = ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Tab)); - if shift_tab || tab { - ctx.memory_mut(|m| m.move_focus(egui::FocusDirection::None)); - let _ = self.run(if shift_tab { "smartArt.promote" } else { "smartArt.demote" }, json!({"index": self.smart_art_item.unwrap_or(0)})); - } - } // Chinese text wants the Chinese face before the Japanese one (one glyph style per line). // An installed CJK font is read only once CJK text is shown (#241): a CJK interface, the // language names in Options, or a file name the caption is drawing. A file opened later @@ -2294,12 +2306,16 @@ impl WordApp { if self.ctx.is_none() { self.ctx = Some(ctx.clone()); } - self.poll_printing(ctx); + let native = self.windows.native(); + if native { + self.printing.poll(ctx); + } else { + self.poll_printing(ctx); + } self.drain_control(ctx); - zotero::poll(self, ctx); - read_aloud::poll(self, ctx); - self.clear_stale_change_picture(); - let _ = self.poll_file_dialog(); + // The active window's own work; with native windows each window does it again as it + // draws, for itself. + self.window_work(ctx); self.drain_inbox(); self.drain_pasted(); self.watch_fonts(ctx); @@ -2321,15 +2337,62 @@ impl WordApp { if let Some(wait) = self.session.recovery_wait() { ctx.request_repaint_after(wait); } + if !native { + self.window_input(ctx); + } + self.issue_screenshots(ctx); + self.send_control_input(ctx); + if !self.synthetic.is_empty() { + ctx.request_repaint(); + } + // The open above may have changed the file name the caption draws. + self.ensure_caption_fonts(); + self.ensure_comment_fonts(); + self.ensure_recent_fonts(); + self.ensure_find_fonts(); + self.install_ui_fonts(ctx, lang); + self.report_dirty(); + } + + /// A window's own work, with its state in place: print feedback for its document, Zotero, + /// Read Aloud, its file dialog, AutoSave of its document. Runs in [`WordApp::logic`] for the + /// active window and, with native windows, as each window draws. + fn window_work(&mut self, ctx: &egui::Context) { + if self.windows.native() { + let doc = self.session.document_id(); + if self.print_document.is_none_or(|d| d == doc || !self.session.is_open(d)) { + self.print_feedback(); + } + } + zotero::poll(self, ctx); + read_aloud::poll(self, ctx); + self.clear_stale_change_picture(); + let _ = self.poll_file_dialog(); self.autosave_tick(now_ms()); if self.autosaves() && self.session.dirty { ctx.request_repaint_after(std::time::Duration::from_millis(1000)); } - self.collect_screenshots(ctx); - self.issue_screenshots(ctx); - if !self.synthetic.is_empty() { - ctx.request_repaint(); + } + + /// A window's own input before it draws: the SmartArt Text Pane's Tab keys, ⌘Q, screenshots + /// of it, files dropped on it. Runs in [`WordApp::logic`] with one window, else as each + /// window draws (each native window has its own input). + fn window_input(&mut self, ctx: &egui::Context) { + // SmartArt Text Pane (#2701): Tab demotes and Shift+Tab promote the focused item, as Word's + // pane does. Consuming the key stops Tab from moving the caret to the next row, and so does + // resetting the focus direction: the item keeps focus after its level changes. + if self.session.view.smart_art_pane && self.smart_art_row_focused && self.smart_art_item.is_some() { + let shift_tab = ctx.input_mut(|i| i.consume_key(egui::Modifiers::SHIFT, egui::Key::Tab)); + let tab = ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Tab)); + if shift_tab || tab { + ctx.memory_mut(|m| m.move_focus(egui::FocusDirection::None)); + let _ = self.run(if shift_tab { "smartArt.promote" } else { "smartArt.demote" }, json!({"index": self.smart_art_item.unwrap_or(0)})); + } } + if self.windows.native() && ctx.input_mut(|i| i.consume_shortcut(&QUIT)) { + self.quit(); + } + self.collect_screenshots(ctx); #[cfg(not(target_arch = "wasm32"))] let mut placed_picture = false; #[cfg(not(target_arch = "wasm32"))] @@ -2354,13 +2417,12 @@ impl WordApp { }; } } - // The open above may have changed the file name the caption draws. - self.ensure_caption_fonts(); - self.ensure_comment_fonts(); - self.ensure_recent_fonts(); - self.ensure_find_fonts(); - self.install_ui_fonts(ctx, lang); - self.report_dirty(); + // The drawn window's caption, comments and Find results borrow faces it may not have. + if self.windows.native() { + self.ensure_caption_fonts(); + self.ensure_comment_fonts(); + self.ensure_find_fonts(); + } } /// Install the interface fonts when the language, the CJK fallback, or a caption fallback @@ -2369,17 +2431,29 @@ impl WordApp { let system_cjk = self.fonts_system_cjk || self.want_system_cjk || lang.uses_cjk(); if !self.styled || lang.prefers_hans() != self.fonts_hans || lang.prefers_korean() != self.fonts_korean || system_cjk != self.fonts_system_cjk { - theme::install_fonts_language_extra(ctx, lang.prefers_hans(), lang.prefers_korean(), system_cjk, &self.canvas.ime_faces); + // Every window's borrowed faces: the fonts are the app's. + let mut faces = self.canvas.ime_faces.clone(); + for w in self.other_windows() { + for face in &w.canvas.ime_faces { + if !faces.iter().any(|f| f.id() == face.id()) { + faces.push(face.clone()); + } + } + } + theme::install_fonts_language_extra(ctx, lang.prefers_hans(), lang.prefers_korean(), system_cjk, &faces); self.fonts_hans = lang.prefers_hans(); self.fonts_korean = lang.prefers_korean(); self.fonts_system_cjk = system_cjk; // Mod with -, = and 0 are Word shortcuts (optional hyphen, subscript, paragraph spacing); // egui's keyboard zoom would also scale the whole window on them. Zoom is View › Zoom. // egui quits on Command-Q, which is Ctrl+Q off the Mac: Word's Reset Paragraph - // Formatting there (#1218). Keep ⌘Q, which only the Mac sends. + // Formatting there (#1218). Keep ⌘Q, which only the Mac sends. egui would close only the + // window it was pressed in, so with native windows the app quits itself + // ([`WordApp::window_logic_before`]). + let quit = (!self.windows.native()).then_some(QUIT).into_iter().collect(); ctx.options_mut(|o| { o.zoom_with_keyboard = false; - o.quit_shortcuts = vec![egui::KeyboardShortcut::new(egui::Modifiers::MAC_CMD, egui::Key::Q)]; + o.quit_shortcuts = quit; }); self.styled = true; } @@ -2447,23 +2521,17 @@ impl WordApp { } /// Read a Control-click as the right-click on macOS, then inject synthetic input events from - /// the control channel (one pointer event per frame). + /// the control channel (one pointer event per frame) when the active window draws in the root + /// viewport; [`windows::ViewportInput`] does both for the other windows. pub fn raw_input_hook(&mut self, raw: &mut egui::RawInput) { if cfg!(target_os = "macos") { control_click_as_secondary(&mut raw.events, &mut self.control_click); } - if self.synthetic.is_empty() { + if self.ctx.as_ref().is_some_and(|ctx| !self.control_input_is_roots(ctx)) { return; } - let n = match self.synthetic.first() { - Some(egui::Event::PointerMoved(_) | egui::Event::PointerButton { .. }) => 1, - _ => self.synthetic.iter().position(|e| matches!(e, egui::Event::Key { pressed: false, .. })).map_or(self.synthetic.len(), |i| i + 1), - }; - if let Some(egui::Event::PointerMoved(p) | egui::Event::PointerButton { pos: p, .. }) = self.synthetic.first() { - raw.events.push(egui::Event::PointerMoved(*p)); - } - let n = n.min(self.synthetic.len()); - if self.synthetic.iter().take(n).any(|e| matches!(e, egui::Event::Key { .. } | egui::Event::Text(_))) { + let Some(events) = self.next_synthetic() else { return }; + if events.iter().any(|e| matches!(e, egui::Event::Key { .. } | egui::Event::Text(_))) { // Control input belongs to this window's remembered widget even while another app // is active. Give only this input frame egui focus, without requesting OS activation. raw.focused = true; @@ -2472,11 +2540,44 @@ impl WordApp { self.canvas.focused = ctx.memory(|m| m.has_focus(id)); } } - raw.events.extend(self.synthetic.drain(..n)); + raw.events.extend(events); } - /// Lay out the window. + /// The control channel's next input frame: one pointer event (after a move to it), or keys + /// and text up to the next key release. + fn next_synthetic(&mut self) -> Option> { + if self.synthetic.is_empty() { + return None; + } + let n = match self.synthetic.first() { + Some(egui::Event::PointerMoved(_) | egui::Event::PointerButton { .. }) => 1, + _ => self.synthetic.iter().position(|e| matches!(e, egui::Event::Key { pressed: false, .. })).map_or(self.synthetic.len(), |i| i + 1), + }; + let mut events = Vec::new(); + if let Some(egui::Event::PointerMoved(p) | egui::Event::PointerButton { pos: p, .. }) = self.synthetic.first() { + events.push(egui::Event::PointerMoved(*p)); + } + let n = n.min(self.synthetic.len()); + events.extend(self.synthetic.drain(..n)); + Some(events) + } + + /// Lay out the windows: on the desktop each open document's in its own native window + /// ([`windows`]), on the web the one window. pub fn ui(&mut self, ui: &mut egui::Ui) { + if self.windows.native() { + self.windows_ui(ui); + } else { + self.window_ui(ui); + } + } + + /// Lay out the drawn window. A command that brings another window's state in (New, Open, + /// Switch Windows) ends this window's drawing for the frame: the rest would be the other + /// window's. + fn window_ui(&mut self, ui: &mut egui::Ui) { + let window = self.windows.shown(); + let moved = |app: &WordApp| app.windows.shown() != window; let t0 = now_ms(); let ctx = ui.ctx().clone(); i18n::set_current(i18n::Lang::from_pref(&self.ui.language)); @@ -2517,38 +2618,58 @@ impl WordApp { chrome::screen_bar(self, ui, screen); } else { chrome::title_bar(self, ui); + if moved(self) { + return; + } ribbon::show(self, ui); + if moved(self) { + return; + } chrome::status_bar(self, ui); panes::show(self, ui); } + if moved(self) { + return; + } let fill = if screen == chrome::Screen::Focus { chrome::FOCUS_CANVAS } else { t.canvas }; egui::CentralPanel::default().frame(egui::Frame::NONE.fill(fill)).show(ui, |ui| { canvas::show(self, ui); }); - if self.focus_ribbon { + if self.focus_ribbon && !moved(self) { chrome::focus_ribbon(self, &ctx); } } + if moved(self) { + return; + } content_controls::popup(self, &ctx); dialogs::show(self, &ctx); + if moved(self) { + return; + } #[cfg(not(target_arch = "wasm32"))] file_browser::show(self, &ctx); colors::show(self, &ctx); zotero::show_alert(self, &ctx); read_aloud::show(self, &ctx); crate::keytips::show(self, &ctx, ui); + if moved(self) { + return; + } keys::global_shortcuts(self, &ctx); + if moved(self) { + return; + } self.refocus_after_chrome_click(&ctx); if let Some(url) = self.canvas.open_url.take() { ctx.open_url(egui::OpenUrl::new_tab(url)); } - let title = if self.has_document() { - format!("{}{} - WordCraft", self.title_stem(), if self.session.dirty { " •" } else { "" }) - } else { - "WordCraft".to_string() - }; + // With native windows the viewport driver gives the window its title. + let title = self.window_title(); if title != self.sent_title { - ctx.send_viewport_cmd(egui::ViewportCommand::Title(title.clone())); + if !self.windows.driven() { + ctx.send_viewport_cmd(egui::ViewportCommand::Title(title.clone())); + } self.sent_title = title; } self.sync_full_screen(&ctx); @@ -2700,12 +2821,14 @@ impl WordApp { self.control_rx = Some(rx); } + /// Ask the active window for the screenshots `ui.screenshot` queued. fn issue_screenshots(&mut self, ctx: &egui::Context) { let now = now_ms(); + let viewport = self.active_viewport(); self.queued_shots.retain_mut(|(token, at, frames)| { *frames += 1; if now >= *at && *frames >= 3 { - ctx.send_viewport_cmd(egui::ViewportCommand::Screenshot(egui::UserData::new(*token))); + ctx.send_viewport_cmd_to(viewport, egui::ViewportCommand::Screenshot(egui::UserData::new(*token))); false } else { true @@ -6707,7 +6830,8 @@ mod tests { assert!(!closes(&h), "Ctrl+Q doesn't close the window"); h.event(key(egui::Key::Q, egui::Modifiers::MAC_CMD | egui::Modifiers::COMMAND)); h.step(); - assert!(closes(&h), "⌘Q still quits on the Mac"); + // Quitting asks about the typed text first, in the document's window. + assert_eq!(h.state().dialog.as_ref().map(|d| d.name()), Some("saveChanges"), "⌘Q still quits on the Mac"); } /// #3424: Pressing Ctrl+Alt+Q (German shortcut for @, or AltGr+Q) types '@' and does not @@ -8909,7 +9033,8 @@ mod tests { let key = |key, modifiers| egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }; h.event(egui::Event::Text("Kilns fire clay. Kilns cool.".into())); h.step(); - let nav_query = |h: &egui_kittest::Harness| h.ctx.data(|d| d.get_temp::(egui::Id::new("nav_query"))).unwrap_or_default(); + let nav_query = + |h: &egui_kittest::Harness| h.ctx.data(|d| d.get_temp::(h.state().window_id("nav_query"))).unwrap_or_default(); // Ctrl+F: the pane, and typing goes into its search box. h.event(key(egui::Key::F, egui::Modifiers::COMMAND)); h.step(); diff --git a/crates/ui-egui/src/panes.rs b/crates/ui-egui/src/panes.rs index 3b9afb611..35eabcf42 100644 --- a/crates/ui-egui/src/panes.rs +++ b/crates/ui-egui/src/panes.rs @@ -122,7 +122,7 @@ fn nav(app: &mut WordApp, ui: &mut Ui) { let _ = app.run("view.navigationPane", json!({"value": false})); return; } - let qid = egui::Id::new("nav_query"); + let qid = app.window_id("nav_query"); let mut q = ui.data(|d| d.get_temp::(qid)).unwrap_or_default(); // Ctrl+F with text selected searches for it, as Word does (a selection within one line). let focus = std::mem::take(&mut app.nav_search_focus); diff --git a/crates/ui-egui/src/previews.rs b/crates/ui-egui/src/previews.rs index 721776f82..fd0fe5b53 100644 --- a/crates/ui-egui/src/previews.rs +++ b/crates/ui-egui/src/previews.rs @@ -522,7 +522,8 @@ pub fn style_gallery(app: &mut WordApp, ui: &mut Ui, state: &Value) { let rev = app.session.rev(); let skey = app.previews.styles_key(&app.session.doc, rev); let ppp = ui.ctx().pixels_per_point(); - let start = ui.data(|d| d.get_temp::(egui::Id::new("gallery_start"))).unwrap_or(0).min(styles.len().saturating_sub(1)); + let start_id = app.window_id("gallery_start"); + let start = ui.data(|d| d.get_temp::(start_id)).unwrap_or(0).min(styles.len().saturating_sub(1)); // A compact ribbon group (#662, #1285) trims the gallery to fewer tiles before it folds. let visible = if crate::ribbon_fit::compact(ui.ctx()) { STYLE_TILES_COMPACT } else { STYLE_TILES }; crate::ribbon_fit::add_saving(ui.ctx(), (STYLE_TILES - STYLE_TILES_COMPACT) as f32 * 76.0); @@ -595,7 +596,7 @@ pub fn style_gallery(app: &mut WordApp, ui: &mut Ui, state: &Value) { let _ = app.run("view.stylesPane", json!({"value": true})); } else { let n = if *delta < 0 { start.saturating_sub(visible) } else { (start + visible).min(styles.len().saturating_sub(1)) }; - ui.data_mut(|d| d.insert_temp(egui::Id::new("gallery_start"), n)); + ui.data_mut(|d| d.insert_temp(start_id, n)); } } } diff --git a/crates/ui-egui/src/read_aloud.rs b/crates/ui-egui/src/read_aloud.rs index 8d77a1bab..ea275b477 100644 --- a/crates/ui-egui/src/read_aloud.rs +++ b/crates/ui-egui/src/read_aloud.rs @@ -60,6 +60,14 @@ pub fn show(app: &mut WordApp, ctx: &egui::Context) { if !app.session.read_aloud.open { return; } + // With a window per document, the player is in the window of the document being read. + if app.windows.native() + && let Some(d) = app.session.read_aloud.document + && d != app.session.document_id() + && app.session.is_open(d) + { + return; + } let t = Tokens::get(ctx); let state = app.session.read_aloud.state(); let (index, count) = app.session.read_aloud.progress(); diff --git a/crates/ui-egui/src/window_ui.rs b/crates/ui-egui/src/window_ui.rs index d79827adf..95c8b9933 100644 --- a/crates/ui-egui/src/window_ui.rs +++ b/crates/ui-egui/src/window_ui.rs @@ -151,7 +151,6 @@ impl WordApp { /// session's active document is not changed: the caller activates `w`'s document /// (`shown_document`) with it, or [`WordApp::sync_document`] treats the change as a switch /// within the window. - #[cfg_attr(not(test), expect(dead_code, reason = "drawing one native window per document swaps windows in"))] pub(crate) fn swap_window(&mut self, w: &mut WindowUi) { let WordApp { // App-wide. @@ -191,6 +190,8 @@ impl WordApp { saves_in_flight: _, recovery_seen: _, no_document: _, + windows: _, + print_document: _, // Per document: the shown document's, swapped with `parked` on a document switch. autosave_path: _, comment_drafts: _, diff --git a/crates/ui-egui/src/windows.rs b/crates/ui-egui/src/windows.rs new file mode 100644 index 000000000..2ff535c23 --- /dev/null +++ b/crates/ui-egui/src/windows.rs @@ -0,0 +1,984 @@ +//! One native window per open document. Each window is an egui viewport, driven by +//! [`craft_window::Viewports`]: one window draws in the root viewport, every other one in an +//! immediate viewport of its own, and each draws the whole interface (title bar, ribbon, page, +//! status bar, dialogs) for its own document. [`WordApp`]'s fields hold the drawn window's state +//! ([`crate::window_ui`]); the other windows keep theirs in their [`Window`]. Between frames the +//! fields hold the active window's (the one focused last), so the control channel, the MCP +//! bridge and the host's menus act on it. +//! +//! A frame: [`WordApp::logic`] does the app-wide work once (the control channel, recovery, +//! timers, printing). [`WordApp::ui`] then draws the windows in turn: for each it swaps the +//! window's state in, makes its document the session's active one, does the window's own work +//! (its file dialog, AutoSave, dropped files, Read Aloud, requests queued for it) and draws it. +//! Last, the active window's state comes back. +//! +//! Which window shows what follows the session ([`WordApp::sync_document`]): a document made +//! active that no window shows (New, Open) gets a new window, focused and cascaded from the last +//! one; a document a window shows brings that window to the front; a window whose document closed +//! closes, and the window focused before it comes forward. Opening a file replaces a blank +//! document nobody used in its own window, and the last window stays open with no document (the +//! empty window the Start screen also leaves). The web app has one window and switches documents +//! in it ([`crate::documents`]). + +use std::collections::HashMap; + +use craft_window::{Document, Frame, Viewports, WindowEvent, WindowId, WindowList, WindowMenu}; +use egui::{ViewportCommand, ViewportId}; +use serde_json::{Value, json}; + +use crate::{WordApp, documents, window_ui::WindowUi}; + +/// The window title for a document called `name`. +pub(crate) fn title(name: &str, dirty: bool) -> String { + format!("{name}{} - WordCraft", if dirty { " •" } else { "" }) +} + +/// An open window. +pub(crate) struct Window { + /// The app's key for the window. A window made while the windows are being drawn gets its + /// [`WindowId`] once the frame's drawing ends. + key: u64, + id: Option, + /// The document it shows. + doc: u64, + /// Its state while another window's is in [`WordApp`]'s fields; `None` for that window. + ui: Option>, + /// Requests commands made for its document while another window was drawn + /// ([`WordApp::handle_request`]), handled when it draws next. + requests: Vec, + /// The title it last drew, for its viewport. + title: String, +} + +/// The app's windows. See the module documentation. +pub struct Windows { + /// Separate native windows (desktop). Off, one window shows every document in turn (web). + native: bool, + /// Every window, with its key, for the viewport driver: opening and focus order, positions. + /// Lent to the driver while the windows draw. + list: WindowList, + viewports: Option, + /// Open windows in opening order. + open: Vec, + /// The window whose state is in [`WordApp`]'s fields. + shown: u64, + next_key: u64, + /// The windows are being drawn: `list` is lent out, so windows opened or closed now join or + /// leave it when the drawing ends. + showing: bool, + /// Window keys, most recently focused first, as the drawing started. + recent: Vec, + /// A window to bring to the front when the drawing ends. + focus: Option, + /// View › Window › Switch Windows and the window titles, as the frame's drawing started. + menu: WindowMenu, + /// What a new window's viewport starts from: size, icon, app id ([`WordApp::set_window_builder`]). + builder: egui::ViewportBuilder, + /// [`ViewportInput`] is registered with egui. + input_plugin: bool, + /// The viewport driver draws the windows this frame, and titles them. + driven: bool, +} + +impl Windows { + /// One window, showing document `doc`. + pub(crate) fn new(doc: u64) -> Windows { + let mut list = WindowList::new(); + let id = list.open(1); + let mut viewports = Viewports::new(Frame::Desktop); + viewports.set_root(Some(id)); + Windows { + native: cfg!(not(target_arch = "wasm32")), + list, + viewports: Some(viewports), + open: vec![Window { key: 1, id: Some(id), doc, ui: None, requests: Vec::new(), title: String::new() }], + shown: 1, + next_key: 2, + showing: false, + recent: vec![1], + focus: None, + menu: WindowMenu::default(), + builder: egui::ViewportBuilder::default().with_inner_size([1440.0, 920.0]).with_min_inner_size([760.0, 480.0]), + input_plugin: false, + driven: false, + } + } + + /// Whether documents get native windows of their own. + pub(crate) fn native(&self) -> bool { + self.native + } + + /// One window for every document, as in the web app. + #[cfg(test)] + pub(crate) fn single(&mut self) { + self.native = false; + } + + /// Whether the viewport driver titles the windows (else the drawn window sends its title). + pub(crate) fn driven(&self) -> bool { + self.driven + } + + /// The key of the window whose state is in [`WordApp`]'s fields. + pub(crate) fn shown(&self) -> u64 { + self.shown + } + + fn get(&self, key: u64) -> Option<&Window> { + self.open.iter().find(|w| w.key == key) + } + + fn get_mut(&mut self, key: u64) -> Option<&mut Window> { + self.open.iter_mut().find(|w| w.key == key) + } + + /// The window showing document `doc`, the most recently focused if several do. + fn of_doc(&self, doc: u64) -> Option { + self.recent_keys().into_iter().find(|&k| self.get(k).is_some_and(|w| w.doc == doc)) + } + + /// Open windows, most recently focused first; windows opened during this frame's drawing + /// come first. + fn recent_keys(&self) -> Vec { + let mut keys: Vec = self.open.iter().rev().filter(|w| w.id.is_none()).map(|w| w.key).collect(); + let listed: Vec = + if self.showing { self.recent.clone() } else { self.list.recent().filter_map(|id| self.list.get(id).copied()).collect() }; + keys.extend(listed.into_iter().filter(|&k| self.get(k).is_some())); + for w in &self.open { + if !keys.contains(&w.key) { + keys.push(w.key); + } + } + keys + } + + /// The document of the window focused last before `key`'s, if any. + fn next_doc(&self, key: u64) -> Option { + if !self.native { + return None; + } + self.recent_keys().into_iter().find(|&k| k != key).and_then(|k| self.get(k)).map(|w| w.doc) + } + + /// The window's viewport. + fn viewport(&self, key: u64) -> ViewportId { + match (self.get(key).and_then(|w| w.id), &self.viewports) { + (Some(id), Some(v)) => v.viewport_id(id), + _ => ViewportId::ROOT, + } + } + + /// The title View › Window › Switch Windows gives the window, as the frame started. + fn menu_title(&self, key: u64) -> Option<&str> { + self.menu.title(self.get(key)?.id?) + } + + /// Bring the list up to date with the windows opened, closed and focused since it was last + /// lent out, and give the root viewport a window when its own closed. + fn settle(&mut self, ctx: Option<&egui::Context>) { + if self.showing { + return; + } + let gone: Vec = self.list.ids().filter(|id| !self.open.iter().any(|w| w.id == Some(*id))).collect(); + for id in gone { + self.list.close(id); + } + let active = self.list.active(); + let mut opened = false; + for w in self.open.iter_mut().filter(|w| w.id.is_none()) { + w.id = Some(self.list.open(w.key)); + opened = true; + } + let focus = self.focus.take().and_then(|k| self.get(k)).and_then(|w| w.id); + // A window opened behind the others (nothing asked to focus it) leaves the active one so. + if let Some(id) = focus.or(active.filter(|_| opened)) { + self.list.focus(id); + } + let Some(viewports) = self.viewports.as_mut() else { return }; + if !viewports.root().is_some_and(|r| self.list.contains(r)) + && let Some(next) = self.list.active() + { + match ctx { + Some(ctx) => viewports.adopt(ctx, &self.list, next), + None => viewports.set_root(Some(next)), + } + } + if let (Some(id), Some(ctx)) = (focus, ctx) { + viewports.focus(ctx, id); + } + if opened && let Some(ctx) = ctx { + ctx.request_repaint(); + } + } +} + +/// Control-channel input for a window in a viewport of its own: eframe's raw-input hook +/// ([`WordApp::raw_input_hook`]) sees only the root viewport's input, and egui runs a plugin's +/// input hook for every viewport. On macOS it also reads Control-click as the secondary button +/// in those windows, as the raw-input hook does in the root's. +#[derive(Default)] +pub(crate) struct ViewportInput { + /// The viewport the queued events go to, on its next pass. + target: Option, + events: Vec, + /// A viewport whose pass got keyboard input this way, until its window has looked. + keys_for: Option, + control_click: HashMap, +} + +impl egui::Plugin for ViewportInput { + fn debug_name(&self) -> &'static str { + "wordcraft_viewport_input" + } + + fn input_hook(&mut self, _ctx: &egui::Context, input: &mut egui::RawInput) { + let viewport = input.viewport_id; + if viewport == ViewportId::ROOT { + return; + } + if cfg!(target_os = "macos") { + crate::control_click_as_secondary(&mut input.events, self.control_click.entry(viewport).or_default()); + } + if self.target != Some(viewport) || self.events.is_empty() { + return; + } + self.target = None; + let events = std::mem::take(&mut self.events); + if events.iter().any(|e| matches!(e, egui::Event::Key { .. } | egui::Event::Text(_))) { + input.focused = true; + self.keys_for = Some(viewport); + } + input.events.extend(events); + } +} + +impl WordApp { + /// How many windows are open. + pub fn window_count(&self) -> usize { + if self.windows.native { self.windows.open.len() } else { 1 } + } + + /// The egui viewport of the active window, the one focused last: control input, + /// `ui.screenshot`, `ui.resize` and the saved window geometry are its. + pub fn active_viewport(&self) -> ViewportId { + if !self.windows.native { + return ViewportId::ROOT; + } + let key = self.windows.recent_keys().first().copied().unwrap_or(self.windows.shown); + self.windows.viewport(key) + } + + /// The viewport new windows start from: the host's size, icon and app id. Their title and + /// place are the app's. + pub fn set_window_builder(&mut self, builder: egui::ViewportBuilder) { + self.windows.builder = builder; + } + + /// The root viewport was asked to close while other windows are open: only its window closes + /// (through [`WindowEvent::CloseRequested`] in [`WordApp::ui`]), so the host keeps the app + /// running. + pub fn closes_a_window(&self) -> bool { + self.window_count() > 1 && !self.quit_requested + } + + /// Quit the app (the app menu's Quit): ask about every document with unsaved work, each in + /// its window, as [`WordApp::close_requested`] does. True when the app may quit now; the + /// host closes it once `quit_requested` is set. + pub fn quit(&mut self) -> bool { + let go = self.close_requested(); + if go { + self.quit_requested = true; + } + go + } + + /// Put the drawn window's state away and draw window `key`, with its document active. False + /// when there is no such window. + pub(crate) fn enter_window(&mut self, key: u64) -> bool { + if key == self.windows.shown { + return true; + } + let Some(mut state) = self.windows.get_mut(key).and_then(|w| w.ui.take()) else { + return false; + }; + let from = self.shown_document; + self.swap_window(&mut state); + let left = std::mem::replace(&mut self.windows.shown, key); + if let Some(w) = self.windows.get_mut(left) { + w.doc = state.shown_document; + w.ui = Some(state); + } + let to = self.shown_document; + self.carry_document_state(from, to); + true + } + + /// Open a new window for open document `doc`, focused, and draw it. The window that was drawn + /// leaves the File page: whatever it was opening is open. + fn open_window(&mut self, doc: u64) { + let key = self.windows.next_key; + self.windows.next_key = self.windows.next_key.saturating_add(1); + let mut state = Box::new(WindowUi::new(doc)); + // Faces borrowed for the interface (non-Latin file names) stay installed. + state.canvas.ime_faces = self.canvas.ime_faces.clone(); + self.ui.backstage = false; + self.windows.open.push(Window { key, id: None, doc, ui: Some(state), requests: Vec::new(), title: String::new() }); + self.enter_window(key); + self.focus_window(key); + } + + /// The drawn window's document has closed: draw window `to` and close this one. + fn close_shown_window(&mut self, to: u64) { + let closing = self.windows.shown; + if !self.enter_window(to) { + return; + } + self.windows.open.retain(|w| w.key != closing); + self.focus_window(to); + } + + /// Bring window `key` to the front (now, or once the frame's drawing ends) and make it the + /// active one. + pub(crate) fn focus_window(&mut self, key: u64) { + self.windows.focus = Some(key); + self.windows.settle(self.ctx.as_ref()); + } + + /// Keep one window per open document: windows whose document closed (an agent closed it) + /// close, and documents with no window (an agent made one while another was active) get one + /// behind the others. The active document is left to [`WordApp::sync_document`]. + pub(crate) fn prune_windows(&mut self) { + let shown = self.windows.shown; + let session = &self.session; + let before = self.windows.open.len(); + self.windows.open.retain(|w| w.key == shown || session.is_open(w.doc)); + let mut changed = self.windows.open.len() != before; + let active = self.session.document_id(); + for d in self.session.documents() { + if d.id == active || Some(d.id) == self.no_document || d.id == self.shown_document || self.windows.of_doc(d.id).is_some() { + continue; + } + let key = self.windows.next_key; + self.windows.next_key = self.windows.next_key.saturating_add(1); + let mut state = Box::new(WindowUi::new(d.id)); + state.canvas.ime_faces = self.canvas.ime_faces.clone(); + self.windows.open.push(Window { key, id: None, doc: d.id, ui: Some(state), requests: Vec::new(), title: String::new() }); + changed = true; + } + if changed { + self.windows.settle(self.ctx.as_ref()); + } + } + + /// [`WordApp::sync_document`] with native windows: the session's active document `id` isn't + /// the drawn window's. Another open document shows in its window, which comes to the front, + /// or in a new window; when the drawn window's document has closed, its window closes and the + /// one showing `id` comes forward. False when the window itself is to show `id` instead: its + /// document was replaced, or it is the Start screen's empty window. + pub(crate) fn follow_in_windows(&mut self, id: u64) -> bool { + let left = self.shown_document; + let stand_in = self.no_document == Some(left); + if self.session.is_open(left) && !stand_in { + match self.windows.of_doc(id).filter(|&k| k != self.windows.shown) { + Some(key) => { + self.enter_window(key); + self.focus_window(key); + } + None => self.open_window(id), + } + return true; + } + if !self.session.is_open(left) + && let Some(key) = self.windows.of_doc(id).filter(|&k| k != self.windows.shown) + { + self.close_shown_window(key); + return true; + } + false + } + + /// The drawn window now shows document `id` (its own was replaced). + pub(crate) fn window_shows(&mut self, id: u64) { + let shown = self.windows.shown; + if let Some(w) = self.windows.get_mut(shown) { + w.doc = id; + } + } + + /// Close the active document. With windows, its window closes and the window focused before + /// it comes forward; else the window shows the next document. The session leaves a new blank + /// document when it was the last. + pub(crate) fn close_shown_document(&mut self) -> Result<(), String> { + let doc = self.session.document_id(); + let next = self.windows.next_doc(self.windows.shown); + self.session.close_document(doc).map_err(|e| e.to_string())?; + if let Some(next) = next + && self.session.is_open(next) + && let Err(e) = self.session.switch_document(next) + { + log::warn!("closing document {doc}: couldn't show document {next}: {e}"); + } + self.sync_document(); + Ok(()) + } + + /// A request made for open document `document`, which another window shows: that window + /// handles it when it draws next, and comes to the front when the request opens something + /// (a dialog, the Print page). False when no other window shows the document (or windows + /// aren't native). + pub(crate) fn queue_for_window(&mut self, document: u64, req: &Value) -> bool { + if !self.windows.native { + return false; + } + let Some(key) = self.windows.of_doc(document).filter(|&k| k != self.windows.shown) else { return false }; + let opens = req.get("open").is_some(); + if opens && !self.windows.showing { + // Between frames: the window comes forward and opens it now. + self.enter_window(key); + self.focus_window(key); + self.handle_request(req); + return true; + } + if let Some(w) = self.windows.get_mut(key) { + w.requests.push(req.clone()); + } + if opens { + self.focus_window(key); + } + if let Some(ctx) = &self.ctx { + ctx.request_repaint(); + } + true + } + + /// View › Window › Switch Windows with native windows: every window by its title, the active + /// one marked, unsaved ones with the title bar's dot. Choosing one brings it to the front. + pub(crate) fn switch_windows_entries(&self) -> Vec<(u64, String, bool)> { + self.windows + .menu + .entries() + .iter() + .filter_map(|e| { + let w = self.windows.open.iter().find(|w| w.id == Some(e.id))?; + Some((w.doc, format!("{}{}", e.title, if e.dirty { " •" } else { "" }), w.key == self.windows.shown)) + }) + .collect() + } + + /// The drawn window's title: its document's name as View › Window › Switch Windows gives it. + pub(crate) fn window_title(&self) -> String { + if !self.has_document() { + return "WordCraft".to_string(); + } + let name = self.windows.menu_title(self.windows.shown).map(str::to_string).unwrap_or_else(|| self.title_stem()); + title(&name, self.session.dirty) + } + + /// Draw every window, each in its viewport (see the module documentation). + pub(crate) fn windows_ui(&mut self, ui: &mut egui::Ui) { + let ctx = ui.ctx().clone(); + self.prune_windows(); + self.windows.settle(Some(&ctx)); + self.windows.menu = self.window_menu(&self.windows.list); + // A backend that can't open native windows (egui embeds them in the root: tests, + // `ui_shot`) shows the active window alone. Embedded, every window would draw the same + // widgets in one viewport. + self.windows.driven = !ctx.embed_viewports() && self.windows.viewports.is_some(); + if !self.windows.driven { + let active = self.windows.recent_keys().first().copied().unwrap_or(self.windows.shown); + self.draw_window(ui, active); + self.after_windows(&ctx); + return; + } + let Some(mut viewports) = self.windows.viewports.take() else { return }; + let mut list = std::mem::take(&mut self.windows.list); + self.windows.recent = list.recent().filter_map(|id| list.get(id).copied()).collect(); + let titles: HashMap = self.windows.open.iter().map(|w| (w.key, w.title.clone())).collect(); + let base = self.windows.builder.clone(); + self.windows.showing = true; + let events = viewports.show( + ui, + &mut list, + |_, key| match titles.get(key).filter(|t| !t.is_empty()) { + Some(t) => base.clone().with_title(t.clone()), + None => base.clone().with_title("WordCraft"), + }, + |ui, _, key| self.draw_window(ui, *key), + ); + self.windows.showing = false; + self.windows.list = list; + self.windows.viewports = Some(viewports); + self.window_events(&ctx, &events); + self.after_windows(&ctx); + } + + /// After the frame's drawing: windows opened, closed or focused meanwhile settle, and the + /// active window's state comes back. + fn after_windows(&mut self, ctx: &egui::Context) { + self.prune_windows(); + self.windows.settle(Some(ctx)); + if let Some(&active) = self.windows.recent_keys().first() { + self.enter_window(active); + } + } + + /// View › Window › Switch Windows for the windows in `list`. + fn window_menu(&self, list: &WindowList) -> WindowMenu { + let docs = self.session.documents(); + let doc_of: HashMap = self.windows.open.iter().map(|w| (w.key, w.doc)).collect(); + WindowMenu::new(list, |_, key| { + let doc = doc_of.get(key).copied().unwrap_or_default(); + match docs.iter().find(|d| d.id == doc) { + Some(d) => Document { key: doc, name: documents::display_title(d), dirty: d.dirty }, + None => Document { key: doc, name: String::new(), dirty: false }, + } + }) + } + + /// Draw window `key` into its viewport's `ui`. + fn draw_window(&mut self, ui: &mut egui::Ui, key: u64) { + if !self.enter_window(key) { + return; + } + let ctx = ui.ctx().clone(); + let queued = self.windows.get_mut(key).map(|w| std::mem::take(&mut w.requests)).unwrap_or_default(); + for req in queued { + self.handle_request(&req); + } + self.control_keys_arrived(&ctx); + self.window_work(&ctx); + self.window_input(&ctx); + if self.windows.shown != key { + return; + } + self.window_ui(ui); + let title = self.sent_title.clone(); + if let Some(w) = self.windows.get_mut(key) { + w.title = title; + } + } + + /// The windows' focus changes and close requests from this frame. + fn window_events(&mut self, ctx: &egui::Context, events: &[WindowEvent]) { + for event in events { + let WindowEvent::CloseRequested(id) = *event else { continue }; + let Some(key) = self.windows.list.get(id).copied() else { continue }; + // The last window's close button quits: the host asks first ([`WordApp::close_requested`]). + if self.windows.open.len() <= 1 || self.quit_requested { + continue; + } + if self.windows.viewports.as_ref().and_then(Viewports::root) == Some(id) { + ctx.send_viewport_cmd_to(ViewportId::ROOT, ViewportCommand::CancelClose); + } + self.close_window(key); + } + } + + /// The close button of window `key` (not the last one): File › Close in that window, which + /// asks about unsaved changes first. + pub(crate) fn close_window(&mut self, key: u64) { + if !self.enter_window(key) { + return; + } + self.focus_window(key); + if !self.has_document() { + // An empty window beside others: nothing to ask about. + let _ = self.close_shown_document(); + return; + } + if let Err(e) = self.run("file.close", json!({})) { + log::warn!("closing a window: {e}"); + } + } + + /// The control channel's next input, for the active window when it has a viewport of its own: + /// handed to [`ViewportInput`], which gives it to that viewport's next pass. + pub(crate) fn send_control_input(&mut self, ctx: &egui::Context) { + if self.synthetic.is_empty() || self.control_input_is_roots(ctx) { + return; + } + if !self.windows.input_plugin { + ctx.add_plugin(ViewportInput::default()); + self.windows.input_plugin = true; + } + let target = self.active_viewport(); + let waiting = ctx.with_plugin(|p: &mut ViewportInput| !p.events.is_empty()).unwrap_or(true); + if waiting { + return; + } + let Some(events) = self.next_synthetic() else { return }; + ctx.with_plugin(|p: &mut ViewportInput| { + p.target = Some(target); + p.events = events; + }); + ctx.request_repaint_of(target); + } + + /// Whether control input goes in through the root viewport's raw input: one window, the + /// active window drawn there, or viewports embedded in the root (tests). + pub(crate) fn control_input_is_roots(&self, ctx: &egui::Context) -> bool { + !self.windows.native || ctx.embed_viewports() || self.active_viewport() == ViewportId::ROOT + } + + /// Keys from the control channel reached this window's viewport: the page counts as focused + /// when its widget has the keyboard, as [`WordApp::raw_input_hook`] does for the root. + fn control_keys_arrived(&mut self, ctx: &egui::Context) { + if !self.windows.input_plugin { + return; + } + let viewport = ctx.viewport_id(); + let arrived = ctx + .with_plugin(|p: &mut ViewportInput| { + let hit = p.keys_for == Some(viewport); + if hit { + p.keys_for = None; + } + hit + }) + .unwrap_or(false); + if arrived && let Some((id, _)) = self.canvas.widget_id { + self.canvas.focused = ctx.memory(|m| m.has_focus(id)); + } + } + + /// The id of the drawn window's own `name` in egui's memory, which every viewport shares: a + /// search box's text, a gallery's first tile. + pub(crate) fn window_id(&self, name: &'static str) -> egui::Id { + egui::Id::new(name).with(self.windows.shown) + } + + /// The windows other than the drawn one, for state every window contributes to (the faces + /// borrowed for the interface). + pub(crate) fn other_windows(&self) -> impl Iterator { + self.windows.open.iter().filter_map(|w| w.ui.as_deref()) + } +} + +#[cfg(test)] +mod tests { + use std::cell::RefCell; + use std::collections::HashMap; + + use egui::{RawInput, ViewportCommand, ViewportEvent, ViewportId, ViewportIdMap, ViewportInfo}; + use serde_json::json; + use wordcraft_doc::StoryRef; + use wordcraft_engine::Session; + + use crate::{Services, WordApp}; + + thread_local! { + /// What the test backend tells each viewport about itself (focus, close requests). + static INFOS: RefCell> = RefCell::new(ViewportIdMap::default()); + /// Input events for each viewport's next pass. + static EVENTS: RefCell>> = RefCell::new(HashMap::new()); + /// The viewports that drew in the last frame, in order. + static DRAWN: RefCell> = const { RefCell::new(Vec::new()) }; + /// Viewport commands the app sent, by viewport. + static COMMANDS: RefCell>> = RefCell::new(HashMap::new()); + /// Textures uploaded (page rasters, the font atlas) since the counter was reset. + static UPLOADS: RefCell = const { RefCell::new(0) }; + /// Each viewport's last input-method output. + static IME: RefCell>> = RefCell::new(HashMap::new()); + } + + fn input(id: ViewportId) -> RawInput { + let mut raw = RawInput { + viewport_id: id, + screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1440.0, 900.0))), + ..Default::default() + }; + raw.viewports = INFOS.with(|i| i.borrow().clone()); + raw.focused = raw.viewports.entry(id).or_default().focused.unwrap_or(false); + raw.events = EVENTS.with(|e| e.borrow_mut().remove(&id).unwrap_or_default()); + raw + } + + fn keep(out: egui::FullOutput, id: ViewportId) { + DRAWN.with(|d| d.borrow_mut().push(id)); + UPLOADS.with(|u| *u.borrow_mut() += out.textures_delta.set.len()); + IME.with(|i| i.borrow_mut().insert(id, out.platform_output.ime)); + for (v, o) in &out.viewport_output { + COMMANDS.with(|c| c.borrow_mut().entry(*v).or_default().extend(o.commands.iter().cloned())); + } + out.drop_without_applying_deltas(); + } + + /// A backend with native viewports, as eframe's: each immediate viewport runs a pass of its + /// own, with its own input. + fn backend() -> egui::Context { + INFOS.with(|i| i.borrow_mut().clear()); + EVENTS.with(|e| e.borrow_mut().clear()); + COMMANDS.with(|c| c.borrow_mut().clear()); + UPLOADS.with(|u| *u.borrow_mut() = 0); + let ctx = egui::Context::default(); + ctx.set_embed_viewports(false); + egui::Context::set_immediate_viewport_renderer(|ctx, viewport| { + let id = viewport.ids.this; + let mut raw = input(id); + raw.viewports.entry(id).or_default().parent = Some(viewport.ids.parent); + let mut cb = viewport.viewport_ui_cb; + keep(ctx.run_ui(raw, |ui| cb(ui)), id); + }); + ctx + } + + /// One frame of every window. + fn frame(ctx: &egui::Context, a: &mut WordApp) { + DRAWN.with(|d| d.borrow_mut().clear()); + let mut raw = input(ViewportId::ROOT); + a.raw_input_hook(&mut raw); + let out = ctx.run_ui(raw, |ui| { + a.logic(ui.ctx()); + a.ui(ui); + }); + keep(out, ViewportId::ROOT); + INFOS.with(|i| i.borrow_mut().values_mut().for_each(|v| v.events.clear())); + } + + fn frames(ctx: &egui::Context, a: &mut WordApp, n: usize) { + for _ in 0..n { + frame(ctx, a); + } + } + + /// Focus viewport `id` alone, as the desktop does when a window is clicked. + fn focus(id: ViewportId) { + INFOS.with(|i| { + let mut i = i.borrow_mut(); + for v in i.values_mut() { + v.focused = Some(false); + } + i.entry(id).or_default().focused = Some(true); + }); + } + + fn sent(id: ViewportId, cmd: &ViewportCommand) -> bool { + COMMANDS.with(|c| c.borrow().get(&id).is_some_and(|v| v.contains(cmd))) + } + + fn app(text: &str) -> WordApp { + let mut a = WordApp::new(Session::new(wordcraft_doc::Document::new()), Services::default()); + a.run("text.insert", json!({"text": text})).unwrap(); + a + } + + fn text(a: &WordApp) -> String { + a.session.doc.plain_text(StoryRef::Body).trim().to_string() + } + + fn prompt(a: &WordApp) -> Option<&'static str> { + a.dialog.as_ref().map(|d| d.name()) + } + + /// New opens the document in a second native window, focused, with a fresh interface; the + /// first keeps its document, its ribbon tab and its open dialog, and gets them back when it + /// is focused again. + #[test] + fn new_opens_a_second_window_and_each_keeps_its_state() { + let ctx = backend(); + let mut a = app("First"); + let first = a.session.document_id(); + frames(&ctx, &mut a, 3); + a.ui.tab = "Insert".into(); + a.run("ui.dialog", json!({"name": "paragraph"})).unwrap(); + assert_eq!(prompt(&a), Some("paragraph")); + + a.run("file.new", json!({})).unwrap(); + let second = a.session.document_id(); + assert_ne!(second, first); + assert_eq!(a.window_count(), 2); + assert!(a.dialog.is_none() && a.ui.tab == crate::DEFAULT_TAB, "the new window starts afresh"); + frames(&ctx, &mut a, 3); + let child = a.active_viewport(); + assert_ne!(child, ViewportId::ROOT, "the new window has a viewport of its own"); + assert_eq!(DRAWN.with(|d| d.borrow().clone()), [child, ViewportId::ROOT], "both windows draw each frame"); + frames(&ctx, &mut a, 3); + assert!(UPLOADS.with(|u| *u.borrow()) > 0, "the pages were rendered"); + UPLOADS.with(|u| *u.borrow_mut() = 0); + frames(&ctx, &mut a, 2); + assert_eq!(UPLOADS.with(|u| *u.borrow()), 0, "a window whose document didn't change renders no page again"); + assert!(a.sent_title.starts_with("Document2"), "{}", a.sent_title); + assert_eq!(a.session.document_id(), second); + a.run("text.insert", json!({"text": "Second"})).unwrap(); + + focus(ViewportId::ROOT); + frames(&ctx, &mut a, 2); + assert_eq!(a.active_viewport(), ViewportId::ROOT); + assert_eq!(a.session.document_id(), first); + assert_eq!((text(&a), a.ui.tab.as_str(), prompt(&a)), ("First".into(), "Insert", Some("paragraph"))); + focus(child); + frames(&ctx, &mut a, 2); + assert_eq!((text(&a), a.ui.tab.as_str(), prompt(&a)), ("Second".into(), crate::DEFAULT_TAB, None)); + } + + fn docx_on_disk(name: &str, body: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!("wordcraft-windows-{name}-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join(format!("{name}.docx")); + let mut s = Session::new(wordcraft_doc::Document::new()); + s.run("text.insert", &json!({"text": body})).unwrap(); + s.run("file.save", &json!({"path": path.to_string_lossy()})).unwrap(); + path + } + + /// Open of a file a window already shows brings that window to the front; Open with only an + /// untouched blank document open replaces it in its own window. + #[test] + fn open_focuses_the_files_window_or_reuses_a_blank_one() { + let path = docx_on_disk("report", "Report"); + let ctx = backend(); + let mut a = WordApp::new(Session::new(wordcraft_doc::Document::new()), Services::default()); + frames(&ctx, &mut a, 2); + let window = a.windows.shown(); + a.run("file.open", json!({"path": path.to_string_lossy()})).unwrap(); + assert_eq!((a.window_count(), a.windows.shown(), text(&a).as_str()), (1, window, "Report"), "the blank document's window shows it"); + let report = a.session.document_id(); + + a.run("file.new", json!({})).unwrap(); + frames(&ctx, &mut a, 2); + assert_eq!(a.window_count(), 2); + assert_ne!(a.session.document_id(), report); + COMMANDS.with(|c| c.borrow_mut().clear()); + let r = a.run("file.open", json!({"path": path.to_string_lossy()})).unwrap(); + assert_eq!(r["alreadyOpen"], true); + assert_eq!((a.window_count(), a.windows.shown(), a.session.document_id()), (2, window, report)); + frames(&ctx, &mut a, 1); + assert!(sent(ViewportId::ROOT, &ViewportCommand::Focus), "the file's window comes to the front"); + assert_eq!(a.session.document_id(), report); + std::fs::remove_dir_all(path.parent().unwrap()).ok(); + } + + /// A window's close button closes it, asking first about unsaved changes; after Don't Save the + /// other window comes forward. When the root viewport's window closes, the other window moves + /// into the root viewport. + #[test] + fn closing_a_window_asks_first_and_the_root_adopts_another() { + let ctx = backend(); + let mut a = app("Kept"); + a.run("file.save", json!({"path": docx_on_disk("kept", "Kept").to_string_lossy()})).unwrap(); + let kept = a.session.document_id(); + frames(&ctx, &mut a, 2); + a.run("file.new", json!({})).unwrap(); + a.run("text.insert", json!({"text": "Dropped"})).unwrap(); + frames(&ctx, &mut a, 2); + let child = a.active_viewport(); + + INFOS.with(|i| i.borrow_mut().entry(child).or_default().events.push(ViewportEvent::Close)); + frame(&ctx, &mut a); + assert_eq!(prompt(&a), Some("saveChanges"), "the dirty window asks"); + assert_eq!((a.window_count(), a.active_viewport()), (2, child)); + a.run("ui.saveChanges", json!({"answer": "dontSave"})).unwrap(); + assert_eq!((a.window_count(), a.session.document_id(), text(&a).as_str()), (1, kept, "Kept")); + assert_eq!(a.active_viewport(), ViewportId::ROOT); + frames(&ctx, &mut a, 2); + assert_eq!(DRAWN.with(|d| d.borrow().clone()), [ViewportId::ROOT], "the closed window's viewport is gone"); + + a.run("file.new", json!({})).unwrap(); + let other = a.windows.shown(); + frames(&ctx, &mut a, 2); + COMMANDS.with(|c| c.borrow_mut().clear()); + INFOS.with(|i| i.borrow_mut().entry(ViewportId::ROOT).or_default().events.push(ViewportEvent::Close)); + assert!(a.closes_a_window(), "the host leaves the root's close to the app"); + frame(&ctx, &mut a); + assert!(sent(ViewportId::ROOT, &ViewportCommand::CancelClose), "the app keeps running"); + assert!(!a.quit_requested); + assert_eq!((a.window_count(), a.windows.shown()), (1, other)); + assert_eq!(a.active_viewport(), ViewportId::ROOT, "the remaining window moved into the root viewport"); + frames(&ctx, &mut a, 2); + assert_eq!(DRAWN.with(|d| d.borrow().clone()), [ViewportId::ROOT]); + assert!(a.has_document() && a.session.documents().len() == 1); + } + + /// A request an agent's command makes for the document of another window is that window's: + /// Print opens the Print page there and brings it forward, and the window that was active + /// keeps its own page. + #[test] + fn a_request_for_another_windows_document_is_handled_there() { + let ctx = backend(); + let mut a = app("First"); + let first = a.session.document_id(); + frames(&ctx, &mut a, 2); + a.run("file.new", json!({})).unwrap(); + let second = a.windows.shown(); + frames(&ctx, &mut a, 2); + a.session.with_document(first, |s| s.run("file.print", &json!({}))).unwrap().unwrap(); + for req in std::mem::take(&mut a.session.ui_requests) { + assert_eq!(req["document"], first); + a.handle_request(&req); + } + assert_eq!(a.session.document_id(), first, "the first window comes forward"); + assert!(a.ui.backstage && a.ui.backstage_page == "print"); + frames(&ctx, &mut a, 2); + assert_eq!(a.active_viewport(), ViewportId::ROOT); + assert!(a.ui.backstage, "the Print page stays in its window"); + let other = a.windows.get(second).and_then(|w| w.ui.as_ref()).map(|w| w.backstage); + assert_eq!(other, Some(false), "the other window keeps its page"); + } + + /// A composition in one window shows and commits only there: the other window draws no + /// composition and its document gets none of the text. + #[test] + fn a_composition_stays_in_its_window() { + let ctx = backend(); + let mut a = app("A"); + frames(&ctx, &mut a, 2); + a.run("file.new", json!({})).unwrap(); + a.run("text.insert", json!({"text": "B"})).unwrap(); + let b = a.active_viewport(); + focus(ViewportId::ROOT); + frames(&ctx, &mut a, 8); + assert_eq!(a.active_viewport(), ViewportId::ROOT); + assert!(a.canvas.focused, "the page has the keyboard"); + EVENTS.with(|e| { + e.borrow_mut() + .entry(ViewportId::ROOT) + .or_default() + .push(egui::Event::Ime(egui::ImeEvent::Preedit { text: "にほん".into(), active_range_chars: None })) + }); + frames(&ctx, &mut a, 2); + assert_eq!(a.canvas.ime_preedit, "にほん"); + let other = a.windows.open.iter().find_map(|w| w.ui.as_ref()); + assert!(other.is_some_and(|w| w.canvas.ime_preedit.is_empty()), "the other window composes nothing"); + assert!(IME.with(|i| i.borrow().get(&ViewportId::ROOT).cloned().flatten()).is_some(), "the composing window places the candidate window"); + + focus(b); + frames(&ctx, &mut a, 2); + assert!(a.canvas.ime_preedit.is_empty(), "the focused window has no composition"); + EVENTS.with(|e| e.borrow_mut().entry(ViewportId::ROOT).or_default().push(egui::Event::Ime(egui::ImeEvent::Commit("にほん".into())))); + frames(&ctx, &mut a, 2); + assert_eq!(text(&a), "B", "a commit in the other window doesn't land here"); + } + + /// Quitting with two documents of unsaved changes asks about each in its window, the window + /// coming to the front; Cancel keeps both. + #[test] + fn quitting_asks_about_each_document_in_its_window() { + let ctx = backend(); + let mut a = app("One"); + let one = a.session.document_id(); + frames(&ctx, &mut a, 2); + a.run("file.new", json!({})).unwrap(); + a.run("text.insert", json!({"text": "Two"})).unwrap(); + let two = a.session.document_id(); + let child = a.active_viewport(); + frames(&ctx, &mut a, 2); + + assert!(!a.quit()); + assert_eq!((a.session.document_id(), prompt(&a)), (two, Some("saveChanges")), "the active window asks first"); + a.run("ui.saveChanges", json!({"answer": "cancel"})).unwrap(); + assert!(!a.quit_requested); + assert_eq!((a.window_count(), a.session.documents().len()), (2, 2), "Cancel keeps both"); + + assert!(!a.quit()); + a.run("ui.saveChanges", json!({"answer": "dontSave"})).unwrap(); + assert_eq!((a.session.document_id(), prompt(&a)), (one, Some("saveChanges")), "then the other window asks"); + assert_eq!(a.window_count(), 1); + COMMANDS.with(|c| c.borrow_mut().clear()); + frame(&ctx, &mut a); + assert!(sent(ViewportId::ROOT, &ViewportCommand::Focus), "its window comes to the front"); + assert!(!DRAWN.with(|d| d.borrow().contains(&child))); + a.run("ui.saveChanges", json!({"answer": "dontSave"})).unwrap(); + assert!(a.quit_requested); + } +} diff --git a/crates/ui-egui/src/zotero.rs b/crates/ui-egui/src/zotero.rs index e5c4ef32b..2fff8966b 100644 --- a/crates/ui-egui/src/zotero.rs +++ b/crates/ui-egui/src/zotero.rs @@ -156,6 +156,11 @@ pub fn poll(app: &mut WordApp, ctx: &egui::Context) { if app.zotero.busy.is_none() { return; } + // Each window polls as it draws: the window showing the document the command started in + // answers it. + if app.windows.native() && app.session.document_id() != app.zotero.document && app.session.is_open(app.zotero.document) { + return; + } let mut changed = false; // One alert at a time: Zotero sends nothing else while it waits for the answer. while app.zotero.alert.is_none() { @@ -217,6 +222,10 @@ pub fn poll(app: &mut WordApp, ctx: &egui::Context) { /// Zotero's question, as a dialog. pub fn show_alert(app: &mut WordApp, ctx: &egui::Context) { let Some(a) = app.zotero.alert.as_ref() else { return }; + // In the window showing the document Zotero is working on. + if app.windows.native() && app.session.document_id() != app.zotero.document && app.session.is_open(app.zotero.document) { + return; + } // Buttons and the value Zotero expects for each. // macOS puts the default button last (rightmost), with Cancel just before it. let buttons: &[(&str, i64)] = match (a.buttons, cfg!(target_os = "macos")) { @@ -336,6 +345,9 @@ mod tests { json!(["Document_complete", [1]]), ]); let mut app = WordApp::new(Session::new(Document::new()), Services::default()); + // One window showing each document in turn (the web app); with a window per document, + // the first document's window carries on with the citation. + app.windows.single(); app.zotero.options.addr = addr; let ctx = egui::Context::default(); app.ctx = Some(ctx.clone());