mirror of
https://github.com/storytold/gridcraft.git
synced 2026-10-24 10:46:50 -06:00
Integration: track t11
# Conflicts: # ROADMAP.md # apps/gridcraft-web/Cargo.toml # crates/ui-egui/src/i18n.rs # docs/control-protocol.md # docs/gaps.md # docs/target-app-parity.md # docs/ui-parity.md
This commit is contained in:
@@ -139,9 +139,12 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
|
||||
| 2026-10-20 | Japanese interface catalog covers every key the Brazilian Portuguese catalog translates: the 398-label shared core plus 545 dialog, message, Backstage and menu strings (943 total, ~43% of ~2,200). A test parses `pt.rs`'s source, so the two catalogs stay in step |
|
||||
| 2026-10-20 | Interface translated into Dutch (518 labels: ribbon, context menus, File view, dialog titles, Options); `nl`, `nl-NL`, `nl-BE` locale tags resolve to Dutch |
|
||||
| 2026-10-19 | Long flat operator chains (`=A1+A2+…`) up to Excel's 8,192-character limit (4,096 operands): entering, filling, inserting rows, undo, Evaluate Formula and XLSX save no longer overflow the stack; chains past 64 operands are saved as plain formulas, not dynamic arrays; Spanish formula entry accepts chains past 256 operands (#916) |
|
||||
| 2026-10-19 | Linux: "Use system setting" for the theme keeps following the desktop when the XDG Desktop Portal stops answering: each read gives up after 2 s and the next poll reconnects, where one unanswered call stalled the watcher for the rest of the session |
|
||||
| 2026-10-16 | Memory: undo steps share a sheet's picture bytes and the workbook's VBA project instead of copying them: 50 edits on a sheet with a 20 MB picture keep 22 MB instead of 992 MB |
|
||||
| 2026-10-16 | Memory: floating pictures are decoded at about the size they are drawn, with limits, into a cache bounded by bytes and dropped with their workbook, instead of at full size into one that never let go; two open workbooks no longer show each other's picture. 12 large pictures scrolled through keep 40 MB of textures instead of 258 MB, and none once the workbook closes |
|
||||
| 2026-10-16 | The web app keeps its preferences between visits (#1562): theme (dark mode), interface size, language, editing options and the rest of what the desktop app saves in `ui.json` and `prefs.json` are kept in the browser's `localStorage`, written as soon as they change |
|
||||
| 2026-10-15 | Function help follows the interface language: argument hints, autocomplete, Insert Function (search and readable categories) and the Formulas-tab menus read per-language tables of descriptions and argument names; Spanish keeps its 10 functions in the new format |
|
||||
| 2026-10-15 | Row auto-fit counts a cell's line breaks only when the cell wraps, as Excel does: a file whose unwrapped cells hold multi-line text no longer opens with tall rows (typing a break still turns wrap on and grows the row) |
|
||||
| 2026-10-14 | Protect Sheet on a protected sheet: Home › Format, its keytips (HOP, RPS) and the tab menu now read Unprotect Sheet… and unprotect (straight away without a password); a protected sheet that forbids selecting locked (or unlocked) cells is enforced for clicks, arrows and Go To, and the Protect Sheet dialog has the select options (#1346) |
|
||||
| 2026-10-14 | XLSX: formulas opened with dynamic-array metadata (`cm`) keep it on save, with `xl/metadata.xml`. Before, a formula returning one value lost it (11 of 40 corpus files that have the part lost it entirely), and Excel recalculated some of them with implicit intersection (`SUMPRODUCT(IFERROR(...))` changed value) |
|
||||
| 2026-10-13 | Formula-entry Command/Ctrl-click inserts another reference with the locale's argument separator; Shift-click extends from a stable, sheet-aware anchor. Pointer tests cover repeated clicks, reversed ranges, ordinary replacement, typed separators, Spanish syntax, sheet switches and keyboard continuation. Readiness estimates unchanged. |
|
||||
@@ -216,8 +219,10 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
|
||||
| 2026-10-21 | minor | Page Setup paper sizes: JIS B4/B5/B6 (ISO by name), Japanese postcards and envelopes, A6; PDF page size follows, XLSX round-trips |
|
||||
| 2026-10-20 | minor | Dutch (nl) listed under other shipped languages; progress log row |
|
||||
| 2026-10-19 | minor | Progress log: long flat operator chains (#916) |
|
||||
| 2026-10-19 | trivial | Progress log: desktop theme portal read timeout |
|
||||
| 2026-10-16 | minor | Progress log: undo steps share picture and macro bytes |
|
||||
| 2026-10-16 | minor | Progress log: floating pictures' textures bounded and kept per workbook |
|
||||
| 2026-10-16 | trivial | Progress log: web preferences kept between visits (#1562) |
|
||||
| 2026-10-14 | minor | XLSX dynamic-array `cm` metadata kept on save; progress log row |
|
||||
| 2026-10-13 | trivial | Progress log: More Colors… and Recent Colors in the colour menus (#928) |
|
||||
| 2026-10-13 | minor | Multi-level category axes (read, drawn, written); progress log row |
|
||||
|
||||
@@ -26,4 +26,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"] }
|
||||
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"] }
|
||||
|
||||
@@ -11,11 +11,14 @@
|
||||
//! 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.
|
||||
//! notes and comments. 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 prefs;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod web;
|
||||
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
//! Preferences between visits (#1562): what the desktop app keeps in `ui.json` (theme, interface
|
||||
//! size, language, ribbon state…) and `prefs.json` (editing options, user name…), kept in the
|
||||
//! browser's `localStorage` under [`UI_KEY`] and [`PREFS_KEY`]. Restored at startup; written back
|
||||
//! whenever they change, since a reload or a closed tab runs no exit code.
|
||||
|
||||
use gridcraft_ui_egui::SheetApp;
|
||||
|
||||
/// The `localStorage` key for the desktop's `ui.json`.
|
||||
pub const UI_KEY: &str = "gridcraft.ui";
|
||||
/// The `localStorage` key for the desktop's `prefs.json`.
|
||||
pub const PREFS_KEY: &str = "gridcraft.prefs";
|
||||
/// Largest stored value read back (`localStorage` holds about 5 MB per site).
|
||||
const MAX_STORED: usize = 1 << 20;
|
||||
|
||||
/// Apply preferences stored on an earlier visit, as the desktop app reads `ui.json` and
|
||||
/// `prefs.json`. Missing, oversized or unreadable values leave what the app starts with.
|
||||
pub fn restore(app: &mut SheetApp, ui: Option<&str>, prefs: Option<&str>) {
|
||||
let readable = |s: &&str| s.len() <= MAX_STORED;
|
||||
if let Some(s) = ui.filter(readable)
|
||||
&& let Ok(json) = serde_json::from_str::<serde_json::Value>(s)
|
||||
&& let Ok(state) = serde_json::from_value::<gridcraft_ui_egui::UiState>(json.clone())
|
||||
{
|
||||
// No usable saved language: keep the browser's, as the desktop keeps the system's.
|
||||
let language = gridcraft_ui_egui::i18n::saved_language(&json).unwrap_or(app.ui.language);
|
||||
app.ui = state;
|
||||
app.ui.language = language;
|
||||
}
|
||||
if let Some(s) = prefs.filter(readable)
|
||||
&& let Ok(p) = serde_json::from_str(s)
|
||||
{
|
||||
app.session.prefs = p;
|
||||
}
|
||||
}
|
||||
|
||||
/// Tracks what was last written, so the store is written only when a preference changed.
|
||||
#[derive(Default)]
|
||||
pub struct PrefsSync {
|
||||
ui: Option<String>,
|
||||
prefs: Option<String>,
|
||||
/// The user name to store in place of the session's (see [`Self::keep_user_name`]).
|
||||
user_name: Option<String>,
|
||||
}
|
||||
|
||||
impl PrefsSync {
|
||||
/// Starts from what is already stored, so restoring doesn't write it straight back.
|
||||
pub fn new(ui: Option<String>, prefs: Option<String>) -> Self {
|
||||
Self { ui, prefs, user_name: None }
|
||||
}
|
||||
|
||||
/// Stores `name` as the user name whatever the session's becomes: under `?host=parent` the
|
||||
/// framing page names the author for this visit only, and must not replace the user's own.
|
||||
pub fn keep_user_name(&mut self, name: String) {
|
||||
self.user_name = Some(name);
|
||||
}
|
||||
|
||||
/// The `(key, JSON)` pairs to store because they differ from what was last written.
|
||||
pub fn changed(&mut self, app: &SheetApp) -> Vec<(&'static str, String)> {
|
||||
let mut out = Vec::new();
|
||||
let prefs = match &self.user_name {
|
||||
Some(name) => serde_json::to_string(&gridcraft_engine::Prefs { user_name: name.clone(), ..app.session.prefs.clone() }),
|
||||
None => serde_json::to_string(&app.session.prefs),
|
||||
};
|
||||
for (key, last, json) in [(UI_KEY, &mut self.ui, serde_json::to_string(&app.ui)), (PREFS_KEY, &mut self.prefs, prefs)] {
|
||||
match json {
|
||||
Ok(json) if last.as_deref() != Some(json.as_str()) => {
|
||||
*last = Some(json.clone());
|
||||
out.push((key, json));
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(e) => log::warn!("preferences: cannot serialize {key}: {e}"),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use gridcraft_engine::Session;
|
||||
use gridcraft_ui_egui::Services;
|
||||
use gridcraft_ui_egui::i18n::Language;
|
||||
use serde_json::json;
|
||||
|
||||
fn app() -> SheetApp {
|
||||
let mut app = SheetApp::new(Session::new(), Services::default());
|
||||
// As `web::start` sets it up before restoring.
|
||||
app.ui.system_theme = true;
|
||||
app.ui.language = Language::De;
|
||||
app
|
||||
}
|
||||
|
||||
fn stored(writes: &[(&'static str, String)], key: &str) -> Option<String> {
|
||||
writes.iter().find(|(k, _)| *k == key).map(|(_, v)| v.clone())
|
||||
}
|
||||
|
||||
/// #1562: dark mode chosen on one visit is there after a reload, with the editing options.
|
||||
#[test]
|
||||
fn dark_mode_survives_reload() {
|
||||
let mut first = app();
|
||||
let mut sync = PrefsSync::new(None, None);
|
||||
assert_eq!(sync.changed(&first).len(), 2, "first frame stores the preferences");
|
||||
assert!(sync.changed(&first).is_empty(), "unchanged preferences are not rewritten");
|
||||
first.run("view.theme", json!({"mode": "dark"})).unwrap();
|
||||
first.session.prefs.enter_direction = "right".into();
|
||||
let writes = sync.changed(&first);
|
||||
let (ui, prefs) = (stored(&writes, UI_KEY), stored(&writes, PREFS_KEY));
|
||||
assert!(ui.is_some() && prefs.is_some(), "both changes are stored: {writes:?}");
|
||||
|
||||
let mut reloaded = app();
|
||||
restore(&mut reloaded, ui.as_deref(), prefs.as_deref());
|
||||
assert!(reloaded.ui.dark && !reloaded.ui.system_theme, "dark mode is back");
|
||||
assert_eq!(reloaded.session.prefs.enter_direction, "right");
|
||||
assert!(PrefsSync::new(ui, prefs).changed(&reloaded).is_empty(), "restoring doesn't write back");
|
||||
}
|
||||
|
||||
/// `?host=parent`: the author the framing page names is not stored over the user's own name.
|
||||
#[test]
|
||||
fn host_author_is_not_stored() {
|
||||
let mut first = app();
|
||||
first.session.prefs.user_name = "Ada".into();
|
||||
let mut sync = PrefsSync::new(None, None);
|
||||
let stored_prefs = stored(&sync.changed(&first), PREFS_KEY);
|
||||
|
||||
let mut hosted = app();
|
||||
restore(&mut hosted, None, stored_prefs.as_deref());
|
||||
let mut sync = PrefsSync::new(None, stored_prefs);
|
||||
sync.keep_user_name(hosted.session.prefs.user_name.clone());
|
||||
hosted.session.prefs.user_name = "Host Author".into();
|
||||
hosted.session.prefs.r1c1 = true;
|
||||
let writes = sync.changed(&hosted);
|
||||
let prefs = stored(&writes, PREFS_KEY).expect("other preferences are still stored");
|
||||
|
||||
let mut reloaded = app();
|
||||
restore(&mut reloaded, None, Some(&prefs));
|
||||
assert_eq!(reloaded.session.prefs.user_name, "Ada");
|
||||
assert!(reloaded.session.prefs.r1c1);
|
||||
}
|
||||
|
||||
/// A stored `ui` without a language keeps the browser's, as the desktop keeps the system's.
|
||||
#[test]
|
||||
fn missing_language_keeps_the_browsers() {
|
||||
let mut a = app();
|
||||
restore(&mut a, Some(r#"{"dark":true}"#), None);
|
||||
assert!(a.ui.dark);
|
||||
assert_eq!(a.ui.language, Language::De);
|
||||
restore(&mut a, Some(r#"{"language":"fr"}"#), None);
|
||||
assert_eq!(a.ui.language, Language::Fr);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn junk_storage_keeps_defaults() {
|
||||
let big = format!("{{\"dark\":true,\"recent\":[\"{}\"]}}", "x".repeat(MAX_STORED));
|
||||
for junk in [None, Some(""), Some("not json"), Some("[1,2]"), Some("{\"dark\":42}"), Some(big.as_str())] {
|
||||
let mut a = app();
|
||||
restore(&mut a, junk, junk);
|
||||
assert!(a.ui.system_theme && !a.ui.dark, "{junk:?}");
|
||||
assert_eq!(a.ui.language, Language::De);
|
||||
assert_eq!(a.session.prefs.enter_direction, "down");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -69,14 +69,20 @@ pub fn start() {
|
||||
) {
|
||||
app.ui.language = browser;
|
||||
}
|
||||
// Preferences from an earlier visit (#1562), as the desktop app reads `ui.json`
|
||||
// and `prefs.json`; a saved language replaces the browser's.
|
||||
let (stored_ui, stored_prefs) = (stored(crate::prefs::UI_KEY), stored(crate::prefs::PREFS_KEY));
|
||||
crate::prefs::restore(&mut app, stored_ui.as_deref(), stored_prefs.as_deref());
|
||||
let mut prefs = crate::prefs::PrefsSync::new(stored_ui, stored_prefs);
|
||||
SheetApp::setup_context_for_language(&cc.egui_ctx, false, app.ui.language);
|
||||
if let Some(bridge) = &app_bridge {
|
||||
prefs.keep_user_name(app.session.prefs.user_name.clone());
|
||||
if let Some(author) = query_param("author") {
|
||||
crate::host::Bridge::set_author(&mut app, &author);
|
||||
}
|
||||
bridge.post_ready();
|
||||
}
|
||||
Ok(Box::new(WebShell { app, inbox, bridge: app_bridge }))
|
||||
Ok(Box::new(WebShell { app, inbox, bridge: app_bridge, prefs }))
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
@@ -104,6 +110,16 @@ struct WebShell {
|
||||
app: SheetApp,
|
||||
inbox: Inbox,
|
||||
bridge: Option<crate::host::Bridge>,
|
||||
prefs: crate::prefs::PrefsSync,
|
||||
}
|
||||
|
||||
fn local_storage() -> Option<web_sys::Storage> {
|
||||
web_sys::window()?.local_storage().ok()?
|
||||
}
|
||||
|
||||
/// A value stored on an earlier visit, if the browser allows storage.
|
||||
fn stored(key: &str) -> Option<String> {
|
||||
local_storage()?.get_item(key).ok()?
|
||||
}
|
||||
|
||||
impl eframe::App for WebShell {
|
||||
@@ -124,6 +140,20 @@ impl eframe::App for WebShell {
|
||||
});
|
||||
}
|
||||
self.app.logic(ctx);
|
||||
// Store changed preferences straight away: a reload or closed tab runs no exit code (#1562).
|
||||
let changed = self.prefs.changed(&self.app);
|
||||
if !changed.is_empty() {
|
||||
match local_storage() {
|
||||
Some(store) => {
|
||||
for (key, json) in changed {
|
||||
if let Err(e) = store.set_item(key, &json) {
|
||||
log::warn!("preferences: cannot save {key}: {e:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
None => log::warn!("preferences: browser storage is unavailable"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||
|
||||
@@ -782,15 +782,24 @@ fn forecast_grid(x: &[f64], f: &[Option<f64>], se_window: usize, p: &Json) -> Gr
|
||||
g.put(idx(i), 0, fi.map(Value::number).unwrap_or_else(na));
|
||||
}
|
||||
if flag(p, "standardErrors") {
|
||||
// A running sum of squared residuals over the last `se_window` points: O(n), not
|
||||
// O(n·window). A point without a forecast restarts it, so every window touching it is #N/A.
|
||||
let sq = |j: usize| Some((x.get(j)? - (*f.get(j)?)?).powi(2));
|
||||
let (mut acc, mut run) = (0.0, 0usize);
|
||||
for i in 0..f.len() {
|
||||
let start = (i + 1).checked_sub(se_window);
|
||||
let v = start.and_then(|s0| {
|
||||
let mut acc = 0.0;
|
||||
for j in s0..=i {
|
||||
acc += (x.get(j)? - (*f.get(j)?)?).powi(2);
|
||||
match sq(i) {
|
||||
Some(t) => {
|
||||
acc += t;
|
||||
run += 1;
|
||||
}
|
||||
Some((acc / se_window as f64).sqrt())
|
||||
});
|
||||
None => (acc, run) = (0.0, 0),
|
||||
}
|
||||
if run > se_window {
|
||||
acc -= i.checked_sub(se_window).and_then(sq).unwrap_or(0.0);
|
||||
run = se_window;
|
||||
}
|
||||
// Rounding in the subtraction can leave a tiny negative sum.
|
||||
let v = (run == se_window).then(|| (acc.max(0.0) / se_window as f64).sqrt());
|
||||
g.put(idx(i), 1, v.map(Value::number).unwrap_or_else(na));
|
||||
}
|
||||
}
|
||||
@@ -1190,6 +1199,37 @@ mod tests {
|
||||
x
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forecast_standard_errors_match_the_full_window_rescan() {
|
||||
// #1043: the running sum must give what re-adding every window gave, gaps included.
|
||||
let x: [f64; 11] = [2.0, 4.0, 5.0, 4.0, 5.0, 7.0, 1.0, 3.0, 8.0, 6.0, 2.0];
|
||||
let f = [None, Some(3.0), Some(4.5), None, Some(4.0), Some(6.5), Some(2.0), Some(2.5), Some(7.0), Some(5.0), Some(4.0)];
|
||||
let rescan = |i: usize, w: usize| -> Value {
|
||||
let v = (i + 1).checked_sub(w).and_then(|s0| {
|
||||
let mut acc = 0.0;
|
||||
for j in s0..=i {
|
||||
acc += (x.get(j)? - (*f.get(j)?)?).powi(2);
|
||||
}
|
||||
Some((acc / w as f64).sqrt())
|
||||
});
|
||||
v.map(Value::number).unwrap_or(Value::Error(gridcraft_core::CellError::NA))
|
||||
};
|
||||
for w in 1..=5 {
|
||||
let g = super::forecast_grid(&x, &f, w, &json!({"standardErrors": true}));
|
||||
for i in 0..f.len() {
|
||||
let (got, want) = (g.cells.get(&(i as u32, 1)).cloned().unwrap_or(Value::Empty), rescan(i, w));
|
||||
match (got.as_f64(), want.as_f64()) {
|
||||
(Some(a), Some(b)) => assert!(close(a, b, 1e-12), "window {w}, row {i}: {a} vs {b}"),
|
||||
_ => assert_eq!(got, want, "window {w}, row {i}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
// Row 3 has no forecast: windows through it are #N/A, the next full window after it is not.
|
||||
let g = super::forecast_grid(&x, &f, 3, &json!({"standardErrors": true}));
|
||||
assert_eq!(g.cells.get(&(5, 1)), Some(&Value::Error(gridcraft_core::CellError::NA)));
|
||||
assert!(g.cells.get(&(6, 1)).and_then(Value::as_f64).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn descriptive_statistics_of_one_to_five() {
|
||||
// 1..5: mean 3, s = √2.5, kurtosis −1.2, skewness 0 (textbook values).
|
||||
|
||||
@@ -464,18 +464,53 @@ fn same_content(a: Option<&gridcraft_model::Cell>, b: Option<&gridcraft_model::C
|
||||
}
|
||||
}
|
||||
|
||||
/// Recomputes an automatic row height from its content: the largest font, and wrapped or
|
||||
/// multi-line text. Rows with a user-set height keep it.
|
||||
/// Lines a single text line needs when word-wrapped into `width` points. The grid wraps whole
|
||||
/// words (egui's LayoutJob), so dividing the total width by the column width undercounts: each
|
||||
/// drawn line ends early at the last word that fits. A word wider than the column breaks
|
||||
/// mid-word, the way egui does. (#1969)
|
||||
fn wrapped_line_count(line: &str, width: f32, size: f32, bold: bool) -> f32 {
|
||||
let word_w = |w: &str| crate::cmd::format::approx_text_width(w, size, bold);
|
||||
let space_w = word_w(" ");
|
||||
let mut lines = 1.0f32;
|
||||
let mut used = 0.0f32;
|
||||
for word in line.split_whitespace() {
|
||||
let w = word_w(word);
|
||||
if w > width {
|
||||
// wider than the column: egui fills the current line, then breaks it across lines
|
||||
if used > 0.0 {
|
||||
lines += 1.0;
|
||||
}
|
||||
let chunks = (w / width).ceil();
|
||||
lines += chunks - 1.0;
|
||||
used = w - (chunks - 1.0) * width;
|
||||
continue;
|
||||
}
|
||||
let gap = if used > 0.0 { space_w } else { 0.0 };
|
||||
if used + gap + w > width {
|
||||
lines += 1.0;
|
||||
used = w;
|
||||
} else {
|
||||
used += gap + w;
|
||||
}
|
||||
}
|
||||
lines
|
||||
}
|
||||
|
||||
/// Recomputes an automatic row height from its content: the largest font, and wrapped text.
|
||||
/// Rows with a user-set height keep it.
|
||||
///
|
||||
/// Line breaks in a cell's text count only when the cell wraps, as in Excel: with Wrap Text off
|
||||
/// a multi-line value shows on one line and AutoFit gives a one-line row.
|
||||
pub fn auto_row_height(wb: &mut Workbook, si: usize, row: u32) {
|
||||
let Some(sh) = wb.sheet(si) else { return };
|
||||
if sh.rows.get(&row).is_some_and(|i| i.custom) {
|
||||
return;
|
||||
}
|
||||
let default = sh.default_row_height;
|
||||
// Fast path: rows of default-size, unwrapped, single-line content keep the default height.
|
||||
// Fast path: rows of default-size, unwrapped content keep the default height.
|
||||
let plain = sh.cells.row(row, 0, gridcraft_core::MAX_COLS - 1).all(|(_, cell)| {
|
||||
let st = wb.styles.get(cell.style);
|
||||
st.font.size <= gridcraft_model::DEFAULT_FONT_SIZE && !st.align.wrap && !cell.value.as_text().is_some_and(|t| t.contains('\n'))
|
||||
st.font.size <= gridcraft_model::DEFAULT_FONT_SIZE && !st.align.wrap
|
||||
});
|
||||
if plain {
|
||||
if sh.rows.get(&row).is_some_and(|i| i.size.is_some())
|
||||
@@ -493,22 +528,24 @@ pub fn auto_row_height(wb: &mut Workbook, si: usize, row: u32) {
|
||||
}
|
||||
let mut need: f32 = default;
|
||||
for (c, cell) in sh.cells.row(row, 0, gridcraft_core::MAX_COLS - 1) {
|
||||
// Merged cells never auto-fit their rows, like Excel (column autofit skips them too).
|
||||
if sh.merge_at(CellRef::new(row, c)).is_some_and(|m| !m.is_single()) {
|
||||
continue;
|
||||
}
|
||||
let st = wb.styles.get(cell.style);
|
||||
let size = st.font.size;
|
||||
let line = (size * 1.8).round().max(default * size / gridcraft_model::DEFAULT_FONT_SIZE);
|
||||
// Scaled from an 11pt row's height, not the sheet's default: a sheet whose default
|
||||
// already fits a bigger font (21pt rows for 16pt text) would otherwise scale it twice.
|
||||
let line = (size * 1.8).round().max(gridcraft_model::DEFAULT_ROW_HEIGHT * size / gridcraft_model::DEFAULT_FONT_SIZE);
|
||||
let mut lines = 1.0f32;
|
||||
if !cell.value.is_empty() || cell.formula.is_some() {
|
||||
let text = crate::display::cell_text(wb, sh, CellRef::new(row, c));
|
||||
let explicit = text.lines().count().max(1) as f32;
|
||||
lines = if st.align.wrap {
|
||||
// Only a wrapping cell is laid out over several lines: each explicit line break
|
||||
// starts one, and each line folds at the column width.
|
||||
if st.align.wrap {
|
||||
let text = crate::display::cell_text(wb, sh, CellRef::new(row, c));
|
||||
let w = sh.col_width(c).max(1.0) - 6.0;
|
||||
text.lines()
|
||||
.map(|l| (crate::cmd::format::approx_text_width(l, size, st.font.bold) / w.max(1.0)).ceil().max(1.0))
|
||||
.sum::<f32>()
|
||||
.max(1.0)
|
||||
} else {
|
||||
explicit
|
||||
};
|
||||
lines = text.lines().map(|l| wrapped_line_count(l, w.max(1.0), size, st.font.bold)).sum::<f32>().max(1.0);
|
||||
}
|
||||
} else if st.font.size <= gridcraft_model::DEFAULT_FONT_SIZE {
|
||||
continue;
|
||||
}
|
||||
|
||||
+201
-56
@@ -1863,13 +1863,11 @@ fn limits_report(d: &DocState, sh: &Sheet, inp: &ReportInput) -> Result<Lines> {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Writes the reports on new sheets ("Answer Report 1"…) before the model's sheet, in one step;
|
||||
/// the model's sheet stays active. Returns the new sheets' names.
|
||||
fn write_reports(s: &mut Session, kinds: &[ReportKind], inp: &ReportInput) -> Result<Vec<String>> {
|
||||
/// Builds every report before the solution or saved model can change.
|
||||
fn prepare_reports(d: &DocState, kinds: &[ReportKind], inp: &ReportInput) -> Result<Vec<(String, Lines)>> {
|
||||
if kinds.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let d = s.doc()?;
|
||||
if d.wb.protected_structure {
|
||||
return Err(EngineError::Other("The workbook is protected and cannot be changed.".into()));
|
||||
}
|
||||
@@ -1886,41 +1884,48 @@ fn write_reports(s: &mut Session, kinds: &[ReportKind], inp: &ReportInput) -> Re
|
||||
.ok_or_else(|| EngineError::Other("Too many report sheets.".into()))?;
|
||||
built.push((name, lines));
|
||||
}
|
||||
Ok(built)
|
||||
}
|
||||
|
||||
/// Inserts prepared reports before the model's sheet, which stays active.
|
||||
fn write_reports(cx: &mut Ctx, at: usize, built: Vec<(String, Lines)>) -> Vec<String> {
|
||||
let names: Vec<String> = built.iter().map(|(n, _)| n.clone()).collect();
|
||||
let at = inp.model.sheet;
|
||||
let count = built.len();
|
||||
edit(s, |cx| {
|
||||
let bold = cx.wb.styles.derive(gridcraft_model::StyleId::DEFAULT, |st| st.font.bold = true);
|
||||
for (i, (name, lines)) in built.into_iter().enumerate() {
|
||||
let mut sheet = Sheet::new(name);
|
||||
for (r, c, v, b) in lines {
|
||||
let style = if b { bold } else { gridcraft_model::StyleId::DEFAULT };
|
||||
sheet.set_cell(CellRef::new(r, c), Cell { value: v, formula: None, style });
|
||||
}
|
||||
for (c, w) in [(0u32, 14.0f32), (1, 110.0), (2, 140.0), (3, 120.0), (4, 120.0), (5, 120.0), (6, 120.0), (7, 120.0)] {
|
||||
sheet.cols.insert(c, gridcraft_model::LineInfo { size: Some(w), ..Default::default() });
|
||||
}
|
||||
cx.wb.sheets.insert(at + i, std::sync::Arc::new(sheet));
|
||||
if count == 0 {
|
||||
return names;
|
||||
}
|
||||
let bold = cx.wb.styles.derive(gridcraft_model::StyleId::DEFAULT, |st| st.font.bold = true);
|
||||
for (i, (name, lines)) in built.into_iter().enumerate() {
|
||||
let mut sheet = Sheet::new(name);
|
||||
for (r, c, v, b) in lines {
|
||||
let style = if b { bold } else { gridcraft_model::StyleId::DEFAULT };
|
||||
sheet.set_cell(CellRef::new(r, c), Cell { value: v, formula: None, style });
|
||||
}
|
||||
for nm in cx.wb.names.iter_mut() {
|
||||
if let Some(sc) = nm.scope.as_mut()
|
||||
&& *sc >= at
|
||||
{
|
||||
*sc += count;
|
||||
}
|
||||
for (c, w) in [(0u32, 14.0f32), (1, 110.0), (2, 140.0), (3, 120.0), (4, 120.0), (5, 120.0), (6, 120.0), (7, 120.0)] {
|
||||
sheet.cols.insert(c, gridcraft_model::LineInfo { size: Some(w), ..Default::default() });
|
||||
}
|
||||
if cx.wb.active_sheet >= at {
|
||||
cx.wb.active_sheet += count;
|
||||
cx.wb.sheets.insert(at + i, std::sync::Arc::new(sheet));
|
||||
}
|
||||
for nm in cx.wb.names.iter_mut() {
|
||||
if let Some(sc) = nm.scope.as_mut()
|
||||
&& *sc >= at
|
||||
{
|
||||
*sc += count;
|
||||
}
|
||||
cx.structural = true;
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
if cx.wb.active_sheet >= at {
|
||||
cx.wb.active_sheet += count;
|
||||
}
|
||||
cx.structural = true;
|
||||
names
|
||||
}
|
||||
|
||||
fn insert_report_selections(s: &mut Session, at: usize, count: usize) {
|
||||
if let Ok(d) = s.doc_mut() {
|
||||
for i in 0..count {
|
||||
d.sheet_selections.insert((at + i).min(d.sheet_selections.len()), crate::Selection::default());
|
||||
}
|
||||
}
|
||||
Ok(names)
|
||||
}
|
||||
|
||||
/// `data.solverReport`: reports for a solution of the sheet's saved model.
|
||||
@@ -1973,7 +1978,9 @@ fn report(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
.map_or(DEFAULT_EVALS, |v| (v as u64).min(MAX_EVALS));
|
||||
let message = str_param(p, "message").unwrap_or("").chars().take(500).collect();
|
||||
let inp = ReportInput { model: &model, prob: &prob, original, solution, message, budget };
|
||||
let sheets = write_reports(s, &kinds, &inp)?;
|
||||
let built = prepare_reports(s.doc()?, &kinds, &inp)?;
|
||||
let sheets = edit(s, |cx| Ok(write_reports(cx, model.sheet, built)))?;
|
||||
insert_report_selections(s, model.sheet, sheets.len());
|
||||
Ok(json!({"sheets": sheets}))
|
||||
}
|
||||
|
||||
@@ -2068,41 +2075,53 @@ fn run(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
}
|
||||
let original: serde_json::Map<String, Json> = model.vars.iter().zip(&x0).map(|(c, v)| (c.a1(), json!(v))).collect();
|
||||
let changed = keep && solution.is_some();
|
||||
if changed || save {
|
||||
let sheet = model.sheet;
|
||||
let vars = model.vars.clone();
|
||||
let m = model.clone();
|
||||
edit(s, |cx| {
|
||||
if changed && let Some(x) = &solution {
|
||||
let sh = cx.sheet_mut(sheet)?;
|
||||
for (c, v) in vars.iter().zip(x) {
|
||||
let style = sh.style_id(*c);
|
||||
sh.set_cell(*c, Cell { value: Value::number(*v), formula: None, style });
|
||||
}
|
||||
for c in &vars {
|
||||
cx.touch(sheet, *c);
|
||||
}
|
||||
}
|
||||
if save {
|
||||
save_model(&mut cx.wb, &m);
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
let built = match (&reports, &solution) {
|
||||
(Some(kinds), Some(x)) => {
|
||||
let inp = ReportInput { model: &model, prob: &prob, original: x0, solution: x.clone(), message: message.to_string(), budget };
|
||||
prepare_reports(s.doc()?, kinds, &inp)?
|
||||
}
|
||||
_ => Vec::new(),
|
||||
};
|
||||
let count = built.len();
|
||||
let before = s.doc()?.wb.clone();
|
||||
let mut out = json!({
|
||||
"status": status,
|
||||
"message": message,
|
||||
"objective": objective,
|
||||
"values": values,
|
||||
"original": original,
|
||||
"model": model_params(&s.doc()?.wb, &model),
|
||||
"model": model_params(&before, &model),
|
||||
"evaluations": evaluations,
|
||||
"changed": changed,
|
||||
});
|
||||
// Reports describe a solution; they are part of the solve's undo step.
|
||||
if let (Some(kinds), Some(x)) = (reports, solution) {
|
||||
let inp = ReportInput { model: &model, prob: &prob, original: x0, solution: x, message: message.to_string(), budget };
|
||||
out["reports"] = json!(write_reports(s, &kinds, &inp)?);
|
||||
let names = if changed || save || count > 0 {
|
||||
let sheet = model.sheet;
|
||||
edit(s, |cx| {
|
||||
if changed && let Some(x) = &solution {
|
||||
let sh = cx.sheet_mut(sheet)?;
|
||||
for (c, v) in model.vars.iter().zip(x) {
|
||||
let style = sh.style_id(*c);
|
||||
sh.set_cell(*c, Cell { value: Value::number(*v), formula: None, style });
|
||||
}
|
||||
for c in &model.vars {
|
||||
cx.touch(sheet + count, *c);
|
||||
}
|
||||
}
|
||||
if save {
|
||||
save_model(&mut cx.wb, &model);
|
||||
}
|
||||
if count > 0 {
|
||||
// Inserting sheets makes this structural, so retain the variable edit's array check.
|
||||
check_arrays(&before, &cx.wb)?;
|
||||
}
|
||||
Ok(write_reports(cx, sheet, built))
|
||||
})?
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
insert_report_selections(s, model.sheet, count);
|
||||
if reports.is_some() && solution.is_some() {
|
||||
out["reports"] = json!(names);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
@@ -2390,6 +2409,132 @@ mod tests {
|
||||
assert_eq!(at(&s, "Answer Report 1", &format!("E{r}")), Value::number(2.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refused_solver_reports_preserve_the_document_and_history() {
|
||||
for (protected, method, constraints, reports, error) in [
|
||||
(true, "simplex", json!(["A1 <= 4"]), json!(["answer"]), "workbook is protected"),
|
||||
(false, "simplex", json!(["A1 <= 4", "A1 int"]), json!(["answer", "limits"]), "integer or binary"),
|
||||
(false, "evolutionary", json!(["A1 <= 4"]), json!(["answer", "limits"]), "Simplex LP and GRG"),
|
||||
] {
|
||||
for (keep, save) in [(true, true), (false, true), (true, false), (false, false)] {
|
||||
let mut s = s();
|
||||
set(&mut s, &[("A1", "1"), ("B1", "=2*A1")]);
|
||||
s.execute(
|
||||
"data.solver",
|
||||
json!({"objective": "B1", "variables": "A1", "constraints": ["A1 <= 2"], "method": "simplex", "keep": false}),
|
||||
)
|
||||
.unwrap();
|
||||
set(&mut s, &[("C1", "redo")]);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
let d = s.doc_mut().unwrap();
|
||||
std::sync::Arc::make_mut(&mut d.wb).protected_structure = protected;
|
||||
d.saved = d.wb.clone();
|
||||
let before = d.clone();
|
||||
let journal = s.journal.clone();
|
||||
let err = s
|
||||
.execute(
|
||||
"data.solver",
|
||||
json!({"objective": "B1", "variables": "A1", "constraints": constraints, "method": method,
|
||||
"reports": reports, "keep": keep, "saveModel": save}),
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(err.to_string().contains(error), "{err}");
|
||||
let d = s.doc().unwrap();
|
||||
assert!(std::sync::Arc::ptr_eq(&d.wb, &before.wb), "{method}: keep={keep}, save={save}: {err}");
|
||||
assert!(!d.is_dirty());
|
||||
assert_eq!(d.revision, before.revision);
|
||||
assert_eq!(d.selection, before.selection);
|
||||
assert_eq!(d.sheet_selections, before.sheet_selections);
|
||||
for (after, before) in [(&d.undo, &before.undo), (&d.redo, &before.redo)] {
|
||||
assert_eq!(after.len(), before.len());
|
||||
for (a, b) in after.iter().zip(before) {
|
||||
assert!(std::sync::Arc::ptr_eq(&a.wb, &b.wb));
|
||||
assert_eq!((&a.label, &a.selection), (&b.label, &b.selection));
|
||||
}
|
||||
}
|
||||
assert_eq!(s.journal, journal);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn solver_reports_and_optional_solution_and_model_share_one_undo() {
|
||||
for (keep, save) in [(true, true), (false, true), (true, false), (false, false)] {
|
||||
let mut s = s();
|
||||
set(&mut s, &[("A1", "1"), ("B1", "=2*A1")]);
|
||||
let before = s.doc().unwrap().clone();
|
||||
let out = s
|
||||
.execute(
|
||||
"data.solver",
|
||||
json!({"objective": "B1", "variables": "A1", "constraints": ["A1 <= 4"], "method": "simplex",
|
||||
"reports": ["answer"], "keep": keep, "saveModel": save}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(out["status"], "optimal");
|
||||
assert_eq!(out["reports"], json!(["Answer Report 1"]));
|
||||
assert_eq!(out["model"]["objective"], "Sheet1!B1");
|
||||
assert_eq!(n(&s, "A1"), if keep { 4.0 } else { 1.0 });
|
||||
assert_eq!(saved(&s), if save { "solver_opt" } else { "" });
|
||||
assert_eq!(s.doc().unwrap().undo.len(), before.undo.len() + 1);
|
||||
assert_eq!(s.doc().unwrap().wb.active().unwrap().name, "Sheet1");
|
||||
let r = row_of(&s, "Answer Report 1", "$A$1") + 1;
|
||||
assert_eq!(at(&s, "Answer Report 1", &format!("E{r}")), Value::number(4.0));
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert!(std::sync::Arc::ptr_eq(&s.doc().unwrap().wb, &before.wb));
|
||||
s.execute("edit.redo", json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().wb.sheets.len(), 2);
|
||||
assert_eq!(n(&s, "A1"), if keep { 4.0 } else { 1.0 });
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn solver_reports_keep_row_autofit_on_the_model_sheet() {
|
||||
let mut s = s();
|
||||
set(&mut s, &[("A1", "1"), ("B1", "=2*A1")]);
|
||||
s.execute("home.columnWidth", json!({"cols": "A:A", "width": 30})).unwrap();
|
||||
s.execute("home.wrapText", json!({"range": "A1", "on": true})).unwrap();
|
||||
let before = s.doc().unwrap().wb.active().unwrap().row_height(0);
|
||||
s.execute(
|
||||
"data.solver",
|
||||
json!({"objective": "B1", "variables": "A1", "constraints": ["A1 <= 1000000"], "method": "simplex", "reports": ["answer"]}),
|
||||
)
|
||||
.unwrap();
|
||||
let sh = s.doc().unwrap().wb.active().unwrap();
|
||||
assert_eq!(sh.name, "Sheet1");
|
||||
assert_eq!(sh.value(CellRef::new(0, 0)), Value::number(1_000_000.0));
|
||||
assert!(sh.row_height(0) > before, "the solved wrapped number must re-fit its row after the report shifts its sheet");
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().wb.active().unwrap().row_height(0), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn solver_report_insertion_keeps_variable_edit_guards() {
|
||||
for array in [false, true] {
|
||||
let mut s = s();
|
||||
set(&mut s, &[("A2", "1"), ("B1", "=2*A2")]);
|
||||
let error = if array {
|
||||
s.execute("selection.set", json!({"range": "A1:A2"})).unwrap();
|
||||
s.execute("cell.set", json!({"input": "=1", "array": true})).unwrap();
|
||||
"part of an array"
|
||||
} else {
|
||||
s.execute("review.protectSheet", json!({})).unwrap();
|
||||
"protected sheet"
|
||||
};
|
||||
let before = s.doc().unwrap().clone();
|
||||
let err = s
|
||||
.execute(
|
||||
"data.solver",
|
||||
json!({"objective": "B1", "variables": "A2", "constraints": ["A2 <= 4"], "method": "simplex", "reports": ["answer"]}),
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(err.to_string().contains(error), "{err}");
|
||||
let d = s.doc().unwrap();
|
||||
assert!(std::sync::Arc::ptr_eq(&d.wb, &before.wb));
|
||||
assert_eq!(d.undo.len(), before.undo.len());
|
||||
assert_eq!(d.sheet_selections, before.sheet_selections);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn limits_report_of_a_nonlinear_model() {
|
||||
// The largest rectangle with perimeter at most 20 (5 × 5): with the height held at 5, the
|
||||
|
||||
@@ -162,7 +162,14 @@ fn open_plain(name: &str, bytes: &[u8], csv_as_text: bool) -> Result<(Workbook,
|
||||
if wb.sheets.is_empty() {
|
||||
wb = Workbook::new();
|
||||
}
|
||||
Ok((wb, vec![]))
|
||||
// The file's band placement and cell count are derived state: rebuild them from the
|
||||
// cells themselves, dropping what is outside the sheet grid (#1008).
|
||||
let dropped: usize = wb.sheets.iter_mut().map(|sh| std::sync::Arc::make_mut(sh).cells.rebuild()).sum();
|
||||
let mut warnings = Vec::new();
|
||||
if dropped > 0 {
|
||||
warnings.push(format!("{name}: {dropped} cells outside the 1,048,576 × 16,384 grid were dropped"));
|
||||
}
|
||||
Ok((wb, warnings))
|
||||
}
|
||||
Some(FileKind::Tsv) => {
|
||||
let opts = gridcraft_xlsx::CsvOptions { delimiter: b'\t', parse_values: !csv_as_text, ..Default::default() };
|
||||
|
||||
@@ -1315,6 +1315,35 @@ fn open_grows_default_height_rows_to_their_font() {
|
||||
assert_eq!(s.doc().unwrap().wb.active().unwrap().row_height(0), default_h, "11pt row stays default");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_breaks_grow_a_row_only_when_the_cell_wraps() {
|
||||
// Excel: a cell holding "a\nb" with Wrap Text off displays on one line and AutoFit gives a
|
||||
// one-line row; with Wrap Text on it gets one line per break.
|
||||
// Typing a break (Alt+Enter) turns wrap on, as in Excel, so the unwrapped case comes from a
|
||||
// file, built with the model API and exported without row heights.
|
||||
let text = "first line\n\n\nfourth line";
|
||||
let bytes = {
|
||||
let mut wb = gridcraft_model::Workbook::new();
|
||||
let sh = wb.sheet_mut(0).unwrap();
|
||||
sh.set_value(CellRef::new(1, 1), Value::text(text));
|
||||
gridcraft_xlsx::write_xlsx(&wb).unwrap()
|
||||
};
|
||||
let default_h = gridcraft_model::Workbook::new().active().unwrap().row_height(1);
|
||||
let mut s = Session::new();
|
||||
s.execute("file.open", json!({"name": "notes.xlsx", "base64": crate::io::base64_encode(&bytes)})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().wb.active().unwrap().row_height(1), default_h, "an unwrapped multi-line cell keeps the default row height on open");
|
||||
// Turning wrap on lays the text out over its lines and the row grows to hold them.
|
||||
s.execute("home.wrapText", json!({"range": "B2", "on": true})).unwrap();
|
||||
let wrapped_h = s.doc().unwrap().wb.active().unwrap().row_height(1);
|
||||
assert!(wrapped_h > default_h * 2.5, "wrapped four-line text grows the row, got {wrapped_h} vs default {default_h}");
|
||||
// Turning it off again returns the row to one line.
|
||||
s.execute("home.wrapText", json!({"range": "B2", "on": false})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().wb.active().unwrap().row_height(1), default_h, "wrap off: back to a one-line row");
|
||||
// Entering a break by hand wraps the cell (Alt+Enter), so that row does grow.
|
||||
s.execute("cell.set", json!({"cell": "D4", "input": text})).unwrap();
|
||||
assert!(s.doc().unwrap().wb.active().unwrap().row_height(3) > default_h * 2.5, "typed line breaks turn wrap on and grow the row");
|
||||
}
|
||||
|
||||
#[test] // B — Review Focus 1
|
||||
fn open_keeps_custom_height_rows() {
|
||||
let mut s = Session::new();
|
||||
@@ -1362,6 +1391,53 @@ fn open_leaves_plain_workbooks_untouched() {
|
||||
assert!(sh.rows.values().all(|i| i.size.is_none()), "no row gains a height entry");
|
||||
}
|
||||
|
||||
/// Merged cells never auto-fit their rows, like Excel (issue #1605): a wrapped merged
|
||||
/// header keeps the default height instead of growing to its column-A wrap width, while an
|
||||
/// unmerged wrapped cell in the same sheet still fits.
|
||||
#[test]
|
||||
fn autofit_skips_merged_cells() {
|
||||
let mut s = s();
|
||||
s.execute("cell.set", json!({"cell": "A1", "input": "This is a long sentence that should wrap across the merged width of five columns"}))
|
||||
.unwrap();
|
||||
s.execute("home.mergeCells", json!({"range": "A1:E1"})).unwrap();
|
||||
s.execute("home.wrapText", json!({"range": "A1:E1", "on": true})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "A3", "input": "This is a long sentence that should wrap across the merged width of five columns"}))
|
||||
.unwrap();
|
||||
s.execute("home.wrapText", json!({"range": "A3", "on": true})).unwrap();
|
||||
let sh = s.doc().unwrap().wb.active().unwrap();
|
||||
assert!(sh.rows.get(&0).is_none_or(|i| i.size.is_none()), "merged wrap must not auto-fit row 1, got {:?}", sh.rows.get(&0));
|
||||
assert!(sh.rows.get(&2).is_some_and(|i| i.size.is_some_and(|h| h > 15.0)), "unmerged wrap still fits its row");
|
||||
// The save writes no height for the merged row.
|
||||
let r = s.execute("file.saveBytes", json!({"format": "xlsx"})).unwrap();
|
||||
let bytes = crate::io::base64_decode(r["base64"].as_str().unwrap()).unwrap();
|
||||
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
|
||||
let mut xml = String::new();
|
||||
std::io::Read::read_to_string(&mut zip.by_name("xl/worksheets/sheet1.xml").unwrap(), &mut xml).unwrap();
|
||||
let row1 = xml.split("<row").skip(1).map(|r| format!("<row{r}")).find(|r| r.starts_with("<row r=\"1\"")).unwrap();
|
||||
assert!(!row1.split('>').next().unwrap().contains("ht="), "merged row 1 must be saved without ht, got {row1}");
|
||||
assert!(xml.contains("<mergeCell ref=\"A1:E1\"/>"), "the merge itself is saved");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_fits_big_font_rows_from_the_font_not_the_sheet_default() {
|
||||
// Excel saves a sheet whose rows hold 16pt text with a 21pt (28px) default row height and no
|
||||
// `ht` on rows of that height. Those rows already fit their font: they open at about 28px,
|
||||
// not scaled up again by 16/11 to 41px.
|
||||
let mut wb = gridcraft_model::Workbook::new();
|
||||
let big = wb.styles.derive(gridcraft_model::StyleId::DEFAULT, |s| s.font.size = 16.0);
|
||||
let sh = wb.sheet_mut(0).unwrap();
|
||||
sh.default_row_height = 28.0;
|
||||
sh.set_value(CellRef::new(4, 0), Value::text("SANTANDER"));
|
||||
sh.set_style(CellRef::new(4, 0), big);
|
||||
let bytes = gridcraft_xlsx::write_xlsx(&wb).unwrap();
|
||||
let mut s = Session::new();
|
||||
s.execute("file.open", json!({"name": "tall-default.xlsx", "base64": crate::io::base64_encode(&bytes)})).unwrap();
|
||||
let sh = s.doc().unwrap().wb.active().unwrap();
|
||||
assert_eq!(sh.default_row_height, 28.0);
|
||||
let h = sh.row_height(4);
|
||||
assert!((28.0..30.0).contains(&h), "a 16pt row on a 28px-default sheet opens at about 28px, got {h}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tables_and_structured_refs() {
|
||||
let mut s = s();
|
||||
@@ -3243,6 +3319,38 @@ fn autofill_month_pattern_clamps_to_month_end() {
|
||||
assert_eq!(v(&s, "A6"), ymd(2026, 11, 30));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_import_rebuilds_cell_store_bands_and_counts() {
|
||||
// #1008: the JSON reader trusted serialized band keys and the cached length. A file written
|
||||
// with a cell in the wrong band lost it from lookup, and a forged count underflowed on the
|
||||
// next removal.
|
||||
let mut s = s();
|
||||
s.execute("cell.set", json!({"cell": "A5", "input": "keep"})).unwrap();
|
||||
let wb = &s.doc().unwrap().wb;
|
||||
let mut v: serde_json::Value = serde_json::to_value(&**wb).unwrap();
|
||||
{
|
||||
let cells = v["sheets"][0]["cells"].as_object_mut().unwrap();
|
||||
let bands = cells["bands"].as_object_mut().unwrap();
|
||||
let band = bands.remove("0").expect("the cell is in band 0");
|
||||
let cell = band["rows"]["4"]["0"].clone();
|
||||
// Off-grid rows never belong in a sheet, whatever band holds them.
|
||||
bands.insert("1".into(), band);
|
||||
bands.insert("16384".into(), json!({"rows": {"1048576": {"0": cell}}}));
|
||||
cells.insert("len".into(), json!(0));
|
||||
}
|
||||
let bytes = serde_json::to_vec(&v).unwrap();
|
||||
let (back, warnings) = crate::io::open_bytes("book.json", &bytes).unwrap();
|
||||
let sh = back.active().unwrap();
|
||||
let cell = sh.cell(CellRef::parse("A5").unwrap());
|
||||
assert_eq!(cell.map(|c| c.value.clone()), Some(Value::text("keep")), "the cell is found where its row says");
|
||||
assert_eq!(sh.cells.len(), 1, "the count is rebuilt from the cells");
|
||||
assert!(warnings.iter().any(|w| w.contains("outside the 1,048,576")), "{warnings:?}");
|
||||
// Removal stays consistent: the forged count used to underflow.
|
||||
let mut cells = sh.cells.clone();
|
||||
cells.remove(CellRef::parse("A5").unwrap());
|
||||
assert_eq!(cells.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn notes_comments_and_links_survive_a_json_save() {
|
||||
let mut s = s();
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
//! #1969: a wrapped cell's row must fit WORD-wrapped lines, not the character-fit
|
||||
//! estimate (words can't split, so each drawn line ends early and narrow columns
|
||||
//! need more lines than width/estimate suggests).
|
||||
|
||||
use gridcraft_engine::Session;
|
||||
use serde_json::json;
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s
|
||||
}
|
||||
|
||||
fn row_height(s: &mut Session, row: u32) -> f32 {
|
||||
s.doc().unwrap().wb.active().unwrap().row_height(row)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrapped_row_fits_word_wrapped_lines() {
|
||||
let mut s = session();
|
||||
// Column fits ~10 characters; six-character words mean only one word per line.
|
||||
s.execute("home.columnWidth", json!({"cols": "A:A", "chars": 10})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "A1", "input": "aaaaaa bbbbbb cccccc dddddd"})).unwrap();
|
||||
let plain = row_height(&mut s, 0);
|
||||
s.execute("home.wrapText", json!({"range": "A1", "on": true})).unwrap();
|
||||
let wrapped = row_height(&mut s, 0);
|
||||
// Word wrap: 4 lines (one word each). The old estimate: ceil(28/10) = 3 lines.
|
||||
// Line height at 11pt is ~19.8, so 4 lines need ~79 vs ~59 for 3.
|
||||
assert!(wrapped >= plain + 3.0 * 19.0, "4 word-wrapped lines must grow the row by ~4 line heights: plain={plain}, wrapped={wrapped}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn issue_repro_nasa_sentence() {
|
||||
let mut s = session();
|
||||
s.execute("home.columnWidth", json!({"cols": "A:A", "chars": 12})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "A1", "input": "Since the 1960s, NASA has left hardware across the solar system\u{2013}on the Moon, on Mars, and in deep space."}))
|
||||
.unwrap();
|
||||
s.execute("home.wrapText", json!({"range": "A1", "on": true})).unwrap();
|
||||
let h = row_height(&mut s, 0);
|
||||
// Excel shows this at ~10 lines in a 12-char column; the character estimate undercounts.
|
||||
assert!(h >= 9.0 * 19.0, "NASA sentence in a 12-char column needs ~10 lines, got height {h}");
|
||||
}
|
||||
@@ -61,6 +61,28 @@ impl CellStore {
|
||||
self.len += 1;
|
||||
}
|
||||
}
|
||||
/// Puts every cell in the band its row says and recounts the length — serialized band keys
|
||||
/// and the cached count are derived state, and the ones a file carries are not trusted. Cells
|
||||
/// outside the sheet grid are dropped; returns how many (#1008).
|
||||
pub fn rebuild(&mut self) -> usize {
|
||||
let mut out = CellStore::new();
|
||||
let mut dropped = 0;
|
||||
for (_, band) in std::mem::take(&mut self.bands) {
|
||||
for (row, cols) in band.rows.iter() {
|
||||
for (col, cell) in cols.iter() {
|
||||
let at = CellRef::new(*row, *col);
|
||||
if at.is_valid() {
|
||||
out.set(at, cell.clone());
|
||||
} else {
|
||||
dropped += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
*self = out;
|
||||
dropped
|
||||
}
|
||||
|
||||
pub fn remove(&mut self, c: CellRef) -> Option<Cell> {
|
||||
let key = c.row / BAND;
|
||||
let band = self.bands.get_mut(&key)?;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//! and sheet tabs until Esc or the back arrow returns to the sheet.
|
||||
//!
|
||||
//! A navigation column on the left picks a page (Home, New, Open, Info, Save As, Print, Export,
|
||||
//! Options); Save and Close act at once. Every action runs an existing command through
|
||||
//! Options, Account); Save and Close act at once. Every action runs an existing command through
|
||||
//! [`SheetApp::run`] or an existing dialog through [`SheetApp::open_dialog`]; the state here is
|
||||
//! UI-only and never saved. Recent workbooks are listed by path, with when each was last changed:
|
||||
//! that is asked of the file system on a thread of its own, never while drawing, and only the
|
||||
@@ -36,10 +36,11 @@ pub enum Page {
|
||||
Print,
|
||||
Export,
|
||||
Options,
|
||||
Account,
|
||||
}
|
||||
|
||||
impl Page {
|
||||
pub const ALL: [Page; 8] = [Page::Home, Page::New, Page::Open, Page::Info, Page::SaveAs, Page::Print, Page::Export, Page::Options];
|
||||
pub const ALL: [Page; 9] = [Page::Home, Page::New, Page::Open, Page::Info, Page::SaveAs, Page::Print, Page::Export, Page::Options, Page::Account];
|
||||
|
||||
/// The id `ui.backstage` and `ui.dialog {"name": "backstage", "page": …}` take.
|
||||
pub fn id(self) -> &'static str {
|
||||
@@ -52,6 +53,7 @@ impl Page {
|
||||
Page::Print => "print",
|
||||
Page::Export => "export",
|
||||
Page::Options => "options",
|
||||
Page::Account => "account",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,6 +73,7 @@ impl Page {
|
||||
Page::Print => "Print",
|
||||
Page::Export => "Export",
|
||||
Page::Options => "Options",
|
||||
Page::Account => "Account",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,6 +87,7 @@ impl Page {
|
||||
Page::Print => Icon::Print,
|
||||
Page::Export => Icon::Share,
|
||||
Page::Options => Icon::Settings,
|
||||
Page::Account => Icon::Info,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -190,9 +194,12 @@ pub fn show(app: &mut SheetApp, ui: &mut Ui) {
|
||||
out.close = true;
|
||||
}
|
||||
}
|
||||
// Bottom up: Account sits just above Options, as in Excel.
|
||||
ui.with_layout(egui::Layout::bottom_up(egui::Align::Min), |ui| {
|
||||
if nav_item(ui, Page::Options.icon(), lang.tr("Options"), bs.page == Page::Options, nav_text, nav_sel).clicked() {
|
||||
out.page = Some(Page::Options);
|
||||
for page in [Page::Options, Page::Account] {
|
||||
if nav_item(ui, page.icon(), lang.tr(page.label()), bs.page == page, nav_text, nav_sel).clicked() {
|
||||
out.page = Some(page);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -211,6 +218,7 @@ pub fn show(app: &mut SheetApp, ui: &mut Ui) {
|
||||
Page::Print => print(app, ui, &mut bs, &mut out),
|
||||
Page::Export => export(app, ui, &mut out),
|
||||
Page::Options => options(app, ui),
|
||||
Page::Account => account(app, ui),
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -996,6 +1004,51 @@ fn options(app: &mut SheetApp, ui: &mut Ui) {
|
||||
dim(ui, lang.tr("Calculation settings are saved with the workbook."));
|
||||
}
|
||||
|
||||
/// The platform the app was built for, e.g. "macOS (aarch64)"; the web build reports "Web".
|
||||
fn platform() -> String {
|
||||
let os = match std::env::consts::OS {
|
||||
"macos" => "macOS",
|
||||
"windows" => "Windows",
|
||||
"linux" => "Linux",
|
||||
"freebsd" => "FreeBSD",
|
||||
"openbsd" => "OpenBSD",
|
||||
"netbsd" => "NetBSD",
|
||||
_ if cfg!(target_arch = "wasm32") => return "Web".into(),
|
||||
other => other,
|
||||
};
|
||||
format!("{os} ({})", std::env::consts::ARCH)
|
||||
}
|
||||
|
||||
/// Excel's File › Account: product information and the About window.
|
||||
fn account(app: &mut SheetApp, ui: &mut Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let lang = app.ui.language;
|
||||
section(ui, lang, "Product Information");
|
||||
ui.label(RichText::new("GridCraft").font(theme::ui_bold(16.0)).color(t.text));
|
||||
ui.add_space(8.0);
|
||||
egui::Grid::new("backstage_account").num_columns(2).spacing(vec2(18.0, 8.0)).show(ui, |ui| {
|
||||
for (label, value) in
|
||||
[("Version", env!("CARGO_PKG_VERSION").to_string()), ("Platform", platform()), ("Licence", env!("CARGO_PKG_LICENSE").to_string())]
|
||||
{
|
||||
ui.label(RichText::new(lang.tr(label)).color(t.text_dim));
|
||||
ui.label(value);
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
ui.add_space(18.0);
|
||||
ui.horizontal(|ui| {
|
||||
if square_button(ui, Icon::Info, lang.tr("About GridCraft")).clicked() {
|
||||
app.open_dialog("about", json!({}));
|
||||
}
|
||||
ui.add_space(12.0);
|
||||
ui.vertical(|ui| {
|
||||
ui.add_space(14.0);
|
||||
ui.label(RichText::new(lang.tr("About GridCraft")).font(theme::ui_bold(13.5)).color(t.text));
|
||||
dim(ui, lang.tr("Version, contributors and the AI models that helped"));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -1077,7 +1130,7 @@ mod tests {
|
||||
assert_eq!(Page::parse(page.id()), Some(page));
|
||||
}
|
||||
assert_eq!(Page::parse(" SAVEAS "), Some(Page::SaveAs));
|
||||
assert_eq!(Page::parse("account"), None);
|
||||
assert_eq!(Page::parse("feedback"), None);
|
||||
assert_eq!(Page::parse(""), None);
|
||||
}
|
||||
|
||||
|
||||
@@ -195,7 +195,7 @@ pub fn handle(app: &mut SheetApp, ctx: &egui::Context, method: &str, p: &Json) -
|
||||
("options.autoComplete", "Enable AutoComplete for cell values", "{on?: bool} (toggles without it) → {on}"),
|
||||
("view.interfaceScale", "Interface Size", "{percent: 75–200 | \"auto\"} (100 = auto; clamped)"),
|
||||
("ui.dialog", "Open Dialog", ""),
|
||||
("ui.backstage", "File (Backstage view)", "{page?: home|new|open|info|saveAs|print|export|options|close}"),
|
||||
("ui.backstage", "File (Backstage view)", "{page?: home|new|open|info|saveAs|print|export|options|account|close}"),
|
||||
(
|
||||
"ui.printSettings",
|
||||
"Print Settings",
|
||||
|
||||
@@ -12,6 +12,12 @@ use std::time::Duration;
|
||||
/// round trip every few seconds is cheap and reacts soon enough after a theme switch.
|
||||
const POLL: Duration = Duration::from_secs(4);
|
||||
|
||||
/// How long one read waits for the portal's reply. Without it a portal that never answers blocks
|
||||
/// the watcher for good, and the theme stops following the desktop. Shorter than [`POLL`]: a read
|
||||
/// that times out drops the connection, and the next poll asks again on a fresh one.
|
||||
#[cfg(target_os = "linux")]
|
||||
const REPLY: Duration = Duration::from_secs(2);
|
||||
|
||||
/// Whether the desktop asks applications to draw dark. `None` when it says nothing, and when
|
||||
/// there is no session bus or no portal to ask (a bare X server, a container), so the caller
|
||||
/// keeps whatever it chose itself.
|
||||
@@ -87,7 +93,7 @@ fn read(link: &mut Link) -> Option<bool> {
|
||||
const SETTINGS: &str = "org.freedesktop.portal.Settings";
|
||||
const SCHEMAS: [(&str, &str); 2] = [("org.gnome.desktop.interface", "color-scheme"), ("org.freedesktop.interface.color-scheme", "color-scheme")];
|
||||
if link.is_none() {
|
||||
*link = Some(zbus::blocking::Connection::session().ok()?);
|
||||
*link = Some(zbus::blocking::connection::Builder::session().ok()?.method_timeout(REPLY).build().ok()?);
|
||||
}
|
||||
let connection = link.as_ref()?;
|
||||
for (schema, key) in SCHEMAS {
|
||||
|
||||
@@ -1578,18 +1578,22 @@ fn paste_special_shortcuts(d: &mut Dialog, ctx: &egui::Context) {
|
||||
|
||||
fn ok_cancel(ui: &mut egui::Ui, confirm: &mut bool, open: &mut bool) {
|
||||
ui.add_space(8.0);
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
let lang = crate::i18n::current(ui.ctx());
|
||||
let ok = ui.add(
|
||||
egui::Button::new(egui::RichText::new(format!(" {} ", lang.tr("OK"))).color(Color32::WHITE))
|
||||
.fill(crate::theme::Tokens::get(ui.ctx()).accent),
|
||||
);
|
||||
if ok.clicked() || ui.input(|i| i.key_pressed(Key::Enter)) {
|
||||
*confirm = true;
|
||||
}
|
||||
if ui.button(format!(" {} ", lang.tr("Cancel"))).clicked() {
|
||||
*open = false;
|
||||
}
|
||||
// A row as tall as the buttons: a right-to-left layout straight in the window takes all the
|
||||
// height it is offered, which stretched every form dialog to the screen's height.
|
||||
ui.horizontal(|ui| {
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
let lang = crate::i18n::current(ui.ctx());
|
||||
let ok = ui.add(
|
||||
egui::Button::new(egui::RichText::new(format!(" {} ", lang.tr("OK"))).color(Color32::WHITE))
|
||||
.fill(crate::theme::Tokens::get(ui.ctx()).accent),
|
||||
);
|
||||
if ok.clicked() || ui.input(|i| i.key_pressed(Key::Enter)) {
|
||||
*confirm = true;
|
||||
}
|
||||
if ui.button(format!(" {} ", lang.tr("Cancel"))).clicked() {
|
||||
*open = false;
|
||||
}
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -958,7 +958,9 @@ pub(crate) fn horizontal_scrollbar(app: &mut SheetApp, ui: &mut egui::Ui, track:
|
||||
pub(crate) fn wrapped_cell_text_y(valign: VAlign, rect: Rect, gh: f32, z: f32) -> f32 {
|
||||
let pad_v = 1.5 * z;
|
||||
let y_top = rect.top() + pad_v;
|
||||
let y_bottom = (rect.bottom() - gh - pad_v).max(y_top);
|
||||
// Not clamped to `y_top`: text taller than the row overflows upward (Bottom) or both ways
|
||||
// (Center), and the cell's clip rect trims it, so Middle and Bottom stay distinct from Top.
|
||||
let y_bottom = rect.bottom() - gh - pad_v;
|
||||
match valign {
|
||||
VAlign::Top => y_top,
|
||||
VAlign::Center | VAlign::Justify | VAlign::Distributed => (y_top + y_bottom) / 2.0,
|
||||
@@ -1067,7 +1069,9 @@ pub(crate) fn paint_quadrant(
|
||||
let (r0, r1) = rows;
|
||||
let (c0, c1) = cols;
|
||||
let range = RangeRef::new(CellRef::new(r0, c0), CellRef::new(r1, c1));
|
||||
let merges: Vec<RangeRef> = sh.merges.iter().filter(|m| m.intersects(&range)).copied().collect();
|
||||
// Merges are looked up 20 columns beyond the quadrant too: text overflowing in from off-screen cells stops at them.
|
||||
let near = RangeRef::new(CellRef::new(r0, c0.saturating_sub(20)), CellRef::new(r1, c1.saturating_add(20).min(MAX_COLS - 1)));
|
||||
let merges: Vec<RangeRef> = sh.merges.iter().filter(|m| m.intersects(&near)).copied().collect();
|
||||
let in_merge = |c: CellRef| merges.iter().find(|m| m.contains(c)).copied();
|
||||
// Gridlines.
|
||||
if sh.show_gridlines {
|
||||
@@ -1121,8 +1125,25 @@ pub(crate) fn paint_quadrant(
|
||||
}
|
||||
looks.push((anchor, rect, lk, cfl));
|
||||
}
|
||||
// Text cells just outside the quadrant whose text may overflow into it: the nearest non-empty
|
||||
// cell left of `c0` and right of `c1` in each row (within the same frozen / scrolled pane).
|
||||
let mut outside: Vec<(CellRef, Rect, Look, Option<CfLook>)> = Vec::new();
|
||||
let same_pane = |col: u32| (col < geo.fc) == (c0 < geo.fc);
|
||||
for r in r0..=r1 {
|
||||
if sh.row_height(r) == 0.0 {
|
||||
continue;
|
||||
}
|
||||
let left = (c0.saturating_sub(20)..c0).rev().map(|col| CellRef::new(r, col)).find(|&n| !empty_ok(n));
|
||||
let right = (c1.saturating_add(1)..=c1.saturating_add(20).min(MAX_COLS - 1)).map(|col| CellRef::new(r, col)).find(|&n| !empty_ok(n));
|
||||
for n in [left, right].into_iter().flatten() {
|
||||
if same_pane(n.col) && sh.col_width(n.col) > 0.0 && in_merge(n).is_none() {
|
||||
let (lk, cfl) = look(wb, si, sh, n, cf);
|
||||
outside.push((n, geo.cell_rect(sh, n), lk, cfl));
|
||||
}
|
||||
}
|
||||
}
|
||||
// Text and per-cell decorations.
|
||||
for (c, rect, lk, cfl) in &looks {
|
||||
for (c, rect, lk, cfl) in looks.iter().chain(&outside) {
|
||||
let c = *c;
|
||||
if let Some(picture) = sh.cell_pictures.get(&c) {
|
||||
crate::cell_pictures::paint(p, *rect, picture);
|
||||
@@ -1230,7 +1251,10 @@ pub(crate) fn paint_quadrant(
|
||||
p.with_clip_rect(rect.intersect(p.clip_rect())).galley(pos2(x, y), galley, color);
|
||||
continue;
|
||||
}
|
||||
let first_line = text.lines().next().unwrap_or("").to_string();
|
||||
// Wrap Text off: Excel ignores the line breaks and draws the whole string on one line.
|
||||
let first_line: String = text.chars().filter(|&ch| ch != '\n' && ch != '\r').collect();
|
||||
// The fill position indexes `text`; move it back by the breaks removed before it.
|
||||
let fill_char = fill_char.map(|(ch, pos)| (ch, pos.saturating_sub(text.get(..pos).map_or(0, |s| s.matches(['\n', '\r']).count()))));
|
||||
// Shrink to fit: too-wide content is drawn in a smaller font so it fits the cell (or the
|
||||
// merged area): it neither spills, nor is clipped, nor turns into ####.
|
||||
let small = (st.align.shrink && !rotated && st.align.h != HAlign::Fill && fill_char.is_none())
|
||||
@@ -4060,10 +4084,24 @@ mod tests {
|
||||
assert!(y_top < y_center);
|
||||
assert!(y_center < y_bottom);
|
||||
|
||||
// Overflow case: gh exceeds rect height
|
||||
// Overflow case: gh exceeds rect height; Top still starts inside the cell.
|
||||
let gh_overflow = 60.0;
|
||||
let y_bottom_ovf = wrapped_cell_text_y(VAlign::Bottom, rect, gh_overflow, z);
|
||||
assert!(y_bottom_ovf >= rect.top());
|
||||
let y_top_ovf = wrapped_cell_text_y(VAlign::Top, rect, gh_overflow, z);
|
||||
assert!(y_top_ovf >= rect.top());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrapped_cell_text_y_keeps_vertical_alignment_when_text_is_taller_than_the_row() {
|
||||
// A default-height row holding three wrapped lines.
|
||||
let rect = Rect::from_min_max(pos2(0.0, 0.0), pos2(100.0, 20.0));
|
||||
let (gh, z) = (60.0, 1.0);
|
||||
let y_top = wrapped_cell_text_y(VAlign::Top, rect, gh, z);
|
||||
let y_center = wrapped_cell_text_y(VAlign::Center, rect, gh, z);
|
||||
let y_bottom = wrapped_cell_text_y(VAlign::Bottom, rect, gh, z);
|
||||
// Taller text overflows upward for Bottom and both ways for Center (the cell clips it).
|
||||
assert!((y_top - 1.5).abs() < 0.01, "Top starts at the top edge, got {y_top}");
|
||||
assert!((y_center + gh / 2.0 - rect.center().y).abs() < 0.01, "Center is centred, got {y_center}");
|
||||
assert!((y_bottom + gh - (rect.bottom() - 1.5)).abs() < 0.01, "Bottom ends at the bottom edge, got {y_bottom}");
|
||||
}
|
||||
|
||||
/// The screen box of a galley of `size` drawn at `pos` turned by `angle` about `pos`, as egui
|
||||
|
||||
@@ -257,6 +257,12 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Export" => "Exportovat",
|
||||
"Close" => "Zavřít",
|
||||
"Options" => "Možnosti",
|
||||
"Account" => "Účet",
|
||||
"Product Information" => "Informace o produktu",
|
||||
"About GridCraft" => "O aplikaci GridCraft",
|
||||
"Version" => "Verze",
|
||||
"Platform" => "Platforma",
|
||||
"Licence" => "Licence",
|
||||
"Blank workbook" => "Prázdný sešit",
|
||||
"Recent" => "Naposledy použité",
|
||||
"No recent workbooks yet." => "Zatím žádné naposledy použité sešity.",
|
||||
|
||||
@@ -259,6 +259,13 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Export" => "Exportieren",
|
||||
"Close" => "Schließen",
|
||||
"Options" => "Optionen",
|
||||
"Account" => "Konto",
|
||||
"Product Information" => "Produktinformationen",
|
||||
"About GridCraft" => "Info zu GridCraft",
|
||||
"Version" => "Version",
|
||||
"Platform" => "Plattform",
|
||||
"Licence" => "Lizenz",
|
||||
"Version, contributors and the AI models that helped" => "Version, Mitwirkende und die KI-Modelle, die geholfen haben",
|
||||
"Blank workbook" => "Leere Arbeitsmappe",
|
||||
"Recent" => "Zuletzt verwendet",
|
||||
"No recent workbooks yet." => "Noch keine zuletzt verwendeten Arbeitsmappen.",
|
||||
|
||||
@@ -258,6 +258,13 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Export" => "Exportar",
|
||||
"Close" => "Cerrar",
|
||||
"Options" => "Opciones",
|
||||
"Account" => "Cuenta",
|
||||
"Product Information" => "Información del producto",
|
||||
"About GridCraft" => "Acerca de GridCraft",
|
||||
"Version" => "Versión",
|
||||
"Platform" => "Plataforma",
|
||||
"Licence" => "Licencia",
|
||||
"Version, contributors and the AI models that helped" => "Versión, colaboradores y los modelos de IA que ayudaron",
|
||||
"Blank workbook" => "Libro en blanco",
|
||||
"Recent" => "Recientes",
|
||||
"No recent workbooks yet." => "Todavía no hay libros recientes.",
|
||||
|
||||
@@ -343,6 +343,13 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Export" => "Exporter",
|
||||
"Close" => "Fermer",
|
||||
"Options" => "Options",
|
||||
"Account" => "Compte",
|
||||
"Product Information" => "Informations sur le produit",
|
||||
"About GridCraft" => "À propos de GridCraft",
|
||||
"Version" => "Version",
|
||||
"Platform" => "Plateforme",
|
||||
"Licence" => "Licence",
|
||||
"Version, contributors and the AI models that helped" => "Version, contributeurs et les modèles d'IA qui ont aidé",
|
||||
"Blank workbook" => "Classeur vide",
|
||||
"Recent" => "Récent",
|
||||
"No recent workbooks yet." => "Aucun classeur récent pour le moment.",
|
||||
|
||||
@@ -344,6 +344,11 @@ pub(super) const CORE: &[(&str, &str)] = &[
|
||||
("Export", "エクスポート"),
|
||||
("Close", "閉じる"),
|
||||
("Options", "オプション"),
|
||||
("Account", "アカウント"),
|
||||
("Product Information", "製品情報"),
|
||||
("Version", "バージョン"),
|
||||
("Platform", "プラットフォーム"),
|
||||
("Licence", "ライセンス"),
|
||||
("Blank workbook", "空白のブック"),
|
||||
("Recent", "最近使ったアイテム"),
|
||||
("No recent workbooks yet.", "最近使ったブックはまだありません。"),
|
||||
|
||||
@@ -345,6 +345,13 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Export" => "내보내기",
|
||||
"Close" => "닫기",
|
||||
"Options" => "옵션",
|
||||
"Account" => "계정",
|
||||
"Product Information" => "제품 정보",
|
||||
"About GridCraft" => "GridCraft 정보",
|
||||
"Version" => "버전",
|
||||
"Platform" => "플랫폼",
|
||||
"Licence" => "라이선스",
|
||||
"Version, contributors and the AI models that helped" => "버전, 기여자 및 도움을 준 AI 모델",
|
||||
"Blank workbook" => "새 통합 문서",
|
||||
"Recent" => "최근 항목",
|
||||
"No recent workbooks yet." => "최근 통합 문서가 아직 없습니다.",
|
||||
|
||||
@@ -341,6 +341,11 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Export" => "Exportar",
|
||||
"Close" => "Fechar",
|
||||
"Options" => "Opções",
|
||||
"Account" => "Conta",
|
||||
"Product Information" => "Informações do produto",
|
||||
"Version" => "Versão",
|
||||
"Platform" => "Plataforma",
|
||||
"Licence" => "Licença",
|
||||
"Blank workbook" => "Pasta de trabalho em branco",
|
||||
"Recent" => "Recentes",
|
||||
"No recent workbooks yet." => "Ainda não há pastas de trabalho recentes.",
|
||||
|
||||
@@ -345,6 +345,13 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Export" => "Экспорт",
|
||||
"Close" => "Закрыть",
|
||||
"Options" => "Параметры",
|
||||
"Account" => "Учетная запись",
|
||||
"Product Information" => "Сведения о продукте",
|
||||
"About GridCraft" => "О программе GridCraft",
|
||||
"Version" => "Версия",
|
||||
"Platform" => "Платформа",
|
||||
"Licence" => "Лицензия",
|
||||
"Version, contributors and the AI models that helped" => "Версия, участники и модели ИИ, которые помогали",
|
||||
"Blank workbook" => "Пустая книга",
|
||||
"Recent" => "Последние",
|
||||
"No recent workbooks yet." => "Недавних книг пока нет.",
|
||||
|
||||
@@ -337,6 +337,13 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Export" => "Izvezi",
|
||||
"Close" => "Zatvori",
|
||||
"Options" => "Opcije",
|
||||
"Account" => "Nalog",
|
||||
"Product Information" => "Informacije o proizvodu",
|
||||
"About GridCraft" => "O programu GridCraft",
|
||||
"Version" => "Verzija",
|
||||
"Platform" => "Platforma",
|
||||
"Licence" => "Licenca",
|
||||
"Version, contributors and the AI models that helped" => "Verzija, saradnici i AI modeli koji su pomogli",
|
||||
"Blank workbook" => "Prazna radna sveska",
|
||||
"Recent" => "Nedavno",
|
||||
"No recent workbooks yet." => "Još nema nedavnih radnih svezaka.",
|
||||
|
||||
@@ -343,6 +343,13 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Export" => "导出",
|
||||
"Close" => "关闭",
|
||||
"Options" => "选项",
|
||||
"Account" => "账户",
|
||||
"Product Information" => "产品信息",
|
||||
"About GridCraft" => "关于 GridCraft",
|
||||
"Version" => "版本",
|
||||
"Platform" => "平台",
|
||||
"Licence" => "许可证",
|
||||
"Version, contributors and the AI models that helped" => "版本、贡献者以及参与协助的 AI 模型",
|
||||
"Blank workbook" => "空白工作簿",
|
||||
"Recent" => "最近",
|
||||
"No recent workbooks yet." => "还没有最近使用的工作簿。",
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
//! Form dialogs fit their content: OK and Cancel sit right under the last field, not halfway down
|
||||
//! a window stretched to the screen's height.
|
||||
|
||||
use egui_kittest::kittest::Queryable as _;
|
||||
use gridcraft_engine::Session;
|
||||
use gridcraft_ui_egui::SheetApp;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn ok_and_cancel_sit_under_the_last_field() {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 1000.0)).build_ui_state(
|
||||
|ui, app: &mut SheetApp| {
|
||||
let ctx = ui.ctx().clone();
|
||||
app.logic(&ctx);
|
||||
app.ui(ui);
|
||||
},
|
||||
SheetApp::new(s, Default::default()),
|
||||
);
|
||||
h.state_mut().open_dialog("cfQuick", json!({"type": "cellIs", "operator": "greaterThan", "title": "Greater Than…"}));
|
||||
h.run_steps(4);
|
||||
let last = h.get_by_label("Red Border").rect();
|
||||
let ok = h.get_by_label_contains("OK").rect();
|
||||
let gap = ok.top() - last.bottom();
|
||||
assert!((0.0..40.0).contains(&gap), "OK is {gap} points under the last option");
|
||||
}
|
||||
@@ -122,7 +122,7 @@ fn file_options_opens_the_options_page_and_pages_are_reachable_by_command() {
|
||||
h.run_steps(2);
|
||||
assert_eq!(page(&h), Some(id));
|
||||
}
|
||||
assert!(h.state_mut().run("ui.backstage", serde_json::json!({"page": "account"})).is_err());
|
||||
assert!(h.state_mut().run("ui.backstage", serde_json::json!({"page": "feedback"})).is_err());
|
||||
h.state_mut().run("ui.backstage", serde_json::json!({"page": "close"})).unwrap();
|
||||
assert!(h.state().backstage.is_none());
|
||||
// The back arrow returns to the sheet too.
|
||||
@@ -161,6 +161,20 @@ fn translated_options_headings_show_no_context_prefix() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_account_shows_the_version_and_opens_about() {
|
||||
let mut h = harness(Services::default());
|
||||
choose(&mut h, "Account");
|
||||
assert_eq!(page(&h), Some("account"));
|
||||
assert!(h.state().dialog.is_none());
|
||||
// The About dialog also shows the version; it must not be open yet.
|
||||
h.get_by_label(env!("CARGO_PKG_VERSION"));
|
||||
h.get_by_label(env!("CARGO_PKG_LICENSE"));
|
||||
click(&mut h, "About GridCraft");
|
||||
assert_eq!(h.state().dialog.as_ref().map(|d| d.name()), Some("about"));
|
||||
assert_eq!(page(&h), Some("account"), "About opens over the File view, like Protect Workbook on Info");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_as_pdf_is_delivered_as_a_download() {
|
||||
let files = Arc::new(Mutex::new(Vec::<(String, Vec<u8>)>::new()));
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
//! Line breaks with Wrap Text off (#1604): Excel ignores them and draws the whole string on one line.
|
||||
|
||||
use egui::Shape;
|
||||
use gridcraft_engine::Session;
|
||||
use serde_json::json;
|
||||
|
||||
type Harness = egui_kittest::Harness<'static, gridcraft_ui_egui::SheetApp>;
|
||||
|
||||
fn harness() -> Harness {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("cell.set", json!({"cell": "A1", "input": "Active\n\nUnits: LF 2x10\r\nBand: 60-20000 Hz"})).unwrap();
|
||||
// Typing a break turns Wrap Text on; a file can still store the text with it off.
|
||||
s.execute("home.formatCells", json!({"range": "A1", "style": {"align": {"wrap": false}}})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "A3", "input": "Wrapped\nText"})).unwrap();
|
||||
s.execute("home.formatCells", json!({"range": "A3", "style": {"align": {"wrap": true}}})).unwrap();
|
||||
s.execute("selection.set", json!({"range": "H20"})).unwrap();
|
||||
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state(
|
||||
|ui, app: &mut gridcraft_ui_egui::SheetApp| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
},
|
||||
gridcraft_ui_egui::SheetApp::new(s, Default::default()),
|
||||
);
|
||||
h.run_steps(4);
|
||||
h
|
||||
}
|
||||
|
||||
fn painted(h: &Harness, text: &str) -> bool {
|
||||
h.output().shapes.iter().any(|s| matches!(&s.shape, Shape::Text(t) if t.galley.job.text == text))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unwrapped_cell_draws_every_line_on_one_line() {
|
||||
let h = harness();
|
||||
assert!(painted(&h, "ActiveUnits: LF 2x10Band: 60-20000 Hz"), "A1 draws the whole string without its breaks");
|
||||
assert!(!painted(&h, "Active"), "A1 is not cut at its first break");
|
||||
// Wrap Text on keeps the breaks.
|
||||
assert!(painted(&h, "Wrapped\nText"));
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
//! Overflowing text stays visible in its empty neighbours when its own cell is scrolled out (#1613).
|
||||
|
||||
use egui::{Rect, Shape};
|
||||
use gridcraft_engine::Session;
|
||||
use gridcraft_engine::core::CellRef;
|
||||
use gridcraft_ui_egui::{SheetApp, grid::Geo};
|
||||
use serde_json::json;
|
||||
|
||||
type Harness = egui_kittest::Harness<'static, SheetApp>;
|
||||
|
||||
const LEFT: &str = "This is a very long left-aligned text that overflows into many empty neighbouring cells";
|
||||
const RIGHT: &str = "This is a very long right-aligned text that overflows into many empty neighbouring cells";
|
||||
|
||||
fn harness(setup: impl FnOnce(&mut Session)) -> Harness {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
setup(&mut s);
|
||||
s.execute("selection.set", json!({"range": "A20"})).unwrap();
|
||||
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state(
|
||||
|ui, app: &mut SheetApp| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
},
|
||||
SheetApp::new(s, Default::default()),
|
||||
);
|
||||
h.run_steps(4);
|
||||
h
|
||||
}
|
||||
|
||||
/// Scroll so that `col` is the first column of the scrolled area.
|
||||
fn scroll_to_col(h: &mut Harness, col: u32) {
|
||||
let app = h.state_mut();
|
||||
let sheet = app.session.active().unwrap().wb.active().unwrap();
|
||||
let fc = sheet.freeze.map(|f| f.1).unwrap_or(0);
|
||||
let x = (sheet.col_left(col) - sheet.col_left(fc)) as f32;
|
||||
app.view_mut().unwrap().scroll.x = x;
|
||||
h.run_steps(4);
|
||||
}
|
||||
|
||||
/// Clip rects of the painted text shapes whose text is `text`.
|
||||
fn text_clips(h: &Harness, text: &str) -> Vec<Rect> {
|
||||
h.output()
|
||||
.shapes
|
||||
.iter()
|
||||
.filter_map(|s| match &s.shape {
|
||||
Shape::Text(t) if t.galley.job.text == text => Some(s.clip_rect),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn cell_rect(h: &Harness, cell: &str) -> Rect {
|
||||
let app = h.state();
|
||||
let sheet = app.session.active().unwrap().wb.active().unwrap();
|
||||
let geo = Geo::new(sheet, app.grid.rect.unwrap(), app.view().scroll);
|
||||
geo.cell_rect(sheet, CellRef::parse(cell).unwrap())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overflow_text_stays_visible_when_source_cell_scrolls_out_left() {
|
||||
let mut h = harness(|s| {
|
||||
s.execute("cell.set", json!({"cell": "B1", "input": LEFT})).unwrap();
|
||||
});
|
||||
assert!(!text_clips(&h, LEFT).is_empty(), "B1 is drawn before scrolling");
|
||||
scroll_to_col(&mut h, 2);
|
||||
let c1 = cell_rect(&h, "C1");
|
||||
let clips = text_clips(&h, LEFT);
|
||||
assert!(clips.iter().any(|r| r.intersects(c1.shrink(1.0))), "B1's overflow is still drawn into C1 after scrolling: {clips:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn right_aligned_overflow_stays_visible_when_source_cell_is_right_of_view() {
|
||||
let mut h = harness(|s| {
|
||||
s.execute("cell.set", json!({"cell": "Z1", "input": RIGHT})).unwrap();
|
||||
s.execute("home.formatCells", json!({"range": "Z1", "style": {"align": {"h": "Right"}}})).unwrap();
|
||||
});
|
||||
// Scroll so that Z1 starts just past the right edge of the cell area.
|
||||
{
|
||||
let app = h.state_mut();
|
||||
let sheet = app.session.active().unwrap().wb.active().unwrap();
|
||||
let geo = Geo::new(sheet, app.grid.rect.unwrap(), app.view().scroll);
|
||||
let x = sheet.col_left(25) as f32 - (geo.cells.width() - geo.frozen_w) / geo.z - 2.0;
|
||||
app.view_mut().unwrap().scroll.x = x;
|
||||
}
|
||||
h.run_steps(4);
|
||||
let (y1, z1, cells) = {
|
||||
let app = h.state();
|
||||
let sheet = app.session.active().unwrap().wb.active().unwrap();
|
||||
let geo = Geo::new(sheet, app.grid.rect.unwrap(), app.view().scroll);
|
||||
(geo.cell_rect(sheet, CellRef::parse("Y1").unwrap()), geo.cell_rect(sheet, CellRef::parse("Z1").unwrap()), geo.cells)
|
||||
};
|
||||
assert!(z1.left() >= cells.right(), "Z1 is out of view on the right ({z1:?} vs {cells:?})");
|
||||
let visible = y1.intersect(cells).shrink(1.0);
|
||||
let clips = text_clips(&h, RIGHT);
|
||||
assert!(clips.iter().any(|r| r.intersects(visible)), "Z1's overflow is drawn into Y1: {clips:?} vs {visible:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scrolled_overflow_does_not_draw_over_frozen_columns() {
|
||||
let mut h = harness(|s| {
|
||||
s.execute("cell.set", json!({"cell": "C1", "input": LEFT})).unwrap();
|
||||
s.execute("view.freezePanes", json!({"cell": "B2"})).unwrap();
|
||||
});
|
||||
scroll_to_col(&mut h, 4);
|
||||
let a1 = cell_rect(&h, "A1");
|
||||
let e1 = cell_rect(&h, "E1");
|
||||
let clips = text_clips(&h, LEFT);
|
||||
assert!(clips.iter().any(|r| r.intersects(e1.shrink(1.0))), "C1's overflow reaches E1: {clips:?}");
|
||||
assert!(clips.iter().all(|r| !r.intersects(a1.shrink(1.0))), "C1's overflow stays out of frozen column A: {clips:?}");
|
||||
}
|
||||
@@ -36,7 +36,7 @@ connection; nothing sent after it runs. Blank lines are skipped.
|
||||
| `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 |
|
||||
| `ui.backstage` (via `engine.execute`) | `{page?: home\|new\|open\|info\|saveAs\|print\|export\|options\|close}` | Open the File tab's Backstage view on a page, or close it (`ui.inspect` reports the open page as `backstage`) |
|
||||
| `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 |
|
||||
| `ui.set` | `{dark?, formulaBar?, ribbonCollapsed?, animations?}` | UI preferences (`formulaBar` runs `view.formulaBar`) |
|
||||
| `ui.resize` / `ui.focus` | `{width, height}` | Window control |
|
||||
|
||||
+5
-2
@@ -114,8 +114,10 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha-
|
||||
FORECAST.ETS family are in, including Forecast Start (hindcast, #785). **Estimate:** 15–25 h.
|
||||
- Solver (`data.solver`, #327) has Simplex LP with integer/binary branch and bound, a local
|
||||
nonlinear method (augmented Lagrangian, not GRG), the Evolutionary method, and Answer and
|
||||
Limits reports; AllDifferent constraints, the Solver Options dialog, the Sensitivity and
|
||||
Population reports and Load/Save Model remain. The Analysis ToolPak
|
||||
Limits reports. Report refusal leaves the solution, saved model and history unchanged; a
|
||||
successful solve and its reports share one Undo step (#1050). AllDifferent constraints,
|
||||
the Solver Options dialog, the Sensitivity and Population reports and Load/Save Model remain.
|
||||
The Analysis ToolPak
|
||||
(`data.analysisToolPak`) has 13 of 19 tools. Forecast Sheet (`data.forecastSheet`) and the
|
||||
FORECAST.ETS family are in; the sheet has no Forecast Start (hindcast) option yet. **Estimate:** 12–20 h.
|
||||
|
||||
@@ -173,6 +175,7 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha-
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-20 | minor | §11: the Japanese catalog covers the dialogs, messages and menus — 943 keys (~43%), matching the pt-BR coverage; a test keeps the two catalogs in step |
|
||||
| 2026-10-15 | minor | §13: Solver prepares reports before committing the solution, model and report sheets together; report refusal preserves the workbook and Undo/Redo (#1050) |
|
||||
| 2026-10-13 | trivial | §4: multi-level category axes landed |
|
||||
| 2026-10-12 | minor | §9: next/previous sheet commands retain the current visible sheet and selection when there is no visible destination |
|
||||
| 2026-10-12 | minor | rows auto-fit oversized fonts on open and whole-row/column formatting (#833) |
|
||||
|
||||
@@ -334,6 +334,7 @@ The inventory carried over from the 2026-10-07 ROADMAP.md, updated for what land
|
||||
|---|---|---|
|
||||
| 2026-10-21 | minor | Page Setup paper sizes: `B4`/`B5`/`B6` are the JIS sizes Japanese Excel uses (ISO B4/B5/B6 by name), and Japanese Postcard, Japanese Double Postcard, the chou/kaku envelopes and A6 are added; PDF MediaBox and `file.print` follow, and the Size menu lists them |
|
||||
| 2026-10-16 | minor | Advanced Filter copy-to clears only its actual output and rejects overflow without changing cells (#1031) |
|
||||
| 2026-10-15 | minor | Row auto-fit: line breaks count only in wrapping cells (Excel: wrap off shows one line, AutoFit gives one line); unwrapped multi-line cells from files no longer grow rows on open (no score change) |
|
||||
| 2026-10-12 | minor | Essentials AutoFit evidence: rows auto-fit oversized fonts on open and row formatting (#833) |
|
||||
| 2026-10-12 | minor | Sheet tabs: reveal the selected tab on navigation and resize while preserving manual scrolling (#681); no score change |
|
||||
| 2026-10-12 | minor | PivotTables: values on rows (Σ Values in Rows at any position, all three layouts; `pivot.moveField` and the field pane move it; `dataOnRows` round-trips) (no score change) |
|
||||
|
||||
@@ -178,6 +178,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12.
|
||||
|---|---|---|
|
||||
| 2026-10-21 | minor | Background error checking, first part (#1994): a constant whose text would be a number if typed (signs, thousands, currency, percent, exponent, parenthesised negative) gets Excel's green corner triangle; the active cell then shows a warning button whose menu names the problem and offers Convert to Number for the selection (`formulas.convertToNumber`). Dates, times and fractions kept as text and formula results are not flagged, as in Excel |
|
||||
| 2026-10-21 | minor | Paste Special laid out as Excel's: Paste and Operation options each in two columns inside a frame, Skip blanks and Transpose side by side, Paste Link as a button that pastes references at once; labels translated (the generic form didn't translate them) and the window no longer runs past the screen |
|
||||
| 2026-10-21 | minor | Form dialogs (conditional-format quick rules, New Name, Remove Duplicates, Text to Columns and every other `Dialog::form`) fit their content: OK and Cancel sit in a row under the last field instead of a right-to-left layout that took the screen's whole height and left the buttons floating halfway down |
|
||||
| 2026-10-20 | minor | Esc and Enter close a message over the cell editor, such as a data validation alert, and the entry stays open to fix, with the keyboard back in it. Before, the editor took them (the first Esc cancelled the edit behind the alert and only the second closed it), and after the message typing went nowhere until the cell was clicked |
|
||||
| 2026-10-19 | minor | Ctrl+Enter (`range.fill`) gives each cell what typing the input there gives: a General cell gets the automatic number format (`5%`, `$5`, `3/4/2026`, `=TODAY()`) even when it already held a value, and a cell with its own format, a formatted row or column, or Text keeps it. Cells that held a value used to keep General (`5%` showed 0.05), and empty cells took the active cell's format |
|
||||
| 2026-10-19 | trivial | While a mouse button is held (a click or a drag selecting cells) the selection outline is drawn on its cells at once instead of gliding after the pointer, which read as lag; keyboard moves still glide |
|
||||
@@ -191,6 +192,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12.
|
||||
| 2026-10-16 | minor | Remove Duplicates shows an editable Range field initialized from the selection and passes it to the engine on OK (#1388). Live range-picking outside the dialog remains unsupported |
|
||||
| 2026-10-16 | minor | Web printing registers one-shot `afterprint` cleanup before opening the dialog; missing content windows and setup, listener or print failures also release the hidden iframe and PDF blob URL (#1054) |
|
||||
| 2026-10-16 | minor | The shared UI clock delegates to the core Unix-millisecond clock, which reads Date.now() on wasm, instead of returning zero there (#1026). Native clock contract tested; browser timing remains unverified |
|
||||
| 2026-10-16 | trivial | Web: preferences (theme, interface size, language, editing options…) are kept in the browser's `localStorage` between visits, as the desktop app keeps `ui.json` and `prefs.json`; before, a reload brought back light mode and the defaults (#1562) |
|
||||
| 2026-10-15 | minor | Number-format named colors use their RGB values; indexed `[Color1]`–`[Color56]` resolve through the shared legacy palette in grid and PDF (#1452) |
|
||||
| 2026-10-14 | minor | Spill-child formula bar uses the anchor formula with disabled theme text and blocks formula-bar edit entry; selection and recalculation restore ordinary behavior |
|
||||
| 2026-10-13 | minor | Dragging a sheet tab with Ctrl held (Cmd or Option on macOS) drops a copy of the sheet where the tab is released, with the copy cursor (#971) |
|
||||
|
||||
Reference in New Issue
Block a user