diff --git a/ROADMAP.md b/ROADMAP.md index 070552d07..aa9784031 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:** minor (formula-entry Command/Ctrl-click adds arguments and Shift-click extends references; readiness unchanged) · **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,11 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | What landed | |---|---| +| 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. | +| 2026-10-13 | `formulas.evaluate` (`gridcraft-cli eval`, MCP `evaluate_formula`) refuses what entering the formula in a cell refuses, with the reason (`=SUM(1;2)`: "expected `,` or `)`"), instead of `#NAME?` for any formula that doesn't parse | +| 2026-10-13 | Colour menus: Fill Color, Font Color and the Format Cells pickers end with Recent Colors (the last ten custom colours) and More Colors… (hue/saturation area, RGB, hex box, preview, OK/Cancel) (#928) | +| 2026-10-13 | Multi-level category axes: a category range two or more columns wide beside a column of values (year beside month, Excel's `multiLvlStrRef`) draws its innermost labels across the plot and the outer levels in rows under them with group separators (columns beside the labels in bar charts), instead of flattening the two columns into twice as many categories; saving writes the reference back as `multiLvlStrRef` with Excel's level caches, so the axis survives a round trip | +| 2026-10-13 | Charts: a huge trendline forecast no longer collapses the chart: on a category axis it adds at most four times the data in empty slots (at least 32), the line is sampled over the plot only, and forecasts read from XLSX or set by command are clamped, NaN and infinity becoming 0 (#1148) | 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) | @@ -193,6 +198,8 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | Change | Summary | |---|---|---| +| 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 | | 2026-10-12 | minor | Rows auto-fit oversized fonts on open and whole-row/column formatting (#833); progress log row | | 2026-10-12 | minor | Progress log: multi-area row/column delete (#758) | | 2026-10-12 | trivial | Progress log: XLSX chart sheets kept on save (#727) | diff --git a/contributors/people.toml b/contributors/people.toml index be84b5b01..dca3f43ef 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -60,11 +60,17 @@ sync_github_name = true [people.jsymes21-byte] real_name = "James Symes" sync_github_name = true - - [people.Silvertree2010] display_name = "Netter" sync_github_name = false [people.Shnrqpdr] sync_github_name = true + +[people.yeniufuklar-com] +real_name = "Ufuk Efe" +display_name = "Ufuk Efe" +sync_github_name = true + +[people.pourie-he] +sync_github_name = true diff --git a/crates/calc/src/eval.rs b/crates/calc/src/eval.rs index 0f9cfb03d..ba9319f58 100644 --- a/crates/calc/src/eval.rs +++ b/crates/calc/src/eval.rs @@ -271,7 +271,7 @@ impl<'h> Evaluator<'h> { if let Some(def) = wb.name(bare, scope_sheet) { let text = def.formula.clone(); let scope = def.scope.unwrap_or(self.sheet); - return match gridcraft_formula::parse(&text) { + return match gridcraft_formula::parse_name(&text) { Ok(expr) => { // Relative references in a name are relative to A1: used here, they move by // this cell's offset from it. diff --git a/crates/calc/src/tests.rs b/crates/calc/src/tests.rs index 97f7a8350..89e5761a8 100644 --- a/crates/calc/src/tests.rs +++ b/crates/calc/src/tests.rs @@ -657,6 +657,24 @@ fn names_and_sheets() { assert_eq!(t.num("A1"), 5.0); } +#[test] +fn name_referring_to_several_ranges() { + let mut t = T::new(); + // As Excel writes it in ``: a union with no parentheses around it. + let union = "Sheet1!$A$1:$A$2,Sheet1!$C$1:$C$2"; + t.wb.names.push(gridcraft_model::DefinedName { name: "u".into(), scope: None, formula: union.into(), comment: String::new(), hidden: false }); + for (at, v) in [("A1", "1"), ("A2", "2"), ("C1", "5"), ("C2", "7")] { + t.set(at, v); + } + t.set("E1", "=SUM(u)"); + t.set("E2", "=AREAS(u)"); + t.set("E3", "=MIN(u)"); + t.set("E4", "=INDEX(u,2,1,2)"); + assert_eq!((t.num("E1"), t.num("E2"), t.num("E3"), t.num("E4")), (15.0, 2.0, 1.0, 7.0)); + t.set("C2", "70"); + assert_eq!(t.num("E1"), 78.0); +} + #[test] fn legacy_array_formulas_fill_their_range() { let mut t = T::new(); diff --git a/crates/chart/src/lib.rs b/crates/chart/src/lib.rs index 697e2a7a7..4b68b8d17 100644 --- a/crates/chart/src/lib.rs +++ b/crates/chart/src/lib.rs @@ -123,6 +123,11 @@ impl Measure for ApproxMeasure { #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] pub struct ChartData { pub categories: Vec, + /// Outer levels of a multi-level category axis (a category range two columns wide beside a + /// column of values: year beside month), nearest the labels first. One entry per category, + /// non-empty where a group starts: `["2023", "", "", "2024", "", ""]`. + #[serde(default)] + pub category_levels: Vec>, pub series: Vec, /// Workbook-theme colours for the points of pie-like charts, repeating (point `j` uses entry `j % len`); empty uses the default theme. #[serde(default)] diff --git a/crates/chart/src/render.rs b/crates/chart/src/render.rs index 037b9099e..ce9b75bde 100644 --- a/crates/chart/src/render.rs +++ b/crates/chart/src/render.rs @@ -192,6 +192,7 @@ fn sanitize(d: &ChartData) -> ChartData { ChartData { palette: d.palette.iter().take(MAX_POINTS).copied().collect(), categories: d.categories.iter().take(MAX_POINTS).cloned().collect(), + category_levels: d.category_levels.iter().take(3).map(|l| l.iter().take(MAX_POINTS).cloned().collect()).collect(), series: d .series .iter() @@ -391,10 +392,20 @@ fn draw_legend(ctx: &mut Ctx, entries: &[(String, Rgba, Swatch)], pos: LegendPos // ---------------------------------------------------------------- axes frame enum CatAxis<'a> { - Labels(&'a [String]), + /// Category labels and, for a multi-level axis, the outer levels (nearest the labels first; + /// one entry per category, non-empty where a group starts). + Labels(&'a [String], &'a [Vec]), Numeric(Scale, &'a Fmt), } +/// Groups of an outer category level: `(first, end)` index pairs, a group starting at every +/// non-empty entry (and at the first category). +fn level_groups(level: &[String], n: usize) -> Vec<(usize, usize)> { + let mut starts: Vec = (0..n).filter(|&j| j == 0 || level.get(j).is_some_and(|t| !t.is_empty())).collect(); + starts.push(n); + starts.windows(2).filter_map(|w| Some((*w.first()?, *w.get(1)?))).collect() +} + struct ValAxis<'a> { scale: Scale, fmt: &'a Fmt, @@ -420,7 +431,11 @@ fn frame(ctx: &mut Ctx, area: Bx, cat: CatAxis, y: &ValAxis, y2: Option<&ValAxis (None, CatAxis::Numeric(s, f)) => (ctx.tw(&f.format(s.max, Some(s.step)), ls) / 2.0).min(area.w * 0.2), _ => 4.0, }; - let plot = Bx { x: area.x + left, y: area.y + lh * 0.5, w: area.w - left - right, h: area.h - lh * 1.5 - 4.0 }; + let levels = match &cat { + CatAxis::Labels(_, o) => o.len() as f32, + CatAxis::Numeric(..) => 0.0, + }; + let plot = Bx { x: area.x + left, y: area.y + lh * 0.5, w: area.w - left - right, h: area.h - lh * 1.5 - 4.0 - lh * levels }; if !plot.ok() { return plot; } @@ -441,7 +456,7 @@ fn frame(ctx: &mut Ctx, area: Bx, cat: CatAxis, y: &ValAxis, y2: Option<&ValAxis let y0 = plot.bottom() - y.scale.frac(zero) * plot.h; ctx.hline(plot.x, plot.right(), y0, AXIS, 0.75); match cat { - CatAxis::Labels(l) => { + CatAxis::Labels(l, outer) => { let n = l.len().max(1); let cw = plot.w / n as f32; let lw = l.iter().take(2000).map(|t| ctx.tw(t, ls)).fold(0.0f32, f32::max); @@ -450,6 +465,22 @@ fn frame(ctx: &mut Ctx, area: Bx, cat: CatAxis, y: &ValAxis, y2: Option<&ValAxis let t = ctx.fit(t, ls, cw * k as f32 - 2.0); ctx.text(plot.x + (j as f32 + 0.5) * cw, plot.bottom() + 3.0, &t, ls, TEXT, HAlign::Center, VAlign::Top); } + // Outer levels, one row each below the labels: a group's name centred under its + // categories, and a separator at each group boundary down through the rows. + for (i, level) in outer.iter().enumerate() { + let top = plot.bottom() + 3.0 + lh * (i as f32 + 1.0); + for (first, end) in level_groups(level, n) { + let (x0, x1) = (plot.x + first as f32 * cw, plot.x + end as f32 * cw); + if let Some(t) = level.get(first).filter(|t| !t.is_empty()) { + let t = ctx.fit(t, ls, x1 - x0 - 2.0); + ctx.text((x0 + x1) / 2.0, top, &t, ls, TEXT, HAlign::Center, VAlign::Top); + } + if first > 0 { + ctx.vline(x0, plot.bottom(), top + lh - 2.0, AXIS, 0.75); + } + } + ctx.vline(plot.right(), plot.bottom(), top + lh - 2.0, AXIS, 0.75); + } } CatAxis::Numeric(s, f) => { let ticks = s.ticks(); @@ -469,13 +500,17 @@ fn frame(ctx: &mut Ctx, area: Bx, cat: CatAxis, y: &ValAxis, y2: Option<&ValAxis } plot } else { - let labels: Vec = match cat { - CatAxis::Labels(l) => l.to_vec(), - CatAxis::Numeric(..) => vec![], + let (labels, outer): (Vec, &[Vec]) = match cat { + CatAxis::Labels(l, o) => (l.to_vec(), o), + CatAxis::Numeric(..) => (vec![], &[]), }; let lw = (labels.iter().take(2000).map(|t| ctx.tw(t, ls)).fold(0.0f32, f32::max) + 6.0).min(area.w * 0.35); + // Outer levels stand in columns left of the labels, each as wide as its longest name. + let level_w: Vec = + outer.iter().map(|l| (l.iter().take(2000).map(|t| ctx.tw(t, ls)).fold(0.0f32, f32::max) + 6.0).min(area.w * 0.15)).collect(); + let levels_w: f32 = level_w.iter().sum(); let right = ylabels.last().map(|(_, t)| ctx.tw(t, ls) / 2.0).unwrap_or(0.0).min(area.w * 0.2) + 2.0; - let plot = Bx { x: area.x + lw, y: area.y + 2.0, w: area.w - lw - right, h: area.h - lh - 6.0 }; + let plot = Bx { x: area.x + lw + levels_w, y: area.y + 2.0, w: area.w - lw - levels_w - right, h: area.h - lh - 6.0 }; if !plot.ok() { return plot; } @@ -501,6 +536,24 @@ fn frame(ctx: &mut Ctx, area: Bx, cat: CatAxis, y: &ValAxis, y2: Option<&ValAxis let t = ctx.fit(t, ls, lw - 6.0); ctx.text(plot.x - 4.0, plot.bottom() - (j as f32 + 0.5) * ch, &t, ls, TEXT, HAlign::Right, VAlign::Middle); } + // Outer levels: a group's name centred beside its categories, separators at the + // boundaries running right to the axis. + let mut x1 = plot.x - lw; + for (level, w) in outer.iter().zip(&level_w) { + let x0 = x1 - w; + for (first, end) in level_groups(level, n) { + let (y0, y1) = (plot.bottom() - end as f32 * ch, plot.bottom() - first as f32 * ch); + if let Some(t) = level.get(first).filter(|t| !t.is_empty()) { + let t = ctx.fit(t, ls, w - 4.0); + ctx.text((x0 + x1) / 2.0, (y0 + y1) / 2.0, &t, ls, TEXT, HAlign::Center, VAlign::Middle); + } + if first > 0 { + ctx.hline(x0, plot.x, y1, AXIS, 0.75); + } + } + ctx.hline(x0, plot.x, plot.y, AXIS, 0.75); + x1 = x0; + } plot } } @@ -658,6 +711,13 @@ fn forecast(v: f64) -> f64 { if v.is_finite() && v > 0.0 { v.min(1e9) } else { 0.0 } } +/// Most empty category slots a forward forecast may add after `ncat` categories: four times the +/// data (at least 32), and never past `MAX_CATS` in total. A larger forecast would shrink every +/// column to nothing. +fn max_forward_slots(ncat: usize) -> f64 { + (ncat.saturating_mul(4).max(32)).min(MAX_CATS.saturating_sub(ncat)) as f64 +} + /// Longest forward forecast among `s`'s regression trendlines. fn max_forward(s: &SeriesData) -> f64 { s.trendlines.iter().filter(|t| !matches!(t.spec.kind, TrendlineKind::MovingAverage(_))).map(|t| forecast(t.spec.forward)).fold(0.0, f64::max) @@ -748,6 +808,7 @@ fn draw_trendlines( frac: &dyn Fn(f64, f64) -> (f64, f64), px: &dyn Fn((f64, f64)) -> [f32; 2], labels: &mut Vec, + window: (f64, f64), ) { let Some((xlo, xhi)) = value_range(pts.iter().map(|p| p.0)) else { return }; for t in s.trendlines.iter().take(MAX_TRENDLINES) { @@ -756,7 +817,8 @@ fn draw_trendlines( TrendlineKind::MovingAverage(p) => moving_average(pts, p).into_iter().map(|(x, y)| Some(frac(x, y))).collect(), _ => { let Some(fit) = fit_trendline(spec, pts) else { continue }; - let (lo, hi) = (xlo - forecast(spec.backward), xhi + forecast(spec.forward)); + // Sample only the part of the forecast that is on the plot. + let (lo, hi) = ((xlo - forecast(spec.backward)).max(window.0), (xhi + forecast(spec.forward)).min(window.1)); if spec.display_equation { labels.push(fit.equation()); } @@ -936,9 +998,12 @@ fn cartesian(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx, horizonta .filter(|(i, _)| kinds.get(*i).is_some_and(|k| supports_trendline(*k))) .map(|(_, s)| max_forward(s)) .fold(0.0, f64::max); - let ncat_slots = ncat.saturating_add(fwd.ceil() as usize).min(MAX_CATS.max(ncat)); + let ncat_slots = ncat.saturating_add(fwd.min(max_forward_slots(ncat)).ceil() as usize).min(MAX_CATS.max(ncat)); let labels: Vec = (0..ncat_slots).map(|j| if j < ncat { cat_label(data, j) } else { String::new() }).collect(); - let plot = frame(ctx, area, CatAxis::Labels(&labels), &y1, y2.as_ref(), horizontal, chart.gridlines); + // Outer levels padded to the slots (forecast slots stay in the last group). + let outer: Vec> = + data.category_levels.iter().map(|l| (0..ncat_slots).map(|j| l.get(j).cloned().unwrap_or_default()).collect()).collect(); + let plot = frame(ctx, area, CatAxis::Labels(&labels, &outer), &y1, y2.as_ref(), horizontal, chart.gridlines); if !plot.ok() { return; } @@ -1102,7 +1167,7 @@ fn cartesian(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx, horizonta [plot.x + fx as f32 * plot.w, plot.bottom() - fy as f32 * plot.h] } }; - draw_trendlines(ctx, s, &pts, &frac, &px, &mut trend_labels); + draw_trendlines(ctx, s, &pts, &frac, &px, &mut trend_labels, (0.5, ncat_slots as f64 + 0.5)); } draw_trend_labels(ctx, plot, &trend_labels); } @@ -1196,7 +1261,7 @@ fn scatter(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) { let pts: Vec<(f64, f64)> = pts.iter().flatten().copied().collect(); let frac = |x: f64, y: f64| ((x - xs.min) / (xs.max - xs.min), (y - ys.min) / (ys.max - ys.min)); let px = |(fx, fy): (f64, f64)| [plot.x + fx as f32 * plot.w, plot.bottom() - fy as f32 * plot.h]; - draw_trendlines(ctx, s, &pts, &frac, &px, &mut trend_labels); + draw_trendlines(ctx, s, &pts, &frac, &px, &mut trend_labels, (xs.min, xs.max)); } draw_trend_labels(ctx, plot, &trend_labels); } @@ -1236,7 +1301,7 @@ fn histogram(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) { let maxc = counts.iter().copied().max().unwrap_or(0) as f64; let ys = nice_scale(0.0, maxc, true); let fc = Fmt::new("General"); - let plot = frame(ctx, area, CatAxis::Labels(&labels), &ValAxis { scale: ys, fmt: &fc }, None, false, chart.gridlines); + let plot = frame(ctx, area, CatAxis::Labels(&labels, &[]), &ValAxis { scale: ys, fmt: &fc }, None, false, chart.gridlines); if !plot.ok() { return; } @@ -1288,7 +1353,7 @@ fn waterfall(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) { let (lo, hi) = value_range(bars.iter().flat_map(|(b, t, _)| [*b, *t])).unwrap_or((0.0, 0.0)); let ys = nice_scale(lo, hi, true); let f = Fmt::new(&s.number_format); - let plot = frame(ctx, area, CatAxis::Labels(&labels), &ValAxis { scale: ys, fmt: &f }, None, false, chart.gridlines); + let plot = frame(ctx, area, CatAxis::Labels(&labels, &[]), &ValAxis { scale: ys, fmt: &f }, None, false, chart.gridlines); if !plot.ok() { return; } @@ -1579,7 +1644,7 @@ fn box_whisker(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) { let Some((lo, hi)) = value_range(data.series.iter().flat_map(|s| s.values.iter().flatten().copied())) else { return }; let ys = nice_scale(lo, hi, false); let f = Fmt::new(data.series.first().map(|s| s.number_format.as_str()).unwrap_or("General")); - let plot = frame(ctx, area, CatAxis::Labels(&labels), &ValAxis { scale: ys, fmt: &f }, None, false, chart.gridlines); + let plot = frame(ctx, area, CatAxis::Labels(&labels, &[]), &ValAxis { scale: ys, fmt: &f }, None, false, chart.gridlines); if !plot.ok() { return; } @@ -1627,7 +1692,7 @@ fn stock(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) { let Some((lo, hi)) = value_range(data.series.iter().flat_map(|s| s.values.iter().flatten().copied())) else { return }; let ys = nice_scale(lo, hi, false); let f = Fmt::new(data.series.first().map(|s| s.number_format.as_str()).unwrap_or("General")); - let plot = frame(ctx, area, CatAxis::Labels(&labels), &ValAxis { scale: ys, fmt: &f }, None, false, chart.gridlines); + let plot = frame(ctx, area, CatAxis::Labels(&labels, &[]), &ValAxis { scale: ys, fmt: &f }, None, false, chart.gridlines); if !plot.ok() { return; } diff --git a/crates/chart/src/resolve.rs b/crates/chart/src/resolve.rs index c007e3e90..c7a8f0b9c 100644 --- a/crates/chart/src/resolve.rs +++ b/crates/chart/src/resolve.rs @@ -1,7 +1,7 @@ //! Pulls chart data (names, categories, values, formats, colours) out of a workbook. use gridcraft_calc::evaluate; -use gridcraft_core::{CellRef, RangeRef, Value, number_to_text}; +use gridcraft_core::{CellRef, RangeRef, Value, category_levels, number_to_text}; use gridcraft_model::{Chart, ChartKind, Theme, Workbook, style::apply_tint}; use gridcraft_numfmt::{NumberFormat, format_value}; @@ -133,12 +133,16 @@ fn value_text(v: &Value) -> String { } } -/// Category labels, formatted with each source cell's number format (dates, currency…). -fn category_texts(wb: &Workbook, sheet: usize, formula: &str) -> Vec { +/// Category labels, formatted with each source cell's number format (dates, currency…), and +/// the outer levels of a multi-level axis: a category range wider than a column of values (or +/// taller than a row of values) groups its innermost labels, year beside month. Each outer level +/// has one entry per category, non-empty where a group starts, as the cells are. +fn category_table(wb: &Workbook, sheet: usize, formula: &str, values: &str) -> (Vec, Vec>) { let vals = eval_list(wb, sheet, formula); let cells: Option<(usize, RangeRef)> = parse_ref(wb, sheet, formula); let src = cells.and_then(|(sh, r)| wb.sheet(sh).map(|s| (s, r))); - vals.iter() + let texts: Vec = vals + .iter() .enumerate() .map(|(i, v)| { if let (Some((s, r)), Value::Number(_)) = (src, v) { @@ -153,7 +157,23 @@ fn category_texts(wb: &Workbook, sheet: usize, formula: &str) -> Vec { } value_text(v) }) - .collect() + .collect(); + let Some((_, r)) = src else { return (texts, vec![]) }; + let value_range = parse_ref(wb, sheet, values).map(|(_, r)| r); + let Some(levels) = category_levels(&r, value_range.as_ref()) else { return (texts, vec![]) }; + let (w, h) = (r.width().max(1) as usize, r.height().max(1) as usize); + if texts.len() != w.saturating_mul(h) { + return (texts, vec![]); // truncated or not a plain block: a flat list + } + let at = |row: usize, col: usize| texts.get(row * w + col).cloned().unwrap_or_default(); + let column = |c: usize| (0..h).map(|row| at(row, c)).collect::>(); + let row = |rr: usize| (0..w).map(|c| at(rr, c)).collect::>(); + let outer = levels.outer as usize; + if levels.by_columns { + (column(w - 1), (1..=outer).filter_map(|k| (w - 1).checked_sub(k)).map(column).collect()) + } else { + (row(h - 1), (1..=outer).filter_map(|k| (h - 1).checked_sub(k)).map(row).collect()) + } } /// A series name: a reference/formula is evaluated, a quoted or bare literal is used as is. @@ -197,6 +217,7 @@ pub fn resolve(wb: &Workbook, sheet: usize, chart: &Chart) -> ChartData { let numeric_x = matches!(chart.kind, ChartKind::Scatter | ChartKind::ScatterLines | ChartKind::Bubble); let mut series = Vec::new(); let mut categories: Option> = None; + let mut category_levels = Vec::new(); for (i, s) in chart.series.iter().take(MAX_SERIES).enumerate() { let values = nums(wb, sheet, &s.values); let name = s.name.as_deref().map(|n| series_name(wb, sheet, n)).filter(|n| !n.is_empty()).unwrap_or_else(|| format!("Series{}", i + 1)); @@ -211,9 +232,10 @@ pub fn resolve(wb: &Workbook, sheet: usize, chart: &Chart) -> ChartData { if categories.is_none() && let Some(f) = &s.categories { - let c = category_texts(wb, sheet, f); + let (c, outer) = category_table(wb, sheet, f, &s.values); if !c.is_empty() { categories = Some(c); + category_levels = outer; } } let sizes = s.bubble_sizes.as_deref().map(|f| nums(wb, sheet, f)); @@ -242,5 +264,5 @@ pub fn resolve(wb: &Workbook, sheet: usize, chart: &Chart) -> ChartData { let n = series.iter().map(|s| s.values.len()).max().unwrap_or(0); let categories = categories.unwrap_or_else(|| (1..=n).map(|i| i.to_string()).collect()); let palette = default_palette(&wb.theme, n.min(PALETTE_CYCLE)); - ChartData { categories, series, palette } + ChartData { categories, category_levels, series, palette } } diff --git a/crates/chart/src/tests.rs b/crates/chart/src/tests.rs index 01f1aac49..7860f4af7 100644 --- a/crates/chart/src/tests.rs +++ b/crates/chart/src/tests.rs @@ -74,6 +74,7 @@ fn sample(kind: ChartKind) -> ChartData { let mut b = series("East", &[38000.0, 30000.0, 45000.0, 61870.0, 52000.0, 41000.0], [200, 80, 40, 255], k2); b.secondary = kind == ChartKind::Combo; ChartData { + category_levels: vec![], palette: vec![], categories: cats.iter().map(|s| s.to_string()).collect(), series: vec![ @@ -363,7 +364,8 @@ fn scatter_with_text_x_values_numbers_the_points() { let d = resolve(&wb, 0, &c); assert_eq!(d.series[0].x, None, "{kind:?}"); let prims = render(&c, &d, 400.0, 300.0, &ApproxMeasure); - let empty = render(&c, &ChartData { palette: vec![], categories: vec![], series: vec![] }, 400.0, 300.0, &ApproxMeasure); + let empty = + render(&c, &ChartData { category_levels: vec![], palette: vec![], categories: vec![], series: vec![] }, 400.0, 300.0, &ApproxMeasure); assert!(prims.len() > empty.len() + 2, "{kind:?} draws its points"); // Numeric X values are kept. c.series[0].categories = Some("Sheet1!$C$1:$C$3".into()); @@ -456,8 +458,12 @@ fn pie_starts_at_twelve_clockwise() { #[test] fn negative_values_put_axis_below_zero() { let c = chart(ChartKind::ColumnClustered); - let d = - ChartData { palette: vec![], categories: vec![], series: vec![series("N", &[-5.0, 3.0, -2.0], [1, 2, 3, 255], ChartKind::ColumnClustered)] }; + let d = ChartData { + category_levels: vec![], + palette: vec![], + categories: vec![], + series: vec![series("N", &[-5.0, 3.0, -2.0], [1, 2, 3, 255], ChartKind::ColumnClustered)], + }; let prims = render(&c, &d, 400.0, 300.0, &ApproxMeasure); let t = texts(&prims); assert!(t.iter().any(|s| s.starts_with('-') || s.starts_with("($") || s.starts_with("-$")), "{t:?}"); @@ -469,11 +475,13 @@ fn edge_cases_never_panic() { let m = ApproxMeasure; let empty = ChartData::default(); let zeros = ChartData { + category_levels: vec![], palette: vec![], categories: vec!["a".into(), "b".into()], series: vec![series("z", &[0.0, 0.0], [0, 0, 0, 255], ChartKind::Line)], }; let weird = ChartData { + category_levels: vec![], palette: vec![], categories: vec![String::new(); 3], series: vec![SeriesData { @@ -505,7 +513,12 @@ fn thousands_of_points_are_decimated() { let vals: Vec = (0..n).map(|i| ((i as f64) * 0.01).sin() * 100.0 + i as f64 * 0.001).collect(); let mut c = chart(ChartKind::Line); c.data_labels = true; - let d = ChartData { palette: vec![], categories: vec![], series: vec![series("big", &vals, [9, 9, 9, 255], ChartKind::Line)] }; + let d = ChartData { + category_levels: vec![], + palette: vec![], + categories: vec![], + series: vec![series("big", &vals, [9, 9, 9, 255], ChartKind::Line)], + }; let prims = render(&c, &d, 800.0, 400.0, &ApproxMeasure); let max_line = prims .iter() @@ -530,7 +543,7 @@ fn smooth_line_adds_interpolated_points_and_keeps_endpoints() { let line_pts = |smooth: bool, m: &ApproxMeasure| -> usize { let mut s = series("L", &[1.0, 5.0, 2.0, 6.0], [10, 20, 30, 255], ChartKind::Line); s.smooth = smooth; - let d = ChartData { palette: vec![], categories: cats.clone(), series: vec![s] }; + let d = ChartData { category_levels: vec![], palette: vec![], categories: cats.clone(), series: vec![s] }; render(&chart(ChartKind::Line), &d, 600.0, 400.0, m) .iter() .filter_map(|p| match p { @@ -547,7 +560,7 @@ fn smooth_line_adds_interpolated_points_and_keeps_endpoints() { // Rendering either way must stay finite and inside the canvas. let mut s = series("L", &[1.0, 5.0, 2.0, 6.0], [10, 20, 30, 255], ChartKind::Line); s.smooth = true; - let d = ChartData { palette: vec![], categories: cats, series: vec![s] }; + let d = ChartData { category_levels: vec![], palette: vec![], categories: cats, series: vec![s] }; assert_sane(&render(&chart(ChartKind::Line), &d, 600.0, 400.0, &m), 600.0, 400.0); } @@ -557,7 +570,7 @@ fn smooth_line_splits_at_gaps() { let mut s = series("L", &[1.0, 5.0, 2.0, 6.0], [10, 20, 30, 255], ChartKind::Line); s.smooth = true; s.values[1] = None; // gap between two data points - let d = ChartData { palette: vec![], categories: vec!["a".into(), "b".into(), "c".into(), "d".into()], series: vec![s] }; + let d = ChartData { category_levels: vec![], palette: vec![], categories: vec!["a".into(), "b".into(), "c".into(), "d".into()], series: vec![s] }; let prims = render(&chart(ChartKind::Line), &d, 600.0, 400.0, &m); // Two separate runs (before/after the gap) → at least two line primitives. let lines = prims.iter().filter(|p| matches!(p, Prim::Line { .. })).count(); @@ -634,7 +647,12 @@ fn fuzz_random_data_and_sizes() { }) .collect(); let ncat = (r.next() % 50) as usize; - let d = ChartData { palette: vec![], categories: (0..ncat).map(|i| "c".repeat(i % 7) + &i.to_string()).collect(), series }; + let d = ChartData { + category_levels: vec![], + palette: vec![], + categories: (0..ncat).map(|i| "c".repeat(i % 7) + &i.to_string()).collect(), + series, + }; let mut c = chart(kind); c.legend = [LegendPos::None, LegendPos::Bottom, LegendPos::Top, LegendPos::Left, LegendPos::Right][(r.next() % 5) as usize]; c.data_labels = r.next().is_multiple_of(2); @@ -809,7 +827,8 @@ fn line_chart_draws_trendline_forecast_and_labels() { let mut s = series("Sales", &[9.0, 7.0, 5.0, 3.0], [10, 20, 30, 255], ChartKind::Line); s.trendlines = vec![TrendlineData { spec: Trendline { forward: 1.0, display_equation: true, display_r_squared: true, ..Trendline::default() }, color: tc }]; - let d = ChartData { palette: vec![], categories: ["a", "b", "c", "d"].map(String::from).to_vec(), series: vec![s.clone()] }; + let d = + ChartData { category_levels: vec![], palette: vec![], categories: ["a", "b", "c", "d"].map(String::from).to_vec(), series: vec![s.clone()] }; let mut c = chart(ChartKind::Line); c.legend = LegendPos::None; // keeps legend swatches out of the line counts let prims = render(&c, &d, 600.0, 400.0, &m); @@ -833,7 +852,7 @@ fn line_chart_draws_trendline_forecast_and_labels() { assert!(lines_of(&pie, tc).is_empty()); // One point: nothing to fit, nothing drawn. s.values.truncate(1); - let one = ChartData { palette: vec![], categories: vec!["a".into()], series: vec![s] }; + let one = ChartData { category_levels: vec![], palette: vec![], categories: vec!["a".into()], series: vec![s] }; assert!(lines_of(&render(&c, &one, 600.0, 400.0, &m), tc).is_empty()); } @@ -853,7 +872,7 @@ fn scatter_trendlines_use_x_values_and_stay_in_the_plot() { TrendlineData { spec: Trendline { kind: TrendlineKind::Exponential, forward: 30.0, ..Trendline::default() }, color: colors[1] }, TrendlineData { spec: Trendline { kind: TrendlineKind::MovingAverage(2), ..Trendline::default() }, color: colors[2] }, ]; - let d = ChartData { palette: vec![], categories: vec![], series: vec![s] }; + let d = ChartData { category_levels: vec![], palette: vec![], categories: vec![], series: vec![s] }; let mut c = chart(ChartKind::Scatter); c.legend = LegendPos::None; let prims = render(&c, &d, 600.0, 400.0, &m); @@ -868,6 +887,7 @@ fn scatter_trendlines_use_x_values_and_stay_in_the_plot() { #[test] fn treemap_and_histogram_shapes() { let d = ChartData { + category_levels: vec![], palette: vec![], categories: vec!["a".into(), "b".into(), "c".into(), "d".into()], series: vec![series("t", &[6.0, 6.0, 4.0, 3.0], [10, 20, 30, 255], ChartKind::Treemap)], @@ -889,7 +909,12 @@ fn treemap_and_histogram_shapes() { assert!((rects[0] / rects[3] - 2.0).abs() < 0.05, "{rects:?}"); let vals: Vec = (0..100).map(|i| i as f64).collect(); - let d = ChartData { palette: vec![], categories: vec![], series: vec![series("h", &vals, [10, 20, 30, 255], ChartKind::Histogram)] }; + let d = ChartData { + category_levels: vec![], + palette: vec![], + categories: vec![], + series: vec![series("h", &vals, [10, 20, 30, 255], ChartKind::Histogram)], + }; let prims = render(&chart(ChartKind::Histogram), &d, 600.0, 400.0, &ApproxMeasure); let bins = prims.iter().filter(|p| matches!(p, Prim::Rect { fill: Some([10, 20, 30, 255]), .. })).count(); assert_eq!(bins, 10); @@ -898,7 +923,12 @@ fn treemap_and_histogram_shapes() { #[test] fn histogram_boundary_values_match_interval_labels() { let heights = |vals: &[f64]| -> Vec { - let d = ChartData { palette: vec![], categories: vec![], series: vec![series("h", vals, [10, 20, 30, 255], ChartKind::Histogram)] }; + let d = ChartData { + category_levels: vec![], + palette: vec![], + categories: vec![], + series: vec![series("h", vals, [10, 20, 30, 255], ChartKind::Histogram)], + }; let prims = render(&chart(ChartKind::Histogram), &d, 600.0, 400.0, &ApproxMeasure); prims.iter().filter_map(|p| if let Prim::Rect { h, fill: Some([10, 20, 30, 255]), .. } = p { Some(*h) } else { None }).collect() }; @@ -967,6 +997,7 @@ fn box_and_stock_charts_honour_the_gridlines_flag() { #[test] fn funnel_value_labels_follow_the_data_labels_flag() { let data = ChartData { + category_levels: vec![], palette: vec![], categories: vec!["a".into(), "b".into(), "c".into()], series: vec![series("S", &[11111.0, 22222.0, 33333.0], [20, 96, 130, 255], ChartKind::Funnel)], @@ -1057,3 +1088,98 @@ fn area_charts_draw_source_value_labels_only_when_enabled() { assert!(first_label > last_poly, "{kind:?}: label drawn under a later area"); } } + +#[test] +fn two_level_categories_resolve_and_draw() { + let years = ["2023", "", "", "2024", "", ""]; + let months = ["Oct", "Nov", "Dec", "Jan", "Feb", "Mar"]; + let mut wb = Workbook::new(); + { + let s = wb.sheet_mut(0).unwrap(); + s.name = "Sales".into(); + for (i, (y, m)) in years.iter().zip(months).enumerate() { + // Year beside month beside the values, points down the rows… + let r = i as u32 + 1; + if !y.is_empty() { + s.set_value(CellRef::new(r, 0), Value::from(*y)); + s.set_value(CellRef::new(10, i as u32), Value::from(*y)); + } + s.set_value(CellRef::new(r, 1), Value::from(m)); + s.set_value(CellRef::new(r, 2), Value::Number(10.0 * (i as f64 + 1.0))); + // …and the same table in rows, points along the columns. + s.set_value(CellRef::new(11, i as u32), Value::from(m)); + s.set_value(CellRef::new(12, i as u32), Value::Number(10.0 * (i as f64 + 1.0))); + } + } + let mut c = chart(ChartKind::Line); + c.series = vec![Series { categories: Some("Sales!$A$2:$B$7".into()), values: "Sales!$C$2:$C$7".into(), ..Default::default() }]; + let d = resolve(&wb, 0, &c); + assert_eq!(d.categories, months); + assert_eq!(d.category_levels, vec![years.map(String::from).to_vec()]); + + let texts = |prims: &[Prim]| -> Vec<(String, f32, f32)> { + prims.iter().filter_map(|p| if let Prim::Text { text, x, y, .. } = p { Some((text.clone(), *x, *y)) } else { None }).collect() + }; + let prims = render(&c, &d, 600.0, 300.0, &ApproxMeasure); + assert_sane(&prims, 600.0, 300.0); + let t = texts(&prims); + let at = |t: &[(String, f32, f32)], s: &str| { + t.iter().find(|(x, _, _)| x == s).map(|(_, x, y)| (*x, *y)).unwrap_or_else(|| panic!("no {s:?} in {t:?}")) + }; + // Six evenly spaced month labels across the plot, not twelve slots for a two-column range. + let xs: Vec = months.iter().map(|m| at(&t, m).0).collect(); + let step = xs[1] - xs[0]; + assert!(step > 40.0 && xs.windows(2).all(|w| (w[1] - w[0] - step).abs() < 0.5), "{xs:?}"); + // The years sit in a row below the months, each under its own group. + let (x23, y23) = at(&t, "2023"); + let (x24, y24) = at(&t, "2024"); + assert!(y23 > at(&t, "Oct").1 && (y23 - y24).abs() < 0.01, "{t:?}"); + assert!(x23 < at(&t, "Dec").0 && x24 > at(&t, "Dec").0 && x24 < at(&t, "Mar").0, "{t:?}"); + + // Bars: the levels stand in a column left of the labels. + c.kind = ChartKind::BarClustered; + let prims = render(&c, &d, 600.0, 300.0, &ApproxMeasure); + assert_sane(&prims, 600.0, 300.0); + let t = texts(&prims); + let (x23, y23) = at(&t, "2023"); + let (x_oct, y_oct) = at(&t, "Oct"); + // Categories run up the axis: the group's name sits left of its labels, between Oct and Dec. + assert!(x23 < x_oct && y23 < y_oct && y23 > at(&t, "Dec").1, "{t:?}"); + let (x24, y24) = at(&t, "2024"); + assert!((x23 - x24).abs() < 0.01 && y24 < y23, "same column, 2024 above: {t:?}"); + + // A row of values: the levels are rows. + c.series[0].categories = Some("Sales!$A$11:$F$12".into()); + c.series[0].values = "Sales!$A$13:$F$13".into(); + let d = resolve(&wb, 0, &c); + assert_eq!(d.categories, months); + assert_eq!(d.category_levels, vec![years.map(String::from).to_vec()]); + + // One column of categories: a plain axis, as before. + c.series[0].categories = Some("Sales!$B$2:$B$7".into()); + c.series[0].values = "Sales!$C$2:$C$7".into(); + let d = resolve(&wb, 0, &c); + assert_eq!(d.categories, months); + assert!(d.category_levels.is_empty()); +} + +#[test] +fn a_huge_trendline_forecast_does_not_collapse_the_columns() { + let m = ApproxMeasure; + let tc: Rgba = [1, 2, 3, 255]; + let col: Rgba = [10, 20, 30, 255]; + let mut s = series("Sales", &[1.0, 3.0, 2.0, 5.0, 4.0], col, ChartKind::ColumnClustered); + s.trendlines = vec![TrendlineData { spec: Trendline { forward: 1e6, backward: 1e6, ..Trendline::default() }, color: tc }]; + let d = ChartData { palette: vec![], category_levels: vec![], categories: ["a", "b", "c", "d", "e"].map(String::from).to_vec(), series: vec![s] }; + let mut c = chart(ChartKind::ColumnClustered); + c.legend = LegendPos::None; + let prims = render(&c, &d, 600.0, 400.0, &m); + assert_sane(&prims, 600.0, 400.0); + let widths: Vec = + prims.iter().filter_map(|p| if let Prim::Rect { w, fill: Some(f), .. } = p { (*f == col).then_some(*w) } else { None }).collect(); + assert_eq!(widths.len(), 5); + assert!(widths.iter().all(|w| *w > 4.0), "columns stay visible: {widths:?}"); + let trend = lines_of(&prims, tc); + assert_eq!(trend.len(), 1); + assert!(trend[0].len() >= 20, "the trendline is sampled over the plot, not the forecast: {}", trend[0].len()); +} diff --git a/crates/core/src/addr.rs b/crates/core/src/addr.rs index 2c087f22e..8e53b5971 100644 --- a/crates/core/src/addr.rs +++ b/crates/core/src/addr.rs @@ -312,3 +312,52 @@ mod tests { assert_eq!(CellRef::new(0, 0).offset_clamped(-5, 3), CellRef::new(0, 3)); } } + +/// The shape of a chart's category range beside its values: a plain list, or a multi-level +/// axis (Excel: a category range two or more columns wide beside a column of values is a +/// two-level axis, year beside month; one two or more rows tall above a row of values likewise). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct CategoryLevels { + /// The levels are columns and the points run down the rows (else the levels are rows and the + /// points run along the columns). + pub by_columns: bool, + /// Outer levels beyond the innermost labels (the column or row nearest the values): 1 for a + /// two-level axis. At most 3 are kept. + pub outer: u32, +} + +/// How `categories` lays its labels out beside `values` (`None` when the values aren't a +/// reference: the categories' own shape decides). `None` for a plain list of labels. +pub fn category_levels(categories: &RangeRef, values: Option<&RangeRef>) -> Option { + if categories.is_full_rows() || categories.is_full_cols() { + return None; + } + let (w, h) = (categories.width(), categories.height()); + let vertical = match values { + Some(v) => v.height() >= v.width(), + None => h >= w, + }; + let outer = if vertical { w.saturating_sub(1) } else { h.saturating_sub(1) }; + (outer > 0).then_some(CategoryLevels { by_columns: vertical, outer: outer.min(3) }) +} + +#[cfg(test)] +mod category_level_tests { + use super::*; + + #[test] + fn a_wide_range_beside_a_column_of_values_is_a_multi_level_axis() { + let r = |s: &str| RangeRef::parse(s).unwrap(); + assert_eq!(category_levels(&r("A4:B39"), Some(&r("C4:C39"))), Some(CategoryLevels { by_columns: true, outer: 1 })); + assert_eq!(category_levels(&r("A4:D39"), Some(&r("C4:C39"))), Some(CategoryLevels { by_columns: true, outer: 3 })); + assert_eq!(category_levels(&r("A4:F39"), Some(&r("C4:C39"))).map(|l| l.outer), Some(3), "capped"); + assert_eq!(category_levels(&r("A4:A39"), Some(&r("C4:C39"))), None, "one column: a plain list"); + // A row of values: levels are rows. + assert_eq!(category_levels(&r("B2:M3"), Some(&r("B4:M4"))), Some(CategoryLevels { by_columns: false, outer: 1 })); + assert_eq!(category_levels(&r("B3:M3"), Some(&r("B4:M4"))), None); + // Literal values: the categories' own shape decides. + assert_eq!(category_levels(&r("A1:B6"), None), Some(CategoryLevels { by_columns: true, outer: 1 })); + assert_eq!(category_levels(&r("A1:F2"), None), Some(CategoryLevels { by_columns: false, outer: 1 })); + assert_eq!(category_levels(&r("A:B"), Some(&r("C:C"))), None, "whole columns"); + } +} diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index d5adbc07e..9e04e8b27 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -8,6 +8,6 @@ pub mod date; pub mod parse; pub mod value; -pub use addr::{CellRef, MAX_COLS, MAX_ROWS, RangeRef, col_to_letters, letters_to_col}; +pub use addr::{CategoryLevels, CellRef, MAX_COLS, MAX_ROWS, RangeRef, category_levels, col_to_letters, letters_to_col}; pub use date::DateSystem; pub use value::{Array, CellError, Value, compare, compare_numbers, compare_text, number_to_text, sort_compare}; diff --git a/crates/engine/examples/fill_handle_perf.rs b/crates/engine/examples/fill_handle_perf.rs new file mode 100644 index 000000000..5e9ea423c --- /dev/null +++ b/crates/engine/examples/fill_handle_perf.rs @@ -0,0 +1,50 @@ +//! Times the fill-handle double-click scan against a densely-filled neighbour column: the old +//! row-by-row `value_ref` loop from `grid.rs` vs. `Sheet::last_contiguous_nonempty`. +//! `cargo run --release -p gridcraft-engine --example fill_handle_perf -- [rows]` + +#![allow(clippy::disallowed_methods)] // a native-only benchmark: wasm never runs it + +use std::time::Instant; + +use gridcraft_engine::Session; +use gridcraft_engine::core::CellRef; +use serde_json::json; + +fn main() { + let rows: u32 = std::env::args().nth(1).and_then(|s| s.parse().ok()).unwrap_or(1_000_000); + let mut s = Session::new(); + s.new_workbook(); + let t = Instant::now(); + let chunk = 10_000u32; + let mut r = 0u32; + while r < rows { + let n = chunk.min(rows - r); + let vals: Vec = (0..n).map(|i| json!([(r + i) as f64])).collect(); + s.execute("range.setValues", json!({"range": format!("A{}", r + 1), "values": vals})).expect("set"); + r += n; + } + println!("fill A1:A{rows}: {:?}", t.elapsed()); + + let d = s.doc().expect("doc"); + let sh = d.wb.active().expect("sheet"); + let neighbour = 0u32; // column A + let cap = (rows - 1).min(999_999); + + let t = Instant::now(); + let mut last = 0u32; + let mut iters = 0u64; + while last + 1 < gridcraft_engine::core::MAX_ROWS && sh.value_ref(CellRef::new(last + 1, neighbour)).is_some_and(|v| !v.is_empty()) && last < cap + { + last += 1; + iters += 1; + } + let old = t.elapsed(); + println!("old row-by-row scan: {iters} iterations, {old:?} ({:.1} ns/iter)", old.as_nanos() as f64 / iters.max(1) as f64); + + let t = Instant::now(); + let new_last = sh.last_contiguous_nonempty(neighbour, 1, cap); + let new_t = t.elapsed(); + println!("new last_contiguous_nonempty: last={new_last}, {new_t:?} ({:.2}x faster)", old.as_secs_f64() / new_t.as_secs_f64().max(1e-12)); + + assert_eq!(last, new_last, "old and new scans must agree"); +} diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index fe7f1fe8b..2afb9f660 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -138,18 +138,7 @@ pub(crate) fn input_to_cell(input: &str, sheet: usize, at: CellRef, wb: &mut gri && input.chars().nth(1).is_some_and(|c| c.is_ascii_alphabetic() || c == '(')); if is_formula { let body = input.strip_prefix('=').unwrap_or(input); - // Excel closes missing parentheses for you. - let mut text = body.to_string(); - let mut parsed = gridcraft_formula::parse(&text); - for _ in 0..8 { - if parsed.is_ok() { - break; - } - text.push(')'); - parsed = gridcraft_formula::parse(&text); - } - let expr = parsed.map_err(|e| EngineError::Other(format!("There's a problem with this formula: {e}")))?; - let expr = wb.name_call_case(expr); + let expr = wb.name_call_case(parse_typed_formula(body)?); check_arg_counts(&expr)?; let mut cell = Cell::formula(Formula::from_expr(expr)); cell.style = style; @@ -191,11 +180,26 @@ pub(crate) fn input_to_cell(input: &str, sheet: usize, at: CellRef, wb: &mut gri Ok(Some(Cell { value: parsed.value, formula: None, style })) } +/// Parses a formula as it is typed (without the `=`). Excel closes missing parentheses for you, +/// and refuses a formula that still doesn't parse. +pub(crate) fn parse_typed_formula(body: &str) -> Result { + let mut text = body.to_string(); + let mut parsed = gridcraft_formula::parse(&text); + for _ in 0..8 { + if parsed.is_ok() { + break; + } + text.push(')'); + parsed = gridcraft_formula::parse(&text); + } + parsed.map_err(|e| EngineError::Other(format!("There's a problem with this formula: {e}"))) +} + /// Like Excel, a formula that calls a built-in function with too few or too many arguments is /// refused when it's entered, rather than stored to show `#VALUE!`. Functions the registry doesn't /// hold (special forms like LET, LAMBDA, OFFSET, INDIRECT, SUBTOTAL), defined LAMBDA names and /// unknown functions are skipped. -fn check_arg_counts(expr: &gridcraft_formula::Expr) -> Result<()> { +pub(crate) fn check_arg_counts(expr: &gridcraft_formula::Expr) -> Result<()> { let mut bad = None; expr.walk(&mut |e| { if bad.is_none() @@ -1222,7 +1226,7 @@ pub(crate) fn rewrite_all_formulas(wb: &mut gridcraft_model::Workbook, target: & } } for n in wb.names.iter_mut() { - if let Ok(expr) = gridcraft_formula::parse(&n.formula) { + if let Ok(expr) = gridcraft_formula::parse_name(&n.formula) { // Names are workbook-level: unqualified refs don't occur; qualified ones adjust. let new = gridcraft_formula::adjust::adjust(expr, "\u{0}", target, e); n.formula = gridcraft_formula::print(&new); diff --git a/crates/engine/src/cmd/extra.rs b/crates/engine/src/cmd/extra.rs index 01e2ac6e6..360c8747a 100644 --- a/crates/engine/src/cmd/extra.rs +++ b/crates/engine/src/cmd/extra.rs @@ -351,7 +351,7 @@ fn add_element(s: &mut Session, p: &Json) -> Result { /// The trendline described by `chart.addElement`'s trendline parameters (Excel's Add Chart /// Element ▸ Trendline choices, plus the Format Trendline options). fn trendline_param(p: &Json) -> Result { - let num = |k: &str| f64_param(p, k).filter(|v| v.is_finite() && *v >= 0.0).unwrap_or(0.0); + let num = |k: &str| Trendline::clamp_forecast(f64_param(p, k).unwrap_or(0.0)); let mut t = Trendline { forward: num("forward"), backward: num("backward"), diff --git a/crates/engine/src/cmd/formulas.rs b/crates/engine/src/cmd/formulas.rs index 454c545d0..537bb4cfa 100644 --- a/crates/engine/src/cmd/formulas.rs +++ b/crates/engine/src/cmd/formulas.rs @@ -1,6 +1,6 @@ //! Formulas tab: AutoSum, Insert Function, names, auditing, calculation. -use gridcraft_core::{CellRef, RangeRef}; +use gridcraft_core::{CellError, CellRef, RangeRef, Value}; use gridcraft_model::{CalcMode, DefinedName, Formula}; use serde_json::{Value as Json, json}; @@ -53,7 +53,7 @@ pub fn specs() -> Vec { cmd!("formulas.showFormulas", "Show Formulas", ["Formulas", "Formula Auditing"], Some("Ctrl+`"), "{on?}", has_doc, show_formulas), cmd!(query "formulas.errorChecking", "Error Checking", ["Formulas", "Formula Auditing"], None, "{} → cells with errors", has_doc, error_checking), cmd!(query "formulas.evaluateFormula", "Evaluate Formula", ["Formulas", "Formula Auditing"], None, "{cell?, formula?} → steps", has_doc, evaluate_formula), - cmd!(query "formulas.evaluate", "Evaluate", [], None, "{formula: \"=SUM(A1:A3)\", cell?} → value without changing the sheet", has_doc, evaluate), + cmd!(query "formulas.evaluate", "Evaluate", [], None, "{formula: \"=SUM(A1:A3)\", cell?} → value without changing the sheet; a formula that couldn't be entered in a cell is an error", has_doc, evaluate), cmd!(noundo "formulas.calculateNow", "Calculate Now", ["Formulas", "Calculation"], Some("F9"), "{}", has_doc, calc_now), cmd!(noundo "formulas.calculateSheet", "Calculate Sheet", ["Formulas", "Calculation"], Some("Shift+F9"), "{}", has_doc, calc_now), cmd!( @@ -265,7 +265,7 @@ fn define_name(s: &mut Session, p: &Json) -> Result { let refers = match str_param(p, "refersTo") { // As in Excel, relative references are relative to the active cell; names keep them // relative to A1, as files do. - Some(r) => match gridcraft_formula::parse(r.trim_start_matches('=')) { + Some(r) => match gridcraft_formula::parse_name(r.trim_start_matches('=')) { Ok(e) if gridcraft_formula::adjust::has_relative_refs(&e) => { let at = d.selection.active; gridcraft_formula::print(&gridcraft_formula::adjust::shift_relative_wrapping(e, -(at.row as i64), -(at.col as i64))) @@ -280,7 +280,7 @@ fn define_name(s: &mut Session, p: &Json) -> Result { format!("{}!{}", gridcraft_formula::quote_sheet(&sh.name), body) } }; - if gridcraft_formula::parse(&refers).is_err() { + if gridcraft_formula::parse_name(&refers).is_err() { return Err(EngineError::Other("There's a problem with this formula.".into())); } let comment = str_param(p, "comment").unwrap_or("").to_string(); @@ -355,7 +355,7 @@ fn rename_name_in_formulas(wb: &mut gridcraft_model::Workbook, old: &str, old_sc } for n in wb.names.iter_mut() { if n.scope.is_none_or(applies) - && let Some(e) = gridcraft_formula::parse(&n.formula).ok().and_then(rename) + && let Some(e) = gridcraft_formula::parse_name(&n.formula).ok().and_then(rename) { n.formula = gridcraft_formula::print(&e); } @@ -389,7 +389,9 @@ fn name_manager(s: &mut Session, _: &Json) -> Result { .filter(|n| !n.hidden) .map(|n| { let sheet = n.scope.unwrap_or(d.wb.active_sheet); - let v = gridcraft_calc::evaluate(&d.wb, sheet, CellRef::default(), &n.formula); + // parse_name, not evaluate's parse: a name's top-level commas are unions. + let v = gridcraft_formula::parse_name(&n.formula) + .map_or(Value::Error(CellError::Name), |e| gridcraft_calc::recalc::evaluate_expr(&d.wb, sheet, CellRef::default(), &e)); json!({"name": n.name, "refersTo": format!("={}", n.formula), "scope": n.scope.and_then(|i| d.wb.sheet(i)).map(|s| s.name.clone()).unwrap_or_else(|| "Workbook".into()), "value": crate::cmd::inspect::value_json(&v), "comment": n.comment}) }) .collect(); @@ -540,11 +542,16 @@ fn evaluate_formula(s: &mut Session, p: &Json) -> Result { Ok(json!({"formula": format!("={text}"), "steps": steps, "result": crate::cmd::inspect::value_json(&result)})) } +/// Evaluates what typing `formula` into a cell would show. A formula `cell.set` would refuse (it +/// doesn't parse, or calls a function with the wrong number of arguments) is an error that says +/// why, not `#NAME?`; an unknown function name still evaluates to `#NAME?`, as in Excel. fn evaluate(s: &mut Session, p: &Json) -> Result { let d = s.doc()?; let f = str_param(p, "formula").ok_or_else(|| bad("formulas.evaluate", "missing `formula`"))?; let at = cell_param(s, p, "cell")?.unwrap_or(d.selection.active); - let v = gridcraft_calc::evaluate(&d.wb, d.wb.active_sheet, at, f); + let expr = crate::cmd::edit::parse_typed_formula(f.strip_prefix('=').unwrap_or(f))?; + crate::cmd::edit::check_arg_counts(&expr)?; + let v = gridcraft_calc::recalc::evaluate_expr(&d.wb, d.wb.active_sheet, at, &expr); Ok(crate::cmd::inspect::value_json(&v)) } @@ -624,4 +631,39 @@ mod tests { let r = s.execute("formulas.createFromSelection", json!({"range": "A1:A2", "top": true})).unwrap(); assert_eq!(r["created"], 1); } + + #[test] + fn names_referring_to_several_ranges() { + let mut s = Session::new(); + s.new_workbook(); + s.execute("range.setValues", json!({"range": "A1:C2", "values": [[1, 0, 5], [2, 0, 7]]})).unwrap(); + // Excel writes and accepts a union in a name without parentheses. + let r = s.execute("formulas.defineName", json!({"name": "u", "refersTo": "=Sheet1!$A$1:$A$2,Sheet1!$C$1:$C$2"})).unwrap(); + assert_eq!(r["refersTo"], "=Sheet1!$A$1:$A$2,Sheet1!$C$1:$C$2"); + for (cell, input) in [("E1", "=SUM(u)"), ("E2", "=AREAS(u)"), ("E3", "=MIN(u)"), ("E4", "=INDEX(u,2,1,2)")] { + s.execute("cell.set", json!({"cell": cell, "input": input})).unwrap(); + } + let v = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap()["value"].clone(); + assert_eq!(v(&mut s, "E1"), json!(15.0)); + assert_eq!(v(&mut s, "E2"), json!(2.0)); + assert_eq!(v(&mut s, "E3"), json!(1.0)); + assert_eq!(v(&mut s, "E4"), json!(7.0)); + // Renaming the sheet follows every area, and the definition keeps Excel's form. + s.execute("sheet.rename", json!({"name": "Data"})).unwrap(); + let name = s.doc().unwrap().wb.names.iter().find(|n| n.name == "u").map(|n| n.formula.clone()); + assert_eq!(name.as_deref(), Some("Data!$A$1:$A$2,Data!$C$1:$C$2")); + assert_eq!(v(&mut s, "E1"), json!(15.0)); + } + + #[test] + fn name_manager_previews_a_name_over_several_ranges() { + let mut s = Session::new(); + s.new_workbook(); + s.execute("range.setValues", json!({"range": "A1:C2", "values": [[1, 0, 5], [2, 0, 7]]})).unwrap(); + s.execute("formulas.defineName", json!({"name": "u", "refersTo": "=Sheet1!$A$1:$A$2,Sheet1!$C$1:$C$2"})).unwrap(); + let list = s.execute("formulas.nameManager", json!({})).unwrap(); + let u = list.as_array().and_then(|l| l.iter().find(|n| n["name"] == "u")).cloned().unwrap(); + // The union parses as one, so the preview is what the union gives in a cell, not #NAME?. + assert_eq!(u["value"], json!({"error": "#VALUE!"})); + } } diff --git a/crates/engine/src/cmd/review.rs b/crates/engine/src/cmd/review.rs index c55aac8e5..18363ead7 100644 --- a/crates/engine/src/cmd/review.rs +++ b/crates/engine/src/cmd/review.rs @@ -426,6 +426,32 @@ mod tests { s.execute("review.unprotectSheet", json!({"password": "old"})).unwrap(); } + #[test] + fn protected_structure_blocks_rename_move_copy_hide_and_unhide_sheet() { + let mut s = book(); + let names = |s: &Session| s.doc().unwrap().wb.sheets.iter().map(|x| x.name.clone()).collect::>(); + let before = names(&s); + s.execute("review.protectWorkbook", json!({})).unwrap(); + assert!(s.execute("sheet.rename", json!({"sheet": 0, "name": "Renamed"})).is_err()); + assert!(s.execute("sheet.move", json!({"sheet": 0, "to": 1})).is_err()); + assert!(s.execute("sheet.move", json!({"sheet": 0, "to": 1, "copy": true})).is_err()); + assert!(s.execute("sheet.hide", json!({"sheet": 1})).is_err()); + assert_eq!(names(&s), before); + assert!(s.doc().unwrap().wb.sheets.iter().all(|x| x.visibility == Visibility::Visible)); + s.execute("review.protectWorkbook", json!({})).unwrap(); + s.execute("sheet.rename", json!({"sheet": 0, "name": "Renamed"})).unwrap(); + s.execute("sheet.move", json!({"sheet": 0, "to": 1})).unwrap(); + s.execute("sheet.move", json!({"sheet": 0, "to": 1, "copy": true})).unwrap(); + assert_eq!(s.doc().unwrap().wb.sheets.len(), 3); + s.execute("sheet.hide", json!({"sheet": 1})).unwrap(); + s.execute("review.protectWorkbook", json!({})).unwrap(); + assert!(s.execute("sheet.unhide", json!({"sheet": 1})).is_err()); + assert!(s.execute("sheet.unhide", json!({})).is_err()); + assert_eq!(s.doc().unwrap().wb.sheets[1].visibility, Visibility::Hidden); + s.execute("review.protectWorkbook", json!({})).unwrap(); + s.execute("sheet.unhide", json!({"sheet": 1})).unwrap(); + } + #[test] fn protect_workbook_with_a_password_really_verifies() { let mut s = book(); diff --git a/crates/engine/src/cmd/sheet.rs b/crates/engine/src/cmd/sheet.rs index 7870dee4e..14bb064a4 100644 --- a/crates/engine/src/cmd/sheet.rs +++ b/crates/engine/src/cmd/sheet.rs @@ -746,7 +746,7 @@ fn delete_sheet(s: &mut Session, p: &Json) -> Result { *sc -= 1; } // Names referring to the sheet become #REF!. - if let Ok(e) = gridcraft_formula::parse(&n.formula) { + if let Ok(e) = gridcraft_formula::parse_name(&n.formula) { let ne = gridcraft_formula::adjust::delete_sheet(e.clone(), &name, &order); if ne != e { n.formula = gridcraft_formula::print(&ne); @@ -767,6 +767,9 @@ fn delete_sheet(s: &mut Session, p: &Json) -> Result { } fn rename_sheet(s: &mut Session, p: &Json) -> Result { + if s.doc()?.wb.protected_structure { + return Err(EngineError::Other("The workbook is protected and cannot be changed.".into())); + } let i = target_sheet(s, p)?; let Some(name) = str_param(p, "name").map(|n| n.trim().to_string()) else { return Err(bad("sheet.rename", "missing `name`")) }; s.doc()?.wb.check_sheet_name(&name, Some(i)).map_err(EngineError::Other)?; @@ -808,7 +811,7 @@ fn rename_sheet(s: &mut Session, p: &Json) -> Result { } } for n in cx.wb.names.iter_mut() { - if let Ok(e) = gridcraft_formula::parse(&n.formula) { + if let Ok(e) = gridcraft_formula::parse_name(&n.formula) { n.formula = gridcraft_formula::print(&gridcraft_formula::adjust::rename_sheet(e, &old, &name)); } } @@ -818,6 +821,9 @@ fn rename_sheet(s: &mut Session, p: &Json) -> Result { } fn move_sheet(s: &mut Session, p: &Json) -> Result { + if s.doc()?.wb.protected_structure { + return Err(EngineError::Other("The workbook is protected and cannot be changed.".into())); + } let i = target_sheet(s, p)?; let copy = bool_param(p, "copy").unwrap_or(false); let to = p.get("to").and_then(Json::as_u64).map(|v| v as usize).ok_or_else(|| bad("sheet.move", "missing `to`"))?; @@ -847,7 +853,7 @@ fn move_sheet(s: &mut Session, p: &Json) -> Result { .iter() .filter(|nm| nm.scope == Some(i)) .map(|nm| { - let formula = gridcraft_formula::parse(&nm.formula) + let formula = gridcraft_formula::parse_name(&nm.formula) .map(|e| gridcraft_formula::print(&gridcraft_formula::adjust::rename_sheet(e, &base, &sh.name))) .unwrap_or_else(|_| nm.formula.clone()); gridcraft_model::DefinedName { scope: Some(at), formula, ..nm.clone() } @@ -895,6 +901,9 @@ fn tab_color(s: &mut Session, p: &Json) -> Result { } fn hide_sheet(s: &mut Session, p: &Json) -> Result { + if s.doc()?.wb.protected_structure { + return Err(EngineError::Other("The workbook is protected and cannot be changed.".into())); + } let i = target_sheet(s, p)?; if s.doc()?.wb.sheets.iter().filter(|s| s.visibility == Visibility::Visible).count() <= 1 { return Err(EngineError::Other("A workbook must contain at least one visible worksheet.".into())); @@ -909,6 +918,9 @@ fn hide_sheet(s: &mut Session, p: &Json) -> Result { } fn unhide_sheet(s: &mut Session, p: &Json) -> Result { + if s.doc()?.wb.protected_structure { + return Err(EngineError::Other("The workbook is protected and cannot be changed.".into())); + } if p.get("sheet").is_none() { let hidden: Vec = s.doc()?.wb.sheets.iter().filter(|s| s.visibility != Visibility::Visible).map(|s| s.name.clone()).collect(); if hidden.is_empty() { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 2ac290654..e2e684b10 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -241,6 +241,24 @@ fn formulas_with_the_wrong_argument_count_are_refused_on_entry() { assert_eq!(v(&s, "C1"), Value::text("=DATEDIF(1,2)")); } +#[test] +fn evaluate_refuses_what_entry_refuses_and_says_why() { + // `formulas.evaluate` (the CLI's `eval`, MCP's evaluate_formula) gave #NAME? for any formula + // that didn't parse, which hid the cause: `=SUM(1;2)`, with the `;` a French or German Excel + // uses, looked like an unknown function. + let mut s = s(); + let eval = |s: &mut Session, f: &str| s.execute("formulas.evaluate", json!({"formula": f})); + let e = eval(&mut s, "=SUM(1;2)").unwrap_err().to_string(); + assert!(e.contains("There's a problem with this formula") && e.contains("expected `,`"), "{e}"); + let e = eval(&mut s, "=DATEDIF(1,2)").unwrap_err().to_string(); + assert!(e.contains("too few arguments"), "{e}"); + // What entry accepts evaluates: an unknown function is #NAME?, as in Excel, missing closing + // parentheses are added, and the `=` is optional. + assert_eq!(eval(&mut s, "=SOMME(1,2)").unwrap(), json!({"error": "#NAME?"})); + assert_eq!(eval(&mut s, "=SUM(1,2").unwrap(), json!(3.0)); + assert_eq!(eval(&mut s, "1/4").unwrap(), json!(0.25)); +} + #[test] fn undo_redo() { let mut s = s(); diff --git a/crates/formula/src/lib.rs b/crates/formula/src/lib.rs index 88d09a8af..5105dadfb 100644 --- a/crates/formula/src/lib.rs +++ b/crates/formula/src/lib.rs @@ -12,7 +12,7 @@ pub mod printer; pub use ast::{Anchor, BinOp, Expr, RefKind, Reference, SheetSel, StructItem, StructRef, TrimMode, UnOp}; pub use input::{FormulaLocale, parse_input, print_input}; -pub use parser::{MAX_FORMULA_LEN, ParseError, normalize_function_name, parse}; +pub use parser::{MAX_FORMULA_LEN, ParseError, normalize_function_name, parse, parse_name}; pub use printer::{print, print_r1c1, quote_sheet}; #[cfg(test)] diff --git a/crates/formula/src/parser.rs b/crates/formula/src/parser.rs index 555717be9..17bb4ac27 100644 --- a/crates/formula/src/parser.rs +++ b/crates/formula/src/parser.rs @@ -28,7 +28,17 @@ pub fn parse(src: &str) -> Result { parse_syntax(src, crate::FormulaLocale::En) } +/// Parses a defined name's formula. Unlike a cell formula, a comma at the top level is the +/// union operator: Excel stores and shows a name over several ranges as `Sheet1!$A$1,Sheet1!$C$1`. +pub fn parse_name(src: &str) -> Result { + parse_top(src, crate::FormulaLocale::En, true) +} + pub(crate) fn parse_syntax(src: &str, locale: crate::FormulaLocale) -> Result { + parse_top(src, locale, false) +} + +fn parse_top(src: &str, locale: crate::FormulaLocale, allow_union: bool) -> Result { let body = src.strip_prefix('=').unwrap_or(src); if body.len() > MAX_FORMULA_LEN && body.chars().count() > MAX_FORMULA_LEN { return Err(ParseError { msg: format!("formula is longer than {MAX_FORMULA_LEN} characters"), pos: 0 }); @@ -36,7 +46,7 @@ pub(crate) fn parse_syntax(src: &str, locale: crate::FormulaLocale) -> Result Ok(e), t => Err(p.err(format!("unexpected {}", describe(t)))), diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index e6322f016..a6879b3ff 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -196,7 +196,8 @@ pub fn tool_definitions() -> Vec { "evaluate_formula", "Evaluate formula", "Evaluate a formula against the active sheet WITHOUT changing any cell, e.g. \"=SUM(B2:B10)/COUNT(B2:B10)\" or \ - \"=VLOOKUP(\\\"x\\\",A:B,2,FALSE)\". `cell` sets the formula's position for relative references and ROW()/COLUMN().", + \"=VLOOKUP(\\\"x\\\",A:B,2,FALSE)\". `cell` sets the formula's position for relative references and ROW()/COLUMN(). \ + Use English function names and `,` between arguments; a formula that couldn't be entered in a cell is an error saying why.", obj( json!({"formula": string("Formula text, with or without the leading ="), "cell": string("Evaluate as if in this cell (default: active cell)")}), &["formula"], diff --git a/crates/model/src/features.rs b/crates/model/src/features.rs index c35254f0e..9f30ca079 100644 --- a/crates/model/src/features.rs +++ b/crates/model/src/features.rs @@ -482,6 +482,18 @@ pub struct Trendline { pub dash: Option, } +impl Trendline { + /// Longest forward / backward forecast kept from a file or a command, in x units. Excel's own + /// limit is unconfirmed; the renderer additionally bounds the forecast by the plot's slot budget. + pub const MAX_FORECAST: f64 = 100_000.0; + + /// A forecast distance as stored: finite, not negative (NaN and infinity become 0) and at most + /// [`Self::MAX_FORECAST`]. + pub fn clamp_forecast(v: f64) -> f64 { + if v.is_finite() && v > 0.0 { v.min(Self::MAX_FORECAST) } else { 0.0 } + } +} + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct Chart { pub id: u32, @@ -1048,3 +1060,19 @@ impl Default for PivotTable { } } } + +#[cfg(test)] +mod trendline_forecast_tests { + use super::Trendline; + + #[test] + fn clamp_forecast_bounds_hostile_values() { + assert_eq!(Trendline::clamp_forecast(2.5), 2.5); + assert_eq!(Trendline::clamp_forecast(0.0), 0.0); + assert_eq!(Trendline::clamp_forecast(-4.0), 0.0); + assert_eq!(Trendline::clamp_forecast(f64::NAN), 0.0); + assert_eq!(Trendline::clamp_forecast(f64::INFINITY), 0.0); + assert_eq!(Trendline::clamp_forecast(f64::NEG_INFINITY), 0.0); + assert_eq!(Trendline::clamp_forecast(1e300), Trendline::MAX_FORECAST); + } +} diff --git a/crates/model/src/sheet.rs b/crates/model/src/sheet.rs index b404b891c..5a75c8f56 100644 --- a/crates/model/src/sheet.rs +++ b/crates/model/src/sheet.rs @@ -183,6 +183,39 @@ impl Sheet { _ => self.spill.get(&c), } } + /// Last row (inclusive) of the maximal contiguous run of non-empty values in `col`, starting + /// at `from_row` and capped at `cap` (inclusive). `from_row - 1` when `from_row` itself is + /// empty (or `from_row > cap`), same as a cell-by-cell `value_ref` scan would find, but + /// without calling `value_ref` once per row: `CellStore::contiguous_value_run` walks each + /// band's row map directly for the common case (a run of stored cells), only falling back to + /// a per-row `value_ref` check where the stored run stops short of `cap`. Used by the fill-handle + /// double-click, where `col` can be densely populated for very long stretches. + pub fn last_contiguous_nonempty(&self, col: u32, from_row: u32, cap: u32) -> u32 { + if from_row > cap { + return from_row.saturating_sub(1); + } + let mut row = from_row; + loop { + if row > cap { + return cap; + } + let c = CellRef::new(row, col); + match self.cells.contiguous_value_run(c, cap) { + Some(last) => row = last + 1, + None => { + // The run stopped short of `row`: ask `value_ref` for this one row, which also + // covers a stored cell with no value and no formula (a formatted spill child) + // falling through to `spill`. + let qualifies = self.value_ref(c).is_some_and(|v| !v.is_empty()); + if qualifies { + row += 1; + } else { + return row.saturating_sub(1); + } + } + } + } + } pub fn style_id(&self, c: CellRef) -> StyleId { if let Some(cell) = self.cells.get(c) { return cell.style; @@ -411,6 +444,7 @@ fn line_at(pos: f64, default: f64, custom: &BTreeMap, max: u32) - #[cfg(test)] mod tests { use super::*; + use crate::cell::Formula; #[test] fn line_index_matches_linear() { @@ -427,6 +461,120 @@ mod tests { } } + /// Reference implementation: one `value_ref` call per row, exactly the loop + /// `last_contiguous_nonempty` replaces (from `grid.rs`'s fill-handle double-click). + fn last_contiguous_nonempty_naive(s: &Sheet, col: u32, from_row: u32, cap: u32) -> u32 { + let mut row = from_row; + while row <= cap && s.value_ref(CellRef::new(row, col)).is_some_and(|v| !v.is_empty()) { + row += 1; + } + row.saturating_sub(1).max(from_row.saturating_sub(1)) + } + + /// Deterministic xorshift32 so failures reproduce without pulling in a `rand` dependency + /// just for this test. + struct Xorshift32(u32); + impl Xorshift32 { + fn next(&mut self) -> u32 { + self.0 ^= self.0 << 13; + self.0 ^= self.0 >> 17; + self.0 ^= self.0 << 5; + self.0 + } + fn below(&mut self, n: u32) -> u32 { + self.next() % n.max(1) + } + } + + #[test] + fn last_contiguous_nonempty_matches_naive() { + let mut rng = Xorshift32(0x9e3779b9); + for trial in 0..300u32 { + let mut s = Sheet::new("S"); + let rows = rng.below(400); + // A mix of directly-stored values, formulas (value empty but formula present), + // spill-only cells, blank-but-stored cells, and gaps, like a real sheet. + for r in 0..rows { + match rng.below(5) { + 0 => s.set_value(CellRef::new(r, 0), Value::Number(r as f64)), + 1 => s.set_cell(CellRef::new(r, 0), Cell { formula: Some(Arc::new(Formula::new("1"))), ..Default::default() }), + 2 => { + s.spill.insert(CellRef::new(r, 0), Value::Number(r as f64)); + } + 3 => { + // Stored but blank: does not count (matches `has_value`/`value_ref`). + s.set_value(CellRef::new(r, 0), Value::Text("".into())); + } + 4 if rng.below(2) == 0 => { + // A style-only stored cell over a spill value: `value_ref` falls through + // to the spill value, so the row counts. + s.spill.insert(CellRef::new(r, 0), Value::Number(r as f64)); + s.set_cell(CellRef::new(r, 0), Cell { style: StyleId(1), ..Default::default() }); + } + _ => {} // gap + } + } + for _ in 0..5 { + let from_row = rng.below(420); + let cap = from_row.saturating_add(rng.below(450)); + let got = s.last_contiguous_nonempty(0, from_row, cap); + let want = last_contiguous_nonempty_naive(&s, 0, from_row, cap); + assert_eq!(got, want, "trial {trial}: col 0, from_row {from_row}, cap {cap}, rows up to {rows}"); + } + } + } + + #[test] + fn last_contiguous_nonempty_dense_run() { + let mut s = Sheet::new("S"); + for r in 0..2000u32 { + s.set_value(CellRef::new(r, 2), Value::Number(r as f64)); + } + assert_eq!(s.last_contiguous_nonempty(2, 0, 1_999), 1999); + assert_eq!(s.last_contiguous_nonempty(2, 0, 1_000), 1000); // capped + assert_eq!(s.last_contiguous_nonempty(2, 500, 1_999), 1999); + assert_eq!(s.last_contiguous_nonempty(2, 2000, 3000), 1999); // nothing at from_row + assert_eq!(s.last_contiguous_nonempty(2, 2001, 3000), 2000); // from_row > cap: from_row - 1 + } + + #[test] + fn last_contiguous_nonempty_formula_with_empty_value_ends_the_run() { + let mut s = Sheet::new("S"); + s.set_value(CellRef::new(0, 1), Value::Number(1.0)); + s.set_value(CellRef::new(1, 1), Value::Number(2.0)); + // row 2: a formula (e.g. `=""`) whose cached value is empty. It does not count, same as + // `value_ref(...).is_some_and(|v| !v.is_empty())` says for it, even though it has a + // formula (unlike `CellStore::has`, used for Ctrl+arrow navigation, which counts it). + s.set_cell(CellRef::new(2, 1), Cell { formula: Some(Arc::new(Formula::new("\"\""))), ..Default::default() }); + s.set_value(CellRef::new(3, 1), Value::Number(4.0)); // would extend the run if row 2 didn't stop it + assert_eq!(s.last_contiguous_nonempty(1, 0, 100), 1); + assert_eq!(s.last_contiguous_nonempty(1, 2, 100), 1); // from_row itself is the stop + } + + #[test] + fn last_contiguous_nonempty_spill_extends_a_style_only_stored_row() { + let mut s = Sheet::new("S"); + s.set_value(CellRef::new(0, 1), Value::Number(1.0)); + // row 1: a formatted spill child: stored with a style only, value from `spill`. + s.spill.insert(CellRef::new(1, 1), Value::Number(2.0)); + s.set_cell(CellRef::new(1, 1), Cell { style: StyleId(1), ..Default::default() }); + s.set_value(CellRef::new(2, 1), Value::Number(3.0)); + assert_eq!(s.last_contiguous_nonempty(1, 0, 100), 2); + assert_eq!(s.last_contiguous_nonempty(1, 1, 100), 2); + } + + #[test] + fn last_contiguous_nonempty_spill_extends_an_unstored_row() { + let mut s = Sheet::new("S"); + s.set_value(CellRef::new(0, 1), Value::Number(1.0)); + // row 1: nothing stored, but a spill value from an array formula anchored elsewhere. + s.spill.insert(CellRef::new(1, 1), Value::Number(2.0)); + s.set_value(CellRef::new(2, 1), Value::Number(3.0)); + // row 3: a genuine gap (nothing stored, no spill). + assert_eq!(s.last_contiguous_nonempty(1, 0, 100), 2); + assert_eq!(s.last_contiguous_nonempty(1, 3, 100), 2); // from_row itself is the gap + } + #[test] fn geometry() { let mut s = Sheet::new("S"); diff --git a/crates/model/src/store.rs b/crates/model/src/store.rs index d6a9c4561..10eceb822 100644 --- a/crates/model/src/store.rs +++ b/crates/model/src/store.rs @@ -13,6 +13,10 @@ use crate::cell::Cell; const BAND: u32 = 64; +fn has_value(cell: &Cell) -> bool { + !cell.value.is_empty() || cell.formula.is_some() +} + #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] struct Band { /// row → (col → cell) @@ -118,7 +122,44 @@ impl CellStore { } /// Last stored row in column `col` at or above `below_or_at` (for Ctrl+arrow navigation). pub fn has(&self, c: CellRef) -> bool { - self.get(c).is_some_and(|cell| !cell.value.is_empty() || cell.formula.is_some()) + self.get(c).is_some_and(has_value) + } + /// Last row (inclusive, capped at `cap`) of the maximal run of contiguous rows at or after + /// `from.row` that store a cell in `from.col` with a non-empty *value* in `from.col`. `None` + /// when `from` itself doesn't qualify. + /// + /// Deliberately narrower than `has`/`get` (which also count a formula with no cached value + /// yet, for Ctrl+arrow navigation): this matches `Sheet::value_ref`, whose callers test the + /// `Value` it returns with `!v.is_empty()` regardless of whether a formula produced it — a + /// formula cell with an empty cached value (`=""`, or not yet recalculated) is "empty" to + /// them. A stored cell with no value and no formula (for example a formatted spill child) does + /// fall through to `spill` in `value_ref`, so `last_contiguous_nonempty` asks `value_ref` + /// about the row where this run stops. + /// + /// Walks each band's row map with `BTreeMap::range` (one in-order pass per band) instead of + /// one `get` per row, so a long, densely-populated run costs roughly one tree-traversal step + /// per row rather than one binary search per row. + pub fn contiguous_value_run(&self, from: CellRef, cap: u32) -> Option { + let mut last = None; + let mut expect = from.row; + while expect <= cap { + let band_key = expect / BAND; + let Some(band) = self.bands.get(&band_key) else { break }; + let band_hi = ((band_key + 1) * BAND - 1).min(cap); + let mut advanced = false; + for (r, cols) in band.rows.range(expect..=band_hi) { + if *r != expect || cols.get(&from.col).is_none_or(|c| c.value.is_empty()) { + return last; + } + last = Some(expect); + expect += 1; + advanced = true; + } + if !advanced { + break; + } + } + last } /// Positions whose cells differ between `self` and `other` (stored on one side only, or not /// equal). Bands both stores share are skipped, so comparing an edited copy with the store diff --git a/crates/model/src/workbook.rs b/crates/model/src/workbook.rs index 828ca692e..f1ecd3944 100644 --- a/crates/model/src/workbook.rs +++ b/crates/model/src/workbook.rs @@ -233,7 +233,7 @@ impl Workbook { } } for n in self.names.iter_mut() { - if let Ok(e) = gridcraft_formula::parse(&n.formula) { + if let Ok(e) = gridcraft_formula::parse_name(&n.formula) { let ne = spell_calls(e.clone(), &lambdas); if ne != e { n.formula = gridcraft_formula::print(&ne); diff --git a/crates/ui-egui/examples/snapshot.rs b/crates/ui-egui/examples/snapshot.rs index a171199a1..dcee71173 100644 --- a/crates/ui-egui/examples/snapshot.rs +++ b/crates/ui-egui/examples/snapshot.rs @@ -1,7 +1,7 @@ //! Renders the whole GridCraft window offscreen (wgpu, no window) to a PNG. //! //! `cargo run --release -p gridcraft-ui-egui --example snapshot -- [--sample sales] [--in file.xlsx] -//! [--size 1440x900] [--scale 2] [--cmd 'id={json}']... [--tab Insert] [--dark] [--language en|zh|ja|ko|ru|pt|cs] +//! [--size 1440x900] [--scale 2] [--cmd 'id={json}']... [--tab Insert] [--dark] [--language en|es|zh|ja|ko|ru|pt|de|cs|fr|tr] //! [--dialog 'name={json}'] [--hf-edit header|footer] out.png` use gridcraft_engine::Session; diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 2ea5cfae0..2698c1778 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -71,6 +71,16 @@ fn wrap(r: Result) -> Outcome { } } +/// Runs a command for an agent. Commands that move the selection scroll the active cell into +/// view, as the Go To dialog, the Name Box and the arrow keys do. +fn run(app: &mut SheetApp, id: &str, params: Json) -> Result { + let r = app.run(id, params); + if r.is_ok() && matches!(id, "edit.goTo" | "edit.goToSpecial" | "selection.set" | "selection.move") { + app.grid.ensure_visible = true; + } + r +} + pub fn inspect(app: &SheetApp, ctx: &egui::Context) -> Json { let r = ctx.content_rect(); let v = app.view(); @@ -171,7 +181,7 @@ pub fn handle(app: &mut SheetApp, ctx: &egui::Context, method: &str, p: &Json) - "engine.execute" | "command" | "ui.menu.invoke" => { let Some(id) = s("command").or(s("id")) else { return err("missing `command`") }; let params = p.get("params").cloned().unwrap_or(json!({})); - wrap(app.run(id, if params.is_null() { json!({}) } else { params })) + wrap(run(app, id, if params.is_null() { json!({}) } else { params })) } "engine.commands" => { let mut v: Vec = app.session.commands().into_iter().map(|c| serde_json::to_value(c).unwrap_or_default()).collect(); @@ -195,7 +205,11 @@ pub fn handle(app: &mut SheetApp, ctx: &egui::Context, method: &str, p: &Json) - "Submit Print Job", "existing file.print sheet/pagination params → {printing,bytes,pending}; submits without opening a dialog", ), - ("app.language.set", "Interface Language", "{language: \"en\"|\"zh\"|\"ja\"|\"ko\"|\"ru\"|\"pt\"|\"de\"|\"cs\"} (alias: code)"), + ( + "app.language.set", + "Interface Language", + "{language: \"en\"|\"zh\"|\"ja\"|\"ko\"|\"ru\"|\"pt\"|\"de\"|\"cs\"|\"fr\"|\"tr\"} (alias: code)", + ), ("app.language.english", "Interface Language: English", ""), ("app.language.japanese", "Interface Language: Japanese", ""), ] { @@ -377,11 +391,13 @@ pub fn handle(app: &mut SheetApp, ctx: &egui::Context, method: &str, p: &Json) - } "app.save" => wrap(app.run("file.save", json!({"path": s("path")}))), "app.quit" => { + // Programmatic calls never open dialogs: quitting from here doesn't ask about unsaved work. + app.allow_close = true; ctx.send_viewport_cmd(egui::ViewportCommand::Close); ok(Json::Null) } // Anything else that names a command runs it. - other if gridcraft_engine::find_command(other).is_some() => wrap(app.run(other, p.clone())), + other if gridcraft_engine::find_command(other).is_some() => wrap(run(app, other, p.clone())), other => err(format!("unknown method `{other}`")), }; ctx.request_repaint(); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index fb899c702..f18bf122d 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -775,7 +775,7 @@ impl Dialog { } "pickList" => Dialog::custom("pickList", "Pick From List", json!({})), "saveCopy" => Dialog::custom("saveCopy", "Save a Copy", json!({"format": p("format")})), - "saveChanges" => Dialog::custom("saveChanges", "Save Changes?", json!({"title": p("title")})), + "saveChanges" => Dialog::custom("saveChanges", "Save Changes?", json!({"title": p("title"), "quit": p("quit")})), "start" => Dialog::custom("start", "GridCraft", json!({})), "comments" => { app.grid.pane = None; @@ -1183,9 +1183,14 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) { } "saveChanges" => { ui.label(format!("Do you want to save the changes you made to {}?", d.values.get("title").and_then(Json::as_str).unwrap_or("this workbook"))); + // Asked while quitting: once answered, the next unsaved workbook is asked about. + let quitting = d.values.get("quit").and_then(Json::as_bool) == Some(true); ui.horizontal(|ui| { if ui.button("Don't Save").clicked() { app.run_or_alert("file.close", json!({"force": true})); + if quitting { + app.continue_quit(ui.ctx()); + } open = false; } if ui.button("Cancel").clicked() { @@ -1196,6 +1201,9 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) { app.run_or_alert("file.save", json!({})); if app.session.active().is_some_and(|x| !x.is_dirty()) { app.run_or_alert("file.close", json!({"force": true})); + if quitting { + app.continue_quit(ui.ctx()); + } } open = false; } diff --git a/crates/ui-egui/src/editor.rs b/crates/ui-egui/src/editor.rs index 799109f89..02d2428f9 100644 --- a/crates/ui-egui/src/editor.rs +++ b/crates/ui-egui/src/editor.rs @@ -32,6 +32,8 @@ pub struct EditState { pub point: Option<(usize, usize)>, /// Where point mode's keyboard cursor is. pub point_cell: Option, + /// The fixed range anchor and its sheet while pointing with the mouse or keyboard. + pub point_anchor: Option<(usize, CellRef)>, pub autocomplete: Vec, pub ac_index: usize, pub request_focus: bool, @@ -56,6 +58,7 @@ impl EditState { caret, point: None, point_cell: None, + point_anchor: None, autocomplete: vec![], ac_index: 0, request_focus: true, @@ -81,9 +84,7 @@ impl EditState { if !self.is_formula() { return false; } - if let Some((_, e)) = self.point - && e == self.byte_caret() - { + if self.point_at_caret() { return true; } let before = self.text.get(..self.byte_caret()).unwrap_or(""); @@ -91,6 +92,10 @@ impl EditState { matches!(last, Some('=' | '(' | ',' | '+' | '-' | '*' | '/' | '^' | '&' | '<' | '>' | ':' | ';' | '{')) } + pub fn point_at_caret(&self) -> bool { + self.point.is_some_and(|(start, end)| end == self.byte_caret() && self.text.get(start..end).is_some()) + } + /// Inserts (or replaces the last pointed) reference text at the caret. pub fn insert_ref(&mut self, r: &str) { let caret_b = self.byte_caret(); @@ -107,6 +112,17 @@ impl EditState { self.sync_caret = true; } + /// Add another argument after the current pointed reference. Keep only the new reference + /// replaceable, so a subsequent plain click does not remove earlier arguments or the separator. + pub fn append_ref(&mut self, r: &str) { + let separator = if self.point_at_caret() { self.locale.list_separator().to_string() } else { String::new() }; + self.point = None; + self.insert_ref(&format!("{separator}{r}")); + if let Some((start, end)) = self.point { + self.point = Some((start + separator.len(), end)); + } + } + /// Updates function autocomplete for the word before the caret. pub fn update_autocomplete(&mut self, names: &[String]) { self.autocomplete.clear(); diff --git a/crates/ui-egui/src/formula_bar.rs b/crates/ui-egui/src/formula_bar.rs index 7f1ad87fc..a44369c42 100644 --- a/crates/ui-egui/src/formula_bar.rs +++ b/crates/ui-egui/src/formula_bar.rs @@ -162,6 +162,7 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font: let focused = ui.ctx().memory(|m| m.focused()); let has_focus = focused == Some(id) || (mine && (ed.request_focus || focused.is_none())); let mut action: Option<(i64, i64, bool, bool)> = None; + let mut arrow = false; let mut cancel = false; if has_focus && mine { let (enter, tab, esc, alt_enter, cmd_enter, ctrl_shift_enter, shift) = ui.input_mut(|i| { @@ -249,6 +250,7 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font: crate::grid::point_move(app, &mut ed, dr, dc, ashift); } else { action = Some((dr, dc, false, false)); + arrow = true; } } // F4 cycles $ anchors of the reference before the caret. @@ -319,6 +321,7 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font: ed.completion = crate::editor::column_completion(sh, ed.cell, &ed.text); } ed.point = None; + ed.point_anchor = None; let names: Vec = function_names() .iter() .map(|name| { @@ -413,7 +416,11 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font: if cancel { app.cancel_edit(); } else if let Some((dr, dc, array, fill)) = action { - app.commit_edit(dr, dc, array, fill); + if arrow { + app.commit_edit_arrow(dr, dc); + } else { + app.commit_edit(dr, dc, array, fill); + } } } diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 106bc6684..ac569b5d6 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -782,6 +782,7 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) { crate::paper_view::interact(app, ui, &resp, &geo, sh, &wb); interact(app, ui, &resp, &geo, sh, &wb); } + update_cursor(app, ui, &resp, &geo, sh); crate::text_box::show(app, ui, &geo, sh); if app.editor.as_ref().is_some_and(|e| !e.from_formula_bar) { in_cell_editor(app, ui, &geo, sh, &wb); @@ -1805,6 +1806,95 @@ pub(crate) fn paint_headers( /// How close (in points) a press must be to an object's bottom-right corner to resize it instead of moving it. const OBJECT_RESIZE_RADIUS: f32 = 10.0; +/// Decide after interaction so drag start and release use the new state in the same frame. +fn update_cursor(app: &mut SheetApp, ui: &egui::Ui, resp: &egui::Response, geo: &Geo, sh: &Sheet) { + if app.text_box_editor.is_some() || app.grid.hf_edit.is_some() { + return; + } + let ctx = ui.ctx(); + let pointer = ctx.input(|i| i.pointer.clone()); + let pos = pointer.interact_pos().or(pointer.hover_pos()).map(|p| geo.mirror_pos(p)); + let in_cells = pos.is_some_and(|p| geo.in_cells(p)); + let (freeze_cols, freeze_rows) = freeze_handles(geo); + // Hover bookkeeping also serves cell notes, independently of who owns the cursor. + if resp.hovered() + && let Some(p) = pos + && !freeze_cols.contains(p) + && !freeze_rows.contains(p) + && !matches!(app.grid.drag, Drag::Freeze { .. }) + { + app.grid.hover_cell = in_cells.then(|| geo.cell_at(sh, p)); + } + // Draw tools own the cursor over cells, including while recording a stroke. + if !app.session.draw_tool.is_empty() && in_cells { + ctx.set_cursor_icon(CursorIcon::Crosshair); + return; + } + // A held drag owns the cursor even outside the grid; a press below egui's threshold still + // has Drag::None and uses hover. A click can briefly leave a drag state after release. + if resp.dragged() && pointer.primary_down() { + let cursor = match app.grid.drag { + Drag::None => None, + Drag::Select => Some(CursorIcon::Cell), + Drag::Rows(_) | Drag::Cols(_) => Some(CursorIcon::Default), + Drag::Fill { .. } => Some(CursorIcon::Crosshair), + Drag::Move { .. } | Drag::MoveCols { .. } | Drag::Object { resize: false, .. } => Some(CursorIcon::Move), + Drag::ResizeCol { .. } => Some(CursorIcon::ResizeColumn), + Drag::ResizeRow { .. } => Some(CursorIcon::ResizeRow), + Drag::Freeze { cols, .. } => Some(if cols { CursorIcon::ResizeHorizontal } else { CursorIcon::ResizeVertical }), + Drag::Object { resize: true, .. } => Some(CursorIcon::ResizeNwSe), + }; + if let Some(cursor) = cursor { + ctx.set_cursor_icon(cursor); + return; + } + } + if !resp.hovered() { + return; + } + let Some(p) = pos else { return }; + if freeze_cols.contains(p) || freeze_rows.contains(p) { + let cols = freeze_cols.contains(p); + ctx.set_cursor_icon(if cols { CursorIcon::ResizeHorizontal } else { CursorIcon::ResizeVertical }); + if app.grid.drag == Drag::None && !pointer.any_down() { + let tip = if cols { "Drag to freeze columns" } else { "Drag to freeze rows" }; + resp.clone().on_hover_text_at_pointer(crate::i18n::current(ctx).tr(tip)); + } + return; + } + let in_col_header = p.y < geo.cells.top() && p.y >= geo.head_top() && p.x >= geo.cells.left(); + let in_row_header = p.x < geo.cells.left() && p.x >= geo.head_left() && p.y >= geo.cells.top(); + let sel = app.session.active().map(|d| d.selection.clone()).unwrap_or_default(); + let cur_rect = geo.range_rect(sh, sel.current()); + let handle = Rect::from_center_size(cur_rect.right_bottom(), vec2(9.0, 9.0)); + let handle_panes = geo.range_panes(RangeRef::cell(sel.current().end)); + let border_panes = geo.range_panes(sel.current()); + let on_handle = handle.contains(p) && handle_panes.iter().any(|r| r.contains(p)); + let on_cur_border = on_border(cur_rect, p) && border_panes.iter().any(|r| r.contains(p)); + let selected_object = crate::chartview::hit(app, geo, sh, p).filter(|&(_, id, _)| app.selected_chart == Some(id)); + let near_corner = selected_object.is_some_and(|(_, _, rect)| (p - rect.right_bottom()).length() < OBJECT_RESIZE_RADIUS); + if app.grid.drag == Drag::None && near_corner { + ctx.set_cursor_icon(CursorIcon::ResizeNwSe); + } else if in_col_header && col_edge(geo, sh, p.x).is_some() { + ctx.set_cursor_icon(CursorIcon::ResizeColumn); + } else if in_row_header && row_edge(geo, sh, p.y).is_some() { + ctx.set_cursor_icon(CursorIcon::ResizeRow); + } else if on_handle && app.editor.is_none() { + ctx.set_cursor_icon(CursorIcon::Crosshair); + if app.grid.drag == Drag::None && !pointer.any_down() { + resp.clone().on_hover_text_at_pointer("Drag to fill cells. Drag from inside a cell to select a range."); + } + } else if in_cells && app.editor.is_none() && on_cur_border { + ctx.set_cursor_icon(CursorIcon::Move); + } else if in_cells { + ctx.set_cursor_icon(CursorIcon::Cell); + } else if in_col_header { + let col = geo.col_at(sh, p.x); + let selected = sel.ranges.len() == 1 && sel.current().is_full_cols() && sel.current().contains(CellRef::new(0, col)); + ctx.set_cursor_icon(if selected { CursorIcon::Move } else { CursorIcon::Default }); + } +} + fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &Geo, sh: &Sheet, wb: &Workbook) { // Text selection and editing must not also move the object or edit worksheet cells. if app.text_box_editor.is_some() || app.grid.hf_edit.is_some() { @@ -1832,51 +1922,6 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & let on_cur_border = |p: Pos2| on_border(cur_rect, p) && border_panes.iter().any(|r| r.contains(p)); let (freeze_cols, freeze_rows) = freeze_handles(geo); - // Hover cursors. - if resp.hovered() - && let Some(p) = pos - && (freeze_cols.contains(p) || freeze_rows.contains(p) || matches!(app.grid.drag, Drag::Freeze { .. })) - { - let cols = match app.grid.drag { - Drag::Freeze { cols, .. } => cols, - _ => freeze_cols.contains(p), - }; - ctx.set_cursor_icon(if cols { CursorIcon::ResizeHorizontal } else { CursorIcon::ResizeVertical }); - if app.grid.drag == Drag::None && !pointer.any_down() { - let tip = if cols { "Drag to freeze columns" } else { "Drag to freeze rows" }; - resp.clone().on_hover_text_at_pointer(crate::i18n::current(&ctx).tr(tip)); - } - } else if resp.hovered() - && let Some(p) = pos - { - let near_col_edge = in_col_header && col_edge(geo, sh, p.x).is_some(); - let near_row_edge = in_row_header && row_edge(geo, sh, p.y).is_some(); - let selected_object = crate::chartview::hit(app, geo, sh, p).filter(|&(_, id, _)| app.selected_chart == Some(id)); - let near_corner = selected_object.is_some_and(|(_, _, rect)| (p - rect.right_bottom()).length() < OBJECT_RESIZE_RADIUS); - let near_object_corner = app.grid.drag == Drag::None && near_corner; - if near_object_corner || matches!(app.grid.drag, Drag::Object { resize: true, .. }) { - ctx.set_cursor_icon(CursorIcon::ResizeNwSe); - } else if near_col_edge { - ctx.set_cursor_icon(CursorIcon::ResizeColumn); - } else if near_row_edge { - ctx.set_cursor_icon(CursorIcon::ResizeRow); - } else if on_handle(p) && app.editor.is_none() { - ctx.set_cursor_icon(CursorIcon::Crosshair); - if app.grid.drag == Drag::None && !pointer.any_down() { - resp.clone().on_hover_text_at_pointer("Drag to fill cells. Drag from inside a cell to select a range."); - } - } else if in_cells && app.editor.is_none() && on_cur_border(p) { - ctx.set_cursor_icon(CursorIcon::Move); - } else if in_cells { - ctx.set_cursor_icon(CursorIcon::Cell); - } else if in_col_header { - let col = geo.col_at(sh, p.x); - let selected = sel.ranges.len() == 1 && sel.current().is_full_cols() && sel.current().contains(CellRef::new(0, col)); - ctx.set_cursor_icon(if selected { CursorIcon::Move } else { CursorIcon::Default }); - } - app.grid.hover_cell = in_cells.then(|| geo.cell_at(sh, p)); - } - // Draw tab tools: the pen records a stroke, the eraser deletes ink under the pointer. if !app.session.draw_tool.is_empty() && in_cells { ctx.set_cursor_icon(CursorIcon::Crosshair); @@ -2005,11 +2050,8 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & // Fill down as far as the neighbouring column goes. let src = sel.current(); let neighbour = if src.start.col > 0 { src.start.col - 1 } else { src.end.col + 1 }; - let mut last = src.end.row; - while last + 1 < MAX_ROWS && sh.value_ref(CellRef::new(last + 1, neighbour)).is_some_and(|v| !v.is_empty()) && last - src.end.row < 1_000_000 - { - last += 1; - } + let cap = src.end.row.saturating_add(1_000_000).min(MAX_ROWS - 1); + let last = if src.end.row < cap { sh.last_contiguous_nonempty(neighbour, src.end.row + 1, cap) } else { src.end.row }; if last > src.end.row { let target = RangeRef::new(src.start, CellRef::new(last, src.end.col)); let _ = app.run("edit.autoFill", json!({"source": src.a1(), "target": target.a1()})); @@ -2098,14 +2140,26 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & if let Some(ed) = app.editor.as_mut() && ed.can_point() { + let anchor = if mods.shift && ed.point_at_caret() { + ed.point_anchor.filter(|(sheet, _)| *sheet == wb.active_sheet).map(|(_, cell)| cell).unwrap_or(c) + } else { + c + }; + let range = RangeRef::new(anchor, c); + let text = if range.is_single() { c.a1() } else { range.a1() }; let sheet_name = (ed.sheet != wb.active_sheet).then(|| sh.name.clone()); let r = match sheet_name { - Some(n) => format!("{}!{}", gridcraft_engine::formula::quote_sheet(&n), c.a1()), - None => c.a1(), + Some(n) => format!("{}!{text}", gridcraft_engine::formula::quote_sheet(&n)), + None => text, }; - ed.insert_ref(&r); + if mods.command && !mods.shift { + ed.append_ref(&r); + } else { + ed.insert_ref(&r); + } ed.request_focus = true; ed.point_cell = Some(c); + ed.point_anchor = Some((wb.active_sheet, anchor)); app.grid.drag = Drag::Select; app.grid.drag_select = true; if resp.drag_started() @@ -2367,6 +2421,7 @@ fn point_header(app: &mut SheetApp, wb: &Workbook, sh: &Sheet, range: RangeRef) let r = range.a1(); let r = if ed.sheet != wb.active_sheet { format!("{}!{r}", gridcraft_engine::formula::quote_sheet(&sh.name)) } else { r }; ed.insert_ref(&r); + ed.point_anchor = None; ed.request_focus = true; true } @@ -2379,12 +2434,13 @@ fn select_to_pointer(app: &mut SheetApp, geo: &Geo, sh: &Sheet, wb: &Workbook, p if let Some(ed) = app.editor.as_mut() && ed.point.is_some() { - if let Some(start) = ed.point_cell { + if let Some(start) = ed.point_anchor.filter(|(sheet, _)| *sheet == wb.active_sheet).map(|(_, cell)| cell).or(ed.point_cell) { let range = RangeRef::new(start, cell); let text = if range.is_single() { range.start.a1() } else { range.a1() }; let text = if ed.sheet != wb.active_sheet { format!("{}!{text}", gridcraft_engine::formula::quote_sheet(&sh.name)) } else { text }; ed.insert_ref(&text); - ed.point_cell = Some(start); + ed.point_cell = Some(cell); + ed.point_anchor = Some((wb.active_sheet, start)); } } else if let Some(d) = app.session.active() { // Read the current anchor: starting a drag may have just replaced the selection. @@ -2925,7 +2981,7 @@ fn context_menu(app: &mut SheetApp, ui: &mut egui::Ui) { ui.separator(); continue; } - if ui.add(egui::Button::new(lang.tr(label)).frame(false).min_size(vec2(190.0, 20.0))).clicked() { + if ui.add(egui::Button::new(lang.tr(label)).frame_when_inactive(false).min_size(vec2(190.0, 20.0))).clicked() { close = true; match id { "ui:pasteSpecial" => app.open_dialog("pasteSpecial", json!({})), @@ -2975,11 +3031,11 @@ pub fn point_move(app: &mut SheetApp, ed: &mut EditState, dr: i64, dc: i64, exte let Some(d) = app.session.active() else { return }; let from = ed.point_cell.unwrap_or(ed.cell); let to = from.offset_clamped(dr, dc); - let text = if extend { + let anchor = if extend { // The anchor is the corner opposite the moving end: the written range is normalized, so // after extending up or left its first cell is the moving end, not the anchor. let pointed = ed.point.and_then(|(s, e)| ed.text.get(s..e)).and_then(RangeRef::parse); - let anchor = pointed + let inferred_anchor = pointed .map(|r| { CellRef::new( if from.row == r.start.row { r.end.row } else { r.start.row }, @@ -2987,13 +3043,15 @@ pub fn point_move(app: &mut SheetApp, ed: &mut EditState, dr: i64, dc: i64, exte ) }) .unwrap_or(from); - RangeRef::new(anchor, to).a1() + ed.point_anchor.filter(|(sheet, _)| *sheet == d.wb.active_sheet).map(|(_, cell)| cell).unwrap_or(inferred_anchor) } else { - to.a1() + to }; - let _ = d; + let text = if extend { RangeRef::new(anchor, to).a1() } else { to.a1() }; + let sheet = d.wb.active_sheet; ed.insert_ref(&text); ed.point_cell = Some(to); + ed.point_anchor = Some((sheet, anchor)); app.grid.ensure_visible = true; } @@ -3115,6 +3173,18 @@ pub(crate) fn paint_overlays( let _ = (wb, si); } +/// Rows the list-validation dropdown shows before it scrolls (Excel shows 8). +const LIST_PICKER_MAX_ROWS: usize = 8; +const LIST_PICKER_ROW_H: f32 = 18.0; +/// Popup frame around the rows: 2 px inner margin and the window stroke on each side, rounded up. +const LIST_PICKER_CHROME: f32 = 8.0; + +/// Popup height for `n` items: every item up to `LIST_PICKER_MAX_ROWS`, then a scrollbar. +fn list_picker_height(n: usize, row_h: f32, gap: f32) -> f32 { + let rows = n.clamp(1, LIST_PICKER_MAX_ROWS) as f32; + rows * row_h + (rows - 1.0) * gap +} + fn list_picker(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, was_open: bool) { let Some(c) = app.grid.list_picker else { return }; let Some(d) = app.session.active() else { return }; @@ -3127,14 +3197,27 @@ fn list_picker(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, was_open: bool) }; let items = gridcraft_engine::cmd::data::list_items(&wb, si, &dv); let r = geo.cell_rect(sh, c); - let (at, pivot) = (geo.mirror_pos(r.left_bottom()), if geo.rtl { Align2::RIGHT_TOP } else { Align2::LEFT_TOP }); + // Fit the popup to its rows (up to LIST_PICKER_MAX_ROWS); without a minimum the scroll area collapses to its 64 px default. + let height = list_picker_height(items.len(), LIST_PICKER_ROW_H, ui.spacing().item_spacing.y); + // Open below the cell, or above it when the rows would run past the bottom of the window (as Excel does). + let screen = ui.ctx().content_rect(); + let above = r.bottom() + height + LIST_PICKER_CHROME > screen.bottom() && r.top() - height - LIST_PICKER_CHROME >= screen.top(); + let (at, pivot) = match (above, geo.rtl) { + (false, false) => (geo.mirror_pos(r.left_bottom()), Align2::LEFT_TOP), + (false, true) => (geo.mirror_pos(r.left_bottom()), Align2::RIGHT_TOP), + (true, false) => (geo.mirror_pos(r.left_top()), Align2::LEFT_BOTTOM), + (true, true) => (geo.mirror_pos(r.left_top()), Align2::RIGHT_BOTTOM), + }; let mut close = false; let response = egui::Area::new(egui::Id::new("dv_list")).fixed_pos(at).pivot(pivot).order(egui::Order::Foreground).show(ui.ctx(), |ui| { egui::Frame::popup(ui.style()).inner_margin(2.0).show(ui, |ui| { ui.set_min_width(r.width().max(100.0)); - egui::ScrollArea::vertical().max_height(200.0).show(ui, |ui| { + egui::ScrollArea::vertical().max_height(height).min_scrolled_height(height).show(ui, |ui| { for it in &items { - if ui.add(egui::Button::new(it.as_str()).frame(false).min_size(vec2(r.width().max(100.0), 18.0))).clicked() { + if ui + .add(egui::Button::new(it.as_str()).frame_when_inactive(false).min_size(vec2(r.width().max(100.0), LIST_PICKER_ROW_H))) + .clicked() + { app.run_or_alert("cell.set", json!({"cell": c.a1(), "input": it})); close = true; } @@ -3197,7 +3280,7 @@ fn header_menu(app: &mut SheetApp, ui: &mut egui::Ui, was_open: bool) { ui.separator(); continue; } - if ui.add(egui::Button::new(lang.tr(label)).frame(false).min_size(vec2(180.0, 20.0))).clicked() { + if ui.add(egui::Button::new(lang.tr(label)).frame_when_inactive(false).min_size(vec2(180.0, 20.0))).clicked() { close = true; match id { "ui:insertPaste" => app.open_dialog("insertPaste", json!({})), @@ -3393,6 +3476,14 @@ mod tests { } use super::*; + #[test] + fn list_picker_fits_up_to_eight_rows_then_scrolls() { + assert_eq!(list_picker_height(5, 18.0, 3.0), 5.0 * 18.0 + 4.0 * 3.0); + assert_eq!(list_picker_height(8, 18.0, 3.0), 8.0 * 18.0 + 7.0 * 3.0); + assert_eq!(list_picker_height(30, 18.0, 3.0), list_picker_height(8, 18.0, 3.0)); + assert_eq!(list_picker_height(0, 18.0, 3.0), 18.0); + } + #[test] fn wheel_zoom_steps_along_the_ladder_and_stops_at_100() { for (from, zoom_in, to) in [ diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 98919f8a4..6e575c1b9 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -17,6 +17,7 @@ mod ko; mod pt; mod ru; mod sr; +mod tr; mod zh; #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] @@ -35,10 +36,12 @@ pub enum Language { Cs, Fr, Sr, + Tr, } impl Language { - pub const ALL: [Self; 11] = [Self::En, Self::Es, Self::Zh, Self::Ja, Self::Ko, Self::Ru, Self::PtBr, Self::De, Self::Cs, Self::Fr, Self::Sr]; + pub const ALL: [Self; 12] = + [Self::En, Self::Es, Self::Zh, Self::Ja, Self::Ko, Self::Ru, Self::PtBr, Self::De, Self::Cs, Self::Fr, Self::Sr, Self::Tr]; /// The language's own name, shown in the switcher. pub fn name(self) -> &'static str { @@ -54,6 +57,7 @@ impl Language { Self::Cs => "Čeština", Self::Fr => "Français", Self::Sr => "Srpski", + Self::Tr => "Türkçe", } } @@ -71,6 +75,7 @@ impl Language { Self::Cs => "cs", Self::Fr => "fr", Self::Sr => "sr", + Self::Tr => "tr", } } @@ -91,6 +96,7 @@ impl Language { "cs" => Some(Self::Cs), "fr" => Some(Self::Fr), "sr" => Some(Self::Sr), + "tr" => Some(Self::Tr), _ => None, } } @@ -129,6 +135,7 @@ impl Language { Self::Cs => cs::translate(text), Self::Fr => fr::translate(text), Self::Sr => sr::translate(text), + Self::Tr => tr::translate(text), }; translated.unwrap_or_else(|| text.rsplit_once('|').map_or(text, |(_, label)| label)) } @@ -187,12 +194,11 @@ const JAPANESE: &[(&str, &str)] = &[ ("Insert Copied Cells…", "コピーしたセルの挿入…"), ("Insert Cut Cells", "切り取ったセルの挿入"), ("Insert Paste", "挿入貼り付け"), - ("Cut (⌘X)", "切り取り (⌘X)"), - ("Copy (⌘C)", "コピー (⌘C)"), + ("Cut", "切り取り"), + ("Copy", "コピー"), ("Format Painter (double-click to keep it on)", "書式のコピー/貼り付け (ダブルクリックで固定)"), - ("Bold (⌘B)", "太字 (⌘B)"), - ("Italic (⌘I)", "斜体 (⌘I)"), - ("Underline (⌘U)", "下線 (⌘U)"), + ("Bold", "太字"), + ("Italic", "斜体"), ("Double Underline", "二重下線"), ("Strikethrough", "取り消し線"), ("Underline", "下線"), @@ -261,7 +267,6 @@ const JAPANESE: &[(&str, &str)] = &[ ("Clear Comments and Notes", "コメントとメモのクリア"), ("Clear Hyperlinks", "ハイパーリンクのクリア"), ("AutoSum", "オート SUM"), - ("AutoSum (⌘⇧T)", "オート SUM (⌘⇧T)"), ("Flash\nFill", "フラッシュ フィル"), ("Flash Fill", "フラッシュ フィル"), ("Fill", "フィル"), @@ -611,6 +616,7 @@ mod tests { assert!(cs::translate(english).is_some_and(|text| !text.is_empty()), "missing Czech: {english}"); assert!(fr::translate(english).is_some_and(|text| !text.is_empty()), "missing French: {english}"); assert!(sr::translate(english).is_some_and(|text| !text.is_empty()), "missing Serbian: {english}"); + assert!(tr::translate(english).is_some_and(|text| !text.is_empty()), "missing Turkish: {english}"); } assert_eq!(Language::Ja.tr("Data"), "データ"); assert_eq!(Language::Zh.tr("Data"), "数据"); @@ -624,6 +630,8 @@ mod tests { assert_eq!(Language::De.tr("Sheet1!A1"), "Sheet1!A1"); assert_eq!(Language::Fr.tr("Data"), "Données"); assert_eq!(Language::Fr.tr("Sheet1!A1"), "Sheet1!A1"); + assert_eq!(Language::Tr.tr("Data"), "Veri"); + assert_eq!(Language::Tr.tr("Sheet1!A1"), "Sheet1!A1"); assert_eq!(Language::PtBr.tr("Date"), "Data"); assert_eq!(Language::PtBr.tr("Sheet1!A1"), "Sheet1!A1"); assert_eq!(Language::Ja.tr("Sheet1!A1"), "Sheet1!A1"); @@ -634,6 +642,7 @@ mod tests { assert_eq!(Language::Cs.tr("Data"), "Data"); assert_eq!(Language::Cs.tr("Formulas"), "Vzorce"); assert_eq!(Language::Cs.tr("Sheet1!A1"), "Sheet1!A1"); + assert_eq!(Language::Tr.tr("Formulas"), "Formüller"); // A `context|label` key shows only its label in English and splits the translation. assert_eq!(Language::En.tr("Page Layout|Orientation"), "Orientation"); assert_eq!(Language::Ja.tr("Page Layout|Orientation"), "印刷の向き"); @@ -644,6 +653,8 @@ mod tests { assert_eq!(Language::Sr.tr("Sheet1!A1"), "Sheet1!A1"); assert_eq!(Language::Sr.tr("Page Layout|Orientation"), "Orijentacija"); assert_eq!(Language::Sr.tr("Orientation"), "Orijentacija"); + assert_eq!(Language::Tr.tr("Page Layout|Orientation"), "Sayfa Yönü"); + assert_eq!(Language::Tr.tr("Orientation"), "Yönlendirme"); assert_eq!(Language::parse("xx"), None); } @@ -738,6 +749,9 @@ mod tests { assert_eq!(Language::from_tag("sr-Latn-RS"), Some(Language::Sr)); assert_eq!(Language::from_tag("sr-RS"), Some(Language::Sr)); assert_eq!(Language::parse("sr"), Some(Language::Sr)); + assert_eq!(Language::from_tag("tr-TR"), Some(Language::Tr)); + assert_eq!(Language::from_tag("tr_TR.UTF-8"), Some(Language::Tr)); + assert_eq!(Language::parse("tr"), Some(Language::Tr)); } #[test] diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index 7ed8372c7..4c1a245b9 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -9,12 +9,9 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Transpose" => "Transponovat", "Paste Link" => "Vložit propojení", "Paste Special…" => "Vložit jinak…", - "Cut (⌘X)" => "Vyjmout (⌘X)", - "Copy (⌘C)" => "Kopírovat (⌘C)", "Format Painter (double-click to keep it on)" => "Štětec formátu (dvojklikem zůstane zapnutý)", - "Bold (⌘B)" => "Tučné (⌘B)", - "Italic (⌘I)" => "Kurzíva (⌘I)", - "Underline (⌘U)" => "Podtržení (⌘U)", + "Bold" => "Tučné", + "Italic" => "Kurzíva", "Double Underline" => "Dvojité podtržení", "Strikethrough" => "Přeškrtnutí", "Underline" => "Podtržení", @@ -83,7 +80,6 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Clear Comments and Notes" => "Vymazat komentáře a poznámky", "Clear Hyperlinks" => "Vymazat hypertextové odkazy", "AutoSum" => "Automatický\nsoučet", - "AutoSum (⌘⇧T)" => "Automatický součet (⌘⇧T)", "Flash\nFill" => "Dynamické\ndoplňování", "Flash Fill" => "Dynamické doplňování", "Fill" => "Vyplnit", @@ -451,9 +447,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Edit chart title" => "Upravit název grafu", "Erase ink strokes" => "Vymazat tahy rukopisu", "Every command run in this session (replayable)" => "Všechny příkazy spuštěné v této relaci (lze znovu přehrát)", - "Filter (⌘⇧F)" => "Filtr (⌘⇧F)", "Find & Select" => "Najít a vybrat", - "Flash Fill (⌘E)" => "Dynamické doplňování (⌘E)", "Forecast Sheet" => "List prognózy", "Format Chart pane" => "Podokno formátu grafu", "Format as Table" => "Formátovat jako tabulku", @@ -464,7 +458,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Import a text or CSV file" => "Importovat textový soubor nebo CSV", "Insert\nSlicer" => "Vložit\nprůřez", "Insert Function (⇧F3)" => "Vložit funkci (⇧F3)", - "Insert Link (⌘K)" => "Vložit odkaz (⌘K)", + "Insert Link" => "Vložit odkaz", "Insert Sheet" => "Vložit list", "Insert Slicer" => "Vložit průřez", "Insert a picture" => "Vložit obrázek", @@ -479,7 +473,6 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Name Manager" => "Správce názvů", "Page Breaks" => "Konce stránek", "Paper Size" => "Velikost papíru", - "Paste (⌘V)" => "Vložit (⌘V)", "Pen\nBlack" => "Pero\nčerné", "Pen\nBlue" => "Pero\nmodré", "Pen\nRed" => "Pero\nčervené", @@ -490,13 +483,12 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Protect Sheet" => "Zamknout list", "Protect Workbook structure" => "Zamknout strukturu sešitu", "Recommended Charts" => "Doporučené grafy", - "Redo (⌘Y)" => "Znovu (⌘Y)", + "Redo" => "Znovu", "Remove Duplicates" => "Odebrat duplicity", "Rename Sheet" => "Přejmenovat list", - "Save (⌘S)" => "Uložit (⌘S)", "Scatter or Bubble" => "Bodový nebo bublinový graf", "Search and run any command" => "Vyhledat a spustit libovolný příkaz", - "Search commands (⌘⇧Space)" => "Vyhledat příkazy (⌘⇧Space)", + "Search commands" => "Vyhledat příkazy", "Select Data" => "Vybrat data", "Select and move ink and shapes" => "Vybrat a přesunout rukopis a obrazce", "Series…" => "Řady…", @@ -507,12 +499,11 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Spelling (F7)" => "Pravopis (F7)", "Statistic" => "Statistické grafy", "Switch Row/Column" => "Prohodit řádky/sloupce", - "Table (⌘T)" => "Tabulka (⌘T)", "Table Styles" => "Styly tabulky", "Text Box" => "Textové pole", "Text to Columns" => "Text do sloupců", "Top Align" => "Zarovnat nahoru", - "Undo (⌘Z)" => "Zpět (⌘Z)", + "Undo" => "Zpět", "Undo list" => "Seznam akcí k vrácení", "Unhide Sheet…" => "Zobrazit list…", "Up" => "Nahoru", diff --git a/crates/ui-egui/src/i18n/de.rs b/crates/ui-egui/src/i18n/de.rs index 371ea7981..029082c7a 100644 --- a/crates/ui-egui/src/i18n/de.rs +++ b/crates/ui-egui/src/i18n/de.rs @@ -10,12 +10,9 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Transpose" => "Transponieren", "Paste Link" => "Verknüpfung einfügen", "Paste Special…" => "Inhalte einfügen…", - "Cut (⌘X)" => "Ausschneiden (⌘X)", - "Copy (⌘C)" => "Kopieren (⌘C)", "Format Painter (double-click to keep it on)" => "Format übertragen (Doppelklick zum Beibehalten)", - "Bold (⌘B)" => "Fett (⌘B)", - "Italic (⌘I)" => "Kursiv (⌘I)", - "Underline (⌘U)" => "Unterstrichen (⌘U)", + "Bold" => "Fett", + "Italic" => "Kursiv", "Double Underline" => "Doppelt unterstrichen", "Strikethrough" => "Durchgestrichen", "Underline" => "Unterstrichen", @@ -83,7 +80,6 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Clear Comments and Notes" => "Kommentare und Notizen löschen", "Clear Hyperlinks" => "Hyperlinks löschen", "AutoSum" => "AutoSumme", - "AutoSum (⌘⇧T)" => "AutoSumme (⌘⇧T)", "Flash\nFill" => "Blitz-\nvorschau", "Flash Fill" => "Blitzvorschau", "Fill" => "Ausfüllen", diff --git a/crates/ui-egui/src/i18n/es.rs b/crates/ui-egui/src/i18n/es.rs index 4ce231bca..120ee8e73 100644 --- a/crates/ui-egui/src/i18n/es.rs +++ b/crates/ui-egui/src/i18n/es.rs @@ -11,12 +11,11 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Transpose" => "Transponer", "Paste Link" => "Pegar vínculo", "Paste Special…" => "Pegado especial…", - "Cut (⌘X)" => "Cortar (⌘X)", - "Copy (⌘C)" => "Copiar (⌘C)", + "Cut" => "Cortar", + "Copy" => "Copiar", "Format Painter (double-click to keep it on)" => "Copiar formato (doble clic para mantenerlo activo)", - "Bold (⌘B)" => "Negrita (⌘B)", - "Italic (⌘I)" => "Cursiva (⌘I)", - "Underline (⌘U)" => "Subrayado (⌘U)", + "Bold" => "Negrita", + "Italic" => "Cursiva", "Double Underline" => "Subrayado doble", "Strikethrough" => "Tachado", "Underline" => "Subrayado", @@ -84,7 +83,6 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Clear Comments and Notes" => "Borrar comentarios y notas", "Clear Hyperlinks" => "Borrar hipervínculos", "AutoSum" => "Autosuma", - "AutoSum (⌘⇧T)" => "Autosuma (⌘⇧T)", "Flash\nFill" => "Relleno\nrápido", "Flash Fill" => "Relleno rápido", "Fill" => "Rellenar", diff --git a/crates/ui-egui/src/i18n/fr.rs b/crates/ui-egui/src/i18n/fr.rs index 82027e847..d7dd55b8d 100644 --- a/crates/ui-egui/src/i18n/fr.rs +++ b/crates/ui-egui/src/i18n/fr.rs @@ -15,12 +15,11 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Insert Copied Cells…" => "Insérer les cellules copiées…", "Insert Cut Cells" => "Insérer les cellules coupées", "Insert Paste" => "Insérer en collant", - "Cut (⌘X)" => "Couper (⌘X)", - "Copy (⌘C)" => "Copier (⌘C)", + "Cut" => "Couper", + "Copy" => "Copier", "Format Painter (double-click to keep it on)" => "Reproduire la mise en forme (double-clic pour la conserver)", - "Bold (⌘B)" => "Gras (⌘B)", - "Italic (⌘I)" => "Italique (⌘I)", - "Underline (⌘U)" => "Souligné (⌘U)", + "Bold" => "Gras", + "Italic" => "Italique", "Double Underline" => "Double soulignement", "Strikethrough" => "Barré", "Underline" => "Souligné", @@ -89,7 +88,6 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Clear Comments and Notes" => "Effacer les commentaires et les notes", "Clear Hyperlinks" => "Effacer les liens hypertexte", "AutoSum" => "Somme automatique", - "AutoSum (⌘⇧T)" => "Somme automatique (⌘⇧T)", "Flash\nFill" => "Remplissage\ninstantané", "Flash Fill" => "Remplissage instantané", "Fill" => "Remplir", diff --git a/crates/ui-egui/src/i18n/ko.rs b/crates/ui-egui/src/i18n/ko.rs index 1902ad301..2e877e154 100644 --- a/crates/ui-egui/src/i18n/ko.rs +++ b/crates/ui-egui/src/i18n/ko.rs @@ -13,12 +13,11 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Insert Copied Cells…" => "복사한 셀 삽입…", "Insert Cut Cells" => "잘라낸 셀 삽입", "Insert Paste" => "삽입 붙여넣기", - "Cut (⌘X)" => "잘라내기 (⌘X)", - "Copy (⌘C)" => "복사 (⌘C)", + "Cut" => "잘라내기", + "Copy" => "복사", "Format Painter (double-click to keep it on)" => "서식 복사 (두 번 클릭하여 유지)", - "Bold (⌘B)" => "굵게 (⌘B)", - "Italic (⌘I)" => "기울임꼴 (⌘I)", - "Underline (⌘U)" => "밑줄 (⌘U)", + "Bold" => "굵게", + "Italic" => "기울임꼴", "Double Underline" => "이중 밑줄", "Strikethrough" => "취소선", "Underline" => "밑줄", @@ -86,7 +85,6 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Clear Comments and Notes" => "댓글 및 메모 지우기", "Clear Hyperlinks" => "하이퍼링크 지우기", "AutoSum" => "자동 합계", - "AutoSum (⌘⇧T)" => "자동 합계 (⌘⇧T)", "Flash\nFill" => "빠른\n채우기", "Flash Fill" => "빠른 채우기", "Fill" => "채우기", diff --git a/crates/ui-egui/src/i18n/pt.rs b/crates/ui-egui/src/i18n/pt.rs index fb9236cdb..e63591d53 100644 --- a/crates/ui-egui/src/i18n/pt.rs +++ b/crates/ui-egui/src/i18n/pt.rs @@ -14,12 +14,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Insert Copied Cells…" => "Inserir Células Copiadas…", "Insert Cut Cells" => "Inserir Células Recortadas", "Insert Paste" => "Inserir Colagem", - "Cut (⌘X)" => "Recortar (⌘X)", - "Copy (⌘C)" => "Copiar (⌘C)", "Format Painter (double-click to keep it on)" => "Pincel de Formatação (clique duas vezes para mantê-lo ativo)", - "Bold (⌘B)" => "Negrito (⌘B)", - "Italic (⌘I)" => "Itálico (⌘I)", - "Underline (⌘U)" => "Sublinhado (⌘U)", "Double Underline" => "Sublinhado Duplo", "Strikethrough" => "Riscado", "Underline" => "Sublinhado", @@ -87,7 +82,6 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Clear Comments and Notes" => "Limpar Comentários e Notas", "Clear Hyperlinks" => "Limpar Hiperlinks", "AutoSum" => "Soma Automática", - "AutoSum (⌘⇧T)" => "Soma Automática (⌘⇧T)", "Flash\nFill" => "Preenchimento\nRelâmpago", "Flash Fill" => "Preenchimento Relâmpago", "Fill" => "Preencher", @@ -874,10 +868,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Erase ink strokes" => "Apagar traços de tinta", "Every command run in this session (replayable)" => "Todos os comandos executados nesta sessão (podem ser repetidos)", "Expand formula bar" => "Expandir barra de fórmulas", - "Filter (⌘⇧F)" => "Filtro (⌘⇧F)", "Filter by Selected Cell's Value" => "Filtrar pelo Valor da Célula Selecionada", "Find & Select" => "Localizar e Selecionar", - "Flash Fill (⌘E)" => "Preenchimento Relâmpago (⌘E)", "Forecast Sheet" => "Planilha de Previsão", "Forecast end (date or number; blank: a third of the history):" => "Fim da previsão (data ou número; em branco: um terço do histórico):", "Format Chart pane" => "Painel Formatar Gráfico", @@ -890,7 +882,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Import a text or CSV file" => "Importar um arquivo de texto ou CSV", "Insert Function" => "Inserir Função", "Insert Function (⇧F3)" => "Inserir Função (⇧F3)", - "Insert Link (⌘K)" => "Inserir Link (⌘K)", + "Insert Link" => "Inserir Link", "Insert Sheet" => "Inserir Planilha", "Insert Slicer" => "Inserir Segmentação de Dados", "Insert a picture" => "Inserir uma imagem", @@ -907,18 +899,16 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Next sheet" => "Próxima planilha", "Page Breaks" => "Quebras", "Paper Size" => "Tamanho", - "Paste (⌘V)" => "Colar (⌘V)", "Pick From Drop-down List…" => "Escolher na Lista Suspensa…", "Pie or Doughnut" => "Pizza ou Rosca", "Previous sheet" => "Planilha anterior", "Print Area" => "Área de Impressão", "Print Titles" => "Imprimir Títulos", "Protect Workbook structure" => "Proteger estrutura da pasta de trabalho", - "Redo (⌘Y)" => "Refazer (⌘Y)", - "Save (⌘S)" => "Salvar (⌘S)", + "Redo" => "Refazer", "Scatter or Bubble" => "Dispersão ou Bolhas", "Search and run any command" => "Pesquisar e executar qualquer comando", - "Search commands (⌘⇧Space)" => "Pesquisar comandos (⌘⇧Space)", + "Search commands" => "Pesquisar comandos", "Select and move ink and shapes" => "Selecionar e mover tinta e formas", "Series…" => "Série…", "Share: export or save a copy" => "Compartilhar: exportar ou salvar uma cópia", @@ -928,12 +918,11 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Spelling (F7)" => "Ortografia (F7)", "Statistic" => "Estatístico", "Switch Row/Column" => "Alternar Linha/Coluna", - "Table (⌘T)" => "Tabela (⌘T)", "Table Styles" => "Estilos de Tabela", "Text to Columns" => "Texto para Colunas", "The output range already holds data. Replace it with the report?" => "O intervalo de saída já contém dados. Substituí-los pelo relatório?", "Top Align" => "Alinhar em Cima", - "Undo (⌘Z)" => "Desfazer (⌘Z)", + "Undo" => "Desfazer", "Undo list" => "Lista de desfazer", "Unhide Sheet…" => "Reexibir Planilha…", "Up" => "Para Cima", diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index 765f6bb32..09e38e916 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -13,12 +13,11 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Insert Copied Cells…" => "Вставить скопированные ячейки…", "Insert Cut Cells" => "Вставить вырезанные ячейки", "Insert Paste" => "Вставка со сдвигом", - "Cut (⌘X)" => "Вырезать (⌘X)", - "Copy (⌘C)" => "Копировать (⌘C)", + "Cut" => "Вырезать", + "Copy" => "Копировать", "Format Painter (double-click to keep it on)" => "Формат по образцу (дважды щёлкните, чтобы закрепить)", - "Bold (⌘B)" => "Полужирный (⌘B)", - "Italic (⌘I)" => "Курсив (⌘I)", - "Underline (⌘U)" => "Подчёркивание (⌘U)", + "Bold" => "Полужирный", + "Italic" => "Курсив", "Double Underline" => "Двойное подчёркивание", "Strikethrough" => "Зачёркивание", "Underline" => "Подчёркивание", @@ -86,7 +85,6 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Clear Comments and Notes" => "Удалить комментарии и заметки", "Clear Hyperlinks" => "Удалить гиперссылки", "AutoSum" => "Автосумма", - "AutoSum (⌘⇧T)" => "Автосумма (⌘⇧T)", "Flash\nFill" => "Мгновенное\nзаполнение", "Flash Fill" => "Мгновенное заполнение", "Fill" => "Заполнить", diff --git a/crates/ui-egui/src/i18n/sr.rs b/crates/ui-egui/src/i18n/sr.rs index 85bd2da72..f267691e3 100644 --- a/crates/ui-egui/src/i18n/sr.rs +++ b/crates/ui-egui/src/i18n/sr.rs @@ -13,12 +13,11 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Insert Copied Cells…" => "Umetni kopirane ćelije…", "Insert Cut Cells" => "Umetni isečene ćelije", "Insert Paste" => "Umetni lepljenje", - "Cut (⌘X)" => "Iseci (⌘X)", - "Copy (⌘C)" => "Kopiraj (⌘C)", + "Cut" => "Iseci", + "Copy" => "Kopiraj", "Format Painter (double-click to keep it on)" => "Kopiraj oblikovanje (dupli klik da ostane uključeno)", - "Bold (⌘B)" => "Podebljano (⌘B)", - "Italic (⌘I)" => "Kurziv (⌘I)", - "Underline (⌘U)" => "Podvučeno (⌘U)", + "Bold" => "Podebljano", + "Italic" => "Kurziv", "Double Underline" => "Dvostruko podvlačenje", "Strikethrough" => "Precrtano", "Underline" => "Podvučeno", @@ -87,7 +86,6 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Clear Comments and Notes" => "Obriši komentare i beleške", "Clear Hyperlinks" => "Obriši hiperveze", "AutoSum" => "Automatski zbir", - "AutoSum (⌘⇧T)" => "Automatski zbir (⌘⇧T)", "Flash\nFill" => "Brzo\npopunjavanje", "Flash Fill" => "Brzo popunjavanje", "Fill" => "Popuni", diff --git a/crates/ui-egui/src/i18n/tr.rs b/crates/ui-egui/src/i18n/tr.rs new file mode 100644 index 000000000..becbc3f5f --- /dev/null +++ b/crates/ui-egui/src/i18n/tr.rs @@ -0,0 +1,414 @@ +//! Turkish translations for the interface strings in i18n.rs. Labels follow the wording Turkish +//! spreadsheet users know; anything absent falls back to English. + +pub(super) fn translate(text: &str) -> Option<&'static str> { + Some(match text { + "Paste" => "Yapıştır", + "Paste Values" => "Değerleri Yapıştır", + "Paste Formulas" => "Formülleri Yapıştır", + "Paste Formatting" => "Biçimlendirmeyi Yapıştır", + "Transpose" => "Devrik Dönüştür", + "Paste Link" => "Bağlantı Yapıştır", + "Paste Special…" => "Özel Yapıştır…", + "Cut (⌘X)" => "Kes (⌘X)", + "Copy (⌘C)" => "Kopyala (⌘C)", + "Format Painter (double-click to keep it on)" => "Biçim Boyacısı (açık tutmak için çift tıklayın)", + "Bold (⌘B)" => "Kalın (⌘B)", + "Italic (⌘I)" => "İtalik (⌘I)", + "Underline (⌘U)" => "Altı Çizili (⌘U)", + "Double Underline" => "Çift Alt Çizgi", + "Strikethrough" => "Üstü Çizili", + "Underline" => "Altı Çizili", + "Increase Font Size" => "Yazı Tipi Boyutunu Büyüt", + "Decrease Font Size" => "Yazı Tipi Boyutunu Küçült", + "Wrap Text" => "Metni Kaydır", + "Merge & Center" => "Birleştir ve Ortala", + "Merge Cells" => "Hücreleri Birleştir", + "Merge Across" => "Yatay Birleştir", + "Unmerge Cells" => "Hücreleri Çöz", + "Fill Color" => "Dolgu Rengi", + "Font Color" => "Yazı Tipi Rengi", + "Borders" => "Kenarlıklar", + "Top\nAlign" => "Üste\nHizala", + "Middle\nAlign" => "Ortaya\nHizala", + "Bottom\nAlign" => "Alta\nHizala", + "Left" => "Sol", + "Right" => "Sağ", + "Center" => "Orta", + "Horizontal" => "Yatay", + "Vertical Text" => "Dikey Metin", + "Decrease Indent" => "Girintiyi Azalt", + "Increase Indent" => "Girintiyi Artır", + "Orientation" => "Yönlendirme", + "Angle Counterclockwise" => "Saat Yönünün Tersine Açılı", + "Angle Clockwise" => "Saat Yönünde Açılı", + "Rotate Text Up" => "Metni Yukarı Döndür", + "Rotate Text Down" => "Metni Aşağı Döndür", + "Accounting Number Format" => "Muhasebe Sayı Biçimi", + "Percent Style" => "Yüzde Stili", + "Comma Style" => "Virgül Stili", + "Increase Decimal" => "Ondalık Artır", + "Decrease Decimal" => "Ondalık Azalt", + "More Accounting Formats…" => "Diğer Muhasebe Biçimleri…", + "Conditional\nFormatting" => "Koşullu\nBiçimlendirme", + "Conditional Formatting" => "Koşullu Biçimlendirme", + "Format\nas Table" => "Tablo Olarak\nBiçimlendir", + "Cell\nStyles" => "Hücre\nStilleri", + "Format Cells…" => "Hücreleri Biçimlendir…", + "Format Cell Alignment…" => "Hücre Hizalamayı Biçimlendir…", + "Format\nPane" => "Biçim\nBölmesi", + "Format" => "Biçim", + "Insert" => "Ekle", + "Insert Cells…" => "Hücre Ekle…", + "Delete" => "Sil", + "Delete Cells…" => "Hücreleri Sil…", + "Insert Sheet Rows" => "Sayfa Satırları Ekle", + "Insert Sheet Columns" => "Sayfa Sütunları Ekle", + "Delete Sheet Rows" => "Sayfa Satırlarını Sil", + "Delete Sheet Columns" => "Sayfa Sütunlarını Sil", + "Row Height…" => "Satır Yüksekliği…", + "AutoFit Row Height" => "Satır Yüksekliğini En Uygun Yap", + "Column Width…" => "Sütun Genişliği…", + "AutoFit Column Width" => "Sütun Genişliğini En Uygun Yap", + "Default Width…" => "Varsayılan Genişlik…", + "Hide Rows" => "Satırları Gizle", + "Unhide Rows" => "Satırları Göster", + "Hide Columns" => "Sütunları Gizle", + "Unhide Columns" => "Sütunları Göster", + "Lock Cell" => "Hücreyi Kilitle", + "Clear" => "Temizle", + "Clear All" => "Tümünü Temizle", + "Clear Formats" => "Biçimleri Temizle", + "Clear Contents" => "İçeriği Temizle", + "Clear Comments and Notes" => "Açıklamaları ve Notları Temizle", + "Clear Hyperlinks" => "Köprüleri Temizle", + "AutoSum" => "Otomatik Toplam", + "AutoSum (⌘⇧T)" => "Otomatik Toplam (⌘⇧T)", + "Flash\nFill" => "Hızlı\nDoldurma", + "Flash Fill" => "Hızlı Doldurma", + "Fill" => "Doldur", + "Clear Print Area" => "Yazdırma Alanını Temizle", + "Find &\nSelect" => "Bul ve\nSeç", + "Find…" => "Bul…", + "Replace…" => "Değiştir…", + "Go To…" => "Git…", + "Go To Special…" => "Özel Git…", + "PivotTable" => "Özet Tablo", + "Recommended\nCharts" => "Önerilen\nGrafikler", + "Table" => "Tablo", + "Pictures" => "Resimler", + "Shapes" => "Şekiller", + "Icons" => "Simgeler", + "Text\nBox" => "Metin\nKutusu", + "Comment" => "Açıklama", + "New\nComment" => "Yeni\nAçıklama", + "New Note" => "Yeni Not", + "Show/Hide Note" => "Notu Göster/Gizle", + "Header &\nFooter" => "Üstbilgi ve\nAltbilgi", + "Text to\nColumns" => "Metni Sütunlara\nDönüştür", + "Link" => "Köprü", + "Symbol" => "Simge", + "Sparklines" => "Mini Grafikler", + "Header & Footer" => "Üstbilgi ve Altbilgi", + "Draw with ink" => "Mürekkeple Çiz", + "Eraser" => "Silgi", + "Ink to\nShape" => "Mürekkebi\nŞekle Dönüştür", + "Select\nObjects" => "Nesneleri\nSeç", + "Themes" => "Temalar", + "Margins" => "Kenar Boşlukları", + "Left Margin" => "Sol Kenar Boşluğu", + "Right Margin" => "Sağ Kenar Boşluğu", + "Top Margin" => "Üst Kenar Boşluğu", + "Bottom Margin" => "Alt Kenar Boşluğu", + "Header Margin" => "Üstbilgi Kenar Boşluğu", + "Footer Margin" => "Altbilgi Kenar Boşluğu", + "Size" => "Boyut", + "Print\nArea" => "Yazdırma\nAlanı", + "Set Print Area" => "Yazdırma Alanını Belirle", + "Breaks" => "Kesmeler", + "Insert Page Break" => "Sayfa Sonu Ekle", + "Remove Page Break" => "Sayfa Sonunu Kaldır", + "Reset All Page Breaks" => "Tüm Sayfa Sonlarını Sıfırla", + "Print\nTitles" => "Başlıkları\nYazdır", + "Narrow" => "Dar", + "Wide" => "Geniş", + "Normal" => "Normal", + "Portrait" => "Dikey", + "Landscape" => "Yatay", + "Custom Margins…" => "Özel Kenar Boşlukları…", + "Insert\nFunction" => "İşlev\nEkle", + "Name\nManager" => "Ad\nYöneticisi", + "Define Name" => "Ad Tanımla", + "Use in Formula" => "Formülde Kullan", + "Create from Selection" => "Seçimden Oluştur", + "Trace Precedents" => "Etkileyenleri İzle", + "Trace Dependents" => "Etkilenenleri İzle", + "Remove Arrows" => "Okları Kaldır", + "Show Formulas" => "Formülleri Göster", + "Error Checking" => "Hata Denetimi", + "Evaluate Formula" => "Formülü Değerlendir", + "Watch Window" => "Gözcü Penceresi", + "Calculation\nOptions" => "Hesaplama\nSeçenekleri", + "Calculate Now" => "Şimdi Hesapla", + "Calculate Sheet" => "Sayfayı Hesapla", + "Automatic" => "Otomatik", + "Automatic Except for Data Tables" => "Veri Tabloları Dışında Otomatik", + "Manual" => "El ile", + "Sort &\nFilter" => "Sırala ve\nFiltre Uygula", + "Sort" => "Sırala", + "Sort A to Z" => "A'dan Z'ye Sırala", + "Sort Z to A" => "Z'den A'ya Sırala", + "Custom Sort…" => "Özel Sıralama…", + "Filter" => "Filtre", + "Reapply" => "Yeniden Uygula", + "Get Data\n(Text/CSV)" => "Veri Al\n(Metin/CSV)", + "Remove\nDuplicates" => "Yinelenenleri\nKaldır", + "Data\nValidation" => "Veri\nDoğrulama", + "Data Validation" => "Veri Doğrulama", + "Data Validation…" => "Veri Doğrulama…", + "Circle Invalid Data" => "Geçersiz Verileri Daire İçine Al", + "Clear Validation" => "Doğrulamayı Temizle", + "Group" => "Gruplandır", + "Ungroup" => "Grubu Çöz", + "Show Detail" => "Ayrıntıyı Göster", + "Hide Detail" => "Ayrıntıyı Gizle", + "Subtotal" => "Alt Toplam", + "What-If\nAnalysis" => "Durum\nÇözümlemesi", + "Data Table…" => "Veri Tablosu…", + "Scenario Manager…" => "Senaryo Yöneticisi…", + "Goal Seek…" => "Hedef Ara…", + "Spelling" => "Yazım Denetimi", + "Check\nAccessibility" => "Erişilebilirliği\nDenetle", + "Threaded Comments" => "Yanıtlanan Açıklamalar", + "Show\nComments" => "Açıklamaları\nGöster", + "Protect\nWorkbook" => "Çalışma Kitabını\nKoru", + "Unprotect\nSheet" => "Sayfa Korumasını\nKaldır", + "Protect Sheet…" => "Sayfayı Koru…", + "Comments" => "Açıklamalar", + "Notes" => "Notlar", + "Page Break\nPreview" => "Sayfa Sonu\nÖnizleme", + "Page\nLayout" => "Sayfa\nDüzeni", + "Formula Bar" => "Formül Çubuğu", + "Gridlines" => "Kılavuz Çizgileri", + "Headings" => "Başlıklar", + "Zoom" => "Yakınlaştır", + "Forecast\nSheet" => "Tahmin\nSayfası", + "Add footer" => "Altbilgi ekle", + "Add header" => "Üstbilgi ekle", + "Click to add data" => "Veri eklemek için tıklayın", + "Current Date" => "Geçerli Tarih", + "Current\nDate" => "Geçerli\nTarih", + "Current\nTime" => "Geçerli\nSaat", + "Current Time" => "Geçerli Saat", + "File Name" => "Dosya Adı", + "File\nName" => "Dosya\nAdı", + "File\nPath" => "Dosya\nYolu", + "File Path" => "Dosya Yolu", + "Footer" => "Altbilgi", + "Different First Page" => "İlk Sayfada Farklı", + "Different Odd & Even Pages" => "Tek ve Çift Sayfalarda Farklı", + "Go to Footer" => "Altbilgiye Git", + "Go to Header" => "Üstbilgiye Git", + "Go to\nFooter" => "Altbilgiye\nGit", + "Go to\nHeader" => "Üstbilgiye\nGit", + "Header" => "Üstbilgi", + "(none)" => "(yok)", + "Number\nof Pages" => "Sayfa\nSayısı", + "Number of Pages" => "Sayfa Sayısı", + "Page\nNumber" => "Sayfa\nNumarası", + "Page Number" => "Sayfa Numarası", + "Ruler" => "Cetvel", + "Sheet Name" => "Sayfa Adı", + "Sheet\nName" => "Sayfa\nAdı", + "Animations" => "Animasyonlar", + "Glide the selection outline to the cell you move to instead of jumping there" => { + "Seçim çerçevesini atlamak yerine taşındığınız hücreye kaydırarak hareket ettir" + } + "Insert Copied Cells" => "Kopyalanan Hücreleri Ekle", + "Insert Copied Cells…" => "Kopyalanan Hücreleri Ekle…", + "Insert Cut Cells" => "Kesilen Hücreleri Ekle", + "Insert Paste" => "Ekleyerek Yapıştır", + "Previous" => "Önceki", + "Next" => "Sonraki", + "Previous Comment" => "Önceki Açıklama", + "Next Comment" => "Sonraki Açıklama", + "Show All Notes" => "Tüm Notları Göster", + "Convert to Comments" => "Açıklamalara Dönüştür", + "Checkbox" => "Onay Kutusu", + "Insert checkboxes in the selected cells" => "Seçili hücrelere onay kutuları ekle", + "Sheet\nRight-to-Left" => "Sayfa\nSağdan Sola", + "Sheet Right-to-Left" => "Sayfa Sağdan Sola", + "Data\nAnalysis" => "Veri\nAnalizi", + "Data Analysis" => "Veri Analizi", + "Solver" => "Çözücü", + "Allow Edit\nRanges" => "Aralıkları Düzenlemeye\nİzin Ver", + "Allow Edit Ranges" => "Aralıkları Düzenlemeye İzin Ver", + "Drag to freeze columns" => "Sütunları dondurmak için sürükleyin", + "Drag to freeze rows" => "Satırları dondurmak için sürükleyin", + "Select\nData" => "Veri\nSeç", + "File" => "Dosya", + "Back" => "Geri", + "New" => "Yeni", + "Open" => "Aç", + "Info" => "Bilgi", + "Save" => "Kaydet", + "Save As" => "Farklı Kaydet", + "Export" => "Dışarı Aktar", + "Close" => "Kapat", + "Options" => "Seçenekler", + "Blank workbook" => "Boş çalışma kitabı", + "Recent" => "Son Kullanılanlar", + "No recent workbooks yet." => "Henüz son kullanılan çalışma kitabı yok.", + "More workbooks" => "Diğer çalışma kitapları", + "Modified" => "Değiştirilme Tarihi", + "Not available" => "Kullanılamıyor", + "Just now" => "Az önce", + "Yesterday" => "Dün", + "{n} min ago" => "{n} dk. önce", + "{n} h ago" => "{n} sa. önce", + "{n} days ago" => "{n} gün önce", + "{n} weeks ago" => "{n} hafta önce", + "{n} months ago" => "{n} ay önce", + "{n} years ago" => "{n} yıl önce", + "Browse…" => "Gözat…", + "Not saved yet" => "Henüz kaydedilmedi", + "Location" => "Konum", + "Sheets" => "Sayfalar", + "Protect Workbook" => "Çalışma Kitabını Koru", + "Sheet structure is locked: sheets can't be added, moved, renamed or deleted." => { + "Sayfa yapısı kilitli: sayfalar eklenemez, taşınamaz, yeniden adlandırılamaz veya silinemez." + } + "Lock the sheet structure so sheets can't be added, moved, renamed or deleted." => { + "Sayfaların eklenmesini, taşınmasını, yeniden adlandırılmasını veya silinmesini önlemek için sayfa yapısını kilitleyin." + } + "Properties" => "Özellikler", + "Title" => "Başlık", + "Subject" => "Konu", + "Author" => "Yazar", + "Company" => "Şirket", + "Keywords" => "Anahtar Sözcükler", + "Description" => "Açıklama", + "No workbook is open." => "Açık çalışma kitabı yok.", + "Save as type" => "Kayıt türü", + "Excel Workbook (*.xlsx)" => "Excel Çalışma Kitabı (*.xlsx)", + "CSV UTF-8 (Comma delimited) (*.csv)" => "CSV UTF-8 (Virgülle ayrılmış) (*.csv)", + "Web Page (*.html)" => "Web Sayfası (*.html)", + "PDF (*.pdf)" => "PDF Belgesi (*.pdf)", + "Download" => "İndir", + "A CSV file, web page or PDF is a copy: the workbook keeps its own file." => { + "CSV dosyası, web sayfası veya PDF bir kopyadır: çalışma kitabı kendi dosyasını korur." + } + "Save the workbook under another name or in another folder." => "Çalışma kitabını başka bir adla veya başka bir klasöre kaydedin.", + "Print Active Sheets" => "Etkin Sayfaları Yazdır", + "Print Entire Workbook" => "Tüm Çalışma Kitabını Yazdır", + "Fit Sheet on One Page" => "Sayfayı Bir Sayfaya Sığdır", + "Page Setup…" => "Sayfa Yapısı…", + "Page {page} of {pages}" => "Sayfa {page} / {pages}", + "Nothing to print." => "Yazdırılacak bir şey yok.", + "Previous page" => "Önceki sayfa", + "Next page" => "Sonraki sayfa", + "Create PDF" => "PDF Oluştur", + "Save the sheet as a PDF document that keeps its page layout." => "Sayfayı, sayfa düzenini koruyan bir PDF belgesi olarak kaydedin.", + "Change File Type" => "Dosya Türünü Değiştir", + "CSV (Comma delimited)" => "CSV (Virgülle ayrılmış)", + "Save the active sheet as comma-separated text." => "Etkin sayfayı virgülle ayrılmış metin olarak kaydedin.", + "Web Page (.html)" => "Web Sayfası (.html)", + "Save the workbook as a web page." => "Çalışma kitabını web sayfası olarak kaydedin.", + "Options|General" => "Genel", + "Options|Save" => "Kaydet", + "Show formula bar" => "Formül çubuğunu göster", + "Enable AutoComplete for cell values" => "Hücre değerleri için Otomatik Tamamla'yı etkinleştir", + "Save .xlsx workbooks automatically after every change" => ".xlsx çalışma kitaplarını her değişiklikten sonra otomatik olarak kaydet", + "Workbook calculation" => "Çalışma kitabı hesaplaması", + "Enable iterative calculation" => "Yinelemeli hesaplamayı etkinleştir", + "Calculation settings are saved with the workbook." => "Hesaplama ayarları çalışma kitabıyla birlikte kaydedilir.", + "Interface size" => "Arayüz boyutu", + "Auto" => "Otomatik", + "100%" => "%100", + "Zoom to\nSelection" => "Seçimi\nYakınlaştır", + "Freeze\nPanes" => "Bölmeleri\nDondur", + "Freeze Panes" => "Bölmeleri Dondur", + "Freeze Top Row" => "Üst Satırı Dondur", + "Freeze First Column" => "İlk Sütunu Dondur", + "Unfreeze Panes" => "Bölmeleri Çöz", + "Dark Mode" => "Koyu Mod", + "Interface language" => "Arayüz dili", + "Command\nPalette" => "Komut\nPaleti", + "Agent\nControl" => "Ajan\nKontrolü", + "Action\nJournal" => "İşlem\nGünlüğü", + "About\nGridCraft" => "GridCraft\nHakkında", + "Share" => "Paylaş", + "Add Chart\nElement" => "Grafik Öğesi\nEkle", + "Change\nChart Type" => "Grafik Türünü\nDeğiştir", + "Switch\nRow/Column" => "Satır/Sütun\nDeğiştir", + "Data Labels: None" => "Veri Etiketleri: Yok", + "Data Labels: Show" => "Veri Etiketleri: Göster", + "Legend: None" => "Gösterge: Yok", + "Legend: Bottom" => "Gösterge: Alt", + "Legend: Top" => "Gösterge: Üst", + "Legend: Right" => "Gösterge: Sağ", + "Gridlines: None" => "Kılavuz Çizgileri: Yok", + "Gridlines: Show" => "Kılavuz Çizgileri: Göster", + "Chart\nTitle" => "Grafik\nBaşlığı", + "Delete\nChart" => "Grafiği\nSil", + "Convert\nto Range" => "Aralığa\nDönüştür", + "Table\nStyles" => "Tablo\nStilleri", + "Top 10 Items" => "İlk 10 Öğe", + "Top 10%" => "İlk %10", + "Bottom 10 Items" => "Son 10 Öğe", + "Bottom 10%" => "Son %10", + "Above Average" => "Ortalamanın Üstünde", + "Below Average" => "Ortalamanın Altında", + "Clear Rules from Entire Sheet" => "Kuralları Tüm Sayfadan Temizle", + "Clear Rules from Selected Cells" => "Kuralları Seçili Hücrelerden Temizle", + "Constants" => "Sabitler", + "Data Bars" => "Veri Çubukları", + "Color Scales" => "Renk Ölçekleri", + "Icon Sets" => "Simge Kümeleri", + "Home" => "Giriş", + "Draw" => "Çiz", + "Page Layout" => "Sayfa Düzeni", + "Formulas" => "Formüller", + "Data" => "Veri", + "Review" => "Gözden Geçir", + "View" => "Görünüm", + "Automate" => "Otomatikleştir", + "Table Design" => "Tablo Tasarımı", + "Chart Design" => "Grafik Tasarımı", + "Ready" => "Hazır", + "Enter" => "Giriş", + "Edit" => "Düzenle", + "Point" => "İşaretle", + "Calculate" => "Hesapla", + "Select destination and press Enter or choose Paste" => "Hedefi seçip Enter tuşuna basın veya Yapıştır'ı seçin", + "General" => "Genel", + "Number" => "Sayı", + "Currency" => "Para Birimi", + "Accounting" => "Muhasebe", + "Date" => "Tarih", + "Time" => "Saat", + "Percentage" => "Yüzde", + "Fraction" => "Kesir", + "Scientific" => "Bilimsel", + "Text" => "Metin", + "Special" => "Özel", + "Custom" => "İsteğe Uyarlanmış", + "Short Date" => "Kısa Tarih", + "Long Date" => "Uzun Tarih", + "More Number Formats…" => "Diğer Sayı Biçimleri…", + "Financial" => "Finansal", + "Logical" => "Mantıksal", + "Date &\nTime" => "Tarih ve\nSaat", + "Lookup &\nReference" => "Arama ve\nBaşvuru", + "Math &\nTrig" => "Matematik ve\nTrigonometri", + "More\nFunctions" => "Diğer\nİşlevler", + "Page Layout|Orientation" => "Sayfa Yönü", + "Display theme" => "Görüntü teması", + "System" => "Sistem", + "Light" => "Açık", + "Dark" => "Koyu", + "Sheet Options|View" => "Görünüm", + "Print" => "Yazdır", + _ => return None, + }) +} diff --git a/crates/ui-egui/src/i18n/zh.rs b/crates/ui-egui/src/i18n/zh.rs index 00175925e..09ad57400 100644 --- a/crates/ui-egui/src/i18n/zh.rs +++ b/crates/ui-egui/src/i18n/zh.rs @@ -13,12 +13,11 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Insert Copied Cells…" => "插入复制的单元格…", "Insert Cut Cells" => "插入剪切的单元格", "Insert Paste" => "插入粘贴", - "Cut (⌘X)" => "剪切 (⌘X)", - "Copy (⌘C)" => "复制 (⌘C)", + "Cut" => "剪切", + "Copy" => "复制", "Format Painter (double-click to keep it on)" => "格式刷(双击可持续使用)", - "Bold (⌘B)" => "粗体 (⌘B)", - "Italic (⌘I)" => "斜体 (⌘I)", - "Underline (⌘U)" => "下划线 (⌘U)", + "Bold" => "粗体", + "Italic" => "斜体", "Double Underline" => "双下划线", "Strikethrough" => "删除线", "Underline" => "下划线", @@ -86,7 +85,6 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Clear Comments and Notes" => "清除批注和备注", "Clear Hyperlinks" => "清除超链接", "AutoSum" => "自动求和", - "AutoSum (⌘⇧T)" => "自动求和 (⌘⇧T)", "Flash\nFill" => "快速\n填充", "Flash Fill" => "快速填充", "Fill" => "填充", diff --git a/crates/ui-egui/src/keytips/keytip_table/generated.rs b/crates/ui-egui/src/keytips/keytip_table/generated.rs index 79920b5b8..cde8066a4 100644 --- a/crates/ui-egui/src/keytips/keytip_table/generated.rs +++ b/crates/ui-egui/src/keytips/keytip_table/generated.rs @@ -6,6 +6,7 @@ use super::{Action, Entry}; /// whenever a pressed letter starts a deeper sequence, so only terminal entries are /// listed here. See keytip_table.rs for the helpers that read this table. pub const SEQUENCES: &[(&str, Entry)] = &[ + ("F", Entry::Tab("File")), // Excel: File tab (Backstage view); Opens the File view on Home; its letters below still run their commands. ("H", Entry::Tab("Home")), ("N", Entry::Tab("Insert")), ("JI", Entry::Tab("Draw")), // Two-letter unit. Deeper Draw letters differ between Excel and GridCraft; left blank for future contributors. diff --git a/crates/ui-egui/src/keytips/keytips.json b/crates/ui-egui/src/keytips/keytips.json index 59aeadcfb..729cf673b 100644 --- a/crates/ui-egui/src/keytips/keytips.json +++ b/crates/ui-egui/src/keytips/keytips.json @@ -11,6 +11,13 @@ " - Keep entries grouped by tab and sorted by sequence within a group; the FIRST entry for a tab is its canonical badge (legacy aliases come after it)." ], "sequences": [ + { + "seq": "F", + "kind": "tab", + "target": "File", + "excel": "File tab (Backstage view)", + "note": "Opens the File view on Home; its letters below still run their commands." + }, { "seq": "H", "kind": "tab", diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index f549aec2b..fa47483ad 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -284,7 +284,13 @@ pub struct SheetApp { /// text noted for an unpublished cut); `None` after a copy, or once the clipboard is seen /// holding other text. Once the cut has been pasted, Paste leaves this text alone. cut_text: Option, + /// A run of Tabs: (doc uid, sheet, column it started in, cell the last Tab landed on). + /// Enter on that cell goes to the next row in the starting column, as in Excel (#911). + tab_run: Option<(u64, usize, u32, CellRef)>, pub started: f64, + /// Every unsaved workbook has been answered for quitting (or `app.quit` asked): the next + /// window close goes through. + pub allow_close: bool, } impl SheetApp { @@ -328,7 +334,9 @@ impl SheetApp { selected_chart: None, clipboard_text_at_copy: None, cut_text: None, + tab_run: None, started: now_ms(), + allow_close: false, } } @@ -654,7 +662,9 @@ impl SheetApp { self.ui.language = l; Ok(json!({"language": l})) } - None => Err(format!("unknown language {code:?} (use \"en\", \"es\", \"zh\", \"ja\", \"ko\", \"ru\", \"pt\" or \"cs\")")), + None => Err(format!( + "unknown language {code:?} (use \"en\", \"es\", \"zh\", \"ja\", \"ko\", \"ru\", \"pt\", \"de\", \"cs\", \"fr\" or \"tr\")" + )), } } "app.language.english" => { @@ -955,6 +965,15 @@ impl SheetApp { } self.toast = Some((msg, now_ms())); } + // Point mode can leave another sheet active; the edit belongs to the sheet it started on. + if self.session.active().is_some_and(|d| d.wb.active_sheet != ed.sheet) + && let Err(e) = self.session.run("sheet.activate", json!({"sheet": ed.sheet})) + { + self.message = Some(("GridCraft".into(), clean_error(&e))); + self.editor = Some(ed); + self.session.mode = gridcraft_engine::Mode::Edit; + return false; + } // Committing nothing into an empty cell changes nothing: no undo step (#1855, Backspace has // already cleared the cell as its own step). let unchanged = !fill_selection @@ -989,6 +1008,15 @@ impl SheetApp { } } + /// Commits the edit with an arrow key. The arrow is an ordinary move, so it ends a run of Tabs + /// instead of continuing it (Right) or going back to its column like Enter (Down), as in Excel. + pub fn commit_edit_arrow(&mut self, dr: i64, dc: i64) -> bool { + let run = self.tab_run.take(); + let ok = self.commit_edit(dr, dc, false, false); + self.tab_run = if ok { None } else { run }; + ok + } + pub fn cancel_edit(&mut self) { self.editor = None; self.session.mode = gridcraft_engine::Mode::Ready; @@ -997,11 +1025,26 @@ impl SheetApp { /// Enter/Tab move inside a multi-cell selection, otherwise to the next cell. pub fn move_after_enter(&mut self, dr: i64, dc: i64) { let multi = self.session.active().is_some_and(|d| !d.selection.is_single_cell()); + // Any other move (arrows, clicks, another sheet) leaves the selection off the cell the + // last Tab landed on, which ends the run. + let here = self.session.active().map(|d| (d.uid, d.wb.active_sheet, d.selection.active)); + let run = self.tab_run.take().filter(|&(uid, sheet, _, at)| here == Some((uid, sheet, at))); if multi { let fwd = dr > 0 || dc > 0; let _ = self.session.run("selection.next", json!({"forward": fwd, "byRow": dc != 0})); + } else if let (Some((_, _, col, at)), 1, 0) = (run, dr, dc) { + let back = i64::from(col) - i64::from(at.col); + let _ = self.session.run("selection.move", json!({"dr": 1, "dc": back})); } else { let _ = self.session.run("selection.move", json!({"dr": dr, "dc": dc})); + if dr == 0 && dc != 0 { + let start = run.map(|r| r.2); + self.tab_run = self.session.active().and_then(|d| { + let (uid, sheet) = (d.uid, d.wb.active_sheet); + let col = start.or(here.map(|h| h.2.col))?; + Some((uid, sheet, col, d.selection.active)) + }); + } } self.grid.ensure_visible = true; } @@ -1026,6 +1069,45 @@ impl SheetApp { } } + /// Holds back a window close (the close button, Alt+F4, the OS quitting the app) while a + /// workbook has unsaved changes, and asks about them first. + fn guard_window_close(&mut self, ctx: &egui::Context) { + if self.allow_close || !ctx.input(|i| i.viewport().close_requested()) { + return; + } + // A cell still being edited is part of the workbook, as for File › Close. + if !self.commit_edit(0, 0, false, false) { + ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose); + return; + } + if !self.session.documents().iter().any(|d| d.is_dirty()) { + return; + } + ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose); + match self.dialog.as_mut() { + // Already asking about a workbook (File › Close): answering it now goes on to quit. + Some(d) if d.name() == "saveChanges" => { + d.values.insert("quit".into(), json!(true)); + } + _ => self.continue_quit(ctx), + } + } + + /// Asks about the next unsaved workbook before quitting, or closes the window once none is left. + pub(crate) fn continue_quit(&mut self, ctx: &egui::Context) { + match self.session.documents().iter().position(|d| d.is_dirty()) { + Some(i) => { + self.session.set_active(i); + let title = self.session.active().map(|d| d.display_title()).unwrap_or_default(); + self.open_dialog("saveChanges", json!({"title": title, "quit": true})); + } + None => { + self.allow_close = true; + ctx.send_viewport_cmd(egui::ViewportCommand::Close); + } + } + } + /// Per-frame logic (control channel, screenshots). Call before `ui`. pub fn logic(&mut self, ctx: &egui::Context) { // A keytip control request lives one frame: the ribbon consumes it during `ui`, and @@ -1094,6 +1176,7 @@ impl SheetApp { ctx.request_repaint(); } control::poll(self, ctx); + self.guard_window_close(ctx); // Files read asynchronously (web file picker, dropped files). let arrived: Vec<(String, Vec)> = self .services @@ -1145,7 +1228,8 @@ impl SheetApp { if !self.chrome.file_tab { self.backstage = None; } - if self.backstage.is_none() { + // Keytips opened the File view (Alt, F): its letters keep working there. + if self.backstage.is_none() || self.keytips.prefix().is_some() { self.ribbon_keys(&ctx); } if self.chrome.title_bar { @@ -1224,12 +1308,18 @@ impl SheetApp { && self.grid.renaming_tab.is_none() && (before.is_some() || !egui::Popup::is_any_open(ctx)); let actions = self.keytips.process(ctx, enabled); + // Backing out of F to the tab letters, or finishing an F sequence, closes the File view + // that Alt, F opened: the command runs on the sheet, as it did without the view. + if self.backstage.is_some() && before.is_some() && (self.keytips.prefix() == Some("") || !actions.is_empty()) { + self.backstage = None; + } if before.is_some() && before.as_deref() != self.keytips.prefix() && !ctx.input(|i| i.pointer.any_pressed()) { // Leave pointer cancellation to the popup so clicking a menu item still works. egui::Popup::close_all(ctx); } for action in actions { match action { + keytips::Action::Tab("File") => self.open_backstage(backstage::Page::Home), keytips::Action::Tab(tab) => { self.run_or_alert("ui.ribbonTab", json!({"tab": tab})); self.ui.ribbon_collapsed = false; diff --git a/crates/ui-egui/src/panes.rs b/crates/ui-egui/src/panes.rs index aaa9bec32..a8a7e35f4 100644 --- a/crates/ui-egui/src/panes.rs +++ b/crates/ui-egui/src/panes.rs @@ -241,7 +241,7 @@ fn format_chart(app: &mut SheetApp, ui: &mut Ui) { ui.label(&name); ui.label(egui::RichText::new(&s.values).small().color(Color32::from_gray(110))); ui.menu_button("Fill…", |ui| { - if let Some(c) = crate::widgets::color_palette(ui, &theme.colors, "Automatic") { + if let Some(c) = crate::widgets::color_palette_fixed(ui, &theme.colors, "Automatic") { app.run_or_alert( "chart.formatSelection", json!({"chart": id, "series": i, "color": if c == "none" { Json::Null } else { json!(c) }}), diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index a0ab5a339..8fc5fd426 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -68,7 +68,7 @@ pub fn title_bar(app: &mut SheetApp, ui: &mut Ui) { if __k.clicked() { app.open_dialog("start", json!({})); } - if icon_button(ui, Icon::Save, t.text_dim, "Save (⌘S)", vec2(26.0, ch)).clicked() + if icon_button(ui, Icon::Save, t.text_dim, "Save", vec2(26.0, ch)).clicked() // File operations must include the cell the user is still editing (as the File > Save menu item already does). && app.commit_edit(0, 0, false, false) { @@ -76,7 +76,7 @@ pub fn title_bar(app: &mut SheetApp, ui: &mut Ui) { } let can_undo = app.session.active().is_some_and(|d| !d.undo.is_empty()); let can_redo = app.session.active().is_some_and(|d| !d.redo.is_empty()); - if icon_button(ui, Icon::Undo, if can_undo { t.text_dim } else { t.text_disabled }, "Undo (⌘Z)", vec2(26.0, ch)).clicked() && can_undo { + if icon_button(ui, Icon::Undo, if can_undo { t.text_dim } else { t.text_disabled }, "Undo", vec2(26.0, ch)).clicked() && can_undo { app.run_or_alert("edit.undo", json!({})); } let undo_list = icon_button(ui, Icon::Chevron, t.text_dim, "Undo list", vec2(14.0, ch)); @@ -92,7 +92,7 @@ pub fn title_bar(app: &mut SheetApp, ui: &mut Ui) { } } }); - if icon_button(ui, Icon::Redo, if can_redo { t.text_dim } else { t.text_disabled }, "Redo (⌘Y)", vec2(26.0, ch)).clicked() && can_redo { + if icon_button(ui, Icon::Redo, if can_redo { t.text_dim } else { t.text_disabled }, "Redo", vec2(26.0, ch)).clicked() && can_redo { app.run_or_alert("edit.redo", json!({})); } let more = icon_button(ui, Icon::More, t.text_dim, "More commands", vec2(26.0, ch)); @@ -124,7 +124,7 @@ pub fn title_bar(app: &mut SheetApp, ui: &mut Ui) { ); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { ui.add_space(8.0); - let __k = icon_button(ui, Icon::Search, t.text_dim, "Search commands (⌘⇧Space)", vec2(28.0, ch)); + let __k = icon_button(ui, Icon::Search, t.text_dim, "Search commands", vec2(28.0, ch)); control_keytip(app, ui, &__k, "commandSearch"); if __k.clicked() { app.open_dialog("commandSearch", json!({})); @@ -265,6 +265,11 @@ fn file_menu(app: &mut SheetApp, ui: &mut Ui) { if file.clicked() { app.open_backstage(crate::backstage::Page::Home); } + if app.keytips.prefix() == Some("") + && let Some(sequence) = keytip_table::tab_sequence("File") + { + keytip(app, ui, sequence, pos2(file.rect.center().x, file.rect.bottom() - 1.0)); + } } /// System / Light / Dark, as in the View tab's Display theme box (`view.theme`). @@ -487,7 +492,7 @@ fn menu_items(app: &mut SheetApp, ui: &mut Ui, items: &[(&str, &str, serde_json: continue; } let lang = app.ui.language; - let response = ui.add(egui::Button::new(lang.tr(label)).frame(false).min_size(vec2(220.0, 22.0))); + let response = ui.add(egui::Button::new(lang.tr(label)).frame_when_inactive(false).min_size(vec2(220.0, 22.0))); if let Some(sequence) = keytip_table::item_letter(app.keytips.prefix().unwrap_or_default(), id) { keytip(app, ui, sequence, pos2(response.rect.right() - 10.0, response.rect.center().y)); } @@ -507,7 +512,7 @@ fn home(app: &mut SheetApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); let st = active_style(app); // Clipboard - let paste = big_button(ui, Icon::Paste, "Paste", "Paste (⌘V)", true); + let paste = big_button(ui, Icon::Paste, "Paste", "Paste", true); paste.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), "Paste")); for sequence in keytip_table::group_extension(app.keytips.prefix().unwrap_or_default(), &["edit.paste", "edit.pasteSpecial", "dialog:pasteSpecial"]) @@ -559,12 +564,12 @@ fn home(app: &mut SheetApp, ui: &mut Ui) { ); }); ui.vertical(|ui| { - let __k = icon_button(ui, Icon::Cut, t.text, "Cut (⌘X)", vec2(26.0, 23.0)); + let __k = icon_button(ui, Icon::Cut, t.text, "Cut", vec2(26.0, 23.0)); control_keytip(app, ui, &__k, "edit.cut"); if __k.clicked() { app.copy_to_clipboard(ui.ctx(), "edit.cut"); } - let __k = icon_button(ui, Icon::Copy, t.text, "Copy (⌘C)", vec2(26.0, 23.0)); + let __k = icon_button(ui, Icon::Copy, t.text, "Copy", vec2(26.0, 23.0)); control_keytip(app, ui, &__k, "edit.copy"); if __k.clicked() { app.copy_to_clipboard(ui.ctx(), "edit.copy"); @@ -600,17 +605,17 @@ fn home(app: &mut SheetApp, ui: &mut Ui) { } }); ui.horizontal(|ui| { - let __k = toggle_button(ui, Icon::Bold, st.font.bold, "Bold (⌘B)"); + let __k = toggle_button(ui, Icon::Bold, st.font.bold, "Bold"); control_keytip(app, ui, &__k, "home.bold"); if __k.clicked() { act(app, "home.bold", json!({})); } - let __k = toggle_button(ui, Icon::Italic, st.font.italic, "Italic (⌘I)"); + let __k = toggle_button(ui, Icon::Italic, st.font.italic, "Italic"); control_keytip(app, ui, &__k, "home.italic"); if __k.clicked() { act(app, "home.italic", json!({})); } - let (u, ua) = split_button(ui, Icon::Underline, None, "Underline (⌘U)"); + let (u, ua) = split_button(ui, Icon::Underline, None, "Underline"); control_keytip(app, ui, &ua, "home.underline"); if u { act(app, "home.underline", json!({})); @@ -671,15 +676,18 @@ fn home(app: &mut SheetApp, ui: &mut Ui) { act(app, "home.fillColor", json!({"color": fill})); } let forced = invoke(app, "home.fillColor"); - egui::Popup::menu(&fa).open_memory(popup_command(forced, fa.clicked())).show(|ui| { - if let Some(c) = color_palette(ui, &theme_colors(app), "No Fill") { - if c != "none" { - app.grid.last_fill = c.clone(); + egui::Popup::menu(&fa) + .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside) + .open_memory(popup_command(forced, fa.clicked())) + .show(|ui| { + if let Some(c) = color_palette(ui, &theme_colors(app), "No Fill") { + if c != "none" { + app.grid.last_fill = c.clone(); + } + act(app, "home.fillColor", json!({"color": c})); + ui.close(); } - act(app, "home.fillColor", json!({"color": c})); - ui.close(); - } - }); + }); let fc = app.grid.last_font_color.clone(); let fc_c = gridcraft_engine::model::Color::from_hex(&fc).and_then(|c| c.resolve(&Default::default())).map(theme::color32); let (c, ca) = split_button(ui, Icon::FontColor, fc_c, "Font Color"); @@ -688,15 +696,18 @@ fn home(app: &mut SheetApp, ui: &mut Ui) { act(app, "home.fontColor", json!({"color": fc})); } let forced = invoke(app, "home.fontColor"); - egui::Popup::menu(&ca).open_memory(popup_command(forced, ca.clicked())).show(|ui| { - if let Some(c) = color_palette(ui, &theme_colors(app), "Automatic") { - if c != "none" { - app.grid.last_font_color = c.clone(); + egui::Popup::menu(&ca) + .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside) + .open_memory(popup_command(forced, ca.clicked())) + .show(|ui| { + if let Some(c) = color_palette(ui, &theme_colors(app), "Automatic") { + if c != "none" { + app.grid.last_font_color = c.clone(); + } + act(app, "home.fontColor", json!({"color": if c == "none" { "auto".to_string() } else { c }})); + ui.close(); } - act(app, "home.fontColor", json!({"color": if c == "none" { "auto".to_string() } else { c }})); - ui.close(); - } - }); + }); }); }); sep(ui); @@ -955,7 +966,7 @@ fn home(app: &mut SheetApp, ui: &mut Ui) { // Editing ui.vertical(|ui| { ui.horizontal(|ui| { - let (s, sa) = split_button(ui, Icon::Sum, None, "AutoSum (⌘⇧T)"); + let (s, sa) = split_button(ui, Icon::Sum, None, "AutoSum"); control_keytip(app, ui, &sa, "formulas.autoSum"); if s { act(app, "formulas.autoSum", json!({})); @@ -1377,7 +1388,7 @@ fn insert(app: &mut SheetApp, ui: &mut Ui) { if __k.clicked() { act(app, "insert.pivotTable", json!({})); } - let __k = big_button(ui, Icon::Table, "Table", "Table (⌘T)", false); + let __k = big_button(ui, Icon::Table, "Table", "Table", false); control_keytip(app, ui, &__k, "insert.table"); if __k.clicked() { act(app, "insert.table", json!({})); @@ -1542,7 +1553,7 @@ fn insert(app: &mut SheetApp, ui: &mut Ui) { act(app, "insert.checkbox", json!({})); } sep(ui); - let __k = big_button(ui, Icon::Link, "Link", "Insert Link (⌘K)", false); + let __k = big_button(ui, Icon::Link, "Link", "Insert Link", false); control_keytip(app, ui, &__k, "dialog:insertLink"); if __k.clicked() { app.open_dialog("insertLink", json!({})); @@ -1911,7 +1922,7 @@ fn data(app: &mut SheetApp, ui: &mut Ui) { if __k.clicked() { app.open_dialog("sort", json!({})); } - let __k = big_button(ui, Icon::Filter, "Filter", "Filter (⌘⇧F)", false); + let __k = big_button(ui, Icon::Filter, "Filter", "Filter", false); control_keytip(app, ui, &__k, "data.filter"); if __k.clicked() { act(app, "data.filter", json!({})); @@ -1934,7 +1945,7 @@ fn data(app: &mut SheetApp, ui: &mut Ui) { if __k.clicked() { app.open_dialog("textToColumns", json!({})); } - let __k = big_button(ui, Icon::FillDown, "Flash\nFill", "Flash Fill (⌘E)", false); + let __k = big_button(ui, Icon::FillDown, "Flash\nFill", "Flash Fill", false); control_keytip(app, ui, &__k, "edit.flashFill"); if __k.clicked() { act(app, "edit.flashFill", json!({})); diff --git a/crates/ui-egui/src/tabs.rs b/crates/ui-egui/src/tabs.rs index b77524bb9..5fcd058f8 100644 --- a/crates/ui-egui/src/tabs.rs +++ b/crates/ui-egui/src/tabs.rs @@ -28,13 +28,33 @@ fn activate_sheet(app: &mut SheetApp, i: usize) { if app.editor.as_ref().is_some_and(|e| e.can_point()) { let _ = app.session.run("sheet.activate", json!({"sheet": i})); } else { - if app.editor.is_some() { - app.commit_edit(0, 0, false, false); + if app.editor.is_some() && !app.commit_edit(0, 0, false, false) { + return; } app.run_or_alert("sheet.activate", json!({"sheet": i})); } } +/// A tab released with Ctrl held (Cmd on macOS, as for the fill handle) drops a copy of the sheet. Excel for Mac uses +/// Option for this, so Option copies there too. +fn drop_copies_sheet(m: egui::Modifiers, mac: bool) -> bool { + m.command || (mac && m.alt) +} + +/// `sheet.move` params for tab `i` dropped `steps` tabs away, or `None` when it lands where it started. +fn tab_drop(i: usize, steps: i64, copy: bool) -> Option { + if steps == 0 { + return None; + } + let to = (i as i64 + steps).max(0) as usize; + if copy { + // A move indexes the list without the sheet, a copy the list with it: to the right, the copy goes one further. + let to = if steps > 0 { to + 1 } else { to }; + return Some(json!({"sheet": i, "to": to, "copy": true})); + } + Some(json!({"sheet": i, "to": to})) +} + pub fn sheet_tabs(app: &mut SheetApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); egui::Panel::bottom("sheet_tabs").exact_size(32.0).frame(egui::Frame::NONE.fill(t.tab_bar)).show(ui, |ui| { @@ -155,15 +175,18 @@ pub fn sheet_tabs(app: &mut SheetApp, ui: &mut Ui) { app.grid.renaming_tab = Some(i); app.grid.rename_text = name.clone(); } - // Drag to reorder. + // Drag to reorder; with the copy modifier held, drop a copy instead. + let copy = drop_copies_sheet(ui.input(|inp| inp.modifiers), ui.ctx().os() == egui::os::OperatingSystem::Mac); + if resp.dragged() && copy { + ui.ctx().set_cursor_icon(egui::CursorIcon::Copy); + } if resp.drag_stopped() && let Some(p) = ui.input(|inp| inp.pointer.interact_pos()) { let dx = p.x - r.center().x; let steps = (dx / w).round() as i64; - if steps != 0 { - let to = (i as i64 + steps).max(0) as usize; - app.run_or_alert("sheet.move", json!({"sheet": i, "to": to})); + if let Some(params) = tab_drop(i, steps, copy) { + app.run_or_alert("sheet.move", params); } } resp.context_menu(|ui| { @@ -186,7 +209,7 @@ pub fn sheet_tabs(app: &mut SheetApp, ui: &mut Ui) { } ui.menu_button("Tab Color", |ui| { let colors = app.session.active().map(|d| d.wb.theme.colors).unwrap_or_default(); - if let Some(c) = crate::widgets::color_palette(ui, &colors, "No Color") { + if let Some(c) = crate::widgets::color_palette_fixed(ui, &colors, "No Color") { app.run_or_alert("sheet.tabColor", json!({"sheet": i, "color": c})); ui.close(); } @@ -340,10 +363,56 @@ pub fn status_bar(app: &mut SheetApp, ui: &mut Ui) { #[cfg(test)] mod tests { - use super::{navigator_items, rename_needs_focus}; + use super::{activate_sheet, drop_copies_sheet, navigator_items, rename_needs_focus, tab_drop}; + use crate::SheetApp; + use egui::Modifiers; use gridcraft_engine::model::{Sheet, Visibility}; + use serde_json::json; use std::sync::Arc; + fn workbook_abc() -> SheetApp { + let mut session = gridcraft_engine::Session::default(); + session.new_workbook(); + let mut app = SheetApp::new(session, Default::default()); + app.run("home.insertSheet", json!({})).unwrap(); + app.run("home.insertSheet", json!({})).unwrap(); + for (i, name) in ["A", "B", "C"].into_iter().enumerate() { + app.run("sheet.rename", json!({"sheet": i, "name": name})).unwrap(); + } + app + } + + fn names(app: &SheetApp) -> Vec { + app.session.active().unwrap().wb.sheets.iter().map(|s| s.name.clone()).collect() + } + + #[test] + fn copy_drop_modifier_is_ctrl_or_cmd_or_option_on_mac() { + // egui reports Ctrl as `command` too off macOS, and Cmd as `mac_cmd` and `command` on macOS. + assert!(drop_copies_sheet(Modifiers::CTRL | Modifiers::COMMAND, false)); + assert!(!drop_copies_sheet(Modifiers::ALT, false)); + assert!(drop_copies_sheet(Modifiers::MAC_CMD | Modifiers::COMMAND, true)); + assert!(drop_copies_sheet(Modifiers::ALT, true)); + assert!(!drop_copies_sheet(Modifiers::CTRL, true), "Ctrl+click is the context menu on macOS"); + assert!(!drop_copies_sheet(Modifiers::NONE, false)); + } + + #[test] + fn tab_dropped_with_the_copy_modifier_leaves_a_copy_where_a_move_would_land() { + let mut app = workbook_abc(); + app.run("sheet.move", tab_drop(0, 1, true).unwrap()).unwrap(); + assert_eq!(names(&app), ["A", "B", "A (2)", "C"]); + + let mut app = workbook_abc(); + app.run("sheet.move", tab_drop(2, -1, true).unwrap()).unwrap(); + assert_eq!(names(&app), ["A", "C (2)", "B", "C"]); + + let mut app = workbook_abc(); + app.run("sheet.move", tab_drop(0, 1, false).unwrap()).unwrap(); + assert_eq!(names(&app), ["B", "A", "C"]); + assert!(tab_drop(1, 0, true).is_none()); + } + #[test] fn rename_field_takes_focus_once_and_lets_go_on_commit() { assert!(rename_needs_focus(false, false), "first frame: grab focus"); @@ -360,4 +429,24 @@ mod tests { let sheets = [Sheet::new("Data"), hidden, very_hidden, Sheet::new("Summary")].map(Arc::new); assert_eq!(navigator_items(&sheets), vec![("0".to_string(), "Data".to_string()), ("3".to_string(), "Summary".to_string())]); } + + #[test] + fn a_rejected_commit_keeps_the_sheet_and_the_edit() { + let mut session = gridcraft_engine::Session::new(); + session.new_workbook(); + session.execute("home.insertSheet", json!({})).unwrap(); + session.execute("sheet.activate", json!({"sheet": 0})).unwrap(); + let rule = json!({"range": "A1", "type": "whole", "operator": "between", "formula1": "1", "formula2": "10"}); + session.execute("data.validation", rule).unwrap(); + let mut app = crate::SheetApp::new(session, Default::default()); + app.begin_edit(Some("99".into()), false); + activate_sheet(&mut app, 1); + assert_eq!(app.session.active().unwrap().wb.active_sheet, 0, "a value the validation stops must not leave its sheet"); + assert!(app.editor.is_some(), "the edit stays open to be corrected"); + app.editor.as_mut().unwrap().text = "7".into(); + activate_sheet(&mut app, 1); + assert_eq!(app.session.active().unwrap().wb.active_sheet, 1, "the corrected value commits and the sheet changes"); + let a1 = gridcraft_engine::core::CellRef::parse("A1").unwrap(); + assert!(app.session.active().unwrap().wb.sheet(0).unwrap().cell(a1).is_some(), "the value lands on the first sheet"); + } } diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 08097ba27..613814d03 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -837,7 +837,7 @@ mod tests { ["sys-cjk-han-zh", "sys-cjk-han-ja", "sys-cjk-hangul", "sys-thai", "sys-hebrew", "sys-symbols", "sys-symbols-2"] ); assert_eq!(HanOrder::of(Language::Ja), HanOrder::Japanese); - for l in [Language::En, Language::Zh, Language::Ko, Language::Ru, Language::PtBr, Language::De, Language::Cs] { + for l in [Language::En, Language::Zh, Language::Ko, Language::Ru, Language::PtBr, Language::De, Language::Cs, Language::Fr, Language::Tr] { assert_eq!(HanOrder::of(l), HanOrder::Chinese, "{l:?} shares the default order (no font rebuild)"); } } diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index e24105b49..79c87a848 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -14,6 +14,41 @@ fn tr<'a>(ui: &Ui, text: &'a str) -> &'a str { i18n::current(ui.ctx()).tr(text) } +/// Keyboard shortcuts shown after a ribbon tooltip's command name: Excel for Mac's on macOS, +/// Excel for Windows' elsewhere (each one GridCraft binds). Only the name is translated. +const TOOLTIP_SHORTCUTS: &[(&str, &str, &str)] = &[ + ("Save", "⌘S", "Ctrl+S"), + ("Undo", "⌘Z", "Ctrl+Z"), + ("Redo", "⌘Y", "Ctrl+Y"), + ("Paste", "⌘V", "Ctrl+V"), + ("Cut", "⌘X", "Ctrl+X"), + ("Copy", "⌘C", "Ctrl+C"), + ("Bold", "⌘B", "Ctrl+B"), + ("Italic", "⌘I", "Ctrl+I"), + ("Underline", "⌘U", "Ctrl+U"), + ("AutoSum", "⌘⇧T", "Alt+="), + ("Table", "⌘T", "Ctrl+T"), + ("Insert Link", "⌘K", "Ctrl+K"), + ("Filter", "⌘⇧F", "Ctrl+Shift+L"), + ("Flash Fill", "⌘E", "Ctrl+E"), +]; + +/// The shortcut a ribbon tooltip shows after `tip`, for this platform (`mac`). +pub fn tooltip_shortcut(tip: &str, mac: bool) -> Option<&'static str> { + TOOLTIP_SHORTCUTS.iter().find(|(name, _, _)| *name == tip).map(|(_, m, other)| if mac { *m } else { *other }) +} + +/// A ribbon tooltip: the translated command name, then its shortcut for this platform, as Excel +/// shows them ("Bold (Ctrl+B)" on Windows, "Bold (⌘B)" on macOS). +fn tip_text(ui: &Ui, tip: &str) -> String { + // A name shared with a two-line ribbon label (Czech "Automatický\nsoučet") reads as one line. + let name = tr(ui, tip).replace('\n', " "); + match tooltip_shortcut(tip, cfg!(target_os = "macos")) { + Some(keys) => format!("{name} ({keys})"), + None => name.to_string(), + } +} + /// A flat square icon button with hover highlight and tooltip. pub fn icon_button(ui: &mut Ui, icon: Icon, color: Color32, tip: &str, size: egui::Vec2) -> Response { let (rect, resp) = ui.allocate_exact_size(size, Sense::click()); @@ -24,7 +59,7 @@ pub fn icon_button(ui: &mut Ui, icon: Icon, color: Color32, tip: &str, size: egu ui.painter().rect_filled(rect, 4.0, t.hover); } icons::paint(ui.painter(), Rect::from_center_size(rect.center(), vec2(16.0, 16.0)), icon, color); - resp.on_hover_text(tr(ui, tip)) + resp.on_hover_text(tip_text(ui, tip)) } /// A toggle-able small button (e.g. Bold) showing a checked state. @@ -38,7 +73,7 @@ pub fn toggle_button(ui: &mut Ui, icon: Icon, on: bool, tip: &str) -> Response { ui.painter().rect_filled(rect, 4.0, t.hover); } icons::paint(ui.painter(), Rect::from_center_size(rect.center(), vec2(16.0, 16.0)), icon, t.text); - resp.on_hover_text(tr(ui, tip)) + resp.on_hover_text(tip_text(ui, tip)) } /// Large ribbon button: 32px icon over a one- or two-line label. @@ -73,7 +108,7 @@ pub fn big_button(ui: &mut Ui, icon: Icon, label: &str, tip: &str, dropdown: boo ); } } - resp.on_hover_text(tr(ui, tip)) + resp.on_hover_text(tip_text(ui, tip)) } /// Small ribbon button: 16px icon with optional label to the right. @@ -101,7 +136,7 @@ pub fn small_button(ui: &mut Ui, icon: Icon, label: &str, tip: &str, dropdown: b t.text_dim, ); } - resp.on_hover_text(tr(ui, tip)) + resp.on_hover_text(tip_text(ui, tip)) } /// A split button: main part runs the default action, the arrow opens a menu. @@ -122,7 +157,7 @@ pub fn split_button(ui: &mut Ui, icon: Icon, accent: Option, tip: &str) ui.painter().rect_filled(Rect::from_min_size(pos2(main.left() + 4.0, main.bottom() - 5.0), vec2(17.0, 3.0)), 0.0, c); } icons::paint(ui.painter(), Rect::from_center_size(arrow.center(), vec2(10.0, 10.0)), Icon::Chevron, t.text_dim); - (m.on_hover_text(tr(ui, tip)).clicked(), a) + (m.on_hover_text(tip_text(ui, tip)).clicked(), a) } /// A one-line text field for a value stored in the workbook, which callers reload every frame. The text being typed is @@ -144,23 +179,84 @@ pub fn committed_text(ui: &mut Ui, id: egui::Id, current: &str, width: Option String { + format!("#{:02X}{:02X}{:02X}", rgb[0], rgb[1], rgb[2]) +} + +/// A colour typed as `#RRGGBB`, `RRGGBB`, `#RGB` or `RGB` (any case, spaces around it ignored). +pub fn parse_hex(text: &str) -> Option<[u8; 3]> { + let t = text.trim(); + let t = t.strip_prefix('#').unwrap_or(t); + let digits: Vec = t.chars().map(|c| c.to_digit(16)).collect::>()?; + let byte = |hi: u32, lo: u32| u8::try_from(hi * 16 + lo).ok(); + match digits[..] { + [r, g, b] => Some([byte(r, r)?, byte(g, g)?, byte(b, b)?]), + [r1, r2, g1, g2, b1, b2] => Some([byte(r1, r2)?, byte(g1, g2)?, byte(b1, b2)?]), + _ => None, + } +} + +/// Puts `hex` first in the recent colours, once, keeping at most [`RECENT_COLORS`]. +pub fn remember_color(recent: &mut Vec, hex: &str) { + let hex = hex.to_ascii_uppercase(); + recent.retain(|h| *h != hex); + recent.insert(0, hex); + recent.truncate(RECENT_COLORS); +} + +fn recent_colors_id() -> egui::Id { + egui::Id::new("gridcraft.recent_colors") +} + +/// The More Colors… picker's state while it is open in a palette. +#[derive(Clone)] +struct MoreColors { + hsva: egui::ecolor::Hsva, + hex: String, +} + +impl MoreColors { + fn new(rgb: [u8; 3]) -> Self { + Self { hsva: egui::ecolor::Hsva::from_srgb(rgb), hex: hex_of(rgb) } + } +} + +/// Our colour palette (theme row + tints + standard colours, then Recent Colors and More Colors…), +/// returns a picked hex colour, `Some("none")` for No Fill/Automatic. Arrow keys move a highlight +/// through the swatches and Enter applies; the key events are stripped so the grid behind the popup +/// does not also react (not while More Colors… is open, whose boxes take the keys). pub fn color_palette(ui: &mut Ui, theme_colors: &[u32; 12], none_label: &str) -> Option { + palette(ui, theme_colors, none_label, true) +} + +/// [`color_palette`] without More Colors…, for a menu that closes on any click inside it (a sub-menu +/// of a context menu), where the picker would vanish as soon as it is used. Recent Colors stays. +pub fn color_palette_fixed(ui: &mut Ui, theme_colors: &[u32; 12], none_label: &str) -> Option { + palette(ui, theme_colors, none_label, false) +} + +fn palette(ui: &mut Ui, theme_colors: &[u32; 12], none_label: &str, more_colors: bool) -> Option { let mut picked = None; const ROWS: usize = 7; // 6 tint rows + 1 standard row const COLS: usize = 10; let pal_id = ui.id().with("__color_palette"); let mut cur: Option<(usize, usize)> = ui.data_mut(|d| d.get_temp(pal_id)).unwrap_or(None); + let more_id = pal_id.with("more"); + let mut more: Option = ui.data_mut(|d| d.get_temp(more_id)).unwrap_or(None); let (mut dl, mut dr, mut du, mut dd, mut ent) = (false, false, false, false, false); - ui.input(|i| { - dl = i.key_pressed(egui::Key::ArrowLeft); - dr = i.key_pressed(egui::Key::ArrowRight); - du = i.key_pressed(egui::Key::ArrowUp); - dd = i.key_pressed(egui::Key::ArrowDown); - ent = i.key_pressed(egui::Key::Enter); - }); + if more.is_none() { + ui.input(|i| { + dl = i.key_pressed(egui::Key::ArrowLeft); + dr = i.key_pressed(egui::Key::ArrowRight); + du = i.key_pressed(egui::Key::ArrowUp); + dd = i.key_pressed(egui::Key::ArrowDown); + ent = i.key_pressed(egui::Key::Enter); + }); + } if dl || dr || du || dd { cur = match cur { None => Some(( @@ -266,6 +362,73 @@ pub fn color_palette(ui: &mut Ui, theme_colors: &[u32; 12], none_label: &str) -> } } }); + // Recent Colors: the custom colours picked below, newest first. + let mut recent: Vec = ui.ctx().data(|d| d.get_temp(recent_colors_id())).unwrap_or_default(); + if !recent.is_empty() { + ui.label(egui::RichText::new("Recent Colors").small()); + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 2.0; + for hex in &recent { + let c = parse_hex(hex).map_or(Color32::BLACK, |c| Color32::from_rgb(c[0], c[1], c[2])); + let (r, resp) = ui.allocate_exact_size(vec2(16.0, 14.0), Sense::click()); + ui.painter().rect_filled(r, 1.0, c); + ui.painter().rect_stroke(r, 1.0, Stroke::new(1.0, Color32::from_gray(200)), StrokeKind::Inside); + if resp.hovered() { + ui.painter().rect_stroke(r.expand(1.0), 1.0, Stroke::new(1.5, Color32::from_rgb(0xE8, 0x7D, 0x2B)), StrokeKind::Outside); + } + if resp.on_hover_text(hex).clicked() { + picked = Some(hex.clone()); + } + } + }); + } + if more_colors { + ui.separator(); + } + if more_colors && ui.button("More Colors…").clicked() { + let start = recent.first().and_then(|h| parse_hex(h)).unwrap_or([0x44, 0x72, 0xC4]); + more = Some(MoreColors::new(start)); + } + if let Some(m) = more.as_mut() { + let mut done = false; + // The saturation / value area with its hue bar and egui's red-green-blue boxes, then the hex + // box with the new colour beside it. + if egui::color_picker::color_picker_hsva_2d(ui, &mut m.hsva, egui::color_picker::Alpha::Opaque) { + m.hex = hex_of(m.hsva.to_srgb()); + } + ui.horizontal(|ui| { + let label = ui.label("Hex"); + let r = ui.add(egui::TextEdit::singleline(&mut m.hex).desired_width(72.0).char_limit(7)).labelled_by(label.id); + if r.changed() + && let Some(c) = parse_hex(&m.hex) + { + m.hsva = egui::ecolor::Hsva::from_srgb(c); + } + let c = m.hsva.to_srgb(); + let (sw, _) = ui.allocate_exact_size(vec2(36.0, 18.0), Sense::hover()); + ui.painter().rect_filled(sw, 1.0, Color32::from_rgb(c[0], c[1], c[2])); + ui.painter().rect_stroke(sw, 1.0, Stroke::new(1.0, Color32::from_gray(160)), StrokeKind::Inside); + ui.label(egui::RichText::new("New").small()); + }); + ui.horizontal(|ui| { + if ui.button(" OK ").clicked() { + // A hex still being typed that is not a colour falls back to the picker's. + let c = parse_hex(&m.hex).unwrap_or_else(|| m.hsva.to_srgb()); + let hex = hex_of(c); + remember_color(&mut recent, &hex); + picked = Some(hex); + done = true; + } + if ui.button("Cancel").clicked() { + done = true; + } + }); + if done { + more = None; + } + } + ui.ctx().data_mut(|d| d.insert_temp(recent_colors_id(), recent)); + ui.data_mut(|d| d.insert_temp(more_id, more)); ui.data_mut(|d| d.insert_temp(pal_id, cur)); picked } @@ -362,3 +525,55 @@ pub fn notice_bar(app: &mut SheetApp, ui: &mut Ui) { app.notice = None; } } + +#[cfg(test)] +mod color_tests { + use super::*; + + #[test] + fn hex_is_read_in_the_forms_people_type() { + assert_eq!(parse_hex("#1A2b3C"), Some([0x1A, 0x2B, 0x3C])); + assert_eq!(parse_hex(" 1a2b3c "), Some([0x1A, 0x2B, 0x3C])); + assert_eq!(parse_hex("#f80"), Some([0xFF, 0x88, 0x00])); + for bad in ["", "#", "#12", "#1234", "#12345", "#1234567", "#12345G", "##123456", "é12345"] { + assert_eq!(parse_hex(bad), None, "{bad:?}"); + } + assert_eq!(hex_of([0, 10, 255]), "#000AFF"); + } + + #[test] + fn recent_colours_keep_the_newest_first_once_each_and_at_most_ten() { + let mut r = Vec::new(); + for i in 0..12u8 { + remember_color(&mut r, &hex_of([i, 0, 0])); + } + assert_eq!(r.len(), RECENT_COLORS); + assert_eq!(r[0], "#0B0000"); + remember_color(&mut r, "#050000"); + remember_color(&mut r, "#0b0000"); + assert_eq!(&r[..2], ["#0B0000", "#050000"]); + assert_eq!(r.iter().filter(|h| *h == "#050000").count(), 1); + assert_eq!(r.len(), RECENT_COLORS); + } +} + +#[cfg(test)] +mod tooltip_shortcut_tests { + use super::tooltip_shortcut; + + #[test] + fn tooltips_show_each_platforms_own_shortcut() { + // #1191: Windows and Linux show Excel for Windows' shortcuts, never the Mac ⌘ ones. + assert_eq!(tooltip_shortcut("Bold", false), Some("Ctrl+B")); + assert_eq!(tooltip_shortcut("AutoSum", false), Some("Alt+=")); + assert_eq!(tooltip_shortcut("Filter", false), Some("Ctrl+Shift+L")); + assert_eq!(tooltip_shortcut("Bold", true), Some("⌘B")); + assert_eq!(tooltip_shortcut("Filter", true), Some("⌘⇧F")); + // Search commands has no shortcut of its own (Ctrl+Shift+Space selects all). + assert_eq!(tooltip_shortcut("Search commands", false), None); + for (name, mac, other) in super::TOOLTIP_SHORTCUTS { + assert!(!other.contains(['⌘', '⇧', '⌥']), "{name}: {other}"); + assert!(mac.starts_with('⌘'), "{name}: {mac}"); + } + } +} diff --git a/crates/ui-egui/tests/commit_after_pointing.rs b/crates/ui-egui/tests/commit_after_pointing.rs new file mode 100644 index 000000000..89e1ccd29 --- /dev/null +++ b/crates/ui-egui/tests/commit_after_pointing.rs @@ -0,0 +1,54 @@ +//! A formula started on one sheet and pointed at another sheet lands on the sheet it started on. + +use egui::vec2; +use gridcraft_engine::Session; +use gridcraft_engine::core::CellRef; +use gridcraft_ui_egui::SheetApp; +use serde_json::json; + +/// Two sheets, `range` selected on the first, and `=1+2` typed there; then pointing moves to the second sheet. +fn pointing_on_second_sheet(range: &str) -> egui_kittest::Harness<'static, SheetApp> { + let mut session = Session::new(); + session.new_workbook(); + session.execute("home.insertSheet", json!({})).unwrap(); + session.execute("sheet.activate", json!({"sheet": 0})).unwrap(); + session.execute("selection.set", json!({"range": range})).unwrap(); + let mut h = egui_kittest::Harness::builder().with_size(vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SheetApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SheetApp::new(session, Default::default()), + ); + h.run_steps(4); + h.state_mut().begin_edit(Some("=1+2".into()), false); + h.run_steps(2); + // Pointing moves to the other sheet without finishing the edit. + h.state_mut().session.execute("sheet.activate", json!({"sheet": 1})).unwrap(); + h +} + +#[test] +fn commit_after_pointing_on_another_sheet_writes_to_the_origin_sheet() { + let mut h = pointing_on_second_sheet("F8"); + assert!(h.state_mut().commit_edit(0, 0, false, false)); + let at = CellRef::parse("F8").unwrap(); + let wb = &h.state().session.active().unwrap().wb; + assert_eq!(wb.active_sheet, 0, "the origin sheet is shown again"); + assert!(wb.sheet(0).unwrap().cell(at).is_some_and(|c| c.formula.is_some()), "formula written on the origin sheet"); + assert!(wb.sheet(1).unwrap().cell(at).is_none(), "the pointed-at sheet is untouched"); +} + +#[test] +fn ctrl_enter_after_pointing_on_another_sheet_fills_the_origin_selection() { + let mut h = pointing_on_second_sheet("B2:C3"); + assert!(h.state_mut().commit_edit(0, 0, false, true)); + let wb = &h.state().session.active().unwrap().wb; + assert_eq!(wb.active_sheet, 0, "the origin sheet is shown again"); + for a1 in ["B2", "C2", "B3", "C3"] { + let at = CellRef::parse(a1).unwrap(); + assert!(wb.sheet(0).unwrap().cell(at).is_some_and(|c| c.formula.is_some()), "{a1} filled on the origin sheet"); + assert!(wb.sheet(1).unwrap().cell(at).is_none(), "{a1} on the pointed-at sheet is untouched"); + } + assert!(wb.sheet(1).unwrap().cell(CellRef::parse("A1").unwrap()).is_none(), "the pointed-at sheet's selection is untouched"); +} diff --git a/crates/ui-egui/tests/drag_cursor.rs b/crates/ui-egui/tests/drag_cursor.rs new file mode 100644 index 000000000..3198f044f --- /dev/null +++ b/crates/ui-egui/tests/drag_cursor.rs @@ -0,0 +1,307 @@ +//! Drag cursors are asserted frame by frame through real egui pointer input (#936). + +use egui::{CursorIcon, Event, Modifiers, PointerButton, Pos2, Rect, pos2, vec2}; +use gridcraft_engine::Session; +use gridcraft_engine::core::{CellRef, RangeRef, Value}; +use gridcraft_ui_egui::SheetApp; +use gridcraft_ui_egui::grid::{Drag, Geo, freeze_handles}; +use serde_json::json; + +type Harness = egui_kittest::Harness<'static, SheetApp>; + +fn harness() -> Harness { + let mut session = Session::new(); + session.new_workbook(); + session.execute("range.setValues", json!({"range": "A1", "values": [["A", "B", "C", "D", "E"], [1, 2]]})).unwrap(); + let mut h = Harness::builder().with_step_dt(1.0 / 60.0).with_size(vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SheetApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SheetApp::new(session, Default::default()), + ); + h.run_steps(4); + h +} + +fn geo(h: &Harness) -> Geo { + h.state().grid.geo.clone().unwrap() +} + +fn cell(h: &Harness, a1: &str) -> Rect { + let sh = h.state().session.active().unwrap().wb.active().unwrap(); + geo(h).screen_cell_rect(sh, CellRef::parse(a1).unwrap()) +} + +fn selection_rect(h: &Harness) -> Rect { + let d = h.state().session.active().unwrap(); + geo(h).range_rect(d.wb.active().unwrap(), d.selection.current()) +} + +fn frame(h: &mut Harness, event: Event) { + h.input_mut().events.push(event); + h.run_steps(1); +} + +fn move_to(h: &mut Harness, pos: Pos2) { + frame(h, Event::PointerMoved(pos)); +} + +fn button(h: &mut Harness, pos: Pos2, pressed: bool) { + frame(h, Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }); +} + +fn press(h: &mut Harness, pos: Pos2) { + move_to(h, pos); + button(h, pos, true); +} + +fn assert_cursor(h: &Harness, expected: CursorIcon) { + assert_eq!(h.output().platform_output.cursor_icon, expected, "drag state: {:?}", h.state().grid.drag); +} + +fn move_with_cursor(h: &mut Harness, pos: Pos2, expected: CursorIcon) { + move_to(h, pos); + assert_cursor(h, expected); + // Check the following idle frame too: hit targets may have moved with the selection. + h.run_steps(1); + assert_cursor(h, expected); +} + +#[test] +fn selecting_keeps_cell_cursor_over_borders_handles_frozen_panes_and_outside() { + for rtl in [false, true] { + let mut h = harness(); + h.state_mut().session.execute("view.rightToLeft", json!({"on": rtl})).unwrap(); + h.state_mut().session.execute("view.freezePanes", json!({"cell": "B2"})).unwrap(); + h.run_steps(2); + let start = cell(&h, "C3").center(); + press(&mut h, start); + assert_eq!(h.state().grid.drag, Drag::None, "a press below the threshold is not a drag"); + move_with_cursor(&mut h, start + vec2(1.0, 1.0), CursorIcon::Cell); + assert_eq!(h.state().grid.drag, Drag::None); + let inside = cell(&h, "D4").center(); + move_with_cursor(&mut h, inside, CursorIcon::Cell); + assert_eq!(h.state().grid.drag, Drag::Select); + assert_eq!(h.state().session.active().unwrap().selection.current().a1(), "C3:D4"); + + let r = selection_rect(&h); + let g = geo(&h); + let border = g.mirror_pos(pos2(r.right() - 1.0, r.bottom() - 8.0)); + let handle = g.mirror_pos(r.right_bottom() - vec2(1.0, 1.0)); + move_with_cursor(&mut h, border, CursorIcon::Cell); + move_with_cursor(&mut h, handle, CursorIcon::Cell); + assert_eq!(h.state().session.active().unwrap().selection.current().a1(), "C3:D4"); + // Cross into the frozen pane, then briefly leave the grid while still holding. + let frozen = cell(&h, "A1").center(); + move_with_cursor(&mut h, frozen, CursorIcon::Cell); + assert_eq!(h.state().session.active().unwrap().selection.current().a1(), "A1:C3"); + let outside = pos2(g.rect.left() - 20.0, g.rect.center().y); + move_with_cursor(&mut h, outside, CursorIcon::Cell); + // Leaving the mirrored grid can auto-scroll, so use its current geometry on return. + let inside = cell(&h, "D4").center(); + move_with_cursor(&mut h, inside, CursorIcon::Cell); + let handle = geo(&h).mirror_pos(selection_rect(&h).right_bottom() - vec2(1.0, 1.0)); + move_with_cursor(&mut h, handle, CursorIcon::Cell); + button(&mut h, handle, false); + assert_eq!(h.state().grid.drag, Drag::None); + assert_cursor(&h, CursorIcon::Crosshair); + let r = selection_rect(&h); + let border = geo(&h).mirror_pos(pos2(r.right() - 1.0, r.bottom() - 8.0)); + move_with_cursor(&mut h, border, CursorIcon::Move); + let inside = cell(&h, "D4").center(); + move_with_cursor(&mut h, inside, CursorIcon::Cell); + let d = h.state().session.active().unwrap(); + assert_eq!(d.selection.current().a1(), "C3:D4"); + assert_eq!(d.wb.active().unwrap().value(CellRef::parse("B2").unwrap()), Value::Number(2.0)); + } +} + +#[test] +fn a_new_press_does_not_treat_a_clicks_transient_state_as_a_drag() { + let mut h = harness(); + let start = cell(&h, "C3").center(); + press(&mut h, start); + button(&mut h, start, false); + assert_eq!(h.state().grid.drag, Drag::Select, "a click briefly leaves a selection state"); + let handle = selection_rect(&h).right_bottom(); + // Another press can arrive before an idle frame clears that state. + h.input_mut().events.push(Event::PointerMoved(handle)); + button(&mut h, handle, true); + assert_cursor(&h, CursorIcon::Crosshair); + button(&mut h, handle, false); + assert_eq!(h.state().grid.drag, Drag::None); +} + +#[test] +fn fill_keeps_crosshair_and_still_extends_a_series() { + let mut h = harness(); + h.state_mut().session.execute("selection.set", json!({"range": "A2:B2"})).unwrap(); + h.run_steps(2); + let start = selection_rect(&h).right_bottom(); + press(&mut h, start); + let target = cell(&h, "E2").center(); + move_with_cursor(&mut h, target, CursorIcon::Crosshair); + assert!(matches!(h.state().grid.drag, Drag::Fill { .. })); + move_with_cursor(&mut h, target + vec2(1.0, 0.0), CursorIcon::Crosshair); + button(&mut h, target, false); + assert_eq!(h.state().grid.drag, Drag::None); + assert_cursor(&h, CursorIcon::Cell); + let sh = h.state().session.active().unwrap().wb.active().unwrap(); + assert_eq!((0..5).map(|c| sh.value(CellRef::new(1, c))).collect::>(), (1..=5).map(|n| Value::Number(n as f64)).collect::>()); +} + +#[test] +fn moving_a_selection_keeps_move_and_still_moves_cells() { + let mut h = harness(); + h.state_mut().session.execute("selection.set", json!({"range": "A2:B2"})).unwrap(); + h.run_steps(2); + let r = selection_rect(&h); + let start = pos2(r.left() + 10.0, r.top() + 1.0); + press(&mut h, start); + let target = cell(&h, "D5").center(); + move_with_cursor(&mut h, target, CursorIcon::Move); + assert!(matches!(h.state().grid.drag, Drag::Move { .. })); + move_with_cursor(&mut h, target + vec2(1.0, 0.0), CursorIcon::Move); + button(&mut h, target, false); + assert_eq!(h.state().grid.drag, Drag::None); + h.run_steps(1); + let sh = h.state().session.active().unwrap().wb.active().unwrap(); + assert_eq!(sh.value(CellRef::parse("A2").unwrap()), Value::Empty); + assert_eq!(sh.value(CellRef::parse("D5").unwrap()), Value::Number(1.0)); + assert_eq!(sh.value(CellRef::parse("E5").unwrap()), Value::Number(2.0)); +} + +#[test] +fn resizing_headers_keeps_the_axis_cursor_when_leaving_the_header() { + for cols in [true, false] { + let mut h = harness(); + let g = geo(&h); + let r = cell(&h, "C3"); + let start = if cols { pos2(r.right(), g.head_top() + g.header_h * 0.5) } else { pos2(g.head_left() + g.header_w * 0.5, r.bottom()) }; + let cursor = if cols { CursorIcon::ResizeColumn } else { CursorIcon::ResizeRow }; + press(&mut h, start); + let first = start + if cols { vec2(12.0, 0.0) } else { vec2(0.0, 12.0) }; + move_with_cursor(&mut h, first, cursor); + assert!(if cols { matches!(h.state().grid.drag, Drag::ResizeCol { .. }) } else { matches!(h.state().grid.drag, Drag::ResizeRow { .. }) }); + let target = cell(&h, "E6").center(); + move_with_cursor(&mut h, target, cursor); + button(&mut h, target, false); + assert_eq!(h.state().grid.drag, Drag::None); + assert_cursor(&h, CursorIcon::Cell); + let sh = h.state().session.active().unwrap().wb.active().unwrap(); + assert!(if cols { sh.col_width(2) > sh.default_col_width } else { sh.row_height(2) > sh.default_row_height }); + } +} + +#[test] +fn freezing_keeps_the_axis_cursor_away_from_the_handle() { + for cols in [true, false] { + let mut h = harness(); + let g = geo(&h); + let handles = freeze_handles(&g); + let start = if cols { handles.0.center() } else { handles.1.center() }; + let cursor = if cols { CursorIcon::ResizeHorizontal } else { CursorIcon::ResizeVertical }; + press(&mut h, start); + let r = cell(&h, "C4"); + let target = if cols { pos2(r.left() + 2.0, r.center().y) } else { pos2(r.center().x, r.top() + 2.0) }; + move_with_cursor(&mut h, target, cursor); + assert!(matches!(h.state().grid.drag, Drag::Freeze { .. })); + move_with_cursor(&mut h, target + vec2(1.0, 1.0), cursor); + button(&mut h, target, false); + assert_eq!(h.state().grid.drag, Drag::None); + assert_cursor(&h, CursorIcon::Cell); + assert_eq!(h.state().session.active().unwrap().wb.active().unwrap().freeze, Some(if cols { (0, 2) } else { (3, 0) })); + } +} + +#[test] +fn moving_columns_keeps_move_over_unselected_headers_and_cells() { + let mut h = harness(); + h.state_mut().session.execute("selection.set", json!({"range": "B:C"})).unwrap(); + h.run_steps(2); + let g = geo(&h); + let start = pos2(cell(&h, "B1").center().x, g.head_top() + g.header_h * 0.5); + press(&mut h, start); + let target = pos2(cell(&h, "E1").left() + 10.0, start.y); + move_with_cursor(&mut h, target, CursorIcon::Move); + assert!(matches!(h.state().grid.drag, Drag::MoveCols { .. })); + let inside = pos2(target.x, cell(&h, "E5").center().y); + move_with_cursor(&mut h, inside, CursorIcon::Move); + button(&mut h, target, false); + assert_eq!(h.state().grid.drag, Drag::None); + let sh = h.state().session.active().unwrap().wb.active().unwrap(); + assert_eq!((0..5).map(|c| sh.value(CellRef::new(0, c))).collect::>(), ["A", "D", "B", "C", "E"].map(|s| Value::Text(s.into()))); +} + +#[test] +fn selecting_headers_keeps_default_cursor_and_extends_whole_lines() { + for cols in [true, false] { + let mut h = harness(); + let g = geo(&h); + let r = cell(&h, "C3"); + let start = if cols { pos2(r.center().x, g.head_top() + g.header_h * 0.5) } else { pos2(g.head_left() + g.header_w * 0.5, r.center().y) }; + press(&mut h, start); + move_with_cursor(&mut h, start + if cols { vec2(12.0, 0.0) } else { vec2(0.0, 7.0) }, CursorIcon::Default); + assert!(if cols { matches!(h.state().grid.drag, Drag::Cols(_)) } else { matches!(h.state().grid.drag, Drag::Rows(_)) }); + let target = cell(&h, "E6").center(); + move_with_cursor(&mut h, target, CursorIcon::Default); + button(&mut h, target, false); + assert_eq!(h.state().grid.drag, Drag::None); + let expected = if cols { RangeRef::cols(2, 4) } else { RangeRef::rows(2, 5) }; + assert_eq!(h.state().session.active().unwrap().selection.current(), expected); + } +} + +#[test] +fn object_move_and_resize_keep_their_cursors_and_update_the_object() { + for resize in [false, true] { + let mut h = harness(); + let result = h.state_mut().session.execute("insert.chart", json!({"type": "column", "at": "D3", "width": 300, "height": 200})).unwrap(); + let id = result["chart"].as_u64().unwrap() as u32; + h.state_mut().selected_chart = Some(id); + h.run_steps(2); + let g = geo(&h); + let min = cell(&h, "D3").min; + let start = if resize { min + vec2(300.0, 200.0) * g.z } else { min + vec2(40.0, 40.0) }; + press(&mut h, start); + let target = start + vec2(60.0, 30.0); + let cursor = if resize { CursorIcon::ResizeNwSe } else { CursorIcon::Move }; + move_with_cursor(&mut h, target, cursor); + assert!(matches!(h.state().grid.drag, Drag::Object { resize: r, .. } if r == resize)); + move_with_cursor(&mut h, target + vec2(1.0, 1.0), cursor); + button(&mut h, target, false); + assert_eq!(h.state().grid.drag, Drag::None); + let a = h.state().session.active().unwrap().wb.active().unwrap().charts.iter().find(|c| c.id == id).unwrap().anchor; + if resize { + assert!(a.width > 340.0 && a.height > 220.0); + } else { + assert_ne!((a.cell, a.dx, a.dy), (CellRef::parse("D3").unwrap(), 0.0, 0.0)); + assert_eq!((a.width, a.height), (300.0, 200.0)); + } + } +} + +#[test] +fn draw_tools_keep_crosshair_and_pen_still_creates_ink() { + for tool in ["draw.pen", "draw.eraser"] { + let mut h = harness(); + h.state_mut().session.execute(tool, json!({"on": true})).unwrap(); + let start = cell(&h, "C3").center(); + press(&mut h, start); + assert_cursor(&h, CursorIcon::Crosshair); + for a1 in ["D4", "E5", "F6"] { + let p = cell(&h, a1).center(); + move_with_cursor(&mut h, p, CursorIcon::Crosshair); + assert_eq!(h.state().grid.hover_cell, Some(CellRef::parse(a1).unwrap()), "drawing still tracks the hovered cell for notes"); + } + let end = cell(&h, "F6").center(); + button(&mut h, end, false); + assert_cursor(&h, CursorIcon::Crosshair); + if tool == "draw.pen" { + assert!( + h.state().session.active().unwrap().wb.active().unwrap().shapes.iter().any(|s| s.kind == gridcraft_engine::model::ShapeKind::Ink) + ); + } + } +} diff --git a/crates/ui-egui/tests/file_menu.rs b/crates/ui-egui/tests/file_menu.rs index 6cce9cdba..7c52e9cbb 100644 --- a/crates/ui-egui/tests/file_menu.rs +++ b/crates/ui-egui/tests/file_menu.rs @@ -199,6 +199,80 @@ fn save_as_offers_pdf_and_leaves_the_workbook_file_alone() { let _ = std::fs::remove_dir_all(&dir); } +/// The window's close button (or Alt+F4, or the OS quitting the app): steps until the request +/// has been handled and returns the viewport commands the app answered with. +fn request_window_close(h: &mut egui_kittest::Harness<'static, SheetApp>) -> Vec { + h.input_mut().viewports.entry(egui::ViewportId::ROOT).or_default().events.push(egui::ViewportEvent::Close); + step_commands(h) +} + +/// Runs a few frames and gathers the commands sent to the window. +fn step_commands(h: &mut egui_kittest::Harness<'static, SheetApp>) -> Vec { + let mut out = Vec::new(); + for _ in 0..3 { + h.step(); + out.extend(h.output().viewport_output.get(&egui::ViewportId::ROOT).map(|v| v.commands.clone()).unwrap_or_default()); + } + out +} + +fn click_commands(h: &mut egui_kittest::Harness<'static, SheetApp>, label: &str) -> Vec { + h.get_by_role_and_label(egui::accesskit::Role::Button, label).click(); + step_commands(h) +} + +#[test] +fn closing_the_window_asks_about_every_unsaved_workbook() { + let mut h = harness(Services::default()); + h.state_mut().run("cell.set", serde_json::json!({"cell": "A1", "input": "42"})).unwrap(); + h.state_mut().run("file.new", serde_json::json!({})).unwrap(); + h.state_mut().run("cell.set", serde_json::json!({"cell": "B2", "input": "7"})).unwrap(); + h.run_steps(2); + + let sent = request_window_close(&mut h); + assert!(sent.contains(&egui::ViewportCommand::CancelClose), "unsaved changes hold the window open: {sent:?}"); + assert_eq!(h.state().dialog.as_ref().map(|d| d.name()), Some("saveChanges")); + let sent = click_commands(&mut h, "Cancel"); + assert!(!sent.contains(&egui::ViewportCommand::Close), "Cancel keeps the app open"); + assert!(h.state().dialog.is_none()); + assert_eq!(h.state().session.documents().len(), 2, "Cancel keeps both workbooks"); + + request_window_close(&mut h); + let sent = click_commands(&mut h, "Don't Save"); + assert!(!sent.contains(&egui::ViewportCommand::Close), "the other workbook is still unsaved"); + assert_eq!(h.state().session.documents().len(), 1); + assert_eq!(h.state().dialog.as_ref().map(|d| d.name()), Some("saveChanges"), "then it asks about the next one"); + let sent = click_commands(&mut h, "Don't Save"); + assert!(sent.contains(&egui::ViewportCommand::Close), "once every workbook is answered the window closes: {sent:?}"); + let sent = request_window_close(&mut h); + assert!(!sent.contains(&egui::ViewportCommand::CancelClose), "the close that follows is let through"); +} + +#[test] +fn closing_the_window_can_save_first_and_a_saved_workbook_closes_at_once() { + let dir = std::env::temp_dir().join(format!("gridcraft-close-window-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("Book.xlsx").to_string_lossy().into_owned(); + let mut h = harness(Services::default()); + h.state_mut().run("file.save", serde_json::json!({"path": &path})).unwrap(); + h.run_steps(2); + let sent = request_window_close(&mut h); + assert!(!sent.contains(&egui::ViewportCommand::CancelClose), "nothing unsaved: the window just closes"); + assert!(h.state().dialog.is_none()); + + // A cell still being typed is an unsaved change too. + h.input_mut().events.push(egui::Event::Text("123".into())); + h.run_steps(2); + assert!(h.state().editor.is_some()); + let sent = request_window_close(&mut h); + assert!(sent.contains(&egui::ViewportCommand::CancelClose), "{sent:?}"); + let sent = click_commands(&mut h, "Save"); + assert!(sent.contains(&egui::ViewportCommand::Close), "{sent:?}"); + let (wb, _) = gridcraft_engine::io::open_bytes("Book.xlsx", &std::fs::read(&path).unwrap()).unwrap(); + assert_eq!(wb.active().unwrap().value(CellRef::new(0, 0)), Value::Number(123.0), "Save wrote the change before closing"); + let _ = std::fs::remove_dir_all(&dir); +} + fn pdf_picker_harness(dir: &std::path::Path) -> (Arc>>, egui_kittest::Harness<'static, SheetApp>) { let asked = Arc::new(Mutex::new(Vec::::new())); let (capture, target) = (asked.clone(), dir.to_path_buf()); diff --git a/crates/ui-egui/tests/formula_locale.rs b/crates/ui-egui/tests/formula_locale.rs index 855869377..16f4dac15 100644 --- a/crates/ui-egui/tests/formula_locale.rs +++ b/crates/ui-egui/tests/formula_locale.rs @@ -219,3 +219,18 @@ fn user_defined_prefix_is_hidden_while_editing_and_kept_on_commit() { assert!(app.commit_edit(0, 0, false, false)); assert_eq!(formula(&app), "_xludf.EOMONTH()&_xludf.CONCAT(\"_xludf.x\")"); } + +#[test] +fn turkish_interface_retains_its_canonical_formula_fallback() { + let mut app = app(); + app.run("app.language.set", json!({"code":"tr-TR"})).unwrap(); + assert_eq!(app.ui.language, Language::Tr); + assert_eq!(app.ui.language.tr("Data"), "Veri"); + assert_eq!(app.ui.language.tr("Formulas"), "Formüller"); + assert!(enter(&mut app, "=SUM(1.5,2.25)")); + app.begin_edit(None, true); + assert_eq!(app.editor.as_ref().unwrap().text, "=SUM(1.5,2.25)"); + assert!(!enter(&mut app, "=TOPLA(1;2)")); + let restored: UiState = serde_json::from_str(&serde_json::to_string(&app.ui).unwrap()).unwrap(); + assert_eq!(restored.language, Language::Tr); +} diff --git a/crates/ui-egui/tests/interaction.rs b/crates/ui-egui/tests/interaction.rs index 02084ffb8..8af57a69e 100644 --- a/crates/ui-egui/tests/interaction.rs +++ b/crates/ui-egui/tests/interaction.rs @@ -177,7 +177,8 @@ fn keyboard_navigation_does_not_replay_the_last_pointer_click() { (Key::Enter, Modifiers::NONE, "B5"), (Key::Enter, Modifiers::SHIFT, "B4"), (Key::Tab, Modifiers::NONE, "C4"), - (Key::Enter, Modifiers::NONE, "C5"), + // #911: Enter after a Tab goes back to the column the Tab started from. + (Key::Enter, Modifiers::NONE, "B5"), ] { key(&mut h, key_code, modifiers); let selection = &h.state().session.active().unwrap().selection; @@ -241,6 +242,50 @@ fn typing_enters_values_and_moves_down() { assert_eq!(active.as_deref(), Some("B2")); } +#[test] +fn enter_after_tabs_returns_to_the_column_where_the_tabs_started() { + // #911: Excel moves to the next row in the column the run of Tabs started from. + let mut h = harness(blank()); + for t in ["a", "b"] { + text(&mut h, t); + key(&mut h, Key::Tab, Modifiers::NONE); + } + text(&mut h, "c"); + key(&mut h, Key::Enter, Modifiers::NONE); + assert_eq!(value(&h, "C1"), Value::from("c")); + assert_eq!(h.state().session.active().unwrap().selection.active.a1(), "A2"); + // The next Enter is an ordinary one again. + key(&mut h, Key::Enter, Modifiers::NONE); + assert_eq!(h.state().session.active().unwrap().selection.active.a1(), "A3"); +} + +#[test] +fn moving_with_an_arrow_ends_the_run_of_tabs() { + let mut h = harness(blank()); + key(&mut h, Key::Tab, Modifiers::NONE); + key(&mut h, Key::ArrowRight, Modifiers::NONE); + key(&mut h, Key::Enter, Modifiers::NONE); + assert_eq!(h.state().session.active().unwrap().selection.active.a1(), "C2"); +} + +#[test] +fn an_arrow_that_commits_typing_ends_the_run_of_tabs() { + // Excel 365 from B2: the arrow is an ordinary move, neither one more Tab (Right) nor an Enter + // back to the starting column (Down). + for (arrow, after_enter) in [(Key::ArrowRight, "D3"), (Key::ArrowDown, "C4")] { + let mut s = blank(); + s.execute("selection.set", json!({"cell": "B2"})).unwrap(); + let mut h = harness(s); + text(&mut h, "a"); + key(&mut h, Key::Tab, Modifiers::NONE); + text(&mut h, "b"); + key(&mut h, arrow, Modifiers::NONE); + assert_eq!(value(&h, "C2"), Value::from("b"), "{arrow:?}"); + key(&mut h, Key::Enter, Modifiers::NONE); + assert_eq!(h.state().session.active().unwrap().selection.active.a1(), after_enter, "{arrow:?}"); + } +} + #[test] fn arrows_escape_and_undo() { let mut h = harness(blank()); @@ -364,6 +409,8 @@ fn language_switch_translates_the_ribbon_and_persists() { (Language::Ru, "Данные"), (Language::PtBr, "Dados"), (Language::De, "Daten"), + (Language::Fr, "Données"), + (Language::Tr, "Veri"), ] { h.state_mut().run("app.language.set", json!({"language": language.code()})).unwrap(); h.run_steps(2); // every locale renders the ribbon without panicking diff --git a/crates/ui-egui/tests/keytips.rs b/crates/ui-egui/tests/keytips.rs index 1cf286eac..a32abe82f 100644 --- a/crates/ui-egui/tests/keytips.rs +++ b/crates/ui-egui/tests/keytips.rs @@ -390,6 +390,25 @@ fn tab_letters_switch_to_their_ribbon_tabs() { assert_eq!(h.state().keytips.prefix(), None, "terminal tab units finish cleanly"); } +#[test] +fn f_opens_the_file_view_and_its_letters_still_run() { + use gridcraft_ui_egui::backstage::Page; + let mut h = harness(blank()); + sequence(&mut h, &[Key::F], false); + assert_eq!(h.state().backstage.as_ref().map(|b| b.page), Some(Page::Home), "F opens the File view on Home"); + assert_eq!(h.state().keytips.prefix(), Some("F")); + // Esc backs out one level: the File view closes and the tab letters show again. + key(&mut h, Key::Escape); + assert!(h.state().backstage.is_none()); + assert_eq!(h.state().keytips.prefix(), Some("")); + letter(&mut h, Key::F, false); + assert!(h.state().backstage.is_some()); + letter(&mut h, Key::N, false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "file.new"), "F, N still runs New"); + assert!(h.state().backstage.is_none(), "the command runs on the sheet, as before the view opened"); + assert_eq!(h.state().keytips.prefix(), None); +} + #[test] fn view_gridlines_sequence_runs_end_to_end() { let mut h = harness(blank()); diff --git a/crates/ui-egui/tests/keytips_generated.rs b/crates/ui-egui/tests/keytips_generated.rs index 653554857..cece3b3ed 100644 --- a/crates/ui-egui/tests/keytips_generated.rs +++ b/crates/ui-egui/tests/keytips_generated.rs @@ -59,6 +59,14 @@ fn sequence(h: &mut Harness, seq: &str, text_first: bool) { } } +#[test] +fn keytip_f_() { + let mut h = harness(blank()); + sequence(&mut h, "F", false); + assert!(h.state().backstage.is_some(), "F must open the File view"); + assert_eq!(h.state().keytips.prefix(), Some("F")); +} + #[test] fn keytip_h_() { let mut h = harness(blank()); @@ -2741,7 +2749,7 @@ fn keytip_q_() { #[test] fn generated_test_count_matches_the_table() { - const EXPECTED: usize = 364; + const EXPECTED: usize = 365; assert_eq!(gridcraft_ui_egui::keytips::keytip_table::SEQUENCES.len(), EXPECTED); } diff --git a/crates/ui-egui/tests/menu_hover.rs b/crates/ui-egui/tests/menu_hover.rs new file mode 100644 index 000000000..61e265235 --- /dev/null +++ b/crates/ui-egui/tests/menu_hover.rs @@ -0,0 +1,71 @@ +//! Menu items highlight under the pointer, as in Excel (#1174): no background at rest, the hover +//! background while the pointer is on an item. + +use egui::{Event, Modifiers, PointerButton, Pos2, Shape, accesskit::Role}; +use egui_kittest::kittest::Queryable as _; +use gridcraft_engine::Session; +use gridcraft_engine::core::CellRef; +use gridcraft_ui_egui::{SheetApp, grid}; + +type Harness = egui_kittest::Harness<'static, SheetApp>; + +fn harness() -> Harness { + let mut s = Session::new(); + s.new_workbook(); + 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 +} + +fn cell_center(h: &Harness, cell: &str) -> egui::Rect { + let app = h.state(); + let sh = app.session.active().unwrap().wb.active().unwrap(); + let geo = grid::Geo::new(sh, app.grid.rect.unwrap(), app.view().scroll); + geo.cell_rect(sh, CellRef::parse(cell).unwrap()) +} + +fn right_click(h: &mut Harness, pos: Pos2) { + h.input_mut().events.push(Event::PointerMoved(pos)); + for pressed in [true, false] { + h.input_mut().events.push(Event::PointerButton { pos, button: PointerButton::Secondary, pressed, modifiers: Modifiers::NONE }); + h.run_steps(2); + } +} + +/// Is a filled rectangle in the hovered-widget colour painted under `at`? +fn hover_fill_under(h: &Harness, at: Pos2) -> bool { + let fill = h.ctx.global_style().visuals.widgets.hovered.weak_bg_fill; + h.output().shapes.iter().any(|s| matches!(&s.shape, Shape::Rect(r) if r.fill == fill && r.rect.contains(at))) +} + +fn check_item(h: &mut Harness, label: &str) { + let at = h.get_by_role_and_label(Role::Button, label).rect().center(); + assert!(!hover_fill_under(h, at), "{label} has no background before the pointer is on it"); + h.input_mut().events.push(Event::PointerMoved(at)); + h.run_steps(2); + assert!(hover_fill_under(h, at), "{label} is highlighted under the pointer"); +} + +#[test] +fn cell_menu_items_highlight_under_the_pointer() { + let mut h = harness(); + let pos = cell_center(&h, "C5").center(); + right_click(&mut h, pos); + assert!(h.state().grid.context_menu.is_some()); + check_item(&mut h, "Copy"); +} + +#[test] +fn header_menu_items_highlight_under_the_pointer() { + let mut h = harness(); + let c1 = cell_center(&h, "C1"); + right_click(&mut h, egui::pos2(c1.center().x, c1.top() - 6.0)); + assert!(h.state().grid.header_menu.is_some()); + check_item(&mut h, "Copy"); +} diff --git a/crates/ui-egui/tests/more_colors.rs b/crates/ui-egui/tests/more_colors.rs new file mode 100644 index 000000000..72704d50b --- /dev/null +++ b/crates/ui-egui/tests/more_colors.rs @@ -0,0 +1,99 @@ +//! The colour menus' More Colors… and Recent Colors, through the real egui input path. + +use egui::{Event, Key, Modifiers, os::OperatingSystem}; +use egui_kittest::kittest::Queryable as _; +use gridcraft_engine::Session; +use gridcraft_ui_egui::SheetApp; + +type Harness = egui_kittest::Harness<'static, SheetApp>; + +fn harness() -> Harness { + let mut s = Session::new(); + s.new_workbook(); + let mut h = egui_kittest::Harness::builder().with_os(OperatingSystem::Nix).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 +} + +fn press(h: &mut Harness, key: Key, modifiers: Modifiers) { + let ev = |pressed| Event::Key { key, physical_key: None, pressed, repeat: false, modifiers }; + h.input_mut().events.extend([ev(true), ev(false)]); + h.run_steps(2); +} + +/// H, H opens the Fill Color palette. +fn open_fill_palette(h: &mut Harness) { + for k in [Key::F10, Key::H, Key::H] { + press(h, k, Modifiers::NONE); + } + h.run_steps(3); +} + +fn type_hex(h: &mut Harness, hex: &str) { + h.get_by_label("Hex").click(); + h.run_steps(2); + press(h, Key::A, Modifiers::COMMAND); + h.input_mut().events.push(Event::Text(hex.to_string())); + h.run_steps(3); +} + +#[test] +fn more_colors_applies_the_typed_colour_and_remembers_it() { + let mut h = harness(); + open_fill_palette(&mut h); + assert!(h.query_by_label("Recent Colors").is_none(), "nothing is recent before a custom colour is used"); + h.get_by_label("More Colors…").click(); + h.run_steps(3); + type_hex(&mut h, "#12AB34"); + h.get_by_label(" OK ").click(); + h.run_steps(4); + assert_eq!(h.state().grid.last_fill, "#12AB34"); + assert_eq!(h.state().session.journal.iter().filter(|(id, _)| id == "home.fillColor").count(), 1); + assert!(h.query_by_label("Theme Colors").is_none(), "the palette closes after OK"); + // The next time any colour menu opens, the colour is the first Recent Color. + open_fill_palette(&mut h); + h.get_by_label("Recent Colors"); + assert!(h.query_by_label("Hex").is_none(), "More Colors… starts closed"); +} + +#[test] +fn a_colour_that_is_not_hex_does_not_apply_and_cancel_applies_nothing() { + let mut h = harness(); + open_fill_palette(&mut h); + h.get_by_label("More Colors…").click(); + h.run_steps(3); + // Enter in the Hex box does not apply the palette's highlighted swatch or close the menu. + type_hex(&mut h, "#12AB"); + press(&mut h, Key::Enter, Modifiers::NONE); + assert!(h.state().session.journal.iter().all(|(id, _)| id != "home.fillColor")); + h.get_by_label("Hex"); + h.get_by_label("Cancel").click(); + h.run_steps(3); + assert!(h.state().session.journal.iter().all(|(id, _)| id != "home.fillColor")); + assert!(h.query_by_label("Hex").is_none()); + assert!(h.query_by_label("Recent Colors").is_none(), "Cancel remembers nothing"); +} + +#[test] +fn the_font_colour_menu_has_them_too_and_shares_the_recent_colours() { + let mut h = harness(); + open_fill_palette(&mut h); + h.get_by_label("More Colors…").click(); + h.run_steps(3); + type_hex(&mut h, "#00FF7F"); + h.get_by_label(" OK ").click(); + h.run_steps(4); + for k in [Key::F10, Key::H, Key::F, Key::C] { + press(&mut h, k, Modifiers::NONE); + } + h.run_steps(3); + h.get_by_label("Automatic"); + h.get_by_label("Recent Colors"); + h.get_by_label("More Colors…"); +} diff --git a/crates/ui-egui/tests/popup_dismissal.rs b/crates/ui-egui/tests/popup_dismissal.rs index 391ccd6af..24d9fafbc 100644 --- a/crates/ui-egui/tests/popup_dismissal.rs +++ b/crates/ui-egui/tests/popup_dismissal.rs @@ -174,3 +174,42 @@ fn worksheet_popups_close_on_escape_or_window_focus_loss() { } } } + +/// Gives `cell` a list validation of `count` items, opens its dropdown and returns the popup's rectangle. +fn open_list_of(h: &mut Harness, cell: CellRef, count: usize) -> egui::Rect { + let items: Vec = (1..=count).map(|i| format!("Item {i}")).collect(); + let formula1 = format!("\"{}\"", items.join(",")); + h.state_mut().session.execute("data.validation", json!({"range": cell.a1(), "type": "list", "formula1": formula1})).unwrap(); + h.state_mut().run("selection.set", json!({"cell": cell.a1()})).unwrap(); + h.run_steps(2); + let geometry = geo(h); + let sh = h.state().session.active().unwrap().wb.active().unwrap(); + let arrow = grid::validation_arrow(sh, &geometry, cell).unwrap().center(); + click(h, arrow, PointerButton::Primary); + assert!(is_open(h, Menu::Validation)); + h.ctx.memory(|m| m.area_rect(egui::Id::new("dv_list"))).unwrap() +} + +#[test] +fn validation_dropdown_fits_up_to_eight_items_then_scrolls() { + // Each item row is 18 px high; the popup used to collapse to egui's 64 px scroll minimum (about three items). + for (count, min_h, max_h) in [(5, 5.0 * 18.0, 12.0 * 18.0), (12, 8.0 * 18.0, 12.0 * 18.0)] { + let mut h = harness(); + let popup = open_list_of(&mut h, CellRef::parse("E5").unwrap(), count); + assert!(popup.height() >= min_h && popup.height() < max_h, "{count} items: popup is {} px high", popup.height()); + } +} + +#[test] +fn validation_dropdown_opens_above_a_cell_near_the_bottom_of_the_window() { + let mut h = harness(); + let bottom = h.state().grid.rect.unwrap().bottom(); + let geometry = geo(&h); + let sh = h.state().session.active().unwrap().wb.active().unwrap(); + // The last row of column E that is fully visible above the bottom of the grid. + let visible = |row: u32| CellRef::parse(&format!("E{row}")).filter(|&c| geometry.cell_rect(sh, c).bottom() <= bottom); + let cell = (1..200).map_while(visible).last().unwrap(); + let popup = open_list_of(&mut h, cell, 8); + let r = geo(&h).cell_rect(h.state().session.active().unwrap().wb.active().unwrap(), cell); + assert!(popup.bottom() <= r.top() + 0.5, "popup {popup:?} covers the cell {r:?}"); +} diff --git a/crates/ui-egui/tests/selection.rs b/crates/ui-egui/tests/selection.rs index faebebf00..e168f3d25 100644 --- a/crates/ui-egui/tests/selection.rs +++ b/crates/ui-egui/tests/selection.rs @@ -53,6 +53,16 @@ fn press(h: &mut Harness, cell: &str) { frame(h, button(from, true)); } +fn click_with_modifiers(h: &mut Harness, cell: &str, modifiers: Modifiers) { + let pos = point(h, cell); + frame(h, Event::ModifiersChanged(modifiers)); + frame(h, Event::PointerMoved(pos)); + for pressed in [true, false] { + frame(h, Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers }); + } + h.run_steps(2); +} + fn assert_selection(h: &Harness, start: &str, end: &str) { let selection = &h.state().session.active().unwrap().selection; let start = CellRef::parse(start).unwrap(); @@ -134,7 +144,8 @@ fn formula_point_mode_consumes_first_movement_and_release() { h.run_steps(1); let editor = h.state().editor.as_ref().unwrap(); assert_eq!(editor.text, "=C3:F7"); - assert_eq!(editor.point_cell, CellRef::parse("C3")); + assert_eq!(editor.point_cell, CellRef::parse("F7")); + assert_eq!(editor.point_anchor, Some((0, CellRef::parse("C3").unwrap()))); assert_eq!(cells(&h), before); } @@ -159,3 +170,119 @@ fn formula_point_drag_keeps_the_sheet_qualifier() { h.state_mut().session.execute("sheet.activate", json!({"sheet": "Sheet1"})).unwrap(); assert_eq!(cells(&h), before); } + +#[test] +fn formula_modifier_command_click_appends_an_argument() { + let mut h = harness("J10"); + let before = cells(&h); + h.state_mut().begin_edit(Some("=SUM(".into()), false); + h.run_steps(2); + click_with_modifiers(&mut h, "C3", Modifiers::NONE); + let command = Modifiers { mac_cmd: true, command: true, ..Modifiers::NONE }; + click_with_modifiers(&mut h, "F7", command); + assert_eq!(h.state().editor.as_ref().unwrap().text, "=SUM(C3,F7"); + assert_eq!(cells(&h), before); +} + +#[test] +fn formula_modifier_shift_click_extends_the_pointed_range() { + let mut h = harness("J10"); + let before = cells(&h); + h.state_mut().begin_edit(Some("=SUM(".into()), false); + h.run_steps(2); + click_with_modifiers(&mut h, "C3", Modifiers::NONE); + click_with_modifiers(&mut h, "F7", Modifiers::SHIFT); + assert_eq!(h.state().editor.as_ref().unwrap().text, "=SUM(C3:F7"); + assert_eq!(cells(&h), before); +} + +#[test] +fn formula_modifier_command_click_keeps_previous_arguments_replaceable_independently() { + for command in [Modifiers { mac_cmd: true, command: true, ..Modifiers::NONE }, Modifiers { ctrl: true, command: true, ..Modifiers::NONE }] { + let mut h = harness("J10"); + let before = cells(&h); + h.state_mut().begin_edit(Some("=SUM(".into()), false); + h.run_steps(2); + click_with_modifiers(&mut h, "C3", Modifiers::NONE); + click_with_modifiers(&mut h, "D4", command); + click_with_modifiers(&mut h, "F7", command); + assert_eq!(h.state().editor.as_ref().unwrap().text, "=SUM(C3,D4,F7"); + click_with_modifiers(&mut h, "G7", Modifiers::NONE); + assert_eq!(h.state().editor.as_ref().unwrap().text, "=SUM(C3,D4,G7"); + click_with_modifiers(&mut h, "F9", Modifiers::SHIFT); + assert_eq!(h.state().editor.as_ref().unwrap().text, "=SUM(C3,D4,F7:G9"); + assert_eq!(cells(&h), before); + } +} + +#[test] +fn formula_modifier_shift_click_keeps_its_anchor_in_both_directions() { + for (start, first, second, expected) in [("C3", "F7", "B2", "B2:C3"), ("F7", "C3", "H9", "F7:H9")] { + let mut h = harness("J10"); + let before = cells(&h); + h.state_mut().begin_edit(Some("=SUM(".into()), false); + h.run_steps(2); + click_with_modifiers(&mut h, start, Modifiers::NONE); + click_with_modifiers(&mut h, first, Modifiers::SHIFT); + click_with_modifiers(&mut h, second, Modifiers::SHIFT); + assert_eq!(h.state().editor.as_ref().unwrap().text, format!("=SUM({expected}")); + assert_eq!(cells(&h), before); + } +} + +#[test] +fn formula_modifier_command_click_respects_locale_and_a_typed_separator() { + use gridcraft_engine::formula::FormulaLocale; + let command = Modifiers { mac_cmd: true, command: true, ..Modifiers::NONE }; + for (locale, prefix, separator) in [(FormulaLocale::En, "=SUM(", ","), (FormulaLocale::Es, "=SUMA(", ";")] { + let mut h = harness("J10"); + h.state_mut().begin_edit(Some(prefix.into()), false); + h.state_mut().editor.as_mut().unwrap().locale = locale; + h.run_steps(2); + click_with_modifiers(&mut h, "C3", command); + assert_eq!(h.state().editor.as_ref().unwrap().text, format!("{prefix}C3")); + click_with_modifiers(&mut h, "F7", command); + assert_eq!(h.state().editor.as_ref().unwrap().text, format!("{prefix}C3{separator}F7")); + frame(&mut h, Event::ModifiersChanged(Modifiers::NONE)); + frame(&mut h, Event::Text(separator.into())); + h.run_steps(2); + assert!(h.state().editor.as_ref().unwrap().point_anchor.is_none()); + click_with_modifiers(&mut h, "G8", command); + assert_eq!(h.state().editor.as_ref().unwrap().text, format!("{prefix}C3{separator}F7{separator}G8")); + } +} + +#[test] +fn formula_modifier_shift_click_does_not_reuse_an_anchor_from_another_sheet() { + let mut h = harness("J10"); + h.state_mut().session.execute("home.insertSheet", json!({"name": "Other sheet"})).unwrap(); + h.state_mut().session.execute("sheet.activate", json!({"sheet": "Sheet1"})).unwrap(); + h.state_mut().begin_edit(Some("=SUM(".into()), false); + h.run_steps(2); + click_with_modifiers(&mut h, "C3", Modifiers::NONE); + h.state_mut().session.execute("sheet.activate", json!({"sheet": "Other sheet"})).unwrap(); + h.run_steps(2); + click_with_modifiers(&mut h, "F7", Modifiers::SHIFT); + assert_eq!(h.state().editor.as_ref().unwrap().text, "=SUM('Other sheet'!F7"); + click_with_modifiers(&mut h, "D4", Modifiers::SHIFT); + assert_eq!(h.state().editor.as_ref().unwrap().text, "=SUM('Other sheet'!D4:F7"); +} + +#[test] +fn formula_modifier_shift_click_then_arrow_moves_from_the_clicked_endpoint() { + use egui::Key; + for (modifiers, expected) in [(Modifiers::SHIFT, "=SUM(C3:G7"), (Modifiers::NONE, "=SUM(G7")] { + let mut h = harness("J10"); + h.state_mut().begin_edit(Some("=SUM(".into()), false); + h.run_steps(2); + click_with_modifiers(&mut h, "C3", Modifiers::NONE); + click_with_modifiers(&mut h, "F7", Modifiers::SHIFT); + frame(&mut h, Event::ModifiersChanged(modifiers)); + for pressed in [true, false] { + frame(&mut h, Event::Key { key: Key::ArrowRight, physical_key: None, pressed, repeat: false, modifiers }); + } + h.run_steps(2); + assert_eq!(h.state().editor.as_ref().unwrap().text, expected); + assert_eq!(h.state().editor.as_ref().unwrap().point_cell, CellRef::parse("G7")); + } +} diff --git a/crates/ui-egui/tests/selection_scroll.rs b/crates/ui-egui/tests/selection_scroll.rs index 06e0d18ce..719f1a0f2 100644 --- a/crates/ui-egui/tests/selection_scroll.rs +++ b/crates/ui-egui/tests/selection_scroll.rs @@ -1,10 +1,12 @@ //! Extending the selection from the keyboard scrolls its moving end into view, not the anchor. +//! Go To and Select over the control channel scroll the active cell into view. use egui::{Event, Key, Modifiers}; use gridcraft_engine::Session; use gridcraft_engine::core::{CellRef, RangeRef}; +use gridcraft_ui_egui::control::{Outcome, handle}; use gridcraft_ui_egui::{SheetApp, grid::Geo}; -use serde_json::json; +use serde_json::{Value as Json, json}; type Harness = egui_kittest::Harness<'static, SheetApp>; @@ -104,3 +106,39 @@ fn ctrl_shift_home_and_end_scroll_the_moving_end_into_view() { key(&mut h, Key::Home, CTRL_SHIFT); assert_extended(&h, "CV200", "A1"); } + +fn control(h: &mut Harness, method: &str, params: Json) -> Json { + let ctx = h.ctx.clone(); + let out = match handle(h.state_mut(), &ctx, method, ¶ms) { + Outcome::Done(v) => v, + Outcome::Screenshot { .. } => json!(null), + }; + h.run_steps(2); + out +} + +fn active(h: &Harness) -> CellRef { + h.state().session.active().unwrap().selection.active +} + +#[test] +fn go_to_over_the_control_channel_scrolls_the_target_into_view() { + let mut h = harness(); + assert!(!visible(&h, "Z300")); + let r = control(&mut h, "engine.execute", json!({"command": "edit.goTo", "params": {"reference": "Z300"}})); + assert_eq!(r["ok"], json!(true), "{r}"); + assert_eq!(active(&h), CellRef::parse("Z300").unwrap()); + assert!(visible(&h, "Z300"), "Go To scrolls Z300 into view"); + assert!(!visible(&h, "A1")); + // A command named as the method runs the same way. + control(&mut h, "edit.goTo", json!({"reference": "A1"})); + assert!(visible(&h, "A1") && !visible(&h, "Z300")); +} + +#[test] +fn select_over_the_control_channel_scrolls_the_active_cell_into_view() { + let mut h = harness(); + control(&mut h, "engine.execute", json!({"command": "selection.set", "params": {"range": "BZ150:CA160"}})); + assert_eq!(active(&h), CellRef::parse("BZ150").unwrap()); + assert!(visible(&h, "BZ150") && !visible(&h, "A1"), "selecting a far range scrolls its active cell into view"); +} diff --git a/crates/xlsx/src/array_formula.rs b/crates/xlsx/src/array_formula.rs index caa5e8e9f..b2c09a749 100644 --- a/crates/xlsx/src/array_formula.rs +++ b/crates/xlsx/src/array_formula.rs @@ -57,7 +57,7 @@ impl Scan<'_> { None => (sheet, n.as_str()), }; if let Some(def) = self.wb.name(bare, scope) { - return match gridcraft_formula::parse(def.formula.trim_start_matches('=')) { + return match gridcraft_formula::parse_name(def.formula.trim_start_matches('=')) { Ok(expr) => self.expr(&expr, def.scope.unwrap_or(scope), array_context, depth + 1), Err(_) => Shape::array(), }; diff --git a/crates/xlsx/src/chart.rs b/crates/xlsx/src/chart.rs index dee33c83d..aaebadca1 100644 --- a/crates/xlsx/src/chart.rs +++ b/crates/xlsx/src/chart.rs @@ -3,7 +3,7 @@ use std::fmt::Write as _; -use gridcraft_core::Value; +use gridcraft_core::{RangeRef, Value, category_levels}; use gridcraft_formula::{Expr, SheetSel}; use gridcraft_model::{Chart, ChartKind, Color, LegendPos, LineDash, Series, Trendline, TrendlineKind, Workbook}; @@ -151,8 +151,8 @@ fn read_trendline(t: &El) -> Option { Some(Trendline { kind, name: t.child("name").map(|n| n.text.clone()), - forward: f64_val(t, "forward").unwrap_or(0.0).max(0.0), - backward: f64_val(t, "backward").unwrap_or(0.0).max(0.0), + forward: Trendline::clamp_forecast(f64_val(t, "forward").unwrap_or(0.0)), + backward: Trendline::clamp_forecast(f64_val(t, "backward").unwrap_or(0.0)), intercept: f64_val(t, "intercept"), display_equation: bool_val(t, "dispEq"), display_r_squared: bool_val(t, "dispRSqr"), @@ -281,8 +281,8 @@ fn group_for(k: ChartKind) -> G { const MAX_CACHE: u64 = 100_000; -/// Values of a same-workbook reference (`Sheet1!$B$2:$B$9`), row-major. -pub(crate) fn ref_values(wb: &Workbook, sheet_idx: usize, f: &str) -> Option> { +/// The sheet and range a same-workbook reference (`Sheet1!$B$2:$B$9`) names. +fn ref_target(wb: &Workbook, sheet_idx: usize, f: &str) -> Option<(usize, RangeRef)> { let e = gridcraft_formula::parse(f).ok()?; let Expr::Ref(r) = e else { return None }; let si = match &r.sheet { @@ -290,11 +290,21 @@ pub(crate) fn ref_values(wb: &Workbook, sheet_idx: usize, f: &str) -> Option wb.sheet_index(n)?, SheetSel::Span(..) => return None, }; + Some((si, r.range())) +} + +/// Values of a same-workbook reference (`Sheet1!$B$2:$B$9`), row-major. +pub(crate) fn ref_values(wb: &Workbook, sheet_idx: usize, f: &str) -> Option> { + let (si, mut range) = ref_target(wb, sheet_idx, f)?; let sheet = wb.sheet(si)?; - let mut range = r.range(); - // Whole columns/rows: limit to the used range. + // Whole columns/rows: cut the far end at the used range. The start stays put, so point indices keep matching the reference. if let Some(u) = sheet.used_range() { - range = range.intersection(&u)?; + if range.is_full_cols() { + range.end.row = range.end.row.min(u.end.row.max(range.start.row)); + } + if range.is_full_rows() { + range.end.col = range.end.col.min(u.end.col.max(range.start.col)); + } } if range.count() > MAX_CACHE { return None; @@ -397,6 +407,47 @@ fn str_data(tag: &str, f: &str, wb: &Workbook, si: usize) -> String { s } +/// `` of a series: a string reference, or a multi-level one (`multiLvlStrRef`) when the +/// category range has more columns than a column of values (or more rows than a row of values: +/// year beside month). Its cache lists the innermost level first, then each outer level with +/// only the cells that start a group, as Excel writes it. +fn cat_data(f: &str, values: &str, wb: &Workbook, si: usize) -> String { + multi_level_cat(f.trim(), values, wb, si).unwrap_or_else(|| str_data("cat", f, wb, si)) +} + +fn multi_level_cat(f: &str, values: &str, wb: &Workbook, si: usize) -> Option { + let (sheet, range) = ref_target(wb, si, f)?; + let value_range = ref_target(wb, si, values).map(|(_, r)| r); + let levels = category_levels(&range, value_range.as_ref())?; + if range.count() > MAX_CACHE { + return None; + } + let sheet = wb.sheet(sheet)?; + let (w, h) = (range.width() as usize, range.height() as usize); + let text = |row: usize, col: usize| -> String { + let v = range.start.offset(row as i64, col as i64).map(|c| sheet.value(c)); + v.filter(|v| !v.is_empty()).map(|v| v.display()).unwrap_or_default() + }; + // The innermost level (nearest the values), then the outer ones. + let (n, lvls): (usize, Vec>) = if levels.by_columns { + (h, (0..=levels.outer as usize).filter_map(|k| (w - 1).checked_sub(k)).map(|c| (0..h).map(|r| text(r, c)).collect()).collect()) + } else { + (w, (0..=levels.outer as usize).filter_map(|k| (h - 1).checked_sub(k)).map(|r| (0..w).map(|c| text(r, c)).collect()).collect()) + }; + let mut s = format!("{}", esc(&crate::fmla::text_to_file(f))); + for lvl in &lvls { + s.push_str(""); + for (i, v) in lvl.iter().enumerate() { + if !v.is_empty() { + let _ = write!(s, "{}", esc(v)); + } + } + s.push_str(""); + } + s.push_str(""); + Some(s) +} + fn series_xml(s: &Series, idx: usize, g: G, wb: &Workbook, si: usize) -> String { let mut x = format!(""); if let Some(n) = &s.name { @@ -453,7 +504,7 @@ fn series_xml(s: &Series, idx: usize, g: G, wb: &Workbook, si: usize) -> String } _ => { if let Some(c) = &s.categories { - x.push_str(&str_data("cat", c, wb, si)); + x.push_str(&cat_data(c, &s.values, wb, si)); } x.push_str(&num_data("val", Some(&s.values), wb, si)); if matches!(g, G::Line { .. }) { diff --git a/crates/xlsx/src/tests/chart_ref_offsets.rs b/crates/xlsx/src/tests/chart_ref_offsets.rs new file mode 100644 index 000000000..87e6ba9fa --- /dev/null +++ b/crates/xlsx/src/tests/chart_ref_offsets.rs @@ -0,0 +1,25 @@ +//! Chart caches keep the position of every point inside the referenced range. + +use gridcraft_core::{CellRef, Value}; +use gridcraft_model::{Sheet, Workbook}; +use std::sync::Arc; + +use crate::chart::ref_values; + +fn book() -> Workbook { + let mut s = Sheet::new("Data"); + s.set_value(CellRef::new(3, 0), Value::Number(5.0)); + Workbook { sheets: vec![Arc::new(s)], ..Workbook::default() } +} + +#[test] +fn explicit_range_keeps_leading_and_trailing_blanks() { + let vals = ref_values(&book(), 0, "Data!$A$3:$A$6").unwrap(); + assert_eq!(vals, vec![Value::Empty, Value::Number(5.0), Value::Empty, Value::Empty]); +} + +#[test] +fn whole_column_still_stops_at_used_range_and_starts_at_top() { + let vals = ref_values(&book(), 0, "Data!$A:$A").unwrap(); + assert_eq!(vals, vec![Value::Empty, Value::Empty, Value::Empty, Value::Number(5.0)]); +} diff --git a/crates/xlsx/src/tests/mod.rs b/crates/xlsx/src/tests/mod.rs index bdbd66e05..a164aa3fa 100644 --- a/crates/xlsx/src/tests/mod.rs +++ b/crates/xlsx/src/tests/mod.rs @@ -2,12 +2,14 @@ mod array_export; mod chart_bool_default; +mod chart_ref_offsets; mod csv_tests; mod encrypted_tests; mod fixtures; mod ink_roundtrip; mod literal_category_roundtrip; mod malformed; +mod multilevel_category_roundtrip; mod ods_tests; mod passthrough_tests; mod pivot_tests; diff --git a/crates/xlsx/src/tests/multilevel_category_roundtrip.rs b/crates/xlsx/src/tests/multilevel_category_roundtrip.rs new file mode 100644 index 000000000..556a8917a --- /dev/null +++ b/crates/xlsx/src/tests/multilevel_category_roundtrip.rs @@ -0,0 +1,109 @@ +//! A two-column category range (year beside month) is written as a multi-level string reference +//! with Excel's level caches, and read back as the same reference. + +use std::io::Read as _; +use std::sync::Arc; + +use gridcraft_core::{CellRef, Value}; +use gridcraft_model::{Anchor, Chart, ChartKind, LegendPos, Series, Sheet, Workbook}; + +use crate::{read_xlsx, write_xlsx}; + +const YEARS: [&str; 6] = ["2023", "", "", "2024", "", ""]; +const MONTHS: [&str; 6] = ["окт", "ноя", "дек", "янв", "фев", "мар"]; + +fn workbook(categories: &str, values: &str) -> Workbook { + let mut s = Sheet::new("Data"); + for (i, (y, m)) in YEARS.iter().zip(MONTHS).enumerate() { + let r = i as u32 + 1; + if !y.is_empty() { + s.set_value(CellRef::new(r, 0), Value::from(*y)); + } + s.set_value(CellRef::new(r, 1), Value::from(m)); + s.set_value(CellRef::new(r, 2), Value::Number(i as f64 + 1.0)); + } + s.charts.push(Chart { + id: 1, + kind: ChartKind::Line, + anchor: Anchor { cell: CellRef::new(0, 4), width: 480.0, height: 300.0, ..Anchor::default() }, + title: None, + series: vec![Series { + name: None, + categories: Some(categories.into()), + values: values.into(), + bubble_sizes: None, + color: None, + secondary: false, + kind: None, + smooth: false, + trendlines: Vec::new(), + }], + legend: LegendPos::Bottom, + data_labels: false, + gridlines: true, + style: 2, + x_title: None, + y_title: None, + source: None, + by_rows: false, + pivot: None, + }); + Workbook { sheets: vec![Arc::new(s)], ..Workbook::default() } +} + +fn chart_xml(bytes: &[u8]) -> String { + let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes.to_vec())).unwrap(); + let mut s = String::new(); + z.by_name("xl/charts/chart1.xml").unwrap().read_to_string(&mut s).unwrap(); + s +} + +fn cat(xml: &str) -> &str { + let a = xml.find("").expect("a "); + let b = xml.find("").expect("a "); + &xml[a..b] +} + +#[test] +fn two_column_categories_are_written_as_levels_and_read_back() { + let bytes = write_xlsx(&workbook("Data!$A$2:$B$7", "Data!$C$2:$C$7")).unwrap(); + let xml = chart_xml(&bytes); + let c = cat(&xml); + assert!(c.starts_with("Data!$A$2:$B$7"), "{c}"); + let lvls: Vec<&str> = c.split("").skip(1).collect(); + assert_eq!(lvls.len(), 2, "{c}"); + // The innermost level first, every month; then the years, only where a group starts. + assert!(lvls[0].contains("окт") && lvls[0].contains("мар"), "{c}"); + assert!(lvls[1].starts_with("20232024"), "{c}"); + assert!(!c.contains("")); + + let (back, _) = read_xlsx(&bytes).unwrap(); + assert_eq!(back.sheets[0].charts[0].series[0].categories.as_deref(), Some("Data!$A$2:$B$7")); + assert_eq!(cat(&chart_xml(&write_xlsx(&back).unwrap())), c, "stable across saves"); +} + +#[test] +fn a_row_of_values_makes_the_levels_rows() { + let mut wb = workbook("Data!$A$9:$F$10", "Data!$A$11:$F$11"); + let s = Arc::make_mut(&mut wb.sheets[0]); + for (i, (y, m)) in YEARS.iter().zip(MONTHS).enumerate() { + if !y.is_empty() { + s.set_value(CellRef::new(8, i as u32), Value::from(*y)); + } + s.set_value(CellRef::new(9, i as u32), Value::from(m)); + s.set_value(CellRef::new(10, i as u32), Value::Number(i as f64)); + } + let xml = chart_xml(&write_xlsx(&wb).unwrap()); + let c = cat(&xml); + assert!(c.contains("Data!$A$9:$F$10"), "{c}"); + let lvls: Vec<&str> = c.split("").skip(1).collect(); + assert_eq!(lvls.len(), 2); + assert!(lvls[0].contains("окт") && lvls[1].contains("2024"), "{c}"); +} + +#[test] +fn a_single_column_of_categories_stays_a_plain_reference() { + let xml = chart_xml(&write_xlsx(&workbook("Data!$B$2:$B$7", "Data!$C$2:$C$7")).unwrap()); + assert!(xml.contains("Data!$B$2:$B$7"), "{xml}"); + assert!(!xml.contains("multiLvlStrRef")); +} diff --git a/crates/xlsx/src/tests/trendline_roundtrip.rs b/crates/xlsx/src/tests/trendline_roundtrip.rs index d954c9bc6..92a1da18d 100644 --- a/crates/xlsx/src/tests/trendline_roundtrip.rs +++ b/crates/xlsx/src/tests/trendline_roundtrip.rs @@ -169,3 +169,20 @@ fn excel_style_trendlines_are_read() { assert_eq!(t[0].color, Some(Color::Theme(5, 0))); assert_eq!(t[1].kind, TrendlineKind::MovingAverage(2), "period clamps to 2..255"); } + +#[test] +fn a_huge_forecast_in_a_file_is_read_clamped() { + let t = |f: &str, b: &str| { + format!( + r#"Data!$B$2:$B$6"# + ) + }; + let read = |f: &str, b: &str| { + let root = crate::xml::parse(t(f, b).as_bytes()).unwrap(); + crate::chart::read_chart(&root).unwrap().series[0].trendlines[0].clone() + }; + let big = read("1000000", "1e300"); + assert_eq!((big.forward, big.backward), (Trendline::MAX_FORECAST, Trendline::MAX_FORECAST)); + let small = read("2.5", "-3"); + assert_eq!((small.forward, small.backward), (2.5, 0.0), "ordinary values are kept"); +} diff --git a/docs/chart-parity.md b/docs/chart-parity.md index 9058b0935..40f6787a9 100644 --- a/docs/chart-parity.md +++ b/docs/chart-parity.md @@ -1,6 +1,6 @@ # Chart parity -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** trivial (chart sheets are kept on save, still not editable (#727); previously minor: trendlines: model, XLSX round-trip, drawing, Add Chart Element) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-13 · **Change:** minor (multi-level category axes, year beside month, are read, drawn and written back; previously trivial: chart sheets are kept on save, still not editable (#727); previously minor: trendlines: model, XLSX round-trip, drawing, Add Chart Element) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Excel's chart types and chart elements against GridCraft. Part of [target-app-parity.md](target-app-parity.md). **Breadth ~50%, ready ~30%, 40–60 h** (estimated). @@ -41,6 +41,7 @@ open PRs #158, #161, and issue #190. | Data labels | value, category, series, percentage, from cells, callouts, position | one on/off flag | | Gridlines | major/minor per axis, formatting | one on/off flag | | Axis options | min/max/units, log scale, reverse order, crossing point, display units, date axis, number format | **missing** (auto only) | +| Multi-level category axis (a category range two or more columns wide beside the values: year beside month) | the outer levels grouped under the labels with separators | read (`multiLvlStrRef`), drawn (rows under the labels; columns beside them in bar charts; up to three outer levels), written back with Excel's level caches | | Secondary axis | any series | line series in combo charts | | Trendlines | linear, exponential, logarithmic, polynomial, power, moving average, equation and R² | all six types, several per series, name, forward/backward forecast, set intercept, equation and R² label, line colour/width/dash; read, written and drawn on 2-D column, bar, line, area, scatter and bubble series; `chart.addElement` `trendline` adds/removes them. No Format Trendline pane, label position/number format not kept | | Error bars | fixed, %, standard deviation/error, custom | **missing** | @@ -74,12 +75,13 @@ back. 4. Error bars; Format Trendline pane (trendlines landed): 4–6 h. 5. Per-element Format pane parity, smoothed lines, exploded pie, pie/bar of pie, 100% variants: 8–12 h. 6. 3-D column/bar/pie/area, surface and contour, Pareto: 8–12 h. -7. Chart sheets, templates, PivotChart field buttons and multi-level categories: 6–10 h. Filled map: owner decision on boundary data. +7. Chart sheets, templates and PivotChart field buttons: 5–8 h. Filled map: owner decision on boundary data. ## Revision history | Date | Change | Summary | |---|---|---| +| 2026-10-13 | minor | Multi-level category axes: read, drawn (outer levels under or beside the labels), written back with level caches | | 2026-10-12 | trivial | chart sheets are kept on save, still not editable (#727) | | 2026-10-11 | minor | Trendlines: all six types modelled, round-tripped through `c:trendline`, drawn with forecast and equation/R² labels; `chart.addElement` `trendline` | | 2026-10-11 | minor | PivotCharts (`insert.pivotChart`) | diff --git a/docs/cli.md b/docs/cli.md index ec9578d60..ad56f0d56 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -24,7 +24,7 @@ cargo install --path apps/gridcraft-cli # or: cargo run -p gridcraft-cli -- |---|---| | `info [--json]` | `gridcraft-cli info book.xlsx` — sheets, used ranges, cell counts, tables, charts, names. | | `convert [--sheet NAME]` | `gridcraft-cli convert book.xlsx data.csv --sheet Sales` — output format from the extension: `.xlsx`, `.csv`, `.tsv`, `.json`, `.html`. | -| `eval [--in FILE] [--sheet S] [--cell A1] [--json]` | `gridcraft-cli eval '=PMT(5%/12,360,-300000)'`; `gridcraft-cli eval '=SUM(B2:B9)' --in book.xlsx` | +| `eval [--in FILE] [--sheet S] [--cell A1] [--json]` | `gridcraft-cli eval '=PMT(5%/12,360,-300000)'`; `gridcraft-cli eval '=SUM(B2:B9)' --in book.xlsx` — formulas in English with `,` between arguments. A formula that couldn't be entered in a cell (it doesn't parse, or a function gets the wrong number of arguments) is an error saying why; an unknown function is `#NAME?`, as in Excel. | | `cat [--range R] [--sheet S] [--formulas] [--csv]` | `gridcraft-cli cat book.xlsx --range A1:F20` — aligned table of displayed values. | | `check ... [--json] [--tolerance 1e-9] [--max-report N]` | `gridcraft-cli check corpus/` — calc oracle and XLSX round-trip fidelity, see below. | | `run …` | see below | diff --git a/docs/control-protocol.md b/docs/control-protocol.md index e82ae7299..baf8e730c 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -23,7 +23,7 @@ connection; nothing sent after it runs. Blank lines are skipped. | Method | Params | What it does | |---|---|---| -| `engine.execute` | `{command, params}` | Runs any engine command (see `engine.commands` or `gridcraft-cli commands`) | +| `engine.execute` | `{command, params}` | Runs any engine command (see `engine.commands` or `gridcraft-cli commands`). `edit.goTo`, `edit.goToSpecial`, `selection.set` and `selection.move` scroll the active cell into view, as Go To does in the window | | `engine.commands` | `{search?}` | Every command: id, label, ribbon path, shortcut, params doc, enabled; `search` keeps those whose id, label or ribbon path contains it (case-insensitive) | | `engine.journal` | | Commands run so far (replayable). Passwords (`password`, `…Password` params) are left out, so a replayed Protect Sheet has no password | | `document.inspect` | | Workbook summary: sheets, used ranges, tables, charts, names, selection, undo labels | diff --git a/docs/gaps.md b/docs/gaps.md index 03e8672c5..65287115a 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -46,7 +46,7 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha- - **Missing:** kinds that survive XLSX (stock, combo, histogram, box, waterfall, funnel, treemap, sunburst), axis scaling, label options, error bars, per-point formatting. - **Evidence:** `Chart` holds only title, legend position, two flags and axis titles - (`crates/model/src/features.rs`); PRs #158, #161 open; charts can't be resized (#190). Smooth lines (#62), Switch Row/Column (#116) and trendlines (read, written, drawn) landed. + (`crates/model/src/features.rs`); PRs #158, #161 open; charts can't be resized (#190). Smooth lines (#62), Switch Row/Column (#116) and trendlines (read, written, drawn) landed. Multi-level category axes (year beside month) are read, drawn and written back. - **Impact:** charts are in most business workbooks. - **Estimate:** 25–35 h for beta (40–60 h full). [chart-parity.md](chart-parity.md) @@ -168,6 +168,7 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha- | Date | Change | Summary | |---|---|---| +| 2026-10-13 | trivial | §4: multi-level category axes landed | | 2026-10-12 | minor | rows auto-fit oversized fonts on open and whole-row/column formatting (#833) | | 2026-10-12 | minor | Page Layout view: dragging margins on the rulers (#792) removed from the §14 remainder | | 2026-10-12 | minor | §13: Forecast Sheet Forecast Start (hindcast) landed (#785) | diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 64876633d..9109311f9 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -60,8 +60,9 @@ separators, number/date formats, machine translation with agent review, and scri | German | de | 266 (~12%) | no | Latin: yes | no | menus only | 4–8 h | | Korean | ko | 266 (~12%) | no | Hangul via the CJK fallback; IME unverified | no | menus only | 6–10 h | | Vietnamese | vi | 0 (0%) | no | Latin with stacked diacritics: unverified | no | none | 6–10 h | +| Turkish | tr | 382 (~17%) | no | Latin: yes | yes | menus only | 4–8 h | -Other shipped languages: Russian (ru) and German (de), 266 labels each (~12%), menus only. +Other shipped languages: Russian (ru), German (de), Czech (cs), French (fr) and Turkish (tr), menus only. **Total:** catalog work 10–20 h + languages 92–144 h ≈ **100–165 h**. Parallelizes almost fully once the catalog covers dialogs and commands. **Needs a human:** native-speaker review of every @@ -89,6 +90,7 @@ language (none so far) and of localized function names. | 2026-10-19 | trivial | Text boxes and shapes keep right-to-left paragraph direction (`a:pPr rtl`) on XLSX save, so Excel-authored Arabic and Hebrew text boxes reopen right to left | | 2026-10-19 | minor | Save as Web Page and copied HTML mark right-to-left sheets `dir="rtl"`, let General-aligned Arabic and Hebrew text align by its own direction (`dir="auto"`), and keep numbers on right-to-left sheets left to right and on the left | | 2026-10-19 | minor | French and Serbian kept the context in three `context\|label` translations ("Options\|Général", "Opcije\|Opšte" for `Options\|General`; also `Options\|Save`, `Sheet Options\|View`). `tr` shows a found translation as it is, so File › Options headings and the Sheet Options "View" checkboxes showed the prefix. Fixed; a test checks that no catalog shows a `\|` | +| 2026-10-12 | minor | Turkish ribbon catalog (`i18n/tr.rs`, 382 labels); `tr` locale tags (tr-TR, tr_TR…) resolve to Turkish | | 2026-10-12 | minor | Format Cells looks up its tabs, labels, options and note through `Language::tr` (Fill tab keyed `Format Cells\|Fill`); dialog titles and OK/Cancel too. Labels already in a table (Number, Currency, Fill…) show translated; the rest fall back to English until the tables add them | | 2026-10-12 | minor | Brazilian Portuguese catalog (`i18n/pt.rs`) grows from 377 to 923 entries: every key the other catalogs translate, including the dialog strings #669 starts looking up (~42%) | | 2026-10-11 | minor | German ribbon catalog (`i18n/de.rs`, 266 labels, ~12%); `de` locale tags (de-DE, de-AT, de-CH…) resolve to German. PR #163 (localized German formulas/numbers) still open | diff --git a/docs/mcp.md b/docs/mcp.md index 3e6e7bc11..1877cf228 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -45,7 +45,7 @@ with a file open. | `read_range` | `range?`, `sheet?`, `formulas?`, `formatted?` | Rows of values (default: the used range). | | `write_range` | `range`, `values` (2-D) | `range.setValues`; strings starting with `=` are formulas. | | `set_cell` / `get_cell` | `cell`, `input` / `cell` | Enter one cell / read value, formula, display text, style. | -| `evaluate_formula` | `formula`, `cell?` | Evaluates without changing the sheet. | +| `evaluate_formula` | `formula`, `cell?` | Evaluates without changing the sheet. A formula that couldn't be entered in a cell (it doesn't parse, or a function gets the wrong number of arguments) is an error saying why. | | `select` | `range`, `active?` | Sets the selection. | | `format_range` | `range`, `bold?`, `italic?`, `underline?`, `fontName?`, `fontSize?`, `fontColor?`, `fillColor?`, `numberFormat?`, `horizontalAlign?`, `wrapText?`, `borders?` | Composes the `home.*` formatting commands. | | `insert_chart` | `range`, `type`, `title?`, `subtype?`, `at?` | `insert.chart`. | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 34cb502f5..929173e75 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-19 · **Change:** minor (Ctrl+Enter fill gives each cell the automatic number format typing gives it; keytip letters typed with Alt held, #1846) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-19 · **Change:** minor (Ctrl+Enter fill gives each cell the automatic number format typing gives it; keytip letters typed with Alt held, #1846; Ctrl/Option-drag a sheet tab drops a copy, #971; formula-entry Command/Ctrl-click adds arguments and Shift-click extends a range; More Colors… and Recent Colors, #928; stable grid drag cursors, #936) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Interaction, controls and feel, against Excel. Part of [target-app-parity.md](target-app-parity.md). **UI/UX fidelity: ~55% ready, 50–80 h** (estimated). Ribbon command coverage is measured @@ -15,13 +15,13 @@ Sources: `crates/ui-egui/src` (ribbon, grid, editor, formula bar, dialogs, panes | Element | Excel | GridCraft | Status | |---|---|---|---| | Title bar, Quick Access Toolbar, AutoSave toggle | yes | yes | done; macOS window controls misaligned (#170), Dock icon size (#169) | -| Ribbon: tabs, groups, split buttons, galleries, contextual tabs | yes | yes (Table Design, Chart Design, Format, PivotTable, Header & Footer tabs) | done; missing contextual tabs for pictures, shapes, sparklines, slicers | +| Ribbon: tabs, groups, split buttons, galleries, contextual tabs | yes | yes (Table Design, Chart Design, Format, PivotTable, Header & Footer tabs); Fill Color, Font Color and the Format Cells colour pickers end with Recent Colors and More Colors… (#928) | done; missing contextual tabs for pictures, shapes, sparklines, slicers | | Ribbon collapse, simplified ribbon, customize ribbon/QAT | yes | partial | customization missing (File ▸ Options is the one missing File command) | | Ribbon: tabs, groups, split buttons, galleries, contextual tabs | yes | yes (Table Design, Chart Design, Format, PivotTable tabs) | done; missing contextual tabs for pictures, shapes, sparklines, header/footer, slicers | | Ribbon collapse, simplified ribbon, customize ribbon/QAT | yes | partial | customization missing | | Key tips (Alt / F10 then letters) | yes, every ribbon control | **initial** (#43, 10-10): Alt or F10 shows key tips on Home for alignment, Paste Special and AutoFit plus legacy Alt sequences; other tabs and controls not yet (#33, #49) | 3–5 h | | Formula bar with Name Box, expand, function hints | yes | yes | done | -| Sheet tabs: rename, reorder, colour, hide, scroll | yes | yes | active tab revealed on navigation and resize (#681); manual wheel/trackpad browsing preserved; rename focus bug on macOS (#145) | +| Sheet tabs: rename, reorder, colour, hide, scroll | yes | yes | Ctrl-drag (Cmd or Option on macOS) drops a copy (#971); active tab revealed on navigation and resize (#681); manual wheel/trackpad browsing preserved; rename focus bug on macOS (#145) | | Status bar: Sum/Count/Average and more, view buttons, zoom | yes | yes | customizable statistics partial | | Task panes (Format Chart, Comments, Watch, Selection, PivotTable fields) | yes | yes | element-level Format pane for shapes and pictures missing | | Backstage (File) view: Info, New with templates, Open recent, Export, Options | yes | partial | Options dialog and template gallery missing | @@ -44,11 +44,12 @@ this logic, with one undo step. | Behaviour | Status | |---|---| | Click/drag/Shift/Ctrl selection, multi-area selection | rectangular drag works in one direction only in some cases (#28) | +| Cursor while dragging cells, headers, freeze handles and objects | stable for the active drag (#936); Draw tools keep their crosshair; header arrows and a custom cell cursor remain unimplemented | | Selection outline glides to a new selection (Excel: "Provide feedback with animation") | done (10-11): 0.12 s, decelerating, with the fill and fill handle; View ▸ Animations (`view.animations`) turns it off. The timing is an estimate, not yet measured against Excel; the system's reduced-motion setting isn't followed yet | | Fill handle: drag to fill series, double-click to fill down, right-drag menu | fill done (Ctrl+drag swaps copy and series); right-drag menu missing | | Drag-move and drag-copy cells (border drag, Ctrl/Option, Shift to insert) | done (M3) | | In-cell editing, Enter/Tab movement, Alt/Option+Enter line breaks | done | -| Point mode (click/drag/arrow references), reference colouring, F4 | done | +| Point mode (click/drag/arrow references), reference colouring, F4 | done; Command/Ctrl-click adds separate arguments with the edit's locale separator, and Shift-click extends the current reference from a fixed anchor on the same sheet. Pointer-event regressions cover repeated/reversed selections, manual separators, sheet switches and keyboard continuation. | | AutoComplete in columns, function autocomplete and argument tips | done; cell helpers clip to the worksheet and hide when the editor scrolls out (#200) | | Header resize and double-click AutoFit | done (#42, #88 for header dividers) | | Outline symbols for grouped rows / columns (band beside the headers, brackets, − / +, level buttons 1…n+1) | done (#373): clicks run `data.hideDetail` / `data.showDetail` / `data.showLevels`, frozen panes and right-to-left sheets included; summary rows/columns are always below/right (the "Summary rows below detail" setting isn't modelled), no "Show outline symbols" option yet, and a block of cells still groups rows without the Rows / Columns dialog | @@ -161,10 +162,14 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | Date | Change | Summary | |---|---|---| +| 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) | | 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-18 | minor | Keytip letters typed with left Alt held continue the sequence (Alt+H, O, A / Alt+H, O, I ran nothing before: Alt+letter cancelled keytips); other Alt combinations, AltGr and macOS Option are unchanged (#1846) | +| 2026-10-13 | trivial | More Colors… and Recent Colors (#928) | +| 2026-10-13 | minor | Grid drags keep their own cursor over selection borders, fill handles and outside the grid; hover resumes on release (#936) | | 2026-10-12 | minor | The File view's recent list tells when each workbook was last changed, read off the UI thread | | 2026-10-13 | minor | Horizontal scrollbar in the sheet tab bar: drag, track click pages, frozen columns excluded from the page (#840) | +| 2026-10-13 | minor | Formula-entry Command/Ctrl-click adds arguments and Shift-click extends references; ordinary replacement and dragging retain their behavior. Readiness estimates unchanged. | | 2026-10-12 | minor | Ctrl+wheel zoom steps along a ladder of percentages about 20% apart that never skips 100%, gliding to each step with animations on, so a notch out undoes a notch in (it drifted: 100 → 122 → 101); Excel steps 15 points, see decisions.md | | 2026-10-12 | minor | Page Layout view: left/right/top/bottom margins are dragged on the rulers (#792) | | 2026-10-12 | minor | Alt+Down opens the active filter header's existing popup, with validation priority and mirrored anchoring on right-to-left sheets (part of #441) | diff --git a/xtask/src/keytips.rs b/xtask/src/keytips.rs index 36847222f..54c332c11 100644 --- a/xtask/src/keytips.rs +++ b/xtask/src/keytips.rs @@ -10,7 +10,8 @@ use std::fs; use std::path::Path; -const TABS: &[&str] = &["Home", "Insert", "Draw", "Page Layout", "Formulas", "Data", "Review", "View", "Automate"]; +/// "File" is not a ribbon tab: its sequence opens the Backstage view. +const TABS: &[&str] = &["File", "Home", "Insert", "Draw", "Page Layout", "Formulas", "Data", "Review", "View", "Automate"]; /// Commands whose empty-params behaviour depends on the host machine (e.g. the /// system dictionary). These are never probed; their tests assert "invoked @@ -260,6 +261,10 @@ fn render_tests(entries: &[Entry], probe: &Probe) -> String { let mut out = String::from(HEADER); for e in entries { let body = match e.kind.as_str() { + "tab" if e.target == "File" => format!( + "sequence(&mut h, {seq:?}, false);\n assert!(h.state().backstage.is_some(), \"{seq} must open the File view\");\n assert_eq!(h.state().keytips.prefix(), Some({seq:?}));", + seq = e.seq, + ), "tab" => format!( "sequence(&mut h, {seq:?}, false);\n assert_eq!(h.state().ui.ribbon_tab, {tab:?}, \"{seq} must switch to the {tab} tab\");", seq = e.seq,