Files
gridcraft/crates/ui-egui/src/lib.rs
T
2026-11-03 09:55:39 -05:00

336 lines
14 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)]
/// GridCraft as craft-update knows it, with its 128 px icon and accent colour: the update
/// prompt shows both, and an AppImage's menu entry gets the icon.
#[cfg(not(target_arch = "wasm32"))]
pub const UPDATE_APP: craft_update::app::App = craft_update::app::App {
icon_png: Some(include_bytes!("../../../assets/app-icon/hicolor/128x128/apps/ai.storyteller.gridcraft.png")),
accent: Some([0x1f, 0x9d, 0x55]),
..craft_update::suite::GRIDCRAFT
};
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;
pub mod dnd;
#[cfg(not(target_arch = "wasm32"))]
pub mod doc_windows;
pub mod editor;
pub mod equation;
mod floating_pictures;
pub mod fonts;
pub mod formula_bar;
mod formula_locale;
pub mod function_library;
pub mod grid;
pub mod i18n;
pub mod icons;
pub mod keymap;
pub mod keytips;
pub mod macro_editor;
pub mod macro_ui;
pub mod objects;
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};
mod app;
mod clock;
mod services;
mod state;
pub use clock::{monotonic_ms, now_ms};
pub use services::{Chrome, Inbox, Services};
pub use state::{NoticeKind, OpenWindow, SaveAnswer, SheetView};
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,
/// Lines the expanded formula bar shows: the height its bottom edge was last dragged to.
/// Expanding it again (the chevron, Ctrl+Shift+U) returns to that height.
pub formula_bar_lines: usize,
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,
/// Tint the active cell's whole row (File ▸ Options ▸ General), on every sheet and workbook, so
/// the eye keeps its place across a wide table. A preference: nothing goes into the file.
pub highlight_active_row: 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>,
/// A keytip control request for the next frame: its tab was switched to this frame, so its
/// control is drawn only then.
#[serde(skip)]
pub keytip_invoke_next: 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,
formula_bar_lines: formula_bar::MAX_LINES,
status_bar: true,
animations: true,
autocomplete: true,
highlight_active_row: false,
autosave: false,
recent: vec![],
keytip_invoke: None,
keytip_invoke_next: 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"
}
}
}
/// 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)>,
}
#[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,
/// 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,
/// The floating object selected on the sheet (chart, picture, shape, equation or slicer).
pub selected_object: Option<gridcraft_engine::model::ObjRef>,
/// 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)>,
/// Automate › Macros: relative recording, recent macros, a macro's question.
pub macros: macro_ui::MacroUi,
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,
/// A quit question is waiting in the active window: [`doc_windows`] brings its native window
/// to the front, also when the app is hidden or the window minimized (a Dock quit).
pub bring_forward: 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,
/// View › Arrange All's layout and windows, waiting for the native windows to move.
pub arrange_windows: Option<(String, Vec<u64>)>,
/// The update check, the startup prompt and a running self-update (desktop only).
#[cfg(not(target_arch = "wasm32"))]
updates_rt: updates_ui::Runtime,
}
/// 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()
}
pub fn cell(r: u32, c: u32) -> CellRef {
CellRef::new(r, c)
}