mirror of
https://github.com/storytold/gridcraft.git
synced 2026-10-27 04:06:52 -06:00
Keytips: full Excel sequential Alt table — 364 sequences, badges on every control, generated tests (fixes #33, #49) # Conflicts: # contributors/people.toml
1105 lines
46 KiB
Rust
1105 lines
46 KiB
Rust
//! GridCraft's egui frontend: an Excel-style window (title bar with Quick Access Toolbar,
|
||
//! ribbon, formula bar, grid, sheet tabs, status bar) over `gridcraft-engine`.
|
||
//!
|
||
//! The UI is thin: it reads engine state and acts through [`SheetApp::run`] (engine commands by
|
||
//! id). Everything it can do is also reachable over the control channel ([`control`]).
|
||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||
#![forbid(unsafe_code)]
|
||
|
||
pub mod backstage;
|
||
mod border_preview;
|
||
mod cell_pictures;
|
||
pub mod chartview;
|
||
pub mod control;
|
||
pub mod credits;
|
||
pub mod dialogs;
|
||
pub mod editor;
|
||
pub mod fonts;
|
||
pub mod formula_bar;
|
||
mod formula_locale;
|
||
pub mod grid;
|
||
pub mod i18n;
|
||
pub mod icons;
|
||
pub mod keymap;
|
||
pub mod keytips;
|
||
pub mod outline;
|
||
pub mod panes;
|
||
pub mod paper_view;
|
||
pub mod pivot_pane;
|
||
pub mod ribbon;
|
||
pub mod selection_glide;
|
||
pub mod tabs;
|
||
pub mod text_box;
|
||
pub mod theme;
|
||
pub mod widgets;
|
||
|
||
use std::collections::HashMap;
|
||
|
||
use gridcraft_engine::core::{CellRef, RangeRef};
|
||
use gridcraft_engine::{Session, UiRequest};
|
||
use serde::{Deserialize, Serialize};
|
||
use serde_json::{Value as Json, json};
|
||
|
||
pub use control::{ControlRequest, ControlResponse};
|
||
pub mod desktop_theme;
|
||
|
||
/// Persisted UI preferences.
|
||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||
#[serde(default, rename_all = "camelCase")]
|
||
pub struct UiState {
|
||
/// The manual preference, retained when following the system appearance.
|
||
pub dark: bool,
|
||
pub system_theme: bool,
|
||
pub ribbon_tab: String,
|
||
pub ribbon_collapsed: bool,
|
||
pub formula_bar_expanded: bool,
|
||
pub status_bar: bool,
|
||
/// Motion in the UI: the selection outline glides to a new selection (View ▸ Animations).
|
||
pub animations: bool,
|
||
pub recent: Vec<String>,
|
||
/// One-shot keytip request for a ribbon control: set when a keytip action resolves to
|
||
/// [`keytips::Action::Control`], consumed (and cleared) by the control's render site.
|
||
#[serde(skip)]
|
||
pub keytip_invoke: Option<&'static str>,
|
||
/// Interface language. English by default (so tests and headless renders never depend on the
|
||
/// host's locale); the desktop app starts from [`i18n::Language::system`] when `ui.json` has
|
||
/// no usable language (see [`i18n::saved_language`]). An unknown saved value reads as English
|
||
/// rather than failing the whole `UiState`.
|
||
#[serde(deserialize_with = "i18n::lenient")]
|
||
pub language: i18n::Language,
|
||
/// Interface size in percent on top of the desktop's scale (100 = Auto), applied as egui's
|
||
/// zoom factor so the ribbon, menus, dialogs and grid grow together. Kept within
|
||
/// [`INTERFACE_SCALE_MIN`]..=[`INTERFACE_SCALE_MAX`]; a malformed saved value reads as 100.
|
||
#[serde(deserialize_with = "lenient_interface_scale")]
|
||
pub interface_scale: u16,
|
||
}
|
||
|
||
pub const INTERFACE_SCALE_MIN: u16 = 75;
|
||
pub const INTERFACE_SCALE_MAX: u16 = 200;
|
||
|
||
/// A requested interface size, rounded and clamped to the supported range; `None` when it isn't
|
||
/// a finite number.
|
||
pub fn clamp_interface_scale(percent: f64) -> Option<u16> {
|
||
percent.is_finite().then(|| percent.round().clamp(INTERFACE_SCALE_MIN as f64, INTERFACE_SCALE_MAX as f64) as u16)
|
||
}
|
||
|
||
fn lenient_interface_scale<'de, D: serde::Deserializer<'de>>(d: D) -> Result<u16, D::Error> {
|
||
let v = Json::deserialize(d)?;
|
||
Ok(v.as_f64().and_then(clamp_interface_scale).unwrap_or(100))
|
||
}
|
||
|
||
impl Default for UiState {
|
||
fn default() -> Self {
|
||
UiState {
|
||
dark: false,
|
||
system_theme: false,
|
||
ribbon_tab: "Home".into(),
|
||
ribbon_collapsed: false,
|
||
formula_bar_expanded: false,
|
||
status_bar: true,
|
||
animations: true,
|
||
recent: vec![],
|
||
keytip_invoke: None,
|
||
language: i18n::Language::En,
|
||
interface_scale: 100,
|
||
}
|
||
}
|
||
}
|
||
|
||
impl UiState {
|
||
pub fn theme_preference(&self) -> egui::ThemePreference {
|
||
if self.system_theme {
|
||
egui::ThemePreference::System
|
||
} else if self.dark {
|
||
egui::ThemePreference::Dark
|
||
} else {
|
||
egui::ThemePreference::Light
|
||
}
|
||
}
|
||
|
||
/// The zoom factor for the interface size preference.
|
||
pub fn zoom_factor(&self) -> f32 {
|
||
clamp_interface_scale(self.interface_scale as f64).unwrap_or(100) as f32 / 100.0
|
||
}
|
||
|
||
pub fn theme_mode(&self) -> &'static str {
|
||
if self.system_theme {
|
||
"system"
|
||
} else if self.dark {
|
||
"dark"
|
||
} else {
|
||
"light"
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Per-sheet view state (scroll position in sheet points).
|
||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||
pub struct SheetView {
|
||
pub scroll: egui::Vec2,
|
||
}
|
||
|
||
/// Severity of a notice shown in the info bar under the ribbon.
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||
pub enum NoticeKind {
|
||
Info,
|
||
Warning,
|
||
}
|
||
|
||
/// A dismissible bar under the ribbon, in the spirit of Excel's message bar. Used to tell the
|
||
/// user what an opened file carried that GridCraft handled differently (e.g. preserved macros).
|
||
#[derive(Clone, Debug, PartialEq)]
|
||
pub struct Notice {
|
||
pub text: String,
|
||
pub kind: NoticeKind,
|
||
/// Optional button: `(label, dialog name)` — opens that dialog when clicked.
|
||
pub action: Option<(String, String)>,
|
||
}
|
||
|
||
/// Host services the platform shell provides (file pickers, opening URLs).
|
||
#[derive(Default)]
|
||
pub struct Services {
|
||
pub pick_open: Option<Box<dyn Fn() -> Option<String>>>,
|
||
pub pick_save: Option<Box<dyn Fn(&str) -> Option<String>>>,
|
||
/// Native picture picker (PNG/JPEG), separate from workbook opening.
|
||
pub pick_picture: Option<Box<dyn Fn() -> Option<String>>>,
|
||
/// Browser picture picker. Each dialog owns its inbox, so canceled picks cannot
|
||
/// insert into a subsequent dialog or open the image as a workbook.
|
||
pub pick_picture_async: Option<Box<dyn Fn(Inbox)>>,
|
||
pub open_url: Option<Box<dyn Fn(&str)>>,
|
||
/// Publish HTML and its plain-text alternative together. `Ok` means the host owns the
|
||
/// write (including any asynchronous fallback); an immediate error uses egui's text path.
|
||
pub copy_html: Option<Box<dyn FnMut(&str, &str) -> Result<(), String>>>,
|
||
/// Read the HTML flavour of the system clipboard when the user pastes text, so cells the
|
||
/// source spreadsheet recorded as numbers stay numbers ("Rp10,000.00" → 10000). `None` from
|
||
/// the host pastes the plain text alone.
|
||
pub paste_html: Option<Box<dyn FnMut() -> Option<String>>>,
|
||
/// Web: deliver a file to the user (name, bytes).
|
||
pub download: Option<Box<dyn Fn(&str, &[u8])>>,
|
||
/// Web: start an asynchronous file pick; the bytes arrive later in `inbox`.
|
||
pub open_async: Option<Box<dyn Fn()>>,
|
||
/// Files delivered asynchronously (name, bytes), opened on the next frame.
|
||
pub inbox: Option<Inbox>,
|
||
/// Web: hand PDF bytes to the browser's print flow directly (no download,
|
||
/// no intermediate file) — opens the system print dialog on that PDF.
|
||
pub print: Option<Box<dyn Fn(&[u8])>>,
|
||
}
|
||
|
||
/// Shared queue of files read asynchronously (browser file picker, drag and drop).
|
||
pub type Inbox = std::sync::Arc<std::sync::Mutex<Vec<(String, Vec<u8>)>>>;
|
||
|
||
#[derive(Clone, Debug, Default)]
|
||
pub struct Perf {
|
||
pub frame_ms: f64,
|
||
pub grid_ms: f64,
|
||
pub fps: f64,
|
||
last: f64,
|
||
}
|
||
|
||
pub struct SheetApp {
|
||
pub session: Session,
|
||
pub ui: UiState,
|
||
pub views: HashMap<(u64, usize), SheetView>,
|
||
pub editor: Option<editor::EditState>,
|
||
pub text_box_editor: Option<text_box::EditState>,
|
||
pub dialog: Option<dialogs::Dialog>,
|
||
/// The File tab's Backstage view, when open (UI-only; replaces the ribbon, grid and tabs).
|
||
pub backstage: Option<backstage::Backstage>,
|
||
pub message: Option<(String, String)>,
|
||
pub toast: Option<(String, f64)>,
|
||
/// Dismissible info bar under the ribbon (open-time notices).
|
||
pub notice: Option<Notice>,
|
||
pub services: Services,
|
||
/// Synthetic input from agents, one batch per frame (drags need several frames).
|
||
pub synthetic: std::collections::VecDeque<Vec<egui::Event>>,
|
||
pub control_rx: Option<std::sync::mpsc::Receiver<ControlRequest>>,
|
||
pub grid: grid::GridState,
|
||
pub perf: Perf,
|
||
pub fonts_ready: bool,
|
||
fonts_set: bool,
|
||
/// Workbook fonts bound into the current font set; a change rebinds (installed fonts only).
|
||
fonts_bound: Vec<String>,
|
||
/// The (doc uid, revision) `fonts_bound` was computed for, so the style table is walked only
|
||
/// when the workbook changes.
|
||
fonts_seen: (u64, u64),
|
||
/// Set while a font-set change is pending activation: skip this frame's paint, like the
|
||
/// startup `fonts_ready` gate (egui applies new definitions at the next pass).
|
||
fonts_rebind: bool,
|
||
/// Han face order the installed fonts were built with; a language switch rebuilds the fonts
|
||
/// only when it changes this (see `theme::HanOrder`).
|
||
fonts_han: Option<theme::HanOrder>,
|
||
effective_dark: bool,
|
||
/// The desktop's light/dark choice, watched while the app runs (Linux has no winit answer).
|
||
desktop_theme: desktop_theme::DesktopTheme,
|
||
/// The last answer [`desktop_theme`] gave, so a frame that asks costs nothing.
|
||
desktop_dark: Option<bool>,
|
||
pub name_box: Option<String>,
|
||
/// Transient ribbon keyboard navigation; never saved with UI preferences.
|
||
pub keytips: keytips::KeyTips,
|
||
pub(crate) shots: control::Shots,
|
||
/// Chart selected on the sheet (id).
|
||
pub selected_chart: Option<u32>,
|
||
pub started: f64,
|
||
}
|
||
|
||
impl SheetApp {
|
||
pub fn new(session: Session, services: Services) -> SheetApp {
|
||
SheetApp {
|
||
session,
|
||
ui: UiState::default(),
|
||
views: HashMap::new(),
|
||
editor: None,
|
||
text_box_editor: None,
|
||
dialog: None,
|
||
backstage: None,
|
||
message: None,
|
||
toast: None,
|
||
notice: None,
|
||
services,
|
||
synthetic: Default::default(),
|
||
control_rx: None,
|
||
grid: grid::GridState {
|
||
last_border: "bottom".into(),
|
||
last_fill: "#FFFF00".into(),
|
||
last_font_color: "#C00000".into(),
|
||
..Default::default()
|
||
},
|
||
perf: Perf::default(),
|
||
fonts_ready: false,
|
||
fonts_set: false,
|
||
fonts_bound: Vec::new(),
|
||
fonts_seen: (0, 0),
|
||
fonts_rebind: false,
|
||
fonts_han: None,
|
||
effective_dark: false,
|
||
desktop_theme: desktop_theme::DesktopTheme::default(),
|
||
desktop_dark: None,
|
||
name_box: None,
|
||
keytips: keytips::KeyTips::default(),
|
||
shots: control::Shots::default(),
|
||
selected_chart: None,
|
||
started: now_ms(),
|
||
}
|
||
}
|
||
|
||
/// One-time context setup: start cataloging the machine's fonts, then install the fonts and
|
||
/// visuals. Workbook-specific fonts are bound later, once the scan is done (see [`logic`]).
|
||
///
|
||
/// [`logic`]: SheetApp::logic
|
||
pub fn setup_context(ctx: &egui::Context, dark: bool) {
|
||
fonts::start_scan();
|
||
Self::setup_context_for_language(ctx, dark, i18n::Language::En);
|
||
}
|
||
|
||
/// Sets up fonts and visuals for a specific persisted interface language.
|
||
pub fn setup_context_for_language(ctx: &egui::Context, dark: bool, language: i18n::Language) {
|
||
ctx.set_fonts(theme::font_definitions_for_language(language));
|
||
theme::apply(ctx, dark);
|
||
}
|
||
|
||
/// The font names the active workbook uses: every style's font plus the theme's heading/body
|
||
/// fonts. These are the families [`fonts`] binds an installed file for.
|
||
pub fn used_fonts(&self) -> Vec<String> {
|
||
let mut names: Vec<String> = Vec::new();
|
||
if let Some(d) = self.session.active() {
|
||
names.extend(d.wb.styles.iter().map(|(_, s)| s.font.name.clone()));
|
||
names.push(d.wb.theme.major_font.clone());
|
||
names.push(d.wb.theme.minor_font.clone());
|
||
// Text boxes and shapes name their own typefaces.
|
||
names.extend(d.wb.sheets.iter().flat_map(|sh| sh.shapes.iter().filter_map(|s| s.format.font.clone())));
|
||
}
|
||
names
|
||
}
|
||
|
||
/// Runs an engine command (or a UI command) and handles UI requests it makes.
|
||
pub fn run(&mut self, id: &str, params: Json) -> Result<Json, String> {
|
||
if matches!(id, "file.save" | "file.saveAs" | "file.saveBytes") {
|
||
self.store_view_state();
|
||
}
|
||
if id.starts_with("file.")
|
||
|| matches!(
|
||
id,
|
||
"window.activate"
|
||
| "sheet.activate"
|
||
| "sheet.next"
|
||
| "sheet.previous"
|
||
| "sheet.move"
|
||
| "sheet.hide"
|
||
| "sheet.unhide"
|
||
| "view.newWindow"
|
||
| "view.hideWindow"
|
||
| "view.unhideWindow"
|
||
| "home.insertSheet"
|
||
| "home.deleteSheet"
|
||
| "edit.undo"
|
||
| "edit.redo"
|
||
| "object.delete"
|
||
| "object.move"
|
||
)
|
||
{
|
||
self.commit_text_box_edit()?;
|
||
}
|
||
if let Some(r) = self.run_ui_command(id, ¶ms) {
|
||
return r;
|
||
}
|
||
let r = self.session.run(id, params);
|
||
self.after_engine();
|
||
if id == "file.exportPdf"
|
||
&& let Ok(r) = &r
|
||
{
|
||
self.deliver_pdf(r);
|
||
}
|
||
r
|
||
}
|
||
|
||
/// Copy or cut the selected cells to the system clipboard. Engine-only commands keep
|
||
/// their existing internal clipboard behavior and do not write to the host clipboard.
|
||
pub fn copy_to_clipboard(&mut self, ctx: &egui::Context, command: &str) {
|
||
// Only ask the engine for HTML when the host can publish it.
|
||
let result = match self.run(command, json!({"html": self.services.copy_html.is_some()})) {
|
||
Ok(result) => result,
|
||
Err(e) => return self.alert(e),
|
||
};
|
||
let Some(text) = result.get("text").and_then(Json::as_str) else { return };
|
||
if let Some(html) = result.get("html").and_then(Json::as_str)
|
||
&& let Some(copy_html) = &mut self.services.copy_html
|
||
{
|
||
match copy_html(html, text) {
|
||
Ok(()) => return,
|
||
Err(error) => log::warn!("HTML clipboard unavailable; copying plain text: {error}"),
|
||
}
|
||
}
|
||
ctx.copy_text(text.to_string());
|
||
}
|
||
|
||
/// Paste text that came from the system clipboard, with the clipboard's HTML flavour when the
|
||
/// host can read it.
|
||
pub fn paste_external(&mut self, text: String) {
|
||
let html = self.services.paste_html.as_mut().and_then(|read| read());
|
||
let params = match html {
|
||
Some(html) => json!({"text": text, "html": html}),
|
||
None => json!({"text": text}),
|
||
};
|
||
self.run_or_alert("edit.paste", params);
|
||
}
|
||
|
||
/// Web: `file.exportPdf` without a path returns the PDF as base64; hand it to the browser as a download.
|
||
fn deliver_pdf(&self, result: &Json) {
|
||
if result.get("path").is_some() {
|
||
return;
|
||
}
|
||
if let Some(dl) = &self.services.download
|
||
&& let Some(b) = result.get("base64").and_then(Json::as_str).and_then(gridcraft_engine::io::base64_decode)
|
||
{
|
||
let title = self.session.active().map(|d| d.display_title()).unwrap_or_else(|| "Book1".into());
|
||
dl(&std::path::Path::new(&title).with_extension("pdf").to_string_lossy(), &b);
|
||
}
|
||
}
|
||
|
||
/// Runs a command and shows its error in a message box (for menu/ribbon clicks).
|
||
/// Follow the desktop's light/dark choice while the app runs (Linux; the desktop app calls
|
||
/// this once — tests and embedders don't start the watcher thread).
|
||
pub fn watch_desktop_theme(&mut self) {
|
||
self.desktop_theme = desktop_theme::DesktopTheme::start();
|
||
}
|
||
|
||
pub fn run_or_alert(&mut self, id: &str, params: Json) {
|
||
// Printing from the UI also hands the PDF to the host's print flow (web).
|
||
if id == "file.print" {
|
||
return self.print_now(params);
|
||
}
|
||
if let Err(e) = self.run(id, params) {
|
||
self.alert(e);
|
||
}
|
||
}
|
||
|
||
/// Shows a command error: a toast for an unavailable command, else a message box.
|
||
fn alert(&mut self, e: String) {
|
||
if e.contains("is not available right now") {
|
||
self.toast = Some((e, now_ms()));
|
||
} else {
|
||
self.message = Some(("GridCraft".into(), clean_error(&e)));
|
||
}
|
||
}
|
||
|
||
/// Runs `file.print`, builds the PDF and (where the host provides the hook — the web
|
||
/// build) hands the bytes straight to the browser's print flow; errors show the usual
|
||
/// alert. Desktop builds have no `services.print`, so this falls back to `run_or_alert`
|
||
/// (which still produces the PDF — just without a UI to act on the result yet).
|
||
pub fn print_now(&mut self, params: Json) {
|
||
match self.run("file.print", params) {
|
||
Ok(r) => {
|
||
let Some(b64) = r.get("base64").and_then(Json::as_str) else { return };
|
||
let Some(bytes) = gridcraft_engine::io::base64_decode(b64) else {
|
||
self.message = Some(("GridCraft".into(), "Couldn't decode the generated PDF.".into()));
|
||
return;
|
||
};
|
||
if let Some(print) = &self.services.print {
|
||
print(&bytes);
|
||
}
|
||
}
|
||
Err(e) => self.alert(e),
|
||
}
|
||
}
|
||
|
||
/// UI-only commands (dialogs, view toggles that aren't saved in the file).
|
||
fn run_ui_command(&mut self, id: &str, p: &Json) -> Option<Result<Json, String>> {
|
||
let r = match id {
|
||
"ui.ribbonTab" => {
|
||
self.ui.ribbon_tab = p.get("tab").and_then(Json::as_str).unwrap_or("Home").to_string();
|
||
Ok(Json::Null)
|
||
}
|
||
"view.collapseRibbon" => {
|
||
self.ui.ribbon_collapsed = !self.ui.ribbon_collapsed;
|
||
Ok(Json::Null)
|
||
}
|
||
"view.darkMode" => {
|
||
let current = if self.ui.system_theme { self.effective_dark } else { self.ui.dark };
|
||
self.ui.dark = p.get("on").and_then(Json::as_bool).unwrap_or(!current);
|
||
self.ui.system_theme = false;
|
||
Ok(json!({"on": self.ui.dark}))
|
||
}
|
||
"view.theme" => {
|
||
match p.get("mode").and_then(Json::as_str) {
|
||
Some("system") => self.ui.system_theme = true,
|
||
Some(mode @ ("light" | "dark")) => {
|
||
self.ui.dark = mode == "dark";
|
||
self.ui.system_theme = false;
|
||
}
|
||
_ => return Some(Err("theme mode must be system, light, or dark".into())),
|
||
}
|
||
Ok(json!({"mode": self.ui.theme_mode()}))
|
||
}
|
||
"view.animations" => {
|
||
self.ui.animations = p.get("on").and_then(Json::as_bool).unwrap_or(!self.ui.animations);
|
||
Ok(json!({"on": self.ui.animations}))
|
||
}
|
||
"view.interfaceScale" => {
|
||
// `{"percent": 125}` or `{"percent": "auto"}`; out-of-range values are clamped.
|
||
let percent = match p.get("percent") {
|
||
Some(Json::String(s)) if s.eq_ignore_ascii_case("auto") => Some(100),
|
||
Some(v) => v.as_f64().and_then(clamp_interface_scale),
|
||
None => None,
|
||
};
|
||
match percent {
|
||
Some(v) => {
|
||
self.ui.interface_scale = v;
|
||
Ok(json!({"percent": v}))
|
||
}
|
||
None => Err(format!("missing `percent` ({INTERFACE_SCALE_MIN}–{INTERFACE_SCALE_MAX}, or \"auto\")")),
|
||
}
|
||
}
|
||
"app.language.set" => {
|
||
// `{"language": "ja"}`; `code` is accepted as an alias.
|
||
let code = p.get("language").or_else(|| p.get("code")).and_then(Json::as_str).unwrap_or("");
|
||
match i18n::Language::parse(code) {
|
||
Some(l) => {
|
||
self.ui.language = l;
|
||
Ok(json!({"language": l}))
|
||
}
|
||
None => Err(format!("unknown language {code:?} (use \"en\", \"es\", \"zh\", \"ja\", \"ko\", \"ru\", \"pt\" or \"cs\")")),
|
||
}
|
||
}
|
||
"app.language.english" => {
|
||
self.ui.language = i18n::Language::En;
|
||
Ok(json!({"language": i18n::Language::En}))
|
||
}
|
||
"app.language.japanese" => {
|
||
self.ui.language = i18n::Language::Ja;
|
||
Ok(json!({"language": i18n::Language::Ja}))
|
||
}
|
||
"ui.backstage" => {
|
||
// `{"page": "info"}` opens File › Info; `{"page": "close"}` returns to the sheet.
|
||
match p.get("page").and_then(Json::as_str).map(str::trim) {
|
||
Some("close" | "none" | "") => self.backstage = None,
|
||
Some(id) => match backstage::Page::parse(id) {
|
||
Some(page) => self.open_backstage(page),
|
||
None => {
|
||
return Some(Err(format!(
|
||
"unknown File page {id:?} (use home, new, open, info, saveAs, print, export, options or close)"
|
||
)));
|
||
}
|
||
},
|
||
None => self.open_backstage(backstage::Page::Home),
|
||
}
|
||
Ok(json!({"page": self.backstage.as_ref().map(|b| b.page.id())}))
|
||
}
|
||
"ui.dialog" => {
|
||
let name = p.get("name").and_then(Json::as_str).unwrap_or("");
|
||
self.open_dialog(name, p.clone());
|
||
Ok(json!({"dialog": self.dialog.as_ref().map(|d| d.name())}))
|
||
}
|
||
"ui.message.dismiss" => {
|
||
self.message = None;
|
||
Ok(Json::Null)
|
||
}
|
||
"ui.notice" => Ok(match &self.notice {
|
||
Some(n) => json!({
|
||
"text": n.text,
|
||
"kind": match n.kind { NoticeKind::Warning => "warning", NoticeKind::Info => "info" },
|
||
"action": n.action,
|
||
}),
|
||
None => Json::Null,
|
||
}),
|
||
"ui.notice.dismiss" => {
|
||
self.notice = None;
|
||
Ok(Json::Null)
|
||
}
|
||
_ => return None,
|
||
};
|
||
Some(r)
|
||
}
|
||
|
||
/// Picks up requests commands made (dialogs, edit mode…).
|
||
pub fn after_engine(&mut self) {
|
||
for req in self.session.take_ui_requests() {
|
||
match req {
|
||
UiRequest::Dialog(name, params) => self.open_dialog(&name, params),
|
||
UiRequest::Message(m) => self.message = Some(("GridCraft".into(), m)),
|
||
UiRequest::EditCell(text) => {
|
||
let text = text.map(|text| {
|
||
self.session
|
||
.active()
|
||
.map(|d| formula_locale::display_template(&text, self.ui.language.formula_locale(), &d.wb, d.wb.active_sheet))
|
||
.unwrap_or(text)
|
||
});
|
||
self.begin_edit(text, false);
|
||
}
|
||
UiRequest::OpenUrl(u) => {
|
||
if let Some(f) = &self.services.open_url {
|
||
f(&u);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
pub fn open_dialog(&mut self, name: &str, params: Json) {
|
||
if let Err(e) = self.commit_text_box_edit() {
|
||
self.message = Some(("Text Box".into(), clean_error(&e)));
|
||
return;
|
||
}
|
||
match name {
|
||
"open" => {
|
||
if let Some(start) = &self.services.open_async {
|
||
start();
|
||
} else if let Some(pick) = &self.services.pick_open
|
||
&& let Some(path) = pick()
|
||
{
|
||
self.open_path(&path);
|
||
}
|
||
}
|
||
"saveAs" => {
|
||
let suggested = self.session.active().map(|d| d.display_title()).unwrap_or_else(|| "Book1".into());
|
||
if let Some(pick) = &self.services.pick_save {
|
||
if let Some(path) = pick(&suggested) {
|
||
let path = if std::path::Path::new(&path).extension().is_none() { format!("{path}.xlsx") } else { path };
|
||
self.run_or_alert("file.save", json!({"path": path}));
|
||
}
|
||
} else if self.services.download.is_some()
|
||
&& let Ok(r) = self.run("file.saveBytes", json!({"format": "xlsx"}))
|
||
&& let Some(dl) = &self.services.download
|
||
&& let Some(b) = r.get("base64").and_then(Json::as_str).and_then(gridcraft_engine::io::base64_decode)
|
||
{
|
||
let name = r.get("name").and_then(Json::as_str).unwrap_or("Book.xlsx").to_string();
|
||
dl(&name, &b);
|
||
}
|
||
}
|
||
// File › Options (`file.options`) and the File tab itself open the Backstage view.
|
||
"options" => self.open_backstage(backstage::Page::Options),
|
||
"backstage" => {
|
||
let page = params.get("page").and_then(Json::as_str).and_then(backstage::Page::parse).unwrap_or(backstage::Page::Home);
|
||
self.open_backstage(page);
|
||
}
|
||
other => self.dialog = dialogs::Dialog::open(self, other, params),
|
||
}
|
||
}
|
||
|
||
/// Opens the File tab's Backstage view on `page`, committing a cell still being edited first
|
||
/// (File actions work on the whole workbook). A draft that can't be committed keeps the sheet.
|
||
pub fn open_backstage(&mut self, page: backstage::Page) {
|
||
if !self.commit_edit(0, 0, false, false) {
|
||
return;
|
||
}
|
||
match &mut self.backstage {
|
||
Some(b) => b.page = page,
|
||
None => self.backstage = Some(backstage::Backstage::new(page)),
|
||
}
|
||
}
|
||
|
||
pub fn open_path(&mut self, path: &str) {
|
||
match self.run("file.open", json!({"path": path})) {
|
||
Ok(r) => self.after_open(Some(path), &r),
|
||
// A password-protected workbook: ask for the password; the dialog opens it.
|
||
Err(e) if e.contains(gridcraft_engine::io::PASSWORD_REQUIRED) => self.open_dialog("openPassword", json!({"path": path})),
|
||
Err(e) => self.message = Some(("GridCraft".into(), clean_error(&e))),
|
||
}
|
||
self.after_engine();
|
||
}
|
||
|
||
/// What follows a successful `file.open`: the workbook shows (not the Backstage), the Recent
|
||
/// list (for a file on disk) and the open warnings notice. Shared by File › Open, the
|
||
/// password dialog and the control channel.
|
||
pub fn after_open(&mut self, path: Option<&str>, r: &Json) {
|
||
self.backstage = None;
|
||
if let Some(path) = path {
|
||
self.ui.recent.retain(|p| p != path);
|
||
self.ui.recent.insert(0, path.to_string());
|
||
self.ui.recent.truncate(20);
|
||
}
|
||
self.show_open_warnings(r);
|
||
}
|
||
|
||
/// Turns the `warnings` a `file.open` result carries into an info-bar notice, choosing the
|
||
/// most important one (a preserved VBA project outranks, say, a skipped optional part).
|
||
/// Kept deliberately generic: any open-time warning can reach the user this way.
|
||
pub fn show_open_warnings(&mut self, r: &Json) {
|
||
let Some(list) = r.get("warnings").and_then(Json::as_array) else {
|
||
self.notice = None;
|
||
return;
|
||
};
|
||
let msgs: Vec<&str> = list.iter().filter_map(Json::as_str).collect();
|
||
if msgs.is_empty() {
|
||
// A clean open clears any notice left over from the previous document.
|
||
self.notice = None;
|
||
return;
|
||
}
|
||
let vba: Vec<&str> = msgs.iter().copied().filter(|m| m.to_lowercase().contains("macro") || m.contains("VBA")).collect();
|
||
if !vba.is_empty() {
|
||
self.notice = Some(Notice {
|
||
text: "This workbook contains VBA macros. GridCraft keeps them intact when you save, but does not run them.".into(),
|
||
kind: NoticeKind::Warning,
|
||
action: Some(("What this means".into(), "macros".into())),
|
||
});
|
||
} else {
|
||
let text = if msgs.len() == 1 {
|
||
msgs[0].to_string()
|
||
} else {
|
||
format!("{} items in this file were handled differently. First: {}", msgs.len(), msgs[0])
|
||
};
|
||
self.notice = Some(Notice { text, kind: NoticeKind::Info, action: None });
|
||
}
|
||
}
|
||
|
||
pub fn view_key(&self) -> Option<(u64, usize)> {
|
||
self.session.active().map(|d| (d.uid, d.wb.active_sheet))
|
||
}
|
||
|
||
pub fn view(&self) -> SheetView {
|
||
self.view_key().and_then(|k| self.views.get(&k).copied()).unwrap_or_else(|| self.saved_view())
|
||
}
|
||
|
||
pub fn view_mut(&mut self) -> Option<&mut SheetView> {
|
||
let k = self.view_key()?;
|
||
let saved = self.saved_view();
|
||
Some(self.views.entry(k).or_insert(saved))
|
||
}
|
||
|
||
/// The view a sheet opens with: scrolled to the file's saved first visible cell (`topLeftCell`).
|
||
fn saved_view(&self) -> SheetView {
|
||
let Some(sh) = self.session.active().and_then(|d| d.wb.active()) else { return SheetView::default() };
|
||
if sh.view != gridcraft_engine::model::SheetViewMode::Normal {
|
||
return SheetView::default();
|
||
}
|
||
let (fr, fc) = sh.freeze.unwrap_or((0, 0));
|
||
let top = sh.view_top_left;
|
||
let y = sh.row_top(top.row.max(fr)) - sh.row_top(fr);
|
||
let x = sh.col_left(top.col.max(fc)) - sh.col_left(fc);
|
||
SheetView { scroll: egui::vec2(x.max(0.0) as f32, y.max(0.0) as f32) }
|
||
}
|
||
|
||
/// Hands the active document's scroll positions (first visible cell per sheet) and active
|
||
/// cell to the engine so a save writes them (`sheetView topLeftCell`). Normal view only:
|
||
/// Page Layout view scrolls over pages, not cells.
|
||
pub fn store_view_state(&mut self) {
|
||
let Some(d) = self.session.active_mut() else { return };
|
||
let mut tops = Vec::new();
|
||
for (&(uid, si), v) in &self.views {
|
||
let Some(sh) = d.wb.sheets.get(si).filter(|sh| uid == d.uid && sh.view == gridcraft_engine::model::SheetViewMode::Normal) else {
|
||
continue;
|
||
};
|
||
let (fr, fc) = sh.freeze.unwrap_or((0, 0));
|
||
// A hair past the edge so a row that starts exactly at the scroll offset is the one found.
|
||
let row = sh.row_at(sh.row_top(fr) + v.scroll.y.max(0.0) as f64 + 0.01);
|
||
let col = sh.col_at(sh.col_left(fc) + v.scroll.x.max(0.0) as f64 + 0.01);
|
||
tops.push((si, CellRef::new(row, col)));
|
||
}
|
||
d.store_view(&tops);
|
||
}
|
||
|
||
/// Starts editing the active cell. `text` replaces the content (typing) or pre-fills it.
|
||
pub fn begin_edit(&mut self, text: Option<String>, from_formula_bar: bool) {
|
||
if let Err(e) = self.commit_text_box_edit() {
|
||
self.message = Some(("Text Box".into(), clean_error(&e)));
|
||
return;
|
||
}
|
||
let Some(d) = self.session.active() else { return };
|
||
let Some(sh) = d.wb.active() else { return };
|
||
let at = sh.merge_at(d.selection.active).map(|m| m.start).unwrap_or(d.selection.active);
|
||
// A picture has no editable scalar text. Selecting it, F2, or clicking
|
||
// the formula bar must not turn its fallback value into a text edit.
|
||
// Typing still starts an intentional replacement.
|
||
if text.is_none() && sh.cell_pictures.contains_key(&at) {
|
||
return;
|
||
}
|
||
let locale = self.ui.language.formula_locale();
|
||
let current = if sh.cell_pictures.contains_key(&at) {
|
||
String::new()
|
||
} else {
|
||
match sh.cell(at) {
|
||
Some(c) if c.formula.is_some() => formula_locale::display(&c.input_text(), locale, &d.wb, d.wb.active_sheet),
|
||
_ => gridcraft_engine::display::edit_text(&d.wb, sh, at),
|
||
}
|
||
};
|
||
let (text, replace) = match text {
|
||
Some(t) => (t, true),
|
||
None => (current.clone(), false),
|
||
};
|
||
let mut ed = editor::EditState::new(d.wb.active_sheet, at, text, replace, from_formula_bar);
|
||
ed.locale = locale;
|
||
if replace && !ed.is_formula() {
|
||
ed.completion = editor::column_completion(sh, at, &ed.text);
|
||
}
|
||
self.editor = Some(ed);
|
||
self.session.mode = if replace { gridcraft_engine::Mode::Enter } else { gridcraft_engine::Mode::Edit };
|
||
}
|
||
|
||
/// Commits the edit; `then` moves the selection (dr, dc) afterwards.
|
||
pub fn commit_edit(&mut self, dr: i64, dc: i64, array: bool, fill_selection: bool) -> bool {
|
||
let Some(ed) = self.editor.take() else { return true };
|
||
self.session.mode = gridcraft_engine::Mode::Ready;
|
||
let text = match &ed.completion {
|
||
Some(full) if full.to_lowercase().starts_with(&ed.text.to_lowercase()) => full.clone(),
|
||
_ => ed.text.clone(),
|
||
};
|
||
let text = match self.session.active().map(|d| formula_locale::canonical(&text, ed.locale, &d.wb, ed.sheet, ed.cell)) {
|
||
Some(Ok(text)) => text,
|
||
Some(Err(error)) => {
|
||
self.message = Some(("GridCraft".into(), error.to_string()));
|
||
self.editor = Some(ed);
|
||
self.session.mode = gridcraft_engine::Mode::Edit;
|
||
return false;
|
||
}
|
||
None => text,
|
||
};
|
||
// Data validation.
|
||
if let Some(d) = self.session.active()
|
||
&& let Some((dv, msg)) = gridcraft_engine::cmd::data::check_validation(&d.wb, ed.sheet, ed.cell, &text)
|
||
{
|
||
if dv.error_style == gridcraft_engine::model::ErrorStyle::Stop {
|
||
let title = if dv.error_title.is_empty() { "GridCraft".to_string() } else { dv.error_title.clone() };
|
||
self.message = Some((title, msg));
|
||
self.editor = Some(ed);
|
||
self.session.mode = gridcraft_engine::Mode::Edit;
|
||
return false;
|
||
}
|
||
self.toast = Some((msg, now_ms()));
|
||
}
|
||
let r = if fill_selection {
|
||
self.session.run("range.fill", json!({"input": text}))
|
||
} else {
|
||
self.session.run("cell.set", json!({"cell": ed.cell.a1(), "input": text, "array": array}))
|
||
};
|
||
match r {
|
||
Ok(_) => {
|
||
if dr != 0 || dc != 0 {
|
||
self.move_after_enter(dr, dc);
|
||
}
|
||
self.after_engine();
|
||
true
|
||
}
|
||
Err(e) => {
|
||
self.message = Some(("GridCraft".into(), clean_error(&e)));
|
||
self.editor = Some(ed);
|
||
self.session.mode = gridcraft_engine::Mode::Edit;
|
||
false
|
||
}
|
||
}
|
||
}
|
||
|
||
pub fn cancel_edit(&mut self) {
|
||
self.editor = None;
|
||
self.session.mode = gridcraft_engine::Mode::Ready;
|
||
}
|
||
|
||
/// Enter/Tab move inside a multi-cell selection, otherwise to the next cell.
|
||
pub fn move_after_enter(&mut self, dr: i64, dc: i64) {
|
||
let multi = self.session.active().is_some_and(|d| !d.selection.is_single_cell());
|
||
if multi {
|
||
let fwd = dr > 0 || dc > 0;
|
||
let _ = self.session.run("selection.next", json!({"forward": fwd, "byRow": dc != 0}));
|
||
} else {
|
||
let _ = self.session.run("selection.move", json!({"dr": dr, "dc": dc}));
|
||
}
|
||
self.grid.ensure_visible = true;
|
||
}
|
||
|
||
/// Per-frame logic (control channel, screenshots). Call before `ui`.
|
||
pub fn logic(&mut self, ctx: &egui::Context) {
|
||
// A keytip control request lives one frame: the ribbon consumes it during `ui`, and
|
||
// anything that never reached a control (collapsed ribbon, tab switch) is dropped.
|
||
self.ui.keytip_invoke = None;
|
||
if self.fonts_han.is_some_and(|han| han != theme::HanOrder::of(self.ui.language)) {
|
||
self.fonts_ready = false;
|
||
self.fonts_set = false;
|
||
}
|
||
if !self.fonts_ready {
|
||
// New fonts apply from the next frame on: paint nothing until then.
|
||
if self.fonts_set {
|
||
self.fonts_ready = true;
|
||
} else {
|
||
SheetApp::setup_context_for_language(ctx, self.ui.dark, self.ui.language);
|
||
self.fonts_set = true;
|
||
// The rebuilt set has no workbook fonts yet: bind them again once it is active.
|
||
self.fonts_bound.clear();
|
||
self.fonts_seen = (0, 0);
|
||
self.fonts_han = Some(theme::HanOrder::of(self.ui.language));
|
||
ctx.request_repaint();
|
||
}
|
||
} else if self.fonts_rebind {
|
||
// A font-set change made last frame is active now.
|
||
self.fonts_rebind = false;
|
||
} else if fonts::scan_done() {
|
||
// Bind the workbook's installed fonts once the background scan has finished. Adding
|
||
// fonts re-applies the font set, which egui activates at the next pass, so skip this
|
||
// frame's paint (same one-frame cost as startup). Only when the workbook changed.
|
||
let seen = self.session.active().map(|d| (d.uid, d.revision)).unwrap_or((0, 0));
|
||
if seen != self.fonts_seen {
|
||
let used = self.used_fonts();
|
||
self.fonts_seen = seen;
|
||
if used != self.fonts_bound {
|
||
ctx.set_fonts(theme::font_definitions_with(self.ui.language, &used));
|
||
self.fonts_bound = used;
|
||
self.fonts_rebind = true;
|
||
ctx.request_repaint();
|
||
}
|
||
}
|
||
}
|
||
if let Some(answer) = self.desktop_theme.take_change() {
|
||
self.desktop_dark = Some(answer);
|
||
}
|
||
let preference = match (
|
||
self.ui.system_theme,
|
||
ctx.system_theme().or_else(|| self.desktop_dark.map(|d| if d { egui::Theme::Dark } else { egui::Theme::Light })),
|
||
) {
|
||
(true, Some(egui::Theme::Dark)) => egui::ThemePreference::Dark,
|
||
(true, Some(egui::Theme::Light)) => egui::ThemePreference::Light,
|
||
_ => self.ui.theme_preference(),
|
||
};
|
||
if ctx.options(|o| o.theme_preference) != preference {
|
||
ctx.set_theme(preference);
|
||
ctx.request_repaint();
|
||
}
|
||
self.effective_dark = ctx.theme() == egui::Theme::Dark;
|
||
// Interface size: the preference owns the zoom factor, so egui's own Ctrl/Cmd +/-/0 zoom
|
||
// keys stay off (they would also swallow GridCraft's shortcuts).
|
||
if ctx.options(|o| o.zoom_with_keyboard) {
|
||
ctx.options_mut(|o| o.zoom_with_keyboard = false);
|
||
}
|
||
let zoom = self.ui.zoom_factor();
|
||
if (ctx.zoom_factor() - zoom).abs() > 0.001 {
|
||
ctx.set_zoom_factor(zoom);
|
||
ctx.request_repaint();
|
||
}
|
||
control::poll(self, ctx);
|
||
// Files read asynchronously (web file picker, dropped files).
|
||
let arrived: Vec<(String, Vec<u8>)> = self
|
||
.services
|
||
.inbox
|
||
.as_ref()
|
||
.map(|i| std::mem::take(&mut *i.lock().unwrap_or_else(std::sync::PoisonError::into_inner)))
|
||
.unwrap_or_default();
|
||
for (name, bytes) in arrived {
|
||
let b64 = gridcraft_engine::io::base64_encode(&bytes);
|
||
match self.run("file.open", json!({"name": &name, "base64": &b64})) {
|
||
// A file picked from File › Open (web) arrives here: show it, not the Backstage.
|
||
Ok(r) => {
|
||
self.backstage = None;
|
||
self.show_open_warnings(&r);
|
||
}
|
||
Err(e) if e.contains(gridcraft_engine::io::PASSWORD_REQUIRED) => {
|
||
self.backstage = None;
|
||
self.open_dialog("openPassword", json!({"name": name, "base64": b64}));
|
||
}
|
||
Err(e) => self.message = Some(("GridCraft".into(), clean_error(&e))),
|
||
}
|
||
self.after_engine();
|
||
}
|
||
if self.session.active().is_none() {
|
||
self.session.new_workbook();
|
||
}
|
||
}
|
||
|
||
pub fn raw_input_hook(&mut self, raw: &mut egui::RawInput) {
|
||
if let Some(mut batch) = self.synthetic.pop_front() {
|
||
raw.events.append(&mut batch);
|
||
}
|
||
}
|
||
|
||
/// Lays out the whole window.
|
||
pub fn ui(&mut self, ui: &mut egui::Ui) {
|
||
let ctx = ui.ctx().clone();
|
||
if !self.fonts_ready || self.fonts_rebind {
|
||
ctx.request_repaint();
|
||
return;
|
||
}
|
||
let t0 = now_ms();
|
||
text_box::before_ui(self, &ctx);
|
||
// The language the widgets translate with this frame (see `i18n::current`).
|
||
i18n::set_current(&ctx, self.ui.language);
|
||
let t = theme::Tokens::get(&ctx);
|
||
if self.backstage.is_none() {
|
||
self.ribbon_keys(&ctx);
|
||
}
|
||
ribbon::title_bar(self, ui);
|
||
if self.backstage.is_some() {
|
||
// File tab: the Backstage view replaces everything below the title bar.
|
||
backstage::show(self, ui);
|
||
} else {
|
||
ribbon::show(self, ui);
|
||
widgets::notice_bar(self, ui);
|
||
if self.session.prefs.formula_bar {
|
||
formula_bar::show(self, ui);
|
||
}
|
||
if self.ui.status_bar {
|
||
tabs::status_bar(self, ui);
|
||
}
|
||
tabs::sheet_tabs(self, ui);
|
||
pivot_pane::show(self, ui);
|
||
panes::show(self, ui);
|
||
egui::CentralPanel::default().frame(egui::Frame::NONE.fill(t.grid_bg)).show(ui, |ui| {
|
||
let g0 = now_ms();
|
||
grid::show(self, ui);
|
||
self.perf.grid_ms = now_ms() - g0;
|
||
});
|
||
}
|
||
dialogs::show(self, &ctx);
|
||
widgets::message_box(self, &ctx);
|
||
widgets::toast(self, &ctx);
|
||
control::issue_screenshots(self, &ctx);
|
||
control::collect_screenshots(self, &ctx);
|
||
if !ctx.input(|i| i.focused) {
|
||
// Nothing else to do.
|
||
}
|
||
let now = now_ms();
|
||
self.perf.frame_ms = now - t0;
|
||
if self.perf.last > 0.0 {
|
||
let dt = now - self.perf.last;
|
||
if dt > 0.0 {
|
||
self.perf.fps = self.perf.fps * 0.9 + 100.0 / dt;
|
||
}
|
||
}
|
||
self.perf.last = now;
|
||
// AutoSave: save workbooks that have a file shortly after they change.
|
||
if self.grid.autosave
|
||
&& self.editor.is_none()
|
||
&& self.text_box_editor.is_none()
|
||
&& now - self.grid.last_autosave > 2000.0
|
||
&& self.session.active().is_some_and(|d| d.is_dirty() && d.path.as_deref().is_some_and(|p| p.ends_with(".xlsx")))
|
||
{
|
||
self.grid.last_autosave = now;
|
||
self.store_view_state();
|
||
let _ = self.session.run("file.save", json!({}));
|
||
}
|
||
// Window title.
|
||
if let Some(d) = self.session.active() {
|
||
let title = format!("{}{}", d.display_title(), if d.is_dirty() { " — Edited" } else { "" });
|
||
if self.grid.last_title.as_deref() != Some(title.as_str()) {
|
||
ctx.send_viewport_cmd(egui::ViewportCommand::Title(title.clone()));
|
||
self.grid.last_title = Some(title);
|
||
}
|
||
}
|
||
}
|
||
|
||
fn ribbon_keys(&mut self, ctx: &egui::Context) {
|
||
let before = self.keytips.prefix().map(str::to_string);
|
||
let enabled = self.editor.is_none()
|
||
&& self.dialog.is_none()
|
||
&& self.message.is_none()
|
||
&& self.name_box.is_none()
|
||
&& !ctx.text_edit_focused()
|
||
&& self.grid.context_menu.is_none()
|
||
&& self.grid.header_menu.is_none()
|
||
&& self.grid.filter_menu.is_none()
|
||
&& self.grid.list_picker.is_none()
|
||
&& self.grid.renaming_tab.is_none()
|
||
&& (before.is_some() || !egui::Popup::is_any_open(ctx));
|
||
let actions = self.keytips.process(ctx, enabled);
|
||
if before.is_some() && before.as_deref() != self.keytips.prefix() && !ctx.input(|i| i.pointer.any_pressed()) {
|
||
// Leave pointer cancellation to the popup so clicking a menu item still works.
|
||
egui::Popup::close_all(ctx);
|
||
}
|
||
for action in actions {
|
||
match action {
|
||
keytips::Action::Tab(tab) => {
|
||
self.run_or_alert("ui.ribbonTab", json!({"tab": tab}));
|
||
self.ui.ribbon_collapsed = false;
|
||
}
|
||
keytips::Action::Command(id) => self.run_or_alert(id, json!({})),
|
||
keytips::Action::Dialog(name) => self.open_dialog(name, json!({})),
|
||
// Reserved sequence: fires silently while its command is missing. When the
|
||
// command lands in the catalog it goes live with no table edit.
|
||
keytips::Action::Pending(id) => {
|
||
if gridcraft_engine::find_command(id).is_some() {
|
||
self.run_or_alert(id, json!({}));
|
||
}
|
||
}
|
||
// Route to the ribbon control that owns this keytip: it opens its dropdown or
|
||
// runs its click handler with the right params (the engine command alone would
|
||
// be missing them).
|
||
keytips::Action::Control(id) => self.ui.keytip_invoke = Some(id),
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Current selection's top-left cell text as A1 (for the Name Box).
|
||
pub fn name_box_text(&self) -> String {
|
||
let Some(d) = self.session.active() else { return String::new() };
|
||
let sel = &d.selection;
|
||
let cur = sel.current();
|
||
// A named range that exactly matches shows its name.
|
||
let sh = d.wb.active();
|
||
if let Some(sh) = sh {
|
||
for n in &d.wb.names {
|
||
let target = n.formula.replace('$', "");
|
||
let (sheet, body) = match target.rsplit_once('!') {
|
||
Some((s, b)) => (Some(s.trim_matches('\'').to_string()), b.to_string()),
|
||
None => (None, target.clone()),
|
||
};
|
||
if sheet.as_deref().is_none_or(|s| s.eq_ignore_ascii_case(&sh.name)) && RangeRef::parse(&body) == Some(cur) {
|
||
return n.name.clone();
|
||
}
|
||
}
|
||
}
|
||
if self.grid.drag_select && !cur.is_single() {
|
||
return format!("{}R x {}C", cur.height(), cur.width());
|
||
}
|
||
sel.active.a1()
|
||
}
|
||
}
|
||
|
||
/// Strips the engine's error prefixes for message boxes.
|
||
pub fn clean_error(e: &str) -> String {
|
||
if let Some(rest) = e.split_once("`: ").map(|(_, r)| r) {
|
||
return rest.to_string();
|
||
}
|
||
e.to_string()
|
||
}
|
||
|
||
#[allow(clippy::disallowed_methods)] // the clock is read only off wasm
|
||
pub fn now_ms() -> f64 {
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
{
|
||
use std::time::{SystemTime, UNIX_EPOCH};
|
||
SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs_f64() * 1000.0).unwrap_or(0.0)
|
||
}
|
||
#[cfg(target_arch = "wasm32")]
|
||
{
|
||
0.0
|
||
}
|
||
}
|
||
|
||
pub fn cell(r: u32, c: u32) -> CellRef {
|
||
CellRef::new(r, c)
|
||
}
|