diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs new file mode 100644 index 000000000..a524925ce --- /dev/null +++ b/crates/ui-egui/src/app.rs @@ -0,0 +1,1754 @@ +use super::clock::now_ms; +use super::services::{Chrome, Services}; +use super::state::{NoticeKind, OpenWindow, SaveAnswer, SheetView}; +use super::*; + +impl SheetApp { + /// Constructs a `SheetApp` with default UI, grid, printing, control and screenshot state and the caller's session and services. + pub fn new(session: Session, services: Services) -> SheetApp { + SheetApp { + session, + ui: UiState::default(), + chrome: Chrome::default(), + views: HashMap::new(), + editor: None, + text_box_editor: None, + dialog: None, + backstage: None, + message: None, + toast: None, + notice: None, + services, + printing: Default::default(), + print_feedback_seen: None, + synthetic: Default::default(), + synthetic_pending: false, + synthetic_injected_at_pass: None, + control_rx: None, + grid: grid::GridState { + last_border: "bottom".into(), + last_fill: "#FFFF00".into(), + last_font_color: "#C00000".into(), + ..Default::default() + }, + perf: Perf::default(), + fonts_ready: false, + fonts_set: false, + prepared_fonts: false, + fonts_bound: Vec::new(), + fonts_seen: (0, 0), + fonts_rebind: false, + fonts_han: None, + effective_dark: false, + desktop_theme: desktop_theme::DesktopTheme::default(), + desktop_dark: None, + name_box: None, + keytips: keytips::KeyTips::default(), + shots: control::Shots::default(), + capture_epoch: 0, + capture_geometry: None, + capture_geometry_from_raw: false, + selected_chart: None, + clipboard_text_at_copy: None, + cut_text: None, + tab_run: None, + started: now_ms(), + allow_close: false, + open_switch_windows: false, + ui_window: None, + parked: HashMap::new(), + native_windows: false, + arrange_windows: None, + #[cfg(not(target_arch = "wasm32"))] + updates_rt: Default::default(), + } + } + + /// One-time context setup: start cataloging the machine's fonts, then install the fonts and + /// visuals. Workbook-specific fonts are bound later, once the scan is done (see [`logic`]). + /// + /// [`logic`]: SheetApp::logic + pub fn setup_context(ctx: &egui::Context, dark: bool) { + fonts::start_scan(); + Self::setup_context_for_language(ctx, dark, i18n::Language::En); + } + + /// Sets up fonts and visuals for a specific persisted interface language. + pub fn setup_context_for_language(ctx: &egui::Context, dark: bool, language: i18n::Language) { + ctx.set_fonts(theme::font_definitions_for_language(language)); + theme::apply(ctx, dark); + } + + /// Test-harness setup: mark a deterministic font set (see + /// [`theme::pinned_font_definitions`]) as already installed, so the first `logic` pass neither + /// starts the host font scan nor replaces the harness's pinned set. Production uses + /// [`Self::setup_context`] instead. + #[doc(hidden)] + pub fn use_prepared_fonts(&mut self) { + self.prepared_fonts = true; + self.fonts_ready = true; + self.fonts_set = true; + self.fonts_rebind = false; + self.fonts_han = Some(theme::HanOrder::of(self.ui.language)); + self.fonts_bound = self.used_fonts(); + self.fonts_seen = self.fonts_key(); + } + + /// The font names the open workbooks use: every style's font plus the theme's heading/body + /// fonts. These are the families [`fonts`] binds an installed file for. Every workbook's, as + /// each may be showing in a window of its own. + pub fn used_fonts(&self) -> Vec { + let mut names: Vec = Vec::new(); + for d in self.session.documents() { + names.extend(d.wb.styles.iter().map(|(_, s)| s.font.name.clone())); + names.push(d.wb.theme.major_font.clone()); + names.push(d.wb.theme.minor_font.clone()); + // Text boxes and shapes name their own typefaces. + names.extend(d.wb.sheets.iter().flat_map(|sh| sh.shapes.iter().filter_map(|s| s.format.font.clone()))); + } + names + } + + /// The open workbooks' (uid, revision), folded: [`Self::used_fonts`] changes only with it. + fn fonts_key(&self) -> (u64, u64) { + self.session + .documents() + .iter() + .fold((0u64, 0u64), |(uid, rev), d| (uid.wrapping_mul(31).wrapping_add(d.uid), rev.wrapping_mul(31).wrapping_add(d.revision))) + } + + /// Runs an engine command (or a UI command) and handles UI requests it makes. + pub fn run(&mut self, id: &str, params: Json) -> Result { + let r = self.run_command(id, params); + // This copy leaves the system clipboard alone: note what it holds, so Paste can tell + // that GridCraft's copy is newer while that text is still there. + if matches!(id, "edit.copy" | "edit.cut") && r.is_ok() { + self.clipboard_text_at_copy = self.system_clipboard_text(); + self.cut_text = if id == "edit.cut" { self.clipboard_text_at_copy.clone() } else { None }; + } + r + } + + fn run_command(&mut self, id: &str, params: Json) -> Result { + let switches_window = id.starts_with("window.") + || matches!(id, "view.newWindow" | "view.hideWindow" | "view.unhideWindow") + || (id == "view.switchWindows" && ["window", "doc"].iter().any(|k| params.get(*k).is_some_and(|d| !d.is_null()))); + if id.starts_with("file.") || switches_window { + self.settle_drafts()?; + } + if matches!(id, "file.save" | "file.saveAs" | "file.saveBytes") { + self.store_view_state(); + } + if matches!( + id, + "sheet.activate" + | "sheet.next" + | "sheet.previous" + | "sheet.move" + | "sheet.hide" + | "sheet.unhide" + | "home.insertSheet" + | "home.deleteSheet" + | "edit.undo" + | "edit.redo" + | "object.delete" + | "object.move" + ) { + self.commit_text_box_edit()?; + } + if let Some(r) = self.run_ui_command(id, ¶ms) { + return r; + } + let before = self.session.active_window(); + let r = self.session.run(id, params); + if id.starts_with("file.") && self.session.active_window() != before { + // File › New or Open from the Backstage view: the window it was in goes back to its sheet. + self.backstage = None; + } + // Requests the command made (a dialog, a draft) belong to the window it left active. + self.follow_active_window(); + self.after_engine(); + if id == "view.arrangeAll" + && self.native_windows + && let Ok(r) = &r + { + let arrange = r.get("arrange").and_then(Json::as_str).unwrap_or("tiled").to_string(); + let windows = r.get("windows").and_then(Json::as_array).map(|a| a.iter().filter_map(Json::as_u64).collect()).unwrap_or_default(); + self.arrange_windows = Some((arrange, windows)); + } + // The new chart is selected, as after inserting any object, so Delete removes it, not its data. + if id == "insert.chart" + && let Ok(r) = &r + && let Some(chart) = r.get("chart").and_then(Json::as_u64).and_then(|id| u32::try_from(id).ok()) + { + self.selected_chart = Some(chart); + } + if id == "file.exportPdf" + && let Ok(r) = &r + { + self.deliver_pdf(r); + } + r + } + + /// Copy or cut the selected cells to the system clipboard. Engine-only commands keep + /// their existing internal clipboard behavior and do not write to the host clipboard. + pub fn copy_to_clipboard(&mut self, ctx: &egui::Context, command: &str) { + // Only ask the engine for HTML when the host can publish it. + let result = match self.run_command(command, json!({"html": self.services.copy_html.is_some()})) { + Ok(result) => result, + Err(e) => return self.alert(e), + }; + let Some(text) = result.get("text").and_then(Json::as_str) else { return }; + // The system clipboard gets this copy, so its text decides what Paste pastes. + self.clipboard_text_at_copy = None; + self.cut_text = (command == "edit.cut").then(|| text.replace("\r\n", "\n")); + if let Some(html) = result.get("html").and_then(Json::as_str) + && let Some(copy_html) = &mut self.services.copy_html + { + match copy_html(html, text) { + Ok(()) => return, + Err(error) => log::warn!("HTML clipboard unavailable; copying plain text: {error}"), + } + } + ctx.copy_text(text.to_string()); + } + + /// Paste text that came from the system clipboard (Cmd/Ctrl+V, or the ribbon and menu Paste), + /// with the clipboard's HTML flavour when the host can read it. + /// + /// Excel pastes a cut once: after GridCraft's cut has been pasted, the system clipboard text + /// that came with it is not pasted again over the moved block; GridCraft's own (empty) + /// clipboard is pasted instead, which reports "Nothing to paste.". Other text means the + /// clipboard has moved on, so the cut's text is forgotten and pastes normally later. + pub fn paste_external(&mut self, text: String) { + if Self::same_clipboard_text(Some(&text), self.cut_text.as_deref()) { + if self.session.clipboard.is_none() { + return self.run_or_alert("edit.paste", json!({})); + } + } else { + self.cut_text = None; + } + let html = self.services.paste_html.as_mut().and_then(|read| read()); + let params = match html { + Some(html) => json!({"text": text, "html": html}), + None => json!({"text": text}), + }; + self.run_or_alert("edit.paste", params); + } + + /// Ribbon and menu Paste: paste the text on the system clipboard, as Cmd/Ctrl+V does. With no + /// text there, no host clipboard (web), or the text that was there when GridCraft last copied + /// without publishing, GridCraft's own clipboard is pasted. + pub fn paste_from_clipboard(&mut self) { + match self.system_clipboard_text() { + Some(text) if self.session.clipboard.is_none() || self.clipboard_text_at_copy.as_ref() != Some(&text) => self.paste_external(text), + _ => self.run_or_alert("edit.paste", json!({})), + } + } + + /// The system clipboard's plain text, with the line endings of egui's `Event::Paste` text. + /// `None` when it holds no text or the host can't read it. + fn system_clipboard_text(&mut self) -> Option { + self.services.paste_text.as_mut().and_then(|read| read()).filter(|t| !t.is_empty()).map(|t| t.replace("\r\n", "\n")) + } + + /// Whether two clipboard texts are the same copy: trailing line breaks don't count, as in the + /// engine's `edit.paste`, and CRLF matches LF (egui's `Event::Paste` text may keep the host's). + fn same_clipboard_text(a: Option<&str>, b: Option<&str>) -> bool { + fn chars(t: &str) -> impl Iterator + '_ { + t.trim_end_matches(['\r', '\n']).chars().filter(|c| *c != '\r') + } + match (a, b) { + (Some(a), Some(b)) => chars(a).eq(chars(b)), + _ => false, + } + } + + /// Web: `file.exportPdf` without a path returns the PDF as base64; hand it to the browser as a download. + fn deliver_pdf(&self, result: &Json) { + if result.get("path").is_some() { + return; + } + if let Some(dl) = &self.services.download + && let Some(b) = result.get("base64").and_then(Json::as_str).and_then(gridcraft_engine::io::base64_decode) + { + let title = self.session.active().map(|d| d.display_title()).unwrap_or_else(|| "Book1".into()); + dl(&std::path::Path::new(&title).with_extension("pdf").to_string_lossy(), &b); + } + } + + /// Runs a command and shows its error in a message box (for menu/ribbon clicks). + /// Follow the desktop's light/dark choice while the app runs (Linux; the desktop app calls + /// this once — tests and embedders don't start the watcher thread). + pub fn watch_desktop_theme(&mut self) { + self.desktop_theme = desktop_theme::DesktopTheme::start(); + } + + pub fn run_or_alert(&mut self, id: &str, params: Json) { + // A menu/shortcut opens the settings first; the Print page's button submits explicitly. + if id == "file.print" { + if self.services.print_native.is_some() { + self.open_backstage(backstage::Page::Print); + return; + } + return self.print_now(params); + } + // Menu-style Export as PDF with no file named: ask where to save, like Save As does. + // Programmatic callers use `run` and keep getting the PDF bytes back. + if id == "file.exportPdf" && self.services.pick_save.is_some() && params.get("path").and_then(Json::as_str).is_none_or(str::is_empty) { + return self.export_pdf_with_picker(params); + } + // Recommended Charts with no chart type named (ribbon button, keytip, command search) asks + // which chart to insert; programmatic callers of `run` still get the first recommendation. + if id == "insert.recommendedCharts" && params.get("type").is_none() { + return self.open_dialog("recommendedCharts", json!({})); + } + // Switch Windows with no window named (key tip, command search) opens the View tab's + // list to choose from; programmatic callers of `run` get the list back. + if id == "view.switchWindows" && ["window", "doc"].iter().all(|k| params.get(*k).is_none_or(Json::is_null)) { + self.ui.ribbon_tab = "View".into(); + self.ui.ribbon_collapsed = false; + self.open_switch_windows = true; + return; + } + match self.run(id, params) { + // A refused cell draft already shows its own message (with the rule's title). + Err(e) if self.message.as_ref().is_some_and(|(_, m)| *m == e) => {} + Err(e) => self.alert(e), + Ok(_) => {} + } + } + + /// Asks for a `.pdf` path with the save picker, then re-runs `file.exportPdf` with it. Other + /// params (range, sheets, fitToPage) are kept. A cancelled picker does nothing. + fn export_pdf_with_picker(&mut self, mut params: Json) { + if !self.commit_edit(0, 0, false, false) { + return; + } + let title = self.session.active().map(|d| d.display_title()).unwrap_or_else(|| "Book1".into()); + let stem = std::path::Path::new(&title).file_stem().and_then(std::ffi::OsStr::to_str).unwrap_or("Book1").to_string(); + let Some(pick) = &self.services.pick_save else { return }; + let Some(path) = pick(&format!("{stem}.pdf")) else { return }; + // The picker may suggest a workbook extension; the export writes a PDF. + let path = std::path::Path::new(&path).with_extension("pdf").to_string_lossy().into_owned(); + match &mut params { + Json::Object(map) => { + map.insert("path".into(), Json::String(path)); + } + other => *other = json!({"path": path}), + } + if let Err(e) = self.run("file.exportPdf", params) { + self.alert(e); + } + } + + /// Shows a command error: a toast for an unavailable command, else a message box. + fn alert(&mut self, e: String) { + // Shown in the window the user ends up in, also after a command that switched windows. + self.follow_active_window(); + if e.contains("is not available right now") { + self.toast = Some((e, now_ms())); + } else { + self.message = Some(("GridCraft".into(), clean_error(&e))); + } + } + + /// Builds the selected sheets' print-ready PDF and hands it to the desktop worker or the + /// browser. Rendering errors use the usual alert; asynchronous spooler errors stay visible + /// on the Print page. The engine command still returns PDF bytes without submitting. + pub fn print_now(&mut self, params: Json) { + if let Err(error) = self.submit_print(params) { + self.alert(error); + } + } + + fn submit_print(&mut self, params: Json) -> Result { + if self.services.print_native.is_some() && !self.printing.ready() { + return Err("Select an available printer in File > Print before submitting.".into()); + } + let pdf = self.run("file.print", params)?; + let encoded = pdf.get("base64").and_then(Json::as_str).ok_or("The print command did not produce a PDF.")?; + let bytes = gridcraft_engine::io::base64_decode(encoded).ok_or("Couldn't decode the generated PDF.")?; + let len = bytes.len(); + if let Some(submit) = &self.services.print_native { + self.printing.begin(submit(bytes, self.printing.settings.clone())).map_err(|_| "A print job is already being submitted.".to_owned())?; + self.print_feedback_seen = None; + } else if let Some(print) = &self.services.print { + print(&bytes); + } else { + return Err("Printing is unavailable here; export a PDF instead.".into()); + } + Ok(json!({"printing": true, "bytes": len, "pending": self.services.print_native.is_some()})) + } + + /// UI-only commands (dialogs, view toggles that aren't saved in the file). + fn run_ui_command(&mut self, id: &str, p: &Json) -> Option> { + let r = match id { + "app.checkForUpdates" | "help.checkForUpdates" => { + self.check_for_updates(); + Ok(Json::Null) + } + "ui.printSettings" => printing::settings(&mut self.printing, p, self.services.printers.as_ref()), + "ui.printSubmit" => self.submit_print(p.clone()), + "ui.ribbonTab" => { + self.ui.ribbon_tab = p.get("tab").and_then(Json::as_str).unwrap_or("Home").to_string(); + Ok(Json::Null) + } + "view.collapseRibbon" => { + self.ui.ribbon_collapsed = !self.ui.ribbon_collapsed; + Ok(Json::Null) + } + "view.expandFormulaBar" => { + self.ui.formula_bar_expanded = p.get("on").and_then(Json::as_bool).unwrap_or(!self.ui.formula_bar_expanded); + Ok(json!({"on": self.ui.formula_bar_expanded})) + } + "view.darkMode" => { + let current = if self.ui.system_theme { self.effective_dark } else { self.ui.dark }; + self.ui.dark = p.get("on").and_then(Json::as_bool).unwrap_or(!current); + self.ui.system_theme = false; + Ok(json!({"on": self.ui.dark})) + } + "view.theme" => { + match p.get("mode").and_then(Json::as_str) { + Some("system") => self.ui.system_theme = true, + Some(mode @ ("light" | "dark")) => { + self.ui.dark = mode == "dark"; + self.ui.system_theme = false; + } + _ => return Some(Err("theme mode must be system, light, or dark".into())), + } + Ok(json!({"mode": self.ui.theme_mode()})) + } + "view.animations" => { + self.ui.animations = p.get("on").and_then(Json::as_bool).unwrap_or(!self.ui.animations); + Ok(json!({"on": self.ui.animations})) + } + "options.autoComplete" => { + self.ui.autocomplete = p.get("on").and_then(Json::as_bool).unwrap_or(!self.ui.autocomplete); + Ok(json!({"on": self.ui.autocomplete})) + } + "view.interfaceScale" => { + // `{"percent": 125}` or `{"percent": "auto"}`; out-of-range values are clamped. + let percent = match p.get("percent") { + Some(Json::String(s)) if s.eq_ignore_ascii_case("auto") => Some(100), + Some(v) => v.as_f64().and_then(clamp_interface_scale), + None => None, + }; + match percent { + Some(v) => { + self.ui.interface_scale = v; + Ok(json!({"percent": v})) + } + None => Err(format!("missing `percent` ({INTERFACE_SCALE_MIN}–{INTERFACE_SCALE_MAX}, or \"auto\")")), + } + } + "app.language.set" => { + // `{"language": "ja"}`; `code` is accepted as an alias. + let code = p.get("language").or_else(|| p.get("code")).and_then(Json::as_str).unwrap_or(""); + match i18n::Language::parse(code) { + Some(l) => { + self.ui.language = l; + Ok(json!({"language": l})) + } + None => Err(format!("unknown language {code:?} (use one of {})", i18n::Language::code_list(", "))), + } + } + "app.language.english" => { + self.ui.language = i18n::Language::En; + Ok(json!({"language": i18n::Language::En})) + } + "app.language.japanese" => { + self.ui.language = i18n::Language::Ja; + Ok(json!({"language": i18n::Language::Ja})) + } + "ui.backstage" => { + // `{"page": "info"}` opens File › Info; `{"page": "close"}` returns to the sheet. + match p.get("page").and_then(Json::as_str).map(str::trim) { + Some("close" | "none" | "") => self.backstage = None, + Some(id) => match backstage::Page::parse(id) { + Some(page) => self.open_backstage(page), + None => { + return Some(Err(format!( + "unknown File page {id:?} (use home, new, open, info, saveAs, print, export, options or close)" + ))); + } + }, + None => self.open_backstage(backstage::Page::Home), + } + Ok(json!({"page": self.backstage.as_ref().map(|b| b.page.id())})) + } + "ui.dialog" => { + let name = p.get("name").and_then(Json::as_str).unwrap_or(""); + self.open_dialog(name, p.clone()); + Ok(json!({"dialog": self.dialog.as_ref().map(|d| d.name())})) + } + "ui.message.dismiss" => { + self.message = None; + Ok(Json::Null) + } + "ui.notice" => Ok(match &self.notice { + Some(n) => json!({ + "text": n.text, + "kind": match n.kind { NoticeKind::Warning => "warning", NoticeKind::Info => "info" }, + "action": n.action, + }), + None => Json::Null, + }), + "ui.notice.dismiss" => { + self.notice = None; + Ok(Json::Null) + } + _ => return None, + }; + Some(r) + } + + /// Picks up requests commands made (dialogs, edit mode…). + pub fn after_engine(&mut self) { + // The requests belong to the window the command left active. + self.follow_active_window(); + for req in self.session.take_ui_requests() { + match req { + UiRequest::Dialog(name, params) => self.open_dialog(&name, params), + UiRequest::Message(m) => self.message = Some(("GridCraft".into(), m)), + UiRequest::EditCell(text) => { + let text = text.map(|text| { + self.session + .active() + .map(|d| formula_locale::display_template(&text, self.ui.language.formula_locale(), &d.wb, d.wb.active_sheet())) + .unwrap_or(text) + }); + self.begin_edit(text, false); + } + UiRequest::InsertName(name) => dialogs::insert_name(self, &name), + UiRequest::OpenUrl(u) => { + if let Some(f) = &self.services.open_url { + f(&u); + } + } + } + } + } + + /// Whether the active sheet of the active document is protected (drives the Protect/Unprotect + /// Sheet labels and actions). + pub fn active_sheet_protected(&self) -> bool { + self.session.active().and_then(|d| d.wb.active().map(|s| s.is_protected())).unwrap_or(false) + } + + pub fn open_dialog(&mut self, name: &str, params: Json) { + if let Err(e) = self.commit_text_box_edit() { + self.message = Some(("Text Box".into(), clean_error(&e))); + return; + } + // Every Protect Sheet entry point (ribbon menus, keytips, tab menu) becomes Unprotect Sheet + // on a protected sheet. A sheet without a password is unprotected straight away. + let name = if name == "protectSheet" && self.active_sheet_protected() { "unprotectSheet" } else { name }; + if name == "unprotectSheet" + && self + .session + .active() + .and_then(|d| d.wb.active()) + .and_then(|s| s.protection.as_ref()) + .is_some_and(|p| p.password.is_none() && p.legacy_password_hash.is_none()) + { + self.run_or_alert("review.unprotectSheet", json!({})); + return; + } + match name { + "open" => { + if let Some(start) = &self.services.open_async { + start(); + } else if let Some(pick) = &self.services.pick_open + && let Some(path) = pick() + { + self.open_path(&path); + } + } + "saveAs" => { + let suggested = self.session.active().map(|d| d.display_title()).unwrap_or_else(|| "Book1".into()); + if let Some(pick) = &self.services.pick_save { + if let Some(path) = pick(&suggested) { + let path = if std::path::Path::new(&path).extension().is_none() { format!("{path}.xlsx") } else { path }; + self.run_or_alert("file.save", json!({"path": path})); + } + } else if self.services.download.is_some() + && let Ok(r) = self.run("file.saveBytes", json!({"format": "xlsx"})) + && let Some(dl) = &self.services.download + && let Some(b) = r.get("base64").and_then(Json::as_str).and_then(gridcraft_engine::io::base64_decode) + { + let name = r.get("name").and_then(Json::as_str).unwrap_or("Book.xlsx").to_string(); + dl(&name, &b); + } + } + // File › Options (`file.options`) and the File tab itself open the Backstage view. + "options" => self.open_backstage(backstage::Page::Options), + "backstage" => { + let page = params.get("page").and_then(Json::as_str).and_then(backstage::Page::parse).unwrap_or(backstage::Page::Home); + self.open_backstage(page); + } + other => self.dialog = dialogs::Dialog::open(self, other, params), + } + } + + /// Opens the File tab's Backstage view on `page`, committing a cell still being edited first + /// (File actions work on the whole workbook). A draft that can't be committed keeps the sheet. + pub fn open_backstage(&mut self, page: backstage::Page) { + if !self.chrome.file_tab { + return; + } + if !self.commit_edit(0, 0, false, false) { + return; + } + match &mut self.backstage { + Some(b) => b.page = page, + None => self.backstage = Some(backstage::Backstage::new(page)), + } + } + + pub fn open_path(&mut self, path: &str) { + match self.run("file.open", json!({"path": path})) { + Ok(r) => self.after_open(Some(path), &r), + // A password-protected workbook: ask for the password; the dialog opens it. + Err(e) if e.contains(gridcraft_engine::io::PASSWORD_REQUIRED) => self.open_dialog("openPassword", json!({"path": path})), + Err(e) => self.message = Some(("GridCraft".into(), clean_error(&e))), + } + self.after_engine(); + } + + /// What follows a successful `file.open`: the workbook shows (not the Backstage), the Recent + /// list (for a file on disk) and the open warnings notice. Shared by File › Open, the + /// password dialog and the control channel. + pub fn after_open(&mut self, path: Option<&str>, r: &Json) { + self.backstage = None; + if let Some(path) = path { + self.ui.recent.retain(|p| p != path); + self.ui.recent.insert(0, path.to_string()); + self.ui.recent.truncate(20); + } + self.show_open_warnings(r); + } + + /// Turns the `warnings` a `file.open` result carries into an info-bar notice, choosing the + /// most important one (a preserved VBA project outranks, say, a skipped optional part). + /// Kept deliberately generic: any open-time warning can reach the user this way. + pub fn show_open_warnings(&mut self, r: &Json) { + // The opened workbook's window shows them, however it was opened (a script may have run + // `file.open` on the session directly). + self.follow_active_window(); + let Some(list) = r.get("warnings").and_then(Json::as_array) else { + self.notice = None; + return; + }; + let msgs: Vec<&str> = list.iter().filter_map(Json::as_str).collect(); + if msgs.is_empty() { + // A clean open clears any notice left over from the previous document. + self.notice = None; + return; + } + let vba: Vec<&str> = msgs.iter().copied().filter(|m| m.to_lowercase().contains("macro") || m.contains("VBA")).collect(); + if !vba.is_empty() { + // The reader says "dropped" when the VBA project was missing or unreadable; never promise to keep it then. + let dropped = vba.iter().any(|m| m.contains("dropped")); + let text = if dropped { + "This workbook's VBA macros could not be loaded and were dropped, so they will not be in the saved file. Keep the original file." + } else { + "This workbook contains VBA macros. GridCraft keeps them when you save as .xlsm, but does not run them." + }; + self.notice = Some(Notice { text: text.into(), kind: NoticeKind::Warning, action: Some(("What this means".into(), "macros".into())) }); + } else { + let text = if msgs.len() == 1 { + msgs[0].to_string() + } else { + format!("{} items in this file were handled differently. First: {}", msgs.len(), msgs[0]) + }; + self.notice = Some(Notice { text, kind: NoticeKind::Info, action: None }); + } + } + + /// The open windows in order, for the Switch Windows lists. + pub fn open_windows(&self) -> Vec { + let active = self.session.active_window(); + self.session + .windows() + .iter() + .filter_map(|w| { + let d = self.session.documents().iter().find(|d| d.uid == w.doc_uid)?; + let title = self.session.window_title(w.id)?; + Some(OpenWindow { window: w.id, doc: d.uid, title, dirty: d.is_dirty(), active: active == Some(w.id) }) + }) + .collect() + } + + /// The active workbook and sheet: what drafts, text boxes and picture targets belong to. + pub fn view_key(&self) -> Option<(u64, usize)> { + self.session.active().map(|d| (d.uid, d.wb.active_sheet())) + } + + /// The active window and sheet: each window scrolls on its own. + fn scroll_key(&self) -> Option<(u64, usize)> { + Some((self.session.active_window()?, self.session.active()?.wb.active_sheet())) + } + + pub fn view(&self) -> SheetView { + self.scroll_key().and_then(|k| self.views.get(&k).copied()).unwrap_or_else(|| self.saved_view()) + } + + pub fn view_mut(&mut self) -> Option<&mut SheetView> { + let k = self.scroll_key()?; + let saved = self.saved_view(); + Some(self.views.entry(k).or_insert(saved)) + } + + /// The view a sheet opens with: scrolled to the file's saved first visible cell (`topLeftCell`). + fn saved_view(&self) -> SheetView { + let Some(sh) = self.session.active().and_then(|d| d.wb.active()) else { return SheetView::default() }; + if sh.view != gridcraft_engine::model::SheetViewMode::Normal { + return SheetView::default(); + } + let (fr, fc) = sh.freeze.unwrap_or((0, 0)); + let top = sh.view_top_left; + let y = sh.row_top(top.row.max(fr)) - sh.row_top(fr); + let x = sh.col_left(top.col.max(fc)) - sh.col_left(fc); + SheetView { scroll: egui::vec2(x.max(0.0) as f32, y.max(0.0) as f32) } + } + + /// Hands the active document's scroll positions (first visible cell per sheet) and active + /// cell to the engine so a save writes them (`sheetView topLeftCell`). Normal view only: + /// Page Layout view scrolls over pages, not cells. + pub fn store_view_state(&mut self) { + let window = self.session.active_window(); + let Some(d) = self.session.active_mut() else { return }; + let mut tops = Vec::new(); + for (&(w, si), v) in &self.views { + let Some(sh) = d.wb.sheets.get(si).filter(|sh| Some(w) == window && sh.view == gridcraft_engine::model::SheetViewMode::Normal) else { + continue; + }; + let (fr, fc) = sh.freeze.unwrap_or((0, 0)); + // A hair past the edge so a row that starts exactly at the scroll offset is the one found. + let row = sh.row_at(sh.row_top(fr) + v.scroll.y.max(0.0) as f64 + 0.01); + let col = sh.col_at(sh.col_left(fc) + v.scroll.x.max(0.0) as f64 + 0.01); + tops.push((si, CellRef::new(row, col))); + } + d.store_view(&tops); + } + + /// Starts editing the active cell. `text` replaces the content (typing) or pre-fills it. + pub fn begin_edit(&mut self, text: Option, from_formula_bar: bool) { + // The source formula is display-only on spill children. Grid replacement keeps its + // existing behavior, but no formula-bar entry path may edit that displayed formula. + if from_formula_bar && formula_bar::active_spill_anchor(self).is_some() { + return; + } + if let Err(e) = self.commit_text_box_edit() { + self.message = Some(("Text Box".into(), clean_error(&e))); + return; + } + let Some(d) = self.session.active() else { return }; + let Some(sh) = d.wb.active() else { return }; + let at = sh.merge_at(d.selection.active).map(|m| m.start).unwrap_or(d.selection.active); + // A picture has no editable scalar text. Selecting it, F2, or clicking + // the formula bar must not turn its fallback value into a text edit. + // Typing still starts an intentional replacement. + if text.is_none() && sh.cell_pictures.contains_key(&at) { + return; + } + let locale = self.ui.language.formula_locale(); + let current = if sh.cell_pictures.contains_key(&at) { + String::new() + } else { + match sh.cell(at) { + Some(c) if c.formula.is_some() => formula_locale::display(&c.input_text(), locale, &d.wb, d.wb.active_sheet()), + _ => gridcraft_engine::display::edit_text(&d.wb, sh, at), + } + }; + let (text, replace) = match text { + Some(t) => (t, true), + None => (current.clone(), false), + }; + let mut ed = editor::EditState::new(d.uid, d.wb.active_sheet(), at, text, replace, from_formula_bar); + ed.window = self.session.active_window(); + ed.locale = locale; + if replace && self.ui.autocomplete && !ed.is_formula() { + ed.completion = editor::column_completion(sh, at, &ed.text); + } + self.editor = Some(ed); + self.session.mode = if replace { gridcraft_engine::Mode::Enter } else { gridcraft_engine::Mode::Edit }; + } + + /// Commits the edit; `then` moves the selection (dr, dc) afterwards. + /// + /// The draft goes into the window and workbook it was typed in. If another window is + /// showing, that window stays active afterwards; a draft its own workbook refuses + /// (validation, a bad formula) brings its window back to the front with the draft still open. + pub fn commit_edit(&mut self, dr: i64, dc: i64, array: bool, fill_selection: bool) -> bool { + let Some((doc, window)) = self.editor.as_ref().map(|ed| (ed.doc, ed.window)) else { return true }; + if !self.draft_elsewhere() { + return self.commit_edit_here(dr, dc, array, fill_selection); + } + // Its window, or (when that window closed) its workbook's most recent one. + let Some(owner) = window.filter(|w| self.session.window(*w).is_some()).or_else(|| self.session.doc_window(doc)) else { + self.cancel_edit(); + return true; + }; + let back = self.session.active_window(); + self.session.activate_window(owner); + if !self.commit_edit_here(dr, dc, array, fill_selection) { + // Refused: its window stays in front with the draft open and the reason shown. + return false; + } + if let Some(back) = back { + self.session.activate_window(back); + } + true + } + + /// The cell draft belongs to a window or workbook other than the one showing. + fn draft_elsewhere(&self) -> bool { + self.editor.as_ref().is_some_and(|ed| { + self.session.active().map(|d| d.uid) != Some(ed.doc) || ed.window.is_some_and(|w| self.session.active_window() != Some(w)) + }) + } + + /// Commits the cell and text-box drafts, as Enter would, before a command that can show + /// another workbook or write this one to a file, so a draft never lands in another workbook. + /// A draft its workbook refuses stays open with the reason shown, and the command doesn't run. + pub(crate) fn settle_drafts(&mut self) -> Result<(), String> { + if !self.commit_edit(0, 0, false, false) { + return Err(self.message.as_ref().map(|(_, m)| m.clone()).unwrap_or_else(|| "The cell being edited can't be entered.".into())); + } + self.commit_text_box_edit() + } + + fn commit_edit_here(&mut self, dr: i64, dc: i64, array: bool, fill_selection: bool) -> bool { + let Some(ed) = self.editor.take() else { return true }; + self.session.mode = gridcraft_engine::Mode::Ready; + let text = match &ed.completion { + Some(full) if full.to_lowercase().starts_with(&ed.text.to_lowercase()) => full.clone(), + _ => ed.text.clone(), + }; + // F2 then Enter (or a file command) on an untouched draft: nothing to write. + if !fill_selection && !array && ed.original.as_ref() == Some(&text) { + if dr != 0 || dc != 0 { + self.move_after_enter(dr, dc); + } + return true; + } + let text = match self.session.active().map(|d| formula_locale::canonical(&text, ed.locale, &d.wb, ed.sheet, ed.cell)) { + Some(Ok(text)) => text, + Some(Err(error)) => { + self.message = Some(("GridCraft".into(), error.to_string())); + self.editor = Some(ed); + self.session.mode = gridcraft_engine::Mode::Edit; + return false; + } + None => text, + }; + // Data validation. + if let Some(d) = self.session.active() + && let Some((dv, msg)) = gridcraft_engine::cmd::data::check_validation(&d.wb, ed.sheet, ed.cell, &text) + { + if dv.error_style == gridcraft_engine::model::ErrorStyle::Stop { + let title = if dv.error_title.is_empty() { "GridCraft".to_string() } else { dv.error_title.clone() }; + self.message = Some((title, msg)); + self.editor = Some(ed); + self.session.mode = gridcraft_engine::Mode::Edit; + return false; + } + self.toast = Some((msg, now_ms())); + } + // Point mode can leave another sheet active; the edit belongs to the sheet it started on. + if self.session.active().is_some_and(|d| d.wb.active_sheet() != ed.sheet) + && let Err(e) = self.session.run("sheet.activate", json!({"sheet": ed.sheet})) + { + self.message = Some(("GridCraft".into(), clean_error(&e))); + self.editor = Some(ed); + self.session.mode = gridcraft_engine::Mode::Edit; + return false; + } + // Committing nothing into an empty cell changes nothing: no undo step (#1855, Backspace has + // already cleared the cell as its own step). + let unchanged = !fill_selection + && !array + && text.is_empty() + && self + .session + .active() + .and_then(|d| d.wb.sheet(ed.sheet)) + .is_some_and(|sh| sh.cell(ed.cell).is_none_or(|c| c.formula.is_none() && c.value == gridcraft_engine::core::Value::Empty)); + let r = if unchanged { + Ok(json!({})) + } else if fill_selection { + self.session.run("range.fill", json!({"input": text})) + } else { + self.session.run("cell.set", json!({"cell": ed.cell.a1(), "input": text, "array": array})) + }; + match r { + Ok(_) => { + if dr != 0 || dc != 0 { + self.move_after_enter(dr, dc); + } + self.after_engine(); + true + } + Err(e) => { + self.message = Some(("GridCraft".into(), clean_error(&e))); + self.editor = Some(ed); + self.session.mode = gridcraft_engine::Mode::Edit; + false + } + } + } + + /// Commits the edit with an arrow key. The arrow is an ordinary move, so it ends a run of Tabs + /// instead of continuing it (Right) or going back to its column like Enter (Down), as in Excel. + pub fn commit_edit_arrow(&mut self, dr: i64, dc: i64) -> bool { + let run = self.tab_run.take(); + let ok = self.commit_edit(dr, dc, false, false); + self.tab_run = if ok { None } else { run }; + ok + } + + pub fn cancel_edit(&mut self) { + self.editor = None; + self.session.mode = gridcraft_engine::Mode::Ready; + } + + /// Enter/Tab move inside a multi-cell selection, otherwise to the next cell. + pub fn move_after_enter(&mut self, dr: i64, dc: i64) { + let multi = self.session.active().is_some_and(|d| !d.selection.is_single_cell()); + // Any other move (arrows, clicks, another sheet) leaves the selection off the cell the + // last Tab landed on, which ends the run. + let here = self.session.active().map(|d| (d.uid, d.wb.active_sheet(), d.selection.active)); + let run = self.tab_run.take().filter(|&(uid, sheet, _, at)| here == Some((uid, sheet, at))); + if multi { + let fwd = dr > 0 || dc > 0; + let _ = self.session.run("selection.next", json!({"forward": fwd, "byRow": dc != 0})); + } else if let (Some((_, _, col, at)), 1, 0) = (run, dr, dc) { + let back = i64::from(col) - i64::from(at.col); + let _ = self.session.run("selection.move", json!({"dr": 1, "dc": back})); + } else { + let _ = self.session.run("selection.move", json!({"dr": dr, "dc": dc})); + if dr == 0 && dc != 0 { + let start = run.map(|r| r.2); + self.tab_run = self.session.active().and_then(|d| { + let (uid, sheet) = (d.uid, d.wb.active_sheet()); + let col = start.or(here.map(|h| h.2.col))?; + Some((uid, sheet, col, d.selection.active)) + }); + } + } + self.grid.ensure_visible = true; + } + + /// Terminal feedback also reaches the normal window when printing starts through control. + /// Polls printing feedback. Called once per frame from [`Self::logic`]; the direct calls in + /// `tests/native_print.rs` deliberately bypass the lifecycle to test this helper alone. + pub fn poll_printing(&mut self, ctx: &egui::Context) { + self.printing.poll(ctx); + if self.printing.message == self.print_feedback_seen { + return; + } + self.print_feedback_seen = self.printing.message.clone(); + let Some(message @ (printing::Feedback::Submitted(_) | printing::Feedback::Failed(_) | printing::Feedback::WorkerStopped)) = + self.printing.message.clone() + else { + return; + }; + let text = printing::feedback(&message, self.ui.language, |label| self.ui.language.tr(label).to_owned()); + if matches!(message, printing::Feedback::Submitted(_)) { + self.toast = Some((text, now_ms())); + } else { + self.alert(text); + } + } + + /// Holds back a window close (the close button, Alt+F4, the OS quitting the app) while a + /// workbook has unsaved changes, and asks about them first. + fn guard_window_close(&mut self, ctx: &egui::Context) { + if self.native_windows || self.allow_close || !ctx.input(|i| i.viewport().close_requested()) { + return; + } + // A cell still being edited is part of the workbook, as for File › Close. + if !self.commit_edit(0, 0, false, false) { + ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose); + return; + } + if !self.session.documents().iter().any(|d| d.is_dirty()) { + return; + } + ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose); + match self.dialog.as_mut() { + // Already asking about a workbook (File › Close): answering it now goes on to quit. + Some(d) if d.name() == "saveChanges" => { + d.values.insert("quit".into(), json!(true)); + } + _ => self.continue_quit(ctx), + } + } + + /// Asks about the next unsaved workbook before quitting, in its window, or closes the app + /// once none is left. + pub(crate) fn continue_quit(&mut self, ctx: &egui::Context) { + let dirty = self.session.documents().iter().find(|d| d.is_dirty()).map(|d| d.uid); + match dirty.and_then(|uid| self.session.doc_window(uid)) { + Some(window) => { + // Its window comes forward (the native window too: see `doc_windows`). + self.enter_window(window); + let title = self.session.active().map(|d| d.display_title()).unwrap_or_default(); + self.open_dialog("saveChanges", json!({"title": title, "quit": true})); + } + None => { + self.allow_close = true; + ctx.send_viewport_cmd_to(egui::ViewportId::ROOT, egui::ViewportCommand::Close); + } + } + } + + /// Answers the Save Changes question about the active workbook (its buttons). While quitting, + /// the next unsaved workbook is asked about once this one is answered; Cancel stops the quit. + /// Returns whether the question is settled: Save keeps it open when the cell being edited + /// can't be entered. + pub fn answer_save_changes(&mut self, answer: SaveAnswer, quitting: bool, ctx: &egui::Context) -> bool { + match answer { + SaveAnswer::Cancel => {} + SaveAnswer::DontSave => { + self.run_or_alert("file.close", json!({"force": true, "document": true})); + if quitting { + self.continue_quit(ctx); + } + } + SaveAnswer::Save => { + // File operations include the cell still being edited, as File › Save does. + if !self.commit_edit(0, 0, false, false) { + return false; + } + self.run_or_alert("file.save", json!({})); + if self.session.active().is_some_and(|x| !x.is_dirty()) { + self.run_or_alert("file.close", json!({"force": true, "document": true})); + if quitting { + self.continue_quit(ctx); + } + } + } + } + true + } + + /// Quit (the macOS app menu, ⌘Q): commits every window's cell draft as Enter would, then asks + /// about each unsaved workbook in its window; Cancel on any of them stops the quit. A draft its + /// workbook refuses stops the quit too, with its window in front and the reason shown. + pub fn request_quit(&mut self, ctx: &egui::Context) { + while let Some(window) = self.parked_window_with_draft() { + self.enter_window(window); + if self.settle_drafts().is_err() { + return; + } + } + if self.settle_drafts().is_err() { + return; + } + if let Some(d) = self.dialog.as_mut().filter(|d| d.name() == "saveChanges") { + // Already asking about a workbook (File › Close): answering it goes on to quit. + d.values.insert("quit".into(), json!(true)); + return; + } + self.continue_quit(ctx); + } + + /// Per-frame logic (control channel, screenshots). Call before `ui`. + pub fn logic(&mut self, ctx: &egui::Context) { + self.ensure_text_width(ctx); + // A keytip control request lives one frame: the ribbon consumes it during `ui`, and + // anything that never reached a control (collapsed ribbon, tab switch) is dropped. + self.ui.keytip_invoke = None; + // Floating pictures' textures go with their workbook, even when no sheet is painted. + let open: Vec = self.session.documents().iter().map(|d| d.uid).collect(); + floating_pictures::forget_closed(ctx, &open); + self.apply_font_state(ctx); + if let Some(answer) = self.desktop_theme.take_change() { + self.desktop_dark = Some(answer); + } + self.sync_theme_preference(ctx); + self.sync_input_options(ctx); + // The geometry captures are bound to. Native frames recorded it from their fresh + // `RawInput` in `raw_input_hook` (including hidden logic-only ticks); the epoch is + // monotonic, so a resize back to the original values is still a new epoch and old pixels + // are never saved with new geometry. + let capture_geometry = control::current_capture_geometry(self, ctx); + control::note_capture_geometry(self, capture_geometry); + // Drain before admission: completed or timed-out captures release their slots and + // reservation first, so a new request is never refused for budget that is about to free. + // Screenshot results, deadlines and geometry validation belong to `logic`; `ui` only + // issues the capture command (after every widget, so a command cannot be missed). + control::drain_screenshots(self, ctx); + control::poll(self, ctx); + // Printing feedback is polled once per frame, here, so hidden windows and logic-only + // frames still see worker completion. `ui` and the desktop app no longer poll it. + self.poll_printing(ctx); + self.guard_window_close(ctx); + #[cfg(not(target_arch = "wasm32"))] + updates_ui::tick(self, ctx); + // Files read asynchronously (web file picker, dropped files). + self.poll_async_opens(); + self.autosave(ctx); + self.update_title(ctx); + self.request_hidden_paint(ctx); + // One window always shows a workbook, unless each workbook has a native window of its own + // (`doc_windows` decides what having none left means). + if self.session.active().is_none() && !self.native_windows { + self.session.new_workbook(); + } + self.follow_active_window(); + } + + /// Installs the text-width closure once, on the first frame. + fn ensure_text_width(&mut self, ctx: &egui::Context) { + if self.session.text_width.is_some() { + return; + } + let ctx = ctx.clone(); + self.session.text_width = Some(std::sync::Arc::new(move |text, font, exact| theme::cell_text_width(&ctx, text, font, exact))); + } + + /// Applies the font gates: a language Han-order change, startup/prepared fonts and workbook + /// font binding. Every branch keeps its one-frame paint gate. + fn apply_font_state(&mut self, ctx: &egui::Context) { + // A pending synthetic step owns the next real `ui` pass: `ui` returns at the font gate + // before it handles input, so a font change requested now would consume the step without + // applying it. Delay the change one frame; the hidden-paint request keeps it coming. + let defer_font_change = self.synthetic_pending || !self.synthetic.is_empty(); + if !defer_font_change && self.fonts_han.is_some_and(|han| han != theme::HanOrder::of(self.ui.language)) { + self.fonts_ready = false; + self.fonts_set = false; + } + if !self.fonts_ready { + self.start_font_setup(ctx); + } else if self.fonts_rebind { + // A font-set change made last frame is active now. + self.fonts_rebind = false; + } else if !defer_font_change && !self.prepared_fonts && fonts::scan_done() { + self.rebind_workbook_fonts(ctx); + } + } + + /// Applies the pending font set. New fonts apply from the next frame on: paint nothing until + /// then. Startup always proceeds: without it a queued step would be re-offered forever. + fn start_font_setup(&mut self, ctx: &egui::Context) { + if self.fonts_set { + self.fonts_ready = true; + return; + } + if self.prepared_fonts { + ctx.set_fonts(theme::pinned_font_definitions()); + theme::apply(ctx, self.ui.dark); + } else { + SheetApp::setup_context_for_language(ctx, self.ui.dark, self.ui.language); + } + self.fonts_set = true; + // The rebuilt set has no workbook fonts yet: bind them again once it is active. + self.fonts_bound.clear(); + self.fonts_seen = (0, 0); + self.fonts_han = Some(theme::HanOrder::of(self.ui.language)); + ctx.request_repaint(); + } + + /// Binds the workbook's installed fonts once the background scan has finished. Adding fonts + /// re-applies the font set, which egui activates at the next pass, so skip this frame's paint + /// (same one-frame cost as startup). Only when an open workbook changed. + fn rebind_workbook_fonts(&mut self, ctx: &egui::Context) { + let seen = self.fonts_key(); + if seen == self.fonts_seen { + return; + } + let used = self.used_fonts(); + self.fonts_seen = seen; + if used == self.fonts_bound { + return; + } + ctx.set_fonts(theme::font_definitions_with(self.ui.language, &used)); + self.fonts_bound = used; + self.fonts_rebind = true; + ctx.request_repaint(); + } + + /// Follows the desktop/system theme preference and records the effective theme. + fn sync_theme_preference(&mut self, ctx: &egui::Context) { + let preference = match ( + self.ui.system_theme, + ctx.system_theme().or_else(|| self.desktop_dark.map(|d| if d { egui::Theme::Dark } else { egui::Theme::Light })), + ) { + (true, Some(egui::Theme::Dark)) => egui::ThemePreference::Dark, + (true, Some(egui::Theme::Light)) => egui::ThemePreference::Light, + _ => self.ui.theme_preference(), + }; + if ctx.options(|o| o.theme_preference) != preference { + ctx.set_theme(preference); + ctx.request_repaint(); + } + self.effective_dark = ctx.theme() == egui::Theme::Dark; + } + + /// Keeps egui's own keyboard zoom and quit shortcuts off and applies GridCraft's zoom factor. + fn sync_input_options(&mut self, ctx: &egui::Context) { + // Interface size: the preference owns the zoom factor, so egui's own Ctrl/Cmd +/-/0 zoom + // keys stay off (they would also swallow GridCraft's shortcuts). + if ctx.options(|o| o.zoom_with_keyboard) { + ctx.options_mut(|o| o.zoom_with_keyboard = false); + } + // egui closes the window on Ctrl+Q (and with Shift or Alt held too) without asking to save + // (#1443). Excel for Windows has no such key (Ctrl+Q is Quick Analysis). With a native + // window per workbook egui's Cmd+Q would close only the window it was typed in: the menu + // bar's Quit item has the key instead (it asks about unsaved workbooks first). + if (ctx.os() != egui::os::OperatingSystem::Mac || self.native_windows) && ctx.options(|o| !o.quit_shortcuts.is_empty()) { + ctx.options_mut(|o| o.quit_shortcuts.clear()); + } + let zoom = self.ui.zoom_factor(); + if (ctx.zoom_factor() - zoom).abs() > 0.001 { + ctx.set_zoom_factor(zoom); + ctx.request_repaint(); + } + } + + /// Opens files that arrived asynchronously (web file picker, dropped files). + fn poll_async_opens(&mut self) { + let arrived: Vec<(String, Vec)> = self + .services + .inbox + .as_ref() + .map(|i| std::mem::take(&mut *i.lock().unwrap_or_else(std::sync::PoisonError::into_inner))) + .unwrap_or_default(); + for (name, bytes) in arrived { + let b64 = gridcraft_engine::io::base64_encode(&bytes); + match self.run("file.open", json!({"name": &name, "base64": &b64})) { + // A file picked from File › Open (web) arrives here: show it, not the Backstage. + Ok(r) => { + self.backstage = None; + self.show_open_warnings(&r); + } + Err(e) if e.contains(gridcraft_engine::io::PASSWORD_REQUIRED) => { + self.backstage = None; + self.open_dialog("openPassword", json!({"name": name, "base64": b64})); + } + Err(e) => self.message = Some(("GridCraft".into(), clean_error(&e))), + } + self.after_engine(); + } + } + + /// AutoSave: save workbooks that have a file shortly after they change. This decision lives in + /// `logic` so a hidden or logic-only frame still saves; the monotonic timestamp check keeps a + /// forced hidden UI pass from saving the same change twice. + fn autosave(&mut self, ctx: &egui::Context) { + let dirty_xlsx = self.session.active().is_some_and(|d| d.is_dirty() && d.path.as_deref().is_some_and(|p| p.ends_with(".xlsx"))); + let now = crate::monotonic_ms(); + let autosave_due = + self.ui.autosave && self.editor.is_none() && self.text_box_editor.is_none() && now - self.grid.last_autosave > 2000.0 && dirty_xlsx; + if autosave_due { + self.grid.last_autosave = now; + self.store_view_state(); + let _ = self.session.run("file.save", json!({})); + } else if self.ui.autosave && dirty_xlsx { + // Keep checking even when no input arrives while the window is hidden. + ctx.request_repaint_after(std::time::Duration::from_secs(2)); + } + } + + /// Look for a newer release once, in the background, as the desktop app starts (a no-op on the + /// web). A found release opens the update prompt; nothing is shown when GridCraft is current. + pub fn start_update_check(&mut self) { + #[cfg(not(target_arch = "wasm32"))] + updates_ui::start(self, false); + } + + /// Help ▸ Check for Updates…: look now, and say so when GridCraft is up to date or the check fails. + pub fn check_for_updates(&mut self) { + #[cfg(not(target_arch = "wasm32"))] + updates_ui::start(self, true); + #[cfg(target_arch = "wasm32")] + { + self.message = Some(("GridCraft".into(), "The web version is always the latest release.".into())); + } + } + + /// Window title, next to the close/autosave state: `ViewportCommand::Title` is valid outside a + /// pass and a control-channel rename while hidden updates it on return. + fn update_title(&mut self, ctx: &egui::Context) { + // Native windows get theirs from `doc_windows`. + if self.native_windows { + return; + } + let Some(d) = self.session.active() else { return }; + let title = format!("{}{}", d.display_title(), if d.is_dirty() { " — Edited" } else { "" }); + if self.grid.last_title.as_deref() != Some(title.as_str()) { + ctx.send_viewport_cmd(egui::ViewportCommand::Title(title.clone())); + self.grid.last_title = Some(title); + } + } + + /// A queued/settling capture with a capturable size and an unapplied synthetic step both need + /// a real `ui` pass. Once a capture is issued, its `Screenshot` command's own `wants_paint` + /// paints the hidden window; after that a full hidden pass every frame only wastes CPU. A + /// minimized request has no size, so no hidden pass can reach issuance: the restore pass issues + /// it instead. Native eframe honours `RequestPaintWhileHidden`; the web runner does not (hidden + /// tabs only run `run_logic`), so a hidden web tab queues the work until it is shown again. + /// This is a shared eframe web follow-up, not a GridCraft-local workaround, and the control + /// protocol documents the limitation. + fn request_hidden_paint(&self, ctx: &egui::Context) { + if !(self.shots.needs_hidden_paint() || self.synthetic_pending || !self.synthetic.is_empty()) { + return; + } + // The web runner has no `RequestPaintWhileHidden`; sending it there only logs an + // unhandled command. A visible tab still gets the repaint below; a hidden tab keeps the + // work queued (see the shared eframe follow-up in docs/egui-037-local-followups.md). + #[cfg(not(target_arch = "wasm32"))] + ctx.send_viewport_cmd(egui::ViewportCommand::RequestPaintWhileHidden); + ctx.request_repaint(); + } + + /// Records capture geometry from the fresh raw frame, then offers exactly one pending synthetic batch and tracks whether it was applied. + pub fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) { + // Capture geometry from this frame's real input before any synthetic event is added. This + // runs on every native frame, including hidden/minimized logic-only ticks, so a resize, + // DPI/zoom change, Wayland frame without `inner_rect`, or minimize/restore is recorded + // even though egui's last UI pass still describes the old layout. + let geometry = control::CaptureGeometry::from_raw_input(raw, ctx); + control::note_capture_geometry(self, geometry); + self.capture_geometry_from_raw = true; + // One unapplied gesture step at a time. eframe retains an unconsumed `RawInput` for the + // next pass and for a hidden logic-only tick, so the batch is offered once and survives + // until a `ui` pass applies it (which pops it). Press and release stay distinct batches. + // + // A completed egui pass may have consumed the injected events without applying them: the + // font gate returns before input handling, but `begin_pass` has already taken the events. + // `cumulative_pass_nr` only advances when a pass ends, so a newer value with the batch + // still pending means exactly that, and the step is offered again for the next real pass. + if self.synthetic_pending { + let consumed_by_an_unapplied_pass = self.synthetic_injected_at_pass.is_some_and(|pass| ctx.cumulative_pass_nr() > pass); + if !consumed_by_an_unapplied_pass { + return; + } + } + if let Some(batch) = self.synthetic.front() { + raw.events.extend(batch.iter().cloned()); + self.synthetic_pending = true; + self.synthetic_injected_at_pass = Some(ctx.cumulative_pass_nr()); + } + } + + /// Flip AutoSave (title-bar switch / Options ▸ Save). Persisted with the other UI + /// preferences, so it stays as the user left it across restarts (#1901). + pub fn toggle_autosave(&mut self) { + self.ui.autosave = !self.ui.autosave; + } + + /// Lays out the whole window. + pub fn ui(&mut self, ui: &mut egui::Ui) { + let ctx = ui.ctx().clone(); + if !self.fonts_ready || self.fonts_rebind { + ctx.request_repaint(); + return; + } + let t0 = now_ms(); + // A script may have switched windows since the last frame. + self.follow_active_window(); + text_box::before_ui(self, &ctx); + // Another window came forward without going through a command (which settles drafts + // first): the draft goes into its own window instead of floating over this one. If its + // workbook refuses it, its window comes back to the front with the draft open. + if self.draft_elsewhere() { + self.commit_edit(0, 0, false, false); + } + // The language the widgets translate with this frame (see `i18n::current`). + i18n::set_current(&ctx, self.ui.language); + #[cfg(not(target_arch = "wasm32"))] + updates_ui::show(self, &ctx); + let t = theme::Tokens::get(&ctx); + // A host can turn the File tab off while Backstage is already open: close it. + if !self.chrome.file_tab { + self.backstage = None; + } + // Keytips opened the File view (Alt, F): its letters keep working there. + if self.backstage.is_none() || self.keytips.prefix().is_some() { + self.ribbon_keys(&ctx); + } + if self.chrome.title_bar { + ribbon::title_bar(self, ui); + } + if self.backstage.is_some() { + // File tab: the Backstage view replaces everything below the title bar. + backstage::show(self, ui); + } else { + ribbon::show(self, ui); + widgets::notice_bar(self, ui); + if self.session.prefs.formula_bar { + formula_bar::show(self, ui); + } + if self.ui.status_bar { + tabs::status_bar(self, ui); + } + tabs::sheet_tabs(self, ui); + pivot_pane::show(self, ui); + panes::show(self, ui); + egui::CentralPanel::default().frame(egui::Frame::NONE.fill(t.grid_bg)).show(ui, |ui| { + let g0 = now_ms(); + grid::show(self, ui); + self.perf.grid_ms = now_ms() - g0; + }); + } + dialogs::show(self, &ctx); + widgets::message_box(self, &ctx); + widgets::toast(self, &ctx); + // Requests are admitted in `logic`, for the root viewport, so only its pass issues them. + if ctx.viewport_id() == egui::ViewportId::ROOT { + control::issue_screenshots(self, &ctx); + } + if !ctx.input(|i| i.focused) { + // Nothing else to do. + } + let now = now_ms(); + self.perf.frame_ms = now - t0; + if self.perf.last > 0.0 { + let dt = now - self.perf.last; + if dt > 0.0 { + self.perf.fps = self.perf.fps * 0.9 + 100.0 / dt; + } + } + self.perf.last = now; + // The step is applied only after a completed UI pass. A discarded or repeated pass of the + // same frame must not offer it again (grid sizing calls `ui` more than once per frame); + // provenance is the injected pass number, so only a pass that missed input handling is + // re-offered by `raw_input_hook`. + // Synthetic input reaches the root viewport only (`raw_input_hook`), so its pass consumes it. + if self.synthetic_pending && ctx.viewport_id() == egui::ViewportId::ROOT { + self.synthetic.pop_front(); + self.synthetic_pending = false; + self.synthetic_injected_at_pass = None; + } + } + + fn ribbon_keys(&mut self, ctx: &egui::Context) { + let before = self.keytips.prefix().map(str::to_string); + let enabled = self.editor.is_none() + && self.dialog.is_none() + && self.message.is_none() + && self.name_box.is_none() + && !ctx.text_edit_focused() + && self.grid.context_menu.is_none() + && self.grid.object_menu.is_none() + && self.grid.header_menu.is_none() + && self.grid.fill_menu.is_none() + && self.grid.filter_menu.is_none() + && self.grid.list_picker.is_none() + && self.grid.renaming_tab.is_none() + && (before.is_some() || !egui::Popup::is_any_open(ctx)); + let actions = self.keytips.process(ctx, enabled); + // Backing out of F to the tab letters, or finishing an F sequence, closes the File view + // that Alt, F opened: the command runs on the sheet, as it did without the view. + if self.backstage.is_some() && before.is_some() && (self.keytips.prefix() == Some("") || !actions.is_empty()) { + self.backstage = None; + } + if before.is_some() && before.as_deref() != self.keytips.prefix() && !ctx.input(|i| i.pointer.any_pressed()) { + // Leave pointer cancellation to the popup so clicking a menu item still works. + egui::Popup::close_all(ctx); + } + for action in actions { + match action { + keytips::Action::Tab("File") => self.open_backstage(backstage::Page::Home), + keytips::Action::Tab(tab) => { + self.run_or_alert("ui.ribbonTab", json!({"tab": tab})); + self.ui.ribbon_collapsed = false; + } + keytips::Action::Command(id @ ("edit.copy" | "edit.cut")) => self.copy_to_clipboard(ctx, id), + keytips::Action::Command("edit.paste") => self.paste_from_clipboard(), + keytips::Action::Command(id) => self.run_or_alert(id, json!({})), + keytips::Action::CommandWith(id, params) => { + let params: Json = serde_json::from_str(params).unwrap_or_else(|_| json!({})); + // A Borders preset picked by key tip becomes the split button's face, as a + // click on the same menu item does. + if id == "home.borders" + && let Some(preset) = params.get("preset").and_then(Json::as_str) + { + self.grid.last_border = preset.to_string(); + } + self.run_or_alert(id, params); + } + keytips::Action::Dialog(name) => self.open_dialog(name, json!({})), + keytips::Action::DialogWith(name, params) => self.open_dialog(name, serde_json::from_str(params).unwrap_or_else(|_| json!({}))), + // Reserved sequence: fires silently while its command is missing. When the + // command lands in the catalog it goes live with no table edit. + keytips::Action::Pending(id) => { + if gridcraft_engine::find_command(id).is_some() { + self.run_or_alert(id, json!({})); + } + } + // Route to the ribbon control that owns this keytip: it opens its dropdown or + // runs its click handler with the right params (the engine command alone would + // be missing them). + keytips::Action::Control(id) => self.ui.keytip_invoke = Some(id), + } + } + } + + /// Current selection's top-left cell text as A1 (for the Name Box). + pub fn name_box_text(&self) -> String { + let Some(d) = self.session.active() else { return String::new() }; + let sel = &d.selection; + let cur = sel.current(); + // A named range that exactly matches shows its name. + let sh = d.wb.active(); + if let Some(sh) = sh { + for n in &d.wb.names { + let target = n.formula.replace('$', ""); + let (sheet, body) = match target.rsplit_once('!') { + Some((s, b)) => (Some(gridcraft_engine::core::unquote_sheet(s)), b.to_string()), + None => (None, target.clone()), + }; + if sheet.as_deref().is_none_or(|s| s.eq_ignore_ascii_case(&sh.name)) && RangeRef::parse(&body) == Some(cur) { + return n.name.clone(); + } + } + } + if self.grid.drag_select && !cur.is_single() { + return format!("{}R x {}C", cur.height(), cur.width()); + } + sel.active.a1() + } +} + +#[cfg(test)] +mod lifecycle_tests { + use super::*; + + /// Builds a key-A press or release event without modifiers. + fn key_event(pressed: bool) -> egui::Event { + egui::Event::Key { key: egui::Key::A, physical_key: None, pressed, repeat: false, modifiers: egui::Modifiers::NONE } + } + + /// Returns a test app with a new workbook and default services. + fn app() -> SheetApp { + let mut session = Session::new(); + session.new_workbook(); + SheetApp::new(session, Services::default()) + } + + /// One headless pass; a test has no renderer for the texture atlas delta (0.37 panics when it + /// is dropped unapplied). + fn ui_pass(ctx: &egui::Context, raw: egui::RawInput, f: impl FnMut(&mut egui::Ui)) -> egui::FullOutput { + let mut out = ctx.run_ui(raw, f); + out.textures_delta.clear(); + out + } + + #[test] + /// Only one synthetic step is offered, is not duplicated by a hidden logic-only tick, and press/release remain distinct batches. + fn a_hidden_logic_tick_keeps_one_unapplied_synthetic_step() { + let ctx = egui::Context::default(); + let mut app = app(); + app.synthetic.push_back(vec![key_event(true)]); + app.synthetic.push_back(vec![key_event(false)]); + + let mut first = egui::RawInput::default(); + app.raw_input_hook(&ctx, &mut first); + assert_eq!(first.events.len(), 1, "only one step is offered"); + assert!(app.synthetic_pending); + assert_eq!(app.synthetic.len(), 2, "a logic-only frame does not consume the step"); + + // eframe carries the unconsumed `RawInput` into the next pass, so the hook offers the step + // only once; a hidden logic-only tick does not duplicate it or offer the release. + let mut second = egui::RawInput::default(); + app.raw_input_hook(&ctx, &mut second); + assert!(second.events.is_empty(), "an unapplied step is not injected a second time"); + assert_eq!(app.synthetic.len(), 2, "press and release stay distinct batches"); + } + + /// Returns raw input with a screen rect at the given size. + fn raw_with_size(size: egui::Vec2) -> egui::RawInput { + egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, size)), ..Default::default() } + } + + #[test] + /// A font-gated early-returning pass does not lose the queued step; the hook offers it again and a later real pass applies it. + fn a_font_gated_pass_offers_the_synthetic_step_again() { + let ctx = egui::Context::default(); + ctx.set_fonts(theme::font_definitions()); + let mut app = app(); + app.fonts_ready = true; + app.fonts_set = true; + // Binds `session.text_width`, as eframe's first frame does. + let _ = ctx.run_logic(&egui::RawInput::default(), |ctx| app.logic(ctx)); + app.synthetic.push_back(vec![key_event(true)]); + + // The font gate returns before input handling; `begin_pass` has still consumed the events. + app.fonts_rebind = true; + let mut raw = raw_with_size(egui::vec2(900.0, 700.0)); + app.raw_input_hook(&ctx, &mut raw); + assert_eq!(raw.events.len(), 1); + let _ = ui_pass(&ctx, raw, |ui| app.ui(ui)); + assert!(app.synthetic_pending, "the early-returning pass did not apply the step"); + assert_eq!(app.synthetic.len(), 1, "the step is still queued"); + + // A completed pass that did not apply it made the hook offer it again for a real pass. + app.fonts_rebind = false; + let mut raw = raw_with_size(egui::vec2(900.0, 700.0)); + app.raw_input_hook(&ctx, &mut raw); + assert_eq!(raw.events.len(), 1, "the step is not lost across the font gate"); + let _ = ui_pass(&ctx, raw, |ui| app.ui(ui)); + assert!(!app.synthetic_pending); + assert!(app.synthetic.is_empty(), "the real UI pass applied the step"); + } + + #[test] + /// A language-driven font rebuild waits while a synthetic step is pending and proceeds once it is clear. + fn a_pending_step_delays_a_font_change() { + let ctx = egui::Context::default(); + let mut app = app(); + app.fonts_ready = true; + app.fonts_han = Some(theme::HanOrder::Chinese); + app.ui.language = i18n::Language::Ja; + app.synthetic.push_back(vec![key_event(true)]); + app.synthetic_pending = true; + let _ = ctx.run_logic(&egui::RawInput::default(), |ctx| app.logic(ctx)); + assert!(app.fonts_ready, "the font rebuild waits for the pass that applies the step"); + app.synthetic.clear(); + app.synthetic_pending = false; + let _ = ctx.run_logic(&egui::RawInput::default(), |ctx| app.logic(ctx)); + assert!(!app.fonts_ready, "without pending input the language rebuild proceeds"); + } + + #[test] + /// Logic ticks see fresh viewport and DPI information; resizing back to the original size still advances the capture epoch. + fn a_hidden_logic_tick_sees_fresh_viewport_info_and_keeps_the_epoch_monotonic() { + /// Runs one logic tick with the requested width and native scale. + fn tick(ctx: &egui::Context, app: &mut SheetApp, width: f32, native_ppp: f32) { + let mut raw = + egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(width, 400.0))), ..Default::default() }; + if let Some(viewport) = raw.viewports.get_mut(&egui::ViewportId::ROOT) { + viewport.inner_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(width, 400.0))); + viewport.native_pixels_per_point = Some(native_ppp); + } + let _ = ctx.run_logic(&raw, |ctx| app.logic(ctx)); + } + let ctx = egui::Context::default(); + let mut app = app(); + tick(&ctx, &mut app, 800.0, 2.0); + let first = app.capture_geometry.expect("the first logic tick records the geometry"); + assert_eq!(first.viewport, Some(egui::vec2(800.0, 400.0))); + assert_eq!(first.pixels_per_point, 2.0, "native scale times the interface zoom"); + tick(&ctx, &mut app, 900.0, 2.0); + let resized = app.capture_geometry.expect("geometry"); + assert_eq!(resized.viewport, Some(egui::vec2(900.0, 400.0)), "run_logic's ViewportInfo is the fresh size"); + let epoch = app.capture_epoch; + tick(&ctx, &mut app, 800.0, 2.0); + assert_eq!(app.capture_geometry.map(|g| g.viewport), Some(Some(egui::vec2(800.0, 400.0)))); + assert!(app.capture_epoch > epoch, "resizing back is still a new epoch: old pixels must not be saved"); + } + + #[test] + /// A resize and a resize back both advance the capture epoch. + fn the_capture_epoch_changes_even_when_the_geometry_returns() { + /// Runs one UI pass at the requested width. + fn tick(ctx: &egui::Context, app: &mut SheetApp, width: f32) { + let raw = + egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(width, 400.0))), ..Default::default() }; + let _ = ui_pass(ctx, raw, |ui| app.logic(ui.ctx())); + } + let ctx = egui::Context::default(); + let mut app = app(); + tick(&ctx, &mut app, 800.0); + let base = app.capture_epoch; + tick(&ctx, &mut app, 900.0); + let changed = app.capture_epoch; + assert!(changed > base, "a resize increments the capture epoch"); + tick(&ctx, &mut app, 800.0); + assert!(app.capture_epoch > changed, "resizing back is still a new epoch: old pixels must not be saved"); + } + + /// Extracts title viewport commands from a logic output. + fn titles(out: &egui::LogicOutput) -> Vec { + out.viewport_commands + .values() + .flatten() + .filter_map(|c| match c { + egui::ViewportCommand::Title(title) => Some(title.clone()), + _ => None, + }) + .collect() + } + + #[test] + /// A hidden logic tick saves a dirty file-backed workbook once and clears its dirty state. + fn a_logic_only_tick_autosaves_a_dirty_workbook_once() { + let ctx = egui::Context::default(); + let mut app = app(); + let dir = std::env::temp_dir().join(format!("gridcraft-logic-autosave-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("autosave.xlsx"); + app.run("cell.set", serde_json::json!({"cell": "A1", "input": "42"})).unwrap(); + app.ui.autosave = true; + app.session.active_mut().unwrap().path = Some(path.to_string_lossy().into_owned()); + app.grid.last_autosave = monotonic_ms() - 3000.0; + + let _ = ctx.run_logic(&egui::RawInput::default(), |ctx| app.logic(ctx)); + assert!(path.exists(), "a hidden logic-only tick saves a dirty workbook with a file"); + assert!(!app.session.active().unwrap().is_dirty(), "the save clears the dirty state"); + let saved_at = app.grid.last_autosave; + let _ = ctx.run_logic(&egui::RawInput::default(), |ctx| app.logic(ctx)); + assert_eq!(app.grid.last_autosave, saved_at, "the same change is not saved a second time"); + + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + /// The title is sent on the first tick and after a rename, but not on every unchanged frame. + fn a_logic_only_tick_updates_the_title_once_per_change() { + let ctx = egui::Context::default(); + let mut app = app(); + let first = ctx.run_logic(&egui::RawInput::default(), |ctx| app.logic(ctx)); + let title = app.session.active().unwrap().display_title().to_string(); + let expected = format!("{title}{}", if app.session.active().unwrap().is_dirty() { " — Edited" } else { "" }); + assert_eq!(titles(&first), [expected], "the first logic tick titles the window"); + let second = ctx.run_logic(&egui::RawInput::default(), |ctx| app.logic(ctx)); + assert!(titles(&second).is_empty(), "an unchanged title is not sent every frame"); + + app.session.active_mut().unwrap().title = "Renamed".into(); + let third = ctx.run_logic(&egui::RawInput::default(), |ctx| app.logic(ctx)); + let expected = format!("Renamed{}", if app.session.active().unwrap().is_dirty() { " — Edited" } else { "" }); + assert_eq!(titles(&third), [expected], "a rename while hidden updates the title"); + } + + #[test] + /// A hidden tick surfaces a failed print job in the app message. + fn a_logic_only_tick_polls_printing_feedback() { + let ctx = egui::Context::default(); + let mut app = app(); + app.printing.printers.push(crate::printing::Printer { name: "Queue".into(), label: String::new(), default: true }); + app.run("ui.printSettings", serde_json::json!({"printer": "Queue"})).unwrap(); + app.services.print_native = Some(Box::new(|_, _| { + let (sender, receiver) = std::sync::mpsc::channel(); + sender.send(Err("printer is offline".into())).unwrap(); + receiver + })); + app.run("ui.printSubmit", serde_json::json!({})).unwrap(); + let _ = ctx.run_logic(&egui::RawInput::default(), |ctx| app.logic(ctx)); + assert_eq!(app.printing.message, Some(crate::printing::Feedback::Failed("printer is offline".into()))); + assert!(app.message.as_ref().is_some_and(|(_, text)| text.contains("printer is offline")), "logic surfaces the failure"); + } + + #[test] + /// Pending synthetic input makes logic request a real hidden paint. + fn a_pending_synthetic_step_requests_a_hidden_paint() { + let ctx = egui::Context::default(); + let mut app = app(); + app.synthetic.push_back(vec![key_event(true)]); + app.synthetic_pending = true; + let out = ui_pass(&ctx, egui::RawInput::default(), |ui| app.logic(ui.ctx())); + let painted = out + .viewport_output + .get(&ctx.viewport_id()) + .is_some_and(|output| output.commands.iter().any(|c| matches!(c, egui::ViewportCommand::RequestPaintWhileHidden))); + assert!(painted, "logic must ask for a real UI pass while a synthetic step is unapplied"); + } +} diff --git a/crates/ui-egui/src/clock.rs b/crates/ui-egui/src/clock.rs new file mode 100644 index 000000000..ca1b57783 --- /dev/null +++ b/crates/ui-egui/src/clock.rs @@ -0,0 +1,25 @@ +/// Wall-clock milliseconds since the Unix epoch, for user-visible timestamps (Backstage's "last +/// changed", toasts). It can jump when the system clock is adjusted, so it must not drive +/// deadlines or pacing; use [`monotonic_ms`] for those. +pub fn now_ms() -> f64 { + gridcraft_engine::core::date::unix_now_ms().unwrap_or(0.0) +} + +/// Monotonic milliseconds since the first call, for screenshot deadlines and AutoSave pacing. +/// +/// Deliberately separate from [`now_ms`]: a wall-clock adjustment must not extend (or expire) a +/// screenshot deadline or save a workbook twice. Native uses [`std::time::Instant`]; wasm uses the +/// browser's `performance.now()`, which is monotonic too. +#[cfg(not(target_arch = "wasm32"))] +#[allow(clippy::disallowed_methods)] // the std clock is read only off wasm +pub fn monotonic_ms() -> f64 { + use std::sync::OnceLock; + static START: OnceLock = OnceLock::new(); + START.get_or_init(std::time::Instant::now).elapsed().as_secs_f64() * 1000.0 +} + +#[cfg(target_arch = "wasm32")] +/// Web monotonic clock: the browser's `performance.now()`, or 0 when it is unavailable. +pub fn monotonic_ms() -> f64 { + web_sys::window().and_then(|window| window.performance()).map_or(0.0, |performance| performance.now()) +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 55b3bd740..421f71459 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -52,6 +52,15 @@ use serde::{Deserialize, Serialize}; use serde_json::{Value as Json, json}; pub use control::{ControlRequest, ControlResponse}; + +mod app; +mod clock; +mod services; +mod state; + +pub use clock::{monotonic_ms, now_ms}; +pub use services::{Chrome, Inbox, Services}; +pub use state::{NoticeKind, OpenWindow, SaveAnswer, SheetView}; pub mod desktop_theme; /// Persisted UI preferences. @@ -160,19 +169,6 @@ impl UiState { } } -/// Per-sheet view state (scroll position in sheet points). -#[derive(Clone, Copy, Debug, Default, PartialEq)] -pub struct SheetView { - pub scroll: egui::Vec2, -} - -/// Severity of a notice shown in the info bar under the ribbon. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum NoticeKind { - Info, - Warning, -} - /// A dismissible bar under the ribbon, in the spirit of Excel's message bar. Used to tell the /// user what an opened file carried that GridCraft handled differently (e.g. preserved macros). #[derive(Clone, Debug, PartialEq)] @@ -183,74 +179,6 @@ pub struct Notice { pub action: Option<(String, String)>, } -/// An answer to the Save Changes question. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum SaveAnswer { - Save, - DontSave, - Cancel, -} - -/// One open window, as View › Switch Windows and the macOS Window menu list it. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct OpenWindow { - /// The window's id, the `window` param of `view.switchWindows`. - pub window: u64, - /// Its workbook's `DocState::uid`. - pub doc: u64, - pub title: String, - pub dirty: bool, - pub active: bool, -} - -impl OpenWindow { - /// The title ("Book1", or "Book1:2" for a workbook's second window), with a bullet after it - /// while the workbook has unsaved changes. - pub fn label(&self) -> String { - if self.dirty { format!("{} •", self.title) } else { self.title.clone() } - } -} - -/// Host services the platform shell provides (file pickers, opening URLs). -#[derive(Default)] -pub struct Services { - /// Desktop: discover configured printers on a worker. - pub printers: Option, - /// Desktop: submit the PDF with the chosen settings on a worker. - pub print_native: Option, - pub pick_open: Option Option>>, - pub pick_save: Option Option>>, - /// Native picture picker (PNG/JPEG), separate from workbook opening. - pub pick_picture: Option Option>>, - /// Browser picture picker. Each dialog owns its inbox, so canceled picks cannot - /// insert into a subsequent dialog or open the image as a workbook. - pub pick_picture_async: Option>, - pub open_url: Option>, - /// Publish HTML and its plain-text alternative together. `Ok` means the host owns the - /// write (including any asynchronous fallback); an immediate error uses egui's text path. - pub copy_html: Option Result<(), String>>>, - /// Read the HTML flavour of the system clipboard when the user pastes text, so cells the - /// source spreadsheet recorded as numbers stay numbers ("Rp10,000.00" → 10000). `None` from - /// the host pastes the plain text alone. - pub paste_html: Option Option>>, - /// Read the system clipboard's plain text for the ribbon and menu Paste commands (Cmd/Ctrl+V - /// gets it from egui's `Event::Paste`). `None` from the host, or no service (web), pastes - /// GridCraft's own clipboard. - pub paste_text: Option Option>>, - /// Web: deliver a file to the user (name, bytes). - pub download: Option>, - /// Web: start an asynchronous file pick; the bytes arrive later in `inbox`. - pub open_async: Option>, - /// Files delivered asynchronously (name, bytes), opened on the next frame. - pub inbox: Option, - /// Web: hand PDF bytes to the browser's print flow directly (no download, - /// no intermediate file) — opens the system print dialog on that PDF. - pub print: Option>, -} - -/// Shared queue of files read asynchronously (browser file picker, drag and drop). -pub type Inbox = std::sync::Arc)>>>; - #[derive(Clone, Debug, Default)] pub struct Perf { pub frame_ms: f64, @@ -259,26 +187,6 @@ pub struct Perf { last: f64, } -/// Which parts of the app's own window chrome are drawn. Everything is on by default; a host -/// that embeds the sheet in its own window (and owns files itself) can turn off the title bar -/// and the File tab, whose Backstage view then never opens. Not persisted: it describes the -/// host, not a user preference. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct Chrome { - /// The title bar: AutoSave, Home, Save, Undo/Redo, the file name, Comments, Share, Search and - /// the theme switch. - pub title_bar: bool, - /// The ribbon's File tab and the Backstage view it opens (also reached by keyboard and - /// commands such as `ui.backstage`). - pub file_tab: bool, -} - -impl Default for Chrome { - fn default() -> Self { - Chrome { title_bar: true, file_tab: true } - } -} - pub struct SheetApp { pub session: Session, pub ui: UiState, @@ -381,1520 +289,6 @@ pub struct SheetApp { updates_rt: updates_ui::Runtime, } -impl SheetApp { - /// Constructs a `SheetApp` with default UI, grid, printing, control and screenshot state and the caller's session and services. - pub fn new(session: Session, services: Services) -> SheetApp { - SheetApp { - session, - ui: UiState::default(), - chrome: Chrome::default(), - views: HashMap::new(), - editor: None, - text_box_editor: None, - dialog: None, - backstage: None, - message: None, - toast: None, - notice: None, - services, - printing: Default::default(), - print_feedback_seen: None, - synthetic: Default::default(), - synthetic_pending: false, - synthetic_injected_at_pass: None, - control_rx: None, - grid: grid::GridState { - last_border: "bottom".into(), - last_fill: "#FFFF00".into(), - last_font_color: "#C00000".into(), - ..Default::default() - }, - perf: Perf::default(), - fonts_ready: false, - fonts_set: false, - prepared_fonts: false, - fonts_bound: Vec::new(), - fonts_seen: (0, 0), - fonts_rebind: false, - fonts_han: None, - effective_dark: false, - desktop_theme: desktop_theme::DesktopTheme::default(), - desktop_dark: None, - name_box: None, - keytips: keytips::KeyTips::default(), - shots: control::Shots::default(), - capture_epoch: 0, - capture_geometry: None, - capture_geometry_from_raw: false, - selected_chart: None, - clipboard_text_at_copy: None, - cut_text: None, - tab_run: None, - started: now_ms(), - allow_close: false, - open_switch_windows: false, - ui_window: None, - parked: HashMap::new(), - native_windows: false, - arrange_windows: None, - #[cfg(not(target_arch = "wasm32"))] - updates_rt: Default::default(), - } - } - - /// One-time context setup: start cataloging the machine's fonts, then install the fonts and - /// visuals. Workbook-specific fonts are bound later, once the scan is done (see [`logic`]). - /// - /// [`logic`]: SheetApp::logic - pub fn setup_context(ctx: &egui::Context, dark: bool) { - fonts::start_scan(); - Self::setup_context_for_language(ctx, dark, i18n::Language::En); - } - - /// Sets up fonts and visuals for a specific persisted interface language. - pub fn setup_context_for_language(ctx: &egui::Context, dark: bool, language: i18n::Language) { - ctx.set_fonts(theme::font_definitions_for_language(language)); - theme::apply(ctx, dark); - } - - /// Test-harness setup: mark a deterministic font set (see - /// [`theme::pinned_font_definitions`]) as already installed, so the first `logic` pass neither - /// starts the host font scan nor replaces the harness's pinned set. Production uses - /// [`Self::setup_context`] instead. - #[doc(hidden)] - pub fn use_prepared_fonts(&mut self) { - self.prepared_fonts = true; - self.fonts_ready = true; - self.fonts_set = true; - self.fonts_rebind = false; - self.fonts_han = Some(theme::HanOrder::of(self.ui.language)); - self.fonts_bound = self.used_fonts(); - self.fonts_seen = self.fonts_key(); - } - - /// The font names the open workbooks use: every style's font plus the theme's heading/body - /// fonts. These are the families [`fonts`] binds an installed file for. Every workbook's, as - /// each may be showing in a window of its own. - pub fn used_fonts(&self) -> Vec { - let mut names: Vec = Vec::new(); - for d in self.session.documents() { - names.extend(d.wb.styles.iter().map(|(_, s)| s.font.name.clone())); - names.push(d.wb.theme.major_font.clone()); - names.push(d.wb.theme.minor_font.clone()); - // Text boxes and shapes name their own typefaces. - names.extend(d.wb.sheets.iter().flat_map(|sh| sh.shapes.iter().filter_map(|s| s.format.font.clone()))); - } - names - } - - /// The open workbooks' (uid, revision), folded: [`Self::used_fonts`] changes only with it. - fn fonts_key(&self) -> (u64, u64) { - self.session - .documents() - .iter() - .fold((0u64, 0u64), |(uid, rev), d| (uid.wrapping_mul(31).wrapping_add(d.uid), rev.wrapping_mul(31).wrapping_add(d.revision))) - } - - /// Runs an engine command (or a UI command) and handles UI requests it makes. - pub fn run(&mut self, id: &str, params: Json) -> Result { - let r = self.run_command(id, params); - // This copy leaves the system clipboard alone: note what it holds, so Paste can tell - // that GridCraft's copy is newer while that text is still there. - if matches!(id, "edit.copy" | "edit.cut") && r.is_ok() { - self.clipboard_text_at_copy = self.system_clipboard_text(); - self.cut_text = if id == "edit.cut" { self.clipboard_text_at_copy.clone() } else { None }; - } - r - } - - fn run_command(&mut self, id: &str, params: Json) -> Result { - let switches_window = id.starts_with("window.") - || matches!(id, "view.newWindow" | "view.hideWindow" | "view.unhideWindow") - || (id == "view.switchWindows" && ["window", "doc"].iter().any(|k| params.get(*k).is_some_and(|d| !d.is_null()))); - if id.starts_with("file.") || switches_window { - self.settle_drafts()?; - } - if matches!(id, "file.save" | "file.saveAs" | "file.saveBytes") { - self.store_view_state(); - } - if matches!( - id, - "sheet.activate" - | "sheet.next" - | "sheet.previous" - | "sheet.move" - | "sheet.hide" - | "sheet.unhide" - | "home.insertSheet" - | "home.deleteSheet" - | "edit.undo" - | "edit.redo" - | "object.delete" - | "object.move" - ) { - self.commit_text_box_edit()?; - } - if let Some(r) = self.run_ui_command(id, ¶ms) { - return r; - } - let before = self.session.active_window(); - let r = self.session.run(id, params); - if id.starts_with("file.") && self.session.active_window() != before { - // File › New or Open from the Backstage view: the window it was in goes back to its sheet. - self.backstage = None; - } - // Requests the command made (a dialog, a draft) belong to the window it left active. - self.follow_active_window(); - self.after_engine(); - if id == "view.arrangeAll" - && self.native_windows - && let Ok(r) = &r - { - let arrange = r.get("arrange").and_then(Json::as_str).unwrap_or("tiled").to_string(); - let windows = r.get("windows").and_then(Json::as_array).map(|a| a.iter().filter_map(Json::as_u64).collect()).unwrap_or_default(); - self.arrange_windows = Some((arrange, windows)); - } - // The new chart is selected, as after inserting any object, so Delete removes it, not its data. - if id == "insert.chart" - && let Ok(r) = &r - && let Some(chart) = r.get("chart").and_then(Json::as_u64).and_then(|id| u32::try_from(id).ok()) - { - self.selected_chart = Some(chart); - } - if id == "file.exportPdf" - && let Ok(r) = &r - { - self.deliver_pdf(r); - } - r - } - - /// Copy or cut the selected cells to the system clipboard. Engine-only commands keep - /// their existing internal clipboard behavior and do not write to the host clipboard. - pub fn copy_to_clipboard(&mut self, ctx: &egui::Context, command: &str) { - // Only ask the engine for HTML when the host can publish it. - let result = match self.run_command(command, json!({"html": self.services.copy_html.is_some()})) { - Ok(result) => result, - Err(e) => return self.alert(e), - }; - let Some(text) = result.get("text").and_then(Json::as_str) else { return }; - // The system clipboard gets this copy, so its text decides what Paste pastes. - self.clipboard_text_at_copy = None; - self.cut_text = (command == "edit.cut").then(|| text.replace("\r\n", "\n")); - if let Some(html) = result.get("html").and_then(Json::as_str) - && let Some(copy_html) = &mut self.services.copy_html - { - match copy_html(html, text) { - Ok(()) => return, - Err(error) => log::warn!("HTML clipboard unavailable; copying plain text: {error}"), - } - } - ctx.copy_text(text.to_string()); - } - - /// Paste text that came from the system clipboard (Cmd/Ctrl+V, or the ribbon and menu Paste), - /// with the clipboard's HTML flavour when the host can read it. - /// - /// Excel pastes a cut once: after GridCraft's cut has been pasted, the system clipboard text - /// that came with it is not pasted again over the moved block; GridCraft's own (empty) - /// clipboard is pasted instead, which reports "Nothing to paste.". Other text means the - /// clipboard has moved on, so the cut's text is forgotten and pastes normally later. - pub fn paste_external(&mut self, text: String) { - if Self::same_clipboard_text(Some(&text), self.cut_text.as_deref()) { - if self.session.clipboard.is_none() { - return self.run_or_alert("edit.paste", json!({})); - } - } else { - self.cut_text = None; - } - let html = self.services.paste_html.as_mut().and_then(|read| read()); - let params = match html { - Some(html) => json!({"text": text, "html": html}), - None => json!({"text": text}), - }; - self.run_or_alert("edit.paste", params); - } - - /// Ribbon and menu Paste: paste the text on the system clipboard, as Cmd/Ctrl+V does. With no - /// text there, no host clipboard (web), or the text that was there when GridCraft last copied - /// without publishing, GridCraft's own clipboard is pasted. - pub fn paste_from_clipboard(&mut self) { - match self.system_clipboard_text() { - Some(text) if self.session.clipboard.is_none() || self.clipboard_text_at_copy.as_ref() != Some(&text) => self.paste_external(text), - _ => self.run_or_alert("edit.paste", json!({})), - } - } - - /// The system clipboard's plain text, with the line endings of egui's `Event::Paste` text. - /// `None` when it holds no text or the host can't read it. - fn system_clipboard_text(&mut self) -> Option { - self.services.paste_text.as_mut().and_then(|read| read()).filter(|t| !t.is_empty()).map(|t| t.replace("\r\n", "\n")) - } - - /// Whether two clipboard texts are the same copy: trailing line breaks don't count, as in the - /// engine's `edit.paste`, and CRLF matches LF (egui's `Event::Paste` text may keep the host's). - fn same_clipboard_text(a: Option<&str>, b: Option<&str>) -> bool { - fn chars(t: &str) -> impl Iterator + '_ { - t.trim_end_matches(['\r', '\n']).chars().filter(|c| *c != '\r') - } - match (a, b) { - (Some(a), Some(b)) => chars(a).eq(chars(b)), - _ => false, - } - } - - /// Web: `file.exportPdf` without a path returns the PDF as base64; hand it to the browser as a download. - fn deliver_pdf(&self, result: &Json) { - if result.get("path").is_some() { - return; - } - if let Some(dl) = &self.services.download - && let Some(b) = result.get("base64").and_then(Json::as_str).and_then(gridcraft_engine::io::base64_decode) - { - let title = self.session.active().map(|d| d.display_title()).unwrap_or_else(|| "Book1".into()); - dl(&std::path::Path::new(&title).with_extension("pdf").to_string_lossy(), &b); - } - } - - /// Runs a command and shows its error in a message box (for menu/ribbon clicks). - /// Follow the desktop's light/dark choice while the app runs (Linux; the desktop app calls - /// this once — tests and embedders don't start the watcher thread). - pub fn watch_desktop_theme(&mut self) { - self.desktop_theme = desktop_theme::DesktopTheme::start(); - } - - pub fn run_or_alert(&mut self, id: &str, params: Json) { - // A menu/shortcut opens the settings first; the Print page's button submits explicitly. - if id == "file.print" { - if self.services.print_native.is_some() { - self.open_backstage(backstage::Page::Print); - return; - } - return self.print_now(params); - } - // Menu-style Export as PDF with no file named: ask where to save, like Save As does. - // Programmatic callers use `run` and keep getting the PDF bytes back. - if id == "file.exportPdf" && self.services.pick_save.is_some() && params.get("path").and_then(Json::as_str).is_none_or(str::is_empty) { - return self.export_pdf_with_picker(params); - } - // Recommended Charts with no chart type named (ribbon button, keytip, command search) asks - // which chart to insert; programmatic callers of `run` still get the first recommendation. - if id == "insert.recommendedCharts" && params.get("type").is_none() { - return self.open_dialog("recommendedCharts", json!({})); - } - // Switch Windows with no window named (key tip, command search) opens the View tab's - // list to choose from; programmatic callers of `run` get the list back. - if id == "view.switchWindows" && ["window", "doc"].iter().all(|k| params.get(*k).is_none_or(Json::is_null)) { - self.ui.ribbon_tab = "View".into(); - self.ui.ribbon_collapsed = false; - self.open_switch_windows = true; - return; - } - match self.run(id, params) { - // A refused cell draft already shows its own message (with the rule's title). - Err(e) if self.message.as_ref().is_some_and(|(_, m)| *m == e) => {} - Err(e) => self.alert(e), - Ok(_) => {} - } - } - - /// Asks for a `.pdf` path with the save picker, then re-runs `file.exportPdf` with it. Other - /// params (range, sheets, fitToPage) are kept. A cancelled picker does nothing. - fn export_pdf_with_picker(&mut self, mut params: Json) { - if !self.commit_edit(0, 0, false, false) { - return; - } - let title = self.session.active().map(|d| d.display_title()).unwrap_or_else(|| "Book1".into()); - let stem = std::path::Path::new(&title).file_stem().and_then(std::ffi::OsStr::to_str).unwrap_or("Book1").to_string(); - let Some(pick) = &self.services.pick_save else { return }; - let Some(path) = pick(&format!("{stem}.pdf")) else { return }; - // The picker may suggest a workbook extension; the export writes a PDF. - let path = std::path::Path::new(&path).with_extension("pdf").to_string_lossy().into_owned(); - match &mut params { - Json::Object(map) => { - map.insert("path".into(), Json::String(path)); - } - other => *other = json!({"path": path}), - } - if let Err(e) = self.run("file.exportPdf", params) { - self.alert(e); - } - } - - /// Shows a command error: a toast for an unavailable command, else a message box. - fn alert(&mut self, e: String) { - // Shown in the window the user ends up in, also after a command that switched windows. - self.follow_active_window(); - if e.contains("is not available right now") { - self.toast = Some((e, now_ms())); - } else { - self.message = Some(("GridCraft".into(), clean_error(&e))); - } - } - - /// Builds the selected sheets' print-ready PDF and hands it to the desktop worker or the - /// browser. Rendering errors use the usual alert; asynchronous spooler errors stay visible - /// on the Print page. The engine command still returns PDF bytes without submitting. - pub fn print_now(&mut self, params: Json) { - if let Err(error) = self.submit_print(params) { - self.alert(error); - } - } - - fn submit_print(&mut self, params: Json) -> Result { - if self.services.print_native.is_some() && !self.printing.ready() { - return Err("Select an available printer in File > Print before submitting.".into()); - } - let pdf = self.run("file.print", params)?; - let encoded = pdf.get("base64").and_then(Json::as_str).ok_or("The print command did not produce a PDF.")?; - let bytes = gridcraft_engine::io::base64_decode(encoded).ok_or("Couldn't decode the generated PDF.")?; - let len = bytes.len(); - if let Some(submit) = &self.services.print_native { - self.printing.begin(submit(bytes, self.printing.settings.clone())).map_err(|_| "A print job is already being submitted.".to_owned())?; - self.print_feedback_seen = None; - } else if let Some(print) = &self.services.print { - print(&bytes); - } else { - return Err("Printing is unavailable here; export a PDF instead.".into()); - } - Ok(json!({"printing": true, "bytes": len, "pending": self.services.print_native.is_some()})) - } - - /// UI-only commands (dialogs, view toggles that aren't saved in the file). - fn run_ui_command(&mut self, id: &str, p: &Json) -> Option> { - let r = match id { - "app.checkForUpdates" | "help.checkForUpdates" => { - self.check_for_updates(); - Ok(Json::Null) - } - "ui.printSettings" => printing::settings(&mut self.printing, p, self.services.printers.as_ref()), - "ui.printSubmit" => self.submit_print(p.clone()), - "ui.ribbonTab" => { - self.ui.ribbon_tab = p.get("tab").and_then(Json::as_str).unwrap_or("Home").to_string(); - Ok(Json::Null) - } - "view.collapseRibbon" => { - self.ui.ribbon_collapsed = !self.ui.ribbon_collapsed; - Ok(Json::Null) - } - "view.expandFormulaBar" => { - self.ui.formula_bar_expanded = p.get("on").and_then(Json::as_bool).unwrap_or(!self.ui.formula_bar_expanded); - Ok(json!({"on": self.ui.formula_bar_expanded})) - } - "view.darkMode" => { - let current = if self.ui.system_theme { self.effective_dark } else { self.ui.dark }; - self.ui.dark = p.get("on").and_then(Json::as_bool).unwrap_or(!current); - self.ui.system_theme = false; - Ok(json!({"on": self.ui.dark})) - } - "view.theme" => { - match p.get("mode").and_then(Json::as_str) { - Some("system") => self.ui.system_theme = true, - Some(mode @ ("light" | "dark")) => { - self.ui.dark = mode == "dark"; - self.ui.system_theme = false; - } - _ => return Some(Err("theme mode must be system, light, or dark".into())), - } - Ok(json!({"mode": self.ui.theme_mode()})) - } - "view.animations" => { - self.ui.animations = p.get("on").and_then(Json::as_bool).unwrap_or(!self.ui.animations); - Ok(json!({"on": self.ui.animations})) - } - "options.autoComplete" => { - self.ui.autocomplete = p.get("on").and_then(Json::as_bool).unwrap_or(!self.ui.autocomplete); - Ok(json!({"on": self.ui.autocomplete})) - } - "view.interfaceScale" => { - // `{"percent": 125}` or `{"percent": "auto"}`; out-of-range values are clamped. - let percent = match p.get("percent") { - Some(Json::String(s)) if s.eq_ignore_ascii_case("auto") => Some(100), - Some(v) => v.as_f64().and_then(clamp_interface_scale), - None => None, - }; - match percent { - Some(v) => { - self.ui.interface_scale = v; - Ok(json!({"percent": v})) - } - None => Err(format!("missing `percent` ({INTERFACE_SCALE_MIN}–{INTERFACE_SCALE_MAX}, or \"auto\")")), - } - } - "app.language.set" => { - // `{"language": "ja"}`; `code` is accepted as an alias. - let code = p.get("language").or_else(|| p.get("code")).and_then(Json::as_str).unwrap_or(""); - match i18n::Language::parse(code) { - Some(l) => { - self.ui.language = l; - Ok(json!({"language": l})) - } - None => Err(format!("unknown language {code:?} (use one of {})", i18n::Language::code_list(", "))), - } - } - "app.language.english" => { - self.ui.language = i18n::Language::En; - Ok(json!({"language": i18n::Language::En})) - } - "app.language.japanese" => { - self.ui.language = i18n::Language::Ja; - Ok(json!({"language": i18n::Language::Ja})) - } - "ui.backstage" => { - // `{"page": "info"}` opens File › Info; `{"page": "close"}` returns to the sheet. - match p.get("page").and_then(Json::as_str).map(str::trim) { - Some("close" | "none" | "") => self.backstage = None, - Some(id) => match backstage::Page::parse(id) { - Some(page) => self.open_backstage(page), - None => { - return Some(Err(format!( - "unknown File page {id:?} (use home, new, open, info, saveAs, print, export, options or close)" - ))); - } - }, - None => self.open_backstage(backstage::Page::Home), - } - Ok(json!({"page": self.backstage.as_ref().map(|b| b.page.id())})) - } - "ui.dialog" => { - let name = p.get("name").and_then(Json::as_str).unwrap_or(""); - self.open_dialog(name, p.clone()); - Ok(json!({"dialog": self.dialog.as_ref().map(|d| d.name())})) - } - "ui.message.dismiss" => { - self.message = None; - Ok(Json::Null) - } - "ui.notice" => Ok(match &self.notice { - Some(n) => json!({ - "text": n.text, - "kind": match n.kind { NoticeKind::Warning => "warning", NoticeKind::Info => "info" }, - "action": n.action, - }), - None => Json::Null, - }), - "ui.notice.dismiss" => { - self.notice = None; - Ok(Json::Null) - } - _ => return None, - }; - Some(r) - } - - /// Picks up requests commands made (dialogs, edit mode…). - pub fn after_engine(&mut self) { - // The requests belong to the window the command left active. - self.follow_active_window(); - for req in self.session.take_ui_requests() { - match req { - UiRequest::Dialog(name, params) => self.open_dialog(&name, params), - UiRequest::Message(m) => self.message = Some(("GridCraft".into(), m)), - UiRequest::EditCell(text) => { - let text = text.map(|text| { - self.session - .active() - .map(|d| formula_locale::display_template(&text, self.ui.language.formula_locale(), &d.wb, d.wb.active_sheet())) - .unwrap_or(text) - }); - self.begin_edit(text, false); - } - UiRequest::InsertName(name) => dialogs::insert_name(self, &name), - UiRequest::OpenUrl(u) => { - if let Some(f) = &self.services.open_url { - f(&u); - } - } - } - } - } - - /// Whether the active sheet of the active document is protected (drives the Protect/Unprotect - /// Sheet labels and actions). - pub fn active_sheet_protected(&self) -> bool { - self.session.active().and_then(|d| d.wb.active().map(|s| s.is_protected())).unwrap_or(false) - } - - pub fn open_dialog(&mut self, name: &str, params: Json) { - if let Err(e) = self.commit_text_box_edit() { - self.message = Some(("Text Box".into(), clean_error(&e))); - return; - } - // Every Protect Sheet entry point (ribbon menus, keytips, tab menu) becomes Unprotect Sheet - // on a protected sheet. A sheet without a password is unprotected straight away. - let name = if name == "protectSheet" && self.active_sheet_protected() { "unprotectSheet" } else { name }; - if name == "unprotectSheet" - && self - .session - .active() - .and_then(|d| d.wb.active()) - .and_then(|s| s.protection.as_ref()) - .is_some_and(|p| p.password.is_none() && p.legacy_password_hash.is_none()) - { - self.run_or_alert("review.unprotectSheet", json!({})); - return; - } - match name { - "open" => { - if let Some(start) = &self.services.open_async { - start(); - } else if let Some(pick) = &self.services.pick_open - && let Some(path) = pick() - { - self.open_path(&path); - } - } - "saveAs" => { - let suggested = self.session.active().map(|d| d.display_title()).unwrap_or_else(|| "Book1".into()); - if let Some(pick) = &self.services.pick_save { - if let Some(path) = pick(&suggested) { - let path = if std::path::Path::new(&path).extension().is_none() { format!("{path}.xlsx") } else { path }; - self.run_or_alert("file.save", json!({"path": path})); - } - } else if self.services.download.is_some() - && let Ok(r) = self.run("file.saveBytes", json!({"format": "xlsx"})) - && let Some(dl) = &self.services.download - && let Some(b) = r.get("base64").and_then(Json::as_str).and_then(gridcraft_engine::io::base64_decode) - { - let name = r.get("name").and_then(Json::as_str).unwrap_or("Book.xlsx").to_string(); - dl(&name, &b); - } - } - // File › Options (`file.options`) and the File tab itself open the Backstage view. - "options" => self.open_backstage(backstage::Page::Options), - "backstage" => { - let page = params.get("page").and_then(Json::as_str).and_then(backstage::Page::parse).unwrap_or(backstage::Page::Home); - self.open_backstage(page); - } - other => self.dialog = dialogs::Dialog::open(self, other, params), - } - } - - /// Opens the File tab's Backstage view on `page`, committing a cell still being edited first - /// (File actions work on the whole workbook). A draft that can't be committed keeps the sheet. - pub fn open_backstage(&mut self, page: backstage::Page) { - if !self.chrome.file_tab { - return; - } - if !self.commit_edit(0, 0, false, false) { - return; - } - match &mut self.backstage { - Some(b) => b.page = page, - None => self.backstage = Some(backstage::Backstage::new(page)), - } - } - - pub fn open_path(&mut self, path: &str) { - match self.run("file.open", json!({"path": path})) { - Ok(r) => self.after_open(Some(path), &r), - // A password-protected workbook: ask for the password; the dialog opens it. - Err(e) if e.contains(gridcraft_engine::io::PASSWORD_REQUIRED) => self.open_dialog("openPassword", json!({"path": path})), - Err(e) => self.message = Some(("GridCraft".into(), clean_error(&e))), - } - self.after_engine(); - } - - /// What follows a successful `file.open`: the workbook shows (not the Backstage), the Recent - /// list (for a file on disk) and the open warnings notice. Shared by File › Open, the - /// password dialog and the control channel. - pub fn after_open(&mut self, path: Option<&str>, r: &Json) { - self.backstage = None; - if let Some(path) = path { - self.ui.recent.retain(|p| p != path); - self.ui.recent.insert(0, path.to_string()); - self.ui.recent.truncate(20); - } - self.show_open_warnings(r); - } - - /// Turns the `warnings` a `file.open` result carries into an info-bar notice, choosing the - /// most important one (a preserved VBA project outranks, say, a skipped optional part). - /// Kept deliberately generic: any open-time warning can reach the user this way. - pub fn show_open_warnings(&mut self, r: &Json) { - // The opened workbook's window shows them, however it was opened (a script may have run - // `file.open` on the session directly). - self.follow_active_window(); - let Some(list) = r.get("warnings").and_then(Json::as_array) else { - self.notice = None; - return; - }; - let msgs: Vec<&str> = list.iter().filter_map(Json::as_str).collect(); - if msgs.is_empty() { - // A clean open clears any notice left over from the previous document. - self.notice = None; - return; - } - let vba: Vec<&str> = msgs.iter().copied().filter(|m| m.to_lowercase().contains("macro") || m.contains("VBA")).collect(); - if !vba.is_empty() { - // The reader says "dropped" when the VBA project was missing or unreadable; never promise to keep it then. - let dropped = vba.iter().any(|m| m.contains("dropped")); - let text = if dropped { - "This workbook's VBA macros could not be loaded and were dropped, so they will not be in the saved file. Keep the original file." - } else { - "This workbook contains VBA macros. GridCraft keeps them when you save as .xlsm, but does not run them." - }; - self.notice = Some(Notice { text: text.into(), kind: NoticeKind::Warning, action: Some(("What this means".into(), "macros".into())) }); - } else { - let text = if msgs.len() == 1 { - msgs[0].to_string() - } else { - format!("{} items in this file were handled differently. First: {}", msgs.len(), msgs[0]) - }; - self.notice = Some(Notice { text, kind: NoticeKind::Info, action: None }); - } - } - - /// The open windows in order, for the Switch Windows lists. - pub fn open_windows(&self) -> Vec { - let active = self.session.active_window(); - self.session - .windows() - .iter() - .filter_map(|w| { - let d = self.session.documents().iter().find(|d| d.uid == w.doc_uid)?; - let title = self.session.window_title(w.id)?; - Some(OpenWindow { window: w.id, doc: d.uid, title, dirty: d.is_dirty(), active: active == Some(w.id) }) - }) - .collect() - } - - /// The active workbook and sheet: what drafts, text boxes and picture targets belong to. - pub fn view_key(&self) -> Option<(u64, usize)> { - self.session.active().map(|d| (d.uid, d.wb.active_sheet())) - } - - /// The active window and sheet: each window scrolls on its own. - fn scroll_key(&self) -> Option<(u64, usize)> { - Some((self.session.active_window()?, self.session.active()?.wb.active_sheet())) - } - - pub fn view(&self) -> SheetView { - self.scroll_key().and_then(|k| self.views.get(&k).copied()).unwrap_or_else(|| self.saved_view()) - } - - pub fn view_mut(&mut self) -> Option<&mut SheetView> { - let k = self.scroll_key()?; - let saved = self.saved_view(); - Some(self.views.entry(k).or_insert(saved)) - } - - /// The view a sheet opens with: scrolled to the file's saved first visible cell (`topLeftCell`). - fn saved_view(&self) -> SheetView { - let Some(sh) = self.session.active().and_then(|d| d.wb.active()) else { return SheetView::default() }; - if sh.view != gridcraft_engine::model::SheetViewMode::Normal { - return SheetView::default(); - } - let (fr, fc) = sh.freeze.unwrap_or((0, 0)); - let top = sh.view_top_left; - let y = sh.row_top(top.row.max(fr)) - sh.row_top(fr); - let x = sh.col_left(top.col.max(fc)) - sh.col_left(fc); - SheetView { scroll: egui::vec2(x.max(0.0) as f32, y.max(0.0) as f32) } - } - - /// Hands the active document's scroll positions (first visible cell per sheet) and active - /// cell to the engine so a save writes them (`sheetView topLeftCell`). Normal view only: - /// Page Layout view scrolls over pages, not cells. - pub fn store_view_state(&mut self) { - let window = self.session.active_window(); - let Some(d) = self.session.active_mut() else { return }; - let mut tops = Vec::new(); - for (&(w, si), v) in &self.views { - let Some(sh) = d.wb.sheets.get(si).filter(|sh| Some(w) == window && sh.view == gridcraft_engine::model::SheetViewMode::Normal) else { - continue; - }; - let (fr, fc) = sh.freeze.unwrap_or((0, 0)); - // A hair past the edge so a row that starts exactly at the scroll offset is the one found. - let row = sh.row_at(sh.row_top(fr) + v.scroll.y.max(0.0) as f64 + 0.01); - let col = sh.col_at(sh.col_left(fc) + v.scroll.x.max(0.0) as f64 + 0.01); - tops.push((si, CellRef::new(row, col))); - } - d.store_view(&tops); - } - - /// Starts editing the active cell. `text` replaces the content (typing) or pre-fills it. - pub fn begin_edit(&mut self, text: Option, from_formula_bar: bool) { - // The source formula is display-only on spill children. Grid replacement keeps its - // existing behavior, but no formula-bar entry path may edit that displayed formula. - if from_formula_bar && formula_bar::active_spill_anchor(self).is_some() { - return; - } - if let Err(e) = self.commit_text_box_edit() { - self.message = Some(("Text Box".into(), clean_error(&e))); - return; - } - let Some(d) = self.session.active() else { return }; - let Some(sh) = d.wb.active() else { return }; - let at = sh.merge_at(d.selection.active).map(|m| m.start).unwrap_or(d.selection.active); - // A picture has no editable scalar text. Selecting it, F2, or clicking - // the formula bar must not turn its fallback value into a text edit. - // Typing still starts an intentional replacement. - if text.is_none() && sh.cell_pictures.contains_key(&at) { - return; - } - let locale = self.ui.language.formula_locale(); - let current = if sh.cell_pictures.contains_key(&at) { - String::new() - } else { - match sh.cell(at) { - Some(c) if c.formula.is_some() => formula_locale::display(&c.input_text(), locale, &d.wb, d.wb.active_sheet()), - _ => gridcraft_engine::display::edit_text(&d.wb, sh, at), - } - }; - let (text, replace) = match text { - Some(t) => (t, true), - None => (current.clone(), false), - }; - let mut ed = editor::EditState::new(d.uid, d.wb.active_sheet(), at, text, replace, from_formula_bar); - ed.window = self.session.active_window(); - ed.locale = locale; - if replace && self.ui.autocomplete && !ed.is_formula() { - ed.completion = editor::column_completion(sh, at, &ed.text); - } - self.editor = Some(ed); - self.session.mode = if replace { gridcraft_engine::Mode::Enter } else { gridcraft_engine::Mode::Edit }; - } - - /// Commits the edit; `then` moves the selection (dr, dc) afterwards. - /// - /// The draft goes into the window and workbook it was typed in. If another window is - /// showing, that window stays active afterwards; a draft its own workbook refuses - /// (validation, a bad formula) brings its window back to the front with the draft still open. - pub fn commit_edit(&mut self, dr: i64, dc: i64, array: bool, fill_selection: bool) -> bool { - let Some((doc, window)) = self.editor.as_ref().map(|ed| (ed.doc, ed.window)) else { return true }; - if !self.draft_elsewhere() { - return self.commit_edit_here(dr, dc, array, fill_selection); - } - // Its window, or (when that window closed) its workbook's most recent one. - let Some(owner) = window.filter(|w| self.session.window(*w).is_some()).or_else(|| self.session.doc_window(doc)) else { - self.cancel_edit(); - return true; - }; - let back = self.session.active_window(); - self.session.activate_window(owner); - if !self.commit_edit_here(dr, dc, array, fill_selection) { - // Refused: its window stays in front with the draft open and the reason shown. - return false; - } - if let Some(back) = back { - self.session.activate_window(back); - } - true - } - - /// The cell draft belongs to a window or workbook other than the one showing. - fn draft_elsewhere(&self) -> bool { - self.editor.as_ref().is_some_and(|ed| { - self.session.active().map(|d| d.uid) != Some(ed.doc) || ed.window.is_some_and(|w| self.session.active_window() != Some(w)) - }) - } - - /// Commits the cell and text-box drafts, as Enter would, before a command that can show - /// another workbook or write this one to a file, so a draft never lands in another workbook. - /// A draft its workbook refuses stays open with the reason shown, and the command doesn't run. - pub(crate) fn settle_drafts(&mut self) -> Result<(), String> { - if !self.commit_edit(0, 0, false, false) { - return Err(self.message.as_ref().map(|(_, m)| m.clone()).unwrap_or_else(|| "The cell being edited can't be entered.".into())); - } - self.commit_text_box_edit() - } - - fn commit_edit_here(&mut self, dr: i64, dc: i64, array: bool, fill_selection: bool) -> bool { - let Some(ed) = self.editor.take() else { return true }; - self.session.mode = gridcraft_engine::Mode::Ready; - let text = match &ed.completion { - Some(full) if full.to_lowercase().starts_with(&ed.text.to_lowercase()) => full.clone(), - _ => ed.text.clone(), - }; - // F2 then Enter (or a file command) on an untouched draft: nothing to write. - if !fill_selection && !array && ed.original.as_ref() == Some(&text) { - if dr != 0 || dc != 0 { - self.move_after_enter(dr, dc); - } - return true; - } - let text = match self.session.active().map(|d| formula_locale::canonical(&text, ed.locale, &d.wb, ed.sheet, ed.cell)) { - Some(Ok(text)) => text, - Some(Err(error)) => { - self.message = Some(("GridCraft".into(), error.to_string())); - self.editor = Some(ed); - self.session.mode = gridcraft_engine::Mode::Edit; - return false; - } - None => text, - }; - // Data validation. - if let Some(d) = self.session.active() - && let Some((dv, msg)) = gridcraft_engine::cmd::data::check_validation(&d.wb, ed.sheet, ed.cell, &text) - { - if dv.error_style == gridcraft_engine::model::ErrorStyle::Stop { - let title = if dv.error_title.is_empty() { "GridCraft".to_string() } else { dv.error_title.clone() }; - self.message = Some((title, msg)); - self.editor = Some(ed); - self.session.mode = gridcraft_engine::Mode::Edit; - return false; - } - self.toast = Some((msg, now_ms())); - } - // Point mode can leave another sheet active; the edit belongs to the sheet it started on. - if self.session.active().is_some_and(|d| d.wb.active_sheet() != ed.sheet) - && let Err(e) = self.session.run("sheet.activate", json!({"sheet": ed.sheet})) - { - self.message = Some(("GridCraft".into(), clean_error(&e))); - self.editor = Some(ed); - self.session.mode = gridcraft_engine::Mode::Edit; - return false; - } - // Committing nothing into an empty cell changes nothing: no undo step (#1855, Backspace has - // already cleared the cell as its own step). - let unchanged = !fill_selection - && !array - && text.is_empty() - && self - .session - .active() - .and_then(|d| d.wb.sheet(ed.sheet)) - .is_some_and(|sh| sh.cell(ed.cell).is_none_or(|c| c.formula.is_none() && c.value == gridcraft_engine::core::Value::Empty)); - let r = if unchanged { - Ok(json!({})) - } else if fill_selection { - self.session.run("range.fill", json!({"input": text})) - } else { - self.session.run("cell.set", json!({"cell": ed.cell.a1(), "input": text, "array": array})) - }; - match r { - Ok(_) => { - if dr != 0 || dc != 0 { - self.move_after_enter(dr, dc); - } - self.after_engine(); - true - } - Err(e) => { - self.message = Some(("GridCraft".into(), clean_error(&e))); - self.editor = Some(ed); - self.session.mode = gridcraft_engine::Mode::Edit; - false - } - } - } - - /// Commits the edit with an arrow key. The arrow is an ordinary move, so it ends a run of Tabs - /// instead of continuing it (Right) or going back to its column like Enter (Down), as in Excel. - pub fn commit_edit_arrow(&mut self, dr: i64, dc: i64) -> bool { - let run = self.tab_run.take(); - let ok = self.commit_edit(dr, dc, false, false); - self.tab_run = if ok { None } else { run }; - ok - } - - pub fn cancel_edit(&mut self) { - self.editor = None; - self.session.mode = gridcraft_engine::Mode::Ready; - } - - /// Enter/Tab move inside a multi-cell selection, otherwise to the next cell. - pub fn move_after_enter(&mut self, dr: i64, dc: i64) { - let multi = self.session.active().is_some_and(|d| !d.selection.is_single_cell()); - // Any other move (arrows, clicks, another sheet) leaves the selection off the cell the - // last Tab landed on, which ends the run. - let here = self.session.active().map(|d| (d.uid, d.wb.active_sheet(), d.selection.active)); - let run = self.tab_run.take().filter(|&(uid, sheet, _, at)| here == Some((uid, sheet, at))); - if multi { - let fwd = dr > 0 || dc > 0; - let _ = self.session.run("selection.next", json!({"forward": fwd, "byRow": dc != 0})); - } else if let (Some((_, _, col, at)), 1, 0) = (run, dr, dc) { - let back = i64::from(col) - i64::from(at.col); - let _ = self.session.run("selection.move", json!({"dr": 1, "dc": back})); - } else { - let _ = self.session.run("selection.move", json!({"dr": dr, "dc": dc})); - if dr == 0 && dc != 0 { - let start = run.map(|r| r.2); - self.tab_run = self.session.active().and_then(|d| { - let (uid, sheet) = (d.uid, d.wb.active_sheet()); - let col = start.or(here.map(|h| h.2.col))?; - Some((uid, sheet, col, d.selection.active)) - }); - } - } - self.grid.ensure_visible = true; - } - - /// Terminal feedback also reaches the normal window when printing starts through control. - /// Polls printing feedback. Called once per frame from [`Self::logic`]; the direct calls in - /// `tests/native_print.rs` deliberately bypass the lifecycle to test this helper alone. - pub fn poll_printing(&mut self, ctx: &egui::Context) { - self.printing.poll(ctx); - if self.printing.message == self.print_feedback_seen { - return; - } - self.print_feedback_seen = self.printing.message.clone(); - let Some(message @ (printing::Feedback::Submitted(_) | printing::Feedback::Failed(_) | printing::Feedback::WorkerStopped)) = - self.printing.message.clone() - else { - return; - }; - let text = printing::feedback(&message, self.ui.language, |label| self.ui.language.tr(label).to_owned()); - if matches!(message, printing::Feedback::Submitted(_)) { - self.toast = Some((text, now_ms())); - } else { - self.alert(text); - } - } - - /// Holds back a window close (the close button, Alt+F4, the OS quitting the app) while a - /// workbook has unsaved changes, and asks about them first. - fn guard_window_close(&mut self, ctx: &egui::Context) { - if self.native_windows || self.allow_close || !ctx.input(|i| i.viewport().close_requested()) { - return; - } - // A cell still being edited is part of the workbook, as for File › Close. - if !self.commit_edit(0, 0, false, false) { - ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose); - return; - } - if !self.session.documents().iter().any(|d| d.is_dirty()) { - return; - } - ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose); - match self.dialog.as_mut() { - // Already asking about a workbook (File › Close): answering it now goes on to quit. - Some(d) if d.name() == "saveChanges" => { - d.values.insert("quit".into(), json!(true)); - } - _ => self.continue_quit(ctx), - } - } - - /// Asks about the next unsaved workbook before quitting, in its window, or closes the app - /// once none is left. - pub(crate) fn continue_quit(&mut self, ctx: &egui::Context) { - let dirty = self.session.documents().iter().find(|d| d.is_dirty()).map(|d| d.uid); - match dirty.and_then(|uid| self.session.doc_window(uid)) { - Some(window) => { - // Its window comes forward (the native window too: see `doc_windows`). - self.enter_window(window); - let title = self.session.active().map(|d| d.display_title()).unwrap_or_default(); - self.open_dialog("saveChanges", json!({"title": title, "quit": true})); - } - None => { - self.allow_close = true; - ctx.send_viewport_cmd_to(egui::ViewportId::ROOT, egui::ViewportCommand::Close); - } - } - } - - /// Answers the Save Changes question about the active workbook (its buttons). While quitting, - /// the next unsaved workbook is asked about once this one is answered; Cancel stops the quit. - /// Returns whether the question is settled: Save keeps it open when the cell being edited - /// can't be entered. - pub fn answer_save_changes(&mut self, answer: SaveAnswer, quitting: bool, ctx: &egui::Context) -> bool { - match answer { - SaveAnswer::Cancel => {} - SaveAnswer::DontSave => { - self.run_or_alert("file.close", json!({"force": true, "document": true})); - if quitting { - self.continue_quit(ctx); - } - } - SaveAnswer::Save => { - // File operations include the cell still being edited, as File › Save does. - if !self.commit_edit(0, 0, false, false) { - return false; - } - self.run_or_alert("file.save", json!({})); - if self.session.active().is_some_and(|x| !x.is_dirty()) { - self.run_or_alert("file.close", json!({"force": true, "document": true})); - if quitting { - self.continue_quit(ctx); - } - } - } - } - true - } - - /// Quit (the macOS app menu, ⌘Q): commits every window's cell draft as Enter would, then asks - /// about each unsaved workbook in its window; Cancel on any of them stops the quit. A draft its - /// workbook refuses stops the quit too, with its window in front and the reason shown. - pub fn request_quit(&mut self, ctx: &egui::Context) { - while let Some(window) = self.parked_window_with_draft() { - self.enter_window(window); - if self.settle_drafts().is_err() { - return; - } - } - if self.settle_drafts().is_err() { - return; - } - if let Some(d) = self.dialog.as_mut().filter(|d| d.name() == "saveChanges") { - // Already asking about a workbook (File › Close): answering it goes on to quit. - d.values.insert("quit".into(), json!(true)); - return; - } - self.continue_quit(ctx); - } - - /// Per-frame logic (control channel, screenshots). Call before `ui`. - pub fn logic(&mut self, ctx: &egui::Context) { - self.ensure_text_width(ctx); - // A keytip control request lives one frame: the ribbon consumes it during `ui`, and - // anything that never reached a control (collapsed ribbon, tab switch) is dropped. - self.ui.keytip_invoke = None; - // Floating pictures' textures go with their workbook, even when no sheet is painted. - let open: Vec = self.session.documents().iter().map(|d| d.uid).collect(); - floating_pictures::forget_closed(ctx, &open); - self.apply_font_state(ctx); - if let Some(answer) = self.desktop_theme.take_change() { - self.desktop_dark = Some(answer); - } - self.sync_theme_preference(ctx); - self.sync_input_options(ctx); - // The geometry captures are bound to. Native frames recorded it from their fresh - // `RawInput` in `raw_input_hook` (including hidden logic-only ticks); the epoch is - // monotonic, so a resize back to the original values is still a new epoch and old pixels - // are never saved with new geometry. - let capture_geometry = control::current_capture_geometry(self, ctx); - control::note_capture_geometry(self, capture_geometry); - // Drain before admission: completed or timed-out captures release their slots and - // reservation first, so a new request is never refused for budget that is about to free. - // Screenshot results, deadlines and geometry validation belong to `logic`; `ui` only - // issues the capture command (after every widget, so a command cannot be missed). - control::drain_screenshots(self, ctx); - control::poll(self, ctx); - // Printing feedback is polled once per frame, here, so hidden windows and logic-only - // frames still see worker completion. `ui` and the desktop app no longer poll it. - self.poll_printing(ctx); - self.guard_window_close(ctx); - #[cfg(not(target_arch = "wasm32"))] - updates_ui::tick(self, ctx); - // Files read asynchronously (web file picker, dropped files). - self.poll_async_opens(); - self.autosave(ctx); - self.update_title(ctx); - self.request_hidden_paint(ctx); - // One window always shows a workbook, unless each workbook has a native window of its own - // (`doc_windows` decides what having none left means). - if self.session.active().is_none() && !self.native_windows { - self.session.new_workbook(); - } - self.follow_active_window(); - } - - /// Installs the text-width closure once, on the first frame. - fn ensure_text_width(&mut self, ctx: &egui::Context) { - if self.session.text_width.is_some() { - return; - } - let ctx = ctx.clone(); - self.session.text_width = Some(std::sync::Arc::new(move |text, font, exact| theme::cell_text_width(&ctx, text, font, exact))); - } - - /// Applies the font gates: a language Han-order change, startup/prepared fonts and workbook - /// font binding. Every branch keeps its one-frame paint gate. - fn apply_font_state(&mut self, ctx: &egui::Context) { - // A pending synthetic step owns the next real `ui` pass: `ui` returns at the font gate - // before it handles input, so a font change requested now would consume the step without - // applying it. Delay the change one frame; the hidden-paint request keeps it coming. - let defer_font_change = self.synthetic_pending || !self.synthetic.is_empty(); - if !defer_font_change && self.fonts_han.is_some_and(|han| han != theme::HanOrder::of(self.ui.language)) { - self.fonts_ready = false; - self.fonts_set = false; - } - if !self.fonts_ready { - self.start_font_setup(ctx); - } else if self.fonts_rebind { - // A font-set change made last frame is active now. - self.fonts_rebind = false; - } else if !defer_font_change && !self.prepared_fonts && fonts::scan_done() { - self.rebind_workbook_fonts(ctx); - } - } - - /// Applies the pending font set. New fonts apply from the next frame on: paint nothing until - /// then. Startup always proceeds: without it a queued step would be re-offered forever. - fn start_font_setup(&mut self, ctx: &egui::Context) { - if self.fonts_set { - self.fonts_ready = true; - return; - } - if self.prepared_fonts { - ctx.set_fonts(theme::pinned_font_definitions()); - theme::apply(ctx, self.ui.dark); - } else { - SheetApp::setup_context_for_language(ctx, self.ui.dark, self.ui.language); - } - self.fonts_set = true; - // The rebuilt set has no workbook fonts yet: bind them again once it is active. - self.fonts_bound.clear(); - self.fonts_seen = (0, 0); - self.fonts_han = Some(theme::HanOrder::of(self.ui.language)); - ctx.request_repaint(); - } - - /// Binds the workbook's installed fonts once the background scan has finished. Adding fonts - /// re-applies the font set, which egui activates at the next pass, so skip this frame's paint - /// (same one-frame cost as startup). Only when an open workbook changed. - fn rebind_workbook_fonts(&mut self, ctx: &egui::Context) { - let seen = self.fonts_key(); - if seen == self.fonts_seen { - return; - } - let used = self.used_fonts(); - self.fonts_seen = seen; - if used == self.fonts_bound { - return; - } - ctx.set_fonts(theme::font_definitions_with(self.ui.language, &used)); - self.fonts_bound = used; - self.fonts_rebind = true; - ctx.request_repaint(); - } - - /// Follows the desktop/system theme preference and records the effective theme. - fn sync_theme_preference(&mut self, ctx: &egui::Context) { - let preference = match ( - self.ui.system_theme, - ctx.system_theme().or_else(|| self.desktop_dark.map(|d| if d { egui::Theme::Dark } else { egui::Theme::Light })), - ) { - (true, Some(egui::Theme::Dark)) => egui::ThemePreference::Dark, - (true, Some(egui::Theme::Light)) => egui::ThemePreference::Light, - _ => self.ui.theme_preference(), - }; - if ctx.options(|o| o.theme_preference) != preference { - ctx.set_theme(preference); - ctx.request_repaint(); - } - self.effective_dark = ctx.theme() == egui::Theme::Dark; - } - - /// Keeps egui's own keyboard zoom and quit shortcuts off and applies GridCraft's zoom factor. - fn sync_input_options(&mut self, ctx: &egui::Context) { - // Interface size: the preference owns the zoom factor, so egui's own Ctrl/Cmd +/-/0 zoom - // keys stay off (they would also swallow GridCraft's shortcuts). - if ctx.options(|o| o.zoom_with_keyboard) { - ctx.options_mut(|o| o.zoom_with_keyboard = false); - } - // egui closes the window on Ctrl+Q (and with Shift or Alt held too) without asking to save - // (#1443). Excel for Windows has no such key (Ctrl+Q is Quick Analysis). With a native - // window per workbook egui's Cmd+Q would close only the window it was typed in: the menu - // bar's Quit item has the key instead (it asks about unsaved workbooks first). - if (ctx.os() != egui::os::OperatingSystem::Mac || self.native_windows) && ctx.options(|o| !o.quit_shortcuts.is_empty()) { - ctx.options_mut(|o| o.quit_shortcuts.clear()); - } - let zoom = self.ui.zoom_factor(); - if (ctx.zoom_factor() - zoom).abs() > 0.001 { - ctx.set_zoom_factor(zoom); - ctx.request_repaint(); - } - } - - /// Opens files that arrived asynchronously (web file picker, dropped files). - fn poll_async_opens(&mut self) { - let arrived: Vec<(String, Vec)> = self - .services - .inbox - .as_ref() - .map(|i| std::mem::take(&mut *i.lock().unwrap_or_else(std::sync::PoisonError::into_inner))) - .unwrap_or_default(); - for (name, bytes) in arrived { - let b64 = gridcraft_engine::io::base64_encode(&bytes); - match self.run("file.open", json!({"name": &name, "base64": &b64})) { - // A file picked from File › Open (web) arrives here: show it, not the Backstage. - Ok(r) => { - self.backstage = None; - self.show_open_warnings(&r); - } - Err(e) if e.contains(gridcraft_engine::io::PASSWORD_REQUIRED) => { - self.backstage = None; - self.open_dialog("openPassword", json!({"name": name, "base64": b64})); - } - Err(e) => self.message = Some(("GridCraft".into(), clean_error(&e))), - } - self.after_engine(); - } - } - - /// AutoSave: save workbooks that have a file shortly after they change. This decision lives in - /// `logic` so a hidden or logic-only frame still saves; the monotonic timestamp check keeps a - /// forced hidden UI pass from saving the same change twice. - fn autosave(&mut self, ctx: &egui::Context) { - let dirty_xlsx = self.session.active().is_some_and(|d| d.is_dirty() && d.path.as_deref().is_some_and(|p| p.ends_with(".xlsx"))); - let now = crate::monotonic_ms(); - let autosave_due = - self.ui.autosave && self.editor.is_none() && self.text_box_editor.is_none() && now - self.grid.last_autosave > 2000.0 && dirty_xlsx; - if autosave_due { - self.grid.last_autosave = now; - self.store_view_state(); - let _ = self.session.run("file.save", json!({})); - } else if self.ui.autosave && dirty_xlsx { - // Keep checking even when no input arrives while the window is hidden. - ctx.request_repaint_after(std::time::Duration::from_secs(2)); - } - } - - /// Look for a newer release once, in the background, as the desktop app starts (a no-op on the - /// web). A found release opens the update prompt; nothing is shown when GridCraft is current. - pub fn start_update_check(&mut self) { - #[cfg(not(target_arch = "wasm32"))] - updates_ui::start(self, false); - } - - /// Help ▸ Check for Updates…: look now, and say so when GridCraft is up to date or the check fails. - pub fn check_for_updates(&mut self) { - #[cfg(not(target_arch = "wasm32"))] - updates_ui::start(self, true); - #[cfg(target_arch = "wasm32")] - { - self.message = Some(("GridCraft".into(), "The web version is always the latest release.".into())); - } - } - - /// Window title, next to the close/autosave state: `ViewportCommand::Title` is valid outside a - /// pass and a control-channel rename while hidden updates it on return. - fn update_title(&mut self, ctx: &egui::Context) { - // Native windows get theirs from `doc_windows`. - if self.native_windows { - return; - } - let Some(d) = self.session.active() else { return }; - let title = format!("{}{}", d.display_title(), if d.is_dirty() { " — Edited" } else { "" }); - if self.grid.last_title.as_deref() != Some(title.as_str()) { - ctx.send_viewport_cmd(egui::ViewportCommand::Title(title.clone())); - self.grid.last_title = Some(title); - } - } - - /// A queued/settling capture with a capturable size and an unapplied synthetic step both need - /// a real `ui` pass. Once a capture is issued, its `Screenshot` command's own `wants_paint` - /// paints the hidden window; after that a full hidden pass every frame only wastes CPU. A - /// minimized request has no size, so no hidden pass can reach issuance: the restore pass issues - /// it instead. Native eframe honours `RequestPaintWhileHidden`; the web runner does not (hidden - /// tabs only run `run_logic`), so a hidden web tab queues the work until it is shown again. - /// This is a shared eframe web follow-up, not a GridCraft-local workaround, and the control - /// protocol documents the limitation. - fn request_hidden_paint(&self, ctx: &egui::Context) { - if !(self.shots.needs_hidden_paint() || self.synthetic_pending || !self.synthetic.is_empty()) { - return; - } - // The web runner has no `RequestPaintWhileHidden`; sending it there only logs an - // unhandled command. A visible tab still gets the repaint below; a hidden tab keeps the - // work queued (see the shared eframe follow-up in docs/egui-037-local-followups.md). - #[cfg(not(target_arch = "wasm32"))] - ctx.send_viewport_cmd(egui::ViewportCommand::RequestPaintWhileHidden); - ctx.request_repaint(); - } - - /// Records capture geometry from the fresh raw frame, then offers exactly one pending synthetic batch and tracks whether it was applied. - pub fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) { - // Capture geometry from this frame's real input before any synthetic event is added. This - // runs on every native frame, including hidden/minimized logic-only ticks, so a resize, - // DPI/zoom change, Wayland frame without `inner_rect`, or minimize/restore is recorded - // even though egui's last UI pass still describes the old layout. - let geometry = control::CaptureGeometry::from_raw_input(raw, ctx); - control::note_capture_geometry(self, geometry); - self.capture_geometry_from_raw = true; - // One unapplied gesture step at a time. eframe retains an unconsumed `RawInput` for the - // next pass and for a hidden logic-only tick, so the batch is offered once and survives - // until a `ui` pass applies it (which pops it). Press and release stay distinct batches. - // - // A completed egui pass may have consumed the injected events without applying them: the - // font gate returns before input handling, but `begin_pass` has already taken the events. - // `cumulative_pass_nr` only advances when a pass ends, so a newer value with the batch - // still pending means exactly that, and the step is offered again for the next real pass. - if self.synthetic_pending { - let consumed_by_an_unapplied_pass = self.synthetic_injected_at_pass.is_some_and(|pass| ctx.cumulative_pass_nr() > pass); - if !consumed_by_an_unapplied_pass { - return; - } - } - if let Some(batch) = self.synthetic.front() { - raw.events.extend(batch.iter().cloned()); - self.synthetic_pending = true; - self.synthetic_injected_at_pass = Some(ctx.cumulative_pass_nr()); - } - } - - /// Flip AutoSave (title-bar switch / Options ▸ Save). Persisted with the other UI - /// preferences, so it stays as the user left it across restarts (#1901). - pub fn toggle_autosave(&mut self) { - self.ui.autosave = !self.ui.autosave; - } - - /// Lays out the whole window. - pub fn ui(&mut self, ui: &mut egui::Ui) { - let ctx = ui.ctx().clone(); - if !self.fonts_ready || self.fonts_rebind { - ctx.request_repaint(); - return; - } - let t0 = now_ms(); - // A script may have switched windows since the last frame. - self.follow_active_window(); - text_box::before_ui(self, &ctx); - // Another window came forward without going through a command (which settles drafts - // first): the draft goes into its own window instead of floating over this one. If its - // workbook refuses it, its window comes back to the front with the draft open. - if self.draft_elsewhere() { - self.commit_edit(0, 0, false, false); - } - // The language the widgets translate with this frame (see `i18n::current`). - i18n::set_current(&ctx, self.ui.language); - #[cfg(not(target_arch = "wasm32"))] - updates_ui::show(self, &ctx); - let t = theme::Tokens::get(&ctx); - // A host can turn the File tab off while Backstage is already open: close it. - if !self.chrome.file_tab { - self.backstage = None; - } - // Keytips opened the File view (Alt, F): its letters keep working there. - if self.backstage.is_none() || self.keytips.prefix().is_some() { - self.ribbon_keys(&ctx); - } - if self.chrome.title_bar { - ribbon::title_bar(self, ui); - } - if self.backstage.is_some() { - // File tab: the Backstage view replaces everything below the title bar. - backstage::show(self, ui); - } else { - ribbon::show(self, ui); - widgets::notice_bar(self, ui); - if self.session.prefs.formula_bar { - formula_bar::show(self, ui); - } - if self.ui.status_bar { - tabs::status_bar(self, ui); - } - tabs::sheet_tabs(self, ui); - pivot_pane::show(self, ui); - panes::show(self, ui); - egui::CentralPanel::default().frame(egui::Frame::NONE.fill(t.grid_bg)).show(ui, |ui| { - let g0 = now_ms(); - grid::show(self, ui); - self.perf.grid_ms = now_ms() - g0; - }); - } - dialogs::show(self, &ctx); - widgets::message_box(self, &ctx); - widgets::toast(self, &ctx); - // Requests are admitted in `logic`, for the root viewport, so only its pass issues them. - if ctx.viewport_id() == egui::ViewportId::ROOT { - control::issue_screenshots(self, &ctx); - } - if !ctx.input(|i| i.focused) { - // Nothing else to do. - } - let now = now_ms(); - self.perf.frame_ms = now - t0; - if self.perf.last > 0.0 { - let dt = now - self.perf.last; - if dt > 0.0 { - self.perf.fps = self.perf.fps * 0.9 + 100.0 / dt; - } - } - self.perf.last = now; - // The step is applied only after a completed UI pass. A discarded or repeated pass of the - // same frame must not offer it again (grid sizing calls `ui` more than once per frame); - // provenance is the injected pass number, so only a pass that missed input handling is - // re-offered by `raw_input_hook`. - // Synthetic input reaches the root viewport only (`raw_input_hook`), so its pass consumes it. - if self.synthetic_pending && ctx.viewport_id() == egui::ViewportId::ROOT { - self.synthetic.pop_front(); - self.synthetic_pending = false; - self.synthetic_injected_at_pass = None; - } - } - - fn ribbon_keys(&mut self, ctx: &egui::Context) { - let before = self.keytips.prefix().map(str::to_string); - let enabled = self.editor.is_none() - && self.dialog.is_none() - && self.message.is_none() - && self.name_box.is_none() - && !ctx.text_edit_focused() - && self.grid.context_menu.is_none() - && self.grid.object_menu.is_none() - && self.grid.header_menu.is_none() - && self.grid.fill_menu.is_none() - && self.grid.filter_menu.is_none() - && self.grid.list_picker.is_none() - && self.grid.renaming_tab.is_none() - && (before.is_some() || !egui::Popup::is_any_open(ctx)); - let actions = self.keytips.process(ctx, enabled); - // Backing out of F to the tab letters, or finishing an F sequence, closes the File view - // that Alt, F opened: the command runs on the sheet, as it did without the view. - if self.backstage.is_some() && before.is_some() && (self.keytips.prefix() == Some("") || !actions.is_empty()) { - self.backstage = None; - } - if before.is_some() && before.as_deref() != self.keytips.prefix() && !ctx.input(|i| i.pointer.any_pressed()) { - // Leave pointer cancellation to the popup so clicking a menu item still works. - egui::Popup::close_all(ctx); - } - for action in actions { - match action { - keytips::Action::Tab("File") => self.open_backstage(backstage::Page::Home), - keytips::Action::Tab(tab) => { - self.run_or_alert("ui.ribbonTab", json!({"tab": tab})); - self.ui.ribbon_collapsed = false; - } - keytips::Action::Command(id @ ("edit.copy" | "edit.cut")) => self.copy_to_clipboard(ctx, id), - keytips::Action::Command("edit.paste") => self.paste_from_clipboard(), - keytips::Action::Command(id) => self.run_or_alert(id, json!({})), - keytips::Action::CommandWith(id, params) => { - let params: Json = serde_json::from_str(params).unwrap_or_else(|_| json!({})); - // A Borders preset picked by key tip becomes the split button's face, as a - // click on the same menu item does. - if id == "home.borders" - && let Some(preset) = params.get("preset").and_then(Json::as_str) - { - self.grid.last_border = preset.to_string(); - } - self.run_or_alert(id, params); - } - keytips::Action::Dialog(name) => self.open_dialog(name, json!({})), - keytips::Action::DialogWith(name, params) => self.open_dialog(name, serde_json::from_str(params).unwrap_or_else(|_| json!({}))), - // Reserved sequence: fires silently while its command is missing. When the - // command lands in the catalog it goes live with no table edit. - keytips::Action::Pending(id) => { - if gridcraft_engine::find_command(id).is_some() { - self.run_or_alert(id, json!({})); - } - } - // Route to the ribbon control that owns this keytip: it opens its dropdown or - // runs its click handler with the right params (the engine command alone would - // be missing them). - keytips::Action::Control(id) => self.ui.keytip_invoke = Some(id), - } - } - } - - /// Current selection's top-left cell text as A1 (for the Name Box). - pub fn name_box_text(&self) -> String { - let Some(d) = self.session.active() else { return String::new() }; - let sel = &d.selection; - let cur = sel.current(); - // A named range that exactly matches shows its name. - let sh = d.wb.active(); - if let Some(sh) = sh { - for n in &d.wb.names { - let target = n.formula.replace('$', ""); - let (sheet, body) = match target.rsplit_once('!') { - Some((s, b)) => (Some(gridcraft_engine::core::unquote_sheet(s)), b.to_string()), - None => (None, target.clone()), - }; - if sheet.as_deref().is_none_or(|s| s.eq_ignore_ascii_case(&sh.name)) && RangeRef::parse(&body) == Some(cur) { - return n.name.clone(); - } - } - } - if self.grid.drag_select && !cur.is_single() { - return format!("{}R x {}C", cur.height(), cur.width()); - } - sel.active.a1() - } -} - /// Strips the engine's error prefixes for message boxes. pub fn clean_error(e: &str) -> String { if let Some(rest) = e.split_once("`: ").map(|(_, r)| r) { @@ -1903,268 +297,6 @@ pub fn clean_error(e: &str) -> String { e.to_string() } -/// Wall-clock milliseconds since the Unix epoch, for user-visible timestamps (Backstage's "last -/// changed", toasts). It can jump when the system clock is adjusted, so it must not drive -/// deadlines or pacing; use [`monotonic_ms`] for those. -pub fn now_ms() -> f64 { - gridcraft_engine::core::date::unix_now_ms().unwrap_or(0.0) -} - -/// Monotonic milliseconds since the first call, for screenshot deadlines and AutoSave pacing. -/// -/// Deliberately separate from [`now_ms`]: a wall-clock adjustment must not extend (or expire) a -/// screenshot deadline or save a workbook twice. Native uses [`std::time::Instant`]; wasm uses the -/// browser's `performance.now()`, which is monotonic too. -#[cfg(not(target_arch = "wasm32"))] -#[allow(clippy::disallowed_methods)] // the std clock is read only off wasm -pub fn monotonic_ms() -> f64 { - use std::sync::OnceLock; - static START: OnceLock = OnceLock::new(); - START.get_or_init(std::time::Instant::now).elapsed().as_secs_f64() * 1000.0 -} - -#[cfg(target_arch = "wasm32")] -/// Web monotonic clock: the browser's `performance.now()`, or 0 when it is unavailable. -pub fn monotonic_ms() -> f64 { - web_sys::window().and_then(|window| window.performance()).map_or(0.0, |performance| performance.now()) -} - pub fn cell(r: u32, c: u32) -> CellRef { CellRef::new(r, c) } - -#[cfg(test)] -mod lifecycle_tests { - use super::*; - - /// Builds a key-A press or release event without modifiers. - fn key_event(pressed: bool) -> egui::Event { - egui::Event::Key { key: egui::Key::A, physical_key: None, pressed, repeat: false, modifiers: egui::Modifiers::NONE } - } - - /// Returns a test app with a new workbook and default services. - fn app() -> SheetApp { - let mut session = Session::new(); - session.new_workbook(); - SheetApp::new(session, Services::default()) - } - - /// One headless pass; a test has no renderer for the texture atlas delta (0.37 panics when it - /// is dropped unapplied). - fn ui_pass(ctx: &egui::Context, raw: egui::RawInput, f: impl FnMut(&mut egui::Ui)) -> egui::FullOutput { - let mut out = ctx.run_ui(raw, f); - out.textures_delta.clear(); - out - } - - #[test] - /// Only one synthetic step is offered, is not duplicated by a hidden logic-only tick, and press/release remain distinct batches. - fn a_hidden_logic_tick_keeps_one_unapplied_synthetic_step() { - let ctx = egui::Context::default(); - let mut app = app(); - app.synthetic.push_back(vec![key_event(true)]); - app.synthetic.push_back(vec![key_event(false)]); - - let mut first = egui::RawInput::default(); - app.raw_input_hook(&ctx, &mut first); - assert_eq!(first.events.len(), 1, "only one step is offered"); - assert!(app.synthetic_pending); - assert_eq!(app.synthetic.len(), 2, "a logic-only frame does not consume the step"); - - // eframe carries the unconsumed `RawInput` into the next pass, so the hook offers the step - // only once; a hidden logic-only tick does not duplicate it or offer the release. - let mut second = egui::RawInput::default(); - app.raw_input_hook(&ctx, &mut second); - assert!(second.events.is_empty(), "an unapplied step is not injected a second time"); - assert_eq!(app.synthetic.len(), 2, "press and release stay distinct batches"); - } - - /// Returns raw input with a screen rect at the given size. - fn raw_with_size(size: egui::Vec2) -> egui::RawInput { - egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, size)), ..Default::default() } - } - - #[test] - /// A font-gated early-returning pass does not lose the queued step; the hook offers it again and a later real pass applies it. - fn a_font_gated_pass_offers_the_synthetic_step_again() { - let ctx = egui::Context::default(); - ctx.set_fonts(theme::font_definitions()); - let mut app = app(); - app.fonts_ready = true; - app.fonts_set = true; - // Binds `session.text_width`, as eframe's first frame does. - let _ = ctx.run_logic(&egui::RawInput::default(), |ctx| app.logic(ctx)); - app.synthetic.push_back(vec![key_event(true)]); - - // The font gate returns before input handling; `begin_pass` has still consumed the events. - app.fonts_rebind = true; - let mut raw = raw_with_size(egui::vec2(900.0, 700.0)); - app.raw_input_hook(&ctx, &mut raw); - assert_eq!(raw.events.len(), 1); - let _ = ui_pass(&ctx, raw, |ui| app.ui(ui)); - assert!(app.synthetic_pending, "the early-returning pass did not apply the step"); - assert_eq!(app.synthetic.len(), 1, "the step is still queued"); - - // A completed pass that did not apply it made the hook offer it again for a real pass. - app.fonts_rebind = false; - let mut raw = raw_with_size(egui::vec2(900.0, 700.0)); - app.raw_input_hook(&ctx, &mut raw); - assert_eq!(raw.events.len(), 1, "the step is not lost across the font gate"); - let _ = ui_pass(&ctx, raw, |ui| app.ui(ui)); - assert!(!app.synthetic_pending); - assert!(app.synthetic.is_empty(), "the real UI pass applied the step"); - } - - #[test] - /// A language-driven font rebuild waits while a synthetic step is pending and proceeds once it is clear. - fn a_pending_step_delays_a_font_change() { - let ctx = egui::Context::default(); - let mut app = app(); - app.fonts_ready = true; - app.fonts_han = Some(theme::HanOrder::Chinese); - app.ui.language = i18n::Language::Ja; - app.synthetic.push_back(vec![key_event(true)]); - app.synthetic_pending = true; - let _ = ctx.run_logic(&egui::RawInput::default(), |ctx| app.logic(ctx)); - assert!(app.fonts_ready, "the font rebuild waits for the pass that applies the step"); - app.synthetic.clear(); - app.synthetic_pending = false; - let _ = ctx.run_logic(&egui::RawInput::default(), |ctx| app.logic(ctx)); - assert!(!app.fonts_ready, "without pending input the language rebuild proceeds"); - } - - #[test] - /// Logic ticks see fresh viewport and DPI information; resizing back to the original size still advances the capture epoch. - fn a_hidden_logic_tick_sees_fresh_viewport_info_and_keeps_the_epoch_monotonic() { - /// Runs one logic tick with the requested width and native scale. - fn tick(ctx: &egui::Context, app: &mut SheetApp, width: f32, native_ppp: f32) { - let mut raw = - egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(width, 400.0))), ..Default::default() }; - if let Some(viewport) = raw.viewports.get_mut(&egui::ViewportId::ROOT) { - viewport.inner_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(width, 400.0))); - viewport.native_pixels_per_point = Some(native_ppp); - } - let _ = ctx.run_logic(&raw, |ctx| app.logic(ctx)); - } - let ctx = egui::Context::default(); - let mut app = app(); - tick(&ctx, &mut app, 800.0, 2.0); - let first = app.capture_geometry.expect("the first logic tick records the geometry"); - assert_eq!(first.viewport, Some(egui::vec2(800.0, 400.0))); - assert_eq!(first.pixels_per_point, 2.0, "native scale times the interface zoom"); - tick(&ctx, &mut app, 900.0, 2.0); - let resized = app.capture_geometry.expect("geometry"); - assert_eq!(resized.viewport, Some(egui::vec2(900.0, 400.0)), "run_logic's ViewportInfo is the fresh size"); - let epoch = app.capture_epoch; - tick(&ctx, &mut app, 800.0, 2.0); - assert_eq!(app.capture_geometry.map(|g| g.viewport), Some(Some(egui::vec2(800.0, 400.0)))); - assert!(app.capture_epoch > epoch, "resizing back is still a new epoch: old pixels must not be saved"); - } - - #[test] - /// A resize and a resize back both advance the capture epoch. - fn the_capture_epoch_changes_even_when_the_geometry_returns() { - /// Runs one UI pass at the requested width. - fn tick(ctx: &egui::Context, app: &mut SheetApp, width: f32) { - let raw = - egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(width, 400.0))), ..Default::default() }; - let _ = ui_pass(ctx, raw, |ui| app.logic(ui.ctx())); - } - let ctx = egui::Context::default(); - let mut app = app(); - tick(&ctx, &mut app, 800.0); - let base = app.capture_epoch; - tick(&ctx, &mut app, 900.0); - let changed = app.capture_epoch; - assert!(changed > base, "a resize increments the capture epoch"); - tick(&ctx, &mut app, 800.0); - assert!(app.capture_epoch > changed, "resizing back is still a new epoch: old pixels must not be saved"); - } - - /// Extracts title viewport commands from a logic output. - fn titles(out: &egui::LogicOutput) -> Vec { - out.viewport_commands - .values() - .flatten() - .filter_map(|c| match c { - egui::ViewportCommand::Title(title) => Some(title.clone()), - _ => None, - }) - .collect() - } - - #[test] - /// A hidden logic tick saves a dirty file-backed workbook once and clears its dirty state. - fn a_logic_only_tick_autosaves_a_dirty_workbook_once() { - let ctx = egui::Context::default(); - let mut app = app(); - let dir = std::env::temp_dir().join(format!("gridcraft-logic-autosave-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - let path = dir.join("autosave.xlsx"); - app.run("cell.set", serde_json::json!({"cell": "A1", "input": "42"})).unwrap(); - app.ui.autosave = true; - app.session.active_mut().unwrap().path = Some(path.to_string_lossy().into_owned()); - app.grid.last_autosave = monotonic_ms() - 3000.0; - - let _ = ctx.run_logic(&egui::RawInput::default(), |ctx| app.logic(ctx)); - assert!(path.exists(), "a hidden logic-only tick saves a dirty workbook with a file"); - assert!(!app.session.active().unwrap().is_dirty(), "the save clears the dirty state"); - let saved_at = app.grid.last_autosave; - let _ = ctx.run_logic(&egui::RawInput::default(), |ctx| app.logic(ctx)); - assert_eq!(app.grid.last_autosave, saved_at, "the same change is not saved a second time"); - - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - /// The title is sent on the first tick and after a rename, but not on every unchanged frame. - fn a_logic_only_tick_updates_the_title_once_per_change() { - let ctx = egui::Context::default(); - let mut app = app(); - let first = ctx.run_logic(&egui::RawInput::default(), |ctx| app.logic(ctx)); - let title = app.session.active().unwrap().display_title().to_string(); - let expected = format!("{title}{}", if app.session.active().unwrap().is_dirty() { " — Edited" } else { "" }); - assert_eq!(titles(&first), [expected], "the first logic tick titles the window"); - let second = ctx.run_logic(&egui::RawInput::default(), |ctx| app.logic(ctx)); - assert!(titles(&second).is_empty(), "an unchanged title is not sent every frame"); - - app.session.active_mut().unwrap().title = "Renamed".into(); - let third = ctx.run_logic(&egui::RawInput::default(), |ctx| app.logic(ctx)); - let expected = format!("Renamed{}", if app.session.active().unwrap().is_dirty() { " — Edited" } else { "" }); - assert_eq!(titles(&third), [expected], "a rename while hidden updates the title"); - } - - #[test] - /// A hidden tick surfaces a failed print job in the app message. - fn a_logic_only_tick_polls_printing_feedback() { - let ctx = egui::Context::default(); - let mut app = app(); - app.printing.printers.push(crate::printing::Printer { name: "Queue".into(), label: String::new(), default: true }); - app.run("ui.printSettings", serde_json::json!({"printer": "Queue"})).unwrap(); - app.services.print_native = Some(Box::new(|_, _| { - let (sender, receiver) = std::sync::mpsc::channel(); - sender.send(Err("printer is offline".into())).unwrap(); - receiver - })); - app.run("ui.printSubmit", serde_json::json!({})).unwrap(); - let _ = ctx.run_logic(&egui::RawInput::default(), |ctx| app.logic(ctx)); - assert_eq!(app.printing.message, Some(crate::printing::Feedback::Failed("printer is offline".into()))); - assert!(app.message.as_ref().is_some_and(|(_, text)| text.contains("printer is offline")), "logic surfaces the failure"); - } - - #[test] - /// Pending synthetic input makes logic request a real hidden paint. - fn a_pending_synthetic_step_requests_a_hidden_paint() { - let ctx = egui::Context::default(); - let mut app = app(); - app.synthetic.push_back(vec![key_event(true)]); - app.synthetic_pending = true; - let out = ui_pass(&ctx, egui::RawInput::default(), |ui| app.logic(ui.ctx())); - let painted = out - .viewport_output - .get(&ctx.viewport_id()) - .is_some_and(|output| output.commands.iter().any(|c| matches!(c, egui::ViewportCommand::RequestPaintWhileHidden))); - assert!(painted, "logic must ask for a real UI pass while a synthetic step is unapplied"); - } -} diff --git a/crates/ui-egui/src/services.rs b/crates/ui-egui/src/services.rs new file mode 100644 index 000000000..1e6455860 --- /dev/null +++ b/crates/ui-egui/src/services.rs @@ -0,0 +1,61 @@ +use super::*; + +/// Host services the platform shell provides (file pickers, opening URLs). +#[derive(Default)] +pub struct Services { + /// Desktop: discover configured printers on a worker. + pub printers: Option, + /// Desktop: submit the PDF with the chosen settings on a worker. + pub print_native: Option, + pub pick_open: Option Option>>, + pub pick_save: Option Option>>, + /// Native picture picker (PNG/JPEG), separate from workbook opening. + pub pick_picture: Option Option>>, + /// Browser picture picker. Each dialog owns its inbox, so canceled picks cannot + /// insert into a subsequent dialog or open the image as a workbook. + pub pick_picture_async: Option>, + pub open_url: Option>, + /// Publish HTML and its plain-text alternative together. `Ok` means the host owns the + /// write (including any asynchronous fallback); an immediate error uses egui's text path. + pub copy_html: Option Result<(), String>>>, + /// Read the HTML flavour of the system clipboard when the user pastes text, so cells the + /// source spreadsheet recorded as numbers stay numbers ("Rp10,000.00" → 10000). `None` from + /// the host pastes the plain text alone. + pub paste_html: Option Option>>, + /// Read the system clipboard's plain text for the ribbon and menu Paste commands (Cmd/Ctrl+V + /// gets it from egui's `Event::Paste`). `None` from the host, or no service (web), pastes + /// GridCraft's own clipboard. + pub paste_text: Option Option>>, + /// Web: deliver a file to the user (name, bytes). + pub download: Option>, + /// Web: start an asynchronous file pick; the bytes arrive later in `inbox`. + pub open_async: Option>, + /// Files delivered asynchronously (name, bytes), opened on the next frame. + pub inbox: Option, + /// Web: hand PDF bytes to the browser's print flow directly (no download, + /// no intermediate file) — opens the system print dialog on that PDF. + pub print: Option>, +} + +/// Shared queue of files read asynchronously (browser file picker, drag and drop). +pub type Inbox = std::sync::Arc)>>>; + +/// Which parts of the app's own window chrome are drawn. Everything is on by default; a host +/// that embeds the sheet in its own window (and owns files itself) can turn off the title bar +/// and the File tab, whose Backstage view then never opens. Not persisted: it describes the +/// host, not a user preference. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Chrome { + /// The title bar: AutoSave, Home, Save, Undo/Redo, the file name, Comments, Share, Search and + /// the theme switch. + pub title_bar: bool, + /// The ribbon's File tab and the Backstage view it opens (also reached by keyboard and + /// commands such as `ui.backstage`). + pub file_tab: bool, +} + +impl Default for Chrome { + fn default() -> Self { + Chrome { title_bar: true, file_tab: true } + } +} diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs new file mode 100644 index 000000000..d9e6ceaaa --- /dev/null +++ b/crates/ui-egui/src/state.rs @@ -0,0 +1,40 @@ +/// Per-sheet view state (scroll position in sheet points). +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct SheetView { + pub scroll: egui::Vec2, +} + +/// Severity of a notice shown in the info bar under the ribbon. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum NoticeKind { + Info, + Warning, +} + +/// An answer to the Save Changes question. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SaveAnswer { + Save, + DontSave, + Cancel, +} + +/// One open window, as View › Switch Windows and the macOS Window menu list it. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct OpenWindow { + /// The window's id, the `window` param of `view.switchWindows`. + pub window: u64, + /// Its workbook's `DocState::uid`. + pub doc: u64, + pub title: String, + pub dirty: bool, + pub active: bool, +} + +impl OpenWindow { + /// The title ("Book1", or "Book1:2" for a workbook's second window), with a bullet after it + /// while the workbook has unsaved changes. + pub fn label(&self) -> String { + if self.dirty { format!("{} •", self.title) } else { self.title.clone() } + } +}