Merge remote-tracking branch 'origin/main' into pr

This commit is contained in:
FredApps committed 2026-11-05 14:49:58 +01:00
commit 0a58f94517
19 files changed
+791 -188

No files matched your search

Generated
+10
View File
@@ -995,6 +995,15 @@ dependencies = [
"zip 9.1.0",
]
[[package]]
name = "craft-web-host"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=2e2312ddaf21bc3d85cd3395d0955c7d239306f6#2e2312ddaf21bc3d85cd3395d0955c7d239306f6"
dependencies = [
"wasm-bindgen",
"web-sys",
]
[[package]]
name = "craft-window"
version = "0.1.0"
@@ -2186,6 +2195,7 @@ dependencies = [
name = "gridcraft-web"
version = "0.16.0"
dependencies = [
"craft-web-host",
"eframe",
"egui",
"gridcraft-engine",
+3 -1
View File
@@ -16,6 +16,8 @@ path = "src/main.rs"
[dependencies]
gridcraft-engine = { workspace = true }
gridcraft-ui-egui = { workspace = true }
# The host messages (storytold/craft-libs): one contract and one bridge for the web builds.
craft-web-host = { git = "https://github.com/storytold/craft-libs", rev = "2e2312ddaf21bc3d85cd3395d0955c7d239306f6", version = "0.1.0" }
egui = { workspace = true }
log = { workspace = true }
serde_json = { workspace = true }
@@ -26,4 +28,4 @@ rfd = { workspace = true }
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
js-sys = "0.3"
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "HtmlIFrameElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Clipboard", "ClipboardItem", "Navigator", "MessageEvent", "UrlSearchParams", "AddEventListenerOptions", "Storage"] }
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "Event", "EventTarget", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "HtmlIFrameElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Clipboard", "ClipboardItem", "Navigator", "MessageEvent", "UrlSearchParams", "AddEventListenerOptions", "Storage", "CustomEvent", "CustomEventInit"] }
+68 -91
View File
@@ -1,57 +1,64 @@
//! The `postMessage` bridge to a same-origin parent page (`?host=parent`), for pages that embed
//! GridCraft in an `<iframe>` (such as a Nextcloud app). Messages are plain objects with a `type`:
//! Host modes: a page that runs GridCraft (as an iframe, or in its own document) opens workbooks
//! in it and stores what it saves instead of the browser downloading. The messages and the two
//! transports are `craft-web-host`'s (storytold/craft-libs), the same contract as the other Craft
//! apps' bridges, with the `gridcraft:` prefix. This module is GridCraft's part of it.
//!
//! - `gridcraft:ready` (to the parent): `{ version }`, the app is listening; send a file now.
//! - `gridcraft:open` (from the parent): `{ name, bytes }`, bytes an `ArrayBuffer` or
//! `Uint8Array`. It opens like a workbook picked with File › Open or dropped on the window.
//! - `gridcraft:save` (to the parent): `{ name, bytes }` (a transferred `ArrayBuffer`) for every
//! file GridCraft would otherwise download: Save and Save As (an .xlsx workbook) and Export
//! as PDF. The parent stores it and reports failures itself.
//! - `gridcraft:dirty` (to the parent): `{ dirty }` whenever unsaved changes appear or go away.
//! - `gridcraft:failed` (to the parent): `{ error }` when the app couldn't start.
//! - `?host=parent` (an `<iframe>`, such as a Nextcloud app) talks to the same-origin parent over
//! `postMessage`; `data-host="page"` on the canvas (the page's own document, no iframe) uses
//! DOM `CustomEvent`s on the canvas instead. `?host=parent` wins when both are present. With no
//! parent, or a parent on another origin, there is no bridge: the normal app with downloads.
//! - `gridcraft:ready` once the app is listening; send a file now. GridCraft reads bytes, so it
//! says `file: false` and a host sends `bytes`.
//! - `gridcraft:open` (from the host): `{ name, bytes }`. It opens like a workbook picked with
//! File › Open or dropped on the window; a file over [`MAX_OPEN_BYTES`] is refused before it is
//! copied.
//! - `gridcraft:save` (to the host): `{ name, saveAs, bytes }` for every file GridCraft would
//! otherwise download: Save and Save As (an .xlsx workbook) and Export as PDF. The host stores
//! it and reports failures itself. GridCraft doesn't tell the host which saves make a file the
//! document, so `saveAs` is always `false`.
//! - `gridcraft:dirty` whenever unsaved changes appear or go away, `gridcraft:failed` when the
//! app couldn't start.
//!
//! Only the parent window on the page's own origin is heard or answered. In host mode the page
//! starts with an empty workbook instead of a sample, and the parent guards leaving the page.
//! In both host modes the page starts with an empty workbook instead of a sample, saves and PDF
//! exports are handed to the page rather than downloaded, and `?author=` names the user for new
//! notes and comments.
use std::cell::{Cell, RefCell};
use std::cell::RefCell;
use std::rc::Rc;
use craft_web_host::web::{FileData, Host, Message};
use gridcraft_ui_egui::{Inbox, Services, SheetApp};
use wasm_bindgen::JsCast as _;
use wasm_bindgen::JsValue;
use wasm_bindgen::closure::Closure;
/// Saving one of these hands the whole workbook to the parent, which stores it: the workbook
/// counts as saved under that name, as it would after Save on the desktop.
use crate::hostmode::open_name;
/// The app id in the host messages (`gridcraft:ready`).
const APP: &str = "gridcraft";
/// Saved files that count as a workbook: the document is then clean under the name it was saved as.
const WORKBOOK_EXTS: &[&str] = &["xlsx"];
/// The largest workbook taken from the host page, refused before it is copied.
const MAX_OPEN_BYTES: u64 = 512 << 20;
/// The longest user name taken from `?author=`.
const MAX_AUTHOR_CHARS: usize = 200;
#[derive(Clone)]
pub struct Bridge {
window: web_sys::Window,
parent: web_sys::Window,
origin: String,
/// Names of the files handed to the parent since the last frame.
host: Rc<Host>,
/// Names of the files handed to the host since the last frame.
saved: Rc<RefCell<Vec<String>>>,
/// The unsaved-changes state last reported to the parent.
reported: Rc<Cell<Option<bool>>>,
}
impl Bridge {
/// `None` when the page isn't framed (there is no parent to talk to).
pub fn new() -> Option<Self> {
let window = web_sys::window()?;
let parent = window.parent().ok().flatten()?;
if js_sys::Object::is(&parent, &window) {
return None;
}
let origin = window.location().origin().ok()?;
Some(Self { window, parent, origin, saved: Rc::default(), reported: Rc::default() })
/// The bridge for this page, or `None` when GridCraft isn't hosted: no `?host=parent` and no
/// `data-host="page"` on the canvas, an unframed `?host=parent` page (it stays the normal app
/// with downloads), or a parent on another origin.
pub fn detect(canvas: &web_sys::HtmlCanvasElement) -> Option<Self> {
Some(Self { host: Rc::new(Host::connect(APP, canvas)?), saved: Rc::default() })
}
/// Send saved and exported files to the parent instead of downloading them, and hear files
/// Send saved and exported files to the host instead of downloading them, and hear files
/// from it.
pub fn connect(&self, services: &mut Services, inbox: Inbox, ctx: egui::Context) {
self.listen(inbox, ctx);
@@ -72,15 +79,16 @@ impl Bridge {
}
pub fn post_ready(&self) {
self.send(&[("type", "gridcraft:ready".into()), ("version", env!("CARGO_PKG_VERSION").into())]);
let _ = self.host.post_ready(env!("CARGO_PKG_VERSION"), false);
}
pub fn post_failed(&self, error: &str) {
self.send(&[("type", "gridcraft:failed".into()), ("error", error.into())]);
let _ = self.host.post_failed(error, None);
}
/// After each frame: a workbook handed to the parent this frame counts as saved under the
/// name it was saved as, and the parent hears when unsaved changes appear or go away.
/// After each frame: a workbook handed to the host this frame counts as saved under the
/// name it was saved as, and the host hears when unsaved changes appear or go away (the
/// crate does not repeat a state it already reported).
pub fn sync(&self, app: &mut SheetApp) {
let saved = std::mem::take(&mut *self.saved.borrow_mut());
if let Some(name) = saved.iter().rev().find(|name| is_workbook(name))
@@ -91,76 +99,45 @@ impl Bridge {
doc.title = name.clone();
}
}
let dirty = app.session.documents().iter().any(|d| d.is_dirty());
if self.reported.get() != Some(dirty) && self.send(&[("type", "gridcraft:dirty".into()), ("dirty", dirty.into())]) {
self.reported.set(Some(dirty));
}
self.host.post_dirty(app.session.documents().iter().any(|d| d.is_dirty()));
}
fn post_save(&self, path: &str, bytes: &[u8]) -> Result<(), String> {
let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string());
let buffer = js_sys::Uint8Array::from(bytes).buffer();
let message = message(&[("type", "gridcraft:save".into()), ("name", name.as_str().into()), ("bytes", buffer.clone().into())])
.ok_or_else(|| "couldn't build the message".to_string())?;
self.parent
.post_message_with_transfer(&message, &self.origin, &js_sys::Array::of1(&buffer))
.map_err(|e| e.as_string().unwrap_or_else(|| format!("{e:?}")))?;
let name = file_name(path);
self.host.post_save(&name, bytes, None, false)?;
self.saved.borrow_mut().push(name);
Ok(())
}
fn listen(&self, inbox: Inbox, ctx: egui::Context) {
let parent = self.parent.clone();
let origin = self.origin.clone();
let on_message = Closure::<dyn FnMut(web_sys::MessageEvent)>::new(move |event: web_sys::MessageEvent| {
if event.origin() != origin || !event.source().is_some_and(|s| js_sys::Object::is(&s, &parent)) {
return;
self.host.listen(move |message| {
if let Message::Open { name, data } = message {
deliver_open(&inbox, &ctx, &name, &data);
}
let data = event.data();
if string(&data, "type").as_deref() != Some("gridcraft:open") {
return;
}
let name = string(&data, "name").filter(|n| !n.trim().is_empty()).unwrap_or_else(|| "Book.xlsx".to_string());
let Some(bytes) = bytes(&data) else { return };
inbox.lock().unwrap_or_else(|e| e.into_inner()).push((name, bytes));
ctx.request_repaint();
});
if self.window.add_event_listener_with_callback("message", on_message.as_ref().unchecked_ref()).is_ok() {
// The listener lives as long as the page.
on_message.forget();
}
}
}
/// Post a message to the parent; false if it couldn't be sent.
fn send(&self, fields: &[(&str, JsValue)]) -> bool {
message(fields).is_some_and(|m| self.parent.post_message(&m, &self.origin).is_ok())
/// The file an open message carries, pushed onto the inbox for the next frame, like a dropped
/// file. A host's file is untrusted input: an oversized one is refused before it is copied into
/// the app's memory (the data's own length, not the host's claim).
fn deliver_open(inbox: &Inbox, ctx: &egui::Context, name: &str, data: &FileData) {
let FileData::Bytes(array) = data else {
log::warn!("host file {name} came as a File; GridCraft reads bytes only; not opened");
return;
};
if data.size() > MAX_OPEN_BYTES as f64 {
log::warn!("host file is {} bytes, over the {} MiB host open limit; not opened", data.size(), MAX_OPEN_BYTES >> 20);
return;
}
inbox.lock().unwrap_or_else(|e| e.into_inner()).push((open_name(name), array.to_vec()));
ctx.request_repaint();
}
fn is_workbook(name: &str) -> bool {
name.rsplit_once('.').is_some_and(|(_, ext)| WORKBOOK_EXTS.iter().any(|e| ext.eq_ignore_ascii_case(e)))
}
fn message(fields: &[(&str, JsValue)]) -> Option<js_sys::Object> {
let m = js_sys::Object::new();
for (key, value) in fields {
js_sys::Reflect::set(&m, &JsValue::from_str(key), value).ok()?;
}
Some(m)
}
fn string(data: &JsValue, key: &str) -> Option<String> {
js_sys::Reflect::get(data, &JsValue::from_str(key)).ok()?.as_string()
}
/// `bytes` as an `ArrayBuffer` or `Uint8Array`.
fn bytes(data: &JsValue) -> Option<Vec<u8>> {
let bytes = js_sys::Reflect::get(data, &JsValue::from_str("bytes")).ok()?;
if let Some(array) = bytes.dyn_ref::<js_sys::Uint8Array>() {
Some(array.to_vec())
} else if bytes.is_instance_of::<js_sys::ArrayBuffer>() {
Some(js_sys::Uint8Array::new(&bytes).to_vec())
} else {
None
}
fn file_name(path: &str) -> String {
std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string())
}
+28
View File
@@ -0,0 +1,28 @@
//! Pure host-mode rules, shared by the bridge ([`crate::host`]) and testable without a browser
//! (like [`crate::prefs`]): the name the bridge fills in when a host's open request has none.
//! Which pages host the app is `craft-web-host`'s.
/// The file name used when an open request carries no usable one.
pub const DEFAULT_OPEN_NAME: &str = "Book.xlsx";
/// The name `craft-web-host` gives an open request that carries no usable one (it cleans a host's
/// name and falls back to this).
const NAMELESS: &str = "document";
/// A usable file name for an open request: the cleaned name the crate hands over, or
/// [`DEFAULT_OPEN_NAME`] when the host sent none.
pub fn open_name(name: &str) -> String {
if name == NAMELESS { DEFAULT_OPEN_NAME.to_string() } else { name.to_string() }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn open_names_fall_back_to_the_default_when_the_host_sent_none() {
assert_eq!(open_name("Budget.xlsx"), "Budget.xlsx");
assert_eq!(open_name("Sheet2!Book.xlsx"), "Sheet2!Book.xlsx");
assert_eq!(open_name("document"), DEFAULT_OPEN_NAME, "what the crate says for a missing or blank name");
}
}
+8 -4
View File
@@ -10,15 +10,19 @@
//!
//! URL query flags: `?webgl` forces WebGL2; `?sample=sales|budget|grades` opens a sample;
//! `?host=parent` (inside an `<iframe>`) talks to a same-origin parent page instead: it sends the
//! workbook to open and stores what GridCraft saves (`host`), and `&author=` names the user for
//! notes and comments. `?chrome=none|notitlebar|nofiletab` leaves the title bar and/or the File tab
//! undrawn for a host that embeds the sheet in its own window (see `Chrome`). Preferences (theme,
//! interface size, editing options…) are kept in `localStorage` (`prefs`).
//! workbook to open and stores what GridCraft saves (`host`). A page can host GridCraft in its
//! own document (no iframe) with `data-host="page"` on the canvas, talking over DOM `CustomEvent`s
//! on the canvas. `&author=` names the user for notes and comments in either host mode.
//! `?chrome=none|notitlebar|nofiletab` leaves the title bar and/or the File tab undrawn for a host
//! that embeds the sheet in its own window (see `Chrome`). Preferences (theme, interface size,
//! editing options…) are kept in `localStorage` (`prefs`).
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#[cfg(target_arch = "wasm32")]
mod host;
#[cfg(any(target_arch = "wasm32", test))]
mod hostmode;
#[cfg(any(target_arch = "wasm32", test))]
mod prefs;
#[cfg(target_arch = "wasm32")]
mod web;
+7 -4
View File
@@ -37,9 +37,12 @@ pub fn start() {
{
create.instance_descriptor.backends = eframe::wgpu::Backends::GL;
}
// `?host=parent`: a same-origin page framing GridCraft (such as a Nextcloud app) opens
// workbooks in it and stores what it saves (`host`).
let bridge = if query_param("host").as_deref() == Some("parent") { crate::host::Bridge::new() } else { None };
// Host modes (`craft-web-host`): `?host=parent`, a same-origin page framing GridCraft (such
// as a Nextcloud app), opens workbooks in it and stores what it saves (`host`). Unframed
// there is no parent, so that URL on its own stays the normal app with downloads.
// `data-host="page"` on the canvas opts a page in instead: it runs GridCraft in its own
// document and talks over DOM `CustomEvent`s on the canvas.
let bridge = crate::host::Bridge::detect(&canvas);
let app_bridge = bridge.clone();
let result = eframe::WebRunner::new()
.start(
@@ -48,7 +51,7 @@ pub fn start() {
Box::new(move |cc| {
let inbox: Inbox = Inbox::default();
let mut session = Session::new();
// In host mode the parent sends the workbook to edit; no sample meanwhile.
// In host mode the host sends the workbook to edit; no sample meanwhile.
let q = if app_bridge.is_some() { String::new() } else { query() };
if let Some(name) = q.split(['?', '&']).find_map(|kv| kv.strip_prefix("sample=")) {
let _ = session.execute("file.new", serde_json::json!({"sample": name}));
+30
View File
@@ -296,10 +296,40 @@ pub fn split_sheet(reference: &str) -> (Option<String>, &str) {
}
}
/// The areas of a reference list, split at the commas between them. A comma inside a quoted
/// sheet name (`'Sales, West'!A1`) is part of the name, and a doubled quote inside the quotes
/// (`'O''Brien, Jr'!A1`) is an escaped quote, not the end of the name: a quote toggles the
/// quoted state, so a doubled one toggles it twice.
pub fn split_areas(reference: &str) -> Vec<&str> {
let mut areas = Vec::new();
let (mut start, mut quoted) = (0, false);
for (i, c) in reference.char_indices() {
match c {
'\'' => quoted = !quoted,
',' if !quoted => {
areas.push(reference.get(start..i).unwrap_or_default());
start = i.saturating_add(1);
}
_ => {}
}
}
areas.push(reference.get(start..).unwrap_or_default());
areas
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn areas_split_only_at_commas_outside_quoted_sheet_names() {
assert_eq!(split_areas("B2:B4,D1"), ["B2:B4", "D1"]);
assert_eq!(split_areas("'Sales, West'!$A$1"), ["'Sales, West'!$A$1"]);
assert_eq!(split_areas("'O''Brien, Jr'!A1:B2,D4"), ["'O''Brien, Jr'!A1:B2", "D4"]);
assert_eq!(split_areas("'Q1, Q2'!A1,'Q3, Q4'!B2"), ["'Q1, Q2'!A1", "'Q3, Q4'!B2"]);
assert_eq!(split_areas(""), [""]);
}
#[test]
fn range_parse_rejects_signed_rows() -> Result<(), std::io::Error> {
assert!(RangeRef::parse("+1:+3").is_none());
+1 -1
View File
@@ -11,7 +11,7 @@ pub mod value;
pub use addr::{
CategoryLevels, CellRef, MAX_COLS, MAX_ROWS, RangeRef, category_levels, col_to_letters, letters_to_col, quote_sheet, sheet_needs_quotes,
split_sheet, unquote_sheet,
split_areas, split_sheet, unquote_sheet,
};
pub use date::DateSystem;
pub use value::{Array, CellError, Value, compare, compare_numbers, compare_text, number_to_text, sort_compare};
+34 -13
View File
@@ -1,3 +1,5 @@
use gridcraft_core::split_areas;
use super::{
CMD, CellRef, DEFAULT_EVALS, DefinedName, EngineError, Json, MAX_CONSTRAINTS, MAX_EVALS, MAX_POPULATION, MAX_VARS, MIN_POPULATION, RangeRef,
Result, Session, Workbook, bad, bool_param, f64_param, json, split_sheet, str_param,
@@ -138,7 +140,8 @@ pub(super) struct Model {
fn cells_of(wb: &Workbook, t: &str, default_sheet: usize, cap: usize) -> Result<(usize, Vec<CellRef>)> {
let mut sheet = None;
let mut out = Vec::new();
for part in t.split(',').map(str::trim).filter(|x| !x.is_empty()) {
// A comma inside a quoted sheet name (`'Sales, West'!$A$1`) does not end the area.
for part in split_areas(t).into_iter().map(str::trim).filter(|x| !x.is_empty()) {
let (sh, body) = split_sheet(part);
let si = match sh {
Some(n) => wb.sheet_index(&n).ok_or_else(|| bad(CMD, format!("no sheet named `{n}`")))?,
@@ -163,14 +166,30 @@ fn num_text(t: &str) -> Option<f64> {
gridcraft_core::parse::parse_number_text(t.trim()).filter(|v| v.is_finite())
}
/// Splits `"B5 <= 10"` / `"B2 int"` into (lhs, relation, rhs).
/// `t` split at the first `pat` outside quoted sheet names: `'A<=B'!$C$1 <= 5` splits at the
/// second `<=`. A doubled quote inside a name (`'O''Brien<=1'!A1`) toggles the quoted state
/// twice, as in [`split_areas`], so it doesn't end the name.
fn split_unquoted<'a>(t: &'a str, pat: &str) -> Option<(&'a str, &'a str)> {
let mut quoted = false;
for (i, c) in t.char_indices() {
if c == '\'' {
quoted = !quoted;
} else if !quoted && t.get(i..).is_some_and(|rest| rest.starts_with(pat)) {
return Some((t.get(..i)?, t.get(i.saturating_add(pat.len())..)?));
}
}
None
}
/// Splits `"B5 <= 10"` / `"B2 int"` into (lhs, relation, rhs). A relation's characters inside
/// a quoted sheet name (`'A<=B'!$C$1 <= 5`) are part of the name.
fn split_constraint(t: &str) -> Option<(String, Rel, String)> {
for op in ["<=", ">=", "=<", "=>", "≤", "≥", "=="] {
if let Some((a, b)) = t.split_once(op) {
if let Some((a, b)) = split_unquoted(t, op) {
return Some((a.trim().into(), Rel::parse(op)?, b.trim().into()));
}
}
if let Some((a, b)) = t.split_once('=') {
if let Some((a, b)) = split_unquoted(t, "=") {
return Some((a.trim().into(), Rel::Eq, b.trim().into()));
}
let lower = t.trim().to_ascii_lowercase();
@@ -337,8 +356,8 @@ pub fn sensitivity_applies(p: &Json) -> bool {
/// `"B2:B4,D1"` as absolute references, each qualified by `sheet_name` when given.
fn abs_text(sheet_name: Option<&str>, cells: &str) -> String {
let q = sheet_name.map(|n| format!("{}!", crate::pivot::quote_sheet(n))).unwrap_or_default();
cells
.split(',')
split_areas(cells)
.into_iter()
.map(str::trim)
.filter(|x| !x.is_empty())
.filter_map(|part| {
@@ -446,7 +465,7 @@ pub fn saved_model(wb: &Workbook, sheet: usize) -> Option<Json> {
let named = |n: &str, scope: Option<usize>| wb.names.iter().find(|d| d.name.eq_ignore_ascii_case(n) && d.scope == scope);
let scope = if named("solver_opt", Some(sheet)).is_some() { Some(sheet) } else { None };
let get = |n: &str| named(n, scope).map(|d| d.formula.trim().trim_start_matches('=').to_string());
let local = |t: String| -> String { t.split(',').map(|p| split_sheet(p.trim()).1.replace('$', "")).collect::<Vec<_>>().join(",") };
let local = |t: String| local_refs(&t);
let objective = local(get("solver_opt")?);
if objective.trim_matches('"').trim().is_empty() {
return None;
@@ -555,7 +574,8 @@ pub(super) fn model_params(wb: &Workbook, m: &Model) -> Json {
/// The cells Save Model writes, top to bottom, as formula text.
pub(super) fn model_formulas(m: &Model) -> Result<Vec<String>> {
let one_area = |t: &str| !t.contains(',');
// A comma inside a quoted sheet name (`'Sales, West'!$A$1`) doesn't start another area.
let one_area = |t: &str| split_areas(t).into_iter().filter(|a| !a.trim().is_empty()).count() <= 1;
let mut rows = Vec::with_capacity(m.cons.len() + 3);
let obj = abs_text(None, &m.objective.a1());
rows.push(match m.goal {
@@ -565,7 +585,8 @@ pub(super) fn model_formulas(m: &Model) -> Result<Vec<String>> {
});
rows.push(format!("=COUNT({})", abs_text(None, &m.vars_text)));
for c in &m.cons {
if !one_area(&c.lhs_text) || !one_area(&c.rhs_text) {
// A number on the right side (`1,000`) is no range, whatever its separators.
if !one_area(&c.lhs_text) || (matches!(c.rhs, Rhs::Cells(_)) && !one_area(&c.rhs_text)) {
return Err(EngineError::Other("Save Model needs each side of a constraint to be one range.".into()));
}
let lhs = abs_text(None, &c.lhs_text);
@@ -608,7 +629,7 @@ fn call_args(t: &str, name: &str) -> Option<String> {
/// References without sheet names or `$`.
fn local_refs(t: &str) -> String {
t.split(',').map(|p| split_sheet(p.trim()).1.replace('$', "")).collect::<Vec<_>>().join(",")
split_areas(t).into_iter().map(|p| split_sheet(p.trim()).1.replace('$', "")).collect::<Vec<_>>().join(",")
}
fn not_a_model() -> EngineError {
@@ -670,13 +691,13 @@ fn read_options_row(model: &mut Json, list: &str) -> Result<()> {
/// One constraint row of Save Model (`AND(relation)`) as a `data.solver` constraint.
fn read_constraint_row(t: String) -> Result<String> {
let t = call_args(&t, "AND").unwrap_or(t);
if let Some((x, y)) = t.split_once("=INT(").or_else(|| t.split_once("=int(")) {
if let Some((x, y)) = split_unquoted(&t, "=INT(").or_else(|| split_unquoted(&t, "=int(")) {
if y.strip_suffix(')') != Some(x) {
return Err(not_a_model());
}
return Ok(format!("{} int", local_refs(x)));
}
if let Some(x) = t.strip_prefix('(').and_then(|x| x.strip_suffix("=1)")).and_then(|x| x.split_once("=0)+(")).filter(|(a, b)| a == b) {
if let Some(x) = t.strip_prefix('(').and_then(|x| x.strip_suffix("=1)")).and_then(|x| split_unquoted(x, "=0)+(")).filter(|(a, b)| a == b) {
return Ok(format!("{} bin", local_refs(x.0)));
}
let (l, rel, r) = split_constraint(&t).ok_or_else(not_a_model)?;
@@ -698,7 +719,7 @@ pub(super) fn parse_model_formulas(rows: &[String]) -> Result<Json> {
} else if let Some(o) = call_args(&first, "MIN") {
("min", o, 0.0)
} else {
let (o, v) = first.split_once('=').ok_or_else(not_a_model)?;
let (o, v) = split_unquoted(&first, "=").ok_or_else(not_a_model)?;
("value", o.to_string(), num_text(v).ok_or_else(not_a_model)?)
};
let variables = call_args(&body(rows.get(1).ok_or_else(not_a_model)?), "COUNT").ok_or_else(not_a_model)?;
+130
View File
@@ -1819,3 +1819,133 @@ fn hostile_models_are_refused_without_panicking() {
let r = s.execute("data.solver", json!({"objective": "D1", "variables": "A1", "maxEvaluations": 1e300})).unwrap();
assert_ne!(r["status"], "converged");
}
#[test]
fn a_comma_inside_a_quoted_sheet_name_does_not_split_a_reference() {
// Wyndor Glass on sheets whose names hold a comma (and, for the second, a doubled quote),
// solved from Sheet1 with references that carry those names.
for (name, quoted) in [("Sales, West", "'Sales, West'"), ("O'Brien, Jr", "'O''Brien, Jr'")] {
let mut s = s();
s.execute("home.insertSheet", json!({"name": name, "before": 1})).unwrap();
s.execute("sheet.activate", json!({"sheet": 1})).unwrap();
set(&mut s, &[("A1", "0"), ("A2", "0"), ("B1", "=3*A1+5*A2"), ("B2", "=2*A2"), ("B3", "=3*A1+2*A2")]);
s.execute("sheet.activate", json!({"sheet": 0})).unwrap();
let r = s
.execute(
"data.solver",
json!({
"objective": format!("{quoted}!$B$1"),
"goal": "max",
"variables": format!("{quoted}!$A$1,{quoted}!$A$2"),
"constraints": [format!("{quoted}!$A$1 <= 4"), format!("{quoted}!$B$2 <= 12"), format!("{quoted}!$B$3 <= 18")],
"method": "simplex",
}),
)
.unwrap_or_else(|e| panic!("{name}: {e}"));
assert_eq!(r["status"], "optimal", "{name}: {r}");
let sheet = &s.doc().unwrap().wb.sheets[1];
let v = |a: &str| sheet.value(CellRef::parse(a).unwrap()).as_f64().unwrap_or(f64::NAN);
assert!((v("A1") - 2.0).abs() < 1e-9 && (v("A2") - 6.0).abs() < 1e-9, "{name}");
assert!((v("B1") - 36.0).abs() < 1e-9, "{name}");
// The saved model keeps both variable cells, and loads back as the same model.
let model = super::saved_model(&s.doc().unwrap().wb, 1).unwrap_or_else(|| panic!("{name}: no saved model"));
assert_eq!(model["objective"], "B1", "{name}: {model}");
assert_eq!(model["variables"], "A1,A2", "{name}: {model}");
assert_eq!(model["constraints"][0], "A1 <= 4", "{name}: {model}");
}
}
#[test]
fn a_relation_inside_a_quoted_sheet_name_does_not_split_a_constraint() {
// Wyndor Glass with a third, binary variable (Z = 37 at 2, 6, 1) on sheets whose names hold
// a relation, solved from Sheet1 with references that carry those names, under every
// relation. One name holds a doubled quote as well, and two the text that marks an integer
// or a binary constraint in a model written on cells.
for name in ["A<=B", "A>=B", "A=B", "Q1=<Q2", "x≤y", "O'Brien<=1", "n=INT(1", "n=0)+(1"] {
let q = gridcraft_core::quote_sheet(name);
let mut s = s();
s.execute("home.insertSheet", json!({"name": name, "before": 1})).unwrap();
s.execute("sheet.activate", json!({"sheet": 1})).unwrap();
set(&mut s, &[("A1", "0"), ("A2", "0"), ("A3", "0"), ("B1", "=3*A1+5*A2+A3"), ("B2", "=2*A2"), ("B3", "=3*A1+2*A2")]);
s.execute("sheet.activate", json!({"sheet": 0})).unwrap();
let constraints = [
format!("{q}!$A$1 <= 4"),
format!("{q}!$B$2 <= 12"),
format!("{q}!$B$3 <= 18"),
format!("{q}!$A$1 >= 1"),
format!("{q}!$A$1 = 2"),
format!("{q}!$A$1:$A$2 int"),
format!("{q}!$A$3 bin"),
];
let model = json!({"objective": format!("{q}!$B$1"), "goal": "max", "variables": format!("{q}!$A$1:$A$3"), "constraints": constraints, "method": "simplex"});
let r = s.execute("data.solver", model).unwrap_or_else(|e| panic!("{name}: {e}"));
assert_eq!(r["status"], "optimal", "{name}: {r}");
assert!((at(&s, name, "B1").as_f64().unwrap_or(f64::NAN) - 37.0).abs() < 1e-9, "{name}");
// The saved model holds every constraint whole, and solves again from its sheet.
let saved = super::saved_model(&s.doc().unwrap().wb, 1).unwrap_or_else(|| panic!("{name}: no saved model"));
assert_eq!(saved["constraints"], json!(["A1 <= 4", "B2 <= 12", "B3 <= 18", "A1 >= 1", "A1 = 2", "A1:A2 int", "A3 bin"]), "{name}: {saved}");
s.execute("sheet.activate", json!({"sheet": 1})).unwrap();
set(&mut s, &[("A1", "0"), ("A2", "0"), ("A3", "0")]);
let r = s.execute("data.solver", json!({"saved": true})).unwrap_or_else(|e| panic!("{name}: {e}"));
assert_eq!(r["status"], "optimal", "{name}: {r}");
assert!((n(&s, "B1") - 37.0).abs() < 1e-9, "{name}");
// An integer constraint is still one, so no Sensitivity report is offered.
let p = json!({"method": "simplex", "constraints": [format!("{q}!$A$1 int")]});
assert!(!super::sensitivity_applies(&p), "{name}");
// A model written on cells with references that carry the name loads whole, a Value Of
// goal included.
let rows = [
format!("=MAX({q}!$B$1)"),
format!("=COUNT({q}!$A$1:$A$3)"),
format!("=AND({q}!$B$3<=18)"),
format!("=AND({q}!$A$1:$A$2=INT({q}!$A$1:$A$2))"),
format!("=AND(({q}!$A$3=0)+({q}!$A$3=1))"),
"={2,1,100,0.075,0,1,0.0001,50,20000,0}".to_string(),
];
for (k, f) in rows.iter().enumerate() {
set(&mut s, &[(&format!("H{}", k + 1), f)]);
}
let m = s.execute("data.solverLoadModel", json!({"from": "H1"})).unwrap_or_else(|e| panic!("{name}: {e}"))["model"].clone();
assert_eq!(m["objective"], "B1", "{name}: {m}");
assert_eq!(m["constraints"], json!(["B3 <= 18", "A1:A2 int", "A3 bin"]), "{name}: {m}");
set(&mut s, &[("J1", &format!("={q}!$B$1=37")), ("J2", &format!("=COUNT({q}!$A$1:$A$3)")), ("J3", &rows[5])]);
let m = s.execute("data.solverLoadModel", json!({"from": "J1"})).unwrap_or_else(|e| panic!("{name}: {e}"))["model"].clone();
assert_eq!((m["objective"].as_str(), m["goal"].as_str(), m["value"].as_f64()), (Some("B1"), Some("value"), Some(37.0)), "{name}: {m}");
}
}
#[test]
fn save_model_counts_areas_outside_quoted_sheet_names() {
// Wyndor Glass on a sheet whose name holds a comma (and, for the second, a doubled quote),
// saved on cells with references that carry the name.
for name in ["Sales, West", "O'Brien, Jr"] {
let q = gridcraft_core::quote_sheet(name);
let mut s = s();
s.execute("home.insertSheet", json!({"name": name, "before": 1})).unwrap();
s.execute("sheet.activate", json!({"sheet": 1})).unwrap();
set(&mut s, &[("A1", "0"), ("A2", "0"), ("B1", "=3*A1+5*A2"), ("B2", "=2*A2"), ("B3", "=3*A1+2*A2"), ("C3", "18")]);
let constraints = [
format!("{q}!$A$1 <= 4"),
format!("{q}!$B$2 <= 12"),
format!("{q}!$B$3 <= {q}!$C$3"),
format!("{q}!$A$1:$A$2 int"),
// A number with a thousands separator is one value, not two areas.
format!("{q}!$B$1 <= 1,000"),
];
let model = json!({"objective": format!("{q}!$B$1"), "variables": format!("{q}!$A$1:$A$2"), "constraints": constraints, "method": "simplex"});
let out = s.execute("data.solverSaveModel", json!({"to": "H1", "model": model})).unwrap_or_else(|e| panic!("{name}: {e}"));
assert_eq!(out["range"], "H1:H8", "{name}: {out}");
let rows: Vec<String> = (3..=7).map(|r| formula(&s, &format!("H{r}"))).collect();
assert_eq!(rows, ["AND($A$1<=4)", "AND($B$2<=12)", "AND($B$3<=$C$3)", "AND($A$1:$A$2=INT($A$1:$A$2))", "AND($B$1<=1000)"], "{name}");
// The model loads back from the cells and solves.
let loaded = s.execute("data.solverLoadModel", json!({"from": "H1"})).unwrap_or_else(|e| panic!("{name}: {e}"));
assert_eq!(loaded["model"]["constraints"], json!(["A1 <= 4", "B2 <= 12", "B3 <= C3", "A1:A2 int", "B1 <= 1000"]), "{name}: {loaded}");
let r = s.execute("data.solver", json!({"saved": true})).unwrap_or_else(|e| panic!("{name}: {e}"));
assert_eq!(r["status"], "optimal", "{name}: {r}");
assert!((n(&s, "B1") - 36.0).abs() < 1e-9, "{name}");
// A side of two areas, each carrying the name, is still refused.
let two = json!({"objective": format!("{q}!$B$1"), "variables": format!("{q}!$A$1:$A$2"), "constraints": [format!("{q}!$A$1:$A$2 <= {q}!$C$1,{q}!$C$2")]});
let e = s.execute("data.solverSaveModel", json!({"to": "J1", "model": two})).unwrap_err().to_string();
assert!(e.contains("one range"), "{name}: {e}");
}
}
+1 -1
View File
@@ -327,7 +327,7 @@ impl SheetApp {
}
/// Whether the active sheet of the active document is protected (drives the Protect/Unprotect
/// Sheet labels and actions). While a dialog's reference boxes have picked, it is the dialog's
/// Sheet labels and actions). While a dialog with reference boxes is open, it is the dialog's
/// own sheet: Protect Sheet ends the pick and acts on that sheet, so the label says so too.
pub fn active_sheet_protected(&self) -> bool {
if let Some(protected) = self.dialog.as_ref().and_then(|d| d.home_sheet_protected(self)) {
+18 -5
View File
@@ -157,19 +157,31 @@ impl Dialog {
ref_edit::end_pick(app, self)
}
/// Whether the dialog's own sheet is protected, once its reference boxes have picked and while
/// Whether the dialog's own sheet is protected, for a dialog with reference boxes and while
/// that sheet exists (the sheet [`Self::end_pick`] shows again); `None` otherwise.
pub(crate) fn home_sheet_protected(&self, app: &SheetApp) -> Option<bool> {
ref_edit::home_sheet_protected(app, self)
}
/// Whether the sheet the dialog was opened on was deleted while its reference boxes picked:
/// OK is then refused with [`SHEET_DELETED`], since references without a sheet name have
/// nothing to refer to.
/// Whether the sheet a dialog with reference boxes was opened on was deleted while it was
/// open: OK is then refused with [`SHEET_DELETED`], since references without a sheet name
/// have nothing to refer to.
pub(crate) fn home_sheet_deleted(&mut self, app: &SheetApp) -> bool {
ref_edit::home_sheet_deleted(app, self)
}
/// A press on the grid that selects cells: the reference box being picked into takes the
/// selection on this frame even when it is unchanged (a click on the cell already selected).
pub(crate) fn note_selecting_press(&mut self) {
ref_edit::note_selecting_press(self);
}
/// Whether a reference box is the one grid selections fill: a click on the grid is then a
/// pick, and a second click on the same cell does not edit the cell.
pub(crate) fn ref_box_picks(&self) -> bool {
ref_edit::picks(self)
}
/// Whether a click on the grid goes to a formula box of this dialog rather than the selection.
pub fn picks_references(&self) -> bool {
self.pick.is_some()
@@ -266,7 +278,7 @@ impl Dialog {
let active = app.session.active().map(|d| d.selection.active.a1()).unwrap_or_default();
let p = |k: &str| params.get(k).cloned().unwrap_or(Json::Null);
use Field::*;
let d = match name {
let mut d = match name {
"insertCells" => Dialog::form(
"insertCells",
"Insert",
@@ -1119,6 +1131,7 @@ impl Dialog {
}
_ => return None,
};
ref_edit::record_home(app, &mut d);
Some(d)
}
}
+4 -2
View File
@@ -1,4 +1,4 @@
use super::ref_edit::{RefLabel, SHEET_DELETED, end_pick, home_sheet_deleted, ref_edit};
use super::ref_edit::{RefLabel, SHEET_DELETED, current_area_ref, end_pick, home_sheet_deleted, ref_edit};
use super::show::{build_params, form_fields, ok_cancel};
use super::{Dialog, Field, Json, Map, SheetApp, json, vec2};
@@ -261,7 +261,9 @@ pub(super) fn analysis_toolpak(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Di
ok_cancel(ui, &mut ok, open);
let pick = d.values.get("pick").and_then(Json::as_str).unwrap_or("").to_string();
if (ok || chosen) && TOOLS.iter().any(|(id, _)| *id == pick) {
let sel = app.session.active().map(|doc| doc.selection.current().a1()).unwrap_or_default();
// The selection becomes the input range, with its sheet's name when another sheet
// than the dialog's own is shown.
let sel = current_area_ref(app, d);
d.values = toolpak_defaults(&sel, &pick);
}
return;
+89 -58
View File
@@ -1,10 +1,12 @@
//! Reference boxes (Excel's RefEdit): text fields that take a range by selection on the grid, with
//! a button that collapses the dialog to the box while the person selects.
use gridcraft_engine::DocState;
use gridcraft_engine::core::split_areas;
use gridcraft_engine::model::{Sheet, SheetUid, Workbook};
use super::data::abs;
use super::{Dialog, Icon, Json, SheetApp, json, theme, vec2};
use super::{Dialog, Field, Icon, Json, SheetApp, json, theme, vec2};
/// What a dialog's reference boxes are doing: which one grid selections fill, and whether the
/// dialog is collapsed to one of them.
@@ -18,12 +20,17 @@ pub(super) struct RefBoxes {
/// The sheet shown when `seen` was taken: changing sheets only refreshes `seen`, since the
/// other sheet's own selection is not a pick.
seen_sheet: String,
/// The dialog's own sheet, from the first pick until the dialog closes, and the selection to
/// put back when it runs its command or closes.
/// The dialog's own sheet, from the moment the dialog opens until it closes, and the
/// selection to put back when it runs its command or closes.
home: Option<Home>,
/// The target took a selection from the grid and takes the keyboard back once the pointer
/// is released, so typing still edits the box.
refocus: bool,
/// A press on the grid selected this frame ([`note_selecting_press`]): the target takes the
/// selection even when it is the one it last saw, since clicking the cell already selected
/// is a pick too. Cleared when a box starts picking and once the frame's boxes are drawn, so
/// only a press made while the box picks counts.
pressed: bool,
/// The target box was drawn this frame. A box the dialog stops showing (another view of the
/// dialog, a hidden area, a constraint operator that takes no value) is no longer picked
/// into, so a selection made meanwhile does not land in it once it is shown again (see
@@ -43,9 +50,9 @@ pub(super) struct RefBoxes {
other_field: bool,
}
/// Where picking began: the dialog's own sheet, kept for as long as the dialog is open (a failed
/// OK leaves it open, and a later OK still needs to know whether that sheet is there), and the
/// selection to put back once.
/// The dialog's own sheet: the sheet shown when the dialog opened ([`record_home`]), kept for as
/// long as the dialog is open (a failed OK leaves it open, and a later OK still needs to know
/// whether that sheet is there), and the selection on it to put back once picking ends.
#[derive(Clone, Debug)]
struct Home {
/// The sheet's identity while the app runs ([`SheetUid`]): fresh for every sheet added,
@@ -54,18 +61,59 @@ struct Home {
/// copy of it is never taken for it, and an undo that brings it back makes it the dialog's
/// again.
uid: SheetUid,
/// The ranges and active cell from before picking began on the dialog's sheet, put back once
/// when the dialog runs its command or closes.
/// The ranges and active cell on the dialog's sheet from before picking began, taken when a
/// box starts picking (from the sheet's kept selection when another sheet is shown then), and
/// put back once when the dialog runs its command or closes.
selection: Option<(String, String)>,
}
/// Whether the dialog has reference boxes: Goal Seek, Solver, Data Analysis, and any form with a
/// [`Field::Ref`].
fn has_reference_boxes(d: &Dialog) -> bool {
matches!(d.name.as_str(), "goalSeek" | "solver" | "analysisToolPak") || d.fields.iter().any(|f| matches!(f, Field::Ref { .. }))
}
/// Records the sheet shown as the dialog's own when a dialog with reference boxes opens, from the
/// ribbon or the control channel alike. A sheet shown later, before any box picks, is not the
/// dialog's: references without a sheet name still mean the sheet the dialog was opened on,
/// whose defaults (the active cell, the selection) the boxes start with.
pub(super) fn record_home(app: &SheetApp, d: &mut Dialog) {
if !has_reference_boxes(d) {
return;
}
if let Some(sheet) = app.session.active().and_then(|doc| doc.wb.active()) {
d.refs.home = Some(Home { uid: sheet.uid, selection: None });
}
}
/// The selection on the dialog's own sheet as (ranges, active cell): the one shown while that
/// sheet is active, else the one the sheet keeps for when it is shown again. `None` once the
/// sheet is deleted.
fn home_selection(doc: &DocState, home: &Home) -> Option<(String, String)> {
let i = doc.wb.sheets.iter().position(|s| s.uid.get() == home.uid.get())?;
let sel = if i == doc.wb.active_sheet() { &doc.selection } else { doc.sheet_selections.get(i)? };
let ranges: Vec<String> = sel.ranges.iter().map(|r| r.a1()).collect();
Some((ranges.join(","), sel.active.a1()))
}
/// The grid's current area (the one being selected) as a reference for a dialog of `d`'s: with
/// the sheet name when it is on another sheet than the dialog's own, as a reference box writes it.
pub(super) fn current_area_ref(app: &SheetApp, d: &Dialog) -> String {
let Some(doc) = app.session.active() else { return String::new() };
let area = doc.selection.current().a1();
match (doc.wb.active(), d.refs.home.as_ref()) {
(Some(sh), Some(home)) if home.uid.get() != sh.uid.get() => format!("{}!{area}", gridcraft_engine::formula::quote_sheet(&sh.name)),
_ => area,
}
}
/// The dialog's own sheet in `wb`, or `None` once it was deleted.
fn home_sheet<'a>(wb: &'a Workbook, home: &Home) -> Option<&'a Sheet> {
wb.sheets.iter().map(|s| &**s).find(|s| s.uid.get() == home.uid.get())
}
/// Whether the dialog's own sheet is protected, once its reference boxes have picked and while
/// that sheet exists: the sheet [`end_pick`] shows again. `None` otherwise.
/// Whether the dialog's own sheet is protected, for a dialog with reference boxes and while that
/// sheet exists: the sheet [`end_pick`] shows again. `None` otherwise.
pub(super) fn home_sheet_protected(app: &SheetApp, d: &Dialog) -> Option<bool> {
let home = d.refs.home.as_ref()?;
app.session.active().and_then(|doc| home_sheet(&doc.wb, home)).map(Sheet::is_protected)
@@ -75,8 +123,9 @@ pub(super) fn home_sheet_protected(app: &SheetApp, d: &Dialog) -> Option<bool> {
pub(crate) const SHEET_DELETED: &str =
"The sheet this dialog was opened on was deleted. Cancel, then open the dialog again on the sheet it should use.";
/// Whether the sheet the dialog was opened on has been deleted while it picked: its references
/// without a sheet name have nothing to refer to, so OK is refused with [`SHEET_DELETED`].
/// Whether the sheet a dialog with reference boxes was opened on has been deleted while it was
/// open: its references without a sheet name have nothing to refer to, so OK is refused with
/// [`SHEET_DELETED`].
pub(super) fn home_sheet_deleted(app: &SheetApp, d: &mut Dialog) -> bool {
d.refs.home.as_ref().is_some_and(|h| app.session.active().is_none_or(|doc| home_sheet(&doc.wb, h).is_none()))
}
@@ -216,6 +265,8 @@ pub(super) fn window_shown(ctx: &egui::Context, d: &mut Dialog, collapsed: bool,
d.refs.rect = rect;
}
d.refs.was_collapsed = collapsed;
// A press the boxes did not take this frame (none was picking) is not kept for a later one.
d.refs.pressed = false;
d.refs.other_field = d.refs.collapsed.as_deref().is_some_and(|key| other_field_focused(ctx, key));
// The box compares the grid selection with the one it last saw only while it is drawn: a
// hidden box would take a selection made meanwhile when it comes back.
@@ -227,26 +278,33 @@ pub(super) fn window_shown(ctx: &egui::Context, d: &mut Dialog, collapsed: bool,
}
}
/// Makes the reference box `key` the one grid selections fill. The first pick remembers the
/// sheet and selection, which are put back when the dialog runs its command or closes.
/// Records a press on the grid that selects (see [`RefBoxes::pressed`]).
pub(super) fn note_selecting_press(d: &mut Dialog) {
d.refs.pressed = true;
}
/// Whether a reference box is being picked into (see [`Dialog::ref_box_picks`]).
pub(super) fn picks(d: &Dialog) -> bool {
d.refs.target.is_some()
}
/// Makes the reference box `key` the one grid selections fill. The first pick (and the first
/// after an OK that failed) remembers the selection on the dialog's own sheet, which is put back
/// when the dialog runs its command or closes. A dialog opened without [`record_home`] takes the
/// sheet shown at its first pick as its own.
pub(super) fn begin_pick(app: &SheetApp, d: &mut Dialog, key: &str) {
if let Some(doc) = app.session.active()
&& let Some(sheet) = doc.wb.active()
{
let ranges: Vec<String> = doc.selection.ranges.iter().map(|r| r.a1()).collect();
let selection = Some((ranges.join(","), doc.selection.active.a1()));
match d.refs.home.as_mut() {
None => d.refs.home = Some(Home { uid: sheet.uid, selection }),
// Picking again after a failed OK: the selection to put back is the one now on the
// dialog's sheet.
Some(home) if home.selection.is_none() && home.uid.get() == sheet.uid.get() => {
home.selection = selection;
}
Some(_) => {}
let home = d.refs.home.get_or_insert(Home { uid: sheet.uid, selection: None });
if home.selection.is_none() {
home.selection = home_selection(doc, home);
}
}
d.refs.target = Some(key.to_string());
d.refs.refocus = false;
// Focusing the box keeps its text: only a press after this one picks.
d.refs.pressed = false;
d.refs.seen = selection_ref(app, d.refs.home.as_ref(), takes_several_areas(&d.name, key));
d.refs.seen_sheet = active_sheet_name(app);
}
@@ -305,14 +363,17 @@ pub(super) fn ref_edit(app: &SheetApp, ui: &mut egui::Ui, d: &mut Dialog, key: &
} else if d.refs.target.as_deref() == Some(key) {
let sel = selection_ref(app, d.refs.home.as_ref(), takes_several_areas(&d.name, key));
let sheet = active_sheet_name(app);
if sheet != d.refs.seen_sheet {
let pressed = std::mem::take(&mut d.refs.pressed);
if sheet != d.refs.seen_sheet && !pressed {
// Another sheet was shown: its selection is where that sheet was left, not a pick.
d.refs.seen = sel;
d.refs.seen_sheet = sheet;
} else if sel != d.refs.seen {
// The person selected on the grid: the selection becomes the reference.
} else if pressed || sel != d.refs.seen {
// The person selected on the grid, even the cell already selected: the selection
// becomes the reference.
s = sel.clone();
d.refs.seen = sel;
d.refs.seen_sheet = sheet;
d.refs.refocus = true;
}
if d.refs.refocus && !ui.input(|i| i.pointer.any_down()) {
@@ -376,27 +437,6 @@ fn anchors(part: &str) -> Option<Anchors> {
Some(Anchors { letters, digits, col_abs, row_abs })
}
/// The areas of a reference list, split at the commas between them. A comma inside a quoted
/// sheet name (`'Sales, West'!A1`) is part of the name, and a doubled quote inside the quotes
/// (`'O''Brien, Jr'!A1`) is an escaped quote, not the end of the name: a quote toggles the
/// quoted state, so a doubled one toggles it twice.
fn split_areas(reference: &str) -> Vec<&str> {
let mut areas = Vec::new();
let (mut start, mut quoted) = (0, false);
for (i, c) in reference.char_indices() {
match c {
'\'' => quoted = !quoted,
',' if !quoted => {
areas.push(reference.get(start..i).unwrap_or_default());
start = i.saturating_add(1);
}
_ => {}
}
}
areas.push(reference.get(start..).unwrap_or_default());
areas
}
/// Excel's F4 in a reference box: `A1` → `$A$1` → `A$1` → `$A1` → `A1`, every part of every
/// area moving together from the first part's anchors (`A1:B2,D4` → `$A$1:$B$2,$D$4`), sheet
/// names kept; whole columns and rows have one anchor each (`A:A` → `$A:$A` → `A:A`). Text that
@@ -442,16 +482,7 @@ pub(crate) fn cycle_anchors(reference: &str) -> String {
#[cfg(test)]
mod tests {
use super::{cycle_anchors, split_areas};
#[test]
fn areas_split_only_at_commas_outside_quoted_sheet_names() {
assert_eq!(split_areas("B2:B4,D1"), ["B2:B4", "D1"]);
assert_eq!(split_areas("'Sales, West'!$A$1"), ["'Sales, West'!$A$1"]);
assert_eq!(split_areas("'O''Brien, Jr'!A1:B2,D4"), ["'O''Brien, Jr'!A1:B2", "D4"]);
assert_eq!(split_areas("'Q1, Q2'!A1,'Q3, Q4'!B2"), ["'Q1, Q2'!A1", "'Q3, Q4'!B2"]);
assert_eq!(split_areas(""), [""]);
}
use super::cycle_anchors;
#[test]
fn f4_keeps_a_comma_inside_a_quoted_sheet_name() {
+23 -4
View File
@@ -350,6 +350,7 @@ fn handle_column_header_press(app: &mut SheetApp, input: &InteractionInput<'_>,
return;
}
let range = if mods.shift { RangeRef::cols(sel.anchor.col.min(c), sel.anchor.col.max(c)) } else { RangeRef::cols(c, c) };
note_selecting_press(app);
select_ranges(app, range, mods.command, CellRef::new(geo.row_at(sh, geo.cells.top() + 1.0), c));
app.grid.drag = Drag::Cols(if mods.shift { sel.anchor.col } else { c });
}
@@ -370,6 +371,7 @@ fn handle_row_header_press(app: &mut SheetApp, input: &InteractionInput<'_>, p:
return;
}
let range = if mods.shift { RangeRef::rows(sel.anchor.row.min(r), sel.anchor.row.max(r)) } else { RangeRef::rows(r, r) };
note_selecting_press(app);
select_ranges(app, range, mods.command, CellRef::new(r, geo.col_at(sh, geo.cells.left() + 1.0)));
app.grid.drag = Drag::Rows(if mods.shift { sel.anchor.row } else { r });
}
@@ -401,6 +403,7 @@ fn handle_press(app: &mut SheetApp, input: &InteractionInput<'_>) {
return;
}
if in_corner {
note_selecting_press(app);
let _ = app.session.run("selection.set", json!({"range": RangeRef::all().a1()}));
return;
}
@@ -486,11 +489,16 @@ fn handle_press(app: &mut SheetApp, input: &InteractionInput<'_>) {
return;
}
let now = crate::now_ms();
if handle_cell_double_click(app, input, c, now) {
// While a reference box picks, a click only picks: a second click on a cell does not edit it,
// and a link is not followed nor a checkbox toggled.
let ref_box_picks = app.dialog.as_ref().is_some_and(|d| d.ref_box_picks());
let acts = primary_clicked && !ref_box_picks;
if !ref_box_picks && handle_cell_double_click(app, input, c, now) {
return;
}
app.grid.last_click = Some((c, now));
if mods.shift {
note_selecting_press(app);
let _ = app.session.run("selection.set", json!({"range": RangeRef::new(sel.anchor, c).a1(), "active": sel.active.a1()}));
if let Some(d) = app.session.active_mut() {
d.selection.anchor = sel.anchor;
@@ -498,22 +506,24 @@ fn handle_press(app: &mut SheetApp, input: &InteractionInput<'_>) {
} else if mods.command {
// Ctrl+click (without Alt) on a hyperlink's text follows it instead of adding the cell to the selection.
if let Some(h) = sh.hyperlinks.get(&c).cloned()
&& primary_clicked
&& acts
&& !mods.alt
&& p.x < geo.cell_rect(sh, c).left() + geo.cell_rect(sh, c).width() * 0.8
{
follow_link(app, &h.target);
} else {
note_selecting_press(app);
select_ranges(app, RangeRef::cell(c), true, c);
}
} else {
note_selecting_press(app);
let _ = app.session.run("selection.set", json!({"cell": c.a1()}));
if primary_clicked && wb.styles.get(sh.style_id(c)).num_fmt.as_str() == "checkbox" {
if acts && wb.styles.get(sh.style_id(c)).num_fmt.as_str() == "checkbox" {
let _ = app.run("cell.toggleCheckbox", json!({"cell": c.a1()}));
}
// A click on a hyperlink's text follows it (like Excel); elsewhere in the cell selects.
if let Some(h) = sh.hyperlinks.get(&c).cloned()
&& primary_clicked
&& acts
&& !mods.any()
{
let r = geo.cell_rect(sh, c);
@@ -532,6 +542,15 @@ fn handle_press(app: &mut SheetApp, input: &InteractionInput<'_>) {
}
}
/// Tells the open dialog that a press on the grid selects: the reference box being picked into
/// takes the selection even when the press leaves it as it was (a click on the cell already
/// selected), as a deliberate pick.
fn note_selecting_press(app: &mut SheetApp) {
if let Some(d) = app.dialog.as_mut() {
d.note_selecting_press();
}
}
fn handle_cell_double_click(app: &mut SheetApp, input: &InteractionInput<'_>, c: CellRef, now: f64) -> bool {
let (sh, mods) = (input.sheet, input.modifiers);
if let Some((last, at)) = app.grid.last_click
+246
View File
@@ -1637,3 +1637,249 @@ fn solver_load_model_after_a_cross_sheet_pick_reads_the_dialogs_sheet() {
assert!(field(&h, "variables").contains("B2"), "{:?}", field(&h, "variables"));
assert_eq!(selection(&h).0, 0, "back on the dialog's sheet");
}
/// Shows the sheet at `index` from a script, as a sheet tab click or the control channel does.
fn show_sheet(h: &mut H, index: usize) {
h.state_mut().session.execute("sheet.activate", json!({"sheet": index})).unwrap();
h.run_steps(3);
}
#[test]
fn a_sheet_shown_before_the_first_pick_is_not_the_dialogs_sheet() {
// Goal Seek opened on Sheet1 (B5 selected); Rates is shown before any box picks.
let mut h = harness(two_sheets());
open_goal_seek(&mut h);
show_sheet(&mut h, 1);
click_button(&mut h, "Collapse dialog", 1);
drag(&mut h, "A2", "A3");
assert_eq!(field(&h, "changing"), "Rates!$A$2:$A$3", "Rates is not the dialog's sheet: the pick carries its name");
// Back on the dialog's own sheet, a pick needs no sheet name, and Cancel puts back the
// selection the dialog was opened with, not the one picked there.
show_sheet(&mut h, 0);
drag(&mut h, "C3", "C3");
assert_eq!(field(&h, "changing"), "$C$3");
key(&mut h, Key::Escape, Modifiers::NONE);
key(&mut h, Key::Escape, Modifiers::NONE);
assert!(h.state().dialog.is_none());
assert_eq!(selection(&h), (0, "B5".into()), "back on the sheet the dialog was opened on, with its selection");
}
#[test]
fn solver_and_data_analysis_keep_the_sheet_they_were_opened_on() {
for (name, params, button, key_name) in [("solver", json!({}), 0, "objective"), ("analysisToolPak", json!({"tool": "descriptive"}), 0, "range")] {
let mut h = harness(two_sheets());
h.state_mut().open_dialog(name, params);
h.run_steps(4);
show_sheet(&mut h, 1);
click_button(&mut h, "Collapse dialog", button);
drag(&mut h, "B2", "B2");
assert_eq!(field(&h, key_name), "Rates!$B$2", "{name}: a pick on a sheet shown after opening carries its name");
key(&mut h, Key::Escape, Modifiers::NONE);
key(&mut h, Key::Escape, Modifiers::NONE);
assert!(h.state().dialog.is_none(), "{name}");
assert_eq!(selection(&h), (0, "B5".into()), "{name}: Cancel goes back to the sheet it was opened on");
}
}
#[test]
fn ok_without_a_pick_runs_on_the_sheet_the_dialog_was_opened_on() {
// Data Analysis opened on Sheet1, Rates shown, then OK with typed, unqualified references:
// they mean Sheet1's cells, and the report lands on Sheet1.
let mut h = harness(two_sheets());
h.state_mut().open_dialog("analysisToolPak", json!({"tool": "descriptive"}));
h.run_steps(4);
show_sheet(&mut h, 1);
let d = h.state_mut().dialog.as_mut().unwrap();
d.values.insert("range".into(), json!("B1:B6"));
d.values.insert("outputTo".into(), json!("range"));
d.values.insert("output".into(), json!("$H$1"));
h.get_by_label(" OK ").click();
h.run_steps(3);
assert!(h.state().dialog.is_none(), "{:?}", h.state().message);
let wb = &h.state().session.active().unwrap().wb;
assert!(!wb.sheets[0].value(CellRef::parse("H1").unwrap()).is_empty(), "the report is on the dialog's own sheet");
assert!(wb.sheets[1].value(CellRef::parse("H1").unwrap()).is_empty(), "not on the sheet shown when OK was pressed");
assert_eq!(selection(&h).0, 0);
}
#[test]
fn a_tool_chosen_on_another_sheet_takes_that_sheets_selection_with_its_name() {
let mut h = harness(two_sheets());
h.state_mut().open_dialog("analysisToolPak", json!({}));
h.run_steps(4);
show_sheet(&mut h, 1);
h.state_mut().session.execute("selection.set", json!({"range": "A1:A6"})).unwrap();
h.state_mut().dialog.as_mut().unwrap().values.insert("pick".into(), json!("descriptive"));
h.run_steps(2);
h.get_by_label(" OK ").click();
h.run_steps(3);
assert_eq!(field(&h, "range"), "Rates!A1:A6", "the input range keeps the sheet it was selected on");
}
#[test]
fn the_opened_sheet_renamed_or_deleted_before_the_first_pick() {
// Renamed: still the dialog's sheet, found by identity.
let mut h = harness(two_sheets());
open_goal_seek(&mut h);
h.state_mut().session.execute("sheet.rename", json!({"sheet": 0, "name": "Model"})).unwrap();
show_sheet(&mut h, 1);
click_button(&mut h, "Collapse dialog", 1);
drag(&mut h, "A2", "A2");
assert_eq!(field(&h, "changing"), "Rates!$A$2");
show_sheet(&mut h, 0);
drag(&mut h, "C3", "C3");
assert_eq!(field(&h, "changing"), "$C$3", "the renamed sheet is the dialog's own");
key(&mut h, Key::Escape, Modifiers::NONE);
key(&mut h, Key::Escape, Modifiers::NONE);
assert_eq!(selection(&h), (0, "B5".into()));
// Deleted: the sheet shown afterwards does not become the dialog's, so picks carry its name
// and OK is refused rather than reading the dialog's references against it.
let mut h = harness(two_sheets());
open_goal_seek(&mut h);
h.state_mut().session.execute("home.deleteSheet", json!({"sheet": 0})).unwrap();
h.run_steps(3);
assert_eq!(sheet_names(&h), ["Rates"]);
click_button(&mut h, "Collapse dialog", 1);
drag(&mut h, "A2", "A2");
assert_eq!(field(&h, "changing"), "Rates!$A$2");
key(&mut h, Key::Escape, Modifiers::NONE);
h.get_by_label(" OK ").click();
h.run_steps(3);
assert!(h.state().dialog.is_some(), "the dialog stays open");
let message = h.state().message.as_ref().map(|m| m.1.clone()).unwrap_or_default();
assert!(message.contains("was deleted"), "{message}");
}
#[test]
fn a_dialog_opened_by_the_control_channel_keeps_the_sheet_it_was_opened_on() {
let mut h = harness(two_sheets());
let r = control(&mut h, "ui.dialog", json!({"name": "goalSeek"}));
assert_eq!(r["result"]["dialog"], json!("goalSeek"), "{r}");
let r = control(&mut h, "engine.execute", json!({"command": "sheet.activate", "params": {"sheet": 1}}));
assert_eq!(r["ok"], json!(true), "{r}");
click_button(&mut h, "Collapse dialog", 1);
drag(&mut h, "A2", "A2");
assert_eq!(field(&h, "changing"), "Rates!$A$2");
let r = control(&mut h, "ui.dialog.cancel", json!({}));
assert_eq!(r["ok"], json!(true), "{r}");
assert_eq!(selection(&h), (0, "B5".into()), "a scripted cancel goes back to the sheet the dialog was opened on");
}
/// A single click on `cell`, however soon after the last one: the harness clicks faster than a
/// double-click, which would start editing the cell instead.
fn single_click(h: &mut H, cell: &str) {
h.state_mut().grid.last_click = None;
let pos = cell_pos(h, cell);
click_at(h, pos);
}
#[test]
fn a_click_on_the_cell_already_selected_picks_it() {
let mut h = harness(workbook());
open_goal_seek(&mut h);
// Not picking: a click on the selected cell changes no box.
single_click(&mut h, "B5");
assert_eq!((field(&h, "set"), field(&h, "changing")), ("B5".into(), String::new()));
// Clicking into a box keeps its text: the earlier click is not taken for a pick.
let boxes = dialog_boxes(&h);
click_at(&mut h, boxes[2].center());
assert_eq!(field(&h, "changing"), "", "focusing the box alone writes nothing");
// Picking: B5 is still the selection, and a click on it writes it.
assert_eq!(selection(&h), (0, "B5".into()));
single_click(&mut h, "B5");
assert_eq!(field(&h, "changing"), "$B$5", "a click on the cell already selected is a pick");
// A drag still picks a range, and after typing over it, a click on the cell already selected
// writes that cell again.
drag(&mut h, "C3", "D4");
assert_eq!(field(&h, "changing"), "$C$3:$D$4");
single_click(&mut h, "C3");
assert_eq!(field(&h, "changing"), "$C$3");
key(&mut h, Key::A, Modifiers::COMMAND);
type_text(&mut h, "X1");
assert_eq!(field(&h, "changing"), "X1");
single_click(&mut h, "C3");
assert_eq!(field(&h, "changing"), "$C$3", "the click replaces the typed text although the selection is unchanged");
assert_eq!(field(&h, "set"), "B5", "only the box being picked into changes");
assert!(h.state().editor.is_none(), "no cell is being edited");
}
/// A click on `cell` at a fraction of its width (a link's text sits in its first 80%), with
/// `modifiers` held.
fn click_cell_with(h: &mut H, cell: &str, modifiers: Modifiers) {
let app = h.state();
let sheet = app.session.active().unwrap().wb.active().unwrap();
let r = Geo::new(sheet, app.grid.rect.unwrap(), app.view().scroll).cell_rect(sheet, CellRef::parse(cell).unwrap());
let pos = egui::pos2(r.left() + r.width() * 0.3, r.center().y);
h.state_mut().grid.last_click = None;
h.input_mut().events.extend([
Event::ModifiersChanged(modifiers),
Event::PointerMoved(pos),
Event::PointerButton { pos, button: PointerButton::Primary, pressed: true, modifiers },
Event::PointerButton { pos, button: PointerButton::Primary, pressed: false, modifiers },
]);
h.run_steps(3);
h.input_mut().events.push(Event::ModifiersChanged(Modifiers::NONE));
h.run_steps(2);
}
#[test]
fn while_picking_a_click_on_a_link_or_checkbox_picks_its_cell_only() {
let mut s = workbook();
for (cell, target) in [("C3", "Sheet1!B5"), ("C4", "Sheet1!F9"), ("C5", "https://example.org/a")] {
s.execute("cell.set", json!({"cell": cell, "input": "link"})).unwrap();
s.execute("insert.link", json!({"cell": cell, "target": target})).unwrap();
}
s.execute("insert.checkbox", json!({"range": "C6"})).unwrap();
let mut h = harness(s);
open_goal_seek(&mut h);
let boxes = dialog_boxes(&h);
click_at(&mut h, boxes[2].center());
// Ctrl+click on a link back to the cell already selected: the cell clicked is the pick.
click_cell_with(&mut h, "C3", Modifiers::COMMAND);
assert_eq!(field(&h, "changing"), "$C$3", "the link is not followed back to B5");
// A link to another cell on the same sheet, with and without Ctrl: no Go To, the cell picks.
click_cell_with(&mut h, "C4", Modifiers::COMMAND);
assert_eq!(field(&h, "changing"), "$C$4");
click_cell_with(&mut h, "C4", Modifiers::NONE);
assert_eq!(field(&h, "changing"), "$C$4");
assert_eq!(selection(&h), (0, "C4".into()), "the link to F9 was not followed");
// A web link asks nothing, so the dialog being filled stays.
click_cell_with(&mut h, "C5", Modifiers::NONE);
assert_eq!(h.state().dialog.as_ref().map(|d| d.name.as_str()), Some("goalSeek"));
assert_eq!(field(&h, "changing"), "$C$5");
// A checkbox cell is picked, not toggled.
let checked = |h: &H| h.state().session.active().unwrap().wb.active().unwrap().value(CellRef::parse("C6").unwrap());
let before = checked(&h);
click_cell_with(&mut h, "C6", Modifiers::NONE);
assert_eq!(field(&h, "changing"), "$C$6");
assert_eq!(checked(&h), before, "the checkbox was not toggled");
}
#[test]
fn a_second_quick_click_on_a_cell_picks_it_instead_of_editing() {
let mut h = harness(workbook());
open_goal_seek(&mut h);
let boxes = dialog_boxes(&h);
click_at(&mut h, boxes[2].center());
// The last click on the grid was on B5 a moment ago (before the box was clicked into), so
// this click would be a double-click.
let now = gridcraft_engine::core::date::unix_now_ms().unwrap();
h.state_mut().grid.last_click = Some((CellRef::parse("B5").unwrap(), now));
let pos = cell_pos(&h, "B5");
click_at(&mut h, pos);
assert!(h.state().editor.is_none(), "the click does not start editing the cell");
assert_eq!(field(&h, "changing"), "$B$5", "the click picks the cell");
}
#[test]
fn a_click_on_the_selected_cell_picks_into_a_collapsed_box_on_another_sheet() {
let mut h = harness(two_sheets());
open_goal_seek(&mut h);
click_button(&mut h, "Collapse dialog", 1);
next_sheet(&mut h);
assert_eq!(selection(&h), (1, "A1".into()));
assert_eq!(field(&h, "changing"), "", "showing another sheet is not a pick");
single_click(&mut h, "A1");
assert_eq!(field(&h, "changing"), "Rates!$A$1", "a click on that sheet's selected cell is");
}
+1 -1
View File
@@ -65,7 +65,7 @@ connection; nothing sent after it runs. Blank lines are skipped.
| `ui.key` / `ui.text` | `{key, shift?, cmd?, alt?, ctrl?}` / `{text}` | Keyboard input (typing in the grid starts editing); same shared queue limits, and one `ui.text` string may be at most 1 MiB |
| `ui.edit.begin` / `ui.edit.text` / `ui.edit.commit` / `ui.edit.cancel` | `{text?}` / `{move?: down\|right\|up\|left\|none}` | Drive the cell editor |
| `ui.ribbon` | `{tab}` | Switch ribbon tab |
| `ui.dialog` / `ui.dialog.set` / `ui.dialog.confirm` / `ui.dialog.cancel` | `{name}` / `{field, value}` | Open, fill in and confirm dialogs; `confirm` runs the dialog's command with the same parameters as its OK button. `confirm` applies to a dialog whose OK runs a command (Solver, Data Analysis, the form dialogs); a dialog that presses its own OK, such as Goal Seek, answers `this dialog has no OK command; drive it with its own commands` and is driven with that command (`data.goalSeek`). That command does not go back to the dialog's sheet: after a pick on another sheet, read the fields (`ui.inspect` reports the dialog's `values`), `cancel` the dialog (which shows its sheet again), then run `data.goalSeek` with those values, so references without a sheet name mean the dialog's sheet. Once the dialog's own sheet is deleted, `cancel` leaves the sheet shown as it is and replies `{"sheetDeleted": true}` (the sheet is found by identity, so a new sheet given its name does not count); the script then stops instead of running `data.goalSeek`, whose references without a sheet name would mean the sheet shown. A dialog whose reference boxes (RefEdit) picked on another sheet goes back to its own sheet: `cancel` puts the selection from before picking back; `confirm` puts it back before the command runs (so references without a sheet name mean the dialog's sheet). Both fail, with the dialog left open, when a text-box draft on the picked sheet cannot be saved (its worksheet is no longer the active one), and `confirm` also once the dialog's sheet was deleted; the window's own Cancel closes the dialog in that case and shows the reason. |
| `ui.dialog` / `ui.dialog.set` / `ui.dialog.confirm` / `ui.dialog.cancel` | `{name}` / `{field, value}` | Open, fill in and confirm dialogs; `confirm` runs the dialog's command with the same parameters as its OK button. `confirm` applies to a dialog whose OK runs a command (Solver, Data Analysis, the form dialogs); a dialog that presses its own OK, such as Goal Seek, answers `this dialog has no OK command; drive it with its own commands` and is driven with that command (`data.goalSeek`). That command does not go back to the dialog's sheet: after another sheet was shown, read the fields (`ui.inspect` reports the dialog's `values`), `cancel` the dialog (which shows its sheet again), then run `data.goalSeek` with those values, so references without a sheet name mean the dialog's sheet. Once the dialog's own sheet is deleted, `cancel` leaves the sheet shown as it is and replies `{"sheetDeleted": true}` (the sheet is found by identity, so a new sheet given its name does not count); the script then stops instead of running `data.goalSeek`, whose references without a sheet name would mean the sheet shown. A dialog with reference boxes (RefEdit) goes back to its own sheet, the one shown when it was opened (a sheet shown later, before any pick, does not take its place): `cancel` puts the selection from before picking back; `confirm` puts it back before the command runs (so references without a sheet name mean the dialog's sheet). Both fail, with the dialog left open, when a text-box draft on the picked sheet cannot be saved (its worksheet is no longer the active one), and `confirm` also once the dialog's sheet was deleted; the window's own Cancel closes the dialog in that case and shows the reason. |
| `ui.pythonMode` (via `engine.execute`) | `{on?}` | Python mode on the active cell, in the formula bar (an edit in progress keeps its text); `on: false` turns it off; without `on` it switches. A start that is refused (a locked cell on a protected sheet, a cell or draft inside another cell's spill, code hidden on a protected sheet) is an error with the reason. Opens no dialog |
| `ui.backstage` (via `engine.execute`) | `{page?: home\|new\|open\|info\|saveAs\|print\|export\|options\|account\|close}` | Open the File tab's Backstage view on a page, or close it (`ui.inspect` reports the open page as `backstage`) |
| `ui.screenshot` | `{path?}` | PNG of the window (base64 when no path). Animations are settled first: the picture shows the final state. On the native app it works while hidden or occluded when the platform still reports the current content size (the request asks for a hidden paint); a minimized window has no capturable size, so its request stays queued until the window is restored and otherwise reports the 8 s timeout. In a **hidden browser tab** the web runner does not implement that paint request, so the capture waits until the tab is shown again or reports the 8 s timeout. Up to 4 outstanding requests; retained images are limited to 128 MiB each and 256 MiB in total (each request reserves the largest image the ±1-pixel rounding rule can retain), one capture's transient encoding working set to 256 MiB, a base64 reply to 64 MiB, and retained images plus one capture's encoding working set to 512 MiB; a request that would exceed a budget is refused without evicting earlier ones. See [Screenshot budgets](#screenshot-budgets). A resize/DPI/zoom/visibility/occlusion change during capture is retried within the bounded retry budget or reported instead of saving stale pixels; the identity intentionally includes zoom and occlusion even though the expected physical size may be unchanged, so a capture never crosses layout generations. Every reply — success, refusal or timeout — carries the request `token` as `request` |
+10 -3
View File
@@ -183,7 +183,10 @@ applies to as it is (#2348).
Reference boxes (RefEdit) take a range by selection on the grid: while a box is the one being
picked into, clicking or dragging on the sheet writes the absolute reference into it (with the
sheet name, quoted when it needs quotes, when the selection is on another sheet:
`'Rates Table'!$A$2:$A$4`), and typing still edits it. The box's collapse button shrinks the
`'Rates Table'!$A$2:$A$4`), and typing still edits it. A click on the cell already selected
writes it too, and so does a second quick click (it does not edit the cell), while clicking into
the box alone keeps the box's text. While a box picks, a click on a link or a checkbox picks its
cell, without following the link or toggling the checkbox. The box's collapse button shrinks the
dialog to the box while the person selects; Escape, Enter or the expand button restore it (Enter
there does not press OK, which is not shown while collapsed). Cancel, Escape, a ribbon command
that opens another dialog over it, and a scripted `ui.dialog.cancel` put the sheet and selection
@@ -209,7 +212,11 @@ the constraint value once the operator is int or bin) is no longer picked into:
made meanwhile does not land in it when it is shown again.
The dialog follows the sheet it was opened on by the sheet's identity while the app runs
(fresh for every sheet added, inserted, copied or read from a file; kept through renames,
moves, undo and redo), wherever it is moved and whatever it is renamed to. A sheet deleted and
moves, undo and redo), wherever it is moved and whatever it is renamed to. The sheet is
recorded when the dialog opens (from the ribbon or a script): a sheet shown before any box
picks is not the dialog's, so picks there carry its name, and OK or Cancel goes back to the
sheet the dialog was opened on and its selection; a Data Analysis tool chosen there takes that
sheet's selection with its name. A sheet deleted and
another inserted in its place or given its own name, or a copy of it, even in one script batch
or one undo step, is not taken for it: OK is refused and Cancel leaves the selection where it
is; an undo that brings the sheet back makes it the dialog's again. The collapse button is
@@ -256,7 +263,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12.
| 2026-11-03 | minor | Copy puts a cell that holds a quote on the clipboard as it is (`say "hi"`), quoting only text with a tab or line break, as Excel does; pasting text reads a field as quoted only when its closing quote ends it, so `"hi" there` pastes with its quotes (#2225) |
| 2026-11-03 | minor | Ctrl+. (Control+. on a Mac): Next Corner of Selection, `selection.nextCorner`: the active cell moves clockwise to the next corner of the selected area, which stays selected, and becomes the anchor, as in Excel (#1442) |
| 2026-11-02 | minor | Dragging the selection's border onto cells that hold data asks "There's already data here. Do you want to replace it?" (OK replaces, Cancel leaves the sheet and clipboard as they were) through the new `range.move` command (#3136); the row no longer claims Ctrl/Option copy and Shift insert for the border drag, which are not wired yet |
| 2026-11-02 | minor | RefEdit reference boxes in dialogs: pick a range on the grid, collapse the dialog while selecting; Goal Seek, Solver (with the Model Range of Load/Save) and Data Analysis use them; while collapsed the arrow keys move the selection and Shift+arrow extends it, F4 cycles a reference's anchors, a click elsewhere keeps the box picking, a dialog opened over a picking one puts the selection back, OK stays on the sheet its command showed, and scripted cancel and confirm end a pick like the buttons; the dialog's sheet is followed through the recorded undo steps, so a sheet deleted and recreated under its name is not taken for it and an undo of the deletion brings it back; a box the dialog stops showing is no longer picked into |
| 2026-11-02 | minor | RefEdit reference boxes in dialogs: pick a range on the grid, collapse the dialog while selecting; Goal Seek, Solver (with the Model Range of Load/Save) and Data Analysis use them; while collapsed the arrow keys move the selection and Shift+arrow extends it, F4 cycles a reference's anchors, a click elsewhere keeps the box picking, a dialog opened over a picking one puts the selection back, OK stays on the sheet its command showed, and scripted cancel and confirm end a pick like the buttons; the dialog's sheet is followed through the recorded undo steps, so a sheet deleted and recreated under its name is not taken for it and an undo of the deletion brings it back; a box the dialog stops showing is no longer picked into; the dialog's sheet is recorded when it opens, not at its first pick |
| 2026-11-02 | minor | Python mode edits a cell's code in the formula bar's code editor (Ctrl+Alt+Shift+P, `=PY` then Tab, editing a Python cell), with the PY badge beside fx; its keys apply in input order, also several in one frame, and Ctrl+Enter commits through `python.set` (empty code clears the cell), keeping the code and showing the command's message when the code is refused |
| 2026-11-02 | minor | Insert › Symbols: Equation split button (access keys N E), a gallery of examples, and an equation editor with quick-insert buttons, the error underlined at its position and a live preview; double-clicking an equation edits it |
| 2026-11-02 | minor | Floating objects: one selection for charts, pictures, shapes and slicers, eight resize handles with matching cursors (right-to-left sheets included), Escape drops the selection |
+80
View File
@@ -1,5 +1,6 @@
# Hosting GridCraft for the web
`gridcraft-web-<version>.zip` (from the GitHub release, or `packaging/web/package.sh`) holds a
static site in `gridcraft-web-<version>/`:
@@ -77,6 +78,85 @@ Local test: `python3 -m http.server 8765` inside the folder, then open http://lo
one. Use them when the host window already shows the file name and owns the files, so the sheet's
own Open or Save As cannot replace what the host thinks is open. Everything stays on by default.
### The parent bridge (`?host=parent`)
A page framing GridCraft in a **same-origin** iframe can add `?host=parent` to the iframe's URL
(`https://example.com/gridcraft/?host=parent`) and have GridCraft hand its files over instead of
downloading them. The two sides talk `postMessage` with plain window messages that carry a
`type`:
| Message | Direction | Fields |
|---|---|---|
| `gridcraft:ready` | app → page | `version`, `appVersion`: the app is listening; send a file now |
| `gridcraft:open` | page → app | `name`, `bytes` (an `ArrayBuffer` or `Uint8Array`): open like File › Open |
| `gridcraft:save` | app → page | `name`, `bytes` (a transferred `ArrayBuffer`): every file the app would otherwise download (Save, Save As, Export as PDF). The page stores it and reports failures itself. |
| `gridcraft:dirty` | app → page | `dirty`: unsaved changes appeared or went away |
| `gridcraft:failed` | app → page | `error`: the app couldn't start |
In host mode the app starts with an empty workbook instead of a sample, so the framing page
should guard leaving the page while changes are unsaved. `&author=` names the user for the
notes and comments the app creates. Opening `?host=parent` outside an iframe gives the normal
app with downloads.
### Embedding without an iframe (page host mode)
A page that loads the GridCraft module into its own document — its own `<canvas
id="gridcraft_canvas">` inside a component, sized to a box beside the page's own header and
controls — opts in with `data-host="page"` on that canvas. The behaviour is the same as the
parent bridge, except the transport is DOM `CustomEvent`s on the canvas instead of
`postMessage`: the app dispatches `gridcraft:ready`, `gridcraft:save`, `gridcraft:dirty` and
`gridcraft:failed` on the canvas with the message fields above as the event's `detail`, and the
page dispatches `gridcraft:open` on the canvas with `detail: { name, bytes }` to open a
workbook. Events don't bubble, so two editors on one page can't hear each other; listen on the
canvas itself.
```html
<canvas id="gridcraft_canvas" data-host="page" style="width: 100%; height: 600px; border: 0;"></canvas>
```
```js
import init from "./gridcraft-web-<hash>.js"; // the wasm-bindgen glue shipped with the app
// Listen first: the app announces itself while `init` runs.
const canvas = document.getElementById("gridcraft_canvas");
canvas.addEventListener("gridcraft:ready", async () => {
// Open a workbook of the page's own, as with File › Open.
const bytes = await workbookBytes(); // an ArrayBuffer or Uint8Array
canvas.dispatchEvent(new CustomEvent("gridcraft:open", {
detail: { name: "Budget.xlsx", bytes },
}));
});
// Every file GridCraft would otherwise download (Save, Save As, Export as PDF) arrives here.
canvas.addEventListener("gridcraft:save", (event) => {
const { name, bytes } = event.detail; // bytes is an ArrayBuffer
storeFile(name, bytes);
});
canvas.addEventListener("gridcraft:dirty", (event) => {
warnBeforeLeaving(event.detail.dirty);
});
canvas.addEventListener("gridcraft:failed", (event) => {
showError(event.detail.error);
});
await init({ module_or_path: "./gridcraft-web-<hash>_bg.wasm" });
```
As with the parent bridge, the app starts with an empty workbook instead of a sample, and
`?author=` on the page's URL names the user for new notes and comments. The `data-host` value
must be exactly `page`; anything else is ignored, and the app runs as a standalone page with
downloads.
The messages and both transports are the shared contract of [`craft-web-host`](https://github.com/storytold/craft-libs/tree/main/crates/web-host) (storytold/craft-libs#420), the same for every Craft app: its README lists every message, including `saved`, with the compatibility notes. In `gridcraft:ready`, `version` is the contract's (`"1"`) and `appVersion` is this app's release. GridCraft reads bytes, so a host sends `bytes`, not a `File`.
### Trust and limits
- **Who is trusted.** In page host mode the page and GridCraft share one document, so any script on the page can drive the app: open files in it and receive what it saves. Use this mode only for a page you control. The iframe mode (`?host=parent`) answers only the parent window, on the page's own origin.
- **Files from the host are untrusted input.** An opened file goes through the same reading as one picked with File ▸ Open or dropped on the window, with the same format limits; before it is copied into the app's memory, its size is checked against the host open limit (512 MiB) and a larger file is refused, and a name is cut to 255 characters.
- **The event and message names are the interface.** `gridcraft:ready`, `gridcraft:open`, `gridcraft:save`, `gridcraft:dirty` and `gridcraft:failed`, and the fields they carry (the same as the `?host=parent` messages), stay stable; changing one is a breaking change.
## Renderer selection
GridCraft's web build is the same egui app as the desktop one, compiled to wasm