//! GridCraft's egui frontend: an Excel-style window (title bar with Quick Access Toolbar, //! ribbon, formula bar, grid, sheet tabs, status bar) over `gridcraft-engine`. //! //! The UI is thin: it reads engine state and acts through [`SheetApp::run`] (engine commands by //! id). Everything it can do is also reachable over the control channel ([`control`]). #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] #![forbid(unsafe_code)] pub mod backstage; mod border_preview; mod cell_pictures; pub mod chartview; pub mod control; pub mod credits; pub mod dialogs; pub mod editor; pub mod fonts; pub mod formula_bar; mod formula_locale; pub mod grid; pub mod i18n; pub mod icons; pub mod keymap; pub mod keytips; pub mod outline; pub mod panes; pub mod paper_view; pub mod pivot_pane; pub mod ribbon; pub mod selection_glide; pub mod tabs; pub mod text_box; pub mod theme; pub mod widgets; use std::collections::HashMap; use gridcraft_engine::core::{CellRef, RangeRef}; use gridcraft_engine::{Session, UiRequest}; use serde::{Deserialize, Serialize}; use serde_json::{Value as Json, json}; pub use control::{ControlRequest, ControlResponse}; pub mod desktop_theme; /// Persisted UI preferences. #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] pub struct UiState { /// The manual preference, retained when following the system appearance. pub dark: bool, pub system_theme: bool, pub ribbon_tab: String, pub ribbon_collapsed: bool, pub formula_bar_expanded: bool, pub status_bar: bool, /// Motion in the UI: the selection outline glides to a new selection (View ▸ Animations). pub animations: bool, pub recent: Vec, /// One-shot keytip request for a ribbon control: set when a keytip action resolves to /// [`keytips::Action::Control`], consumed (and cleared) by the control's render site. #[serde(skip)] pub keytip_invoke: Option<&'static str>, /// Interface language. English by default (so tests and headless renders never depend on the /// host's locale); the desktop app starts from [`i18n::Language::system`] when `ui.json` has /// no usable language (see [`i18n::saved_language`]). An unknown saved value reads as English /// rather than failing the whole `UiState`. #[serde(deserialize_with = "i18n::lenient")] pub language: i18n::Language, /// Interface size in percent on top of the desktop's scale (100 = Auto), applied as egui's /// zoom factor so the ribbon, menus, dialogs and grid grow together. Kept within /// [`INTERFACE_SCALE_MIN`]..=[`INTERFACE_SCALE_MAX`]; a malformed saved value reads as 100. #[serde(deserialize_with = "lenient_interface_scale")] pub interface_scale: u16, } pub const INTERFACE_SCALE_MIN: u16 = 75; pub const INTERFACE_SCALE_MAX: u16 = 200; /// A requested interface size, rounded and clamped to the supported range; `None` when it isn't /// a finite number. pub fn clamp_interface_scale(percent: f64) -> Option { percent.is_finite().then(|| percent.round().clamp(INTERFACE_SCALE_MIN as f64, INTERFACE_SCALE_MAX as f64) as u16) } fn lenient_interface_scale<'de, D: serde::Deserializer<'de>>(d: D) -> Result { let v = Json::deserialize(d)?; Ok(v.as_f64().and_then(clamp_interface_scale).unwrap_or(100)) } impl Default for UiState { fn default() -> Self { UiState { dark: false, system_theme: false, ribbon_tab: "Home".into(), ribbon_collapsed: false, formula_bar_expanded: false, status_bar: true, animations: true, recent: vec![], keytip_invoke: None, language: i18n::Language::En, interface_scale: 100, } } } impl UiState { pub fn theme_preference(&self) -> egui::ThemePreference { if self.system_theme { egui::ThemePreference::System } else if self.dark { egui::ThemePreference::Dark } else { egui::ThemePreference::Light } } /// The zoom factor for the interface size preference. pub fn zoom_factor(&self) -> f32 { clamp_interface_scale(self.interface_scale as f64).unwrap_or(100) as f32 / 100.0 } pub fn theme_mode(&self) -> &'static str { if self.system_theme { "system" } else if self.dark { "dark" } else { "light" } } } /// 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)] pub struct Notice { pub text: String, pub kind: NoticeKind, /// Optional button: `(label, dialog name)` — opens that dialog when clicked. pub action: Option<(String, String)>, } /// Host services the platform shell provides (file pickers, opening URLs). #[derive(Default)] pub struct Services { 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>>, /// 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, pub grid_ms: f64, pub fps: f64, last: f64, } pub struct SheetApp { pub session: Session, pub ui: UiState, pub views: HashMap<(u64, usize), SheetView>, pub editor: Option, pub text_box_editor: Option, pub dialog: Option, /// The File tab's Backstage view, when open (UI-only; replaces the ribbon, grid and tabs). pub backstage: Option, pub message: Option<(String, String)>, pub toast: Option<(String, f64)>, /// Dismissible info bar under the ribbon (open-time notices). pub notice: Option, pub services: Services, /// Synthetic input from agents, one batch per frame (drags need several frames). pub synthetic: std::collections::VecDeque>, pub control_rx: Option>, pub grid: grid::GridState, pub perf: Perf, pub fonts_ready: bool, fonts_set: bool, /// Workbook fonts bound into the current font set; a change rebinds (installed fonts only). fonts_bound: Vec, /// The (doc uid, revision) `fonts_bound` was computed for, so the style table is walked only /// when the workbook changes. fonts_seen: (u64, u64), /// Set while a font-set change is pending activation: skip this frame's paint, like the /// startup `fonts_ready` gate (egui applies new definitions at the next pass). fonts_rebind: bool, /// Han face order the installed fonts were built with; a language switch rebuilds the fonts /// only when it changes this (see `theme::HanOrder`). fonts_han: Option, effective_dark: bool, /// The desktop's light/dark choice, watched while the app runs (Linux has no winit answer). desktop_theme: desktop_theme::DesktopTheme, /// The last answer [`desktop_theme`] gave, so a frame that asks costs nothing. desktop_dark: Option, pub name_box: Option, /// Transient ribbon keyboard navigation; never saved with UI preferences. pub keytips: keytips::KeyTips, pub(crate) shots: control::Shots, /// Chart selected on the sheet (id). pub selected_chart: Option, pub started: f64, } impl SheetApp { pub fn new(session: Session, services: Services) -> SheetApp { SheetApp { session, ui: UiState::default(), views: HashMap::new(), editor: None, text_box_editor: None, dialog: None, backstage: None, message: None, toast: None, notice: None, services, synthetic: Default::default(), 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, 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(), selected_chart: None, started: now_ms(), } } /// 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); } /// The font names the active workbook uses: every style's font plus the theme's heading/body /// fonts. These are the families [`fonts`] binds an installed file for. pub fn used_fonts(&self) -> Vec { let mut names: Vec = Vec::new(); if let Some(d) = self.session.active() { 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 } /// Runs an engine command (or a UI command) and handles UI requests it makes. pub fn run(&mut self, id: &str, params: Json) -> Result { if matches!(id, "file.save" | "file.saveAs" | "file.saveBytes") { self.store_view_state(); } if id.starts_with("file.") || matches!( id, "window.activate" | "sheet.activate" | "sheet.next" | "sheet.previous" | "sheet.move" | "sheet.hide" | "sheet.unhide" | "view.newWindow" | "view.hideWindow" | "view.unhideWindow" | "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 r = self.session.run(id, params); self.after_engine(); 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, 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 }; 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, with the clipboard's HTML flavour when the /// host can read it. pub fn paste_external(&mut self, text: String) { 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); } /// 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) { // Printing from the UI also hands the PDF to the host's print flow (web). if id == "file.print" { return self.print_now(params); } if let Err(e) = self.run(id, params) { self.alert(e); } } /// Shows a command error: a toast for an unavailable command, else a message box. fn alert(&mut self, e: String) { if e.contains("is not available right now") { self.toast = Some((e, now_ms())); } else { self.message = Some(("GridCraft".into(), clean_error(&e))); } } /// Runs `file.print`, builds the PDF and (where the host provides the hook — the web /// build) hands the bytes straight to the browser's print flow; errors show the usual /// alert. Desktop builds have no `services.print`, so this falls back to `run_or_alert` /// (which still produces the PDF — just without a UI to act on the result yet). pub fn print_now(&mut self, params: Json) { match self.run("file.print", params) { Ok(r) => { let Some(b64) = r.get("base64").and_then(Json::as_str) else { return }; let Some(bytes) = gridcraft_engine::io::base64_decode(b64) else { self.message = Some(("GridCraft".into(), "Couldn't decode the generated PDF.".into())); return; }; if let Some(print) = &self.services.print { print(&bytes); } } Err(e) => self.alert(e), } } /// 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 { "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.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})) } "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 \"en\", \"es\", \"zh\", \"ja\", \"ko\", \"ru\", \"pt\" or \"cs\")")), } } "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) { 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::OpenUrl(u) => { if let Some(f) = &self.services.open_url { f(&u); } } } } } 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; } 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.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) { 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() { self.notice = Some(Notice { text: "This workbook contains VBA macros. GridCraft keeps them intact when you save, but does not run them.".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 }); } } pub fn view_key(&self) -> Option<(u64, usize)> { self.session.active().map(|d| (d.uid, d.wb.active_sheet)) } pub fn view(&self) -> SheetView { self.view_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.view_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 Some(d) = self.session.active_mut() else { return }; let mut tops = Vec::new(); for (&(uid, si), v) in &self.views { let Some(sh) = d.wb.sheets.get(si).filter(|sh| uid == d.uid && 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) { 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.wb.active_sheet, at, text, replace, from_formula_bar); ed.locale = locale; if replace && !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. pub fn commit_edit(&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(), }; 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())); } let r = 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 } } } 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()); if multi { let fwd = dr > 0 || dc > 0; let _ = self.session.run("selection.next", json!({"forward": fwd, "byRow": dc != 0})); } else { let _ = self.session.run("selection.move", json!({"dr": dr, "dc": dc})); } self.grid.ensure_visible = true; } /// Per-frame logic (control channel, screenshots). Call before `ui`. pub fn logic(&mut self, ctx: &egui::Context) { // 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; if 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 { // New fonts apply from the next frame on: paint nothing until then. if self.fonts_set { self.fonts_ready = true; } 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(); } } else if self.fonts_rebind { // A font-set change made last frame is active now. self.fonts_rebind = false; } else if fonts::scan_done() { // Bind 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 the workbook changed. let seen = self.session.active().map(|d| (d.uid, d.revision)).unwrap_or((0, 0)); if seen != self.fonts_seen { let used = self.used_fonts(); self.fonts_seen = seen; if used != self.fonts_bound { ctx.set_fonts(theme::font_definitions_with(self.ui.language, &used)); self.fonts_bound = used; self.fonts_rebind = true; ctx.request_repaint(); } } } if let Some(answer) = self.desktop_theme.take_change() { self.desktop_dark = Some(answer); } 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; // 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); } let zoom = self.ui.zoom_factor(); if (ctx.zoom_factor() - zoom).abs() > 0.001 { ctx.set_zoom_factor(zoom); ctx.request_repaint(); } control::poll(self, ctx); // Files read asynchronously (web file picker, dropped files). 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(); } if self.session.active().is_none() { self.session.new_workbook(); } } pub fn raw_input_hook(&mut self, raw: &mut egui::RawInput) { if let Some(mut batch) = self.synthetic.pop_front() { raw.events.append(&mut batch); } } /// 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(); text_box::before_ui(self, &ctx); // The language the widgets translate with this frame (see `i18n::current`). i18n::set_current(&ctx, self.ui.language); let t = theme::Tokens::get(&ctx); if self.backstage.is_none() { self.ribbon_keys(&ctx); } 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); control::issue_screenshots(self, &ctx); control::collect_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; // AutoSave: save workbooks that have a file shortly after they change. if self.grid.autosave && self.editor.is_none() && self.text_box_editor.is_none() && now - self.grid.last_autosave > 2000.0 && self.session.active().is_some_and(|d| d.is_dirty() && d.path.as_deref().is_some_and(|p| p.ends_with(".xlsx"))) { self.grid.last_autosave = now; self.store_view_state(); let _ = self.session.run("file.save", json!({})); } // Window title. if let Some(d) = self.session.active() { 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); } } } 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.header_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); 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(tab) => { self.run_or_alert("ui.ribbonTab", json!({"tab": tab})); self.ui.ribbon_collapsed = false; } keytips::Action::Command(id) => self.run_or_alert(id, json!({})), keytips::Action::Dialog(name) => self.open_dialog(name, 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(s.trim_matches('\'').to_string()), 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) { return rest.to_string(); } e.to_string() } #[allow(clippy::disallowed_methods)] // the clock is read only off wasm pub fn now_ms() -> f64 { #[cfg(not(target_arch = "wasm32"))] { use std::time::{SystemTime, UNIX_EPOCH}; SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs_f64() * 1000.0).unwrap_or(0.0) } #[cfg(target_arch = "wasm32")] { 0.0 } } pub fn cell(r: u32, c: u32) -> CellRef { CellRef::new(r, c) }