Files
gridcraft/crates/ui-egui/src/lib.rs
T
aiongg 7085aa1c47 G3: build on current main
Screenshots are admitted in logic for the root viewport and issued from its pass, so nothing
reads the focused native window any more; the field goes.
2026-10-26 02:45:24 +00:00

2160 lines
101 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 code_editor;
pub mod control;
mod craft_fonts_policy;
pub mod credits;
pub mod dialogs;
#[cfg(not(target_arch = "wasm32"))]
pub mod doc_windows;
pub mod editor;
mod floating_pictures;
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 printing;
pub mod ribbon;
pub mod selection_glide;
pub mod tabs;
pub mod text_box;
pub mod theme;
#[cfg(not(target_arch = "wasm32"))]
mod updates_ui;
pub mod widgets;
mod window_ui;
pub use window_ui::WindowUi;
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,
/// Enable AutoComplete for cell values (File ▸ Options ▸ Editing): typed text is completed
/// from the one matching entry in the same column.
pub autocomplete: bool,
/// AutoSave (the title-bar switch): save saved-to-file .xlsx workbooks shortly after every
/// change. A preference like the rest of this struct — it stays on across restarts (#1901).
pub autosave: 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,
autocomplete: true,
autosave: false,
recent: vec![],
keytip_invoke: None,
language: i18n::Language::En,
interface_scale: 100,
}
}
}
impl UiState {
/// The state saved by an earlier run, as the app starts with it. The ribbon tab is written to
/// the preferences but not restored: Excel opens on Home every launch, so a contextual tab
/// (Table Design) cannot come back with nothing selected to justify it.
pub fn at_startup(mut self) -> Self {
self.ribbon_tab = "Home".into();
self
}
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)>,
}
/// 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<printing::Discover>,
/// Desktop: submit the PDF with the chosen settings on a worker.
pub print_native: Option<printing::Submit>,
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>>>,
/// 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<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,
}
/// 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,
/// The window chrome to draw (see [`Chrome`]).
pub chrome: Chrome,
/// Scroll position per (window id, sheet index).
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,
pub printing: printing::State,
print_feedback_seen: Option<printing::Feedback>,
/// Synthetic input from agents, one batch per step (drags need several steps).
pub synthetic: std::collections::VecDeque<Vec<egui::Event>>,
/// The front synthetic batch was injected into `RawInput` but no `ui` pass has applied it
/// yet. eframe retains that unconsumed input across hidden logic-only ticks; the flag makes
/// the hook offer it once and asks for a hidden paint until a UI pass applies it.
pub(crate) synthetic_pending: bool,
/// `cumulative_pass_nr` when the front batch was last injected. A completed egui pass that
/// did not apply it (the font gate's early return) consumed the events with `begin_pass`
/// without reaching input handling, so the hook must offer the same step again.
pub(crate) synthetic_injected_at_pass: Option<u64>,
pub control_rx: Option<std::sync::mpsc::Receiver<ControlRequest>>,
pub grid: grid::GridState,
pub perf: Perf,
pub fonts_ready: bool,
fonts_set: bool,
/// Test harness set: rebuilds use the deterministic pinned font set instead of the host scan.
prepared_fonts: bool,
/// Workbook fonts bound into the current font set; a change rebinds (installed fonts only).
fonts_bound: Vec<String>,
/// The open workbooks' (uid, revision), folded, that `fonts_bound` was computed for, so the
/// style tables are walked only when a 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,
/// Monotonic epoch of [`control::CaptureGeometry`]: any resize/DPI/zoom/surface change bumps
/// it, including a change back to an earlier value, so a capture taken in another layout is
/// never saved against the current geometry.
pub(crate) capture_epoch: u64,
/// The geometry `capture_epoch` describes; `None` before the first frame.
pub(crate) capture_geometry: Option<control::CaptureGeometry>,
/// The recorded geometry came from a native `RawInput` frame (see `raw_input_hook`). When
/// false (a bare test/embedder that never runs the hook) capture code falls back to egui's
/// current input instead of reusing a stale recorded value.
pub(crate) capture_geometry_from_raw: bool,
/// Chart selected on the sheet (id).
pub selected_chart: Option<u32>,
/// The system clipboard's text when GridCraft last copied without publishing to it (a
/// control-channel or MCP `edit.copy`/`edit.cut`). While the system clipboard still holds this
/// text, GridCraft's copy is the newer one and Paste uses it.
clipboard_text_at_copy: Option<String>,
/// The system clipboard's text right after GridCraft's last cut (the published text, or the
/// text noted for an unpublished cut); `None` after a copy, or once the clipboard is seen
/// holding other text. Once the cut has been pasted, Paste leaves this text alone.
cut_text: Option<String>,
/// A run of Tabs: (doc uid, sheet, column it started in, cell the last Tab landed on).
/// Enter on that cell goes to the next row in the starting column, as in Excel (#911).
tab_run: Option<(u64, usize, u32, CellRef)>,
pub started: f64,
/// Every unsaved workbook has been answered for quitting (or `app.quit` asked): the next
/// window close goes through.
pub allow_close: bool,
/// View › Switch Windows opens its list on the next frame the View tab draws (asked for by a
/// key tip, the command search or a menu, possibly while another tab was showing).
pub(crate) open_switch_windows: bool,
/// The window whose interface state the per-window fields above hold (see [`WindowUi`]).
ui_window: Option<u64>,
/// The other windows' interface state, by window id.
parked: HashMap<u64, WindowUi>,
/// Each window is a native window of its own ([`doc_windows`] on the desktop): a window's
/// close button closes that window, and quitting is asked for separately. Without it (web,
/// tests, embedding hosts) one window shows the active workbook window, and its close button
/// quits.
pub native_windows: bool,
/// The update check, the startup prompt and a running self-update (desktop only).
#[cfg(not(target_arch = "wasm32"))]
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,
#[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<String> {
let mut names: Vec<String> = 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<Json, String> {
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<Json, String> {
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, &params) {
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();
// 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<String> {
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<Item = char> + '_ {
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<Json, String> {
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<Result<Json, String>> {
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<OpenWindow> {
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<String>, 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<u64> = 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<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();
}
}
/// 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) {
return rest.to_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<std::time::Instant> = 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<String> {
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");
}
}