From 5cf0e24f430176fdac37dbd2a284faa7215f1dd7 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Tue, 13 Oct 2026 21:12:43 +0200 Subject: [PATCH 01/23] Charts: one combo plot-kind rule, no fixed_scale helper, ticks off the step grid (#1146) Co-Authored-By: Claude Sonnet 5.5 --- ROADMAP.md | 3 ++- crates/chart/src/axis.rs | 11 ++++------- crates/chart/src/render.rs | 20 +++++++++++--------- crates/chart/src/resolve.rs | 10 ++++++++-- crates/chart/src/tests.rs | 32 ++++++++++++++++++++++++++++++++ 5 files changed, 57 insertions(+), 19 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 070552d07..2e9336683 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~25 points of "ready for real work" and ~160–240 h away -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (rows auto-fit oversized fonts on open and whole-row/column formatting) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-13 · **Change:** trivial (chart combo plot-kind rule, scale ticks) · **Target:** Microsoft Excel (Microsoft 365) GridCraft aims at full Microsoft Excel parity, and at being better in places: faster, open (XLSX is the native format), fully scriptable by agents (MCP, control channel, CLI), and on the web. @@ -132,6 +132,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | What landed | |---|---| +| 2026-10-13 | Charts: one combo plot-kind rule for resolving and drawing; ticks of a scale off the step grid keep their own values (#1146) | | 2026-10-12 | Rows auto-fit oversized fonts: opening a file and whole-row/column formatting grow non-custom rows to their tallest font (#833) | | 2026-10-12 | Number formats: parsing a format code is linear in its length (long runs of `[`, `,` or `/` no longer take quadratic time), and an unclosed `[` is a literal when splitting sections as well as when tokenizing (#810) | | 2026-10-12 | Number formats: a fraction denominator with 65,536 zeros no longer crashes; format codes are capped at 255 characters like Excel (longer codes display as General, `TEXT` gives `#VALUE!`) (#807) | diff --git a/crates/chart/src/axis.rs b/crates/chart/src/axis.rs index 8686bf7d9..46fc43f99 100644 --- a/crates/chart/src/axis.rs +++ b/crates/chart/src/axis.rs @@ -25,9 +25,11 @@ impl Scale { (0..=n) .map(|i| { let v = self.min + i as f64 * self.step; - // Snap away accumulated float error. + // Snap away accumulated float error, but only onto a multiple of the step that is + // really that close: a `min` off the step grid must keep its own ticks. let snapped = (v / self.step).round() * self.step; - if snapped.abs() < self.step * 1e-9 { 0.0 } else { snapped } + let v = if (snapped - v).abs() < self.step * 1e-9 { snapped } else { v }; + if v.abs() < self.step * 1e-9 { 0.0 } else { v } }) .collect() } @@ -107,11 +109,6 @@ pub fn nice_scale(lo: f64, hi: f64, force_zero: bool) -> Scale { Scale { min, max, step } } -/// Exact fixed scale (100% stacked axes). -pub(crate) fn fixed_scale(min: f64, max: f64, step: f64) -> Scale { - Scale { min, max, step } -} - /// A number format prepared once and reused for many labels. pub(crate) struct Fmt { general: bool, diff --git a/crates/chart/src/render.rs b/crates/chart/src/render.rs index 037b9099e..82769bcdc 100644 --- a/crates/chart/src/render.rs +++ b/crates/chart/src/render.rs @@ -5,7 +5,7 @@ use std::f32::consts::{PI, TAU}; use gridcraft_model::{Chart, ChartKind, LegendPos, LineDash, Theme, TrendlineKind}; -use crate::axis::{Fmt, Scale, clean, fixed_scale, nice_scale}; +use crate::axis::{Fmt, Scale, clean, nice_scale}; use crate::resolve::{MAX_POINTS, MAX_SERIES, MAX_TRENDLINES, series_color}; use crate::trend::{auto_name, fit_trendline, moving_average}; use crate::{ChartData, HAlign, Measure, Prim, Rgba, SeriesData, VAlign}; @@ -120,9 +120,10 @@ pub enum Swatch { Marker, } -/// The kind a series is drawn as (per-series kinds only apply to combo charts). -fn plot_kind(chart: &Chart, s: &SeriesData) -> ChartKind { - if chart.kind == ChartKind::Combo { if s.kind == ChartKind::Combo { ChartKind::Line } else { s.kind } } else { chart.kind } +/// The kind series `i` is drawn as (per-series kinds only apply to combo charts); the same rule +/// as `series_plot_kind`, applied to the already resolved series kind. +fn plot_kind(chart: &Chart, s: &SeriesData, i: usize) -> ChartKind { + crate::resolve::plot_kind(chart.kind, Some(s.kind), i) } /// Colour of point `j` for charts that colour by point (pie, doughnut, treemap, sunburst). @@ -163,8 +164,9 @@ pub fn legend_entries(chart: &Chart, data: &ChartData) -> Vec<(String, Rgba, Swa let series = data.series.iter().take(MAX_SERIES); let mut out: Vec<(String, Rgba, Swatch)> = series .clone() - .map(|s| { - let sw = match plot_kind(chart, s) { + .enumerate() + .map(|(i, s)| { + let sw = match plot_kind(chart, s, i) { ChartKind::Line | ChartKind::LineStacked | ChartKind::Radar | ChartKind::ScatterLines => Swatch::Line, ChartKind::LineMarkers => Swatch::LineMarker, ChartKind::Scatter | ChartKind::Bubble => Swatch::Marker, @@ -174,7 +176,7 @@ pub fn legend_entries(chart: &Chart, data: &ChartData) -> Vec<(String, Rgba, Swa }) .collect(); // Trendlines follow the series, named "Linear (Sales)" unless given a name. - for s in series.filter(|s| supports_trendline(plot_kind(chart, s))) { + for (_, s) in series.enumerate().filter(|(i, s)| supports_trendline(plot_kind(chart, s, *i))) { for t in s.trendlines.iter().take(MAX_TRENDLINES) { out.push((t.spec.name.clone().unwrap_or_else(|| auto_name(t.spec.kind, &s.name)), t.color, Swatch::Line)); } @@ -833,7 +835,7 @@ fn cartesian(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx, horizonta if ncat == 0 { return; } - let kinds: Vec = data.series.iter().map(|s| if horizontal { chart.kind } else { plot_kind(chart, s) }).collect(); + let kinds: Vec = data.series.iter().enumerate().map(|(i, s)| if horizontal { chart.kind } else { plot_kind(chart, s, i) }).collect(); let fams: Vec = kinds.iter().map(|k| family(*k)).collect(); let any_primary = data.series.iter().any(|s| !s.secondary); let sec: Vec = data.series.iter().map(|s| s.secondary && any_primary && !horizontal).collect(); @@ -915,7 +917,7 @@ fn cartesian(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx, horizonta })); let (lo, hi) = range.unwrap_or((0.0, 0.0)); let (scale, fmt) = if all100 { - (fixed_scale(if lo < -1e-9 { -1.0 } else { 0.0 }, if hi > 1e-9 || lo >= -1e-9 { 1.0 } else { 0.0 }, 0.2), Fmt::new("0%")) + (Scale { min: if lo < -1e-9 { -1.0 } else { 0.0 }, max: if hi > 1e-9 || lo >= -1e-9 { 1.0 } else { 0.0 }, step: 0.2 }, Fmt::new("0%")) } else { let code = members.first().and_then(|i| data.series.get(*i)).map(|s| s.number_format.as_str()).unwrap_or("General"); (nice_scale(lo, hi, force_zero), Fmt::new(code)) diff --git a/crates/chart/src/resolve.rs b/crates/chart/src/resolve.rs index c007e3e90..2faeeb356 100644 --- a/crates/chart/src/resolve.rs +++ b/crates/chart/src/resolve.rs @@ -170,7 +170,7 @@ fn series_name(wb: &Workbook, sheet: usize, name: &str) -> String { t.to_string() } -fn effective_kind(chart_kind: ChartKind, series_kind: Option, i: usize) -> ChartKind { +pub(crate) fn effective_kind(chart_kind: ChartKind, series_kind: Option, i: usize) -> ChartKind { match (chart_kind, series_kind) { (ChartKind::Combo, Some(k)) if k != ChartKind::Combo => k, (ChartKind::Combo, _) => { @@ -185,9 +185,15 @@ fn effective_kind(chart_kind: ChartKind, series_kind: Option, i: usiz } } +/// The kind series `i` is drawn as: its own kind in a combo chart (first series a column, the +/// rest lines when it has none), else the chart's. The one rule shared by resolving and drawing. +pub(crate) fn plot_kind(chart_kind: ChartKind, series_kind: Option, i: usize) -> ChartKind { + if chart_kind == ChartKind::Combo { effective_kind(chart_kind, series_kind, i) } else { chart_kind } +} + /// The kind series `i` of `chart` is drawn as: its own kind in a combo chart, else the chart's. pub fn series_plot_kind(chart: &Chart, i: usize) -> ChartKind { - if chart.kind == ChartKind::Combo { effective_kind(chart.kind, chart.series.get(i).and_then(|s| s.kind), i) } else { chart.kind } + plot_kind(chart.kind, chart.series.get(i).and_then(|s| s.kind), i) } /// Evaluates a chart's series formulas: names, values, categories (default "1", "2", …), X diff --git a/crates/chart/src/tests.rs b/crates/chart/src/tests.rs index 01f1aac49..4dcfcde84 100644 --- a/crates/chart/src/tests.rs +++ b/crates/chart/src/tests.rs @@ -1057,3 +1057,35 @@ fn area_charts_draw_source_value_labels_only_when_enabled() { assert!(first_label > last_poly, "{kind:?}: label drawn under a later area"); } } + +#[test] +fn drawing_and_resolving_agree_on_the_combo_plot_kind() { + // Resolved series carry `effective_kind`; drawing re-derives the kind from it. Both paths + // must give the same kind for every chart kind, series kind (or none) and series index. + let series_kinds = std::iter::once(None).chain(ALL_KINDS.iter().map(|k| Some(*k))); + for ck in ALL_KINDS { + for sk in series_kinds.clone() { + for i in 0..3 { + let mut c = chart(ck); + c.series = (0..3).map(|j| Series { kind: if j == i { sk } else { None }, ..Default::default() }).collect(); + let from_chart = series_plot_kind(&c, i); + let resolved = crate::resolve::effective_kind(ck, sk, i); + assert_eq!(crate::resolve::plot_kind(ck, Some(resolved), i), from_chart, "{ck:?} {sk:?} {i}"); + if ck != ChartKind::Combo { + assert_eq!(from_chart, ck); + } else { + assert_ne!(from_chart, ChartKind::Combo); + } + } + } + } +} + +#[test] +fn ticks_of_a_scale_off_the_step_grid_start_at_its_min() { + let s = Scale { min: 3.0, max: 7.0, step: 4.0 }; + assert_eq!(s.ticks(), vec![3.0, 7.0]); + // Aligned scales still snap float error away. + let s = Scale { min: 0.0, max: 1.0, step: 0.1 }; + assert!((s.ticks()[3] - 0.3).abs() < 1e-12 && s.ticks()[0] == 0.0); +} From 890870cd57167fda190716689b64897fcb16596e Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 26 Oct 2026 22:11:08 +0100 Subject: [PATCH 02/23] perf: select instead of sort in MEDIAN, PERCENTILE, QUARTILE, LARGE, SMALL Read the needed order statistics with select_nth_unstable_by(total_cmp) instead of a full sort. Results are bit-identical; a test compares them against the previous full-sort code on randomised data. Fixes #2679 Co-Authored-By: Claude Sonnet 5.5 --- crates/functions/src/stat.rs | 149 +++++++++++++++++++++++++++++++---- 1 file changed, 132 insertions(+), 17 deletions(-) diff --git a/crates/functions/src/stat.rs b/crates/functions/src/stat.rs index 344ba9ff8..4ef2625cc 100644 --- a/crates/functions/src/stat.rs +++ b/crates/functions/src/stat.rs @@ -51,10 +51,11 @@ fn median(v: Vec) -> R { if v.is_empty() { return Err(CellError::Num); } - let s = sorted(v); - let n = s.len(); - let hi = s.get(n / 2).copied().unwrap_or(0.0); - if n % 2 == 1 { Ok(hi) } else { Ok(s.get(n / 2 - 1).copied().unwrap_or(0.0).midpoint(hi)) } + let mut v = v; + let n = v.len(); + let (lower, hi, _) = v.select_nth_unstable_by(n / 2, f64::total_cmp); + let hi = *hi; + if n % 2 == 1 { Ok(hi) } else { Ok(lower.iter().copied().max_by(f64::total_cmp).unwrap_or(0.0).midpoint(hi)) } } /// Values occurring at least twice, ordered by first occurrence, with their (equal, maximal) count. @@ -125,29 +126,38 @@ fn kth(v: Vec, k: f64, largest: bool) -> R { if k.is_nan() || k < 1.0 || k > v.len() as f64 { return Err(CellError::Num); } - let s = sorted(v); + let mut v = v; let k = k as usize; - let idx = if largest { s.len() - k } else { k - 1 }; - s.get(idx).copied().ok_or(CellError::Num) + let idx = if largest { v.len() - k } else { k - 1 }; + // Selection instead of a full sort; same total order, so the same bit pattern as `sorted(v)[idx]`. + Ok(*v.select_nth_unstable_by(idx, f64::total_cmp).1) } -fn interp(s: &[f64], pos: f64) -> R { +/// Value at fractional rank `pos` of the sorted data, linearly interpolated, found by selection. +fn interp(mut v: Vec, pos: f64) -> R { let i = pos.floor(); let frac = pos - i; let i = i as usize; - let lo = *s.get(i).ok_or(CellError::Num)?; - match s.get(i + 1) { - Some(hi) if frac > 0.0 => Ok(lo + frac * (hi - lo)), - _ => Ok(lo), + if i >= v.len() { + return Err(CellError::Num); } + let (_, lo, upper) = v.select_nth_unstable_by(i, f64::total_cmp); + let lo = *lo; + // The next order statistic is the minimum of the upper partition. + if frac > 0.0 + && let Some(hi) = upper.iter().copied().min_by(f64::total_cmp) + { + return Ok(lo + frac * (hi - lo)); + } + Ok(lo) } fn percentile_inc(v: Vec, k: f64) -> R { if v.is_empty() || !(0.0..=1.0).contains(&k) { return Err(CellError::Num); } - let s = sorted(v); - interp(&s, k * (s.len() - 1) as f64) + let pos = k * (v.len() - 1) as f64; + interp(v, pos) } fn percentile_exc(v: Vec, k: f64) -> R { @@ -159,8 +169,7 @@ fn percentile_exc(v: Vec, k: f64) -> R { if pos < 1.0 || pos > n { return Err(CellError::Num); } - let s = sorted(v); - interp(&s, pos - 1.0) + interp(v, pos - 1.0) } fn quartile_inc(v: Vec, q: f64) -> R { @@ -2274,7 +2283,9 @@ pub(crate) fn specs() -> Vec { #[cfg(test)] mod tests { - use super::{MAX_REGRESSORS, aggregate_values, aggregate_values_k, check_regression_size}; + use super::{ + MAX_REGRESSORS, R, aggregate_values, aggregate_values_k, check_regression_size, kth, median, percentile_exc, percentile_inc, sorted, + }; use crate::util::testutil::*; use gridcraft_core::{CellError, Value}; @@ -2463,6 +2474,110 @@ mod tests { close(ev("RANK.AVG", vec![n(3.5), r2, n(1.0)]), 3.5); } + // Reference implementations: the previous full-sort versions, as the oracle for the selection ones. + fn old_interp(s: &[f64], pos: f64) -> R { + let i = pos.floor(); + let frac = pos - i; + let i = i as usize; + let lo = *s.get(i).ok_or(CellError::Num)?; + match s.get(i + 1) { + Some(hi) if frac > 0.0 => Ok(lo + frac * (hi - lo)), + _ => Ok(lo), + } + } + + fn old_median(v: Vec) -> R { + if v.is_empty() { + return Err(CellError::Num); + } + let s = sorted(v); + let n = s.len(); + let hi = s.get(n / 2).copied().unwrap_or(0.0); + if n % 2 == 1 { Ok(hi) } else { Ok(s.get(n / 2 - 1).copied().unwrap_or(0.0).midpoint(hi)) } + } + + fn old_kth(v: Vec, k: f64, largest: bool) -> R { + let k = k.ceil(); + if k.is_nan() || k < 1.0 || k > v.len() as f64 { + return Err(CellError::Num); + } + let s = sorted(v); + let k = k as usize; + let idx = if largest { s.len() - k } else { k - 1 }; + s.get(idx).copied().ok_or(CellError::Num) + } + + fn old_percentile_inc(v: Vec, k: f64) -> R { + if v.is_empty() || !(0.0..=1.0).contains(&k) { + return Err(CellError::Num); + } + let s = sorted(v); + old_interp(&s, k * (s.len() - 1) as f64) + } + + fn old_percentile_exc(v: Vec, k: f64) -> R { + let n = v.len() as f64; + if v.is_empty() || !(k > 0.0 && k < 1.0) { + return Err(CellError::Num); + } + let pos = k * (n + 1.0); + if pos < 1.0 || pos > n { + return Err(CellError::Num); + } + let s = sorted(v); + old_interp(&s, pos - 1.0) + } + + /// Same Ok bit pattern or same error. + fn same(a: R, b: R) -> bool { + match (a, b) { + (Ok(x), Ok(y)) => x.to_bits() == y.to_bits(), + (Err(x), Err(y)) => x == y, + _ => false, + } + } + + #[test] + fn selection_matches_full_sort_bit_for_bit() { + let mut state = 0x9E37_79B9_7F4A_7C15_u64; + let mut next = move || { + state ^= state << 13; + state ^= state >> 7; + state ^= state << 17; + state + }; + let specials = [0.0, -0.0, f64::INFINITY, f64::NEG_INFINITY, f64::NAN, -f64::NAN, f64::MAX, f64::MIN_POSITIVE, 1.0, -1.0]; + for len in (0..=40).chain([63, 64, 65, 100, 257, 1000, 4096]) { + for round in 0..12 { + let v: Vec = (0..len) + .map(|_| match (round % 4, next() % 8) { + // Few distinct values: many ties. + (0, r) => (r % 3) as f64, + // Specials, including both zeros and NaNs. + (1, r) => specials[(next() as usize + r as usize) % specials.len()], + // Wide random range. + (2, _) => (next() as i64 as f64) / 1e3, + // Integers with some specials mixed in. + (_, 0) => specials[next() as usize % specials.len()], + _ => (next() % 50) as f64, + }) + .collect(); + assert!(same(median(v.clone()), old_median(v.clone())), "median len={len}"); + let ks = [-1.0, 0.0, 0.2, 1.0, 1.5, 2.0, len as f64 / 2.0, len as f64 - 0.5, len as f64, len as f64 + 1.0, f64::NAN]; + for &k in &ks { + for largest in [true, false] { + assert!(same(kth(v.clone(), k, largest), old_kth(v.clone(), k, largest)), "kth len={len} k={k}"); + } + } + let ps = [-0.1, 0.0, 0.01, 0.1, 0.25, 0.3, 0.5, 0.75, 0.9, 0.99, 1.0, 1.1, f64::NAN, (next() % 1001) as f64 / 1000.0]; + for &p in &ps { + assert!(same(percentile_inc(v.clone(), p), old_percentile_inc(v.clone(), p)), "pinc len={len} p={p}"); + assert!(same(percentile_exc(v.clone(), p), old_percentile_exc(v.clone(), p)), "pexc len={len} p={p}"); + } + } + } + } + #[test] fn percentiles() { close(ev("PERCENTILE.INC", vec![c(&[1.0, 3.0, 2.0, 4.0]), n(0.3)]), 1.9); From c567511cf1e5335e4078e5bdd2036e14e72293c6 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 26 Oct 2026 22:21:15 +0100 Subject: [PATCH 03/23] calc: rebuild the dependency graph from shared syntax trees Calc::rebuild and Calc::cells_changed called Formula::expr(), which deep-copies each formula's syntax tree only to read its references. Use expr_arc() (already used when formulas are calculated), which hands out the cached Arc without copying. Co-Authored-By: Claude Sonnet 5.5 --- crates/calc/src/recalc.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/calc/src/recalc.rs b/crates/calc/src/recalc.rs index 17a0e6649..6cd1551fe 100644 --- a/crates/calc/src/recalc.rs +++ b/crates/calc/src/recalc.rs @@ -1010,7 +1010,7 @@ impl Calc { for (si, sheet) in wb.sheets.iter().enumerate() { for (c, cell) in sheet.cells.iter() { if let Some(f) = &cell.formula - && let Some(e) = f.expr() + && let Some(e) = f.expr_arc() { let (areas, dynamic) = precedents(wb, si, &e); self.graph.insert((si, c), Node { areas, dynamic }); @@ -1088,7 +1088,7 @@ impl Calc { let mut cleared: Vec<(usize, RangeRef)> = Vec::new(); for &k in changed { let formula = wb.sheet(k.0).and_then(|s| s.cell(k.1)).and_then(|c| c.formula.clone()); - match formula.and_then(|f| f.expr()) { + match formula.and_then(|f| f.expr_arc()) { Some(e) => { let (areas, dynamic) = precedents(wb, k.0, &e); self.graph.insert(k, Node { areas, dynamic }); From 4523b817ce53be54a9ce9d5b5b49761c3907a75d Mon Sep 17 00:00:00 2001 From: vangel76 Date: Thu, 29 Oct 2026 00:51:12 +0100 Subject: [PATCH 04/23] Read already-sorted input directly instead of selecting A full sort recognises ascending and descending runs in O(n), which a selection cannot beat, so sorted, reverse-sorted and tie-block input was up to 1.8x slower than before. Check for a monotone run first (one pass that stops at the first out-of-order pair) and index into it. Extend the bit-for-bit oracle test with ascending, descending and nearly ascending data. Co-Authored-By: Claude Sonnet 5.5 --- crates/functions/src/stat/descriptive.rs | 32 +++++++++++++++++++++++- crates/functions/src/stat/tests.rs | 22 +++++++++++++--- 2 files changed, 50 insertions(+), 4 deletions(-) diff --git a/crates/functions/src/stat/descriptive.rs b/crates/functions/src/stat/descriptive.rs index 49d30d42c..08938d039 100644 --- a/crates/functions/src/stat/descriptive.rs +++ b/crates/functions/src/stat/descriptive.rs @@ -38,12 +38,31 @@ pub(super) fn sorted(mut v: Vec) -> Vec { v } +/// Sorts `v` if it is already monotone in `total_cmp` order (a descending slice is reversed) and +/// says whether it did. One cheap pass that stops at the first out-of-order pair, so unordered +/// data costs next to nothing. Needed because the std sort recognises ascending and descending +/// runs in O(n), which a selection cannot beat. +fn sorted_if_monotone(v: &mut [f64]) -> bool { + if v.is_sorted_by(|a, b| a.total_cmp(b).is_le()) { + return true; + } + if v.is_sorted_by(|a, b| a.total_cmp(b).is_ge()) { + v.reverse(); + return true; + } + false +} + pub(super) fn median(v: Vec) -> R { if v.is_empty() { return Err(CellError::Num); } let mut v = v; let n = v.len(); + if sorted_if_monotone(&mut v) { + let hi = v.get(n / 2).copied().unwrap_or(0.0); + return if n % 2 == 1 { Ok(hi) } else { Ok(v.get(n / 2 - 1).copied().unwrap_or(0.0).midpoint(hi)) }; + } let (lower, hi, _) = v.select_nth_unstable_by(n / 2, f64::total_cmp); let hi = *hi; if n % 2 == 1 { Ok(hi) } else { Ok(lower.iter().copied().max_by(f64::total_cmp).unwrap_or(0.0).midpoint(hi)) } @@ -120,7 +139,11 @@ pub(super) fn kth(v: Vec, k: f64, largest: bool) -> R { let mut v = v; let k = k as usize; let idx = if largest { v.len() - k } else { k - 1 }; - // Selection instead of a full sort; same total order, so the same bit pattern as `sorted(v)[idx]`. + // Selection instead of a full sort (monotone input is read directly); same total order, so the + // same bit pattern as `sorted(v)[idx]`. + if sorted_if_monotone(&mut v) { + return v.get(idx).copied().ok_or(CellError::Num); + } Ok(*v.select_nth_unstable_by(idx, f64::total_cmp).1) } @@ -132,6 +155,13 @@ fn interp(mut v: Vec, pos: f64) -> R { if i >= v.len() { return Err(CellError::Num); } + if sorted_if_monotone(&mut v) { + let lo = v.get(i).copied().ok_or(CellError::Num)?; + return match v.get(i + 1) { + Some(hi) if frac > 0.0 => Ok(lo + frac * (hi - lo)), + _ => Ok(lo), + }; + } let (_, lo, upper) = v.select_nth_unstable_by(i, f64::total_cmp); let lo = *lo; // The next order statistic is the minimum of the upper partition. diff --git a/crates/functions/src/stat/tests.rs b/crates/functions/src/stat/tests.rs index 60da9f59e..f11629369 100644 --- a/crates/functions/src/stat/tests.rs +++ b/crates/functions/src/stat/tests.rs @@ -263,8 +263,8 @@ fn selection_matches_full_sort_bit_for_bit() { }; let specials = [0.0, -0.0, f64::INFINITY, f64::NEG_INFINITY, f64::NAN, -f64::NAN, f64::MAX, f64::MIN_POSITIVE, 1.0, -1.0]; for len in (0..=40).chain([63, 64, 65, 100, 257, 1000, 4096]) { - for round in 0..12 { - let v: Vec = (0..len) + for round in 0..24 { + let mut v: Vec = (0..len) .map(|_| match (round % 4, next() % 8) { // Few distinct values: many ties. (0, r) => (r % 3) as f64, @@ -277,7 +277,23 @@ fn selection_matches_full_sort_bit_for_bit() { _ => (next() % 50) as f64, }) .collect(); - assert!(same(median(v.clone()), old_median(v.clone())), "median len={len}"); + // Rounds 12..24 repeat the data as already ascending, already descending and nearly + // ascending (ends swapped): the monotone shortcut and its fallback to selection. + match round / 4 { + 3 => v.sort_by(f64::total_cmp), + 4 => { + v.sort_by(f64::total_cmp); + v.reverse(); + } + 5 => { + v.sort_by(f64::total_cmp); + if let Some(last) = v.len().checked_sub(1) { + v.swap(0, last); + } + } + _ => {} + } + assert!(same(median(v.clone()), old_median(v.clone())), "median len={len} round={round}"); let ks = [-1.0, 0.0, 0.2, 1.0, 1.5, 2.0, len as f64 / 2.0, len as f64 - 0.5, len as f64, len as f64 + 1.0, f64::NAN]; for &k in &ks { for largest in [true, false] { From b6bbdcd5cf23626171768d0cf061e4b0319a67c1 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Thu, 29 Oct 2026 00:51:45 +0100 Subject: [PATCH 05/23] calc: build precedents from a borrowed syntax tree, no Arc for text-only formulas Formula::with_expr runs a closure on the cached tree, or on a freshly parsed one that is dropped afterwards, so rebuild and cells_changed need neither a tree copy nor an Arc allocation / reference count. Co-Authored-By: Claude Sonnet 5.5 --- crates/calc/src/recalc.rs | 11 +++++------ crates/calc/src/tests.rs | 39 +++++++++++++++++++++++++++++++++++++++ crates/model/src/cell.rs | 10 ++++++++++ 3 files changed, 54 insertions(+), 6 deletions(-) diff --git a/crates/calc/src/recalc.rs b/crates/calc/src/recalc.rs index 34c712ddc..9886ca9bd 100644 --- a/crates/calc/src/recalc.rs +++ b/crates/calc/src/recalc.rs @@ -128,9 +128,8 @@ impl Calc { for (si, sheet) in wb.sheets.iter().enumerate() { for (c, cell) in sheet.cells.iter() { if let Some(f) = &cell.formula - && let Some(e) = f.expr_arc() + && let Some((areas, dynamic)) = f.with_expr(|e| precedents(wb, si, e)) { - let (areas, dynamic) = precedents(wb, si, &e); self.graph.insert((si, c), Node { areas, dynamic }); if cell.value == Value::Error(CellError::Circ) { circ.push((si, c)); @@ -205,10 +204,10 @@ impl Calc { pub fn cells_changed(&mut self, wb: &mut Workbook, changed: &[Key]) { let mut cleared: Vec<(usize, RangeRef)> = Vec::new(); for &k in changed { - let formula = wb.sheet(k.0).and_then(|s| s.cell(k.1)).and_then(|c| c.formula.clone()); - match formula.and_then(|f| f.expr_arc()) { - Some(e) => { - let (areas, dynamic) = precedents(wb, k.0, &e); + let precedent = + wb.sheet(k.0).and_then(|s| s.cell(k.1)).and_then(|c| c.formula.as_ref()).and_then(|f| f.with_expr(|e| precedents(wb, k.0, e))); + match precedent { + Some((areas, dynamic)) => { self.graph.insert(k, Node { areas, dynamic }); } None => { diff --git a/crates/calc/src/tests.rs b/crates/calc/src/tests.rs index 5b9e90085..0d4c973d4 100644 --- a/crates/calc/src/tests.rs +++ b/crates/calc/src/tests.rs @@ -2274,3 +2274,42 @@ fn loan_schedule_residue_keeps_excels_sign() { } assert_eq!(t.get("P7"), Value::Text("".into())); } + +/// `rebuild` and `cells_changed` read formulas through `Formula::with_expr`, which borrows the +/// cached tree or parses on the fly. The dependency graph must come out the same for formulas that +/// carry their tree and for formulas that only carry their text (as after loading a file). +#[test] +fn graph_built_from_text_only_formulas_matches_cached() { + fn run(cached: bool) -> Vec { + let mut t = T::new(); + t.set("A1", "2"); + for (at, f) in [("B1", "=A1*10"), ("C1", "=B1+A1"), ("D1", "=SUM(A1:C1)"), ("E1", "=NOPE(")] { + // E1 does not parse, so it has no tree either way and only covers the `None` branch. + let mut formula = Formula::new(f); + if !cached { + formula.expr = None; + } + t.wb.sheet_mut(0).unwrap().cells.set(c(at), Cell::formula(formula)); + } + t.calc.rebuild(&t.wb); + t.calc.recalc_all(&mut t.wb); + let mut out: Vec = ["B1", "C1", "D1", "E1"].iter().map(|a| t.get(a)).collect(); + // Editing the input must reach the dependents through the rebuilt graph. + t.set("A1", "5"); + out.extend(["B1", "C1", "D1"].iter().map(|a| t.get(a))); + // A text-only formula that is rewritten through `cells_changed` joins the graph too. + let mut formula = Formula::new("=A1+100"); + if !cached { + formula.expr = None; + } + t.wb.sheet_mut(0).unwrap().cells.set(c("F1"), Cell::formula(formula)); + t.calc.cells_changed(&mut t.wb, &[(0, c("F1"))]); + t.set("A1", "7"); + out.push(t.get("F1")); + out + } + let cached = run(true); + assert_eq!(cached, run(false)); + assert_eq!(cached[4], Value::Number(50.0)); + assert_eq!(cached[7], Value::Number(107.0)); +} diff --git a/crates/model/src/cell.rs b/crates/model/src/cell.rs index d4466365f..7d3a7715e 100644 --- a/crates/model/src/cell.rs +++ b/crates/model/src/cell.rs @@ -46,6 +46,16 @@ impl Formula { pub fn expr_arc(&self) -> Option> { self.expr.clone().or_else(|| gridcraft_formula::parse(&self.text).ok().map(Arc::new)) } + /// Runs `f` on the parsed expression, borrowing the cached tree or, after deserialization, + /// a freshly parsed one that is dropped afterwards. Unlike [`expr`](Self::expr) it never copies + /// the tree, and unlike [`expr_arc`](Self::expr_arc) it needs no reference count or `Arc` + /// allocation. `None` when the text does not parse. + pub fn with_expr(&self, f: impl FnOnce(&Expr) -> R) -> Option { + match &self.expr { + Some(e) => Some(f(e)), + None => gridcraft_formula::parse(&self.text).ok().map(|e| f(&e)), + } + } } /// A static picture stored as cell content, independent of floating drawing anchors. From 8b330ac9e213c05906b4dee0468444e558f6e4a0 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Thu, 29 Oct 2026 00:52:59 +0100 Subject: [PATCH 06/23] Tighten selection comment and test index arithmetic Co-Authored-By: Claude Sonnet 5.5 --- crates/functions/src/stat/descriptive.rs | 7 ++++--- crates/functions/src/stat/tests.rs | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/crates/functions/src/stat/descriptive.rs b/crates/functions/src/stat/descriptive.rs index 08938d039..3c8f78f69 100644 --- a/crates/functions/src/stat/descriptive.rs +++ b/crates/functions/src/stat/descriptive.rs @@ -39,9 +39,10 @@ pub(super) fn sorted(mut v: Vec) -> Vec { } /// Sorts `v` if it is already monotone in `total_cmp` order (a descending slice is reversed) and -/// says whether it did. One cheap pass that stops at the first out-of-order pair, so unordered -/// data costs next to nothing. Needed because the std sort recognises ascending and descending -/// runs in O(n), which a selection cannot beat. +/// says whether it did. At most two passes, each stopping at the first out-of-order pair, so +/// unordered data costs next to nothing. Needed because the std sort recognises ascending and +/// descending runs in O(n) with a small constant; selection is O(n) too but measured 1.2-1.8x +/// slower on such input. fn sorted_if_monotone(v: &mut [f64]) -> bool { if v.is_sorted_by(|a, b| a.total_cmp(b).is_le()) { return true; diff --git a/crates/functions/src/stat/tests.rs b/crates/functions/src/stat/tests.rs index f11629369..023b36815 100644 --- a/crates/functions/src/stat/tests.rs +++ b/crates/functions/src/stat/tests.rs @@ -269,7 +269,7 @@ fn selection_matches_full_sort_bit_for_bit() { // Few distinct values: many ties. (0, r) => (r % 3) as f64, // Specials, including both zeros and NaNs. - (1, r) => specials[(next() as usize + r as usize) % specials.len()], + (1, r) => specials[(next() as usize).wrapping_add(r as usize) % specials.len()], // Wide random range. (2, _) => (next() as i64 as f64) / 1e3, // Integers with some specials mixed in. From 1ff10df55756d3823bcf91e3d3fc31462b6276b4 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Tue, 3 Nov 2026 20:56:00 +0100 Subject: [PATCH 07/23] fix(autosum): stop the guessed range at a blank cell (#3291) The single-cell AutoSum guess stepped over one blank cell when a number sat above it, so a second block of numbers below a gap was summed together with the first block. It now stops at the first non-number, as Excel does. Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/cmd/formulas.rs | 5 +---- crates/engine/src/tests.rs | 13 +++++++++++++ 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/cmd/formulas.rs b/crates/engine/src/cmd/formulas.rs index d72f814a1..b38571d64 100644 --- a/crates/engine/src/cmd/formulas.rs +++ b/crates/engine/src/cmd/formulas.rs @@ -179,10 +179,7 @@ fn guessed_range(sh: &gridcraft_model::sheet::Sheet, at: CellRef) -> Option 0 && (numeric(CellRef::new(at.row - 1, at.col)) || at.col == 0 || !numeric(CellRef::new(at.row, at.col - 1))) { let mut top = at.row - 1; - while top > 0 - && (numeric(CellRef::new(top - 1, at.col)) - || sh.value(CellRef::new(top - 1, at.col)).is_empty() && top > 1 && numeric(CellRef::new(top - 2, at.col))) - { + while top > 0 && numeric(CellRef::new(top - 1, at.col)) { top -= 1; } if numeric(CellRef::new(at.row - 1, at.col)) { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index fe42b6977..0fccb81e1 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2067,6 +2067,19 @@ fn formula_at(s: &Session, a: &str) -> Option { s.doc().unwrap().wb.active().unwrap().cell(CellRef::parse(a).unwrap()).and_then(|c| c.formula.as_ref().map(|f| f.text.clone())) } +#[test] +fn autosum_of_a_single_cell_stops_at_a_blank_cell() { + // #3291: a blank row between two blocks of numbers ended the guess one block too late, so the + // second total summed the first block (and its total) as well. + let mut s = s(); + s.execute("range.setValues", json!({"range": "B4", "values": [[1], [2], [3]]})).unwrap(); + s.execute("cell.set", json!({"cell": "B7", "input": "=SUM(B4:B6)"})).unwrap(); + s.execute("range.setValues", json!({"range": "B9", "values": [[10], [20]]})).unwrap(); + s.execute("selection.set", json!({"cell": "B11"})).unwrap(); + s.execute("formulas.autoSum", json!({})).unwrap(); + assert_eq!(v(&s, "B11"), Value::Number(30.0)); +} + #[test] fn autosum_over_a_row_totals_it_to_the_right() { // As in Excel: a row selected and AutoSum puts one total to its right, not a one-cell total From b3a709f314ef7203959f23a121836868342451aa Mon Sep 17 00:00:00 2001 From: vangel76 Date: Wed, 4 Nov 2026 02:12:09 +0100 Subject: [PATCH 08/23] contributors: set names for vangel76 Co-Authored-By: Claude Sonnet 5.5 --- contributors/people.toml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/contributors/people.toml b/contributors/people.toml index 00f6ce76a..1b8bcf996 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -33,7 +33,8 @@ sync_github_name = false [people.vangel76] real_name = "Valentin Klein" -sync_github_name = true +display_name = "vangel76" +sync_github_name = false [people.MacJedi42] sync_github_name = true From cb059716592935b9b0b9542b7e80d34a4748631e Mon Sep 17 00:00:00 2001 From: Zinadin Zidan Date: Thu, 5 Nov 2026 11:47:39 +0600 Subject: [PATCH 09/23] =?UTF-8?q?i18n:=20German=20context=20menus'=20Inser?= =?UTF-8?q?t=20reads=20"Zellen=20einf=C3=BCgen",=20not=20a=20second=20"Ein?= =?UTF-8?q?f=C3=BCgen"=20(#2874)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit German showed "Einfügen" for Paste and again for Insert in the cell menu ("Einfügen…") and the row and column header menus ("Einfügen"). The cell menu's Insert… now reads "Zellen einfügen…", as Excel's German menu does. The header menus' Insert gets its own key, "Header Menu|Insert", German "Zellen einfügen"; the plain "Insert" stays as the Insert dialog's title and button use it, and every other catalog gets the new key with the text it shows today. A test reads the three menus' items from menus.rs and checks that no two read alike in German. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/grid/menus.rs | 4 +-- crates/ui-egui/src/grid/tests.rs | 46 ++++++++++++++++++++++++++++++++ crates/ui-egui/src/i18n/cs.rs | 1 + crates/ui-egui/src/i18n/de.rs | 3 ++- crates/ui-egui/src/i18n/el.rs | 1 + crates/ui-egui/src/i18n/es.rs | 1 + crates/ui-egui/src/i18n/fr.rs | 1 + crates/ui-egui/src/i18n/it.rs | 1 + crates/ui-egui/src/i18n/ja.rs | 1 + crates/ui-egui/src/i18n/ko.rs | 1 + crates/ui-egui/src/i18n/nl.rs | 1 + crates/ui-egui/src/i18n/pl.rs | 1 + crates/ui-egui/src/i18n/pt.rs | 1 + crates/ui-egui/src/i18n/ro.rs | 1 + crates/ui-egui/src/i18n/ru.rs | 1 + crates/ui-egui/src/i18n/sr.rs | 1 + crates/ui-egui/src/i18n/tr.rs | 1 + crates/ui-egui/src/i18n/uk.rs | 1 + crates/ui-egui/src/i18n/vi.rs | 1 + crates/ui-egui/src/i18n/zh.rs | 1 + 20 files changed, 67 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/grid/menus.rs b/crates/ui-egui/src/grid/menus.rs index 575dac504..e9ee1a976 100644 --- a/crates/ui-egui/src/grid/menus.rs +++ b/crates/ui-egui/src/grid/menus.rs @@ -496,7 +496,7 @@ pub(super) fn header_menu(app: &mut SheetApp, ui: &mut egui::Ui, was_open: bool) ("Copy", "edit.copy"), ("Paste", "edit.paste"), ("-", ""), - ("Insert", "home.insertRows"), + ("Header Menu|Insert", "home.insertRows"), ("Delete", "home.deleteRows"), ("Clear Contents", "edit.clearContents"), ("-", ""), @@ -513,7 +513,7 @@ pub(super) fn header_menu(app: &mut SheetApp, ui: &mut egui::Ui, was_open: bool) ("Copy", "edit.copy"), ("Paste", "edit.paste"), ("-", ""), - ("Insert", "home.insertColumns"), + ("Header Menu|Insert", "home.insertColumns"), ("Delete", "home.deleteColumns"), ("Clear Contents", "edit.clearContents"), ("-", ""), diff --git a/crates/ui-egui/src/grid/tests.rs b/crates/ui-egui/src/grid/tests.rs index 74abfc861..427c67629 100644 --- a/crates/ui-egui/src/grid/tests.rs +++ b/crates/ui-egui/src/grid/tests.rs @@ -180,6 +180,52 @@ fn filter_menu_uses_the_table_whose_header_was_clicked() { assert_eq!(filter_table_range(&sh, 1, 4), None); } +/// The `("Label", "id")` keys written in `src`, without separators. +fn menu_labels(src: &str) -> Vec<&str> { + src.split("(\"").skip(1).filter_map(|rest| rest.split_once("\", \"").map(|(label, _)| label)).filter(|label| *label != "-").collect() +} + +/// The source of `menus.rs` from `start` to the first `end` after it. +fn menu_source<'a>(src: &'a str, start: &str, end: &str) -> &'a str { + let from = src.find(start).unwrap_or_else(|| panic!("{start:?} not in menus.rs")); + let rest = &src[from..]; + &rest[..rest.find(end).unwrap_or_else(|| panic!("{end:?} not after {start:?}"))] +} + +/// German read "Einfügen" for Paste and "Einfügen…"/"Einfügen" for Insert in the same menu +/// (#2874): no two items of the cell, row header or column header menu may read alike, ignoring +/// case and a trailing ellipsis. The items are read from `menus.rs`, so new ones are checked too. +#[test] +fn german_context_menus_have_no_two_items_alike() { + use crate::i18n::Language; + let src = include_str!("menus.rs"); + let cell = menu_labels(menu_source(src, "fn context_menu(", "let lang =")); + let (rows, columns) = + menu_source(src, "fn header_menu(", "let lang =").split_once("} else {").expect("header menu has a rows and a columns list"); + let insert_copied = menu_labels(menu_source(src, "fn insert_copied_item(", "\n}")); + assert_eq!(insert_copied.len(), 3, "{insert_copied:?}"); + for (menu, keys) in [("cell", cell), ("row header", menu_labels(rows)), ("column header", menu_labels(columns))] { + assert!(keys.contains(&"Paste") && keys.len() > 8, "{menu} menu not read from menus.rs: {keys:?}"); + // A menu holds at most one Insert Copied/Cut Cells entry. + for extra in &insert_copied { + let mut seen: Vec<(String, &str)> = Vec::new(); + for key in keys.iter().chain([extra]) { + let shown = Language::De.tr(key).trim_end_matches('…').trim().to_lowercase(); + if let Some((_, other)) = seen.iter().find(|(text, _)| *text == shown) { + panic!("German {menu} menu shows {key:?} and {other:?} both as {shown:?}"); + } + seen.push((shown, key)); + } + } + } + assert_eq!(Language::De.tr("Insert…"), "Zellen einfügen…"); + assert_eq!(Language::De.tr("Header Menu|Insert"), "Zellen einfügen"); + // Only German changed: the header menu's Insert reads as the plain Insert everywhere else. + for language in Language::ALL.into_iter().filter(|l| *l != Language::De) { + assert_eq!(language.tr("Header Menu|Insert"), language.tr("Insert"), "{}", language.code()); + } +} + #[test] fn cell_text_y_moves_monotonically_in_standard_and_tall_rows() { let rect_std = Rect::from_min_max(pos2(0.0, 0.0), pos2(100.0, 20.0)); diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index 58d308413..39e6337dd 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -57,6 +57,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Format\nPane" => "Podokno\nformátu", "Format" => "Formát", "Insert" => "Vložit", + "Header Menu|Insert" => "Vložit", "Insert Cells…" => "Vložit buňky…", "Delete" => "Odstranit", "Delete Cells…" => "Odstranit buňky…", diff --git a/crates/ui-egui/src/i18n/de.rs b/crates/ui-egui/src/i18n/de.rs index 2e6ba713f..f136e483b 100644 --- a/crates/ui-egui/src/i18n/de.rs +++ b/crates/ui-egui/src/i18n/de.rs @@ -57,6 +57,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Format\nPane" => "Format-\nbereich", "Format" => "Format", "Insert" => "Einfügen", + "Header Menu|Insert" => "Zellen einfügen", "Insert Cells…" => "Zellen einfügen…", "Delete" => "Löschen", "Select Data…" => "Daten auswählen…", @@ -476,7 +477,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { // grid.rs cell and row/column header context menus. "Cut" => "Ausschneiden", "Copy" => "Kopieren", - "Insert…" => "Einfügen…", + "Insert…" => "Zellen einfügen…", "Delete…" => "Löschen…", "Filter by Selected Cell's Value" => "Nach Wert der gewählten Zelle filtern", "New Comment" => "Neuer Kommentar", diff --git a/crates/ui-egui/src/i18n/el.rs b/crates/ui-egui/src/i18n/el.rs index 218fa5a6f..dc57982c7 100644 --- a/crates/ui-egui/src/i18n/el.rs +++ b/crates/ui-egui/src/i18n/el.rs @@ -56,6 +56,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Format\nPane" => "Παράθυρο\nμορφοποίησης", "Format" => "Μορφοποίηση", "Insert" => "Εισαγωγή", + "Header Menu|Insert" => "Εισαγωγή", "Insert Cells…" => "Εισαγωγή κελιών…", "Delete" => "Διαγραφή", "Delete Cells…" => "Διαγραφή κελιών…", diff --git a/crates/ui-egui/src/i18n/es.rs b/crates/ui-egui/src/i18n/es.rs index b6adcf774..3c15a16b1 100644 --- a/crates/ui-egui/src/i18n/es.rs +++ b/crates/ui-egui/src/i18n/es.rs @@ -60,6 +60,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Format\nPane" => "Panel\nde formato", "Format" => "Formato", "Insert" => "Insertar", + "Header Menu|Insert" => "Insertar", "Insert Cells…" => "Insertar celdas…", "Delete" => "Eliminar", "Delete Cells…" => "Eliminar celdas…", diff --git a/crates/ui-egui/src/i18n/fr.rs b/crates/ui-egui/src/i18n/fr.rs index e11ef4599..3e95d4b72 100644 --- a/crates/ui-egui/src/i18n/fr.rs +++ b/crates/ui-egui/src/i18n/fr.rs @@ -67,6 +67,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Format\nPane" => "Volet\nFormat", "Format" => "Format", "Insert" => "Insertion", + "Header Menu|Insert" => "Insertion", "Insert Cells…" => "Insérer des cellules…", "Delete" => "Supprimer", "Delete Cells…" => "Supprimer des cellules…", diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index 9a371e673..3ad0ff328 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -60,6 +60,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Format\nPane" => "Riquadro\nFormato", "Format" => "Formato", "Insert" => "Inserisci", + "Header Menu|Insert" => "Inserisci", "Insert Cells…" => "Inserisci celle…", "Delete" => "Elimina", "Delete Cells…" => "Elimina celle…", diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index 47fd0e9f3..19fc85f02 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -64,6 +64,7 @@ pub(super) const CORE: &[(&str, &str)] = &[ ("Format\nPane", "書式ウィンドウ"), ("Format", "書式"), ("Insert", "挿入"), + ("Header Menu|Insert", "挿入"), ("Insert Cells…", "セルの挿入…"), ("Delete", "削除"), ("Delete Cells…", "セルの削除…"), diff --git a/crates/ui-egui/src/i18n/ko.rs b/crates/ui-egui/src/i18n/ko.rs index 906dea54f..8c28be26e 100644 --- a/crates/ui-egui/src/i18n/ko.rs +++ b/crates/ui-egui/src/i18n/ko.rs @@ -62,6 +62,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Format\nPane" => "서식\n창", "Format" => "서식", "Insert" => "삽입", + "Header Menu|Insert" => "삽입", "Insert Cells…" => "셀 삽입…", "Delete" => "삭제", "Delete Cells…" => "셀 삭제…", diff --git a/crates/ui-egui/src/i18n/nl.rs b/crates/ui-egui/src/i18n/nl.rs index b1d415211..b5cddb88d 100644 --- a/crates/ui-egui/src/i18n/nl.rs +++ b/crates/ui-egui/src/i18n/nl.rs @@ -61,6 +61,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Format\nPane" => "Opmaak-\nvenster", "Format" => "Opmaak", "Insert" => "Invoegen", + "Header Menu|Insert" => "Invoegen", "Insert Cells…" => "Cellen invoegen…", "Delete" => "Verwijderen", "Delete Cells…" => "Cellen verwijderen…", diff --git a/crates/ui-egui/src/i18n/pl.rs b/crates/ui-egui/src/i18n/pl.rs index ad9c254c1..f2bfd72c7 100644 --- a/crates/ui-egui/src/i18n/pl.rs +++ b/crates/ui-egui/src/i18n/pl.rs @@ -56,6 +56,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Format\nPane" => "Okienko\nformatowania", "Format" => "Formatuj", "Insert" => "Wstaw", + "Header Menu|Insert" => "Wstaw", "Insert Cells…" => "Wstaw komórki…", "Delete" => "Usuń", "Delete Cells…" => "Usuń komórki…", diff --git a/crates/ui-egui/src/i18n/pt.rs b/crates/ui-egui/src/i18n/pt.rs index f78ef4cf5..d525f027e 100644 --- a/crates/ui-egui/src/i18n/pt.rs +++ b/crates/ui-egui/src/i18n/pt.rs @@ -59,6 +59,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Format\nPane" => "Painel de\nFormatação", "Format" => "Formatar", "Insert" => "Inserir", + "Header Menu|Insert" => "Inserir", "Insert Cells…" => "Inserir Células…", "Delete" => "Excluir", "Delete Cells…" => "Excluir Células…", diff --git a/crates/ui-egui/src/i18n/ro.rs b/crates/ui-egui/src/i18n/ro.rs index 738fc554f..66eb854b7 100644 --- a/crates/ui-egui/src/i18n/ro.rs +++ b/crates/ui-egui/src/i18n/ro.rs @@ -63,6 +63,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Format\nPane" => "Panou\nformatare", "Format" => "Format", "Insert" => "Inserare", + "Header Menu|Insert" => "Inserare", "Insert Cells…" => "Inserare celule…", "Delete" => "Ștergere", "Delete Cells…" => "Ștergere celule…", diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index 60141a853..00a3062bd 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -62,6 +62,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Format\nPane" => "Область\nформатирования", "Format" => "Формат", "Insert" => "Вставить", + "Header Menu|Insert" => "Вставить", "Insert Cells…" => "Вставить ячейки…", "Delete" => "Удалить", "Delete Cells…" => "Удалить ячейки…", diff --git a/crates/ui-egui/src/i18n/sr.rs b/crates/ui-egui/src/i18n/sr.rs index d02c66bb8..9254dc938 100644 --- a/crates/ui-egui/src/i18n/sr.rs +++ b/crates/ui-egui/src/i18n/sr.rs @@ -63,6 +63,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Format\nPane" => "Okno za\noblikovanje", "Format" => "Oblikovanje", "Insert" => "Umetni", + "Header Menu|Insert" => "Umetni", "Insert Cells…" => "Umetni ćelije…", "Delete" => "Obriši", "Delete Cells…" => "Obriši ćelije…", diff --git a/crates/ui-egui/src/i18n/tr.rs b/crates/ui-egui/src/i18n/tr.rs index a58505848..c8da06cb1 100644 --- a/crates/ui-egui/src/i18n/tr.rs +++ b/crates/ui-egui/src/i18n/tr.rs @@ -59,6 +59,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Format\nPane" => "Biçim\nBölmesi", "Format" => "Biçim", "Insert" => "Ekle", + "Header Menu|Insert" => "Ekle", "Insert Cells…" => "Hücre Ekle…", "Delete" => "Sil", "Delete Cells…" => "Hücreleri Sil…", diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index 96070eaaf..35fc9f0f7 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -58,6 +58,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Format\nPane" => "Область\nформатування", "Format" => "Формат", "Insert" => "Вставити", + "Header Menu|Insert" => "Вставити", "Insert Cells…" => "Вставити комірки…", "Delete" => "Видалити", "Delete Cells…" => "Видалити комірки…", diff --git a/crates/ui-egui/src/i18n/vi.rs b/crates/ui-egui/src/i18n/vi.rs index 7a188a08e..8cb5a32ac 100644 --- a/crates/ui-egui/src/i18n/vi.rs +++ b/crates/ui-egui/src/i18n/vi.rs @@ -64,6 +64,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Format\nPane" => "Ngăn\nđịnh dạng", "Format" => "Định dạng", "Insert" => "Chèn", + "Header Menu|Insert" => "Chèn", "Insert Cells…" => "Chèn ô…", "Delete" => "Xóa", "Delete Cells…" => "Xóa ô…", diff --git a/crates/ui-egui/src/i18n/zh.rs b/crates/ui-egui/src/i18n/zh.rs index 601e0a1bb..e71a0df3f 100644 --- a/crates/ui-egui/src/i18n/zh.rs +++ b/crates/ui-egui/src/i18n/zh.rs @@ -62,6 +62,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Format\nPane" => "格式\n窗格", "Format" => "格式", "Insert" => "插入", + "Header Menu|Insert" => "插入", "Insert Cells…" => "插入单元格…", "Delete" => "删除", "Delete Cells…" => "删除单元格…", From d628abb7b019cbb9e2230b93d4f2c8be4774b98f Mon Sep 17 00:00:00 2001 From: Helen Kwok <107752876+helenkwok@users.noreply.github.com> Date: Mon, 26 Oct 2026 08:53:11 +1030 Subject: [PATCH 10/23] Web: page host mode, so a page can host GridCraft in its own document (#2592) data-host="page" on the canvas opts a page that loads the module into its own document into the host bridge. The ?host=parent messages travel as non-bubbling CustomEvents on the canvas; Bridge holds a Transport (Parent or Page) and shares the save, dirty and ready logic. Unframed ?host=parent still gets no bridge. Hosting guide documents both modes. --- apps/gridcraft-web/Cargo.toml | 2 +- apps/gridcraft-web/src/host.rs | 184 ++++++++++++++++++++++------- apps/gridcraft-web/src/hostmode.rs | 45 +++++++ apps/gridcraft-web/src/main.rs | 12 +- apps/gridcraft-web/src/web.rs | 15 ++- packaging/web/README.md | 72 +++++++++++ 6 files changed, 279 insertions(+), 51 deletions(-) create mode 100644 apps/gridcraft-web/src/hostmode.rs diff --git a/apps/gridcraft-web/Cargo.toml b/apps/gridcraft-web/Cargo.toml index 41059d379..e384ffe09 100644 --- a/apps/gridcraft-web/Cargo.toml +++ b/apps/gridcraft-web/Cargo.toml @@ -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", "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"] } diff --git a/apps/gridcraft-web/src/host.rs b/apps/gridcraft-web/src/host.rs index 2286023e5..8130d2682 100644 --- a/apps/gridcraft-web/src/host.rs +++ b/apps/gridcraft-web/src/host.rs @@ -1,5 +1,10 @@ -//! The `postMessage` bridge to a same-origin parent page (`?host=parent`), for pages that embed -//! GridCraft in an `