Files
gridcraft/crates/ui-egui/src/lib.rs
T

898 lines
36 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 formula_bar;
mod formula_locale;
pub mod grid;
pub mod i18n;
pub mod icons;
pub mod keymap;
pub mod keytips;
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};
/// 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>,
/// 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![],
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,
}
/// 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)>,
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,
/// 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,
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,
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_han: None,
effective_dark: false,
name_box: None,
keytips: keytips::KeyTips::default(),
shots: control::Shots::default(),
selected_chart: None,
started: now_ms(),
}
}
/// One-time context setup: fonts and visuals (default Han order; never reads the host locale).
pub fn setup_context(ctx: &egui::Context, dark: bool) {
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);
}
/// 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 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).
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)
}
_ => 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 let Some(dl) = &self.services.download
&& let Ok(r) = self.session.run("file.saveBytes", json!({"format": "xlsx"}))
&& 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(_) => {
self.ui.recent.retain(|p| p != path);
self.ui.recent.insert(0, path.to_string());
self.ui.recent.truncate(20);
}
Err(e) => self.message = Some(("GridCraft".into(), clean_error(&e))),
}
self.after_engine();
}
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_default()
}
pub fn view_mut(&mut self) -> Option<&mut SheetView> {
let k = self.view_key()?;
Some(self.views.entry(k).or_default())
}
/// 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) {
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;
self.fonts_han = Some(theme::HanOrder::of(self.ui.language));
ctx.request_repaint();
}
}
let preference = 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(_) => self.backstage = None,
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 {
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);
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;
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);
}
// The legacy Edit/Format paths lead to the same existing Home menus. Switch once, when
// the prefix changes, not every frame (that would reset a tab the user picked meanwhile).
let after = self.keytips.prefix();
if after != before.as_deref() && matches!(after, Some("E" | "O" | "OC")) {
self.run_or_alert("ui.ribbonTab", json!({"tab": "Home"}));
self.ui.ribbon_collapsed = false;
}
for action in actions {
match action {
keytips::Action::Home => {
self.run_or_alert("ui.ribbonTab", json!({"tab": "Home"}));
self.ui.ribbon_collapsed = false;
}
keytips::Action::Command(id) => self.run_or_alert(id, json!({})),
keytips::Action::Dialog(name) => self.open_dialog(name, json!({})),
}
}
}
/// 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)
}