From 9feaa3d480318ea18f5dacfe71120ba021335dfc Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 16:54:37 +0000 Subject: [PATCH] W3b: UI: one native window per open document New and Open open the document in a window of its own (an egui viewport, craft-window's window list and driver), focused and cascaded; Open replaces an untouched blank document in its window and brings an already-open file's window forward. Each window keeps its own interface state and draws its own document. Close and a window's close button close that window, asking first; the root viewport adopts another window when its own closes; the last window stays. Quitting asks about each document in its window. Requests for another window's document go to that window. Control input, screenshots and the saved geometry follow the active window. The web app keeps one window. --- Cargo.lock | 10 + apps/wordcraft/src/apple_events.rs | 7 +- apps/wordcraft/src/main.rs | 12 +- crates/engine/src/session/documents.rs | 8 + crates/ui-egui/Cargo.toml | 2 + crates/ui-egui/src/backstage.rs | 9 +- crates/ui-egui/src/control.rs | 6 +- crates/ui-egui/src/dialogs.rs | 3 +- crates/ui-egui/src/documents.rs | 82 ++- crates/ui-egui/src/lib.rs | 263 +++++-- crates/ui-egui/src/panes.rs | 2 +- crates/ui-egui/src/previews.rs | 5 +- crates/ui-egui/src/read_aloud.rs | 8 + crates/ui-egui/src/window_ui.rs | 3 +- crates/ui-egui/src/windows.rs | 984 +++++++++++++++++++++++++ crates/ui-egui/src/zotero.rs | 12 + 16 files changed, 1306 insertions(+), 110 deletions(-) create mode 100644 crates/ui-egui/src/windows.rs 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());