Files
gridcraft/crates/ui-egui/src/lib.rs
T
Brandon Thomas 3b19cb80e6 Merge pull request #412 from jsymes21-byte/keytips-table-refactor
Keytips: full Excel sequential Alt table — 364 sequences, badges on every control, generated tests (fixes #33, #49)

# Conflicts:
#	contributors/people.toml
2026-10-11 18:06:06 -04:00

1105 lines
46 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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<String>,
/// 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<u16> {
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<u16, D::Error> {
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<Box<dyn Fn() -> Option<String>>>,
pub pick_save: Option<Box<dyn Fn(&str) -> Option<String>>>,
/// Native picture picker (PNG/JPEG), separate from workbook opening.
pub pick_picture: Option<Box<dyn Fn() -> Option<String>>>,
/// 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<Box<dyn Fn(Inbox)>>,
pub open_url: Option<Box<dyn Fn(&str)>>,
/// 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<Box<dyn FnMut(&str, &str) -> 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<Box<dyn FnMut() -> Option<String>>>,
/// Web: deliver a file to the user (name, bytes).
pub download: Option<Box<dyn Fn(&str, &[u8])>>,
/// Web: start an asynchronous file pick; the bytes arrive later in `inbox`.
pub open_async: Option<Box<dyn Fn()>>,
/// Files delivered asynchronously (name, bytes), opened on the next frame.
pub inbox: Option<Inbox>,
/// 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<Box<dyn Fn(&[u8])>>,
}
/// Shared queue of files read asynchronously (browser file picker, drag and drop).
pub type Inbox = std::sync::Arc<std::sync::Mutex<Vec<(String, Vec<u8>)>>>;
#[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<editor::EditState>,
pub text_box_editor: Option<text_box::EditState>,
pub dialog: Option<dialogs::Dialog>,
/// The File tab's Backstage view, when open (UI-only; replaces the ribbon, grid and tabs).
pub backstage: Option<backstage::Backstage>,
pub message: Option<(String, String)>,
pub toast: Option<(String, f64)>,
/// Dismissible info bar under the ribbon (open-time notices).
pub notice: Option<Notice>,
pub services: Services,
/// Synthetic input from agents, one batch per frame (drags need several frames).
pub synthetic: std::collections::VecDeque<Vec<egui::Event>>,
pub control_rx: Option<std::sync::mpsc::Receiver<ControlRequest>>,
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<String>,
/// 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<theme::HanOrder>,
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<bool>,
pub name_box: Option<String>,
/// 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<u32>,
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<String> {
let mut names: Vec<String> = 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<Json, String> {
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, &params) {
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<Result<Json, String>> {
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<String>, 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<u8>)> = 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)
}