diff --git a/ROADMAP.md b/ROADMAP.md index aa9784031..172a920d2 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-13 · **Change:** minor (formula-entry Command/Ctrl-click adds arguments and Shift-click extends references; readiness unchanged) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-14 · **Change:** minor (XLSX dynamic-array formulas keep their `cm` metadata on save) · **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,9 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | What landed | |---|---| +| 2026-10-15 | Function help follows the interface language: argument hints, autocomplete, Insert Function (search and readable categories) and the Formulas-tab menus read per-language tables of descriptions and argument names; Spanish keeps its 10 functions in the new format | +| 2026-10-14 | Protect Sheet on a protected sheet: Home › Format, its keytips (HOP, RPS) and the tab menu now read Unprotect Sheet… and unprotect (straight away without a password); a protected sheet that forbids selecting locked (or unlocked) cells is enforced for clicks, arrows and Go To, and the Protect Sheet dialog has the select options (#1346) | +| 2026-10-14 | XLSX: formulas opened with dynamic-array metadata (`cm`) keep it on save, with `xl/metadata.xml`. Before, a formula returning one value lost it (11 of 40 corpus files that have the part lost it entirely), and Excel recalculated some of them with implicit intersection (`SUMPRODUCT(IFERROR(...))` changed value) | | 2026-10-13 | Formula-entry Command/Ctrl-click inserts another reference with the locale's argument separator; Shift-click extends from a stable, sheet-aware anchor. Pointer tests cover repeated clicks, reversed ranges, ordinary replacement, typed separators, Spanish syntax, sheet switches and keyboard continuation. Readiness estimates unchanged. | | 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) | @@ -198,6 +201,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | XLSX dynamic-array `cm` metadata kept on save; progress log row | | 2026-10-13 | trivial | Progress log: More Colors… and Recent Colors in the colour menus (#928) | | 2026-10-13 | minor | Multi-level category axes (read, drawn, written); progress log row | | 2026-10-12 | minor | Rows auto-fit oversized fonts on open and whole-row/column formatting (#833); progress log row | diff --git a/crates/calc/src/eval.rs b/crates/calc/src/eval.rs index ba9319f58..523aa7e0f 100644 --- a/crates/calc/src/eval.rs +++ b/crates/calc/src/eval.rs @@ -262,7 +262,7 @@ impl<'h> Evaluator<'h> { let wb = self.host.workbook(); // Sheet-qualified `Sheet1!Name`. let (scope_sheet, bare) = match n.split_once('!') { - Some((s, b)) => match wb.sheet_index(s.trim_matches('\'')) { + Some((s, b)) => match wb.sheet_index(&gridcraft_core::unquote_sheet(s)) { Some(i) => (i, b), None => return err(CellError::Name), }, diff --git a/crates/chart/src/resolve.rs b/crates/chart/src/resolve.rs index c7a8f0b9c..6eee201ae 100644 --- a/crates/chart/src/resolve.rs +++ b/crates/chart/src/resolve.rs @@ -41,7 +41,7 @@ fn parse_ref(wb: &Workbook, sheet: usize, formula: &str) -> Option<(usize, Range let (sh, addr) = match f.rfind('!') { Some(i) => { let name = f.get(..i)?.trim(); - let name = name.strip_prefix('\'').and_then(|n| n.strip_suffix('\'')).map(|n| n.replace("''", "'")).unwrap_or_else(|| name.to_string()); + let name = gridcraft_core::unquote_sheet(name); (wb.sheet_index(&name)?, f.get(i + 1..)?) } None => (sheet, f), @@ -176,13 +176,23 @@ fn category_table(wb: &Workbook, sheet: usize, formula: &str, values: &str) -> ( } } -/// A series name: a reference/formula is evaluated, a quoted or bare literal is used as is. +/// Whether `t` names a defined name seen from `sheet` (`Name`, or `Sheet1!Name` looked up on +/// that sheet), the way the evaluator looks names up. +fn is_defined_name(wb: &Workbook, sheet: usize, t: &str) -> bool { + match t.split_once('!') { + Some((s, bare)) => wb.sheet_index(s.trim_matches('\'')).is_some_and(|i| wb.name(bare, i).is_some()), + None => wb.name(t, sheet).is_some(), + } +} + +/// A series name: a reference/formula or a defined name (`[0]!Title` in a file) is evaluated, +/// a quoted or other bare literal is used as is. fn series_name(wb: &Workbook, sheet: usize, name: &str) -> String { let t = name.trim(); if let Some(q) = t.strip_prefix('"').and_then(|q| q.strip_suffix('"')) { return q.replace("\"\"", "\""); } - if t.starts_with('=') || parse_ref(wb, sheet, t).is_some() && t.contains('!') { + if t.starts_with('=') || parse_ref(wb, sheet, t).is_some() && t.contains('!') || is_defined_name(wb, sheet, t) { let vals = eval_list(wb, sheet, t); let parts: Vec = vals.iter().map(value_text).filter(|s| !s.is_empty()).take(8).collect(); return parts.join(" "); diff --git a/crates/core/src/addr.rs b/crates/core/src/addr.rs index 8e53b5971..45812b0b6 100644 --- a/crates/core/src/addr.rs +++ b/crates/core/src/addr.rs @@ -237,6 +237,59 @@ impl fmt::Display for RangeRef { } } +/// Whether `name` reads as an R1C1 reference: `R`, `C`, `RC`, `R1`, `C1`, `R1C`, `RC1`, `R1C1` +/// in any case. +fn is_r1c1_token(name: &str) -> bool { + let mut chars = name.chars().peekable(); + let mut letters = 0; + for letter in ['r', 'c'] { + if chars.peek().is_some_and(|c| c.to_ascii_lowercase() == letter) { + chars.next(); + letters += 1; + while chars.peek().is_some_and(|c| c.is_ascii_digit()) { + chars.next(); + } + } + } + letters > 0 && chars.next().is_none() +} + +/// Whether Excel quotes a sheet name in a reference (`'My Sheet'!A1`): it starts with a digit or +/// `.`, holds anything but letters (any script), digits, `_` and `.`, reads as an A1 cell within +/// the sheet or as an R1C1 reference, or is a boolean. +pub fn sheet_needs_quotes(name: &str) -> bool { + let Some(first) = name.chars().next() else { + return true; + }; + if first.is_ascii_digit() || first == '.' || name.chars().any(|c| !(c.is_alphanumeric() || c == '_' || c == '.')) { + return true; + } + CellRef::parse(name).is_some() || is_r1c1_token(name) || name.eq_ignore_ascii_case("TRUE") || name.eq_ignore_ascii_case("FALSE") +} + +/// A sheet name as it is written in a reference: quoted, with `'` doubled, when Excel quotes it. +pub fn quote_sheet(name: &str) -> String { + if sheet_needs_quotes(name) { format!("'{}'", name.replace('\'', "''")) } else { name.to_string() } +} + +/// The inverse of [`quote_sheet`]: takes the quotes off a sheet name and reads `''` as `'`. +/// Text that isn't quoted is returned as it is. +pub fn unquote_sheet(text: &str) -> String { + match text.trim().strip_prefix('\'').and_then(|t| t.strip_suffix('\'')) { + Some(inner) => inner.replace("''", "'"), + None => text.trim().to_string(), + } +} + +/// `'My Sheet'!A1` → (`Some("My Sheet")`, `A1`). Splits at the last `!`, so a quoted name may +/// hold one (`'a!b'!A1`). +pub fn split_sheet(reference: &str) -> (Option, &str) { + match reference.rsplit_once('!') { + Some((sheet, body)) => (Some(unquote_sheet(sheet)), body), + None => (None, reference), + } +} + #[cfg(test)] mod tests { use super::*; @@ -253,6 +306,50 @@ mod tests { assert_eq!((r.start.row, r.end.row), (0, 2)); } + #[test] + fn sheet_quoting_follows_excel() { + for quoted in [ + "A1", + "XFD1", + "AB1048576", + "R1", + "C1", + "RC1", + "R", + "C", + "RC", + "R1C", + "R1C1", + "r2c3", + "CR1", + ".x", + "TRUE", + "false", + "1abc", + "My Sheet", + "Bob's", + "a-b", + "", + ] { + assert!(sheet_needs_quotes(quoted), "{quoted:?} should be quoted"); + } + for bare in ["Sheet1", "XFE1", "AB1048577", "Ärger", "日本", "Σ", "Sheet.1", "_x", "A1A", "Data", "RR", "CRX"] { + assert!(!sheet_needs_quotes(bare), "{bare:?} should stay bare"); + } + } + + #[test] + fn quote_unquote_and_split_sheet() { + assert_eq!(quote_sheet("Bob's"), "'Bob''s'"); + assert_eq!(quote_sheet("Data"), "Data"); + assert_eq!(unquote_sheet("'Bob''s'"), "Bob's"); + assert_eq!(unquote_sheet("Data"), "Data"); + assert_eq!(unquote_sheet("'"), "'"); + assert_eq!(split_sheet("'a!b'!A1"), (Some("a!b".to_string()), "A1")); + assert_eq!(split_sheet("'Bob''s'!$A$1:$B$2"), (Some("Bob's".to_string()), "$A$1:$B$2")); + assert_eq!(split_sheet("A1"), (None, "A1")); + } + #[test] fn letters_roundtrip() { for (c, s) in [(0, "A"), (25, "Z"), (26, "AA"), (51, "AZ"), (52, "BA"), (701, "ZZ"), (702, "AAA"), (16383, "XFD")] { diff --git a/crates/core/src/color.rs b/crates/core/src/color.rs new file mode 100644 index 000000000..f2ea01fc7 --- /dev/null +++ b/crates/core/src/color.rs @@ -0,0 +1,20 @@ +//! Shared legacy indexed colours used by XLSX and number-format rendering. + +/// The 64-entry legacy indexed colour palette, stored as `RRGGBB`. +pub const INDEXED: [u32; 64] = [ + 0x000000, 0xFFFFFF, 0xFF0000, 0x00FF00, 0x0000FF, 0xFFFF00, 0xFF00FF, 0x00FFFF, 0x000000, 0xFFFFFF, 0xFF0000, 0x00FF00, 0x0000FF, 0xFFFF00, + 0xFF00FF, 0x00FFFF, 0x800000, 0x008000, 0x000080, 0x808000, 0x800080, 0x008080, 0xC0C0C0, 0x808080, 0x9999FF, 0x993366, 0xFFFFCC, 0xCCFFFF, + 0x660066, 0xFF8080, 0x0066CC, 0xCCCCFF, 0x000080, 0xFF00FF, 0xFFFF00, 0x00FFFF, 0x800080, 0x800000, 0x008080, 0x0000FF, 0x00CCFF, 0xCCFFFF, + 0xCCFFCC, 0xFFFF99, 0x99CCFF, 0xFF99CC, 0xCC99FF, 0xFFCC99, 0x3366FF, 0x33CCCC, 0x99CC00, 0xFFCC00, 0xFF9900, 0xFF6600, 0x666699, 0x969696, + 0x003366, 0x339966, 0x003300, 0x333300, 0x993300, 0x993366, 0x333399, 0x333333, +]; + +/// Resolve an Excel number-format color index (`Color1` through `Color56`). +pub fn indexed_color(index: u8) -> Option<[u8; 3]> { + if !(1..=56).contains(&index) { + return None; + } + let slot = index.checked_add(7)?; + let rgb = *INDEXED.get(slot as usize)?; + Some([(rgb >> 16) as u8, (rgb >> 8) as u8, rgb as u8]) +} diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 9e04e8b27..6c4441304 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -4,10 +4,13 @@ #![forbid(unsafe_code)] pub mod addr; +pub mod color; pub mod date; pub mod parse; pub mod value; -pub use addr::{CategoryLevels, CellRef, MAX_COLS, MAX_ROWS, RangeRef, category_levels, col_to_letters, letters_to_col}; +pub use addr::{ + CategoryLevels, CellRef, MAX_COLS, MAX_ROWS, RangeRef, category_levels, col_to_letters, letters_to_col, quote_sheet, sheet_needs_quotes, split_sheet, unquote_sheet, +}; 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/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 2afb9f660..9e1f35754 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -79,7 +79,7 @@ pub fn specs() -> Vec { "AutoFill", [], None, - "{source: \"A1:A2\", target: \"A1:A10\", mode?: series|alternate|copy|formats|values}: the fill-handle drag (alternate = Ctrl+drag: a lone number counts up, any other series copies)", + "{source: \"A1:A2\", target: \"A1:A10\", mode?: series|alternate|copy|formats|values|extend}: the fill-handle drag (alternate = Ctrl+drag: a lone number counts up, any other series copies; values = Fill Without Formatting: the series, targets keep their formats; extend = Fill Series: the series, and a lone number counts up)", has_doc, auto_fill ), @@ -377,6 +377,12 @@ fn range_fill(s: &mut Session, p: &Json) -> Result { // ---------------------------------------------------------------- selection +/// Whether the cell may be selected: a protected sheet can forbid selecting locked and/or unlocked cells. +fn cell_selectable(wb: &gridcraft_model::Workbook, sheet: &gridcraft_model::Sheet, c: CellRef) -> bool { + let Some(p) = sheet.protection.as_ref() else { return true }; + if wb.styles.get(sheet.style_id(c)).protection.locked { p.select_locked } else { p.select_unlocked } +} + fn selection_set(s: &mut Session, p: &Json) -> Result { if p.get("sheet").is_some() { let i = target_sheet(s, p)?; @@ -394,6 +400,9 @@ fn selection_set(s: &mut Session, p: &Json) -> Result { let mut sel = crate::Selection { active, anchor: ranges.last().map(|r| r.start).unwrap_or(active), ranges }; if let Some(sh) = d.wb.active() { sel.expand_merges(sh); + if !cell_selectable(&d.wb, sh, sel.active) { + return Err(EngineError::Other("The cell you are trying to change is on a protected sheet that does not allow selecting it.".into())); + } } d.selection = sel; Ok(json!({"selection": d.selection.a1(), "active": d.selection.active.a1()})) @@ -462,6 +471,29 @@ fn selection_move(s: &mut Session, p: &Json) -> Result { { to = m.start; } + if !extend && !cell_selectable(&d.wb, sheet, to) { + // Step on to the next selectable cell in the same direction; stay put if there is none. + let (sr, sc) = (dr.signum(), dc.signum()); + let mut t = to; + let mut found = false; + if !jmp && (sr, sc) != (0, 0) { + for _ in 0..10_000 { + let n = t.offset_clamped(sr, sc); + if n == t { + break; + } + t = n; + if !sheet.is_row_hidden(t.row) && !sheet.is_col_hidden(t.col) && cell_selectable(&d.wb, sheet, t) { + found = true; + break; + } + } + } + if !found { + return Ok(json!({"selection": sel.a1(), "active": sel.active.a1()})); + } + to = t; + } if extend { sel.extend_to(to); sel.expand_merges(sheet); @@ -529,7 +561,7 @@ pub(super) fn go_to(s: &mut Session, p: &Json) -> Result { } else if let Some((si, ti)) = d.wb.table(reference) { // As in Excel, a table's name stands for its data, without the header and totals rows. let t = d.wb.sheet(si).and_then(|sh| sh.tables.get(ti)).map(|t| t.data_range().unwrap_or(t.range).a1()).unwrap_or_default(); - format!("'{}'!{}", d.wb.sheet(si).map(|s| s.name.clone()).unwrap_or_default(), t) + format!("{}!{}", gridcraft_core::quote_sheet(&d.wb.sheet(si).map(|s| s.name.clone()).unwrap_or_default()), t) } else { return Err(EngineError::Other("Reference isn't valid.".into())); } @@ -1015,11 +1047,12 @@ fn paste_special(s: &mut Session, p: &Json) -> Result { // The array range moves with its formula. let (dr, dc) = (dest.row as i64 - sc.row as i64, dest.col as i64 - sc.col as i64); moved.array = f.array.and_then(|a| Some(RangeRef::new(a.start.offset(dr, dc)?, a.end.offset(dr, dc)?))); + moved.dynamic = f.dynamic; return Some(std::sync::Arc::new(moved)); } let moved = gridcraft_formula::adjust::shift_relative(e, dest.row as i64 - sc.row as i64, dest.col as i64 - sc.col as i64); - Some(std::sync::Arc::new(Formula::from_expr(moved))) + Some(std::sync::Arc::new(Formula { dynamic: f.dynamic, ..Formula::from_expr(moved) })) }; if link { let sheet_prefix = if dest_sheet == clip.sheet && same_doc { @@ -1221,6 +1254,7 @@ pub(crate) fn rewrite_all_formulas(wb: &mut gridcraft_model::Workbook, target: & { let mut nf = Formula::from_expr(new); nf.array = array; + nf.dynamic = f.dynamic; cell.formula = Some(std::sync::Arc::new(nf)); } } @@ -1373,6 +1407,7 @@ fn auto_fill(s: &mut Session, p: &Json) -> Result { "alternate" => crate::fill::FillMode::Alternate, "formats" => crate::fill::FillMode::Formats, "values" => crate::fill::FillMode::ValuesOnly, + "extend" => crate::fill::FillMode::Extend, _ => crate::fill::FillMode::Series, }; if target.count() > 5_000_000 || src.count() > 5_000_000 { @@ -1590,6 +1625,63 @@ fn format_painter(s: &mut Session, p: &Json) -> Result { let Some(src_sheet) = wb.sheet(sheet) else { return Ok(()) }; let (h, w) = (src.height(), src.width()); let dest = if dest.is_single() { RangeRef::new(dest.start, dest.start.offset_clamped(h as i64 - 1, w as i64 - 1)) } else { dest }; + // Whole rows or columns take a line format, as the ribbon's formatting commands give + // them, instead of a style on each of their cells (a column alone has a million). The + // line format comes from the first cell of the source pattern across the line; cells + // already stored follow the pattern. Row heights and column widths stay as they are. + let rows = dest.is_full_rows() && !dest.is_full_cols(); + let cols = dest.is_full_cols() && !dest.is_full_rows(); + let src_full = (rows && src.is_full_rows()) || (cols && src.is_full_cols()); + if rows || cols { + let (first, src_first, n) = if rows { (dest.start.row, src.start.row, h) } else { (dest.start.col, src.start.col, w) }; + let lines = if rows { dest.start.row..=dest.end.row } else { dest.start.col..=dest.end.col }; + for line in lines { + let sl = src_first + (line - first) % n; + // The source cell for position `k` along the line. + let at = |k: u32| if rows { CellRef::new(sl, src.start.col + k % w) } else { CellRef::new(src.start.row + k % h, sl) }; + let line_style = if src_full { + if rows { src_sheet.rows.get(&sl) } else { src_sheet.cols.get(&sl) }.and_then(|i| i.style) + } else { + Some(src_sheet.style_id(at(0))) + }; + let line_style = line_style.map(|st| if same { st } else { cx.wb.styles.intern(wb.styles.get(st).clone()) }); + let (dest_line, src_line) = if rows { + (RangeRef::rows(line, line), RangeRef::rows(sl, sl)) + } else { + (RangeRef::cols(line, line), RangeRef::cols(sl, sl)) + }; + let mut cells: Vec = + cx.wb.sheet(dest_sheet).map(|sh| sh.cells.iter_range(dest_line).map(|(c, _)| c).collect()).unwrap_or_default(); + if src_full { + cells.extend( + src_sheet + .cells + .iter_range(src_line) + .map(|(c, _)| if rows { CellRef::new(line, c.col) } else { CellRef::new(c.row, line) }), + ); + } + if cols { + // A row format outranks a column format: the column's cells in formatted + // rows need a style of their own. + cells.extend( + cx.wb + .sheet(dest_sheet) + .into_iter() + .flat_map(|sh| sh.rows.iter().filter(|(_, i)| i.style.is_some()).map(|(r, _)| CellRef::new(*r, line))), + ); + } + let sh = cx.sheet_mut(dest_sheet)?; + (if rows { &mut sh.rows } else { &mut sh.cols }).entry(line).or_default().style = line_style; + for c in cells { + let st = src_sheet.style_id(at(if rows { c.col } else { c.row })); + let st = if same { st } else { cx.wb.styles.intern(wb.styles.get(st).clone()) }; + cx.sheet_mut(dest_sheet)?.set_style(c, st); + cx.touch(dest_sheet, c); + } + } + *cx.sel = crate::Selection::range(dest); + return Ok(()); + } for c in dest.iter().take(2_000_000) { let sc = CellRef::new(src.start.row + (c.row - dest.start.row) % h, src.start.col + (c.col - dest.start.col) % w); let st = src_sheet.style_id(sc); diff --git a/crates/engine/src/cmd/formulas.rs b/crates/engine/src/cmd/formulas.rs index 537bb4cfa..5e7fcf008 100644 --- a/crates/engine/src/cmd/formulas.rs +++ b/crates/engine/src/cmd/formulas.rs @@ -348,6 +348,7 @@ fn rename_name_in_formulas(wb: &mut gridcraft_model::Workbook, old: &str, old_sc let Some(e) = f.expr().and_then(rename) else { continue }; let mut nf = Formula::from_expr(e); nf.array = f.array; + nf.dynamic = f.dynamic; if let Some(cell) = sh.cells.get_mut(c) { cell.formula = Some(std::sync::Arc::new(nf)); } diff --git a/crates/engine/src/cmd/inspect.rs b/crates/engine/src/cmd/inspect.rs index f92b48bd9..f373de489 100644 --- a/crates/engine/src/cmd/inspect.rs +++ b/crates/engine/src/cmd/inspect.rs @@ -130,7 +130,7 @@ fn cell_get(s: &mut Session, p: &Json) -> Result { "text": crate::display::cell_text(&d.wb, sh, c), "input": sh.input_text(c), "picture": sh.cell_pictures.get(&c).map(|picture| json!({"mime": picture.mime, "alt": picture.alt, "bytes": picture.data.len()})), - "spilledFrom": sh.spill_ranges.iter().find(|(a, r)| **a != c && r.contains(c)).map(|(a, _)| a.a1()), + "spilledFrom": sh.spill_anchor(c).map(|a| a.a1()), "spill": sh.spill_ranges.get(&c).map(|r| r.a1()), "style": style, "merge": sh.merge_at(c).map(|m| m.a1()), diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 50865a2b7..e18bae20b 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -269,13 +269,7 @@ pub(crate) fn target_sheet(s: &Session, p: &Json) -> Result { Ok(d.wb.active_sheet) } -/// `'My Sheet'!A1` → (`Some("My Sheet")`, `A1`). -pub(crate) fn split_sheet(r: &str) -> (Option, &str) { - match r.rsplit_once('!') { - Some((sh, body)) => (Some(sh.trim_matches('\'').replace("''", "'")), body), - None => (None, r), - } -} +pub(crate) use gridcraft_core::split_sheet; /// A single-cell parameter. Absent or null is `Ok(None)` (callers default it); a value that is not /// a cell reference, or that names a sheet other than the active one, is an error rather than a diff --git a/crates/engine/src/cmd/pivot.rs b/crates/engine/src/cmd/pivot.rs index 06050f7e8..6205a510a 100644 --- a/crates/engine/src/cmd/pivot.rs +++ b/crates/engine/src/cmd/pivot.rs @@ -1392,7 +1392,7 @@ fn chart_pivot(s: &Session, p: &Json) -> Result<(usize, usize)> { let (sheet, addr) = match t.rsplit_once('!') { Some((sh, a)) => { let sh = sh.trim(); - let sh = sh.strip_prefix('\'').and_then(|x| x.strip_suffix('\'')).map(|x| x.replace("''", "'")).unwrap_or_else(|| sh.to_string()); + let sh = gridcraft_core::unquote_sheet(sh); (d.wb.sheet_index(&sh), a) } None => (Some(d.wb.active_sheet), t), diff --git a/crates/engine/src/cmd/print.rs b/crates/engine/src/cmd/print.rs index 8350e56d7..c23b20dd4 100644 --- a/crates/engine/src/cmd/print.rs +++ b/crates/engine/src/cmd/print.rs @@ -604,20 +604,6 @@ fn rgb_of(c: Color, wb: &Workbook) -> Option { c.resolve(&wb.theme) } -fn numfmt_rgb(c: gridcraft_numfmt::FormatColor) -> Rgb { - use gridcraft_numfmt::FormatColor as F; - match c { - F::Black | F::Indexed(_) => [0, 0, 0], - F::Blue => [0, 0, 255], - F::Cyan => [0, 255, 255], - F::Green => [0, 128, 0], - F::Magenta => [255, 0, 255], - F::Red => [255, 0, 0], - F::White => [255, 255, 255], - F::Yellow => [255, 255, 0], - } -} - fn blend(fg: [u8; 4]) -> Rgb { let a = fg[3] as u16; let mix = |c: u8| ((c as u16 * a + 255 * (255 - a)) / 255) as u8; @@ -1065,7 +1051,7 @@ fn draw_text(pg: &mut Page, b: &Block, c: CellRef, l: &Look, rect: (f32, f32, f3 let avail = (w - 2.0 * pad - indent).max(0.0); let code = st.num_fmt.as_str(); let formatted = crate::display::format(&v, code, wb); - let mut color = formatted.color.map(numfmt_rgb).or_else(|| rgb_of(st.font.color, wb)).unwrap_or([0, 0, 0]); + let mut color = formatted.color.and_then(gridcraft_numfmt::FormatColor::rgb).or_else(|| rgb_of(st.font.color, wb)).unwrap_or([0, 0, 0]); if l.cf.as_ref().and_then(|x| x.style.as_ref()).is_some_and(|s| s.font.color != Color::Auto) { color = rgb_of(st.font.color, wb).unwrap_or(color); } @@ -1952,6 +1938,16 @@ mod tests { assert!(contains(&pdf, "(4.00) Tj") && !contains(&pdf, "(4.0) Tj"), "formatted cell under the rule"); } + #[test] + fn pdf_uses_indexed_number_format_palette_color() { + let mut s = s(); + s.execute("cell.set", json!({"cell": "A1", "input": "42"})).unwrap(); + s.execute("home.numberFormat", json!({"range": "A1", "format": "[Color4]0"})).unwrap(); + let (pdf, pages) = pdf_of(&mut s, json!({})); + assert_eq!(pages, 1); + assert!(contains(&pdf, "0 1 0 rg"), "expected indexed Color4 green in PDF text stream"); + } + #[test] fn export_writes_file() { let mut s = s(); diff --git a/crates/engine/src/cmd/review.rs b/crates/engine/src/cmd/review.rs index 18363ead7..5464abba5 100644 --- a/crates/engine/src/cmd/review.rs +++ b/crates/engine/src/cmd/review.rs @@ -139,11 +139,15 @@ fn protect_sheet(s: &mut Session, p: &Json) -> Result { return Err(EngineError::Other("The sheet is protected with a password. Unprotect it first (Review › Unprotect Sheet).".into())); } let b = |k: &str| bool_param(p, k).unwrap_or(false); + // Selection rights default to what the sheet has now (a file may forbid selecting locked cells). + let current = s.doc()?.wb.sheet(sheet).and_then(|sh| sh.protection.clone()); + let select_locked = bool_param(p, "selectLockedCells").unwrap_or_else(|| current.as_ref().is_none_or(|c| c.select_locked)); + let select_unlocked = bool_param(p, "selectUnlockedCells").unwrap_or_else(|| current.as_ref().is_none_or(|c| c.select_unlocked)); let prot = SheetProtection { password: None, legacy_password_hash: None, - select_locked: true, - select_unlocked: true, + select_locked, + select_unlocked, format_cells: b("formatCells"), format_columns: b("formatColumns"), format_rows: b("formatRows"), @@ -399,6 +403,26 @@ mod tests { assert_eq!(at(&s), (0, "A1".to_string())); } + #[test] + fn protection_that_forbids_selecting_locked_cells_is_enforced_and_kept() { + let mut s = book(); + s.execute("home.lockCell", json!({"range": "B2", "on": false})).unwrap(); + s.execute("review.protectSheet", json!({"selectLockedCells": false})).unwrap(); + // Locked cells can't be selected; unlocked ones can. + assert!(s.execute("selection.set", json!({"cell": "A1"})).is_err()); + s.execute("selection.set", json!({"cell": "B2"})).unwrap(); + // Arrow keys skip locked cells, and stay put when nothing selectable is left. + s.execute("selection.move", json!({"dr": 0, "dc": 1})).unwrap(); + assert_eq!(s.doc().unwrap().selection.active.a1(), "B2"); + // Re-protecting without the flag keeps the sheet's selection rights. + s.execute("review.unprotectSheet", json!({})).unwrap(); + s.execute("review.protectSheet", json!({"selectLockedCells": false})).unwrap(); + s.execute("review.protectSheet", json!({"sort": true})).unwrap(); + assert!(!s.doc().unwrap().wb.active().unwrap().protection.clone().unwrap().select_locked); + s.execute("review.unprotectSheet", json!({})).unwrap(); + s.execute("selection.set", json!({"cell": "A1"})).unwrap(); + } + #[test] fn sheet_passwords_use_excel_hashes_and_old_json_hashes_still_verify() { let mut s = book(); diff --git a/crates/engine/src/fill.rs b/crates/engine/src/fill.rs index 5848bb0ca..29a6f3f9f 100644 --- a/crates/engine/src/fill.rs +++ b/crates/engine/src/fill.rs @@ -19,7 +19,17 @@ pub enum FillMode { Alternate, Copy, Formats, + /// Fill Without Formatting: extends like a plain drag, the targets keep their formats. ValuesOnly, + /// Fill Series from the right-drag menu: extends like a plain drag, and a lone number counts up. + Extend, +} + +impl FillMode { + /// The modes that continue the source's series rather than copy it. + fn extends_series(self) -> bool { + matches!(self, FillMode::Series | FillMode::Extend | FillMode::ValuesOnly) + } } /// Built-in custom lists. @@ -67,15 +77,18 @@ pub fn fill(cx: &mut Ctx, sheet: usize, src: RangeRef, target: RangeRef, mode: F cols.into_iter().map(|c| CellRef::new(lane, c)).collect() }; let fmt = |c: &(CellRef, Option)| cx.wb.styles.get(c.1.as_ref().map(|c| c.style).unwrap_or_default()).num_fmt.as_str(); - let pattern = if matches!(mode, FillMode::Series | FillMode::Alternate) { + let pattern = if mode.extends_series() || mode == FillMode::Alternate { let detected = Pattern::detect(&src_cells, &custom, cx.wb.date_system, src_cells.first().map(fmt).unwrap_or_default()); - if mode == FillMode::Alternate { detected.alternate(&src_cells) } else { detected } + match (mode, detected) { + (FillMode::Alternate, p) | (FillMode::Extend, p @ Pattern::Copy) => p.alternate(&src_cells), + (_, p) => p, + } } else { Pattern::Copy }; // A source with no common pattern (`Product 1`, `On backorder`): each cell continues its // own series, one step per repeat of the source. - let per_cell: Vec = if mode == FillMode::Series && matches!(pattern, Pattern::Copy) && src_cells.len() > 1 { + let per_cell: Vec = if mode.extends_series() && matches!(pattern, Pattern::Copy) && src_cells.len() > 1 { src_cells.iter().map(|c| Pattern::detect(std::slice::from_ref(c), &custom, cx.wb.date_system, fmt(c))).collect() } else { Vec::new() @@ -93,7 +106,7 @@ pub fn fill(cx: &mut Ctx, sheet: usize, src: RangeRef, target: RangeRef, mode: F writes.push((*t, Some(existing))); continue; } - FillMode::ValuesOnly | FillMode::Series | FillMode::Alternate | FillMode::Copy => { + FillMode::ValuesOnly | FillMode::Series | FillMode::Alternate | FillMode::Copy | FillMode::Extend => { let style = if mode == FillMode::ValuesOnly { sh.cell(*t).map(|c| c.style).unwrap_or_default() } else { cell.style }; if let Some(f) = &cell.formula { if let Some(e) = f.expr() { diff --git a/crates/engine/src/pivot.rs b/crates/engine/src/pivot.rs index 6146bd4f9..eaa8ab4db 100644 --- a/crates/engine/src/pivot.rs +++ b/crates/engine/src/pivot.rs @@ -9,7 +9,8 @@ use std::cmp::Ordering; use std::collections::{HashMap, HashSet}; use gridcraft_core::date::datetime_from_serial; -use gridcraft_core::{CellError, CellRef, MAX_COLS, MAX_ROWS, RangeRef, Value}; +pub use gridcraft_core::quote_sheet; +use gridcraft_core::{CellError, CellRef, MAX_COLS, MAX_ROWS, RangeRef, Value, split_sheet}; use gridcraft_model::*; use crate::pivot_calc::CalcSet; @@ -47,26 +48,6 @@ impl Source { } } -/// `'My Sheet'!A1` → (`Some("My Sheet")`, `A1`). -fn split_sheet(r: &str) -> (Option, &str) { - match r.rsplit_once('!') { - Some((sh, body)) => (Some(sh.trim_matches('\'').replace("''", "'")), body), - None => (None, r), - } -} - -/// Quotes a sheet name for a reference when needed. -pub fn quote_sheet(name: &str) -> String { - if !name.is_empty() - && name.chars().all(|c| c.is_alphanumeric() || c == '_' || c == '.') - && !name.chars().next().is_some_and(|c| c.is_ascii_digit()) - { - name.to_string() - } else { - format!("'{}'", name.replace('\'', "''")) - } -} - /// Absolute reference text for a range on a sheet: `Sheet1!$A$1:$E$20`. pub fn range_text(sheet_name: &str, r: RangeRef) -> String { let abs = |c: CellRef| format!("${}${}", gridcraft_core::col_to_letters(c.col), c.row as u64 + 1); @@ -1511,7 +1492,7 @@ pub fn parse_chart_link(link: &str) -> Option<(String, String)> { }; let (sheet, name) = t.rsplit_once('!')?; let sheet = sheet.trim(); - let sheet = sheet.strip_prefix('\'').and_then(|x| x.strip_suffix('\'')).map(|x| x.replace("''", "'")).unwrap_or_else(|| sheet.to_string()); + let sheet = gridcraft_core::unquote_sheet(sheet); let name = name.trim(); if sheet.is_empty() || name.is_empty() { return None; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index e2e684b10..dd3ca91ee 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -801,6 +801,43 @@ fn ctrl_drag_fill_swaps_copy_and_series() { assert_eq!(v(&s, "G3"), Value::Empty); } +#[test] +fn fill_series_from_the_right_drag_menu_counts_a_lone_number() { + // Excel's right-drag Fill Series: a lone number counts up, everything else extends as a plain drag. + let mut s = s(); + s.execute("cell.set", json!({"cell": "A1", "input": "7"})).unwrap(); + s.execute("edit.autoFill", json!({"source": "A1", "target": "A1:A3", "mode": "extend"})).unwrap(); + assert_eq!([v(&s, "A2"), v(&s, "A3")], [Value::Number(8.0), Value::Number(9.0)]); + s.execute("range.setValues", json!({"range": "B1", "values": [[1], [3]]})).unwrap(); + s.execute("edit.autoFill", json!({"source": "B1:B2", "target": "B1:B4", "mode": "extend"})).unwrap(); + assert_eq!([v(&s, "B3"), v(&s, "B4")], [Value::Number(5.0), Value::Number(7.0)]); + s.execute("cell.set", json!({"cell": "C1", "input": "Item 9"})).unwrap(); + s.execute("edit.autoFill", json!({"source": "C1", "target": "C1:C2", "mode": "extend"})).unwrap(); + assert_eq!(v(&s, "C2"), Value::from("Item 10")); + // A source with no common pattern continues each cell's own series, as a plain drag does. + s.execute("range.setValues", json!({"range": "D1", "values": [["Product 1"], ["On backorder"]]})).unwrap(); + s.execute("edit.autoFill", json!({"source": "D1:D2", "target": "D1:D4", "mode": "extend"})).unwrap(); + assert_eq!([v(&s, "D3"), v(&s, "D4")], [Value::from("Product 2"), Value::from("On backorder")]); +} + +#[test] +fn fill_without_formatting_extends_the_series_and_keeps_the_target_formats() { + let mut s = s(); + s.execute("range.setValues", json!({"range": "A1", "values": [[1], [2], [null], [9]]})).unwrap(); + s.execute("home.bold", json!({"range": "A1:A2", "on": true})).unwrap(); + s.execute("home.italic", json!({"range": "A4", "on": true})).unwrap(); + s.execute("edit.autoFill", json!({"source": "A1:A2", "target": "A1:A4", "mode": "values"})).unwrap(); + assert_eq!([v(&s, "A3"), v(&s, "A4")], [Value::Number(3.0), Value::Number(4.0)]); + let d = s.doc().unwrap(); + let sh = d.wb.active().unwrap(); + let a4 = d.wb.styles.get(sh.style_id(CellRef::parse("A4").unwrap())); + assert!(a4.font.italic && !a4.font.bold, "A4 keeps its own formats"); + assert!(!d.wb.styles.get(sh.style_id(CellRef::parse("A3").unwrap())).font.bold); + s.execute("range.setValues", json!({"range": "B1", "values": [["Product 1"], ["On backorder"]]})).unwrap(); + s.execute("edit.autoFill", json!({"source": "B1:B2", "target": "B1:B4", "mode": "values"})).unwrap(); + assert_eq!([v(&s, "B3"), v(&s, "B4")], [Value::from("Product 2"), Value::from("On backorder")]); +} + #[test] fn insert_delete_cells_adjust() { let formula = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap()["formula"].clone(); diff --git a/crates/engine/tests/chart_workbook_names.rs b/crates/engine/tests/chart_workbook_names.rs new file mode 100644 index 000000000..0ac156b62 --- /dev/null +++ b/crates/engine/tests/chart_workbook_names.rs @@ -0,0 +1,134 @@ +//! Chart series that point at workbook-scoped defined names. Excel writes these as `[0]!Name` +//! in ``; they used to read back verbatim and evaluate to `#NAME?`, so a chart +//! driven by dynamic (OFFSET) names opened blank with a single `#NAME?` category. + +use std::io::{Read, Write}; + +use gridcraft_engine::{Session, io}; +use gridcraft_model::Workbook; +use serde_json::json; + +const TASKS: usize = 6; + +/// A plan table, OFFSET names over its structured references (and names built on names; one +/// scoped to the sheet), and a stacked bar chart whose series name those names. +fn plan() -> Workbook { + let mut s = Session::new(); + s.new_workbook(); + let mut rows = vec![json!(["Task", "Start", "Done", "Left"])]; + for i in 0..TASKS { + rows.push(json!([format!("Task {}", i + 1), (45_200 + 7 * i).to_string(), (i + 1).to_string(), (TASKS - i).to_string()])); + } + s.execute("range.setValues", json!({"range": "A1", "values": rows})).unwrap(); + let range = format!("A1:D{}", TASKS + 1); + s.execute("insert.table", json!({"range": range, "header": true, "name": "Plan"})).unwrap(); + for (name, refers, scope) in [ + ("TaskNames", "=OFFSET(Plan[[#Headers],[Task]],1,0,COUNTA(Plan[Task]),1)", "Workbook"), + ("TaskStart", "=OFFSET(TaskNames,0,1)", "Workbook"), + ("TaskDone", "=OFFSET(TaskNames,0,2)", "Workbook"), + ("TaskLeft", "=OFFSET(TaskNames,0,3)", "Sheet1"), + ("LeftTitle", "=Sheet1!$D$1", "Workbook"), + ("DoneTitle", "=Sheet1!$C$1", "Sheet1"), + ] { + s.execute("formulas.defineName", json!({"name": name, "refersTo": refers, "scope": scope})).unwrap(); + } + s.execute("insert.chart", json!({"range": range, "type": "bar", "subtype": "stacked"})).unwrap(); + let mut wb = Workbook::clone(&s.doc().unwrap().wb); + let chart = &mut wb.sheet_mut(0).unwrap().charts[0]; + assert_eq!(chart.series.len(), 3); + for (ser, (name, values)) in + chart.series.iter_mut().zip([("Sheet1!$B$1", "TaskStart"), ("Sheet1!DoneTitle", "TaskDone"), ("LeftTitle", "TaskLeft")]) + { + ser.name = Some(name.into()); + ser.categories = Some("TaskNames".into()); + ser.values = values.into(); + } + wb +} + +fn chart_xml(bytes: &[u8]) -> String { + let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap(); + let mut f = z.by_name("xl/charts/chart1.xml").unwrap(); + let mut xml = String::new(); + f.read_to_string(&mut xml).unwrap(); + xml +} + +/// The package with `edit` applied to its chart part. +fn edit_chart(bytes: &[u8], edit: impl Fn(String) -> String) -> Vec { + let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap(); + let mut out = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + for i in 0..z.len() { + let mut f = z.by_index(i).unwrap(); + let name = f.name().to_string(); + let mut data = Vec::new(); + f.read_to_end(&mut data).unwrap(); + if name == "xl/charts/chart1.xml" { + data = edit(String::from_utf8(data).unwrap()).into_bytes(); + } + out.start_file(name, zip::write::SimpleFileOptions::default()).unwrap(); + out.write_all(&data).unwrap(); + } + out.finish().unwrap().into_inner() +} + +fn reopen(bytes: &[u8]) -> Workbook { + io::open_bytes("plan.xlsx", bytes).unwrap().0 +} + +#[test] +fn workbook_names_in_chart_series_resolve_after_save_and_reopen() { + let bytes = io::save_bytes(&plan(), "plan.xlsx").unwrap(); + // Qualified, as Excel needs them: it draws a chart with a bare name in `` blank. + let xml = chart_xml(&bytes); + for f in ["[0]!TaskNames", "[0]!TaskStart", "[0]!TaskDone", "Sheet1!TaskLeft", "[0]!LeftTitle"] { + assert!(xml.contains(&format!("{f}")), "{f} missing from {xml}"); + } + assert!(!xml.contains("Task"), "a bare name in {xml}"); + // Series names that are defined names stay references, qualified or not. + assert!(xml.contains("Sheet1!DoneTitle"), "{xml}"); + // A series name that is a defined name stays a reference, not literal text. + assert!(!xml.contains("LeftTitle")); + + let wb = reopen(&bytes); + let chart = &wb.sheet(0).unwrap().charts[0]; + assert_eq!(chart.series[0].categories.as_deref(), Some("TaskNames")); + assert_eq!(chart.series[0].values, "TaskStart"); + assert_eq!(chart.series[2].values, "Sheet1!TaskLeft"); + assert_eq!(chart.series[2].name.as_deref(), Some("LeftTitle")); + + let data = gridcraft_chart::resolve(&wb, 0, chart); + let tasks: Vec = (1..=TASKS).map(|i| format!("Task {i}")).collect(); + assert_eq!(data.categories, tasks); + let names: Vec<&str> = data.series.iter().map(|s| s.name.as_str()).collect(); + assert_eq!(names, ["Start", "Done", "Left"]); + let start: Vec> = (0..TASKS).map(|i| Some((45_200 + 7 * i) as f64)).collect(); + assert_eq!(data.series[0].values, start); + let left: Vec> = (0..TASKS).map(|i| Some((TASKS - i) as f64)).collect(); + assert_eq!(data.series[2].values, left); + + // A second save and reopen gives the same chart data. + let again = reopen(&io::save_bytes(&wb, "plan.xlsx").unwrap()); + assert_eq!(gridcraft_chart::resolve(&again, 0, &again.sheet(0).unwrap().charts[0]), data); +} + +#[test] +fn a_missing_workbook_name_resolves_to_no_values_without_panicking() { + let bytes = io::save_bytes(&plan(), "plan.xlsx").unwrap(); + let bytes = edit_chart(&bytes, |x| x.replace("Sheet1!TaskLeft", "[0]!NoSuchName")); + let wb = reopen(&bytes); + let chart = &wb.sheet(0).unwrap().charts[0]; + assert_eq!(chart.series[2].values, "NoSuchName"); + let data = gridcraft_chart::resolve(&wb, 0, chart); + assert!(data.series[2].values.iter().all(Option::is_none), "{:?}", data.series[2].values); + // The other series still plot. + assert_eq!(data.series[0].values.len(), TASKS); +} + +#[test] +fn external_workbook_refs_in_chart_series_are_kept_as_they_were() { + let bytes = io::save_bytes(&plan(), "plan.xlsx").unwrap(); + let bytes = edit_chart(&bytes, |x| x.replace("[0]!TaskDone", "[1]Sheet1!$C$2:$C$7")); + let wb = reopen(&bytes); + assert_eq!(wb.sheet(0).unwrap().charts[0].series[1].values, "[1]Sheet1!$C$2:$C$7"); +} diff --git a/crates/engine/tests/dynamicmetadata.rs b/crates/engine/tests/dynamicmetadata.rs new file mode 100644 index 000000000..e1edfc958 --- /dev/null +++ b/crates/engine/tests/dynamicmetadata.rs @@ -0,0 +1,87 @@ +//! A formula opened with dynamic-array metadata keeps it through edits that only move or adjust +//! it, and loses it when it is retyped (the writer then decides from the formula's shape). + +use std::sync::Arc; + +use gridcraft_engine::core::{CellRef, Value}; +use gridcraft_engine::model::{Cell, Formula, Workbook}; +use gridcraft_engine::{DocState, Session, io}; +use serde_json::json; + +fn at(s: &str) -> CellRef { + CellRef::parse(s).unwrap() +} + +/// A workbook saved by GridCraft whose D1 formula came from a file with `cm`. +fn opened() -> Session { + let mut wb = Workbook::new(); + let sh = wb.sheet_mut(0).unwrap(); + for r in 0..3 { + sh.set_value(CellRef::new(r, 1), Value::Number(f64::from(r + 1))); + sh.set_value(CellRef::new(r, 2), Value::Number(f64::from(r + 2))); + } + let mut f = Formula::new("SUMPRODUCT(B1:B3*C1:C3)"); + f.dynamic = true; + sh.cells.set(at("D1"), Cell::formula(f)); + let bytes = io::save_bytes(&wb, "dynamic.xlsx").unwrap(); + let (back, _) = io::open_bytes("dynamic.xlsx", &bytes).unwrap(); + let mut s = Session::new(); + s.add_document(DocState::new(back, None, "dynamic".into())); + s +} + +fn formula(s: &Session, cell: &str) -> Arc { + s.doc().unwrap().wb.sheet(0).unwrap().cell(at(cell)).unwrap().formula.clone().unwrap() +} + +/// Whether `cell` of the saved file carries dynamic-array metadata. +fn saved_dynamic(s: &Session, cell: &str) -> bool { + let bytes = io::save_bytes(&s.doc().unwrap().wb, "dynamic.xlsx").unwrap(); + let (back, _) = io::open_bytes("dynamic.xlsx", &bytes).unwrap(); + back.sheet(0).unwrap().cell(at(cell)).unwrap().formula.as_ref().unwrap().dynamic +} + +#[test] +fn opened_dynamic_formula_keeps_its_metadata_on_save() { + let s = opened(); + assert!(formula(&s, "D1").dynamic); + assert_eq!(s.doc().unwrap().wb.sheet(0).unwrap().value(at("D1")), Value::Number(20.0)); + assert!(saved_dynamic(&s, "D1")); +} + +#[test] +fn inserting_rows_and_moving_keep_the_mark() { + let mut s = opened(); + s.execute("home.insertRows", json!({"rows": "1:1"})).unwrap(); + let f = formula(&s, "D2"); + assert!(f.dynamic); + assert_eq!(f.text, "SUMPRODUCT(B2:B4*C2:C4)"); + assert!(saved_dynamic(&s, "D2")); + s.execute("selection.set", json!({"range": "D2"})).unwrap(); + s.execute("edit.cut", json!({})).unwrap(); + s.execute("selection.set", json!({"range": "F2"})).unwrap(); + s.execute("edit.paste", json!({})).unwrap(); + assert!(formula(&s, "F2").dynamic); + assert!(saved_dynamic(&s, "F2")); +} + +#[test] +fn retyped_formula_drops_the_mark() { + let mut s = opened(); + s.execute("cell.set", json!({"cell": "D1", "input": "=SUMPRODUCT(B1:B3*C1:C3)"})).unwrap(); + assert!(!formula(&s, "D1").dynamic); + // SUMPRODUCT evaluates its arguments as arrays anyway: saved as a legacy formula. + assert!(!saved_dynamic(&s, "D1")); +} + +#[test] +fn dynamic_mark_is_serialized_only_when_set() { + let s = opened(); + let dynamic = serde_json::to_value(&*formula(&s, "D1")).unwrap(); + assert_eq!(dynamic["dynamic"], json!(true)); + let plain = serde_json::to_value(Formula::new("SUM(A1:A3)")).unwrap(); + assert!(plain.get("dynamic").is_none(), "{plain}"); + // Documents written before the field existed still load. + let old: Formula = serde_json::from_value(json!({"text": "SUM(A1:A3)", "array": null})).unwrap(); + assert!(!old.dynamic); +} diff --git a/crates/engine/tests/sheet_name_quoting.rs b/crates/engine/tests/sheet_name_quoting.rs new file mode 100644 index 000000000..c7ba317bb --- /dev/null +++ b/crates/engine/tests/sheet_name_quoting.rs @@ -0,0 +1,51 @@ +//! #1458: sheet names are quoted in references the way Excel quotes them, in formulas, saved +//! files, PivotTable sources and ADDRESS. + +use gridcraft_engine::Session; +use gridcraft_engine::core::{CellRef, Value}; +use serde_json::{Value as Json, json}; + +fn run(s: &mut Session, command: &str, params: Json) -> Json { + s.execute(command, params).unwrap() +} + +fn formula(s: &Session, cell: &str) -> String { + s.doc().unwrap().wb.active().unwrap().cell(CellRef::parse(cell).unwrap()).unwrap().formula.as_ref().unwrap().text.clone() +} + +#[test] +fn formulas_keep_the_quotes_excel_needs_through_a_save() { + let mut s = Session::new(); + s.new_workbook(); + for name in [".x", "R1C1", "A1", "Bob's"] { + run(&mut s, "home.insertSheet", json!({"name": name})); + } + run(&mut s, "sheet.activate", json!({"sheet": "Sheet1"})); + let input = "='.x'!A1+'R1C1'!A1+'A1'!B1+'Bob''s'!A1"; + run(&mut s, "cell.set", json!({"cell": "A1", "input": input})); + assert_eq!(formula(&s, "A1"), input.trim_start_matches('=')); + + let saved = run(&mut s, "file.saveBytes", json!({"format": "xlsx"})); + let mut reopened = Session::new(); + run(&mut reopened, "file.open", json!({"name": "q.xlsx", "base64": saved["base64"]})); + run(&mut reopened, "sheet.activate", json!({"sheet": "Sheet1"})); + assert_eq!(formula(&reopened, "A1"), input.trim_start_matches('=')); +} + +#[test] +fn address_and_pivot_source_quote_a_sheet_named_like_a_cell() { + let mut s = Session::new(); + s.new_workbook(); + run(&mut s, "home.insertSheet", json!({"name": "A1"})); + run(&mut s, "sheet.activate", json!({"sheet": "A1"})); + for (cell, input) in [("A1", "K"), ("B1", "V"), ("A2", "a"), ("B2", "1"), ("A3", "b"), ("B3", "2")] { + run(&mut s, "cell.set", json!({"cell": cell, "input": input})); + } + run(&mut s, "cell.set", json!({"cell": "D1", "input": "=ADDRESS(1,1,1,TRUE,\"A1\")"})); + let d1 = s.doc().unwrap().wb.active().unwrap().value(CellRef::parse("D1").unwrap()); + assert_eq!(d1, Value::from("'A1'!$A$1")); + + run(&mut s, "selection.set", json!({"range": "A2"})); + let pivot = run(&mut s, "insert.pivotTable", json!({"rows": ["K"], "values": ["V"]})); + assert_eq!(pivot["source"], json!("'A1'!$A$1:$B$3")); +} diff --git a/crates/formula/src/printer.rs b/crates/formula/src/printer.rs index 3975a7c61..055b0f145 100644 --- a/crates/formula/src/printer.rs +++ b/crates/formula/src/printer.rs @@ -6,22 +6,7 @@ use gridcraft_core::{CellRef, col_to_letters, number_to_text}; use crate::ast::*; -/// Whether a sheet name needs quotes in a reference. -pub fn needs_quotes(name: &str) -> bool { - if name.is_empty() { - return true; - } - let first = name.chars().next().unwrap_or('_'); - if first.is_ascii_digit() || name.chars().any(|c| !(c.is_alphanumeric() || c == '_' || c == '.')) { - return true; - } - // Looks like a cell ref (`A1`, `R1C1`) or a boolean. - gridcraft_core::CellRef::parse(name).is_some() || name.eq_ignore_ascii_case("TRUE") || name.eq_ignore_ascii_case("FALSE") -} - -pub fn quote_sheet(name: &str) -> String { - if needs_quotes(name) { format!("'{}'", name.replace('\'', "''")) } else { name.to_string() } -} +pub use gridcraft_core::{quote_sheet, sheet_needs_quotes as needs_quotes}; fn sheet_prefix(s: &SheetSel) -> String { match s { diff --git a/crates/formula/src/tests.rs b/crates/formula/src/tests.rs index d23eba0e2..d398da979 100644 --- a/crates/formula/src/tests.rs +++ b/crates/formula/src/tests.rs @@ -401,3 +401,15 @@ fn formulas_longer_than_excel_allows_are_refused() { assert!(text.len() > crate::MAX_FORMULA_LEN); assert!(parse(&text).is_ok()); } + +#[test] +fn sheet_names_excel_quotes_stay_quoted() { + // Excel quotes R1C1-looking names, a leading `.`, cells within the sheet and booleans (#1458). + for name in ["R", "C", "RC", "R1C", "RC1", "R1C1", "r2c3", "R1", "C1", "A1", "XFD1", "AB1048576", ".x", "TRUE", "false"] { + assert_eq!(rt(&format!("'{name}'!A1")), format!("'{name}'!A1"), "{name}"); + } + // Names Excel leaves bare. + for name in ["XFE1", "AB1048577", "Sheet.1", "_x", "A1A", "\u{3a3}"] { + assert_eq!(rt(&format!("'{name}'!A1")), format!("{name}!A1"), "{name}"); + } +} diff --git a/crates/functions/src/lookup.rs b/crates/functions/src/lookup.rs index 56739c933..f9c0b6f44 100644 --- a/crates/functions/src/lookup.rs +++ b/crates/functions/src/lookup.rs @@ -413,15 +413,15 @@ fn transpose(args: &[Arg], _c: &mut dyn Ctx) -> R { } } -/// Sheet text for ADDRESS, quoted when it needs to be. A `[Book]Sheet` prefix is judged by its +/// Sheet text for ADDRESS, quoted when Excel quotes it. A `[Book]Sheet` prefix is judged by its /// parts (Microsoft's ADDRESS examples: `"[Book1]Sheet1"` stays bare, `"EXCEL SHEET"` becomes -/// `'EXCEL SHEET'`). +/// `'EXCEL SHEET'`). Unlike in a formula, `TRUE` and `FALSE` stay bare. fn quote_sheet(s: &str) -> String { - let word = |p: &str| p.chars().all(|c| c.is_alphanumeric() || c == '_' || c == '.'); - let bare_sheet = |p: &str| !p.is_empty() && word(p) && !p.chars().next().is_some_and(|c| c.is_ascii_digit()); + let bare = |p: &str| p.eq_ignore_ascii_case("TRUE") || p.eq_ignore_ascii_case("FALSE") || !gridcraft_core::sheet_needs_quotes(p); + let word = |p: &str| !p.is_empty() && p.chars().all(|c| c.is_alphanumeric() || c == '_' || c == '.'); let plain = match s.strip_prefix('[').and_then(|r| r.split_once(']')) { - Some((book, sheet)) => !book.is_empty() && word(book) && bare_sheet(sheet), - None => bare_sheet(s), + Some((book, sheet)) => word(book) && bare(sheet), + None => bare(s), }; if plain { s.to_string() } else { format!("'{}'", s.replace('\'', "''")) } } @@ -791,6 +791,13 @@ mod tests { for (r, c, abs, a1, sheet, want) in cases { is_text(ev("ADDRESS", vec![n(r), n(c), n(abs), b(a1), t(sheet)]), want); } + // Sheet names that read as a cell or an R1C1 reference, or start with a dot, are quoted; + // off-sheet cells and booleans are not (#1458). + for (sheet, want) in + [("A1", "'A1'!$A$1"), ("R1C1", "'R1C1'!$A$1"), ("RC", "'RC'!$A$1"), (".x", "'.x'!$A$1"), ("XFE1", "XFE1!$A$1"), ("TRUE", "TRUE!$A$1")] + { + is_text(ev("ADDRESS", vec![n(1.0), n(1.0), n(1.0), b(true), t(sheet)]), want); + } is_text(ev("ADDRESS", vec![n(1048576.0), n(16384.0)]), "$XFD$1048576"); is_err(ev("ADDRESS", vec![n(0.0), n(1.0)]), CellError::Value); is_err(ev("ADDRESS", vec![n(1.0), n(16385.0)]), CellError::Value); diff --git a/crates/model/src/cell.rs b/crates/model/src/cell.rs index 8700f9a28..d4466365f 100644 --- a/crates/model/src/cell.rs +++ b/crates/model/src/cell.rs @@ -17,6 +17,12 @@ pub struct Formula { /// Legacy array formula (Ctrl+Shift+Enter) covering this range; the formula lives in the /// top-left cell. pub array: Option, + /// Read from a file that marks this formula as a dynamic-array formula (cell metadata `cm`). + /// The writer keeps the mark, so a formula Excel evaluates as an array is never saved as a + /// legacy formula (which Excel evaluates with implicit intersection). Formulas entered or + /// rewritten in GridCraft start without it, and the writer decides from their shape. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub dynamic: bool, } impl Formula { @@ -25,12 +31,12 @@ impl Formula { pub fn new(text: &str) -> Formula { let body = text.strip_prefix('=').unwrap_or(text); match gridcraft_formula::parse(body) { - Ok(e) => Formula { text: gridcraft_formula::print(&e), expr: Some(Arc::new(e)), array: None }, - Err(_) => Formula { text: body.to_string(), expr: None, array: None }, + Ok(e) => Formula { text: gridcraft_formula::print(&e), expr: Some(Arc::new(e)), array: None, dynamic: false }, + Err(_) => Formula { text: body.to_string(), expr: None, array: None, dynamic: false }, } } pub fn from_expr(e: Expr) -> Formula { - Formula { text: gridcraft_formula::print(&e), expr: Some(Arc::new(e)), array: None } + Formula { text: gridcraft_formula::print(&e), expr: Some(Arc::new(e)), array: None, dynamic: false } } /// The parsed expression, re-parsing after deserialization. pub fn expr(&self) -> Option { diff --git a/crates/model/src/sheet.rs b/crates/model/src/sheet.rs index 5a75c8f56..50c71a100 100644 --- a/crates/model/src/sheet.rs +++ b/crates/model/src/sheet.rs @@ -170,6 +170,12 @@ impl Sheet { pub fn cell(&self, c: CellRef) -> Option<&Cell> { self.cells.get(c) } + /// The source of a non-anchor dynamic-array spill cell, from the current calc state. + /// Anchors, ordinary cells and cells outside successfully generated spills return `None`. + pub fn spill_anchor(&self, c: CellRef) -> Option { + self.spill_ranges.iter().find(|(anchor, range)| **anchor != c && range.contains(c)).map(|(anchor, _)| *anchor) + } + /// Displayed value of a cell: its own value, or a spilled value. pub fn value(&self, c: CellRef) -> Value { match self.cells.get(c) { diff --git a/crates/model/src/workbook.rs b/crates/model/src/workbook.rs index f1ecd3944..e5f359614 100644 --- a/crates/model/src/workbook.rs +++ b/crates/model/src/workbook.rs @@ -219,7 +219,7 @@ impl Workbook { let f = cell.formula.as_ref()?; let e = f.expr()?; let ne = spell_calls(e.clone(), &lambdas); - (ne != e).then(|| (c, Formula { array: f.array, ..Formula::from_expr(ne) })) + (ne != e).then(|| (c, Formula { array: f.array, dynamic: f.dynamic, ..Formula::from_expr(ne) })) }) .collect(); if fixed.is_empty() { diff --git a/crates/numfmt/src/lib.rs b/crates/numfmt/src/lib.rs index 6280e1d26..06d0a8693 100644 --- a/crates/numfmt/src/lib.rs +++ b/crates/numfmt/src/lib.rs @@ -38,6 +38,24 @@ pub enum FormatColor { Indexed(u8), } +impl FormatColor { + /// RGB value used to render this number-format color. + pub fn rgb(self) -> Option<[u8; 3]> { + use FormatColor as F; + Some(match self { + F::Black => [0, 0, 0], + F::Blue => [0, 0, 255], + F::Cyan => [0, 255, 255], + F::Green => [0, 255, 0], + F::Magenta => [255, 0, 255], + F::Red => [255, 0, 0], + F::White => [255, 255, 255], + F::Yellow => [255, 255, 0], + F::Indexed(index) => return gridcraft_core::color::indexed_color(index), + }) + } +} + /// Best guess at the ribbon's number-format category. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub enum FormatKind { diff --git a/crates/numfmt/src/tests.rs b/crates/numfmt/src/tests.rs index e5687f5bd..acd09249b 100644 --- a/crates/numfmt/src/tests.rs +++ b/crates/numfmt/src/tests.rs @@ -1,5 +1,18 @@ use super::*; +#[test] +fn format_colors_resolve_named_and_indexed_rgb_safely() { + assert_eq!(FormatColor::Green.rgb(), Some([0, 255, 0])); + assert_eq!(FormatColor::Black.rgb(), Some([0, 0, 0])); + assert_eq!(FormatColor::Indexed(1).rgb(), Some([0, 0, 0])); + assert_eq!(FormatColor::Indexed(10).rgb(), Some([0, 128, 0])); + assert_eq!(FormatColor::Indexed(46).rgb(), Some([255, 102, 0])); + assert_eq!(FormatColor::Indexed(56).rgb(), Some([51, 51, 51])); + for index in [0, 57, 255] { + assert_eq!(FormatColor::Indexed(index).rgb(), None); + } +} + const S: DateSystem = DateSystem::D1900; fn f(code: &str, n: f64) -> String { diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index f18bf122d..83a25cff1 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -182,15 +182,19 @@ impl Dialog { vec![ Password { key: "password", label: "Password (optional):" }, Note("Allow all users of this sheet to:".into()), + Check { key: "selectLockedCells", label: "Select locked cells" }, + Check { key: "selectUnlockedCells", label: "Select unlocked cells" }, Check { key: "formatCells", label: "Format cells" }, Check { key: "formatColumns", label: "Format columns" }, Check { key: "formatRows", label: "Format rows" }, + Check { key: "insertColumns", label: "Insert columns" }, Check { key: "insertRows", label: "Insert rows" }, + Check { key: "deleteColumns", label: "Delete columns" }, Check { key: "deleteRows", label: "Delete rows" }, Check { key: "sort", label: "Sort" }, Check { key: "autofilter", label: "Use AutoFilter" }, ], - json!({}), + json!({"selectLockedCells": true, "selectUnlockedCells": true}), ), "allowEditRanges" => { let list = app.session.run("review.allowEditRanges", json!({})).unwrap_or(Json::Null); @@ -332,7 +336,7 @@ impl Dialog { Number { key: "step", label: "Step value:" }, Text { key: "stop", label: "Stop value:" }, ], - json!({"direction": "columns", "type": "linear", "dateUnit": "day", "step": 1}), + json!({"direction": if p("direction") == "rows" { "rows" } else { "columns" }, "type": "linear", "dateUnit": "day", "step": 1}), ), "comment" => { let threaded = p("threaded").as_bool().unwrap_or(true); @@ -1753,31 +1757,33 @@ fn format_cells(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: } } +/// Insert Function: searches names (as the formula language spells them) and descriptions, shows +/// each hit's category and description, and starts editing `=NAME(` (or appends to the formula). fn insert_function(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool) { + let language = app.ui.language; ui.horizontal(|ui| { - ui.label("Search:"); - let r = ui.add(egui::TextEdit::singleline(&mut d.search).desired_width(300.0).hint_text("e.g. lookup, average, date")); + ui.label(language.tr("Search:")); + let r = ui.add(egui::TextEdit::singleline(&mut d.search).desired_width(300.0).hint_text(language.tr("e.g. lookup, average, date"))); r.request_focus(); }); - let locale = app.ui.language.formula_locale(); + let locale = language.formula_locale(); let list = gridcraft_engine::cmd::formulas::function_list(); - let q = d.search.to_ascii_lowercase(); + // The names and descriptions searched are the ones shown, so in the interface language. + let q = d.search.to_lowercase(); let hits: Vec<&Json> = list .iter() .filter(|f| { q.is_empty() - || f["name"].as_str().is_some_and(|n| locale.function_name(n).to_ascii_lowercase().contains(&q)) - || f["description"].as_str().is_some_and(|n| n.to_ascii_lowercase().contains(&q)) + || f["name"].as_str().is_some_and(|n| locale.function_name(n).to_lowercase().contains(&q)) + || f["description"].as_str().is_some_and(|n| language.function_description(n).unwrap_or(n).to_lowercase().contains(&q)) }) .take(300) .collect(); egui::ScrollArea::vertical().max_height(260.0).show(ui, |ui| { for (i, f) in hits.iter().enumerate() { let name = locale.function_name(f["name"].as_str().unwrap_or("")); - let r = ui.add( - egui::Button::selectable(i == d.list_index, format!("{name:<18} {}", f["category"].as_str().unwrap_or(""))) - .min_size(vec2(480.0, 20.0)), - ); + let category = language.tr(function_category_label(f["category"].as_str().unwrap_or(""))); + let r = ui.add(egui::Button::selectable(i == d.list_index, format!("{name:<18} {category}")).min_size(vec2(480.0, 20.0))); if r.clicked() { d.list_index = i; } @@ -1788,9 +1794,10 @@ fn insert_function(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confir } }); if let Some(f) = hits.get(d.list_index) { + let name = f["name"].as_str().unwrap_or(""); ui.separator(); - ui.label(egui::RichText::new(crate::formula_locale::signature(f["name"].as_str().unwrap_or(""), locale).unwrap_or_default()).strong()); - ui.label(crate::formula_locale::description(f["name"].as_str().unwrap_or(""), locale).unwrap_or_default()); + ui.label(egui::RichText::new(crate::formula_locale::signature(name, locale, language).unwrap_or_default()).strong()); + ui.label(crate::formula_locale::description(name, locale, language).unwrap_or_default()); } let mut open = true; ok_cancel(ui, confirm, &mut open); @@ -1808,6 +1815,18 @@ fn insert_function(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confir } } +/// Excel's name for a function category, given the catalog's id (`MathTrig`). +fn function_category_label(id: &str) -> &str { + match id { + "DateTime" => "Date & Time", + "Lookup" => "Lookup & Reference", + "MathTrig" => "Math & Trig", + "AddIn" => "Add-in and Automation", + // Financial, Logical, Text, Statistical, Engineering, Database, Information, … + other => other, + } +} + fn find(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog) { ui.horizontal(|ui| { for tab in ["Find", "Replace"] { diff --git a/crates/ui-egui/src/editor.rs b/crates/ui-egui/src/editor.rs index 02d2428f9..6ef4d2401 100644 --- a/crates/ui-egui/src/editor.rs +++ b/crates/ui-egui/src/editor.rs @@ -224,20 +224,29 @@ pub fn formula_refs(text: &str) -> Vec<(usize, usize, RangeRef, Option, Tok::Sheet(a, _) => (Some(a.clone()), toks[i].start, i + 1), _ => (None, toks[i].start, i), }; - if let Some(Tok::Word(w)) = toks.get(j).map(|t| &t.tok) - && let Some(c) = CellRef::parse(w) - { - let mut end = toks[j].end; - let mut range = RangeRef::cell(c); - if let (Some(Tok::Colon | Tok::Trim(_)), Some(Tok::Word(w2))) = (toks.get(j + 1).map(|t| &t.tok), toks.get(j + 2).map(|t| &t.tok)) - && let Some(c2) = CellRef::parse(w2) - { - range = RangeRef::new(c, c2); - end = toks[j + 2].end; - i = j + 3; + if let Some(Tok::Word(w)) = toks.get(j).map(|t| &t.tok) { + let range_end = if matches!(toks.get(j + 1).map(|t| &t.tok), Some(Tok::Colon | Tok::Trim(_))) { + toks.get(j + 2).and_then(|token| { + let end = match &token.tok { + Tok::Word(w) => Some(w.clone()), + Tok::Number(n) if n.fract() == 0.0 && *n >= 1.0 => Some(format!("{n}")), + _ => None, + }?; + RangeRef::parse(&format!("{w}:{end}")).map(|range| (range, token.end)) + }) } else { + None + }; + let (range, end) = if let Some(range_end) = range_end { + i = j + 3; + range_end + } else if let Some(c) = CellRef::parse(w) { i = j + 1; - } + (RangeRef::cell(c), toks[j].end) + } else { + i += 1; + continue; + }; let key = body.get(start..end).unwrap_or("").replace('$', "").to_ascii_uppercase(); let idx = match seen.iter().position(|k| *k == key) { Some(k) => k, @@ -362,4 +371,26 @@ mod tests { assert!(!ref_is_on_sheet(Some("Sheet2"), "Sheet1", "Sheet1")); assert!(ref_is_on_sheet(Some("sheet2"), "Sheet1", "Sheet2"), "sheet names compare ignoring case"); } + + #[test] + fn whole_row_and_column_ref_spans() { + let refs = formula_refs("=SUM(C:C,4:6,$D:$E,Sheet2!7:8)"); + assert_eq!(refs.len(), 4); + assert_eq!(refs[0].2.a1(), "C:C"); + assert_eq!(refs[1].2.a1(), "4:6"); + assert_eq!(refs[2].2.a1(), "D:E"); + assert_eq!(refs[3].2.a1(), "7:8"); + assert_eq!(refs[3].3.as_deref(), Some("Sheet2")); + } + + #[test] + fn inserting_a_union_keeps_each_reference_separately_pointed() { + let mut edit = EditState::new(0, CellRef::new(0, 0), "=SUM(".into(), true, false); + edit.insert_ref("C:C"); + edit.append_ref("E:E"); + assert_eq!(edit.text, "=SUM(C:C,E:E"); + assert_eq!(formula_refs(&edit.text).iter().map(|r| r.2.a1()).collect::>(), ["C:C", "E:E"]); + assert_eq!(edit.point, Some((9, 12))); + assert!(edit.can_point()); + } } diff --git a/crates/ui-egui/src/formula_bar.rs b/crates/ui-egui/src/formula_bar.rs index a44369c42..ebc5bd996 100644 --- a/crates/ui-egui/src/formula_bar.rs +++ b/crates/ui-egui/src/formula_bar.rs @@ -39,20 +39,20 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) { ui.painter().rect_stroke(field, 4.0, Stroke::new(1.0, t.input_border), StrokeKind::Inside); let mut child = ui.new_child(egui::UiBuilder::new().max_rect(field.shrink2(vec2(6.0, 3.0)))); let id = egui::Id::new("gridcraft.formula_bar"); - let editing_here = app.editor.as_ref().is_some_and(|e| e.from_formula_bar); + let read_only = active_spill_anchor(app).is_some(); if app.editor.is_some() { editor_widget(app, &mut child, id, theme::ui_font(13.0), expanded, field.width() - 12.0); } else { // Show the active cell's content; clicking starts editing in the bar. let text = active_input(app); + let color = if read_only { t.text_disabled } else { t.text }; let resp = child - .add(egui::Label::new(egui::RichText::new(text).font(theme::ui_font(13.0)).color(t.text)).truncate().sense(Sense::click())); + .add(egui::Label::new(egui::RichText::new(text).font(theme::ui_font(13.0)).color(color)).truncate().sense(Sense::click())); let click = child.interact(field, id.with("click"), Sense::click()); - if resp.clicked() || click.clicked() { + if !read_only && (resp.clicked() || click.clicked()) { app.begin_edit(None, true); } } - let _ = editing_here; ui.allocate_space(vec2(avail, h - 6.0)); let chevron = if expanded { Icon::ChevronUp } else { Icon::Chevron }; if crate::widgets::icon_button(ui, chevron, t.text_dim, "Expand formula bar", vec2(20.0, 22.0)).clicked() { @@ -62,6 +62,14 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) { }); } +/// Resolve ownership on every frame; never cache a spill range in presentation state. +pub(crate) fn active_spill_anchor(app: &SheetApp) -> Option { + let d = app.session.active()?; + let sh = d.wb.active()?; + let at = sh.merge_at(d.selection.active).map(|m| m.start).unwrap_or(d.selection.active); + sh.spill_anchor(at) +} + fn active_input(app: &SheetApp) -> String { let Some(d) = app.session.active() else { return String::new() }; let Some(sh) = d.wb.active() else { return String::new() }; @@ -69,6 +77,15 @@ fn active_input(app: &SheetApp) -> String { if let Some(picture) = sh.cell_pictures.get(&a) { return if picture.alt.is_empty() { "Picture in cell".into() } else { format!("Picture: {}", picture.alt) }; } + if let Some(anchor) = sh.spill_anchor(a) { + if gridcraft_engine::display::formula_hidden(&d.wb, sh, anchor) { + return String::new(); + } + return sh + .cell(anchor) + .map(|c| crate::formula_locale::display(&c.input_text(), app.ui.language.formula_locale(), &d.wb, d.wb.active_sheet)) + .unwrap_or_default(); + } if let Some(c) = sh.cell(a) { if c.formula.is_some() && d.wb.styles.get(c.style).protection.hidden && sh.is_protected() { return String::new(); @@ -81,17 +98,6 @@ fn active_input(app: &SheetApp) -> String { gridcraft_engine::display::edit_text(&d.wb, sh, a) }; } - // A spilled cell shows the anchor's formula greyed out in Excel; we show it plainly. - for (anchor, r) in &sh.spill_ranges { - if r.contains(a) - && let Some(f) = sh.cell(*anchor).and_then(|c| c.formula.as_ref()) - { - if gridcraft_engine::display::formula_hidden(&d.wb, sh, *anchor) { - return String::new(); - } - return crate::formula_locale::display(&format!("={}", f.text), app.ui.language.formula_locale(), &d.wb, d.wb.active_sheet); - } - } String::new() } @@ -154,6 +160,18 @@ fn name_box(app: &mut SheetApp, ui: &mut egui::Ui, t: &Tokens) { }); } +/// Backspace and Delete take back the AutoComplete proposal (Excel's selected rest), not a typed character. +fn dismiss_completion(i: &mut egui::InputState, completion: &mut Option) { + if completion.is_some() && (i.consume_key(egui::Modifiers::NONE, Key::Backspace) || i.consume_key(egui::Modifiers::NONE, Key::Delete)) { + *completion = None; + } +} + +/// Column AutoComplete proposes only after typing: deleting characters (Backspace, Delete) brings no proposal back, as in Excel. +fn entry_shrank(chars_before: usize, text: &str) -> bool { + text.chars().count() < chars_before +} + /// The text editor used in the cell and in the formula bar (they share `app.editor`). pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font: FontId, multiline: bool, width: f32) { let Some(mut ed) = app.editor.take() else { return }; @@ -174,10 +192,7 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font: let enter = i.consume_key(egui::Modifiers::NONE, Key::Enter) || i.consume_key(egui::Modifiers::SHIFT, Key::Enter); let tab = i.consume_key(egui::Modifiers::NONE, Key::Tab) || i.consume_key(egui::Modifiers::SHIFT, Key::Tab); let esc = i.consume_key(egui::Modifiers::NONE, Key::Escape); - // Backspace takes back the AutoComplete proposal (Excel's selected rest), not a typed character. - if ed.completion.is_some() && i.consume_key(egui::Modifiers::NONE, Key::Backspace) { - ed.completion = None; - } + dismiss_completion(i, &mut ed.completion); (enter, tab, esc, alt_enter, cmd_enter, ctrl_shift_enter, m.shift) }); if alt_enter { @@ -281,6 +296,7 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font: job.wrap.max_width = if multiline { wrap_width } else { f32::INFINITY }; ui.fonts_mut(|f| f.layout_job(job)) }; + let chars_before = ed.text.chars().count(); let edit = if multiline || ed.text.contains('\n') { TextEdit::multiline(&mut ed.text) } else { TextEdit::singleline(&mut ed.text) }; let mut output = edit .id(id) @@ -315,6 +331,7 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font: ed.completion = None; if app.ui.autocomplete && !ed.is_formula() + && !entry_shrank(chars_before, &ed.text) && ed.caret == ed.text.chars().count() && let Some(sh) = app.session.active().and_then(|d| d.wb.sheet(ed.sheet)) { @@ -354,6 +371,7 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font: // Autocomplete list and argument hint under the editor. if mine && ui.is_rect_visible(resp.rect) && (!ed.autocomplete.is_empty() || ed.current_function().is_some()) { let below = resp.rect.left_bottom() + vec2(0.0, 4.0); + let language = app.ui.language; // Areas have their own clip rect: keep cell helpers inside the worksheet. let bounds = if ed.from_formula_bar { ui.ctx().content_rect() } else { ui.clip_rect() }; egui::Area::new(id.with("ac")).fixed_pos(below).constrain_to(bounds).order(egui::Order::Tooltip).show(ui.ctx(), |ui| { @@ -369,11 +387,11 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font: if r.clicked() { pick = Some(i); } - if sel && let Some(desc) = crate::formula_locale::description(n, ed.locale) { + if sel && let Some(desc) = crate::formula_locale::description(n, ed.locale, language) { r.on_hover_text(desc); } } - if let Some(desc) = ed.autocomplete.get(ed.ac_index).and_then(|n| crate::formula_locale::description(n, ed.locale)) { + if let Some(desc) = ed.autocomplete.get(ed.ac_index).and_then(|n| crate::formula_locale::description(n, ed.locale, language)) { ui.separator(); ui.add(egui::Label::new(egui::RichText::new(desc).small()).wrap()); } @@ -383,12 +401,12 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font: ed.request_focus = true; } } else if let Some((name, arg)) = ed.current_function() - && let Some(sig) = crate::formula_locale::signature(&name, ed.locale) + && let Some(sig) = crate::formula_locale::signature(&name, ed.locale, language) { ui.horizontal_wrapped(|ui| { ui.spacing_mut().item_spacing.x = 0.0; let (head, rest) = sig.split_once('(').unwrap_or((&sig, "")); - ui.label(egui::RichText::new(format!("{}(", ed.locale.function_name(head))).font(theme::ui_font(12.0))); + ui.label(egui::RichText::new(format!("{head}(")).font(theme::ui_font(12.0))); let inner = rest.trim_end_matches(')'); let parts: Vec<&str> = inner.split(&format!("{} ", ed.locale.list_separator())).collect(); for (i, part) in parts.iter().enumerate() { @@ -403,7 +421,7 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font: }); // The description too, so values an argument takes (DATEDIF's units, #363) show // while it's typed; wrapped to the hint's width. - if let Some(desc) = crate::formula_locale::description(&name, ed.locale) { + if let Some(desc) = crate::formula_locale::description(&name, ed.locale, language) { ui.set_max_width(ui.min_rect().width().max(220.0)); ui.separator(); ui.add(egui::Label::new(egui::RichText::new(desc).small()).wrap()); @@ -481,3 +499,163 @@ pub fn function_signature(name: &str) -> Option { pub fn function_description(name: &str) -> Option { function_info(name).and_then(|f| f["description"].as_str().map(str::to_string)) } + +#[cfg(test)] +mod spill_tests { + use super::*; + use gridcraft_engine::Session; + + fn app() -> SheetApp { + let mut s = Session::new(); + s.new_workbook(); + SheetApp::new(s, Default::default()) + } + + fn set(app: &mut SheetApp, cell: &str, input: &str) { + app.run("cell.set", json!({"cell": cell, "input": input})).unwrap(); + } + + fn select(app: &mut SheetApp, cell: &str) { + app.cancel_edit(); + app.run("selection.set", json!({"cell": cell})).unwrap(); + } + + fn check(app: &mut SheetApp, cell: &str, text: &str, read_only: bool) { + select(app, cell); + assert_eq!(active_input(app), text); + assert_eq!(active_spill_anchor(app).is_some(), read_only); + app.begin_edit(None, true); + assert_eq!(app.editor.is_none(), read_only); + app.cancel_edit(); + if read_only { + app.begin_edit(Some("=SUM(1,2)".into()), true); + assert!(app.editor.is_none()); + } + } + + #[test] + fn selection_restores_editing_and_keeps_spill_formulas_only_at_anchor() { + let mut app = app(); + set(&mut app, "A1", "=SEQUENCE(5,1,10,10)"); + set(&mut app, "D1", "=SUM(1,2)"); + set(&mut app, "D2", "hello"); + for cell in ["A2", "A3", "A4", "A5"] { + check(&mut app, cell, "=SEQUENCE(5,1,10,10)", true); + } + check(&mut app, "A1", "=SEQUENCE(5,1,10,10)", false); + check(&mut app, "D1", "=SUM(1,2)", false); + check(&mut app, "D2", "hello", false); + check(&mut app, "E1", "", false); + let sh = app.session.active().unwrap().wb.active().unwrap(); + assert!(sh.cell(gridcraft_engine::core::CellRef::parse("A2").unwrap()).and_then(|c| c.formula.as_ref()).is_none()); + // Intentional grid replacement retains the existing entry behavior. + select(&mut app, "A2"); + app.begin_edit(Some("replacement".into()), false); + assert!(app.editor.is_some()); + } + + #[test] + fn spill_ownership_follows_recalculation_and_obstructions() { + let mut app = app(); + set(&mut app, "D1", "2"); + set(&mut app, "A1", "=SEQUENCE(D1,2)"); + check(&mut app, "B2", "=SEQUENCE(D1,2)", true); + set(&mut app, "D1", "4"); + check(&mut app, "B4", "=SEQUENCE(D1,2)", true); + set(&mut app, "D1", "1"); + check(&mut app, "B4", "", false); + check(&mut app, "B1", "=SEQUENCE(D1,2)", true); + set(&mut app, "A1", ""); + check(&mut app, "B1", "", false); + set(&mut app, "B2", "blocked"); + set(&mut app, "A1", "=SEQUENCE(3,2)"); + check(&mut app, "A2", "", false); + check(&mut app, "B2", "blocked", false); + set(&mut app, "B2", ""); + check(&mut app, "B2", "=SEQUENCE(3,2)", true); + } + + #[test] + fn formatted_children_and_other_dynamic_array_functions() { + let mut app = app(); + app.run("home.bold", json!({"range": "A2"})).unwrap(); + set(&mut app, "D1", "3"); + set(&mut app, "D2", "1"); + set(&mut app, "D3", "3"); + for formula in ["=SORT(D1:D3)", "=UNIQUE(D1:D3)", "=FILTER(D1:D3,D1:D3>0)"] { + set(&mut app, "A1", formula); + check(&mut app, "A2", formula, true); + } + app.run("home.insertSheet", json!({"name": "Other"})).unwrap(); + set(&mut app, "A1", "=SORT(Sheet1!D1:D3)"); + check(&mut app, "A2", "=SORT(Sheet1!D1:D3)", true); + app.run("sheet.activate", json!({"sheet": "Sheet1"})).unwrap(); + check(&mut app, "A2", "=FILTER(D1:D3,D1:D3>0)", true); + } + #[test] + fn formula_bar_click_and_theme_presentation() { + use egui_kittest::kittest::Queryable as _; + for dark in [false, true] { + let mut app = app(); + set(&mut app, "A1", "=SEQUENCE(3)"); + select(&mut app, "A2"); + let mut fonts_ready = false; + let mut h = egui_kittest::Harness::builder().with_size(vec2(900.0, 100.0)).build_ui_state( + move |ui, app: &mut SheetApp| { + if !fonts_ready { + ui.ctx().set_fonts(theme::font_definitions()); + fonts_ready = true; + return; + } + ui.ctx().set_visuals(if dark { egui::Visuals::dark() } else { egui::Visuals::light() }); + show(app, ui); + }, + app, + ); + h.run_steps(3); + let disabled = Tokens::get(&h.ctx).text_disabled; + assert!(h.output().shapes.iter().any(|s| matches!(&s.shape, egui::Shape::Text(t) + if t.galley.job.text == "=SEQUENCE(3)" && t.galley.job.sections.iter().all(|section| section.format.color == disabled)))); + h.get_by_label("=SEQUENCE(3)").click(); + h.run_steps(3); + assert!(h.state().editor.is_none()); + select(h.state_mut(), "A1"); + h.run_steps(3); + h.get_by_label("=SEQUENCE(3)").click(); + h.run_steps(3); + assert!(h.state().editor.as_ref().is_some_and(|e| e.from_formula_bar)); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn press(key: Key) -> egui::RawInput { + let mut raw = egui::RawInput::default(); + raw.events.push(egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }); + raw + } + + #[test] + fn backspace_and_delete_dismiss_completion() { + let ctx = egui::Context::default(); + for key in [Key::Backspace, Key::Delete] { + let mut completion = Some("SomeText".to_string()); + let _ = ctx.run_ui(press(key), |ui| ui.input_mut(|i| dismiss_completion(i, &mut completion))); + assert_eq!(completion, None, "{key:?} must drop the proposal"); + } + let mut completion = Some("SomeText".to_string()); + let _ = ctx.run_ui(press(Key::A), |ui| ui.input_mut(|i| dismiss_completion(i, &mut completion))); + assert!(completion.is_some()); + } + + #[test] + fn only_typing_proposes_completion() { + assert!(!entry_shrank(4, "SomeT")); + // Backspacing "Some" to "Som" must not bring "SomeText" back. + assert!(entry_shrank(4, "Som")); + assert!(!entry_shrank(4, "Same")); + } +} diff --git a/crates/ui-egui/src/formula_locale.rs b/crates/ui-egui/src/formula_locale.rs index 6e833b95d..c34e3cf86 100644 --- a/crates/ui-egui/src/formula_locale.rs +++ b/crates/ui-egui/src/formula_locale.rs @@ -1,4 +1,5 @@ //! UI formula syntax boundary. Engine commands, control/API callers and XLSX keep English. +use crate::i18n::Language; use gridcraft_engine::formula::{self, FormulaLocale, ParseError}; use gridcraft_engine::model::Workbook; @@ -86,39 +87,55 @@ fn hide_udf_prefix(text: &str, locale: FormulaLocale) -> String { out } -// Descriptions/signatures for the supported Spanish function table; untranslated functions -// retain their English help. Parameter punctuation follows the same locale as entry. -const SPANISH_HELP: &[(&str, &str, &str)] = &[ - ("SUM", "SUMA(número1; [número2]; ...)", "Suma los números."), - ("AVERAGE", "PROMEDIO(número1; [número2]; ...)", "Devuelve el promedio de los números."), - ("IF", "SI(prueba_lógica; valor_si_verdadero; [valor_si_falso])", "Devuelve un valor según una condición."), - ("COUNT", "CONTAR(valor1; [valor2]; ...)", "Cuenta las celdas que contienen números."), - ("SUMIF", "SUMAR.SI(rango; criterio; [rango_suma])", "Suma las celdas que cumplen un criterio."), - ("COUNTIF", "CONTAR.SI(rango; criterio)", "Cuenta las celdas que cumplen un criterio."), - ("VLOOKUP", "BUSCARV(valor_buscado; matriz; indicador_columnas; [ordenado])", "Busca un valor en la primera columna de una tabla."), - ("TODAY", "HOY()", "Devuelve la fecha actual."), - ("TRUE", "VERDADERO()", "Devuelve el valor lógico VERDADERO."), - ("FALSE", "FALSO()", "Devuelve el valor lógico FALSO."), -]; - -pub(crate) fn signature(name: &str, locale: FormulaLocale) -> Option { - let canonical = locale.canonical_name(name); - if locale == FormulaLocale::Es - && let Some((_, signature, _)) = SPANISH_HELP.iter().find(|(name, ..)| *name == canonical) - { - return Some((*signature).into()); - } - crate::formula_bar::function_signature(canonical).map(|s| s.replace(',', &locale.list_separator().to_string())) +/// A function's argument hint. The name and separators follow the formula syntax; the argument +/// names follow the interface language, all of them or none, so a hint never mixes two languages. +pub(crate) fn signature(name: &str, locale: FormulaLocale, language: Language) -> Option { + let english = crate::formula_bar::function_signature(locale.canonical_name(name))?; + let Some((head, args)) = split_signature(&english) else { return Some(english) }; + let separator = format!("{} ", locale.list_separator()); + let args = match args.iter().map(|arg| translate_argument(arg, language)).collect::>>() { + Some(translated) => translated.join(&separator), + None => args.join(&separator), + }; + Some(format!("{}({args})", locale.function_name(head))) } -pub(crate) fn description(name: &str, locale: FormulaLocale) -> Option { - let canonical = locale.canonical_name(name); - if locale == FormulaLocale::Es - && let Some((_, _, description)) = SPANISH_HELP.iter().find(|(name, ..)| *name == canonical) - { - return Some((*description).into()); +/// A function's description in the interface language (English while untranslated). +pub(crate) fn description(name: &str, locale: FormulaLocale, language: Language) -> Option { + let english = crate::formula_bar::function_description(locale.canonical_name(name))?; + Some(language.function_description(&english).map_or(english, str::to_string)) +} + +/// `SUM(number1, [number2], ...)` -> `SUM` and its arguments as written. +fn split_signature(signature: &str) -> Option<(&str, Vec<&str>)> { + let (head, rest) = signature.split_once('(')?; + let inner = rest.strip_suffix(')')?; + Some((head, if inner.is_empty() { Vec::new() } else { inner.split(", ").collect() })) +} + +/// The catalog names of a signature's arguments (`number` for `[number2]`), without `...`; the +/// function help tables are checked against them. +#[cfg(test)] +pub(crate) fn argument_names(signature: &str) -> Vec<&str> { + split_signature(signature).map(|(_, args)| args.into_iter().map(argument_name).filter(|name| *name != "...").collect()).unwrap_or_default() +} + +/// `[number2]` -> `number`. A bracket can open or close on its own (SWITCH's +/// `[default_or_value2, result2]`, LAMBDA's `[parameter1, ...]`). +fn argument_name(arg: &str) -> &str { + let arg = arg.strip_prefix('[').unwrap_or(arg); + let arg = arg.strip_suffix(']').unwrap_or(arg); + arg.trim_end_matches(|c: char| c.is_ascii_digit()) +} + +/// `[number2]` -> `[número2]`: brackets, index and `...` stay. +fn translate_argument(arg: &str, language: Language) -> Option { + let name = argument_name(arg); + if name == "..." { + return Some(arg.to_string()); } - crate::formula_bar::function_description(canonical) + // `arg` is `[`? + `name` + index + `]`?, so the first match is the name itself. + Some(arg.replacen(name, language.function_argument(name)?, 1)) } /// Avoid suggesting an alias that would call a workbook/table name instead of the function. @@ -138,3 +155,34 @@ pub(crate) fn display_template(text: &str, locale: FormulaLocale, wb: &Workbook, } display(text, locale, wb, sheet) } + +#[cfg(test)] +mod tests { + use super::*; + + /// The hint for `name`, empty when there is none. + fn sig(name: &str, locale: FormulaLocale, language: Language) -> String { + signature(name, locale, language).unwrap_or_default() + } + + /// Hints keep the formula syntax's name and separators and translate the argument names + /// only when the interface language has them all. + #[test] + fn function_help_follows_the_interface_language_and_the_formula_syntax() { + use FormulaLocale::{En, Es}; + // Spanish help over English formulas: the name and `,` stay as typed; brackets, indexes + // and `...` survive. + assert_eq!(sig("IF", En, Language::Es), "IF(prueba_lógica, [valor_si_verdadero], [valor_si_falso])"); + assert_eq!(sig("SUM", En, Language::Es), "SUM(número1, [número2], ...)"); + assert_eq!(description("IF", En, Language::Es).as_deref(), Some("Devuelve un valor según una condición.")); + // Spanish names and separators, from a localized name. + assert_eq!(sig("suma", Es, Language::Es), "SUMA(número1; [número2]; ...)"); + assert_eq!(description("SUMA", Es, Language::Es).as_deref(), Some("Suma los números.")); + // An argument list Spanish covers only in part stays English instead of mixing (the name + // is whatever the Spanish formula table calls SUMIFS). + let sumifs = sig("SUMIFS", Es, Language::Es); + assert!(sumifs.ends_with("(sum_range; criteria_range1; criteria1; ...)"), "{sumifs}"); + assert_eq!(description("SUMIFS", Es, Language::Es).as_deref(), Some("Adds the cells that meet every condition.")); + assert_eq!(sig("IF", En, Language::En), "IF(logical_test, [value_if_true], [value_if_false])"); + } +} diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index ac569b5d6..9444d369a 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -78,8 +78,10 @@ pub enum Drag { start: f32, orig: f32, }, + /// Dragging the fill handle; `menu` when the right button drags it (the fill menu opens on release). Fill { target: Option, + menu: bool, }, /// Dragging a freeze handle: `cols` for the column bar, `to` the line count it would freeze. Freeze { @@ -114,6 +116,10 @@ pub struct GridState { /// Column and row of the clicked header cell, and where to open the menu. pub filter_menu: Option<(u32, u32, Pos2)>, pub context_menu: Option, + /// The frame a right-click opened `context_menu` in: that click's own press doesn't close it. + pub context_menu_frame: u64, + /// After a right-drag of the fill handle: the source, the dragged range and where to open the menu. + pub fill_menu: Option<(RangeRef, RangeRef, Pos2)>, pub marching_phase: f32, /// The selection outline on its way to a new selection. pub glide: crate::selection_glide::SelectionGlide, @@ -434,18 +440,7 @@ fn col32(c: Color, wb: &Workbook, default: Color32) -> Color32 { } fn numfmt_color(c: gridcraft_engine::numfmt::FormatColor) -> Color32 { - use gridcraft_engine::numfmt::FormatColor as F; - match c { - F::Black => Color32::BLACK, - F::Blue => Color32::from_rgb(0, 0, 255), - F::Cyan => Color32::from_rgb(0, 255, 255), - F::Green => Color32::from_rgb(0, 128, 0), - F::Magenta => Color32::from_rgb(255, 0, 255), - F::Red => Color32::from_rgb(255, 0, 0), - F::White => Color32::WHITE, - F::Yellow => Color32::from_rgb(255, 255, 0), - F::Indexed(_) => Color32::BLACK, - } + c.rgb().map(|[r, g, b]| Color32::from_rgb(r, g, b)).unwrap_or(Color32::BLACK) } /// Last column of a Center Across Selection run starting at `start`: it extends over following columns (up to `limit`) while `continues` holds. @@ -756,7 +751,8 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) { } // Headers. if sh.show_headings { - paint_headers(&painter, &geo, sh, &sel, &t, &quads); + let headings = table_headings(&geo, sh, sel.active); + paint_headers(&painter, &geo, sh, &sel, &t, &quads, &headings); paint_freeze_handles(&painter, &geo, sh, &app.grid.drag, &t); } crate::outline::paint(&painter, &geo, sh, &t); @@ -768,7 +764,7 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) { crate::paper_view::paint_rulers(app, &painter, rect, &geo, &t); } // Interaction. - let previous_popups = (app.grid.filter_menu, app.grid.list_picker, app.grid.header_menu); + let previous_popups = (app.grid.filter_menu, app.grid.list_picker, app.grid.header_menu, app.grid.fill_menu); // A pan the grid never saw end (it was hidden mid-drag) must not swallow the next press. if app.grid.touch_pan && !resp.dragged() && !resp.drag_stopped() { app.grid.touch_pan = false; @@ -791,6 +787,7 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) { list_picker(app, ui, &geo, previous_popups.1 == app.grid.list_picker); header_menu(app, ui, previous_popups.2 == app.grid.header_menu); context_menu(app, ui); + fill_menu(app, ui, previous_popups.3 == app.grid.fill_menu); let track_x = if rtl { (available.left(), rect.left()) } else { (rect.right(), available.right()) }; vertical_scrollbar(app, ui, sh, &geo, track_x, max_y, &t); if app.session.clipboard.is_some() { @@ -1418,10 +1415,7 @@ pub(crate) fn paint_quadrant( if !range.contains(c) { continue; } - let r = filter_button_rect(geo, sh, c); - p.rect_filled(r, 2.0, Color32::from_gray(250)); - p.rect_stroke(r, 2.0, Stroke::new(1.0, Color32::from_gray(170)), StrokeKind::Inside); - crate::icons::paint(p, r.shrink(1.5), if active { crate::icons::Icon::Filter } else { crate::icons::Icon::Chevron }, Color32::from_gray(70)); + paint_filter_button(p, filter_button_rect(geo, sh, c), active); } } @@ -1433,6 +1427,67 @@ pub fn filter_button_rect(geo: &Geo, sh: &Sheet, c: CellRef) -> Rect { Rect::from_min_size(pos2(rect.right() - s - pad, rect.bottom() - s - pad), vec2(s, s)) } +/// A Table column heading that shows the column's header name in place of its letters. +#[derive(Clone, Debug)] +pub struct TableHeading { + pub col: u32, + /// The table's header row, which the filter menu is keyed by. + pub header_row: u32, + pub name: String, + /// The heading slot over the column (logical coordinates). + pub rect: Rect, + /// The filter button, when the table shows filter buttons. + pub button: Option, + /// The column is filtered (the button shows the funnel). + pub filtered: bool, +} + +/// The Table whose header names replace the column letters: the one holding the active cell, +/// with its header row scrolled out of view and some of its rows still in view (Excel, #1423). +pub fn heading_table<'a>(geo: &Geo, sh: &'a Sheet, active: CellRef) -> Option<&'a gridcraft_engine::model::Table> { + // Page Layout view keeps the letters. + if geo.paper.is_some() || geo.header_h <= 0.0 { + return None; + } + let tb = sh.tables.iter().find(|t| t.header_row && t.range.contains(active))?; + let header = tb.range.start.row; + // A header row in the frozen rows is always on screen. + if header < geo.fr { + return None; + } + let top = geo.cells.top() + geo.frozen_h; + let bottom = |r: u32| geo.y(sh, r) + sh.row_height(r) * geo.z; + (bottom(header) <= top + 0.5 && bottom(tb.range.end.row) > top + 0.5).then_some(tb) +} + +/// The headings of `heading_table`'s visible columns. +pub fn table_headings(geo: &Geo, sh: &Sheet, active: CellRef) -> Vec { + let Some(tb) = heading_table(geo, sh, active) else { return Vec::new() }; + let af = sh.autofilter.as_ref().filter(|af| af.range == tb.range); + let mut out = Vec::new(); + for col in tb.range.start.col..=tb.range.end.col { + let w = sh.col_width(col) * geo.z; + let off = col - tb.range.start.col; + let Some(column) = tb.columns.get(off as usize) else { continue }; + if w <= 0.0 { + continue; + } + let rect = Rect::from_min_size(pos2(geo.x(sh, col), geo.head_top()), vec2(w, geo.header_h)); + // Clear of the 4-point column resize edge, and never wider than the column. + let s = (rect.height() - 6.0).clamp(10.0, 16.0).min(w - 7.0); + let button = (tb.filter_button && s > 0.0).then(|| Rect::from_min_size(pos2(rect.right() - s - 5.0, rect.center().y - s / 2.0), vec2(s, s))); + let filtered = tb.filter.iter().any(|(o, _)| *o == off) || af.is_some_and(|af| af.criteria.iter().any(|(o, _)| *o == off)); + out.push(TableHeading { col, header_row: tb.range.start.row, name: column.name.clone(), rect, button, filtered }); + } + out +} + +fn paint_filter_button(p: &Painter, r: Rect, filtered: bool) { + p.rect_filled(r, 2.0, Color32::from_gray(250)); + p.rect_stroke(r, 2.0, Stroke::new(1.0, Color32::from_gray(170)), StrokeKind::Inside); + crate::icons::paint(p, r.shrink(1.5), if filtered { crate::icons::Icon::Filter } else { crate::icons::Icon::Chevron }, Color32::from_gray(70)); +} + /// The outline segments of merged area `m` drawn at `rect`: the top and left edges come from the /// first row's and first column's cells, the bottom and right edges from the last row's and last /// column's cells, one segment per visible cell (limited to the visible `rows` × `cols`). @@ -1682,7 +1737,7 @@ pub(crate) fn paint_selection( p.rect_filled(h, 0.0, t.sel_border); } // Fill-handle drag preview. - if let crate::grid::Drag::Fill { target: Some(tr) } = &app.grid.drag + if let crate::grid::Drag::Fill { target: Some(tr), .. } = &app.grid.drag && range_in_pane(*tr, rows, cols) { let rr = geo.range_rect(sh, *tr); @@ -1728,6 +1783,7 @@ pub(crate) fn paint_headers( sel: &gridcraft_engine::Selection, t: &Tokens, quads: &[((u32, u32), (u32, u32), Rect)], + headings: &[TableHeading], ) { let font = theme::ui_font((11.5 * (geo.z / DISPLAY_SCALE).max(0.75)).min(16.0)); // The headers' own area: inside the outline bands. @@ -1742,6 +1798,8 @@ pub(crate) fn paint_headers( let full_col = |c: u32| sel.ranges.iter().any(|x| x.is_full_cols() && c >= x.start.col && c <= x.end.col); let full_row = |rr: u32| sel.ranges.iter().any(|x| x.is_full_rows() && rr >= x.start.row && rr <= x.end.row); let _ = bounds; + // Hidden columns and rows leave a double divider where they sit, as in Excel. + let hidden_mark = Stroke::new(1.0, t.header_text.gamma_multiply(0.6)); let mut done_cols = std::collections::HashSet::new(); let mut done_rows = std::collections::HashSet::new(); for (rows, cols, clip) in quads { @@ -1762,8 +1820,28 @@ pub(crate) fn paint_headers( pc.line_segment([pos2(cr.left(), cr.bottom() - 1.0), pos2(cr.right(), cr.bottom() - 1.0)], Stroke::new(2.0, t.accent)); } pc.line_segment([pos2(cr.right() - 0.5, cr.top() + 3.0), pos2(cr.right() - 0.5, cr.bottom())], Stroke::new(1.0, t.header_line)); + let (before, after) = hidden_around(c, |i| sh.col_width(i)); + for x in [before.then_some(cr.left()), after.then_some(cr.right())].into_iter().flatten() { + for dx in [-1.5, 1.5] { + pc.line_segment([pos2(x + dx, cr.top() + 3.0), pos2(x + dx, cr.bottom())], hidden_mark); + } + } let color = if col_sel(c) { t.header_sel_text } else { t.header_text }; - pc.text(cr.center(), Align2::CENTER_CENTER, col_to_letters(c), font.clone(), color); + match headings.iter().find(|h| h.col == c) { + Some(h) => { + // The name is cut off at the button, as a cell's text is at its neighbour. + let end = h.button.map_or(cr.right(), |b| b.left()) - 2.0; + let room = Rect::from_min_max(cr.min, pos2(end.max(cr.left()), cr.bottom())).intersect(pc.clip_rect()); + let pad = 4.0 * (geo.z / DISPLAY_SCALE).max(0.75); + pc.with_clip_rect(room).text(pos2(cr.left() + pad, cr.center().y), Align2::LEFT_CENTER, &h.name, font.clone(), color); + if let Some(b) = h.button { + paint_filter_button(&pc, b, h.filtered); + } + } + None => { + pc.text(cr.center(), Align2::CENTER_CENTER, col_to_letters(c), font.clone(), color); + } + } } } let pr = p.with_clip_rect(Rect::from_min_max(pos2(left.left(), clip.top()), pos2(left.right(), clip.bottom()))); @@ -1783,6 +1861,12 @@ pub(crate) fn paint_headers( pr.line_segment([pos2(cr.right() - 1.0, cr.top()), pos2(cr.right() - 1.0, cr.bottom())], Stroke::new(2.0, t.accent)); } pr.line_segment([pos2(cr.left() + 3.0, cr.bottom() - 0.5), pos2(cr.right(), cr.bottom() - 0.5)], Stroke::new(1.0, t.header_line)); + let (before, after) = hidden_around(rr, |i| sh.row_height(i)); + for y in [before.then_some(cr.top()), after.then_some(cr.bottom())].into_iter().flatten() { + for dy in [-1.5, 1.5] { + pr.line_segment([pos2(cr.left() + 3.0, y + dy), pos2(cr.right(), y + dy)], hidden_mark); + } + } let color = if row_sel(rr) { t.header_sel_text } else { t.header_text }; pr.text(cr.center(), Align2::CENTER_CENTER, (rr as u64 + 1).to_string(), font.clone(), color); } @@ -1801,6 +1885,12 @@ pub(crate) fn paint_headers( p.line_segment([pos2(geo.cells.left() - 0.5, r.top()), pos2(geo.cells.left() - 0.5, r.bottom())], Stroke::new(1.0, t.header_line)); } +/// Whether the line before / after visible line `i` is hidden (zero size), so its header edges +/// get the hidden marker. Line 0 has nothing before it. +fn hidden_around(i: u32, size: impl Fn(u32) -> f32) -> (bool, bool) { + (i > 0 && size(i - 1) <= 0.0, i < u32::MAX && size(i + 1) <= 0.0) +} + // ------------------------------------------------------------------ interaction /// How close (in points) a press must be to an object's bottom-right corner to resize it instead of moving it. @@ -1875,10 +1965,16 @@ fn update_cursor(app: &mut SheetApp, ui: &egui::Ui, resp: &egui::Response, geo: 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 (in_col_header && col_edge(geo, sh, p.x).is_some()) || (in_row_header && row_edge(geo, sh, p.y).is_some()) { + let near_col_edge = in_col_header && col_edge(geo, sh, p.x).is_some(); + // Just past a hidden column or row, Excel shows a split cursor: dragging or + // double-clicking there unhides it. + let hidden = app.grid.drag == Drag::None + && if near_col_edge { hidden_col_edge(geo, sh, p.x).is_some() } else { hidden_row_edge(geo, sh, p.y).is_some() }; + match pointer.hover_pos() { + Some(at) if hidden => paint_split_cursor(ctx, at, near_col_edge), + _ => ctx.set_cursor_icon(if near_col_edge { CursorIcon::ResizeColumn } else { 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() { @@ -2020,7 +2116,16 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & if resp.double_clicked() && let Some(p) = pos { - let autofit = if in_col_header { + let unhide = if in_col_header { + hidden_col_edge(geo, sh, p.x).map(|col| ("home.unhideColumns", json!({"cols": RangeRef::cols(col, col).a1()}))) + } else if in_row_header { + hidden_row_edge(geo, sh, p.y).map(|row| ("home.unhideRows", json!({"rows": RangeRef::rows(row, row).a1()}))) + } else { + None + }; + let autofit = if unhide.is_some() { + unhide + } else if in_col_header { col_edge(geo, sh, p.x).map(|(col, _)| { let selected = sel.ranges.iter().any(|r| r.is_full_cols() && col >= r.start.col && col <= r.end.col); ("home.autofitColumnWidth", if selected { json!({}) } else { json!({"cols": RangeRef::cols(col, col).a1()}) }) @@ -2089,11 +2194,18 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & return; } let c = geo.col_at(sh, p.x); - if point_header(app, wb, sh, RangeRef::cols(c, c)) { + // A Table heading's filter button, while the header names stand in for the letters. + if let Some(h) = table_headings(geo, sh, sel.active).into_iter().find(|h| h.col == c && h.button.is_some_and(|b| b.contains(p))) { + app.grid.filter_menu = Some((c, h.header_row, geo.mirror_pos(p))); return; } + if point_header(app, wb, sh, RangeRef::cols(c, c), mods.shift, mods.command) { + return; + } + // An armed Format Painter paints the columns dragged over instead of moving them. if resp.drag_started() && !mods.any() + && app.session.format_painter.is_none() && sel.ranges.len() == 1 && sel.current().is_full_cols() && sel.current().contains(CellRef::new(0, c)) @@ -2112,7 +2224,7 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & return; } let r = geo.row_at(sh, p.y); - if point_header(app, wb, sh, RangeRef::rows(r, r)) { + if point_header(app, wb, sh, RangeRef::rows(r, r), mods.shift, mods.command) { return; } let range = if mods.shift { RangeRef::rows(sel.anchor.row.min(r), sel.anchor.row.max(r)) } else { RangeRef::rows(r, r) }; @@ -2173,7 +2285,7 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & return; } if on_handle(p) { - app.grid.drag = Drag::Fill { target: None }; + app.grid.drag = Drag::Fill { target: None, menu: resp.drag_started_by(egui::PointerButton::Secondary) }; return; } if on_cur_border(p) && resp.drag_started() { @@ -2308,10 +2420,10 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & Drag::Freeze { cols, .. } => { app.grid.drag = Drag::Freeze { cols, to: Some(freeze_boundary(geo, sh, p, cols)) }; } - Drag::Fill { .. } => { + Drag::Fill { menu, .. } => { let src = sel.current(); let target = fill_target(src, pc); - app.grid.drag = Drag::Fill { target: Some(target) }; + app.grid.drag = Drag::Fill { target: Some(target), menu }; scroll_by(app, auto); } Drag::Move { grab, .. } => { @@ -2343,12 +2455,27 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & // Release. if resp.drag_stopped() || (!pointer.any_down() && app.grid.drag != Drag::None && !resp.dragged()) { match std::mem::take(&mut app.grid.drag) { - Drag::Select if resp.drag_stopped() => { - if let Some(p) = pos { + Drag::Select => { + if resp.drag_stopped() + && let Some(p) = pos + { select_to_pointer(app, geo, sh, wb, p); } + format_painter_apply(app); } - Drag::Fill { target: Some(t) } => { + // Whole rows or columns clicked or dragged over by their headers. + Drag::Rows(_) | Drag::Cols(_) => format_painter_apply(app), + // Esc mid-drag lands here too (egui ends the drag on Esc): no menu, and the key is + // spent on the drag so the grid's Esc doesn't also drop the copy. + Drag::Fill { target: Some(t), menu: true } => { + if let Some(p) = pos + && t != sel.current() + && !ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) + { + app.grid.fill_menu = Some((sel.current(), t, geo.mirror_pos(p))); + } + } + Drag::Fill { target: Some(t), menu: false } => { let src = sel.current(); if t != src { // Ctrl (Cmd on macOS) swaps copying and extending, as in Excel. @@ -2406,6 +2533,7 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & app.grid.header_menu = Some((geo.mirror_pos(p), rows)); } else { app.grid.context_menu = Some(geo.mirror_pos(p)); + app.grid.context_menu_frame = ui.ctx().cumulative_frame_nr(); } } @@ -2415,13 +2543,53 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & keyboard(app, &ctx, resp, geo, sh); } -/// Point mode on a header: inserts the whole-column or whole-row reference instead of selecting. -fn point_header(app: &mut SheetApp, wb: &Workbook, sh: &Sheet, range: RangeRef) -> bool { +/// An armed Format Painter pastes onto the cells just clicked or dragged over (not while +/// pointing a formula reference, which also drags a Select). +fn format_painter_apply(app: &mut SheetApp) { + if app.session.format_painter.is_some() && !app.editor.as_ref().is_some_and(|ed| ed.can_point()) { + let dest = app.session.active().map(|d| d.selection.current().a1()); + if let Some(dest) = dest + && let Err(e) = app.run("edit.formatPainter", json!({"apply": dest})) + { + app.message = Some(("GridCraft".into(), crate::clean_error(&e))); + } + } +} + +/// Point mode on a header: inserts a whole-row or whole-column reference instead of selecting. +fn point_header(app: &mut SheetApp, wb: &Workbook, sh: &Sheet, range: RangeRef, extend: bool, add: bool) -> bool { let Some(ed) = app.editor.as_mut().filter(|ed| ed.can_point()) else { return false }; - 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); + let edit_sheet = ed.sheet; + let active_sheet = wb.active_sheet; + let range = if extend { + ed.point + .and_then(|point| crate::editor::formula_refs(&ed.text).into_iter().find(|(start, end, ..)| (*start, *end) == point)) + .and_then(|(_, _, previous, previous_sheet, _)| { + let previous_sheet = previous_sheet.as_deref().map(|name| wb.sheet_index(name)).unwrap_or(Some(edit_sheet)); + if previous_sheet != Some(active_sheet) { + return None; + } + if range.is_full_cols() && previous.is_full_cols() { + Some(RangeRef::cols(previous.start.col.min(range.start.col), previous.end.col.max(range.end.col))) + } else if range.is_full_rows() && previous.is_full_rows() { + Some(RangeRef::rows(previous.start.row.min(range.start.row), previous.end.row.max(range.end.row))) + } else { + None + } + }) + .unwrap_or(range) + } else { + range + }; + let reference = range.a1(); + let reference = if ed.sheet != wb.active_sheet { format!("{}!{reference}", gridcraft_engine::formula::quote_sheet(&sh.name)) } else { reference }; + if add { + ed.append_ref(&reference); + } else { + ed.insert_ref(&reference); + } ed.point_anchor = None; + ed.point_cell = Some(range.start); ed.request_focus = true; true } @@ -2581,6 +2749,36 @@ fn row_edge(geo: &Geo, sh: &Sheet, y: f32) -> Option<(u32, f32)> { None } +/// The hidden column whose (zero-width) edge is at `x`: only just right of the boundary, as the +/// edge just left of it belongs to the visible column there. +fn hidden_col_edge(geo: &Geo, sh: &Sheet, x: f32) -> Option { + col_edge(geo, sh, x).map(|(c, _)| c).filter(|&c| sh.col_width(c) <= 0.0) +} + +fn hidden_row_edge(geo: &Geo, sh: &Sheet, y: f32) -> Option { + row_edge(geo, sh, y).map(|(r, _)| r).filter(|&r| sh.row_height(r) <= 0.0) +} + +/// A split cursor (two bars with arrows either side) painted in place of the OS cursor, which +/// has no such shape. Black on a white outline, like the system cursors. +fn paint_split_cursor(ctx: &egui::Context, at: Pos2, cols: bool) { + ctx.set_cursor_icon(CursorIcon::None); + let p = ctx.layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::new("gridcraft.split_cursor"))); + // Drawn for columns (bars vertical); rows swap the axes. + let pt = |x: f32, y: f32| if cols { pos2(at.x + x, at.y + y) } else { pos2(at.x + y, at.y + x) }; + let arrow = |dir: f32| vec![pt(dir * 10.0, 0.0), pt(dir * 6.0, -4.0), pt(dir * 6.0, 4.0)]; + for (width, color) in [(3.0, Color32::WHITE), (1.0, Color32::BLACK)] { + let stroke = Stroke::new(width, color); + for bx in [-1.5, 1.5] { + p.line_segment([pt(bx, -8.0), pt(bx, 8.0)], stroke); + } + p.line_segment([pt(-7.0, 0.0), pt(7.0, 0.0)], stroke); + for dir in [-1.0, 1.0] { + p.add(egui::Shape::convex_polygon(arrow(dir), color, Stroke::new(width - 1.0, color))); + } + } +} + fn is_filter_button(sh: &Sheet, geo: &Geo, p: Pos2) -> bool { let c = geo.cell_at(sh, p); sh.has_filter_button(c) && filter_button_rect(geo, sh, c).contains(p) @@ -2661,7 +2859,16 @@ fn keyboard(app: &mut SheetApp, ctx: &egui::Context, resp: &egui::Response, geo: } let other_focus = ctx.memory(|m| m.focused()).is_some_and(|f| f != resp.id && (editing || !stale.contains(&f))); // An open filter menu owns the keyboard even when a click inside it left focus on nothing (#1516). - if editing || app.dialog.is_some() || other_focus || app.message.is_some() || app.grid.filter_menu.is_some() { + // So do the fill menu (its Esc closes it, not the copy) and the right-drag that opens it (an + // arrow key would move the selection the menu fills from). + if editing + || app.dialog.is_some() + || other_focus + || app.message.is_some() + || app.grid.filter_menu.is_some() + || app.grid.fill_menu.is_some() + || matches!(app.grid.drag, Drag::Fill { menu: true, .. }) + { return; } if ctx.memory(|m| m.focused()).is_none() { @@ -2812,6 +3019,9 @@ fn in_cell_editor(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, sh: &Sheet, let id = egui::Id::new("gridcraft.cell_editor"); let mut child = ui.new_child(egui::UiBuilder::new().max_rect(erect.shrink2(vec2(2.0, 1.0)))); child.set_clip_rect(clip); + // The theme's caret is meant for the UI chrome and vanishes on some fills (and, in the dark + // theme, on the white paper): pick black or white against the fill, like Excel. + child.visuals_mut().text_cursor.stroke.color = ink_on(fill); crate::formula_bar::editor_widget(app, &mut child, id, font, false, erect.width() - 4.0); } @@ -2821,6 +3031,12 @@ fn popup_dismissed(response: &egui::Response, was_open: bool) -> bool { response.ctx.input(|i| !i.focused || i.key_pressed(egui::Key::Escape)) || (was_open && response.clicked_elsewhere()) } +/// Black or white, whichever reads on `fill` (the threshold chart labels use). +fn ink_on(fill: Color32) -> Color32 { + let l = 0.299 * fill.r() as f32 + 0.587 * fill.g() as f32 + 0.114 * fill.b() as f32; + if l < 150.0 { Color32::WHITE } else { Color32::BLACK } +} + /// The table whose header cell (`row`, `col`) was clicked, not just the first table in the column. fn filter_table_range(sh: &Sheet, col: u32, row: u32) -> Option { sh.tables.iter().find(|t| t.range.start.row == row && col >= t.range.start.col && col <= t.range.end.col).map(|t| t.range) @@ -3015,9 +3231,14 @@ fn context_menu(app: &mut SheetApp, ui: &mut egui::Ui) { } }); }); + // A quick right-click presses and releases in one frame, and the menu's corner snaps to the + // nearest physical pixel, which can put the pointer just outside it: that press opened the + // menu, so it doesn't count as a click elsewhere. + let opened_now = app.grid.context_menu_frame == ui.ctx().cumulative_frame_nr(); if close || ui.input(|i| i.key_pressed(egui::Key::Escape)) - || (ui.input(|i| i.pointer.any_pressed()) + || (!opened_now + && ui.input(|i| i.pointer.any_pressed()) && !resp.response.contains_pointer() && !resp.response.hovered() && ui.input(|i| i.pointer.interact_pos()).is_some_and(|p| !resp.response.rect.contains(p))) @@ -3026,6 +3247,45 @@ fn context_menu(app: &mut SheetApp, ui: &mut egui::Ui) { } } +/// The menu a right-drag of the fill handle opens on release, as in Excel. +fn fill_menu(app: &mut SheetApp, ui: &mut egui::Ui, was_open: bool) { + let Some((src, target, at)) = app.grid.fill_menu else { return }; + let mut close = false; + let response = egui::Area::new(egui::Id::new("fill_menu")).fixed_pos(at).order(egui::Order::Foreground).show(ui.ctx(), |ui| { + egui::Frame::popup(ui.style()).show(ui, |ui| { + ui.set_min_width(190.0); + let items = [ + ("Copy Cells", "copy"), + ("Fill Series", "extend"), + ("Fill Formatting Only", "formats"), + ("Fill Without Formatting", "values"), + ("-", ""), + ("Series…", "ui:series"), + ]; + let lang = crate::i18n::current(ui.ctx()); + for (label, mode) in items { + if label == "-" { + ui.separator(); + continue; + } + if ui.add(egui::Button::new(lang.tr(label)).frame(false).min_size(vec2(180.0, 20.0))).clicked() { + close = true; + if mode == "ui:series" { + let _ = app.session.run("selection.set", json!({"range": target.union(&src).a1(), "active": src.start.a1()})); + let direction = if target.width() != src.width() { "rows" } else { "columns" }; + app.open_dialog("series", json!({"direction": direction})); + } else if let Err(e) = app.run("edit.autoFill", json!({"source": src.a1(), "target": target.a1(), "mode": mode})) { + app.message = Some(("GridCraft".into(), crate::clean_error(&e))); + } + } + } + }); + }); + if close || popup_dismissed(&response.response, was_open) { + app.grid.fill_menu = None; + } +} + /// Used by `EditState` users to keep the active cell visible while pointing. pub fn point_move(app: &mut SheetApp, ed: &mut EditState, dr: i64, dc: i64, extend: bool) { let Some(d) = app.session.active() else { return }; @@ -3247,6 +3507,7 @@ fn header_menu(app: &mut SheetApp, ui: &mut egui::Ui, was_open: bool) { ("Delete", "home.deleteRows"), ("Clear Contents", "edit.clearContents"), ("-", ""), + ("Format Cells…", "ui:formatCells"), ("Row Height…", "ui:rowHeight"), ("AutoFit Row Height", "home.autofitRowHeight"), ("Hide", "home.hideRows"), @@ -3263,6 +3524,7 @@ fn header_menu(app: &mut SheetApp, ui: &mut egui::Ui, was_open: bool) { ("Delete", "home.deleteColumns"), ("Clear Contents", "edit.clearContents"), ("-", ""), + ("Format Cells…", "ui:formatCells"), ("Column Width…", "ui:columnWidth"), ("AutoFit Column Width", "home.autofitColumnWidth"), ("Hide", "home.hideColumns"), @@ -3285,6 +3547,7 @@ fn header_menu(app: &mut SheetApp, ui: &mut egui::Ui, was_open: bool) { match id { "ui:insertPaste" => app.open_dialog("insertPaste", json!({})), "ui:rowHeight" => app.open_dialog("rowHeight", json!({})), + "ui:formatCells" => app.open_dialog("formatCells", json!({})), "ui:columnWidth" => app.open_dialog("columnWidth", json!({})), "edit.copy" | "edit.cut" => { app.copy_to_clipboard(ui.ctx(), id); @@ -3484,6 +3747,19 @@ mod tests { assert_eq!(list_picker_height(0, 18.0, 3.0), 18.0); } + #[test] + fn number_format_colors_resolve_named_and_indexed_rgb() { + use gridcraft_engine::numfmt::FormatColor as F; + assert_eq!(numfmt_color(F::Green), Color32::from_rgb(0, 255, 0)); + assert_eq!(numfmt_color(F::Indexed(1)), Color32::BLACK); + assert_eq!(numfmt_color(F::Indexed(10)), Color32::from_rgb(0, 128, 0)); + assert_eq!(numfmt_color(F::Indexed(46)), Color32::from_rgb(255, 102, 0)); + assert_eq!(numfmt_color(F::Indexed(56)), Color32::from_rgb(51, 51, 51)); + for index in [0, 57, 255] { + assert_eq!(numfmt_color(F::Indexed(index)), Color32::BLACK); + } + } + #[test] fn wheel_zoom_steps_along_the_ladder_and_stops_at_100() { for (from, zoom_in, to) in [ @@ -3762,6 +4038,38 @@ mod pane_tests { Geo::new(sh, Rect::from_min_size(pos2(0.0, 0.0), vec2(800.0, 600.0)), scroll) } + #[test] + fn the_edge_just_past_a_hidden_line_unhides_it() { + let mut sh = Sheet::new("S"); + sh.cols.entry(11).or_default().hidden = true; // L + sh.rows.entry(26).or_default().hidden = true; // 27 + let geo = Geo::new(&sh, Rect::from_min_size(pos2(0.0, 0.0), vec2(2000.0, 1200.0)), vec2(0.0, 0.0)); + let x = geo.x(&sh, 12); // between K and M + assert_eq!(hidden_col_edge(&geo, &sh, x + 2.0), Some(11)); + assert_eq!(hidden_col_edge(&geo, &sh, x - 2.0), None); // K's own edge + assert_eq!(hidden_col_edge(&geo, &sh, geo.x(&sh, 5)), None); + let y = geo.y(&sh, 27); // between 26 and 28 + assert_eq!(hidden_row_edge(&geo, &sh, y + 2.0), Some(26)); + assert_eq!(hidden_row_edge(&geo, &sh, y - 2.0), None); + } + + #[test] + fn headers_mark_the_edges_next_to_hidden_lines() { + let mut sh = Sheet::new("S"); + sh.cols.entry(0).or_default().hidden = true; // A + sh.cols.entry(11).or_default().hidden = true; // L + sh.rows.entry(26).or_default().hidden = true; // 27 + let col = |c| hidden_around(c, |i| sh.col_width(i)); + assert_eq!(col(1), (true, false)); // B, after hidden A + assert_eq!(col(10), (false, true)); // K + assert_eq!(col(12), (true, false)); // M + assert_eq!(col(5), (false, false)); + let row = |r| hidden_around(r, |i| sh.row_height(i)); + assert_eq!(row(25), (false, true)); + assert_eq!(row(27), (true, false)); + assert_eq!(row(0), (false, false)); + } + #[test] fn overlays_skip_cells_scrolled_under_the_frozen_columns() { let mut sh = Sheet::new("S"); diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 6e575c1b9..4892ad1bd 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -12,6 +12,7 @@ use serde::{Deserialize, Serialize}; mod cs; mod de; mod es; +mod es_functions; mod fr; mod ko; mod pt; @@ -139,6 +140,35 @@ impl Language { }; translated.unwrap_or_else(|| text.rsplit_once('|').map_or(text, |(_, label)| label)) } + + /// A worksheet function's description in this language, looked up by the English text of the + /// function catalog; `None` when untranslated, and the help shows the English. + pub fn function_description(self, english: &str) -> Option<&'static str> { + lookup(self.function_help()?.0, english) + } + + /// A function argument name in this language, given as the catalog spells it without + /// brackets or index (`number`, `logical_test`). + pub fn function_argument(self, english: &str) -> Option<&'static str> { + lookup(self.function_help()?.1, english) + } + + /// The function help tables (descriptions, argument names) of the languages that have them. + fn function_help(self) -> Option<(HelpTable, HelpTable)> { + match self { + Self::Es => Some((es_functions::DESCRIPTIONS, es_functions::ARGUMENTS)), + _ => None, + } + } +} + +/// English text and its translation, sorted by the English. +type HelpTable = &'static [(&'static str, &'static str)]; + +/// The translation of `english` in a help table (binary search, so the table must stay sorted). +fn lookup(table: HelpTable, english: &str) -> Option<&'static str> { + let i = table.binary_search_by(|(key, _)| (*key).cmp(english)).ok()?; + table.get(i).map(|(_, translated)| *translated) } /// Deserializes a saved language leniently: an unknown or malformed value (a language removed in a @@ -599,6 +629,27 @@ const JAPANESE: &[(&str, &str)] = &[ mod tests { use super::*; + /// Help tables are sorted, unique, and keyed by text the function catalog still has. + #[test] + fn function_help_is_sorted_and_keyed_by_current_catalog_text() { + // Lookups binary-search the tables. A key that no longer matches the catalog (an English + // description reworded, an argument renamed) would quietly show English again. + let catalog = gridcraft_engine::cmd::formulas::function_list(); + let descriptions: std::collections::HashSet<&str> = catalog.iter().filter_map(|f| f["description"].as_str()).collect(); + let arguments: std::collections::HashSet<&str> = + catalog.iter().filter_map(|f| f["signature"].as_str()).flat_map(crate::formula_locale::argument_names).collect(); + for language in Language::ALL { + let Some((description_table, argument_table)) = language.function_help() else { continue }; + for (table, catalog_text) in [(description_table, &descriptions), (argument_table, &arguments)] { + assert!(table.windows(2).all(|pair| pair[0].0 < pair[1].0), "{language:?} function help is not sorted and unique"); + for (english, translated) in table { + assert!(catalog_text.contains(english), "{language:?} translates {english:?}, which the function catalog doesn't use"); + assert!(!translated.is_empty(), "{language:?}: empty translation of {english:?}"); + } + } + } + } + #[test] fn translations_are_unique_and_preserve_unknown_text() { for (i, (en, ja)) in JAPANESE.iter().enumerate() { @@ -693,6 +744,36 @@ mod tests { } } + #[test] + fn sheet_tab_menu_and_autosave_labels_are_translated() { + let labels = [ + "Insert Sheet", + "Delete", + "Rename", + "Move or Copy…", + "Duplicate", + "Tab Color", + "No Color", + "Hide", + "Unhide…", + "Protect Sheet…", + "Unprotect Sheet…", + "Select All Sheets", + "AutoSave", + "AutoSave: save after every change (workbooks saved to a file)", + "Previous sheet", + "Next sheet", + "All sheets", + "New sheet (⇧F11)", + ]; + for language in [Language::Es, Language::De, Language::Fr, Language::Ko, Language::Ru, Language::Zh, Language::Sr] { + for label in labels { + assert_ne!(language.tr(label), label, "{} lacks a translation of {label:?}", language.code()); + } + } + assert_eq!(Language::Es.tr("AutoSave"), "Autoguardado"); + } + #[test] fn translations_never_show_the_context_prefix() { // `tr` returns a found translation as it is, so a catalog that keeps `context|` in its diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index 4c1a245b9..7d8994bcb 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -180,6 +180,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Protect\nWorkbook" => "Zamknout\nsešit", "Unprotect\nSheet" => "Odemknout\nlist", "Protect Sheet…" => "Zamknout list…", + "Unprotect Sheet…" => "Odemknout list…", "Comments" => "Komentáře", "Notes" => "Poznámky", "Page Break\nPreview" => "Náhled\nkonců stránek", @@ -526,6 +527,16 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Zoom Out" => "Oddálit", // widgets.rs (alphabetical keys). "Dismiss" => "Zavřít", + "Rename" => "Přejmenovat", + "Move or Copy…" => "Přesunout nebo kopírovat…", + "Duplicate" => "Duplikovat", + "Tab Color" => "Barva záložky", + "No Color" => "Bez barvy", + "Unhide…" => "Zobrazit…", + "Select All Sheets" => "Vybrat všechny listy", + "AutoSave" => "Automatické ukládání", + "AutoSave: save after every change (workbooks saved to a file)" => "Automatické ukládání: ukládat po každé změně (sešity uložené do souboru)", + "All sheets" => "Všechny listy", _ => return None, }) } diff --git a/crates/ui-egui/src/i18n/de.rs b/crates/ui-egui/src/i18n/de.rs index 029082c7a..8be90f023 100644 --- a/crates/ui-egui/src/i18n/de.rs +++ b/crates/ui-egui/src/i18n/de.rs @@ -180,6 +180,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Protect\nWorkbook" => "Arbeitsmappe\nschützen", "Unprotect\nSheet" => "Blattschutz\naufheben", "Protect Sheet…" => "Blatt schützen…", + "Unprotect Sheet…" => "Blattschutz aufheben…", "Comments" => "Kommentare", "Notes" => "Notizen", "Page Break\nPreview" => "Seitenumbruch-\nvorschau", @@ -419,6 +420,22 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Link…" => "Link…", "Hide" => "Ausblenden", "Unhide" => "Einblenden", + "Insert Sheet" => "Blatt einfügen", + "Rename" => "Umbenennen", + "Move or Copy…" => "Verschieben oder kopieren…", + "Duplicate" => "Duplizieren", + "Tab Color" => "Registerfarbe", + "No Color" => "Keine Farbe", + "Unhide…" => "Einblenden…", + "Select All Sheets" => "Alle Blätter auswählen", + "AutoSave" => "AutoSpeichern", + "AutoSave: save after every change (workbooks saved to a file)" => { + "AutoSpeichern: nach jeder Änderung speichern (Arbeitsmappen, die in einer Datei gespeichert sind)" + } + "Previous sheet" => "Vorheriges Blatt", + "Next sheet" => "Nächstes Blatt", + "All sheets" => "Alle Blätter", + "New sheet (⇧F11)" => "Neues Blatt (⇧F11)", _ => return None, }) } diff --git a/crates/ui-egui/src/i18n/es.rs b/crates/ui-egui/src/i18n/es.rs index 120ee8e73..9a7292e24 100644 --- a/crates/ui-egui/src/i18n/es.rs +++ b/crates/ui-egui/src/i18n/es.rs @@ -183,6 +183,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Protect\nWorkbook" => "Proteger\nlibro", "Unprotect\nSheet" => "Desproteger\nhoja", "Protect Sheet…" => "Proteger hoja…", + "Unprotect Sheet…" => "Desproteger hoja…", "Comments" => "Comentarios", "Notes" => "Notas", "Page Break\nPreview" => "Vista previa de\nsalto de página", @@ -438,6 +439,23 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Compact" => "Compacto", "Outline" => "Esquema", "Tabular" => "Tabular", + "Insert Sheet" => "Insertar hoja", + "Rename" => "Cambiar nombre", + "Move or Copy…" => "Mover o copiar…", + "Duplicate" => "Duplicar", + "Tab Color" => "Color de etiqueta", + "No Color" => "Sin color", + "Hide" => "Ocultar", + "Unhide…" => "Mostrar…", + "Select All Sheets" => "Seleccionar todas las hojas", + "AutoSave" => "Autoguardado", + "AutoSave: save after every change (workbooks saved to a file)" => { + "Autoguardado: guardar después de cada cambio (libros guardados en un archivo)" + } + "Previous sheet" => "Hoja anterior", + "Next sheet" => "Hoja siguiente", + "All sheets" => "Todas las hojas", + "New sheet (⇧F11)" => "Nueva hoja (⇧F11)", _ => return None, }) } diff --git a/crates/ui-egui/src/i18n/es_functions.rs b/crates/ui-egui/src/i18n/es_functions.rs new file mode 100644 index 000000000..da607ed5b --- /dev/null +++ b/crates/ui-egui/src/i18n/es_functions.rs @@ -0,0 +1,35 @@ +//! Spanish help for the worksheet functions. It covers the 10 common functions Spanish had help +//! for (SUM, AVERAGE, IF…); the others keep their English help. + +/// Function descriptions. +pub(super) const DESCRIPTIONS: &[(&str, &str)] = &[ + ("Adds all the numbers in the arguments.", "Suma los números."), + ("Adds the cells that meet a condition.", "Suma las celdas que cumplen un criterio."), + ("Arithmetic mean of the numbers.", "Devuelve el promedio de los números."), + ("Counts the cells that meet a condition.", "Cuenta las celdas que cumplen un criterio."), + ("Counts the numbers.", "Cuenta las celdas que contienen números."), + ( + "Finds a value in the first column of a table and returns the value in the same row of another column.", + "Busca un valor en la primera columna de una tabla.", + ), + ("Returns one value when a condition is TRUE and another when it is FALSE.", "Devuelve un valor según una condición."), + ("Returns the current date.", "Devuelve la fecha actual."), + ("Returns the logical value FALSE.", "Devuelve el valor lógico FALSO."), + ("Returns the logical value TRUE.", "Devuelve el valor lógico VERDADERO."), +]; + +/// Argument names without brackets or index (`number`, not `[number2]`). +pub(super) const ARGUMENTS: &[(&str, &str)] = &[ + ("col_index_num", "indicador_columnas"), + ("criteria", "criterio"), + ("logical_test", "prueba_lógica"), + ("lookup_value", "valor_buscado"), + ("number", "número"), + ("range", "rango"), + ("range_lookup", "ordenado"), + ("sum_range", "rango_suma"), + ("table_array", "matriz"), + ("value", "valor"), + ("value_if_false", "valor_si_falso"), + ("value_if_true", "valor_si_verdadero"), +]; diff --git a/crates/ui-egui/src/i18n/fr.rs b/crates/ui-egui/src/i18n/fr.rs index d7dd55b8d..dfa5a5d94 100644 --- a/crates/ui-egui/src/i18n/fr.rs +++ b/crates/ui-egui/src/i18n/fr.rs @@ -204,6 +204,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Allow Edit Ranges" => "Autoriser la modification des plages", "Unprotect\nSheet" => "Ôter la protection\nde la feuille", "Protect Sheet…" => "Protéger la feuille…", + "Unprotect Sheet…" => "Ôter la protection de la feuille…", "Comments" => "Commentaires", "Notes" => "Notes", "Page Break\nPreview" => "Aperçu des sauts\nde page", @@ -408,6 +409,23 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Workbook calculation" => "Calcul du classeur", "Enable iterative calculation" => "Activer le calcul itératif", "Calculation settings are saved with the workbook." => "Les paramètres de calcul sont enregistrés avec le classeur.", + "Insert Sheet" => "Insérer une feuille", + "Rename" => "Renommer", + "Move or Copy…" => "Déplacer ou copier…", + "Duplicate" => "Dupliquer", + "Tab Color" => "Couleur de l'onglet", + "No Color" => "Sans couleur", + "Hide" => "Masquer", + "Unhide…" => "Afficher…", + "Select All Sheets" => "Sélectionner toutes les feuilles", + "AutoSave" => "Enregistrement automatique", + "AutoSave: save after every change (workbooks saved to a file)" => { + "Enregistrement automatique : enregistrer après chaque modification (classeurs enregistrés dans un fichier)" + } + "Previous sheet" => "Feuille précédente", + "Next sheet" => "Feuille suivante", + "All sheets" => "Toutes les feuilles", + "New sheet (⇧F11)" => "Nouvelle feuille (⇧F11)", _ => return None, }) } diff --git a/crates/ui-egui/src/i18n/ko.rs b/crates/ui-egui/src/i18n/ko.rs index 2e877e154..4d23d200b 100644 --- a/crates/ui-egui/src/i18n/ko.rs +++ b/crates/ui-egui/src/i18n/ko.rs @@ -201,6 +201,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Allow Edit Ranges" => "범위 편집 허용", "Unprotect\nSheet" => "시트 보호\n해제", "Protect Sheet…" => "시트 보호…", + "Unprotect Sheet…" => "시트 보호 해제…", "Comments" => "댓글", "Notes" => "메모", "Page Break\nPreview" => "페이지 나누기\n미리 보기", @@ -408,6 +409,21 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Workbook calculation" => "통합 문서 계산", "Enable iterative calculation" => "반복 계산 사용", "Calculation settings are saved with the workbook." => "계산 설정은 통합 문서와 함께 저장됩니다.", + "Insert Sheet" => "시트 삽입", + "Rename" => "이름 바꾸기", + "Move or Copy…" => "이동 또는 복사…", + "Duplicate" => "복제", + "Tab Color" => "탭 색상", + "No Color" => "색 없음", + "Hide" => "숨기기", + "Unhide…" => "숨기기 취소…", + "Select All Sheets" => "모든 시트 선택", + "AutoSave" => "자동 저장", + "AutoSave: save after every change (workbooks saved to a file)" => "자동 저장: 변경할 때마다 저장 (파일로 저장된 통합 문서)", + "Previous sheet" => "이전 시트", + "Next sheet" => "다음 시트", + "All sheets" => "모든 시트", + "New sheet (⇧F11)" => "새 시트 (⇧F11)", _ => return None, }) } diff --git a/crates/ui-egui/src/i18n/pt.rs b/crates/ui-egui/src/i18n/pt.rs index e63591d53..83f3ee56c 100644 --- a/crates/ui-egui/src/i18n/pt.rs +++ b/crates/ui-egui/src/i18n/pt.rs @@ -198,6 +198,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Allow Edit Ranges" => "Permitir Edição de Intervalos", "Unprotect\nSheet" => "Desproteger\nPlanilha", "Protect Sheet…" => "Proteger Planilha…", + "Unprotect Sheet…" => "Desproteger Planilha…", "Comments" => "Comentários", "Notes" => "Notas", "Page Break\nPreview" => "Visualização\nde Quebras de Página", @@ -945,6 +946,17 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Protect\nSheet" => "Proteger\nPlanilha", "Watch\nWindow" => "Janela de\nInspeção", "Workbook\nStatistics" => "Estatísticas da\nPasta de Trabalho", + "Rename" => "Renomear", + "Move or Copy…" => "Mover ou copiar…", + "Duplicate" => "Duplicar", + "Tab Color" => "Cor da guia", + "No Color" => "Sem cor", + "Unhide…" => "Reexibir…", + "Select All Sheets" => "Selecionar todas as planilhas", + "AutoSave: save after every change (workbooks saved to a file)" => { + "Salvamento automático: salvar após cada alteração (pastas de trabalho salvas em um arquivo)" + } + "All sheets" => "Todas as planilhas", _ => return None, }) } diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index 09e38e916..cec82ada6 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -201,6 +201,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Allow Edit Ranges" => "Разрешить изменение диапазонов", "Unprotect\nSheet" => "Снять защиту\nлиста", "Protect Sheet…" => "Защитить лист…", + "Unprotect Sheet…" => "Снять защиту листа…", "Comments" => "Комментарии", "Notes" => "Заметки", "Page Break\nPreview" => "Предварительный просмотр\nразрывов страниц", @@ -408,6 +409,23 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Workbook calculation" => "Вычисления в книге", "Enable iterative calculation" => "Включить итеративные вычисления", "Calculation settings are saved with the workbook." => "Параметры вычислений сохраняются вместе с книгой.", + "Insert Sheet" => "Вставить лист", + "Rename" => "Переименовать", + "Move or Copy…" => "Переместить или скопировать…", + "Duplicate" => "Дублировать", + "Tab Color" => "Цвет вкладки", + "No Color" => "Без цвета", + "Hide" => "Скрыть", + "Unhide…" => "Показать…", + "Select All Sheets" => "Выделить все листы", + "AutoSave" => "Автосохранение", + "AutoSave: save after every change (workbooks saved to a file)" => { + "Автосохранение: сохранять после каждого изменения (книги, сохранённые в файл)" + } + "Previous sheet" => "Предыдущий лист", + "Next sheet" => "Следующий лист", + "All sheets" => "Все листы", + "New sheet (⇧F11)" => "Новый лист (⇧F11)", _ => return None, }) } diff --git a/crates/ui-egui/src/i18n/sr.rs b/crates/ui-egui/src/i18n/sr.rs index f267691e3..cd6a053f6 100644 --- a/crates/ui-egui/src/i18n/sr.rs +++ b/crates/ui-egui/src/i18n/sr.rs @@ -196,6 +196,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Allow Edit Ranges" => "Dozvoli uređivanje opsega", "Unprotect\nSheet" => "Ukloni zaštitu\nlista", "Protect Sheet…" => "Zaštiti list…", + "Unprotect Sheet…" => "Ukloni zaštitu lista…", "Comments" => "Komentari", "Notes" => "Beleške", "Page Break\nPreview" => "Pregled preloma\nstranica", @@ -406,6 +407,23 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Bottom Margin" => "Donja margina", "Header Margin" => "Margina zaglavlja", "Footer Margin" => "Margina podnožja", + "Insert Sheet" => "Убаци лист", + "Rename" => "Преименуј", + "Move or Copy…" => "Премести или копирај…", + "Duplicate" => "Дупликат", + "Tab Color" => "Боја картице", + "No Color" => "Без боје", + "Hide" => "Сакриј", + "Unhide…" => "Прикажи…", + "Select All Sheets" => "Изабери све листове", + "AutoSave" => "Аутоматско чување", + "AutoSave: save after every change (workbooks saved to a file)" => { + "Аутоматско чување: сачувај после сваке измене (свеске сачуване у датотеку)" + } + "Previous sheet" => "Претходни лист", + "Next sheet" => "Следећи лист", + "All sheets" => "Сви листови", + "New sheet (⇧F11)" => "Нови лист (⇧F11)", _ => return None, }) } diff --git a/crates/ui-egui/src/i18n/zh.rs b/crates/ui-egui/src/i18n/zh.rs index 09ad57400..a31cfd09f 100644 --- a/crates/ui-egui/src/i18n/zh.rs +++ b/crates/ui-egui/src/i18n/zh.rs @@ -201,6 +201,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Allow Edit Ranges" => "允许编辑区域", "Unprotect\nSheet" => "撤销\n工作表保护", "Protect Sheet…" => "保护工作表…", + "Unprotect Sheet…" => "撤销工作表保护…", "Comments" => "批注", "Notes" => "备注", "Page Break\nPreview" => "分页符\n预览", @@ -400,6 +401,21 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Workbook calculation" => "工作簿计算", "Enable iterative calculation" => "启用迭代计算", "Calculation settings are saved with the workbook." => "计算设置随工作簿一起保存。", + "Insert Sheet" => "插入工作表", + "Rename" => "重命名", + "Move or Copy…" => "移动或复制…", + "Duplicate" => "复制", + "Tab Color" => "标签颜色", + "No Color" => "无颜色", + "Hide" => "隐藏", + "Unhide…" => "取消隐藏…", + "Select All Sheets" => "选择所有工作表", + "AutoSave" => "自动保存", + "AutoSave: save after every change (workbooks saved to a file)" => "自动保存:每次更改后保存(已保存到文件的工作簿)", + "Previous sheet" => "上一个工作表", + "Next sheet" => "下一个工作表", + "All sheets" => "所有工作表", + "New sheet (⇧F11)" => "新建工作表 (⇧F11)", _ => return None, }) } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index fa47483ad..ca1801be2 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -741,11 +741,31 @@ impl SheetApp { } } + /// Whether the active sheet of the active document is protected (drives the Protect/Unprotect + /// Sheet labels and actions). + pub fn active_sheet_protected(&self) -> bool { + self.session.active().and_then(|d| d.wb.active().map(|s| s.is_protected())).unwrap_or(false) + } + pub fn open_dialog(&mut self, name: &str, params: Json) { if let Err(e) = self.commit_text_box_edit() { self.message = Some(("Text Box".into(), clean_error(&e))); return; } + // Every Protect Sheet entry point (ribbon menus, keytips, tab menu) becomes Unprotect Sheet + // on a protected sheet. A sheet without a password is unprotected straight away. + let name = if name == "protectSheet" && self.active_sheet_protected() { "unprotectSheet" } else { name }; + if name == "unprotectSheet" + && self + .session + .active() + .and_then(|d| d.wb.active()) + .and_then(|s| s.protection.as_ref()) + .is_some_and(|p| p.password.is_none() && p.legacy_password_hash.is_none()) + { + self.run_or_alert("review.unprotectSheet", json!({})); + return; + } match name { "open" => { if let Some(start) = &self.services.open_async { @@ -899,6 +919,11 @@ impl SheetApp { /// Starts editing the active cell. `text` replaces the content (typing) or pre-fills it. pub fn begin_edit(&mut self, text: Option, from_formula_bar: bool) { + // The source formula is display-only on spill children. Grid replacement keeps its + // existing behavior, but no formula-bar entry path may edit that displayed formula. + if from_formula_bar && formula_bar::active_spill_anchor(self).is_some() { + return; + } if let Err(e) = self.commit_text_box_edit() { self.message = Some(("Text Box".into(), clean_error(&e))); return; @@ -1170,6 +1195,12 @@ impl SheetApp { if ctx.options(|o| o.zoom_with_keyboard) { ctx.options_mut(|o| o.zoom_with_keyboard = false); } + // egui closes the window on Ctrl+Q (and with Shift or Alt held too) without asking to save + // (#1443). Excel for Windows has no such key (Ctrl+Q is Quick Analysis); macOS keeps Cmd+Q, + // the app's Quit there. + if ctx.os() != egui::os::OperatingSystem::Mac && ctx.options(|o| !o.quit_shortcuts.is_empty()) { + ctx.options_mut(|o| o.quit_shortcuts.clear()); + } let zoom = self.ui.zoom_factor(); if (ctx.zoom_factor() - zoom).abs() > 0.001 { ctx.set_zoom_factor(zoom); @@ -1303,6 +1334,7 @@ impl SheetApp { && !ctx.text_edit_focused() && self.grid.context_menu.is_none() && self.grid.header_menu.is_none() + && self.grid.fill_menu.is_none() && self.grid.filter_menu.is_none() && self.grid.list_picker.is_none() && self.grid.renaming_tab.is_none() @@ -1354,7 +1386,7 @@ impl SheetApp { for n in &d.wb.names { let target = n.formula.replace('$', ""); let (sheet, body) = match target.rsplit_once('!') { - Some((s, b)) => (Some(s.trim_matches('\'').to_string()), b.to_string()), + Some((s, b)) => (Some(gridcraft_engine::core::unquote_sheet(s)), b.to_string()), None => (None, target.clone()), }; if sheet.as_deref().is_none_or(|s| s.eq_ignore_ascii_case(&sh.name)) && RangeRef::parse(&body) == Some(cur) { diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 8fc5fd426..cedfdb6f7 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -52,14 +52,14 @@ pub fn title_bar(app: &mut SheetApp, ui: &mut Ui) { ui.horizontal_centered(|ui| { ui.add_space(TITLE_LEFT_PAD); // AutoSave toggle (saves after each change when the workbook has a file). - ui.label(egui::RichText::new("AutoSave").font(theme::ui_font(12.5)).color(t.text_dim)); + ui.label(egui::RichText::new(app.ui.language.tr("AutoSave")).font(theme::ui_font(12.5)).color(t.text_dim)); let on = app.grid.autosave(); let (r, resp) = ui.allocate_exact_size(vec2(30.0, 16.0), Sense::click()); ui.painter().rect_filled(r, 8.0, if on { t.accent } else { Color32::TRANSPARENT }); ui.painter().rect_stroke(r, 8.0, Stroke::new(1.0, if on { t.accent } else { t.text_dim }), StrokeKind::Inside); let knob = if on { pos2(r.right() - 8.0, r.center().y) } else { pos2(r.left() + 8.0, r.center().y) }; ui.painter().circle_filled(knob, 5.0, if on { Color32::WHITE } else { t.text_dim }); - if resp.on_hover_text("AutoSave: save after every change (workbooks saved to a file)").clicked() { + if resp.on_hover_text(app.ui.language.tr("AutoSave: save after every change (workbooks saved to a file)")).clicked() { app.grid.toggle_autosave(); } ui.add_space(6.0); @@ -112,17 +112,8 @@ pub fn title_bar(app: &mut SheetApp, ui: &mut Ui) { app.print_now(json!({})); } }); - // Centered title. - let title = app.session.active().map(|d| d.display_title()).unwrap_or_default(); - let dirty = app.session.active().is_some_and(|d| d.is_dirty()); - ui.painter().text( - rect.center(), - Align2::CENTER_CENTER, - format!("{title}{}", if dirty { " •" } else { "" }), - theme::ui_bold(13.0), - t.text, - ); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let left_end = ui.min_rect().right(); + let right = 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", vec2(28.0, ch)); control_keytip(app, ui, &__k, "commandSearch"); @@ -157,11 +148,58 @@ pub fn title_bar(app: &mut SheetApp, ui: &mut Ui) { act(app, "view.darkMode", json!({})); } theme.context_menu(|ui| theme_choices(app, ui)); + ui.min_rect().left() }); + // Centred title, cut with "…" so it never runs under the controls on either side. + let title = app.session.active().map(|d| d.display_title()).unwrap_or_default(); + let dirty = app.session.active().is_some_and(|d| d.is_dirty()); + let max_w = title_max_width(rect.center().x, left_end, right.inner); + paint_title(ui, rect.center(), &title, dirty, max_w, t.text); }); }); } +/// Lay out the title truncated to fit `max_w` together with the unsaved marker (when `dirty`). +fn title_galleys( + painter: &egui::Painter, + title: &str, + dirty: bool, + max_w: f32, + color: Color32, +) -> (std::sync::Arc, Option>) { + let font = theme::ui_bold(13.0); + let dot = dirty.then(|| painter.layout_no_wrap(" •".to_string(), font.clone(), color)); + let dot_w = dot.as_ref().map_or(0.0, |g| g.size().x); + let mut job = egui::text::LayoutJob::simple_singleline(title.to_string(), font, color); + job.wrap = egui::text::TextWrapping::truncate_at_width((max_w - dot_w).max(0.0)); + (painter.layout_job(job), dot) +} + +/// Gap kept between the title and the controls beside it. +const TITLE_GAP: f32 = 12.0; + +/// The widest the centred title may be without reaching the controls that end at `left_end` or +/// start at `right_start`. +fn title_max_width(center_x: f32, left_end: f32, right_start: f32) -> f32 { + let half = (center_x - left_end).min(right_start - center_x) - TITLE_GAP; + (2.0 * half).max(0.0) +} + +/// Paint the workbook title centred on `center`, truncated with "…" to `max_w`. The unsaved +/// marker stays visible however short the title gets. +fn paint_title(ui: &Ui, center: egui::Pos2, title: &str, dirty: bool, max_w: f32, color: Color32) { + let (galley, dot) = title_galleys(ui.painter(), title, dirty, max_w, color); + let dot_w = dot.as_ref().map_or(0.0, |g| g.size().x); + let w = galley.size().x + dot_w; + let left = center.x - w / 2.0; + let top = center.y - galley.size().y / 2.0; + let galley_w = galley.size().x; + ui.painter().galley(pos2(left, top), galley, color); + if let Some(dot) = dot { + ui.painter().galley(pos2(left + galley_w, top), dot, color); + } +} + pub fn show(app: &mut SheetApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); let collapsed = app.ui.ribbon_collapsed; @@ -576,6 +614,7 @@ fn home(app: &mut SheetApp, ui: &mut Ui) { } let fp_on = app.session.format_painter.is_some(); let fp = toggle_button(ui, Icon::Brush, fp_on, "Format Painter (double-click to keep it on)"); + fp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, ui.is_enabled(), fp_on, "Format Painter")); control_keytip(app, ui, &fp, "edit.formatPainter"); if fp.double_clicked() { act(app, "edit.formatPainter", json!({"sticky": true})); @@ -934,6 +973,7 @@ fn home(app: &mut SheetApp, ui: &mut Ui) { if format_keys { format_popup = format_popup.open_memory(egui::SetOpenCommand::Bool(true)); } + let protect_label = if app.active_sheet_protected() { "Unprotect Sheet…" } else { "Protect Sheet…" }; format_popup.show(|ui| { menu_items( app, @@ -955,7 +995,7 @@ fn home(app: &mut SheetApp, ui: &mut Ui) { ("Rename Sheet", "dialog:renameSheet", json!({})), ("Move or Copy Sheet…", "dialog:moveSheet", json!({})), ("-", "", json!(null)), - ("Protect Sheet…", "dialog:protectSheet", json!({})), + (protect_label, "dialog:protectSheet", json!({})), ("Lock Cell", "home.lockCell", json!({})), ("Format Cells…", "dialog:formatCells", json!({})), ], @@ -1760,6 +1800,8 @@ fn page_layout(app: &mut SheetApp, ui: &mut Ui) { } } +/// The Formulas tab: Insert Function, AutoSum, the function-category menus (names in the formula +/// language, descriptions in the interface language), defined names, auditing and calculation. fn formulas(app: &mut SheetApp, ui: &mut Ui) { let __k = big_button(ui, Icon::Function, "Insert\nFunction", "Insert Function (⇧F3)", false); control_keytip(app, ui, &__k, "dialog:insertFunction"); @@ -1793,7 +1835,7 @@ fn formulas(app: &mut SheetApp, ui: &mut Ui) { let locale = app.ui.language.formula_locale(); let name = app.session.active().map(|d| crate::formula_locale::completion_name(n, locale, &d.wb, d.wb.active_sheet)).unwrap_or(n); - let desc = crate::formula_locale::description(n, locale).unwrap_or_default(); + let desc = crate::formula_locale::description(n, locale, app.ui.language).unwrap_or_default(); if ui.button(name).on_hover_text(desc).clicked() { app.run_or_alert("formulas.insertFunction", json!({"name": n})); ui.close(); @@ -2095,11 +2137,7 @@ fn review(app: &mut SheetApp, ui: &mut Ui) { let __k = big_button(ui, Icon::Lock, if protected { "Unprotect\nSheet" } else { "Protect\nSheet" }, "Protect Sheet", false); control_keytip(app, ui, &__k, "dialog:protectSheet"); if __k.clicked() { - if protected { - app.open_dialog("unprotectSheet", json!({})); - } else { - app.open_dialog("protectSheet", json!({})); - } + app.open_dialog("protectSheet", json!({})); } let __k = big_button(ui, Icon::Book, "Protect\nWorkbook", "Protect Workbook structure", false); control_keytip(app, ui, &__k, "review.protectWorkbook"); @@ -2522,6 +2560,60 @@ mod title_bar_tests { let lights_end = MAC_LIGHTS_LEFT + 2.0 * MAC_LIGHT_PITCH + MAC_LIGHT; assert_eq!(MAC_CONTENT_LEFT - lights_end, margin); } + + #[test] + fn title_fits_between_the_controls() { + // Room limited by the nearer side, symmetric about the centre. + assert_eq!(title_max_width(500.0, 300.0, 900.0), 2.0 * (200.0 - TITLE_GAP)); + assert_eq!(title_max_width(500.0, 100.0, 650.0), 2.0 * (150.0 - TITLE_GAP)); + // Controls overlapping the centre leave no room rather than a negative width. + assert_eq!(title_max_width(500.0, 520.0, 900.0), 0.0); + } + + /// Lay out a title in a real egui frame, returning (title text, title width, dot width). + fn layout_title(title: &str, dirty: bool, max_w: f32) -> (String, f32, f32) { + let ctx = egui::Context::default(); + ctx.set_fonts(theme::font_definitions()); + let mut out = (String::new(), 0.0, 0.0); + let mut frame = ctx.run_ui(egui::RawInput::default(), |ui| { + let (galley, dot) = title_galleys(ui.painter(), title, dirty, max_w, Color32::WHITE); + // The glyphs actually drawn: `galley.text()` is the untruncated source. + let shown = galley.rows.iter().flat_map(|r| r.glyphs.iter().map(|g| g.chr)).collect(); + out = (shown, galley.size().x, dot.map_or(0.0, |d| d.size().x)); + }); + frame.textures_delta.clear(); // nothing consumes the atlas in a headless test + out + } + + #[test] + fn long_title_is_truncated_with_an_ellipsis() { + let long = format!("upload-{}-cleaned (1).xlsx", "3FMyNTYg2gDaANiAAACq20AAAAQMBqug".repeat(4)); + let (text, w, _) = layout_title(&long, false, 300.0); + assert!(text.ends_with('…'), "truncated title ends with an ellipsis: {text:?}"); + assert!(text.chars().count() < long.chars().count()); + assert!(w <= 300.0, "title stays within its room: {w}"); + } + + #[test] + fn short_title_is_left_whole() { + let (text, _, _) = layout_title("Book1.xlsx", false, 300.0); + assert_eq!(text, "Book1.xlsx"); + } + + #[test] + fn unsaved_dot_survives_truncation() { + let long = "a very long workbook name ".repeat(20); + let (text, w, dot_w) = layout_title(&long, true, 200.0); + assert!(dot_w > 0.0, "the unsaved marker is laid out"); + assert!(text.ends_with('…')); + assert!(w + dot_w <= 200.0, "title and marker fit together: {}", w + dot_w); + } + + #[test] + fn no_room_draws_nothing_but_does_not_crash() { + let (_, w, _) = layout_title("Book1.xlsx", true, 0.0); + assert!(w <= 20.0, "a zero-width slot leaves at most the ellipsis: {w}"); + } } #[cfg(test)] diff --git a/crates/ui-egui/src/tabs.rs b/crates/ui-egui/src/tabs.rs index 5fcd058f8..6c6545638 100644 --- a/crates/ui-egui/src/tabs.rs +++ b/crates/ui-egui/src/tabs.rs @@ -63,14 +63,14 @@ pub fn sheet_tabs(app: &mut SheetApp, ui: &mut Ui) { ui.horizontal_centered(|ui| { ui.spacing_mut().item_spacing.x = 0.0; ui.add_space(8.0); - if crate::widgets::icon_button(ui, Icon::Left, t.text_dim, "Previous sheet", vec2(24.0, 24.0)).clicked() { + if crate::widgets::icon_button(ui, Icon::Left, t.text_dim, app.ui.language.tr("Previous sheet"), vec2(24.0, 24.0)).clicked() { app.run_or_alert("sheet.previous", json!({})); } - if crate::widgets::icon_button(ui, Icon::Right, t.text_dim, "Next sheet", vec2(24.0, 24.0)).clicked() { + if crate::widgets::icon_button(ui, Icon::Right, t.text_dim, app.ui.language.tr("Next sheet"), vec2(24.0, 24.0)).clicked() { app.run_or_alert("sheet.next", json!({})); } - let all = crate::widgets::icon_button(ui, Icon::SheetList, t.text_dim, "All sheets", vec2(24.0, 24.0)); - all.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, "All sheets")); + let all = crate::widgets::icon_button(ui, Icon::SheetList, t.text_dim, app.ui.language.tr("All sheets"), vec2(24.0, 24.0)); + all.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, app.ui.language.tr("All sheets"))); let items = app.session.active().map(|d| navigator_items(&d.wb.sheets)).unwrap_or_default(); let active = app.session.active().map(|d| d.wb.active_sheet.to_string()).unwrap_or_default(); let rows: Vec<(&str, String)> = items.iter().map(|(i, name)| (i.as_str(), name.clone())).collect(); @@ -168,7 +168,9 @@ pub fn sheet_tabs(app: &mut SheetApp, ui: &mut Ui) { } else { ui.painter().text(r.center(), Align2::CENTER_CENTER, &name, font, if active { t.accent } else { t.text }); } - if resp.clicked() { + // A right-click activates the tab too, as in Excel, so the menu's sheet commands (Protect + // Sheet, Select All Sheets) act on the sheet that was clicked rather than the one that was active. + if resp.clicked() || (resp.secondary_clicked() && !active) { activate_sheet(app, i); } if resp.double_clicked() { @@ -191,42 +193,49 @@ pub fn sheet_tabs(app: &mut SheetApp, ui: &mut Ui) { } resp.context_menu(|ui| { ui.set_min_width(180.0); - if ui.button("Insert Sheet").clicked() { + if ui.button(app.ui.language.tr("Insert Sheet")).clicked() { app.run_or_alert("home.insertSheet", json!({"before": i})); } - if ui.button("Delete").clicked() { + if ui.button(app.ui.language.tr("Delete")).clicked() { app.run_or_alert("home.deleteSheet", json!({"sheet": i})); } - if ui.button("Rename").clicked() { + if ui.button(app.ui.language.tr("Rename")).clicked() { app.grid.renaming_tab = Some(i); app.grid.rename_text = name.clone(); } - if ui.button("Move or Copy…").clicked() { + if ui.button(app.ui.language.tr("Move or Copy…")).clicked() { app.open_dialog("moveSheet", json!({"sheet": i})); } - if ui.button("Duplicate").clicked() { + if ui.button(app.ui.language.tr("Duplicate")).clicked() { app.run_or_alert("sheet.move", json!({"sheet": i, "to": i + 1, "copy": true})); } - ui.menu_button("Tab Color", |ui| { + ui.menu_button(app.ui.language.tr("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_fixed(ui, &colors, "No Color") { + if let Some(c) = crate::widgets::color_palette_fixed(ui, &colors, app.ui.language.tr("No Color")) { app.run_or_alert("sheet.tabColor", json!({"sheet": i, "color": c})); ui.close(); } }); ui.separator(); - if ui.button("Hide").clicked() { + if ui.button(app.ui.language.tr("Hide")).clicked() { app.run_or_alert("sheet.hide", json!({"sheet": i})); } - if ui.button("Unhide…").clicked() { + if ui.button(app.ui.language.tr("Unhide…")).clicked() { app.run_or_alert("sheet.unhide", json!({})); } ui.separator(); - let protected = app.session.active().and_then(|d| d.wb.active().map(|s| s.is_protected())).unwrap_or(false); - if ui.button(if protected { "Unprotect Sheet…" } else { "Protect Sheet…" }).clicked() { - app.open_dialog(if protected { "unprotectSheet" } else { "protectSheet" }, json!({})); + let protected = app.active_sheet_protected(); + if ui + .button(if protected { + app.ui.language.tr("Unprotect Sheet…") + } else { + app.ui.language.tr("Protect Sheet…") + }) + .clicked() + { + app.open_dialog("protectSheet", json!({})); } - if ui.button("Select All Sheets").clicked() { + if ui.button(app.ui.language.tr("Select All Sheets")).clicked() { ui.close(); } }); @@ -235,7 +244,7 @@ pub fn sheet_tabs(app: &mut SheetApp, ui: &mut Ui) { }); }); ui.add_space(6.0); - if crate::widgets::icon_button(ui, Icon::Plus, t.text_dim, "New sheet (⇧F11)", vec2(26.0, 26.0)).clicked() { + if crate::widgets::icon_button(ui, Icon::Plus, t.text_dim, app.ui.language.tr("New sheet (⇧F11)"), vec2(26.0, 26.0)).clicked() { app.run_or_alert("home.insertSheet", json!({})); } // The track ends where the grid does, so it stops under the vertical scrollbar. diff --git a/crates/ui-egui/tests/cell_editor_caret.rs b/crates/ui-egui/tests/cell_editor_caret.rs new file mode 100644 index 000000000..95b0ff6f3 --- /dev/null +++ b/crates/ui-egui/tests/cell_editor_caret.rs @@ -0,0 +1,59 @@ +//! The caret of the in-cell editor stays visible on the cell's fill, whatever the theme. +use egui::{Color32, Context, RawInput, Rect, Shape, pos2, vec2}; +use gridcraft_engine::Session; +use gridcraft_ui_egui::{SheetApp, theme}; +use serde_json::json; + +fn frame(ctx: &Context, app: &mut SheetApp) -> egui::FullOutput { + let mut output = + ctx.run_ui(RawInput { screen_rect: Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(1200.0, 800.0))), ..Default::default() }, |ui| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }); + output.textures_delta.clear(); + output +} + +/// The caret painted while editing A1 over `fill` (`None`: no fill), with the app in `dark` mode. +fn caret(fill: Option<&str>, dark: bool) -> Color32 { + let ctx = Context::default(); + theme::apply(&ctx, dark); + let mut session = Session::new(); + session.new_workbook(); + let mut app = SheetApp::new(session, Default::default()); + if let Some(color) = fill { + app.run("home.fillColor", json!({"range": "A1", "color": color})).unwrap(); + } + for _ in 0..4 { + frame(&ctx, &mut app); + } + app.begin_edit(Some("abc".into()), false); + for _ in 0..4 { + frame(&ctx, &mut app); + } + let output = frame(&ctx, &mut app); + // The editor paints its backdrop first; the caret is the vertical segment after it. + let paper = fill.map_or(Color32::WHITE, |c| Color32::from_hex(c).unwrap()); + let backdrop = output + .shapes + .iter() + .rposition(|s| matches!(&s.shape, Shape::Rect(r) if r.fill == paper && r.rect.width() > 20.0)) + .expect("editor backdrop painted"); + output.shapes[backdrop..] + .iter() + .find_map(|s| match &s.shape { + Shape::LineSegment { points, stroke } if points[0].x == points[1].x => Some(stroke.color), + _ => None, + }) + .expect("caret painted") +} + +#[test] +fn caret_contrasts_with_the_cell_fill() { + assert_eq!(caret(Some("#1F3864"), false), Color32::WHITE, "dark fill, light theme"); + assert_eq!(caret(Some("#1F3864"), true), Color32::WHITE, "dark fill, dark theme"); + assert_eq!(caret(Some("#FFFF00"), true), Color32::BLACK, "light fill, dark theme"); + assert_eq!(caret(None, true), Color32::BLACK, "no fill, dark theme: the cell is still white paper"); + assert_eq!(caret(None, false), Color32::BLACK, "no fill, light theme"); +} diff --git a/crates/ui-egui/tests/context_menu_stays_open.rs b/crates/ui-egui/tests/context_menu_stays_open.rs new file mode 100644 index 000000000..dada039a6 --- /dev/null +++ b/crates/ui-egui/tests/context_menu_stays_open.rs @@ -0,0 +1,102 @@ +//! A right-click on a cell opens its context menu, and the menu stays open until the user picks +//! an item, presses Escape or clicks elsewhere. + +use egui::{Event, Modifiers, PointerButton, Pos2}; +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 { + harness_at(1.0) +} + +/// `ppp` physical pixels per point: 2 on a Retina display. +fn harness_at(ppp: f32) -> Harness { + let mut session = Session::new(); + session.new_workbook(); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).with_pixels_per_point(ppp).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 center(h: &Harness, cell: &str) -> Pos2 { + let app = h.state(); + let sh = app.session.active().unwrap().wb.active().unwrap(); + grid::Geo::new(sh, app.grid.rect.unwrap(), app.view().scroll).cell_rect(sh, CellRef::parse(cell).unwrap()).center() +} + +fn button(h: &mut Harness, pos: Pos2, pressed: bool) { + h.input_mut().events.push(Event::PointerButton { pos, button: PointerButton::Secondary, pressed, modifiers: Modifiers::NONE }); +} + +/// Press and release arrive in the same frame (a quick click). +fn quick_right_click(h: &mut Harness, cell: &str) { + let pos = center(h, cell); + h.input_mut().events.push(Event::PointerMoved(pos)); + button(h, pos, true); + button(h, pos, false); + h.run_steps(4); +} + +/// Press and release in separate frames (a slower click). +fn slow_right_click(h: &mut Harness, cell: &str) { + let pos = center(h, cell); + h.input_mut().events.push(Event::PointerMoved(pos)); + h.run_steps(1); + button(h, pos, true); + h.run_steps(1); + button(h, pos, false); + h.run_steps(4); +} + +#[test] +fn a_quick_right_click_keeps_the_menu_open() { + let mut h = harness(); + quick_right_click(&mut h, "C5"); + assert!(h.state().grid.context_menu.is_some(), "the menu stays open"); +} + +#[test] +fn a_slow_right_click_keeps_the_menu_open() { + let mut h = harness(); + slow_right_click(&mut h, "C5"); + assert!(h.state().grid.context_menu.is_some(), "the menu stays open"); +} + +#[test] +fn right_clicking_another_cell_moves_the_menu_there() { + for quick in [true, false] { + let mut h = harness(); + slow_right_click(&mut h, "C5"); + if quick { + quick_right_click(&mut h, "M2"); + } else { + slow_right_click(&mut h, "M2"); + } + let at = h.state().grid.context_menu; + assert!(at.is_some(), "the menu stays open (quick: {quick})"); + assert_eq!(at, Some(center(&h, "M2")), "at the second cell (quick: {quick})"); + } +} + +/// A real pointer sits between pixels (x = 252.46), and the menu's corner snaps to the nearest +/// physical pixel (252.5 on a Retina display), just past the pointer. The press that comes in the +/// same frame as the release then counted as a press outside the menu and closed it at once. +#[test] +fn a_quick_right_click_between_pixels_keeps_the_menu_open_on_retina() { + let mut h = harness_at(2.0); + let pos = center(&h, "C5") + egui::vec2(0.46, 0.37); + h.input_mut().events.push(Event::PointerMoved(pos)); + button(&mut h, pos, true); + button(&mut h, pos, false); + h.run_steps(4); + assert!(h.state().grid.context_menu.is_some(), "the menu stays open"); +} diff --git a/crates/ui-egui/tests/fill_handle_right_drag.rs b/crates/ui-egui/tests/fill_handle_right_drag.rs new file mode 100644 index 000000000..9562c1d63 --- /dev/null +++ b/crates/ui-egui/tests/fill_handle_right_drag.rs @@ -0,0 +1,268 @@ +//! Dragging the fill handle with the right button fills nothing on release: it opens Excel's fill +//! menu (Copy Cells, Fill Series, Fill Formatting Only, Fill Without Formatting, Series…). + +use egui::{Event, Key, Modifiers, PointerButton, Pos2, vec2}; +use egui_kittest::kittest::Queryable; +use gridcraft_engine::Session; +use gridcraft_engine::core::{CellRef, RangeRef, Value}; +use gridcraft_ui_egui::{SheetApp, grid::Drag, grid::Geo}; +use serde_json::json; + +type Harness = egui_kittest::Harness<'static, SheetApp>; + +fn harness(setup: impl FnOnce(&mut Session)) -> Harness { + let mut session = Session::new(); + session.new_workbook(); + setup(&mut session); + let mut h = egui_kittest::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 +} + +/// B1:B2 holds 1, 2 and is selected. +fn one_two(s: &mut Session) { + s.execute("range.setValues", json!({"range": "B1", "values": [[1], [2]]})).unwrap(); + s.execute("selection.set", json!({"range": "B1:B2"})).unwrap(); +} + +fn corner(h: &Harness, cell: &str) -> Pos2 { + let app = h.state(); + let sheet = app.session.active().unwrap().wb.active().unwrap(); + let geo = Geo::new(sheet, app.grid.rect.unwrap(), app.view().scroll); + geo.cell_rect(sheet, CellRef::parse(cell).unwrap()).right_bottom() - vec2(1.0, 1.0) +} + +fn center(h: &Harness, cell: &str) -> Pos2 { + let app = h.state(); + let sheet = app.session.active().unwrap().wb.active().unwrap(); + let geo = Geo::new(sheet, app.grid.rect.unwrap(), app.view().scroll); + geo.cell_rect(sheet, CellRef::parse(cell).unwrap()).center() +} + +fn press(h: &mut Harness, pos: Pos2, button: PointerButton) { + h.input_mut().events.extend([Event::PointerMoved(pos), Event::PointerButton { pos, button, pressed: true, modifiers: Modifiers::NONE }]); + h.step(); +} + +fn move_to(h: &mut Harness, from: Pos2, to: Pos2) { + for i in 1..=10 { + h.input_mut().events.push(Event::PointerMoved(from + (to - from) * (i as f32 / 10.0))); + h.step(); + } +} + +fn release(h: &mut Harness, pos: Pos2, button: PointerButton) { + h.input_mut().events.push(Event::PointerButton { pos, button, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(3); +} + +fn escape(h: &mut Harness) { + h.input_mut().events.push(Event::Key { key: Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); + h.step(); +} + +/// Drags the handle of the current selection (whose bottom-right cell is `from`) to `to`. +fn drag_handle(h: &mut Harness, from: &str, to: &str, button: PointerButton) { + let (start, end) = (corner(h, from), center(h, to)); + press(h, start, button); + move_to(h, start, end); + release(h, end, button); +} + +fn value(h: &Harness, cell: &str) -> Value { + h.state().session.active().unwrap().wb.active().unwrap().value(CellRef::parse(cell).unwrap()) +} + +fn values(h: &Harness, cells: &[&str]) -> Vec { + cells.iter().map(|c| value(h, c)).collect() +} + +fn bold(h: &Harness, cell: &str) -> bool { + let d = h.state().session.active().unwrap(); + let sh = d.wb.active().unwrap(); + d.wb.styles.get(sh.style_id(CellRef::parse(cell).unwrap())).font.bold +} + +fn italic(h: &Harness, cell: &str) -> bool { + let d = h.state().session.active().unwrap(); + let sh = d.wb.active().unwrap(); + d.wb.styles.get(sh.style_id(CellRef::parse(cell).unwrap())).font.italic +} + +fn fills(h: &Harness) -> usize { + h.state().session.journal.iter().filter(|(id, _)| id == "edit.autoFill").count() +} + +fn selection(h: &Harness) -> String { + h.state().session.active().unwrap().selection.a1() +} + +fn n(x: f64) -> Value { + Value::Number(x) +} + +#[test] +fn right_drag_opens_the_fill_menu_and_fills_nothing() { + let mut h = harness(one_two); + drag_handle(&mut h, "B2", "B6", PointerButton::Secondary); + + let menu = h.state().grid.fill_menu.map(|(src, target, _)| (src, target)); + assert_eq!(menu, Some((RangeRef::parse("B1:B2").unwrap(), RangeRef::parse("B1:B6").unwrap()))); + assert_eq!(values(&h, &["B3", "B6"]), [Value::Empty, Value::Empty]); + assert_eq!(fills(&h), 0); + assert!(h.state().grid.context_menu.is_none(), "not the cell context menu"); + assert_eq!(selection(&h), "B1:B2"); +} + +#[test] +fn left_drag_still_fills_the_series() { + let mut h = harness(one_two); + drag_handle(&mut h, "B2", "B6", PointerButton::Primary); + + assert_eq!(values(&h, &["B3", "B4", "B5", "B6"]), [n(3.0), n(4.0), n(5.0), n(6.0)]); + assert!(h.state().grid.fill_menu.is_none()); +} + +#[test] +fn copy_cells_repeats_the_source() { + let mut h = harness(one_two); + drag_handle(&mut h, "B2", "B6", PointerButton::Secondary); + h.get_by_label("Copy Cells").click(); + h.run_steps(3); + + assert_eq!(values(&h, &["B3", "B4", "B5", "B6"]), [n(1.0), n(2.0), n(1.0), n(2.0)]); + assert!(h.state().grid.fill_menu.is_none()); + assert_eq!(selection(&h), "B1:B6"); +} + +#[test] +fn fill_series_counts_up_from_a_lone_number() { + let mut h = harness(|s| { + s.execute("cell.set", json!({"cell": "B1", "input": "5"})).unwrap(); + s.execute("selection.set", json!({"cell": "B1"})).unwrap(); + }); + drag_handle(&mut h, "B1", "B4", PointerButton::Secondary); + h.get_by_label("Fill Series").click(); + h.run_steps(3); + + assert_eq!(values(&h, &["B2", "B3", "B4"]), [n(6.0), n(7.0), n(8.0)]); +} + +/// B1:B2 = 1, 2 in bold, B4 = 9 in italic. +fn formatted(s: &mut Session) { + s.execute("range.setValues", json!({"range": "B1", "values": [[1], [2], [null], [9]]})).unwrap(); + s.execute("home.bold", json!({"range": "B1:B2", "on": true})).unwrap(); + s.execute("home.italic", json!({"range": "B4", "on": true})).unwrap(); + s.execute("selection.set", json!({"range": "B1:B2"})).unwrap(); +} + +#[test] +fn fill_formatting_only_leaves_the_values() { + let mut h = harness(formatted); + drag_handle(&mut h, "B2", "B4", PointerButton::Secondary); + h.get_by_label("Fill Formatting Only").click(); + h.run_steps(3); + + assert_eq!(values(&h, &["B3", "B4"]), [Value::Empty, n(9.0)]); + assert!(bold(&h, "B3") && bold(&h, "B4")); +} + +#[test] +fn fill_without_formatting_extends_the_series_under_the_targets_formats() { + let mut h = harness(formatted); + drag_handle(&mut h, "B2", "B4", PointerButton::Secondary); + h.get_by_label("Fill Without Formatting").click(); + h.run_steps(3); + + assert_eq!(values(&h, &["B3", "B4"]), [n(3.0), n(4.0)]); + assert!(!bold(&h, "B3") && !bold(&h, "B4"), "the source's bold stays behind"); + assert!(italic(&h, "B4")); +} + +#[test] +fn series_opens_the_series_dialog_on_the_dragged_cells_in_their_direction() { + let mut h = harness(|s| { + s.execute("cell.set", json!({"cell": "B1", "input": "1"})).unwrap(); + s.execute("selection.set", json!({"cell": "B1"})).unwrap(); + }); + drag_handle(&mut h, "B1", "E1", PointerButton::Secondary); + h.get_by_label("Series…").click(); + h.run_steps(3); + + assert_eq!(selection(&h), "B1:E1"); + let dialog = h.state().dialog.as_ref().map(|d| (d.name().to_string(), d.values.get("direction").cloned())); + assert_eq!(dialog, Some(("series".into(), Some(json!("rows"))))); + h.get_by_label(" OK ").click(); + h.run_steps(3); + assert_eq!(values(&h, &["C1", "D1", "E1"]), [n(2.0), n(3.0), n(4.0)]); +} + +#[test] +fn escape_mid_right_drag_cancels_it() { + let mut h = harness(|s| { + one_two(s); + s.execute("edit.copy", json!({"range": "D1"})).unwrap(); + s.execute("selection.set", json!({"range": "B1:B2"})).unwrap(); + }); + assert!(h.state().session.clipboard.is_some()); + let (start, end) = (corner(&h, "B2"), center(&h, "B6")); + press(&mut h, start, PointerButton::Secondary); + move_to(&mut h, start, end); + escape(&mut h); + assert_eq!(h.state().grid.drag, Drag::None, "the dashed outline goes"); + assert!(h.query_by_label("Copy Cells").is_none(), "the menu doesn't flash for the Esc frame"); + release(&mut h, end, PointerButton::Secondary); + + assert!(h.state().grid.fill_menu.is_none()); + assert!(h.state().grid.context_menu.is_none()); + assert_eq!(values(&h, &["B3", "B6"]), [Value::Empty, Value::Empty]); + assert!(h.state().session.clipboard.is_some(), "Esc cancels the drag, not the copy"); +} + +#[test] +fn arrow_keys_mid_right_drag_leave_the_source_alone() { + let mut h = harness(one_two); + let (start, end) = (corner(&h, "B2"), center(&h, "B6")); + press(&mut h, start, PointerButton::Secondary); + move_to(&mut h, start, end); + h.input_mut().events.push(Event::Key { key: Key::ArrowDown, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); + h.step(); + release(&mut h, end, PointerButton::Secondary); + + assert_eq!(selection(&h), "B1:B2"); + assert_eq!(h.state().grid.fill_menu.map(|(src, ..)| src), Some(RangeRef::parse("B1:B2").unwrap())); +} + +#[test] +fn escape_closes_the_menu_without_filling() { + let mut h = harness(|s| { + one_two(s); + s.execute("edit.copy", json!({"range": "D1"})).unwrap(); + s.execute("selection.set", json!({"range": "B1:B2"})).unwrap(); + }); + drag_handle(&mut h, "B2", "B6", PointerButton::Secondary); + assert!(h.state().grid.fill_menu.is_some()); + escape(&mut h); + h.run_steps(2); + + assert!(h.state().grid.fill_menu.is_none()); + assert_eq!(values(&h, &["B3", "B6"]), [Value::Empty, Value::Empty]); + assert!(h.state().session.clipboard.is_some(), "Esc closes the menu, not the copy"); +} + +#[test] +fn right_click_on_the_handle_opens_the_context_menu() { + let mut h = harness(one_two); + let at = corner(&h, "B2"); + press(&mut h, at, PointerButton::Secondary); + release(&mut h, at, PointerButton::Secondary); + + assert!(h.state().grid.context_menu.is_some()); + assert!(h.state().grid.fill_menu.is_none()); +} diff --git a/crates/ui-egui/tests/format_painter.rs b/crates/ui-egui/tests/format_painter.rs new file mode 100644 index 000000000..54a78930a --- /dev/null +++ b/crates/ui-egui/tests/format_painter.rs @@ -0,0 +1,196 @@ +//! Format Painter through the real ribbon button and a click on the grid. + +use egui::accesskit::Toggled; +use egui::{Event, Modifiers, PointerButton, Pos2}; +use egui_kittest::kittest::{NodeT, Queryable}; +use gridcraft_engine::Session; +use gridcraft_engine::core::CellRef; +use gridcraft_engine::model::Style; +use gridcraft_ui_egui::{SheetApp, grid::Geo}; +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": [["source"]]})).unwrap(); + session.execute("home.bold", json!({"range": "A1", "on": true})).unwrap(); + session.execute("home.fillColor", json!({"range": "A1", "color": "#FFFF00"})).unwrap(); + session.execute("selection.set", json!({"cell": "A1"})).unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SheetApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + SheetApp::new(session, Default::default()), + ); + h.run_steps(4); + h +} + +fn style(h: &Harness, a1: &str) -> Style { + let doc = h.state().session.active().unwrap(); + let sheet = doc.wb.active().unwrap(); + doc.wb.styles.get(sheet.style_id(CellRef::parse(a1).unwrap())).clone() +} + +fn point(h: &Harness, a1: &str) -> Pos2 { + let app = h.state(); + let sheet = app.session.active().unwrap().wb.active().unwrap(); + Geo::new(sheet, app.grid.rect.unwrap(), app.view().scroll).cell_rect(sheet, CellRef::parse(a1).unwrap()).center() +} + +fn pressed(h: &Harness) -> bool { + h.get_by_label("Format Painter").accesskit_node().toggled() == Some(Toggled::True) +} + +fn click_cell(h: &mut Harness, a1: &str) { + let at = point(h, a1); + for event in [ + Event::PointerMoved(at), + Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }, + Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }, + ] { + h.input_mut().events.push(event); + h.run_steps(1); + } + h.run_steps(2); +} + +#[test] +fn ribbon_button_picks_up_the_format_and_a_click_on_a_cell_pastes_it() { + let mut h = harness(); + let source = style(&h, "A1"); + assert!(source.font.bold); + assert_ne!(style(&h, "C3"), source); + + h.get_by_label("Format Painter").click(); + h.run_steps(2); + assert!(h.state().session.format_painter.is_some(), "the button must arm the painter"); + assert!(pressed(&h), "the armed button must show as pressed"); + + click_cell(&mut h, "C3"); + assert_eq!(style(&h, "C3"), source, "clicking C3 must paste A1's format"); + assert!(h.state().session.format_painter.is_none(), "a single-click painter disarms after one use"); + assert!(!pressed(&h)); +} + +fn header_point(h: &Harness, header: &str) -> Pos2 { + let app = h.state(); + let sheet = app.session.active().unwrap().wb.active().unwrap(); + let geo = Geo::new(sheet, app.grid.rect.unwrap(), app.view().scroll); + match header.parse::() { + Ok(row) => { + let cell = geo.cell_rect(sheet, CellRef::new(row - 1, 0)).center(); + Pos2::new(geo.cells.left() - geo.header_w * 0.5, cell.y) + } + Err(_) => { + let cell = geo.cell_rect(sheet, CellRef::parse(&format!("{header}1")).unwrap()).center(); + Pos2::new(cell.x, geo.cells.top() - geo.header_h * 0.5) + } + } +} + +fn drag(h: &mut Harness, from: Pos2, to: Pos2) { + for event in [ + Event::PointerMoved(from), + Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }, + Event::PointerMoved(to), + Event::PointerMoved(to + egui::vec2(1.0, 0.0)), + Event::PointerButton { pos: to + egui::vec2(1.0, 0.0), button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }, + ] { + h.input_mut().events.push(event); + h.run_steps(1); + } + h.run_steps(2); +} + +fn stored_cells(h: &Harness) -> usize { + h.state().session.active().unwrap().wb.active().unwrap().cells.len() +} + +#[test] +fn a_click_on_a_row_header_pastes_the_format_on_the_whole_row() { + let mut h = harness(); + let source = style(&h, "A1"); + h.get_by_label("Format Painter").click(); + h.run_steps(2); + + let at = header_point(&h, "5"); + drag(&mut h, at, at); + assert_eq!(h.state().session.active().unwrap().selection.current().a1(), "5:5"); + for a1 in ["A5", "K5", "XFD5"] { + assert_eq!(style(&h, a1), source, "{a1} must take A1's format"); + } + assert_ne!(style(&h, "A6"), source, "row 6 was not painted"); + // The row takes a row format: its 16384 cells are not stored one by one. + assert_eq!(stored_cells(&h), 1); + assert!(h.state().session.format_painter.is_none(), "a single-click painter disarms after one use"); + assert!(!pressed(&h)); +} + +#[test] +fn dragging_over_column_headers_with_a_kept_painter_formats_every_column() { + let mut h = harness(); + let source = style(&h, "A1"); + h.state_mut().session.execute("edit.formatPainter", json!({"sticky": true})).unwrap(); + h.run_steps(2); + + let (from, to) = (header_point(&h, "C"), header_point(&h, "E")); + drag(&mut h, from, to); + assert_eq!(h.state().session.active().unwrap().selection.current().a1(), "C:E"); + for a1 in ["C2", "D500", "E1048576"] { + assert_eq!(style(&h, a1), source, "{a1} must take A1's format"); + } + for a1 in ["B2", "F2"] { + assert_ne!(style(&h, a1), source, "{a1} is outside the dragged headers"); + } + assert_eq!(stored_cells(&h), 1, "whole columns take a column format, not a million cell styles"); + assert!(h.state().session.format_painter.is_some(), "a double-click painter stays armed"); + + let at = header_point(&h, "8"); + drag(&mut h, at, at); + assert_eq!(style(&h, "Z8"), source, "the kept painter paints the next header clicked too"); +} + +#[test] +fn dragging_from_the_header_of_the_picked_up_column_paints_instead_of_moving_it() { + let mut h = harness(); + let session = &mut h.state_mut().session; + session.execute("range.setValues", json!({"range": "C1", "values": [["kept"]]})).unwrap(); + session.execute("home.italic", json!({"range": "C:C", "on": true})).unwrap(); + session.execute("selection.set", json!({"range": "C:C"})).unwrap(); + session.execute("edit.formatPainter", json!({})).unwrap(); + h.run_steps(2); + let source = style(&h, "C2"); + + let (from, to) = (header_point(&h, "C"), header_point(&h, "E")); + drag(&mut h, from, to); + let sheet = h.state().session.active().unwrap().wb.active().unwrap(); + assert_eq!(sheet.value(CellRef::parse("C1").unwrap()), "kept".into(), "column C must stay where it is"); + assert_eq!(style(&h, "E2"), source, "E must take C's format"); + assert!(h.state().session.format_painter.is_none()); +} + +#[test] +fn a_two_cell_source_on_a_column_header_paints_a_column_format() { + let mut h = harness(); + let source = style(&h, "A1"); + let session = &mut h.state_mut().session; + session.execute("home.italic", json!({"range": "A2", "on": true})).unwrap(); + session.execute("home.fillColor", json!({"range": "7:7", "color": "#00FF00"})).unwrap(); + session.execute("selection.set", json!({"range": "A1:A2"})).unwrap(); + session.execute("edit.formatPainter", json!({})).unwrap(); + h.run_steps(2); + assert_eq!(stored_cells(&h), 2); + + let at = header_point(&h, "C"); + drag(&mut h, at, at); + assert_eq!(h.state().session.active().unwrap().selection.current().a1(), "C:C"); + assert_eq!(style(&h, "C1048575"), source, "the column takes the pattern's first cell as its format"); + assert_eq!(style(&h, "C7"), source, "row 7's own format must not hide the painted column"); + // Only C7 is stored on top of A1:A2: not a million cells, one per pattern repeat. + assert_eq!(stored_cells(&h), 3); +} diff --git a/crates/ui-egui/tests/formula_point_headers.rs b/crates/ui-egui/tests/formula_point_headers.rs index 4313fb9f6..97bd790d2 100644 --- a/crates/ui-egui/tests/formula_point_headers.rs +++ b/crates/ui-egui/tests/formula_point_headers.rs @@ -35,13 +35,17 @@ fn header_center(h: &egui_kittest::Harness<'_, SheetApp>, rows: bool, index: u32 } fn click(h: &mut egui_kittest::Harness<'_, SheetApp>, pos: Pos2) { + click_with_modifiers(h, pos, Modifiers::NONE); +} + +fn click_with_modifiers(h: &mut egui_kittest::Harness<'_, SheetApp>, pos: Pos2, modifiers: Modifiers) { + h.input_mut().events.push(Event::ModifiersChanged(modifiers)); for pressed in [true, false] { - h.input_mut() - .events - .extend([Event::PointerMoved(pos), Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }]); + h.input_mut().events.extend([Event::PointerMoved(pos), Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers }]); h.step(); } h.run_steps(2); + h.input_mut().events.push(Event::ModifiersChanged(Modifiers::NONE)); } #[test] @@ -52,6 +56,8 @@ fn column_header_click_inserts_a_column_reference() { let app = h.state(); assert_eq!(app.editor.as_ref().map(|e| e.text.as_str()), Some("=SUM(C:C")); assert_eq!(app.session.active().unwrap().selection.current().a1(), "F8"); + let text = app.editor.as_ref().map_or("", |ed| ed.text.as_str()); + assert_eq!(gridcraft_ui_egui::editor::formula_refs(text).first().map(|r| r.2.a1()).as_deref(), Some("C:C")); } #[test] @@ -62,6 +68,8 @@ fn row_header_click_inserts_a_row_reference() { let app = h.state(); assert_eq!(app.editor.as_ref().map(|e| e.text.as_str()), Some("=SUM(4:4")); assert_eq!(app.session.active().unwrap().selection.current().a1(), "F8"); + let text = app.editor.as_ref().map_or("", |ed| ed.text.as_str()); + assert_eq!(gridcraft_ui_egui::editor::formula_refs(text).first().map(|r| r.2.a1()).as_deref(), Some("4:4")); } #[test] @@ -73,3 +81,95 @@ fn a_second_header_click_replaces_the_pointed_reference() { click(&mut h, second); assert_eq!(h.state().editor.as_ref().map(|e| e.text.as_str()), Some("=SUM(E:E")); } + +#[test] +fn shift_selects_a_contiguous_column_range_while_editing_a_formula() { + let mut h = harness(); + let first = header_center(&h, false, 2); + click(&mut h, first); + let second = header_center(&h, false, 4); + click_with_modifiers(&mut h, second, Modifiers::SHIFT); + let app = h.state(); + let text = app.editor.as_ref().map_or("", |ed| ed.text.as_str()); + assert_eq!(text, "=SUM(C:E"); + assert_eq!(gridcraft_ui_egui::editor::formula_refs(text).iter().map(|r| r.2.a1()).collect::>(), ["C:E"]); +} + +#[test] +fn command_selects_discontiguous_columns_while_editing_a_formula() { + let mut h = harness(); + let first = header_center(&h, false, 2); + click(&mut h, first); + let second = header_center(&h, false, 4); + click_with_modifiers(&mut h, second, Modifiers::COMMAND); + let app = h.state(); + let text = app.editor.as_ref().map_or("", |ed| ed.text.as_str()); + assert_eq!(text, "=SUM(C:C,E:E"); + assert_eq!(gridcraft_ui_egui::editor::formula_refs(text).iter().map(|r| r.2.a1()).collect::>(), ["C:C", "E:E"]); +} + +#[test] +fn shift_selects_a_contiguous_row_range_while_editing_a_formula() { + let mut h = harness(); + let first = header_center(&h, true, 3); + click(&mut h, first); + let second = header_center(&h, true, 5); + click_with_modifiers(&mut h, second, Modifiers::SHIFT); + let app = h.state(); + let text = app.editor.as_ref().map_or("", |ed| ed.text.as_str()); + assert_eq!(text, "=SUM(4:6"); + assert_eq!(gridcraft_ui_egui::editor::formula_refs(text).iter().map(|r| r.2.a1()).collect::>(), ["4:6"]); +} + +#[test] +fn command_selects_discontiguous_rows_while_editing_a_formula() { + let mut h = harness(); + let first = header_center(&h, true, 3); + click(&mut h, first); + let second = header_center(&h, true, 5); + click_with_modifiers(&mut h, second, Modifiers::COMMAND); + let app = h.state(); + let text = app.editor.as_ref().map_or("", |ed| ed.text.as_str()); + assert_eq!(text, "=SUM(4:4,6:6"); + assert_eq!(gridcraft_ui_egui::editor::formula_refs(text).iter().map(|r| r.2.a1()).collect::>(), ["4:4", "6:6"]); +} + +#[test] +fn shift_does_not_extend_a_column_reference_across_sheets() { + let mut h = harness(); + h.state_mut().session.execute("home.insertSheet", json!({"name": "Other sheet"})).unwrap(); + h.state_mut().session.execute("sheet.activate", json!({"sheet": "Sheet1"})).unwrap(); + h.run_steps(2); + let first = header_center(&h, false, 2); + click(&mut h, first); + h.state_mut().session.execute("sheet.activate", json!({"sheet": "Other sheet"})).unwrap(); + h.run_steps(2); + let second = header_center(&h, false, 4); + click_with_modifiers(&mut h, second, Modifiers::SHIFT); + let text = h.state().editor.as_ref().map_or("", |ed| ed.text.as_str()); + assert_eq!(text, "=SUM('Other sheet'!E:E"); + assert_eq!( + gridcraft_ui_egui::editor::formula_refs(text).iter().map(|r| (r.2.a1(), r.3.clone())).collect::>(), + [("E:E".into(), Some("Other sheet".into())),] + ); +} + +#[test] +fn shift_does_not_extend_a_row_reference_across_sheets() { + let mut h = harness(); + h.state_mut().session.execute("home.insertSheet", json!({"name": "Other sheet"})).unwrap(); + h.state_mut().session.execute("sheet.activate", json!({"sheet": "Sheet1"})).unwrap(); + h.run_steps(2); + let first = header_center(&h, true, 3); + click(&mut h, first); + h.state_mut().session.execute("sheet.activate", json!({"sheet": "Other sheet"})).unwrap(); + h.run_steps(2); + let second = header_center(&h, true, 5); + click_with_modifiers(&mut h, second, Modifiers::SHIFT); + let text = h.state().editor.as_ref().map_or("", |ed| ed.text.as_str()); + assert_eq!(text, "=SUM('Other sheet'!6:6"); + assert_eq!( + gridcraft_ui_egui::editor::formula_refs(text).iter().map(|r| (r.2.a1(), r.3.clone())).collect::>(), + [("6:6".into(), Some("Other sheet".into())),] + ); +} diff --git a/crates/ui-egui/tests/insert_copied_cells.rs b/crates/ui-egui/tests/insert_copied_cells.rs index 89621f21b..d6ca79f2d 100644 --- a/crates/ui-egui/tests/insert_copied_cells.rs +++ b/crates/ui-egui/tests/insert_copied_cells.rs @@ -58,3 +58,34 @@ fn row_header_menu_inserts_copied_rows() { assert_eq!(value(&h, "A4"), Value::Number(3.0)); assert_eq!(value(&h, "A5"), Value::Number(4.0)); } + +fn right_click_col_header(h: &mut Harness, cell: &str) { + 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); + let x = geo.cell_rect(sh, CellRef::parse(cell).unwrap()).center().x; + let pos = pos2(x, (geo.rect.top() + geo.cells.top()) / 2.0); + h.input_mut().events.push(Event::PointerMoved(pos)); + h.input_mut().events.push(Event::PointerButton { pos, button: PointerButton::Secondary, pressed: true, modifiers: Modifiers::NONE }); + h.input_mut().events.push(Event::PointerButton { pos, button: PointerButton::Secondary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(2); +} + +/// As in Excel, the row and column header menus offer Format Cells… for the selected lines. +#[test] +fn header_menus_open_format_cells() { + for rows in [true, false] { + let mut h = harness(); + if rows { + right_click_row_header(&mut h, "A3"); + } else { + right_click_col_header(&mut h, "B1"); + } + h.get_by_label("Format Cells…").click(); + h.run_steps(2); + let app = h.state(); + assert_eq!(app.dialog.as_ref().map(|d| d.name()), Some("formatCells"), "rows: {rows}"); + let sel = app.session.active().unwrap().selection.a1(); + assert_eq!(sel, if rows { "3:3" } else { "B:B" }); + } +} diff --git a/crates/ui-egui/tests/interaction.rs b/crates/ui-egui/tests/interaction.rs index 8af57a69e..db957608b 100644 --- a/crates/ui-egui/tests/interaction.rs +++ b/crates/ui-egui/tests/interaction.rs @@ -228,6 +228,19 @@ fn name_box_enter_goes_to_the_typed_cell_not_the_one_below() { assert_eq!(h.state().session.active().unwrap().selection.active, CellRef::new(1, 0)); } +#[test] +fn name_box_shows_a_name_defined_on_a_sheet_with_an_apostrophe() { + // #1458: the quotes of `'Bob''s'!$A$1:$B$2` were taken off without reading `''` as `'`. + let mut h = harness(blank()); + let session = &mut h.state_mut().session; + session.run("home.insertSheet", json!({"name": "Bob's"})).unwrap(); + session.run("sheet.activate", json!({"sheet": "Bob's"})).unwrap(); + session.run("formulas.defineName", json!({"name": "Box", "refersTo": "='Bob''s'!$A$1:$B$2"})).unwrap(); + session.run("selection.set", json!({"range": "A1:B2"})).unwrap(); + h.run_steps(2); + assert_eq!(h.state().name_box_text(), "Box"); +} + #[test] fn typing_enters_values_and_moves_down() { let mut h = harness(blank()); diff --git a/crates/ui-egui/tests/keyboard_shortcuts.rs b/crates/ui-egui/tests/keyboard_shortcuts.rs index c0a1307b3..ea593611d 100644 --- a/crates/ui-egui/tests/keyboard_shortcuts.rs +++ b/crates/ui-egui/tests/keyboard_shortcuts.rs @@ -214,3 +214,37 @@ fn alt_down_only_opens_enabled_table_filter_buttons() { assert_eq!(h.state().session.active().unwrap().selection.active.a1(), "B1"); } } + +/// #1443: Ctrl+Q (also with Shift or Alt, which is what AltGr+Q reports on Windows) must not +/// close the window and lose unsaved input; macOS keeps Cmd+Q as the app's Quit. +#[test] +fn ctrl_q_does_not_close_the_window_outside_macos() { + let closes = |h: &mut Harness, k: Key, m: Modifiers| -> bool { + h.input_mut().events.push(Event::Key { key: k, physical_key: Some(k), pressed: true, repeat: false, modifiers: m }); + h.input_mut().events.push(Event::Key { key: k, physical_key: Some(k), pressed: false, repeat: false, modifiers: m }); + let mut closed = false; + for _ in 0..3 { + h.step(); + closed |= h.output().viewport_output.values().any(|v| v.commands.iter().any(|c| matches!(c, egui::ViewportCommand::Close))); + } + closed + }; + let ctrl_alt = Modifiers { alt: true, ..CTRL }; + for os in [egui::os::OperatingSystem::Windows, egui::os::OperatingSystem::Nix] { + let mut h = harness(book("A1")); + h.ctx.set_os(os); + h.run_steps(2); + for (name, m) in [("Ctrl+Q", CTRL), ("Ctrl+Shift+Q", CTRL_SHIFT), ("Ctrl+Alt+Q", ctrl_alt)] { + assert!(!closes(&mut h, Key::Q, m), "{os:?}: {name} closed the window"); + } + assert!(h.ctx.options(|o| o.quit_shortcuts.is_empty()), "{os:?}: no quit shortcut left"); + } + // macOS: Cmd+Q stays the app's Quit. + let mut h = harness(book("A1")); + // The harness's first frame already ran as the host OS; restore egui's default, as a real + // macOS launch would have it. + h.ctx.options_mut(|o| o.quit_shortcuts = vec![egui::KeyboardShortcut::new(Modifiers::COMMAND, Key::Q)]); + h.ctx.set_os(egui::os::OperatingSystem::Mac); + h.run_steps(2); + assert!(h.ctx.options(|o| !o.quit_shortcuts.is_empty()), "Cmd+Q is still egui's quit shortcut on macOS"); +} diff --git a/crates/ui-egui/tests/protect_password.rs b/crates/ui-egui/tests/protect_password.rs index ee68cf2b6..7a2fe551e 100644 --- a/crates/ui-egui/tests/protect_password.rs +++ b/crates/ui-egui/tests/protect_password.rs @@ -68,3 +68,22 @@ fn protect_and_unprotect_sheet_mask_the_password() { h.run_steps(3); assert!(h.state().session.active().unwrap().wb.active().unwrap().protection.is_none()); } + +#[test] +fn protect_sheet_entry_unprotects_a_protected_sheet() { + // No password: the Protect Sheet entry points (Home > Format, HOP, RPS, tab menu) unprotect straight away. + let mut h = harness(); + h.state_mut().run("review.protectSheet", json!({})).unwrap(); + assert!(h.state().active_sheet_protected()); + h.state_mut().open_dialog("protectSheet", json!({})); + h.run_steps(3); + assert!(h.state().dialog.is_none(), "no password: no dialog"); + assert!(!h.state().active_sheet_protected()); + + // With a password the Unprotect dialog asks for it, and a wrong one leaves the sheet protected. + h.state_mut().run("review.protectSheet", json!({"password": SECRET})).unwrap(); + h.state_mut().open_dialog("protectSheet", json!({})); + h.run_steps(3); + assert_eq!(h.state().dialog.as_ref().map(|d| d.name.as_str()), Some("unprotectSheet")); + assert!(h.state().active_sheet_protected()); +} diff --git a/crates/ui-egui/tests/sheet_tabs.rs b/crates/ui-egui/tests/sheet_tabs.rs index 7b43cd12b..34d24c854 100644 --- a/crates/ui-egui/tests/sheet_tabs.rs +++ b/crates/ui-egui/tests/sheet_tabs.rs @@ -162,3 +162,35 @@ fn overflow_leaves_the_worksheet_scrollbar_reachable() { assert!(track.left() >= 0.0 && track.right() <= width + 0.5, "{width}: the scrollbar is offscreen: {track:?}"); } } + +#[test] +fn right_clicking_a_tab_activates_its_sheet_so_the_menu_acts_on_it() { + let mut h = harness(1200.0, 0); + h.state_mut().run("sheet.activate", json!({"sheet":3})).unwrap(); + h.state_mut().run("review.protectSheet", json!({})).unwrap(); + h.state_mut().run("sheet.activate", json!({"sheet":0})).unwrap(); + // Tall enough for the whole tab menu to be painted. + h.set_size(vec2(1200.0, 600.0)); + h.run_steps(20); + let (text, _) = tab(&h, "Tab03").unwrap(); + let at = text.center(); + h.input_mut().events.extend([ + Event::PointerMoved(at), + Event::PointerButton { pos: at, button: PointerButton::Secondary, pressed: true, modifiers: Modifiers::NONE }, + ]); + h.step(); + h.input_mut().events.push(Event::PointerButton { pos: at, button: PointerButton::Secondary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(20); + assert_eq!(h.state().session.doc().unwrap().wb.active_sheet, 3, "right-clicking Tab03 should activate it"); + // The menu now offers Unprotect for the protected sheet that was clicked, not Protect for the one that was active. + let texts: Vec = h + .output() + .shapes + .iter() + .filter_map(|s| match &s.shape { + Shape::Text(t) => Some(t.galley.job.text.clone()), + _ => None, + }) + .collect(); + assert!(texts.iter().any(|t| t == "Unprotect Sheet…"), "menu should offer Unprotect Sheet…, painted: {texts:?}"); +} diff --git a/crates/ui-egui/tests/table_headings.rs b/crates/ui-egui/tests/table_headings.rs new file mode 100644 index 000000000..5a2685e01 --- /dev/null +++ b/crates/ui-egui/tests/table_headings.rs @@ -0,0 +1,165 @@ +//! A Table's header names and filter buttons stand in for the column letters while its header row +//! is scrolled out of view and the active cell is inside it, as in Excel (#1423). + +use egui::{Event, Modifiers, PointerButton, Pos2, Rect, pos2, vec2}; +use gridcraft_engine::Session; +use gridcraft_engine::core::{CellRef, RangeRef}; +use gridcraft_engine::model::Sheet; +use gridcraft_ui_egui::SheetApp; +use gridcraft_ui_egui::grid::{Geo, heading_table, table_headings}; +use serde_json::json; + +type Harness = egui_kittest::Harness<'static, SheetApp>; + +/// A Table on `range` (header row 3) with these header names and numbers below. +fn table(s: &mut Session, range: &str, names: &[&str]) { + let r = RangeRef::parse(range).unwrap(); + let mut rows = vec![names.iter().map(|n| json!(n)).collect::>()]; + for i in r.start.row + 1..=r.end.row { + rows.push(names.iter().map(|_| json!(i)).collect()); + } + s.execute("range.setValues", json!({"range": r.start.a1(), "values": rows})).unwrap(); + s.execute("insert.table", json!({"range": range, "header": true})).unwrap(); +} + +fn session(active: &str) -> Session { + let mut s = Session::new(); + s.new_workbook(); + table(&mut s, "A3:E40", &["Bank", "Date", "Type", "Amount", "Ref"]); + s.execute("selection.set", json!({"cell": active})).unwrap(); + s +} + +fn sheet(s: &Session) -> Sheet { + s.active().unwrap().wb.active().unwrap().clone() +} + +/// The grid scrolled so that `row` is the first one under the frozen rows. +fn geo_at(sh: &Sheet, row: u32) -> Geo { + let fr = sh.freeze.map_or(0, |f| f.0); + let scroll = vec2(0.0, (sh.row_top(row) - sh.row_top(fr)) as f32); + Geo::new(sh, Rect::from_min_size(pos2(0.0, 0.0), vec2(1000.0, 600.0)), scroll) +} + +fn names(geo: &Geo, sh: &Sheet, active: &str) -> Vec<(u32, String)> { + table_headings(geo, sh, CellRef::parse(active).unwrap()).into_iter().map(|h| (h.col, h.name)).collect() +} + +fn owned(v: &[(u32, &str)]) -> Vec<(u32, String)> { + v.iter().map(|(c, n)| (*c, n.to_string())).collect() +} + +#[test] +fn header_names_replace_the_letters_only_while_the_header_row_is_out_of_view() { + let s = session("C20"); + let sh = sheet(&s); + let c20 = CellRef::parse("C20").unwrap(); + assert!(heading_table(&geo_at(&sh, 0), &sh, c20).is_none(), "header row 3 on screen: letters"); + assert!(heading_table(&geo_at(&sh, 2), &sh, c20).is_none(), "header row 3 at the top: letters"); + assert_eq!(heading_table(&geo_at(&sh, 3), &sh, c20).map(|t| t.range.a1()).as_deref(), Some("A3:E40")); + assert_eq!(names(&geo_at(&sh, 10), &sh, "C20"), owned(&[(0, "Bank"), (1, "Date"), (2, "Type"), (3, "Amount"), (4, "Ref")])); + // Scrolled past the table's last row, the letters come back. + assert!(heading_table(&geo_at(&sh, 40), &sh, c20).is_none()); +} + +#[test] +fn an_active_cell_outside_the_table_keeps_the_letters() { + let s = session("G20"); + let sh = sheet(&s); + // The table's rows fill the screen, so only where the active cell is tells the two apart. + assert!(heading_table(&geo_at(&sh, 10), &sh, CellRef::parse("G20").unwrap()).is_none()); + assert!(heading_table(&geo_at(&sh, 10), &sh, CellRef::parse("E20").unwrap()).is_some()); +} + +#[test] +fn a_header_row_in_the_frozen_pane_keeps_the_letters() { + let mut s = session("C20"); + s.execute("view.freezePanes", json!({"cell": "A4"})).unwrap(); + let sh = sheet(&s); + assert_eq!(sh.freeze, Some((3, 0))); + assert!(heading_table(&geo_at(&sh, 10), &sh, CellRef::parse("C20").unwrap()).is_none()); + + // Frozen above the header row, the header row scrolls under the frozen rows. + s.execute("view.freezePanes", json!({"cell": "A2"})).unwrap(); + let sh = sheet(&s); + assert_eq!(sh.freeze, Some((1, 0))); + assert!(heading_table(&geo_at(&sh, 2), &sh, CellRef::parse("C20").unwrap()).is_none()); + assert!(heading_table(&geo_at(&sh, 3), &sh, CellRef::parse("C20").unwrap()).is_some()); +} + +#[test] +fn with_two_tables_the_names_come_from_the_active_cells_table() { + let mut s = Session::new(); + s.new_workbook(); + table(&mut s, "A3:C40", &["Bank", "Date", "Type"]); + table(&mut s, "E3:G40", &["Region", "Units", "Price"]); + let sh = sheet(&s); + let geo = geo_at(&sh, 10); + assert_eq!(names(&geo, &sh, "F20"), owned(&[(4, "Region"), (5, "Units"), (6, "Price")])); + assert_eq!(names(&geo, &sh, "B20"), owned(&[(0, "Bank"), (1, "Date"), (2, "Type")])); + assert_eq!(names(&geo, &sh, "D20"), owned(&[])); +} + +#[test] +fn hidden_columns_leave_the_other_names_over_their_own_columns() { + let mut s = session("C20"); + s.execute("home.hideColumns", json!({"cols": "B:B"})).unwrap(); + let sh = sheet(&s); + assert_eq!(sh.col_width(1), 0.0); + let geo = geo_at(&sh, 10); + let headings = table_headings(&geo, &sh, CellRef::parse("C20").unwrap()); + let got: Vec<(u32, &str)> = headings.iter().map(|h| (h.col, h.name.as_str())).collect(); + assert_eq!(got, [(0, "Bank"), (2, "Type"), (3, "Amount"), (4, "Ref")]); + for h in &headings { + let cell = geo.cell_rect(&sh, CellRef::new(10, h.col)); + assert_eq!((h.rect.left(), h.rect.right()), (cell.left(), cell.right()), "{} sits over column {}", h.name, h.col); + let b = h.button.unwrap(); + assert!(b.left() > cell.left() && b.right() < cell.right() && b.top() >= geo.head_top() && b.bottom() <= geo.cells.top()); + } +} + +fn click(h: &mut Harness, at: Pos2) { + let press = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }; + h.input_mut().events.extend([Event::PointerMoved(at), press(true)]); + h.step(); + h.input_mut().events.push(press(false)); + h.run_steps(3); +} + +#[test] +fn the_button_in_a_replaced_heading_opens_that_columns_filter_menu() { + let mut s = session("C20"); + s.execute("home.hideColumns", json!({"cols": "B:B"})).unwrap(); + s.execute("selection.set", json!({"cell": "C20"})).unwrap(); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SheetApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SheetApp::new(s, Default::default()), + ); + h.run_steps(4); + let sh = sheet(&h.state().session); + let scroll = sh.row_top(10) as f32; + h.state_mut().view_mut().unwrap().scroll.y = scroll; + h.run_steps(2); + if let Ok(path) = std::env::var("GRIDCRAFT_TABLE_HEADINGS_SCREENSHOT") { + h.render().unwrap().save(path).unwrap(); + } + let app = h.state(); + let geo = Geo::new(&sh, app.grid.rect.unwrap(), app.view().scroll); + let headings = table_headings(&geo, &sh, CellRef::parse("C20").unwrap()); + let amount = headings.iter().find(|x| x.name == "Amount").unwrap(); + assert_eq!(amount.col, 3); + + click(&mut h, amount.button.unwrap().center()); + let menu = h.state().grid.filter_menu.map(|(c, r, _)| (c, r)); + assert_eq!(menu, Some((3, 2)), "the menu of column D, keyed by the table's header row"); + assert_eq!(h.state().session.active().unwrap().selection.active.a1(), "C20", "the button doesn't select"); + + // Beside the button the heading still selects the whole column. + h.state_mut().grid.filter_menu = None; + click(&mut h, pos2(amount.rect.left() + 6.0, amount.rect.center().y)); + assert_eq!(h.state().grid.filter_menu, None); + assert_eq!(h.state().session.active().unwrap().selection.current().a1(), "D:D"); +} diff --git a/crates/xlsx/src/array_formula.rs b/crates/xlsx/src/array_formula.rs index b2c09a749..a3dfe0d20 100644 --- a/crates/xlsx/src/array_formula.rs +++ b/crates/xlsx/src/array_formula.rs @@ -53,7 +53,7 @@ impl Scan<'_> { return *shape; } let (scope, bare) = match n.rsplit_once('!') { - Some((s, name)) => (self.wb.sheet_index(&s.trim_matches('\'').replace("''", "'")).unwrap_or(sheet), name), + Some((s, name)) => (self.wb.sheet_index(&gridcraft_core::unquote_sheet(s)).unwrap_or(sheet), name), None => (sheet, n.as_str()), }; if let Some(def) = self.wb.name(bare, scope) { diff --git a/crates/xlsx/src/chart.rs b/crates/xlsx/src/chart.rs index aaebadca1..f4823cc25 100644 --- a/crates/xlsx/src/chart.rs +++ b/crates/xlsx/src/chart.rs @@ -5,7 +5,7 @@ use std::fmt::Write as _; use gridcraft_core::{RangeRef, Value, category_levels}; use gridcraft_formula::{Expr, SheetSel}; -use gridcraft_model::{Chart, ChartKind, Color, LegendPos, LineDash, Series, Trendline, TrendlineKind, Workbook}; +use gridcraft_model::{Chart, ChartKind, Color, DefinedName, LegendPos, LineDash, Series, Trendline, TrendlineKind, Workbook}; use crate::drawing::{dml_color, srgb}; use crate::xml::{El, esc, num}; @@ -61,12 +61,49 @@ fn group_kind(g: &El) -> Option { }) } +/// Excel's file notation for "this workbook" in front of a workbook-scoped defined name. +const THIS_BOOK: &str = "[0]!"; + +/// A chart reference (``) read from a file → model text: drops a leading `=` and the +/// `[0]!` in front of workbook-scoped names (`[0]!Sales` → `Sales`), which the formula parser +/// would take for a structured reference. External books (`[1]Sheet1!$A$1`), quoted sheet +/// names and string literals are kept as they are. +pub(crate) fn chart_ref_from_file(text: &str) -> String { + let t = text.trim(); + let t = t.strip_prefix('=').unwrap_or(t); + let mut out = String::with_capacity(t.len()); + let mut quote: Option = None; + // The last character kept: `[0]!` only counts at the start or after an operator, + // never inside a structured reference (`T[[0]]`) or a longer name. + let mut prev: Option = None; + let mut i = 0; + while let Some(c) = t.get(i..).and_then(|r| r.chars().next()) { + let rest = t.get(i..).unwrap_or(""); + if quote.is_none() + && rest.starts_with(THIS_BOOK) + && prev.is_none_or(|p| !(p.is_alphanumeric() || "_.\\[]'\"!$#".contains(p))) + && rest.get(THIS_BOOK.len()..).and_then(|n| n.chars().next()).is_some_and(|n| n.is_alphabetic() || n == '_' || n == '\\') + { + i += THIS_BOOK.len(); + continue; + } + match (c, quote) { + ('"' | '\'', None) => quote = Some(c), + (q, Some(open)) if q == open => quote = None, + _ => {} + } + out.push(c); + prev = Some(c); + i += c.len_utf8(); + } + out +} + fn data_ref(e: Option<&El>) -> Option { let e = e?; for k in ["numRef", "strRef", "multiLvlStrRef"] { if let Some(f) = e.child(k).and_then(|r| r.child("f")) { - let t = f.text.trim(); - return Some(t.strip_prefix('=').unwrap_or(t).to_string()); + return Some(chart_ref_from_file(&f.text)); } } // Literal values become an array constant. @@ -84,14 +121,9 @@ fn data_ref(e: Option<&El>) -> Option { } fn read_series(s: &El) -> Series { - let name = s.child("tx").and_then(|tx| { - if let Some(f) = tx.path(&["strRef", "f"]) { - let t = f.text.trim(); - Some(t.strip_prefix('=').unwrap_or(t).to_string()) - } else { - tx.child("v").map(|v| v.text.clone()) - } - }); + let name = s + .child("tx") + .and_then(|tx| tx.path(&["strRef", "f"]).map(|f| chart_ref_from_file(&f.text)).or_else(|| tx.child("v").map(|v| v.text.clone()))); let sp = s.child("spPr"); let color = sp .and_then(|p| p.child("solidFill")) @@ -316,6 +348,41 @@ fn is_ref(f: &str) -> bool { matches!(gridcraft_formula::parse(f), Ok(Expr::Ref(_))) } +/// A chart reference that is a name: its sheet when qualified (`Sheet1!Sales`), the bare +/// name, and the definition formulas would find from sheet `si` (none when undefined). `None` +/// when it isn't a name, or names a sheet the workbook doesn't have. +fn chart_name<'a>(wb: &'a Workbook, si: usize, f: &str) -> Option<(Option, String, Option<&'a DefinedName>)> { + let Ok(Expr::Name(n)) = gridcraft_formula::parse(f.trim()) else { return None }; + match n.split_once('!') { + Some((s, bare)) => { + let i = wb.sheet_index(s.trim_matches('\''))?; + Some((Some(i), bare.to_string(), wb.name(bare, i))) + } + None => { + let def = wb.name(&n, si); + Some((None, n, def)) + } + } +} + +/// A chart reference for ``. Excel needs a name qualified there: with a bare `Name` it +/// draws the whole chart blank, with no repair prompt (observed in Excel for Windows, +/// Microsoft 365). A workbook-scoped name is written `[0]!Name`, as Excel writes it, a +/// sheet-scoped one `Sheet!Name`; an undefined name gets `[0]!`, as the file it came from had. +fn chart_ref_to_file(wb: &Workbook, si: usize, f: &str) -> String { + let on_sheet = |i: usize, n: &str| wb.sheet(i).map(|sh| format!("{}!{n}", gridcraft_formula::quote_sheet(&sh.name))); + match chart_name(wb, si, f) { + Some((Some(i), n, _)) => on_sheet(i, &n).unwrap_or_else(|| crate::fmla::text_to_file(f)), + Some((None, n, def)) => def.and_then(|d| d.scope).and_then(|i| on_sheet(i, &n)).unwrap_or_else(|| format!("{THIS_BOOK}{n}")), + None => crate::fmla::text_to_file(f), + } +} + +/// A series name that refers to a defined name (as opposed to literal text that parses as one). +fn is_defined_name(wb: &Workbook, si: usize, f: &str) -> bool { + chart_name(wb, si, f).is_some_and(|(_, _, d)| d.is_some()) +} + fn num_data(tag: &str, f: Option<&str>, wb: &Workbook, si: usize) -> String { let f = f.unwrap_or("").trim(); if f.is_empty() { @@ -332,7 +399,7 @@ fn num_data(tag: &str, f: Option<&str>, wb: &Workbook, si: usize) -> String { let _ = write!(s, ""); return s; } - let mut s = format!("{}", esc(&crate::fmla::text_to_file(f))); + let mut s = format!("{}", esc(&chart_ref_to_file(wb, si, f))); if let Some(vals) = ref_values(wb, si, f) { let _ = write!(s, "General", vals.len()); for (i, v) in vals.iter().enumerate() { @@ -393,7 +460,7 @@ fn str_data(tag: &str, f: &str, wb: &Workbook, si: usize) -> String { let _ = write!(s, ""); return s; } - let mut s = format!("{}", esc(&crate::fmla::text_to_file(f))); + let mut s = format!("{}", esc(&chart_ref_to_file(wb, si, f))); if let Some(vals) = ref_values(wb, si, f) { let _ = write!(s, "", vals.len()); for (i, v) in vals.iter().enumerate() { @@ -451,7 +518,7 @@ fn multi_level_cat(f: &str, values: &str, wb: &Workbook, si: usize) -> Option String { let mut x = format!(""); if let Some(n) = &s.name { - if is_ref(n) { + if is_ref(n) || is_defined_name(wb, si, n) { x.push_str(&str_data("tx", n, wb, si)); } else { let lit = n.trim().strip_prefix('"').and_then(|t| t.strip_suffix('"')).unwrap_or(n); diff --git a/crates/xlsx/src/chartex.rs b/crates/xlsx/src/chartex.rs index 2ab8c221b..e9f8729a4 100644 --- a/crates/xlsx/src/chartex.rs +++ b/crates/xlsx/src/chartex.rs @@ -48,15 +48,13 @@ fn kind_of(layout: &str) -> Option { fn formula(dim: &El) -> Option { let f = dim.child("f")?; - let t = f.text.trim(); - Some(crate::fmla::from_file(t.strip_prefix('=').unwrap_or(t))) + Some(crate::fmla::from_file(&crate::chart::chart_ref_from_file(&f.text))) } fn text(tx: &El) -> Option { if let Some(d) = tx.child("txData") { if let Some(f) = d.child("f") { - let t = f.text.trim(); - return Some(t.strip_prefix('=').unwrap_or(t).to_string()); + return Some(crate::chart::chart_ref_from_file(&f.text)); } return d.child("v").map(|v| v.text.clone()); } diff --git a/crates/xlsx/src/pivot.rs b/crates/xlsx/src/pivot.rs index 743a746e7..de0b82238 100644 --- a/crates/xlsx/src/pivot.rs +++ b/crates/xlsx/src/pivot.rs @@ -378,10 +378,7 @@ fn resolve_source(wb: &Workbook, text: &str, default_sheet: usize, depth: u32) - let xml = if valid { format!("", esc_attr(&tb.name)) } else { range_source_xml(&sh.name, r) }; return Some((si, r, xml)); } - let (sheet, body) = match t.rsplit_once('!') { - Some((sh, body)) => (Some(sh.trim_matches('\'').replace("''", "'")), body), - None => (None, t), - }; + let (sheet, body) = gridcraft_core::split_sheet(t); if let Some(r) = RangeRef::parse(body) { let si = match &sheet { Some(n) => wb.sheet_index(n)?, diff --git a/crates/xlsx/src/read.rs b/crates/xlsx/src/read.rs index 1a479081c..ee93c1b77 100644 --- a/crates/xlsx/src/read.rs +++ b/crates/xlsx/src/read.rs @@ -265,7 +265,7 @@ pub fn read_xlsx(bytes: &[u8]) -> Result<(Workbook, ReadReport), IoError> { continue; } if lname.starts_with("_xlchart.") { - chart_names.push((lname, crate::fmla::from_file(text))); + chart_names.push((lname, crate::fmla::from_file(&crate::chart::chart_ref_from_file(text)))); continue; } if lname == "_xlnm.print_area" || lname == "_xlnm.print_titles" { diff --git a/crates/xlsx/src/sheet_read.rs b/crates/xlsx/src/sheet_read.rs index 0194d2268..5a904fcd2 100644 --- a/crates/xlsx/src/sheet_read.rs +++ b/crates/xlsx/src/sheet_read.rs @@ -456,6 +456,11 @@ fn read_cell(cx: &mut Ctx<'_>, sheet: &mut Sheet, st: &mut RowState, c: &El, pos } fn read_formula(cx: &mut Ctx<'_>, st: &mut RowState, f: &El, pos: CellRef, dynamic: bool) -> Option { + // Keep the file's dynamic-array mark, so saving never turns the formula into a legacy one. + read_formula_text(cx, st, f, pos, dynamic).map(|fm| Formula { dynamic, ..fm }) +} + +fn read_formula_text(cx: &mut Ctx<'_>, st: &mut RowState, f: &El, pos: CellRef, dynamic: bool) -> Option { let model = from_file(f.text.trim()); let text = model.as_str(); match f.attr("t").unwrap_or("normal") { diff --git a/crates/xlsx/src/sheet_write.rs b/crates/xlsx/src/sheet_write.rs index 78e1afaae..6d9a4731b 100644 --- a/crates/xlsx/src/sheet_write.rs +++ b/crates/xlsx/src/sheet_write.rs @@ -667,6 +667,10 @@ fn write_cell( Value::Error(CellError::Spill | CellError::Calc) => Value::Error(CellError::Value), v => v, }; + // A formula read with dynamic-array metadata keeps it, even where the shape analysis would + // not need it: Excel decided the formula is evaluated as an array. + let own = formula.filter(|f| f.dynamic && f.array.is_none()).map(|_| RangeRef::cell(c)); + let dynamic = dynamic.or(own.as_ref()); // A non-spilling formula may still need array evaluation to avoid implicit intersection. if dynamic.is_some() && formula.is_some_and(|f| f.array.is_none()) { s.push_str(" cm=\"1\""); diff --git a/crates/xlsx/src/tables.rs b/crates/xlsx/src/tables.rs index fe7cbdc42..aba547f79 100644 --- a/crates/xlsx/src/tables.rs +++ b/crates/xlsx/src/tables.rs @@ -52,17 +52,7 @@ pub fn builtin_id(code: &str) -> Option { BUILTIN_FORMATS.iter().find(|(i, c)| *c == code && !(5..=8).contains(i)).map(|(i, _)| *i) } -/// The 64-entry legacy palette (`indexed` colours), RRGGBB. -pub const INDEXED: [u32; 64] = [ - 0x000000, 0xFFFFFF, 0xFF0000, 0x00FF00, 0x0000FF, 0xFFFF00, 0xFF00FF, 0x00FFFF, // 0-7 - 0x000000, 0xFFFFFF, 0xFF0000, 0x00FF00, 0x0000FF, 0xFFFF00, 0xFF00FF, 0x00FFFF, // 8-15 - 0x800000, 0x008000, 0x000080, 0x808000, 0x800080, 0x008080, 0xC0C0C0, 0x808080, // 16-23 - 0x9999FF, 0x993366, 0xFFFFCC, 0xCCFFFF, 0x660066, 0xFF8080, 0x0066CC, 0xCCCCFF, // 24-31 - 0x000080, 0xFF00FF, 0xFFFF00, 0x00FFFF, 0x800080, 0x800000, 0x008080, 0x0000FF, // 32-39 - 0x00CCFF, 0xCCFFFF, 0xCCFFCC, 0xFFFF99, 0x99CCFF, 0xFF99CC, 0xCC99FF, 0xFFCC99, // 40-47 - 0x3366FF, 0x33CCCC, 0x99CC00, 0xFFCC00, 0xFF9900, 0xFF6600, 0x666699, 0x969696, // 48-55 - 0x003366, 0x339966, 0x003300, 0x333300, 0x993300, 0x993366, 0x333399, 0x333333, // 56-63 -]; +pub use gridcraft_core::color::INDEXED; /// Paper sizes (`pageSetup paperSize`) we name. const PAPERS: [(u32, &str); 14] = [ diff --git a/crates/xlsx/src/tests/cellmetadata.rs b/crates/xlsx/src/tests/cellmetadata.rs new file mode 100644 index 000000000..3e8791630 --- /dev/null +++ b/crates/xlsx/src/tests/cellmetadata.rs @@ -0,0 +1,97 @@ +//! Dynamic-array cell metadata (`cm`) read from a file is written back, so Excel keeps +//! evaluating those formulas as arrays rather than with implicit intersection. + +use gridcraft_core::CellRef; + +use crate::package::Package; +use crate::xml::El; +use crate::{read_xlsx, write_xlsx}; + +const METADATA: &str = r#""#; +const METADATA_REL: &str = + r#""#; + +/// Formulas Excel saves with `cm` although they return one value, and which GridCraft's own +/// shape analysis would save without it. +const DYNAMIC: [(&str, &str); 5] = [ + ("D1", "SUMPRODUCT(B1:B3*C1:C3)"), + ("D2", "SUMPRODUCT(IFERROR(B1:B3/C1:C3,0))"), + ("D3", "IF(AND(B1:B3>0),\"all\",\"some\")"), + ("D4", "MID(CELL(\"filename\",A1),FIND(\"]\",CELL(\"filename\",A1))+1,31)"), + ("D5", "SUMPRODUCT(--(YEAR(A1:A3)=2024))"), +]; + +fn source() -> Vec { + let mut rows = String::new(); + for r in 1..=3 { + rows.push_str(&format!(r#"{}{r}{}"#, 45290 + r, r + 1)); + let (address, text) = DYNAMIC[r as usize - 1]; + rows.push_str(&format!(r#"{}0"#, crate::xml::esc(text))); + } + for (address, text) in &DYNAMIC[3..] { + let r = &address[1..]; + rows.push_str(&format!( + r#"{}0"#, + crate::xml::esc(text) + )); + } + // The same SUMPRODUCT saved by legacy Excel: no `cm`. + rows.push_str(r#"SUMPRODUCT(B1:B3*C1:C3)20"#); + super::minimal(&format!("{rows}"), &[("xl/metadata.xml", METADATA)], METADATA_REL, "") +} + +fn cell<'a>(sheet: &'a El, address: &str) -> &'a El { + sheet.child("sheetData").unwrap().kids("row").flat_map(|r| r.kids("c")).find(|c| c.attr("r") == Some(address)).unwrap() +} + +fn at(s: &str) -> CellRef { + CellRef::parse(s).unwrap() +} + +fn assert_written_dynamic(bytes: &[u8]) { + let mut pkg = Package::open(bytes).unwrap(); + let sheet = pkg.read_xml("xl/worksheets/sheet1.xml").unwrap().unwrap(); + for (address, text) in DYNAMIC { + let c = cell(&sheet, address); + assert_eq!(c.attr("cm"), Some("1"), "{address} lost its dynamic-array metadata"); + let f = c.child("f").unwrap(); + assert_eq!((f.attr("t"), f.attr("ref")), (Some("array"), Some(address))); + assert_eq!(f.text, text); + } + let legacy = cell(&sheet, "D6"); + assert_eq!(legacy.attr("cm"), None, "a legacy formula must not become dynamic"); + assert_eq!(legacy.child("f").unwrap().attr("t"), None); + + let metadata = pkg.read_xml("xl/metadata.xml").unwrap().expect("metadata part written"); + assert_eq!(metadata.path(&["metadataTypes", "metadataType"]).unwrap().attr("name"), Some("XLDAPR")); + let record = metadata.path(&["cellMetadata", "bk", "rc"]).unwrap(); + assert_eq!((record.attr("t"), record.attr("v")), (Some("1"), Some("0"))); + assert!(pkg.rels("xl/workbook.xml").unwrap().iter().any(|r| r.kind == "sheetMetadata" && r.target == "xl/metadata.xml")); + let types = pkg.read_xml("[Content_Types].xml").unwrap().unwrap(); + assert!(types.kids("Override").any(|e| e.attr("PartName") == Some("/xl/metadata.xml") + && e.attr("ContentType") == Some("application/vnd.openxmlformats-officedocument.spreadsheetml.sheetMetadata+xml"))); +} + +#[test] +fn dynamic_array_metadata_is_read_onto_formulas() { + let (wb, report) = read_xlsx(&source()).unwrap(); + assert!(!report.warnings.iter().any(|w| w.contains("metadata")), "{:?}", report.warnings); + let sh = wb.sheet(0).unwrap(); + for (address, _) in DYNAMIC { + let f = sh.cell(at(address)).unwrap().formula.as_ref().unwrap(); + assert!(f.dynamic, "{address}"); + assert_eq!(f.array, None, "{address} is not a legacy CSE array"); + } + assert!(!sh.cell(at("D6")).unwrap().formula.as_ref().unwrap().dynamic); +} + +#[test] +fn dynamic_array_metadata_survives_saving_scalar_results() { + let (wb, _) = read_xlsx(&source()).unwrap(); + let bytes = write_xlsx(&wb).unwrap(); + assert_written_dynamic(&bytes); + // And again: the mark read from GridCraft's own file is kept too. + let (back, report) = read_xlsx(&bytes).unwrap(); + assert!(report.warnings.is_empty(), "{:?}", report.warnings); + assert_written_dynamic(&write_xlsx(&back).unwrap()); +} diff --git a/crates/xlsx/src/tests/chart_book_names.rs b/crates/xlsx/src/tests/chart_book_names.rs new file mode 100644 index 000000000..3182cbae1 --- /dev/null +++ b/crates/xlsx/src/tests/chart_book_names.rs @@ -0,0 +1,139 @@ +//! `[0]!Name` ("this workbook") in chart references: read as the bare name, written back for +//! workbook-scoped names only. + +use std::io::Read; +use std::sync::Arc; + +use gridcraft_model::{Anchor, Chart, ChartKind, DefinedName, LegendPos, Series, Sheet, Workbook}; + +use crate::chart::chart_ref_from_file; +use crate::{read_xlsx, write_xlsx}; + +#[test] +fn this_book_prefix_is_dropped_only_in_front_of_names() { + for (file, model) in [ + ("[0]!TaskNames", "TaskNames"), + ("=[0]!TaskNames", "TaskNames"), + (" [0]!_Hidden ", "_Hidden"), + ("([0]!a,[0]!b)", "(a,b)"), + // External books keep their index: they go through externalLink parts. + ("[1]Sheet1!$A$1:$A$5", "[1]Sheet1!$A$1:$A$5"), + ("[2]!Rates", "[2]!Rates"), + // Not a name after the prefix, or not at a name's start. + ("[0]!$A$1", "[0]!$A$1"), + ("Plan[[0]]", "Plan[[0]]"), + // Inside a quoted sheet name or a string. + ("'[0]!x'!$A$1", "'[0]!x'!$A$1"), + ("\"[0]!x\"", "\"[0]!x\""), + ("'It''s [0]!'!$A$1", "'It''s [0]!'!$A$1"), + ("Sheet1!$B$2:$B$9", "Sheet1!$B$2:$B$9"), + ("", ""), + ("[0]!", "[0]!"), + ("[0]!é", "é"), + ] { + assert_eq!(chart_ref_from_file(file), model, "{file}"); + } +} + +fn series(name: &str, values: &str) -> Series { + Series { + name: Some(name.into()), + categories: Some("Items".into()), + values: values.into(), + bubble_sizes: None, + color: None, + secondary: false, + kind: None, + smooth: false, + trendlines: Vec::new(), + } +} + +fn name(n: &str, scope: Option, formula: &str) -> DefinedName { + DefinedName { name: n.into(), scope, formula: formula.into(), comment: String::new(), hidden: false } +} + +/// Excel draws a chart blank when a `` holds a bare name (observed in Excel for Windows, +/// Microsoft 365, with no repair prompt); `[0]!Name` and `Sheet!Name` both plot. +#[test] +fn chart_names_are_written_qualified() { + let mut wb = Workbook::default(); + let mut s = Sheet::new("Data"); + s.charts.push(Chart { + id: 1, + kind: ChartKind::BarStacked, + anchor: Anchor { cell: gridcraft_core::CellRef::new(0, 3), width: 480.0, height: 300.0, ..Anchor::default() }, + title: None, + series: vec![ + series("Title", "Amounts"), + series("Data!$B$1", "Local"), + series("\"Plain\"", "Data!$B$2:$B$4"), + series("'My Data'!Other", "'My Data'!Other"), + series("Literal", "NoSuchName"), + series("Gone!Thing", "Gone!Thing"), + ], + legend: LegendPos::Bottom, + data_labels: false, + gridlines: true, + style: 2, + x_title: None, + y_title: None, + source: None, + by_rows: false, + pivot: None, + }); + wb.sheets = vec![Arc::new(s), Arc::new(Sheet::new("My Data"))]; + wb.names = vec![ + name("Items", None, "Data!$A$2:$A$4"), + name("Amounts", None, "OFFSET(Items,0,1)"), + name("Title", None, "Data!$B$1"), + name("Local", Some(0), "Data!$C$2:$C$4"), + name("Other", Some(1), "'My Data'!$A$1:$A$3"), + ]; + let bytes = write_xlsx(&wb).unwrap(); + let mut xml = String::new(); + zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap().by_name("xl/charts/chart1.xml").unwrap().read_to_string(&mut xml).unwrap(); + for f in [ + // Workbook-scoped: `[0]!`, as Excel writes it. + "[0]!Items", + "[0]!Amounts", + "[0]!Title", + // Sheet-scoped: qualified with its sheet. + "Data!Local", + "'My Data'!Other", + "'My Data'!Other", + // Undefined: `[0]!`, as a file that referred to it had. + "[0]!NoSuchName", + // Literal series names stay literal. + "Plain", + "Literal", + // An unknown sheet is kept as written, never re-qualified. + "Gone!Thing", + ] { + assert!(xml.contains(f), "{f} missing from {xml}"); + } + for bare in ["Local", "Items", "Amounts", "Other", "NoSuchName"] { + assert!(!xml.contains(bare), "bare {bare} in {xml}"); + } + + let (back, _) = read_xlsx(&bytes).unwrap(); + let got: Vec<(Option<&str>, Option<&str>, &str)> = + back.sheets[0].charts[0].series.iter().map(|s| (s.name.as_deref(), s.categories.as_deref(), s.values.as_str())).collect(); + assert_eq!( + got, + [ + (Some("Title"), Some("Items"), "Amounts"), + (Some("Data!$B$1"), Some("Items"), "Data!Local"), + (Some("Plain"), Some("Items"), "Data!$B$2:$B$4"), + (Some("'My Data'!Other"), Some("Items"), "'My Data'!Other"), + (Some("Literal"), Some("Items"), "NoSuchName"), + (Some("Gone!Thing"), Some("Items"), "Gone!Thing"), + ] + ); + // A second save writes the same references. + let mut again = String::new(); + let bytes2 = write_xlsx(&back).unwrap(); + zip::ZipArchive::new(std::io::Cursor::new(&bytes2)).unwrap().by_name("xl/charts/chart1.xml").unwrap().read_to_string(&mut again).unwrap(); + let refs = |x: &str| x.split("").skip(1).filter_map(|p| p.split_once("").map(|(f, _)| f.to_string())).collect::>(); + assert_eq!(refs(&again), refs(&xml)); +} diff --git a/crates/xlsx/src/tests/mod.rs b/crates/xlsx/src/tests/mod.rs index a164aa3fa..e3056ddb3 100644 --- a/crates/xlsx/src/tests/mod.rs +++ b/crates/xlsx/src/tests/mod.rs @@ -1,6 +1,8 @@ //! Integration-style tests: round trips, hand-written fixtures, hostile input and CSV. mod array_export; +mod cellmetadata; +mod chart_book_names; mod chart_bool_default; mod chart_ref_offsets; mod csv_tests; diff --git a/docs/chart-parity.md b/docs/chart-parity.md index 40f6787a9..581e057fd 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-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 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-16 · **Change:** trivial (series that refer to workbook-scoped defined names (`[0]!Name` in the file) plot their data instead of `#NAME?` (#1686); previously trivial: chart sheets are kept on save, still not editable (#727)) · **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). @@ -67,6 +67,13 @@ extensions) is lost when GridCraft saves a workbook it opened. That is the main **~30% ready**: a user who opens an Excel workbook with charts and saves it gets simpler charts back. +Series data, categories and names may refer to defined names, as dynamic (OFFSET) ranges do: +Excel writes a workbook-scoped name as `[0]!Name` in ``. GridCraft reads it as the bare +name and writes it back with `[0]!`; a sheet-scoped name is written `Sheet!Name`. Excel needs the +qualifier: with a bare name in `` it draws the whole chart blank, without a repair prompt +(observed in Excel for Windows, Microsoft 365). External books (`[1]Sheet1!…`) are kept as they +are (#1686). Such series get no `numCache`/`strCache` on save (#1690). + ## Work (40–60 h) 1. Kinds survive XLSX round-trip (merge PRs #158, #161): 2–4 h. Chart resizing (#190): 1–2 h. @@ -81,6 +88,7 @@ back. | Date | Change | Summary | |---|---|---| +| 2026-10-16 | trivial | Series that refer to defined names (`[0]!Name`, `Sheet!Name`) resolve instead of showing `#NAME?`, and are saved qualified, as Excel needs (#1686) | | 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` | diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 5a7588986..bfadedc39 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-19 · **Change:** minor (.xls dialog sheets are skipped instead of read as worksheets; `.xls` import reads each sheet's display options from WINDOW2: right-to-left, gridlines, headings, zeros, formulas; XLSX text boxes and shapes keep their paragraphs' right-to-left direction on save; CSV/TSV/TXT can open with every field kept as text via `file.open {csvAsText}`; formulas over Excel's 8,192 characters, and `%` or call chains past the nesting limit, are parse errors instead of stack overflows when a file is opened; XLSX PivotTables read and write `rowGrandTotals`/`colGrandTotals` the way Excel does, so a table saved with only one grand total opens with the right one; HTML export keeps right-to-left) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-19 · **Change:** minor (XLSX formulas and PivotTable sources quote sheet names like Excel, so `'.x'!A1` no longer saves as `.x!A1` (#1458); .xls dialog sheets are skipped instead of read as worksheets; `.xls` import reads each sheet's display options from WINDOW2: right-to-left, gridlines, headings, zeros, formulas; XLSX text boxes and shapes keep their paragraphs' right-to-left direction on save; CSV/TSV/TXT can open with every field kept as text via `file.open {csvAsText}`; formulas over Excel's 8,192 characters, and `%` or call chains past the nesting limit, are parse errors instead of stack overflows when a file is opened; XLSX PivotTables read and write `rowGrandTotals`/`colGrandTotals` the way Excel does, so a table saved with only one grand total opens with the right one; HTML export keeps right-to-left) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Every format Excel opens or saves, with GridCraft's read and write support, fidelity and tests. Part of [target-app-parity.md](target-app-parity.md). **File formats: ~50% ready, 80–120 h** @@ -20,6 +20,16 @@ not interpreted as ink. This does not implement Office InkML or fix inserted ico Command and package tests cover repeated saves and unrelated cell edits; external Excel rendering and repair-free opening remain unverified. +Dynamic-array formulas keep their mark on save. A formula read with dynamic-array cell metadata +(`cm`, the `XLDAPR` record in `xl/metadata.xml`) is written back with it, together with the +metadata part, even when it returns one value and GridCraft's own shape analysis would not need +array evaluation. Dropping the mark turned such formulas into legacy ones, which Excel evaluates +with implicit intersection: `SUMPRODUCT(IFERROR(B1:B3/C1:C3,0))` recalculated to a different +value in Excel. Moving the formula, inserting or deleting rows and columns, and renaming sheets or +names keep the mark; retyping the formula drops it, and the writer then decides from the +formula's shape. Rich-value metadata other than cell pictures (data types, `#SPILL!` error +values) is still dropped. + | Format | Excel | GridCraft read | GridCraft write | Fidelity / notes | Tests | |---|---|---|---|---|---| | `.xlsx` Excel Workbook (transitional) | R/W, native | yes | yes | Cells, shared/array/dynamic formulas, styles, themes, merges, CF, validation, tables, comments and notes, hyperlinks, names, freeze panes, outline, print settings, pictures, shapes, charts (simplified, see [chart-parity.md](chart-parity.md)), sparklines, PivotTables (caches and tables), protection, calc settings. **Kept unchanged on save (pass-through, `passthrough.rs`):** custom document properties, ribbon customisation and add-in task panes, custom XML, links to external workbooks (never followed or refreshed), data connections, sheet query tables, printer settings (#324), form controls, ActiveX controls, OLE objects and sheet backgrounds (with their VML shapes), and chart sheets (#727: the sheet, drawing, chart, style and colour parts are written back unchanged in their place, and left out when a sheet they plot was deleted or renamed or a worksheet took their name; they can't be edited or created); dropped when their sheet is deleted or their parts point at content GridCraft rewrites. **Not preserved:** slicers and slicer caches, timelines, data model, query tables attached to tables, sheet and workbook `extLst` entries we don't model, chart details; Strict Open XML files keep nothing. Never checked in Excel across a corpus: repair prompts unknown. 100 MB files fail (#175) | `roundtrip.rs`, `fixtures.rs`, `malformed.rs`, `pivot_tests.rs` | @@ -91,7 +101,10 @@ refresh. Counted under features (Get & Transform, ~10% ready), not here. | 2026-10-18 | trivial | `file.open {csvAsText: true}` keeps every CSV/TSV/TXT field as the text the file holds, so identifiers (GTINs, SKUs, ZIP codes) keep their digits and leading zeros (#1808; a preference and a CLI flag are separate work) | | 2026-10-18 | trivial | XLSX PivotTables: `rowGrandTotals` is the Grand Total row and `colGrandTotals` the Grand Total column. Both were read and written the other way round, which a GridCraft round trip hid, so an Excel file with only one grand total opened showing the other | | 2026-10-19 | minor | HTML (Save as Web Page and clipboard) marks right-to-left sheets and right-to-left cell text with `dir` | +| 2026-10-16 | trivial | XLSX charts: `[0]!Name` (a workbook-scoped defined name) in series references is read as the name, so the series resolve instead of showing `#NAME?`. Names in chart references are written qualified (`[0]!Name`, or `Sheet!Name` for a sheet-scoped name): Excel draws a chart with a bare name blank (#1686) | +| 2026-10-15 | minor | XLSX: formulas and PivotTable sources quote sheet names like Excel (a leading `.`, R1C1-like and cell-like names), so `'.x'!A1` no longer saves as `.x!A1`, which Excel could not open (#1458) | | 2026-10-14 | trivial | Formula parser: text longer than Excel's 8,192 characters, and postfix chains (`1%%%…`, `f(1)(1)…`) past the 256-level nesting limit, are parse errors (the cell keeps its formula text and cached value) instead of trees deep enough to overflow the stack when a hostile file is opened, saved or dropped | +| 2026-10-14 | minor | XLSX formulas read with dynamic-array metadata (`cm`) keep it on save, so Excel does not recalculate them with implicit intersection | | 2026-10-12 | trivial | XLSX chart sheets are kept on save (#727) | | 2026-10-12 | trivial | CSV and TSV export is capped at 512 MiB and refuses a larger used range with an error (#528) | | 2026-10-12 | minor | `.xls` import reads the calculation settings (CalcMode, CalcIter, CalcCount, CalcDelta, CalcPrecision), so circular models calculate instead of showing `#CIRC!` (#724) | diff --git a/docs/function-parity.md b/docs/function-parity.md index 2343e0de9..8688f405c 100644 --- a/docs/function-parity.md +++ b/docs/function-parity.md @@ -1,6 +1,6 @@ # Function parity: worksheet functions and formulas -> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (MEDIAN and AGGREGATE median avoid intermediate overflow) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-19 · **Change:** minor (ADDRESS quotes sheet names like Excel (#1458); MEDIAN and AGGREGATE median avoid intermediate overflow) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Excel's worksheet functions, category by category, against GridCraft's function library. Part of [target-app-parity.md](target-app-parity.md); gaps feed [gaps.md](gaps.md). @@ -115,6 +115,7 @@ workbooks; results checked in as data, not Excel files) and fixing what it finds | Date | Change | Summary | |---|---|---| | 2026-10-15 | minor | MEDIAN and AGGREGATE median use an overflow-safe midpoint (#1579); regressions cover finite extremes, tiny values and dependent recalculation | +| 2026-10-15 | minor | ADDRESS quotes a sheet name the way Excel does: names that read as an A1 cell (`A1`) or an R1C1 reference (`R1C1`, `RC`) and names starting with `.` get quotes; `TRUE` / `FALSE` stay bare (#1458) | | 2026-10-13 | minor | Clear reports changed stored cells sparsely, including those beyond the first million coordinates; dependent formulas and removed spills update, while clearing empty merges still unblocks spills (#1032) | | 2026-10-12 | minor | USDOLLAR added as a Compatibility function (not on Microsoft's list, so the counts don't change); MIRR with no positive flow is -1, COUNTIF's bare `"<="` / `">="` skip empty strings, INDIRECT with `a1` = FALSE gives `#REF!` for A1-style text (#738) | | 2026-10-11 | minor | TEXT / number formats fixed against Excel-cached results: fraction literals and zero fractions, `0` denominators zero padded, `?` pads the thousands separator, booleans use the text section (226 mismatches → 0 in 352 TEXT cells) | diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 9109311f9..78af92e88 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -1,6 +1,6 @@ # Localization parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-19 · **Change:** minor (defined-name length limit counts characters, so long Arabic names are accepted; Table names are limited to 255 characters; right-to-left sheets in `.xls` files open right-to-left; text boxes and shapes keep right-to-left paragraph direction through XLSX save; Save as Web Page and copied HTML keep right-to-left; French and Serbian `context|label` translations no longer show the context) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-19 · **Change:** minor (function help follows the interface language; defined-name length limit counts characters, so long Arabic names are accepted; Table names are limited to 255 characters; right-to-left sheets in `.xls` files open right-to-left; text boxes and shapes keep right-to-left paragraph direction through XLSX save; Save as Web Page and copied HTML keep right-to-left; French and Serbian `context|label` translations no longer show the context) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Per-language status against Excel, which ships its UI in dozens of languages (Windows language packs cover 100+), with localized function names, argument separators, number and date formats, @@ -16,8 +16,14 @@ right-to-left sheets and full complex-script support. Part of each. The tables are used by the ribbon, sheet tabs and widgets (`ribbon.rs`, `tabs.rs`, `widgets.rs`), the Format Cells dialog, every dialog title and the shared OK/Cancel buttons; the other dialogs, menus, - context menus, task panes, status messages, command labels and function descriptions are still - English literals. Untranslated labels fall back to English. + context menus, task panes, status messages and command labels are still English literals. + Untranslated labels fall back to English. +- **Function help** (the argument hint, autocomplete, Insert Function and the Formulas-tab + menus) follows the interface language through per-language tables keyed by the catalog's + English text (`i18n/_functions.rs`): descriptions, and argument names from which the + hint is assembled with the formula syntax's function name and separator. Spanish translates + the 10 common functions it had help for (SUM, AVERAGE, IF…); other languages show the English + help. - **About 2,200 user-visible strings** in all (heuristic source scan: ~1,700 UI and command strings plus ~500 function descriptions), so each shipped language covers **~12%** (measured count / estimated total). @@ -90,6 +96,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-15 | minor | Function help in the interface language: argument hints, autocomplete, Insert Function (search, categories) and the Formulas-tab menus read per-language tables of descriptions and argument names keyed by the catalog's English text; Spanish keeps its 10 functions in the new format | | 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%) | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 929173e75..124d335f7 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -22,6 +22,8 @@ Sources: `crates/ui-egui/src` (ribbon, grid, editor, formula bar, dialogs, panes | 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 | 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) | +| Formula bar with Name Box, expand, function hints | yes | yes | done; dynamic-array spill children show the source formula muted and read-only, using current spill ownership | +| 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) | | 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 | @@ -46,15 +48,18 @@ this logic, with one undo step. | 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 | +| 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 partial: Copy Cells, Fill Series, Fill Formatting Only, Fill Without Formatting, Series…; Fill Days/Weekdays/Months/Years, Linear/Growth Trend and Flash Fill 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; 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. | +| Number-format colors (`[Green]`, `[Color1]`–`[Color56]`) | Rendered in grid and PDF from shared RGB mappings (#1452) | +| Point mode (click/drag/arrow references), reference colouring, F4 | done | | 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 | | Reorder selected whole columns by dragging their headers | done (10-11) | | Freeze panes, split panes | freeze done; split to verify | +| A Table's header names and filter buttons in the column headings once its header row scrolls away | done (#1423): with the active cell in the Table, as in Excel; the button opens the column's filter menu, the rest of the heading still selects the column. Plain AutoFilter ranges keep their letters (Excel does the same) | | Copy as HTML table for other apps | done (#36, 10-10) | | Context menu on cells | 16 items (Cut, Copy, Paste, Paste Special, Insert, Delete, Clear Contents, Filter by value, Sort, New Comment, New Note, Format Cells, Pick From Drop-down List, Define Name, Link). Insert Copied Cells (Insert Cut Cells for whole rows/columns) while the clipboard holds a copy (#21). Missing: Insert Cut Cells for a cell block, paste-option icons, Quick Analysis, Get Data from Table, Show Changes, Smart Lookup | | Move and resize charts, pictures and shapes | charts can't be resized (#190) | @@ -165,6 +170,10 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | 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-16 | minor | Right-drag on the fill handle opens the fill menu on release: Copy Cells, Fill Series, Fill Formatting Only, Fill Without Formatting, Series…; Esc cancels the drag or closes the menu without filling | +| 2026-10-16 | minor | With the active cell in a Table whose header row is scrolled out of view, the column headings show the header names and their filter buttons in place of the letters (#1423) | +| 2026-10-15 | minor | Number-format named colors use their RGB values; indexed `[Color1]`–`[Color56]` resolve through the shared legacy palette in grid and PDF (#1452) | +| 2026-10-14 | minor | Spill-child formula bar uses the anchor formula with disabled theme text and blocks formula-bar edit entry; selection and recalculation restore ordinary behavior | | 2026-10-13 | 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 |