Compare commits

..
Author SHA1 Message Date
Dylan M. Taylor 7c851d7906 numfmt: TEXT returns #VALUE! when its result passes a cell's 32,767 characters
A text section repeats its input once per @ placeholder, and TEXT handed
back whatever that built, so nested TEXT calls could multiply a long
text until memory ran out. Results past the cell text limit are now
#VALUE!.
2026-11-03 12:03:51 +00:00
112 changed files with 423 additions and 4545 deletions
+10 -31
View File
@@ -31,41 +31,20 @@ fn is_volatile(e: &Expr, volatile_names: &HashSet<String>) -> bool {
}
/// Upper-cased defined names whose formula is volatile, directly or through other names.
///
/// Volatility flows backwards along name references: each name is parsed and walked once, and a
/// worklist carries volatility from a name to the names that use it. Rescanning every name until
/// nothing changes was quadratic: a file listing its names so that each pass marked only one more
/// did one full pass per name.
pub(super) fn volatile_names(wb: &Workbook) -> HashSet<String> {
let parsed: Vec<(String, Option<Expr>)> =
wb.names.iter().map(|n| (n.name.to_ascii_uppercase(), gridcraft_engine::formula::parse(&n.formula).ok())).collect();
let mut set = HashSet::new();
// Upper-cased name -> the names whose formulas refer to it.
let mut users: HashMap<String, Vec<String>> = HashMap::new();
let mut queue = Vec::new();
let none = HashSet::new();
for n in &wb.names {
let name = n.name.to_ascii_uppercase();
let Ok(e) = gridcraft_engine::formula::parse(&n.formula) else { continue };
if is_volatile(&e, &none) {
if set.insert(name.clone()) {
queue.push(name.clone());
// Each round adds at least one name or stops, so `len + 1` rounds reach the fixed point.
for _ in 0..=parsed.len() {
let before = set.len();
for (name, e) in &parsed {
if !set.contains(name) && e.as_ref().is_some_and(|e| is_volatile(e, &set)) {
set.insert(name.clone());
}
continue;
}
let mut used = BTreeSet::new();
e.walk(&mut |x| {
if let Expr::Name(r) = x {
used.insert(r.to_ascii_uppercase());
}
});
for r in used {
users.entry(r).or_default().push(name.clone());
}
}
while let Some(v) = queue.pop() {
for user in users.remove(&v).unwrap_or_default() {
if set.insert(user.clone()) {
queue.push(user);
}
if set.len() == before {
break;
}
}
set
-22
View File
@@ -178,28 +178,6 @@ fn volatile_names_propagate() {
assert_eq!(check(&wb, &Options::default()).calc.volatile, 1);
}
#[test]
fn volatile_names_propagate_through_a_long_chain_in_one_walk() {
// Chain0 uses Chain1, Chain1 uses Chain2, ... and only the last is volatile: listed in this order, a rescan
// until nothing changes marked one name per pass (quadratic). Every name in the chain is
// volatile; a name off the chain is not.
const LEN: usize = 5_000;
let mut wb = book(&[("A1", "=1", n(1.0))]);
let name =
|name: String, formula: String| gridcraft_engine::model::DefinedName { name, scope: None, formula, comment: String::new(), hidden: false };
for i in 0..LEN {
let formula = if i + 1 == LEN { "NOW()".to_string() } else { format!("Chain{}+1", i + 1) };
wb.names.push(name(format!("Chain{i}"), formula));
}
wb.names.push(name("Plain".into(), "Chain0*0+Other".into()));
wb.names.push(name("Other".into(), "1".into()));
let set = volatile_names(&wb);
assert!((0..LEN).all(|i| set.contains(&format!("CHAIN{i}"))));
assert!(set.contains("PLAIN"));
assert!(!set.contains("OTHER"));
assert_eq!(set.len(), LEN + 1);
}
#[test]
fn round_trip_of_a_simple_book_is_clean() {
let mut wb = book(&[("A1", "2", n(2.0)), ("B1", "=A1*3", n(6.0)), ("A2", "x", Value::text("x"))]);
+1 -1
View File
@@ -193,7 +193,7 @@ fn services(inbox: Inbox, ctx: egui::Context) -> Services {
let ctx = ctx.clone();
wasm_bindgen_futures::spawn_local(async move {
let Some(file) = rfd::AsyncFileDialog::new()
.add_filter("Spreadsheets", &["xlsx", "xlsm", "xls", "xlsb", "ods", "csv", "tsv", "txt", "slk", "json"])
.add_filter("Spreadsheets", &["xlsx", "xlsm", "xls", "xlsb", "ods", "csv", "tsv", "txt", "json"])
.pick_file()
.await
else {
+2 -2
View File
@@ -37,13 +37,13 @@ winit = { version = "0.30", default-features = false, features = ["x11", "waylan
craft-print = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7" }
craft-update = { workspace = true }
rfd = { workspace = true }
arboard = { workspace = true, features = ["image-data"] }
arboard = { workspace = true }
gridcraft-mcp = { workspace = true }
# The control channel's token (OS random numbers); already in the tree.
getrandom = "0.4"
[target.'cfg(any(target_os = "linux", target_os = "freebsd"))'.dependencies]
arboard = { workspace = true, features = ["image-data", "wayland-data-control"] }
arboard = { workspace = true, features = ["wayland-data-control"] }
[target.'cfg(target_os = "windows")'.dependencies]
# Blocks on the start-up DirectX 12 adapter probe (`graphics.rs`); already in the tree via eframe.
+2 -6
View File
@@ -154,7 +154,7 @@ fn read_clipboard<T>(
fn services() -> Services {
// Keep the owner alive: X11/Wayland serve clipboard data from this handle.
let clipboard: Rc<RefCell<Option<arboard::Clipboard>>> = Rc::default();
let (html_clipboard, text_clipboard, image_clipboard) = (clipboard.clone(), clipboard.clone(), clipboard.clone());
let (html_clipboard, text_clipboard) = (clipboard.clone(), clipboard.clone());
Services {
copy_html: Some(Box::new(move |html, text| {
let mut clipboard = clipboard.borrow_mut();
@@ -165,13 +165,9 @@ fn services() -> Services {
})),
paste_html: Some(Box::new(move || read_clipboard(&html_clipboard, |clipboard| clipboard.get().html()))),
paste_text: Some(Box::new(move || read_clipboard(&text_clipboard, |clipboard| clipboard.get_text()))),
paste_image: Some(Box::new(move || {
let image = read_clipboard(&image_clipboard, |clipboard| clipboard.get_image())?;
image::RgbaImage::from_raw(u32::try_from(image.width).ok()?, u32::try_from(image.height).ok()?, image.bytes.into_owned())
})),
pick_open: Some(Box::new(|| {
rfd::FileDialog::new()
.add_filter("Spreadsheets", &["xlsx", "xlsm", "xls", "xlsb", "ods", "csv", "tsv", "txt", "slk", "json"])
.add_filter("Spreadsheets", &["xlsx", "xlsm", "xls", "xlsb", "ods", "csv", "tsv", "txt", "json"])
.add_filter("Excel Workbook", &["xlsx", "xlsm"])
.add_filter("Excel 97-2003 Workbook (import)", &["xls"])
.add_filter("Excel Binary Workbook (data import)", &["xlsb"])
+3 -4
View File
@@ -359,10 +359,9 @@ impl<'h> Evaluator<'h> {
return v.clone();
}
let wb = self.host.workbook();
// Sheet-qualified `Sheet1!Name`. The parser stores the sheet unquoted, so it is used as
// is (`' Sales '` keeps its spaces) and split at the last `!` (`'a!b'!Name`).
let (scope_sheet, bare) = match n.rsplit_once('!') {
Some((s, b)) => match wb.sheet_index(s) {
// Sheet-qualified `Sheet1!Name`.
let (scope_sheet, bare) = match n.split_once('!') {
Some((s, b)) => match wb.sheet_index(&gridcraft_core::unquote_sheet(s)) {
Some(i) => (i, b),
None => return err(CellError::Name),
},
+3 -4
View File
@@ -179,10 +179,9 @@ fn category_table(wb: &Workbook, sheet: usize, formula: &str, values: &str) -> (
/// 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 {
// `'Bob''s'!Name`: the sheet is unquoted as everywhere else (#2012).
match gridcraft_core::split_sheet(t) {
(Some(s), bare) => wb.sheet_index(&s).is_some_and(|i| wb.name(bare, i).is_some()),
(None, _) => wb.name(t, sheet).is_some(),
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(),
}
}
-72
View File
@@ -1258,75 +1258,3 @@ fn box_whisker_draws_each_series_quartile_method() {
// The boxes span 3.5 and 2.5 data units on the same value axis.
assert!((heights[0] / heights[1] - 1.4).abs() < 0.001);
}
#[test]
fn a_series_named_by_a_name_on_a_sheet_with_an_apostrophe_resolves() {
// #2012: `'Bob''s'!Title` kept the doubled quote, so the sheet wasn't found and the series
// was named by the literal text.
let mut wb = Workbook::new();
{
let s = wb.sheet_mut(0).unwrap();
s.name = "Bob's".into();
s.set_value(CellRef::new(0, 0), Value::from("Takings"));
s.set_value(CellRef::new(1, 0), Value::Number(4.0));
s.set_value(CellRef::new(2, 0), Value::Number(9.0));
}
wb.names.push(gridcraft_model::DefinedName {
name: "Title".into(),
scope: Some(0),
formula: "'Bob''s'!$A$1".into(),
comment: String::new(),
hidden: false,
});
let mut c = chart(ChartKind::ColumnClustered);
c.series = vec![Series {
name: Some("'Bob''s'!Title".into()),
categories: None,
values: "'Bob''s'!$A$2:$A$3".into(),
bubble_sizes: None,
color: None,
secondary: false,
kind: None,
quartiles: Default::default(),
smooth: false,
trendlines: Vec::new(),
}];
let d = resolve(&wb, 0, &c);
assert_eq!(d.series.first().map(|s| s.name.as_str()), Some("Takings"));
}
#[test]
fn a_series_named_by_a_name_on_a_sheet_with_a_bang_resolves() {
// `'a!b'!Title`: the sheet name holds a `!`, so the evaluator must split the parsed name at
// the last one, not the first.
let mut wb = Workbook::new();
{
let s = wb.sheet_mut(0).unwrap();
s.name = "a!b".into();
s.set_value(CellRef::new(0, 0), Value::from("Takings"));
s.set_value(CellRef::new(1, 0), Value::Number(4.0));
s.set_value(CellRef::new(2, 0), Value::Number(9.0));
}
wb.names.push(gridcraft_model::DefinedName {
name: "Title".into(),
scope: Some(0),
formula: "'a!b'!$A$1".into(),
comment: String::new(),
hidden: false,
});
let mut c = chart(ChartKind::ColumnClustered);
c.series = vec![Series {
name: Some("'a!b'!Title".into()),
categories: None,
values: "'a!b'!$A$2:$A$3".into(),
bubble_sizes: None,
color: None,
secondary: false,
kind: None,
quartiles: Default::default(),
smooth: false,
trendlines: Vec::new(),
}];
let d = resolve(&wb, 0, &c);
assert_eq!(d.series.first().map(|s| s.name.as_str()), Some("Takings"));
}
+7 -9
View File
@@ -17,13 +17,11 @@ const MAX_ROW_SPAN: usize = gridcraft_core::MAX_ROWS as usize;
/// table is ignored: the HTML only refines cells of the tab-separated text, which stays the source.
const MAX_HTML_CELLS: usize = 1_000_000;
/// One table cell: its visible text, the number the source recorded for it, if any, and whether
/// it is a header cell (`<th>`).
/// One table cell: its visible text and the number the source recorded for it, if any.
#[derive(Clone, Debug, Default, PartialEq)]
pub(crate) struct HtmlCell {
pub text: String,
pub num: Option<f64>,
pub header: bool,
}
/// The cells of the first table in `html`, row by row, read no further than `max_rows` rows of
@@ -90,7 +88,7 @@ pub(crate) fn table_cells(html: &str, max_rows: usize, max_cols: usize) -> Vec<V
let num = attr(attrs, "x:num").or_else(|| attr(attrs, "sdval")).and_then(|v| v.trim().parse::<f64>().ok()).filter(|n| n.is_finite());
let colspan = span(attrs, "colspan", MAX_SPAN);
let rowspan = span(attrs, "rowspan", MAX_ROW_SPAN);
cell = Some(OpenCell { cell: HtmlCell { text: String::new(), num, header: name == "th" }, colspan, rowspan });
cell = Some(OpenCell { cell: HtmlCell { text: String::new(), num }, colspan, rowspan });
}
("td" | "th", true) | ("tr", true) => grid.finish(&mut cell),
("br", _) => {
@@ -378,18 +376,18 @@ mod tests {
<tr><td colspan=2 x:num="2.5">2.50 &amp; up</td><td>x</td></tr><!--EndFragment--></table></body></html>"#;
let rows = table_cells(excel, 2, 3);
assert_eq!(rows.len(), 2);
assert_eq!(rows[0][0], HtmlCell { text: "Rp10,000.00".into(), num: Some(10000.0), ..HtmlCell::default() });
assert_eq!(rows[0][1], HtmlCell { text: "Name".into(), num: None, ..HtmlCell::default() });
assert_eq!(rows[0][0], HtmlCell { text: "Rp10,000.00".into(), num: Some(10000.0) });
assert_eq!(rows[0][1], HtmlCell { text: "Name".into(), num: None });
assert_eq!(rows[1].len(), 3, "colspan keeps the next cell in its column");
assert_eq!(rows[1][0].text, "2.50 & up");
assert_eq!(rows[1][2].text, "x");
let calc = r#"<table cellspacing="0" border="0"><colgroup width="85"></colgroup><tr>
<td height="17" align="right" sdval="10000" sdnum="1033;0;[$Rp-421]#,##0.00"><font face="Liberation Sans">Rp10,000.00</font></td></tr></table>"#;
assert_eq!(table_cells(calc, 1, 1), vec![vec![HtmlCell { text: "Rp10,000.00".into(), num: Some(10000.0), ..HtmlCell::default() }]]);
assert_eq!(table_cells(calc, 1, 1), vec![vec![HtmlCell { text: "Rp10,000.00".into(), num: Some(10000.0) }]]);
let quoted_gt = r#"<table><tr><td style='mso-number-format:"\[>=1000\]0"' x:num="1500">1500</td></tr></table>"#;
assert_eq!(table_cells(quoted_gt, 1, 1), vec![vec![HtmlCell { text: "1500".into(), num: Some(1500.0), ..HtmlCell::default() }]]);
assert_eq!(table_cells(quoted_gt, 1, 1), vec![vec![HtmlCell { text: "1500".into(), num: Some(1500.0) }]]);
}
#[test]
@@ -436,7 +434,7 @@ mod tests {
assert!(cells <= MAX_HTML_CELLS, "{cells} cells");
assert!(rows.iter().all(|r| r.len() <= MAX_SPAN));
assert_eq!(rows[0].len(), MAX_SPAN, "the first span fills the sheet's width");
assert_eq!(rows[0][0], HtmlCell { text: "1".into(), num: Some(1.0), ..HtmlCell::default() });
assert_eq!(rows[0][0], HtmlCell { text: "1".into(), num: Some(1.0) });
// Bounded by the pasted text's shape, the same markup yields just that shape.
let rows = table_cells(&html, 3, 2);
+3 -7
View File
@@ -459,11 +459,8 @@ pub(crate) const TOUCH_LOG_LIMIT: usize = 1_000_000;
pub(crate) const PROTECTED: &str = "The cell or chart you're trying to change is on a protected sheet.";
/// Refuses an edit that changed what a locked cell on a protected sheet holds (its value or
/// formula), or that unlocked it, whichever command made it. Sheets are matched by name, so
/// moving, renaming or deleting a sheet isn't an edit of its cells. A sort or duplicate removal
/// that moves an unlocked cell with equal content onto a locked one changes no content but would
/// leave the cell editable, so the effective lock (row and column formats included) is compared
/// as well.
/// formula), whichever command made it. Sheets are matched by name, so moving, renaming or
/// deleting a sheet isn't an edit of its cells.
fn check_protection(old: &Workbook, new: &Workbook, unlocked: &[(String, String)]) -> Result<()> {
for sh in &old.sheets {
if !sh.is_protected() {
@@ -474,8 +471,7 @@ fn check_protection(old: &Workbook, new: &Workbook, unlocked: &[(String, String)
continue;
}
for c in sh.cells.diff(&after.cells) {
let unlocked_now = !new.styles.get(after.style_id(c)).protection.locked;
if (unlocked_now || !same_content(sh.cell(c), after.cell(c)))
if !same_content(sh.cell(c), after.cell(c))
&& old.styles.get(sh.style_id(c)).protection.locked
&& let Some(m) = locked_cell_error(sh, c, unlocked)
{
+19 -65
View File
@@ -23,7 +23,7 @@ use cell::{cell_set, range_fill, range_set_values};
pub(crate) use cell::{check_arg_counts, input_to_cell, is_formula_input, json_to_input, parse_typed_formula};
#[cfg(test)]
pub(crate) use clipboard::parse_tsv;
use clipboard::{clear_clipboard, copy, cut, paste, range_move};
use clipboard::{clear_clipboard, copy, cut, paste};
use fill::{auto_fill, fill_dir, fill_series, flash_fill};
#[cfg(test)]
use find::replace_ci;
@@ -36,7 +36,7 @@ use go_to::go_to_special;
use go_to::{GO_TO_MAX_AREAS, GO_TO_RECENT, reference_parts};
use paste_special::paste_special;
pub(crate) use paste_special::{rewrite_all_formulas, rewrite_cell_links, rewrite_python_refs};
use selection::{select_all, select_col, select_region, select_row, selection_move, selection_next, selection_next_corner, selection_set};
use selection::{select_all, select_col, select_region, select_row, selection_move, selection_next, selection_set};
pub fn specs() -> Vec<CommandSpec> {
vec![
@@ -62,15 +62,6 @@ pub fn specs() -> Vec<CommandSpec> {
cmd!(noundo "selection.set", "Select", [], None, "{range: \"A1:B2,D4\" | cell, active?: \"A1\", sheet?}", has_doc, selection_set),
cmd!(noundo "selection.move", "Move Selection", [], None, "{dr, dc, extend?: bool, jump?: bool (Ctrl+arrow), page?: bool}", has_doc, selection_move),
cmd!(noundo "selection.next", "Next Cell in Selection", [], None, "{forward?: true, byRow?: false} (Enter/Tab inside a selection)", has_doc, selection_next),
cmd!(
noundo "selection.nextCorner",
"Next Corner of Selection",
[],
Some("Ctrl+."),
"{} (the active cell moves clockwise to the next corner of the selected area, which stays selected)",
has_doc,
selection_next_corner
),
cmd!(noundo "edit.selectAll", "Select All", ["Edit"], Some("Cmd+A"), "{} (current region first, then the whole sheet)", has_doc, select_all),
cmd!(noundo "selection.currentRegion", "Select Current Region", [], Some("Ctrl+Shift+8"), "{}", has_doc, select_region),
cmd!(noundo "selection.row", "Select Entire Row", [], Some("Shift+Space"), "{}", has_doc, select_row),
@@ -90,15 +81,6 @@ pub fn specs() -> Vec<CommandSpec> {
has_doc,
paste
),
cmd!(
"range.move",
"Drag and Drop",
[],
None,
"{range?, to: \"C3\", overwrite?: bool} (drag the selection's border: a cut and paste that asks before replacing data unless overwrite)",
has_doc,
range_move
),
CommandSpec {
shortcut_mac: Some("Ctrl+Cmd+V"),
..cmd!(
@@ -144,13 +126,13 @@ pub fn specs() -> Vec<CommandSpec> {
fill_series
),
cmd!("edit.flashFill", "Flash Fill", ["Home", "Editing", "Fill"], Some("Ctrl+E"), "{range?}", has_doc, flash_fill),
cmd!(noundo "edit.find", "Find…", ["Home", "Editing", "Find & Select"], Some("Cmd+F"), "{what, matchCase?, wholeCell?, lookIn?: formulas|values, byColumns?, all?: bool, within?: sheet|workbook|selection} (selection: only the selected cells; Find Next keeps them selected and moves the active cell)", has_doc, find),
cmd!(noundo "edit.find", "Find…", ["Home", "Editing", "Find & Select"], Some("Cmd+F"), "{what, matchCase?, wholeCell?, lookIn?: formulas|values, byColumns?, all?: bool, within?: sheet|workbook}", has_doc, find),
cmd!(
"edit.replace",
"Replace…",
["Home", "Editing", "Find & Select"],
Some("Ctrl+H"),
"{what, with, matchCase?, wholeCell?, all?: true, within?: sheet|workbook|selection} → {replaced, skipped?} (in a Python cell the code is edited; skipped counts Python cells whose code would no longer fit)",
"{what, with, matchCase?, wholeCell?, all?: true, within?: sheet|workbook} → {replaced, skipped?} (in a Python cell the code is edited; skipped counts Python cells whose code would no longer fit)",
has_doc,
replace
),
@@ -262,36 +244,21 @@ fn clear(s: &mut Session, p: &Json, what: &str) -> Result<Json> {
})
})
};
// Notes and links aren't cell contents, so the cell comparison that guards a protected sheet
// doesn't see them go: a locked cell's are refused here, stored cell or not.
if matches!(what, "all" | "comments" | "hyperlinks") {
let d = s.doc()?;
if let Some(sh) = d.wb.sheet(sheet).filter(|sh| sh.is_protected()) {
for &whole in &ranges {
let skip = filtered_rows(sh, whole);
let notes = matches!(what, "all" | "comments").then(|| sh.comments.keys()).into_iter().flatten();
let links = matches!(what, "all" | "hyperlinks").then(|| sh.hyperlinks.keys()).into_iter().flatten();
for &c in notes.chain(links).filter(|c| whole.contains(**c) && !skip.contains(&c.row)) {
if d.wb.styles.get(sh.style_id(c)).protection.locked
&& let Some(m) = locked_cell_error(sh, c, &d.unlocked_ranges)
{
return Err(EngineError::Other(m));
}
}
}
}
}
edit(s, |cx| {
for &whole in &ranges {
// Cells are only stored inside the used range. Notes and links are kept apart from
// them, so they are cleared over the whole range: one from a file can sit on a cell
// with nothing stored in it.
let used = cx.wb.sheet(sheet).and_then(|sh| sh.used_range()).and_then(|u| whole.intersection(&u));
let skip = cx.wb.sheet(sheet).map(|sh| filtered_rows(sh, whole)).unwrap_or_default();
let cells: Vec<(CellRef, Cell)> = match (cx.wb.sheet(sheet), used) {
(Some(sh), Some(r)) => sh.cells.iter_range(r).filter(|(c, _)| !skip.contains(&c.row)).map(|(c, x)| (c, x.clone())).collect(),
_ => Vec::new(),
for r in &ranges {
let r = match cx.wb.sheet(sheet).and_then(|sh| sh.used_range()) {
Some(u) => match r.intersection(&u) {
Some(x) => x,
None => continue,
},
None => continue,
};
let skip = cx.wb.sheet(sheet).map(|sh| filtered_rows(sh, r)).unwrap_or_default();
let cells: Vec<(CellRef, Cell)> = cx
.wb
.sheet(sheet)
.map(|sh| sh.cells.iter_range(r).filter(|(c, _)| !skip.contains(&c.row)).map(|(c, x)| (c, x.clone())).collect())
.unwrap_or_default();
// Report every stored cell we touch, even beyond a million coordinates in a sparse
// selection. Removed formula anchors must also reach calc so their spills disappear.
cx.changed.extend(cells.iter().map(|(c, _)| (sheet, *c)));
@@ -311,18 +278,6 @@ fn clear(s: &mut Session, p: &Json, what: &str) -> Result<Json> {
}
}
}
let cleared = |c: CellRef| whole.contains(c) && !skip.contains(&c.row);
// A removed note or link changes its cell even with nothing stored there.
let metadata: Vec<CellRef> = cx
.wb
.sheet(sheet)
.map(|sh| {
let notes = matches!(what, "all" | "comments").then(|| sh.comments.keys()).into_iter().flatten();
let links = matches!(what, "all" | "hyperlinks").then(|| sh.hyperlinks.keys()).into_iter().flatten();
notes.chain(links).copied().filter(|c| cleared(*c)).collect()
})
.unwrap_or_default();
cx.changed.extend(metadata.into_iter().map(|c| (sheet, c)));
let sh = cx.sheet_mut(sheet)?;
for (c, mut cell) in cells {
match what {
@@ -339,15 +294,14 @@ fn clear(s: &mut Session, p: &Json, what: &str) -> Result<Json> {
_ => {}
}
}
let cleared = |c: CellRef| r.contains(c) && !skip.contains(&c.row);
if matches!(what, "all" | "comments") {
sh.comments.retain(|c, _| !cleared(*c));
}
if matches!(what, "all" | "hyperlinks") {
sh.hyperlinks.retain(|c, _| !cleared(*c));
}
if let Some(r) = used
&& matches!(what, "all" | "formats")
{
if matches!(what, "all" | "formats") {
let mut unmerged = false;
sh.merges.retain(|m| {
let keep = !r.contains_range(m) || skip.range(m.start.row..=m.end.row).next().is_some();
+5 -80
View File
@@ -1,8 +1,7 @@
use super::cell::input_to_cell;
use super::paste_special::paste_special;
use super::{
Cell, Clipboard, EngineError, Json, RangeRef, Result, Session, Value, bad, bool_param, cell_param_on, edit, json, ok, str_param, target_ranges,
target_sheet,
Cell, Clipboard, EngineError, Json, RangeRef, Result, Session, Value, cell_param_on, edit, json, ok, str_param, target_ranges, target_sheet,
};
// ---------------------------------------------------------------- clipboard
@@ -164,40 +163,6 @@ pub(super) fn paste(s: &mut Session, p: &Json) -> Result<Json> {
paste_special(s, &json!({"what": "all", "at": p.get("at").cloned().unwrap_or(Json::Null)}))
}
/// Drag and drop (dragging the selection's border): moves `range` so its top-left cell lands on
/// `to`, as Cut and Paste would. Like Excel, it asks before replacing cells that hold data or show a
/// spilled value (other than the moved cells themselves); run it again with `overwrite: true` to replace them. A
/// refused or failed move leaves the clipboard as it was.
pub(super) fn range_move(s: &mut Session, p: &Json) -> Result<Json> {
let sheet = target_sheet(s, p)?;
let to = cell_param_on(s, p, "to", sheet)?.ok_or_else(|| bad("range.move", "missing `to`"))?;
let kept = s.clipboard.take();
let moved = cut(s, p).and_then(|_| {
let src = s.clipboard.as_ref().map(|c| c.range).unwrap_or_default();
let dest = to.offset(i64::from(src.height()) - 1, i64::from(src.width()) - 1).map(|end| RangeRef::new(to, end));
if bool_param(p, "overwrite") != Some(true)
&& let Some(dest) = dest
&& let Some(sh) = s.doc()?.wb.sheet(sheet)
&& (sh.cells.iter_range(dest).any(|(c, cell)| !src.contains(c) && cell.has_content())
|| sh.spill.range(dest.start..=dest.end).any(|(c, v)| dest.contains(*c) && !src.contains(*c) && *v != Value::Empty))
{
return Err(EngineError::Confirm("There's already data here. Do you want to replace it?".into()));
}
// On the range's sheet, which `paste` would leave to the active one. The selection
// belongs to the active sheet: it follows the moved cells only when they are on it.
let selection = s.doc()?.selection.clone();
let pasted = paste_special(s, &json!({"what": "all", "at": to.a1(), "sheet": sheet}))?;
if sheet != s.doc()?.wb.active_sheet() {
s.doc_mut()?.selection = selection;
}
Ok(pasted)
});
if moved.is_err() {
s.clipboard = kept;
}
moved
}
fn paste_text(s: &mut Session, p: &Json, text: &str) -> Result<Json> {
let sheet = target_sheet(s, p)?;
let at = cell_param_on(s, p, "at", sheet)?.unwrap_or(s.doc()?.selection.current().start);
@@ -261,55 +226,16 @@ pub(crate) fn parse_tsv(text: &str) -> Vec<Vec<String>> {
let mut row = Vec::new();
let mut field = String::new();
let mut chars = text.chars().peekable();
// Text read again as unquoted, ahead of the rest of the input: a field that opened with a
// quote but turned out not to be quoted.
let mut replay: std::collections::VecDeque<char> = std::collections::VecDeque::new();
// Text is replayed at most as much as the input is long; past that, quotes are text, so
// hostile input full of fields that only look quoted reads in linear time.
let mut replay_budget = text.len();
let mut quoted = false;
let mut at_start = true;
// A quoted field's text as it stands in the input, for a field that turns out not to be one.
let mut raw = String::new();
loop {
let c = match replay.pop_front().or_else(|| chars.next()) {
Some(c) => c,
// A quote that never closes: the field is its text as written.
None if quoted => {
quoted = false;
field.clear();
field.push('"');
replay_budget = replay_budget.saturating_sub(raw.len());
replay.extend(raw.chars().skip(1));
raw.clear();
continue;
}
None => break,
};
while let Some(c) = chars.next() {
if quoted {
raw.push(c);
if c == '"' {
let next = replay.front().copied().or_else(|| chars.peek().copied());
if next == Some('"') {
if chars.peek() == Some(&'"') {
field.push('"');
raw.push('"');
if replay.pop_front().is_none() {
chars.next();
}
chars.next();
} else {
quoted = false;
// A field is quoted only when its closing quote ends it, as Excel reads it:
// `"hi" there` is that text, quotes included, and a tab or line break inside
// it still ends the cell, as if the leading quote weren't there.
if !matches!(next, None | Some('\t' | '\n' | '\r')) {
field.clear();
field.push('"');
replay_budget = replay_budget.saturating_sub(raw.len());
let rest: Vec<char> = std::mem::take(&mut raw).chars().skip(1).collect();
for ch in rest.into_iter().rev() {
replay.push_front(ch);
}
}
}
} else {
field.push(c);
@@ -317,10 +243,9 @@ pub(crate) fn parse_tsv(text: &str) -> Vec<Vec<String>> {
continue;
}
match c {
'"' if at_start && replay_budget > 0 => {
'"' if at_start => {
quoted = true;
at_start = false;
raw = String::from('"');
}
'\t' => {
row.push(std::mem::take(&mut field));
+2 -11
View File
@@ -62,8 +62,6 @@ pub(super) fn find(s: &mut Session, p: &Json) -> Result<Json> {
let by_cols = bool_param(p, "byColumns").unwrap_or(false);
let all = bool_param(p, "all").unwrap_or(false);
let workbook = str_param(p, "within") == Some("workbook");
// `selection`: only the selected cells of the active sheet, as Excel searches a selected range.
let in_selection = str_param(p, "within") == Some("selection");
s.last_find = Some(p.clone());
let d = s.doc()?;
let sheets: Vec<usize> = if workbook { (0..d.wb.sheets.len()).collect() } else { vec![d.wb.active_sheet()] };
@@ -73,7 +71,6 @@ pub(super) fn find(s: &mut Session, p: &Json) -> Result<Json> {
let mut cells: Vec<(CellRef, String)> = sh
.cells
.iter()
.filter(|(c, _)| !in_selection || d.selection.contains(*c))
.filter_map(|(c, cell)| {
let text = if values || cell.formula.is_none() || crate::display::formula_hidden(&d.wb, sh, c) {
crate::display::cell_text(&d.wb, sh, c)
@@ -103,12 +100,7 @@ pub(super) fn find(s: &mut Session, p: &Json) -> Result<Json> {
let Some((si, c, t)) = next else { return Err(EngineError::Other("not found".into())) };
let dd = s.doc_mut()?;
dd.wb_switch_sheet(si);
if in_selection {
// The range stays selected and the active cell moves through it, as in Excel.
dd.selection.active = c;
} else {
dd.selection = crate::Selection::at(c);
}
dd.selection = crate::Selection::at(c);
Ok(json!({"cell": c.a1(), "sheet": si, "value": t, "count": hits.len()}))
}
@@ -122,7 +114,6 @@ pub(super) fn replace(s: &mut Session, p: &Json) -> Result<Json> {
let whole = bool_param(p, "wholeCell").unwrap_or(false);
let all = bool_param(p, "all").unwrap_or(true);
let workbook = str_param(p, "within") == Some("workbook");
let selection = (str_param(p, "within") == Some("selection")).then(|| s.doc().map(|d| d.selection.clone())).transpose()?;
let active = s.doc()?.selection.active;
let mut skipped = 0usize;
let n = edit(s, |cx| {
@@ -132,7 +123,7 @@ pub(super) fn replace(s: &mut Session, p: &Json) -> Result<Json> {
let cells: Vec<(CellRef, String)> =
cx.wb.sheet(si).map(|sh| sh.cells.iter().map(|(c, _)| (c, sh.input_text(c))).collect()).unwrap_or_default();
for (c, text) in cells {
if (!all && c != active) || selection.as_ref().is_some_and(|sel| !sel.contains(c)) {
if !all && c != active {
continue;
}
if !matches(&text, &what, case, whole) {
-17
View File
@@ -137,23 +137,6 @@ pub(super) fn selection_next(s: &mut Session, p: &Json) -> Result<Json> {
Ok(json!({"active": d.selection.active.a1()}))
}
pub(super) fn selection_next_corner(s: &mut Session, _: &Json) -> Result<Json> {
let d = s.doc_mut()?;
let Some(sheet) = d.wb.active() else { return Err(EngineError::NoDocument) };
// A corner in a merged cell lands on its top-left cell. One in a hidden row or column, one
// the sheet's protection doesn't let you select, and a merged cell that starts outside the
// selected area are skipped.
let area = d.selection.current();
let stop = |c: CellRef| {
Some(sheet.merge_at(c).map_or(c, |m| m.start))
.filter(|c| area.contains(*c) && !sheet.is_row_hidden(c.row) && !sheet.is_col_hidden(c.col) && cell_selectable(&d.wb, sheet, *c))
};
let mut next = d.selection.clone();
next.next_corner_by(stop);
d.selection = next;
Ok(json!({"active": d.selection.active.a1(), "selection": d.selection.a1()}))
}
pub(super) fn select_all(s: &mut Session, _: &Json) -> Result<Json> {
let d = s.doc_mut()?;
let Some(sheet) = d.wb.active() else { return Err(EngineError::NoDocument) };
-209
View File
@@ -307,9 +307,6 @@ fn tsv() {
assert_eq!(parse_tsv("a\tb\n1\t2\n"), vec![vec!["a", "b"], vec!["1", "2"]]);
assert_eq!(parse_tsv("\"x\ty\"\tz"), vec![vec!["x\ty", "z"]]);
assert_eq!(parse_tsv("\"he said \"\"hi\"\"\""), vec![vec!["he said \"hi\""]]);
// #2225: a quote that doesn't close the field leaves it as written, as Excel pastes it.
assert_eq!(parse_tsv("\"hi\" there\tx\n"), vec![vec!["\"hi\" there", "x"]]);
assert_eq!(parse_tsv("say \"hi\"\n"), vec![vec!["say \"hi\""]]);
}
#[test]
@@ -356,74 +353,6 @@ fn cut_paste_that_overflows_the_grid_moves_nothing() {
assert_eq!(get(&mut s, "XFD2")["value"], json!(2.0));
}
#[test]
fn drag_and_drop_asks_before_replacing_data() {
// #3136: dragging column B's border onto column A replaced A without asking.
let mut s = Session::new();
s.new_workbook();
s.execute("range.setValues", json!({"range": "A1:B3", "values": [["Apple", "Kiwi"], ["Pear", "Plum"], ["Fig", "Lime"]]})).unwrap();
s.execute("edit.copy", json!({"range": "D9"})).unwrap();
let text = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap()["input"].clone();
let r = s.execute("range.move", json!({"range": "B:B", "to": "A1"}));
let Err(EngineError::Confirm(q)) = r else { panic!("{r:?}") };
assert_eq!(q, "There's already data here. Do you want to replace it?");
// Nothing moved, and the copy made before is still on the clipboard.
assert_eq!(text(&mut s, "A1"), json!("Apple"));
assert_eq!(text(&mut s, "B1"), json!("Kiwi"));
assert!(s.clipboard.as_ref().is_some_and(|c| !c.cut && c.range.a1() == "D9"));
// Confirmed, column B replaces column A.
s.execute("range.move", json!({"range": "B:B", "to": "A1", "overwrite": true})).unwrap();
assert_eq!(text(&mut s, "A1"), json!("Kiwi"));
assert_eq!(text(&mut s, "A3"), json!("Lime"));
assert_eq!(text(&mut s, "B1"), json!(""));
// Onto empty cells, or overlapping only the moved cells themselves, nothing is asked.
s.execute("range.move", json!({"range": "A1:A3", "to": "A2"})).unwrap();
assert_eq!(text(&mut s, "A4"), json!("Lime"));
s.execute("range.move", json!({"range": "A2:A4", "to": "C2"})).unwrap();
assert_eq!(text(&mut s, "C2"), json!("Kiwi"));
assert!(s.execute("range.move", json!({"range": "C2"})).is_err(), "`to` is required");
}
#[test]
fn drag_and_drop_asks_before_replacing_spilled_values() {
// Cells showing a dynamic array's spilled values hold no cell of their own, but they are data.
let mut s = Session::new();
s.new_workbook();
s.execute("cell.set", json!({"cell": "A1", "input": "=SEQUENCE(3)"})).unwrap();
s.execute("range.setValues", json!({"range": "C2:C3", "values": [["Kiwi"], ["Plum"]]})).unwrap();
let r = s.execute("range.move", json!({"range": "C2:C3", "to": "A2"}));
let Err(EngineError::Confirm(q)) = r else { panic!("{r:?}") };
assert_eq!(q, "There's already data here. Do you want to replace it?");
let sh = s.doc().unwrap().wb.active().unwrap();
assert_eq!(sh.value(CellRef::new(2, 2)), Value::text("Plum"));
s.execute("range.move", json!({"range": "C2:C3", "to": "A2", "overwrite": true})).unwrap();
let sh = s.doc().unwrap().wb.active().unwrap();
assert_eq!(sh.value(CellRef::new(1, 0)), Value::text("Kiwi"));
}
#[test]
fn drag_and_drop_on_another_sheet_stays_on_that_sheet() {
// The move was checked on the range's sheet but pasted on the active one, replacing its data
// without asking.
let mut s = Session::new();
s.new_workbook();
s.execute("home.insertSheet", json!({"name": "Two"})).unwrap();
s.execute("range.setValues", json!({"range": "B1:B2", "values": [["Kiwi"], ["Plum"]]})).unwrap();
s.execute("sheet.activate", json!({"sheet": 0})).unwrap();
s.execute("cell.set", json!({"cell": "A1", "input": "Keep"})).unwrap();
s.execute("selection.set", json!({"range": "C5"})).unwrap();
s.execute("range.move", json!({"range": "Two!B1:B2", "to": "A1"})).unwrap();
// Sheet1 stays active with its own selection; the moved cells are on Two.
assert_eq!(s.doc().unwrap().wb.active_sheet(), 0);
assert_eq!(s.doc().unwrap().selection.a1(), "C5");
let wb = &s.doc().unwrap().wb;
let two = wb.sheet_index("Two").unwrap();
assert_eq!(wb.sheet(0).unwrap().value(CellRef::new(0, 0)), Value::text("Keep"));
assert_eq!(wb.sheet(two).unwrap().value(CellRef::new(0, 0)), Value::text("Kiwi"));
assert_eq!(wb.sheet(two).unwrap().value(CellRef::new(1, 0)), Value::text("Plum"));
assert_eq!(wb.sheet(two).unwrap().value(CellRef::new(0, 1)), Value::Empty);
}
#[test]
fn replace_case_insensitive() {
assert_eq!(replace_ci("Apple apple APPLE", "apple", "pear"), "pear pear pear");
@@ -442,141 +371,3 @@ fn replace_case_insensitive_keeps_text_when_folding_moves_boundaries() {
let sh = s.doc().unwrap().wb.active().unwrap();
assert_eq!(sh.value(CellRef::new(0, 0)), Value::text(text));
}
#[test]
fn next_corner_of_selection_goes_clockwise_and_anchors_there() {
// #1442, as Excel does it: B2 → D2 → D4 → B4 → B2, the area stays selected, and Shift+arrows
// then move the corner opposite the new active cell.
let mut s = Session::new();
s.new_workbook();
s.execute("selection.set", json!({"range": "B2:D4", "active": "B2"})).unwrap();
let mut seen = Vec::new();
for _ in 0..4 {
let r = s.execute("selection.nextCorner", json!({})).unwrap();
assert_eq!(r["selection"], "B2:D4");
seen.push(r["active"].as_str().unwrap().to_string());
}
assert_eq!(seen, ["D2", "D4", "B4", "B2"]);
s.execute("selection.nextCorner", json!({})).unwrap();
s.execute("selection.nextCorner", json!({})).unwrap();
s.execute("selection.move", json!({"dr": 0, "dc": 1, "extend": true})).unwrap();
assert_eq!(s.doc().unwrap().selection.current().a1(), "C2:D4");
// One row goes end to end; a single cell stays.
s.execute("selection.set", json!({"range": "B2:D2", "active": "B2"})).unwrap();
assert_eq!(s.execute("selection.nextCorner", json!({})).unwrap()["active"], "D2");
assert_eq!(s.execute("selection.nextCorner", json!({})).unwrap()["active"], "B2");
s.execute("selection.set", json!({"range": "C5"})).unwrap();
assert_eq!(s.execute("selection.nextCorner", json!({})).unwrap()["active"], "C5");
}
#[test]
fn next_corner_skips_corners_the_sheet_protection_does_not_let_you_select() {
// With locked cells unselectable, Ctrl+. goes on to the next corner it may select.
let mut s = Session::new();
s.new_workbook();
s.execute("home.lockCell", json!({"range": "B2:C3", "on": false})).unwrap();
s.execute("selection.set", json!({"range": "B2:D3", "active": "B2"})).unwrap();
s.execute("review.protectSheet", json!({"selectLockedCells": false})).unwrap();
let r = s.execute("selection.nextCorner", json!({})).unwrap();
assert_eq!((r["active"].as_str(), r["selection"].as_str()), (Some("B3"), Some("B2:D3")), "D2 and D3 are locked");
assert_eq!(s.execute("selection.nextCorner", json!({})).unwrap()["active"], "B2");
}
#[test]
fn next_corner_stops_once_on_a_merged_corner_at_its_top_left_cell() {
// A corner inside a merged cell is that merged cell, whose active cell is its top-left one.
let mut s = Session::new();
s.new_workbook();
s.execute("home.mergeCells", json!({"range": "C2:D2"})).unwrap();
s.execute("selection.set", json!({"range": "B2:D3", "active": "B2"})).unwrap();
let seen: Vec<String> = (0..4).map(|_| s.execute("selection.nextCorner", json!({})).unwrap()["active"].as_str().unwrap().to_string()).collect();
assert_eq!(seen, ["C2", "D3", "B3", "B2"]);
// A selection that is one merged cell has no other corner.
s.execute("selection.set", json!({"range": "C2:D2", "active": "C2"})).unwrap();
assert_eq!(s.execute("selection.nextCorner", json!({})).unwrap()["active"], "C2");
}
#[test]
fn next_corner_skips_hidden_corners_and_stays_inside_the_selected_area() {
// A corner in a hidden column is passed over, as keyboard moves pass over hidden cells.
let mut s = Session::new();
s.new_workbook();
s.execute("home.hideColumns", json!({"cols": "D:D"})).unwrap();
s.execute("selection.set", json!({"range": "B2:D3", "active": "B2"})).unwrap();
assert_eq!(s.execute("selection.nextCorner", json!({})).unwrap()["active"], "B3");
// Column C selected with B1:C1 merged: its top corner's merged cell starts in column B,
// outside the selection, so the active cell doesn't go there.
let mut s = Session::new();
s.new_workbook();
s.execute("home.mergeCells", json!({"range": "B1:C1"})).unwrap();
s.execute("selection.set", json!({"cell": "C5"})).unwrap();
s.execute("selection.column", json!({})).unwrap();
let r = s.execute("selection.nextCorner", json!({})).unwrap();
assert_eq!((r["active"].as_str(), r["selection"].as_str()), (Some("C1048576"), Some("C:C")));
}
#[test]
fn copy_quotes_only_cells_with_tabs_or_line_breaks() {
// #2225: a cell holding a quote was wrapped in quotes on copy; Excel quotes only text with a
// tab or line break, and pastes the result back as the same cells.
let mut s = Session::new();
s.new_workbook();
for (cell, input) in [("A1", "say \"hi\""), ("A2", "plain"), ("A3", "\"hi\" there"), ("A4", "x \"q\"\ny"), ("A5", "tab\tinside")] {
s.execute("cell.set", json!({"cell": cell, "input": input})).unwrap();
}
let text = s.execute("edit.copy", json!({"range": "A1:A5"})).unwrap()["text"].as_str().unwrap().to_string();
assert_eq!(text, "say \"hi\"\nplain\n\"hi\" there\n\"x \"\"q\"\"\ny\"\n\"tab\tinside\"\n");
let rows = parse_tsv(&text);
let cells: Vec<&str> = rows.iter().map(|r| r[0].as_str()).collect();
assert_eq!(cells, ["say \"hi\"", "plain", "\"hi\" there", "x \"q\"\ny", "tab\tinside"]);
}
#[test]
fn a_field_that_is_not_really_quoted_keeps_its_separators() {
// A field opening with a quote is quoted only if a closing quote ends it; when it doesn't (or
// never closes), the text is read as written, and the tabs and line breaks inside it still
// separate cells, as they would have without the leading quote.
assert_eq!(parse_tsv("\"a\tb\"x\tc"), vec![vec!["\"a", "b\"x", "c"]]);
assert_eq!(parse_tsv("\"a\tb\nc"), vec![vec!["\"a", "b"], vec!["c"]]);
assert_eq!(parse_tsv("\"a\tb\tc\n"), vec![vec!["\"a", "b", "c"]]);
// Cells copied without quotes (they hold no tab or line break) read back as the same cells.
let mut s = Session::new();
s.new_workbook();
for (cell, input) in [("A1", "'\"a"), ("B1", "b\"x"), ("C1", "c")] {
s.execute("cell.set", json!({"cell": cell, "input": input})).unwrap();
}
let text = s.execute("edit.copy", json!({"range": "A1:C1"})).unwrap()["text"].as_str().unwrap().to_string();
assert_eq!(parse_tsv(&text), vec![vec!["\"a", "b\"x", "c"]], "{text:?}");
// Many fields that only look quoted: each is replayed at most once (the replay budget is the
// input's length), and every one is still its own cell.
let hostile = "\"x\t".repeat(50_000);
assert_eq!(parse_tsv(&hostile).first().map(Vec::len), Some(50_001));
}
/// `within: selection` searches only the selected cells, as Excel does with a range selected
/// (#3224): Find All lists them, Find Next moves the active cell through them and keeps the range
/// selected, and Replace changes only them.
#[test]
fn find_and_replace_within_the_selection() {
let mut s = Session::new();
s.new_workbook();
s.execute("range.setValues", json!({"range": "A1:B4", "values": [["pear", "pear"], ["plum", "pear"], ["pear", "fig"], ["pear", "pear"]]}))
.unwrap();
s.execute("selection.set", json!({"range": "B1:B4"})).unwrap();
let all = s.execute("edit.find", json!({"what": "pear", "all": true, "within": "selection"})).unwrap();
let cells: Vec<&str> = all["results"].as_array().unwrap().iter().filter_map(|r| r["cell"].as_str()).collect();
assert_eq!(cells, ["B1", "B2", "B4"], "only column B");
assert_eq!(s.execute("edit.find", json!({"what": "pear", "within": "selection"})).unwrap()["cell"], "B2");
assert_eq!(s.execute("edit.find", json!({"what": "pear", "within": "selection"})).unwrap()["cell"], "B4");
assert_eq!(s.execute("edit.find", json!({"what": "pear", "within": "selection"})).unwrap()["cell"], "B1", "wraps inside the range");
let sel = &s.doc().unwrap().selection;
assert_eq!((sel.a1(), sel.active.a1()), ("B1:B4".to_string(), "B1".to_string()), "the range stays selected");
assert!(s.execute("edit.find", json!({"what": "plum", "within": "selection"})).is_err(), "A2 is outside the range");
assert_eq!(s.execute("edit.replace", json!({"what": "pear", "with": "kiwi", "within": "selection"})).unwrap()["replaced"], 3);
let sh = s.doc().unwrap().wb.active().unwrap();
let at = |a: &str| sh.value(CellRef::parse(a).unwrap());
assert_eq!((at("A1"), at("B1"), at("B4"), at("A4")), (Value::text("pear"), Value::text("kiwi"), Value::text("kiwi"), Value::text("pear")));
// The sheet scope is unchanged.
let all = s.execute("edit.find", json!({"what": "pear", "all": true})).unwrap();
assert_eq!(all["count"], 3, "A1, A3 and A4 on the whole sheet");
}
+3 -7
View File
@@ -507,13 +507,9 @@ pub(super) fn value_settings(s: &mut Session, p: &Json) -> Result<Json> {
let mut v = cur.clone();
if let Some(f) = func {
v.func = f;
// An automatic caption follows the function. Count Numbers was captioned "Count
// Numbers of …" before it took Excel's "Count of …", and saved workbooks still carry it.
let is_auto = |base: &str| {
cur.name.eq_ignore_ascii_case(base) || cur.name.strip_prefix(base).is_some_and(|r| r.chars().all(|c| c.is_ascii_digit()))
};
let legacy = cur.func == PivotFunc::CountNumbers && is_auto(&format!("Count Numbers of {}", cur.source_col));
if is_auto(&auto_name(cur.func, &cur.source_col)) || legacy {
// An automatic caption follows the function.
let auto = auto_name(cur.func, &cur.source_col);
if cur.name.eq_ignore_ascii_case(&auto) || cur.name.strip_prefix(auto.as_str()).is_some_and(|r| r.chars().all(|c| c.is_ascii_digit())) {
v.name = unique_value_name(pt, &auto_name(f, &cur.source_col), Some(i));
}
}
-33
View File
@@ -1013,36 +1013,3 @@ fn detail_cells_are_the_cells_show_details_takes() {
let pt = &d.wb.active().unwrap().pivots[0];
assert!(crate::pivot::detail_cells(&d.wb, pt, d.wb.active_sheet()).unwrap_err().contains("out of date"));
}
#[test]
fn rank_ties_are_dense_and_count_numbers_is_captioned_count_of() {
// #1448 items 10 and 12: Excel ranks 5, 4, 3, 3, 4 as 1, 2, 3, 3, 2 (GridCraft gave 1, 2, 4,
// 4, 2), and captions a Count Numbers field "Count of Val".
let mut s = Session::new();
s.new_workbook();
let rows = json!([["Key", "Val"], ["A", 5], ["B", 4], ["C", 3], ["D", 3], ["E", 4]]);
s.execute("range.setValues", json!({"range": "A1", "values": rows})).unwrap();
s.execute("insert.pivotTable", json!({"source": "Sheet1!$A$1:$B$6", "destination": "Sheet1!D3", "rows": ["Key"], "values": ["Val"]})).unwrap();
s.execute("pivot.valueSettings", json!({"index": 0, "showAs": "rank"})).unwrap();
let ranks: Vec<Value> = ["E4", "E5", "E6", "E7", "E8"].iter().map(|a| val(&s, a)).collect();
assert_eq!(ranks, [1.0, 2.0, 3.0, 3.0, 2.0].map(Value::Number));
s.execute("pivot.valueSettings", json!({"index": 0, "showAs": "normal", "func": "countNumbers"})).unwrap();
assert_eq!(text(&s, "E3"), "Count of Val");
}
#[test]
fn legacy_count_numbers_caption_follows_the_function() {
// Workbooks saved before Count Numbers was captioned "Count of" carry "Count Numbers of Val";
// that is still an automatic caption, so it follows a new function. A custom name stays.
let mut s = Session::new();
s.new_workbook();
let rows = json!([["Key", "Val"], ["A", 5], ["B", 4]]);
s.execute("range.setValues", json!({"range": "A1", "values": rows})).unwrap();
s.execute("insert.pivotTable", json!({"source": "Sheet1!$A$1:$B$3", "destination": "Sheet1!D3", "rows": ["Key"], "values": ["Val"]})).unwrap();
for (legacy, after) in [("Count Numbers of Val", "Sum of Val"), ("Count Numbers of Val2", "Sum of Val"), ("Numbers counted", "Numbers counted")] {
s.execute("pivot.valueSettings", json!({"index": 0, "func": "countNumbers", "name": legacy})).unwrap();
assert_eq!(text(&s, "E3"), legacy);
s.execute("pivot.valueSettings", json!({"index": 0, "func": "sum"})).unwrap();
assert_eq!(text(&s, "E3"), after, "{legacy}");
}
}
-73
View File
@@ -695,79 +695,6 @@ fn bands_unit() {
assert!(w.len() > 2);
}
/// The wrapper `wrap_lines` replaced, re-measuring strings, as the reference for its lines.
fn wrap_lines_remeasuring(text: &str, font: Font, size: f32, width: f32) -> Vec<String> {
let mut out = Vec::new();
for para in text.split('\n') {
let mut line = String::new();
for word in para.split(' ') {
let cand = if line.is_empty() { word.to_string() } else { format!("{line} {word}") };
if line.is_empty() || gridcraft_pdf::text_width(font, size, &cand) <= width {
line = cand;
} else {
out.push(std::mem::take(&mut line));
line = word.to_string();
}
while gridcraft_pdf::text_width(font, size, &line) > width && line.chars().count() > 1 {
let chars: Vec<char> = line.chars().collect();
let (mut lo, mut hi) = (1usize, chars.len());
while lo < hi {
let mid = lo + (hi - lo).div_ceil(2);
let cut: String = chars[..mid].iter().collect();
if gridcraft_pdf::text_width(font, size, &cut) <= width {
lo = mid;
} else {
hi = mid - 1;
}
}
if lo == chars.len() {
break;
}
out.push(chars[..lo].iter().collect());
line = chars[lo..].iter().collect();
}
if out.len() > 2000 {
return out;
}
}
out.push(line);
}
out
}
#[test]
fn wrap_lines_matches_the_remeasuring_wrapper() {
let texts = [
"the quick brown fox jumps",
"Supercalifragilistic",
"a b c\n\nverylongwordthatneedsbreaking short\nend ",
"Wide WWWWWWWWWW words, iiiiiiii narrow ones; mixed MMMMiiiiMMMM",
"€ ü ß — unicode Ünïcödé text",
"",
" ",
];
for text in texts {
for font in [Font::Helvetica, Font::HelveticaBold, Font::Courier] {
for (size, width) in [(10.0, 50.0), (10.0, 20.0), (11.0, 1.0), (8.0, 300.0), (12.0, 0.0)] {
assert_eq!(wrap_lines(text, font, size, width), wrap_lines_remeasuring(text, font, size, width), "{text:?} {font:?} {size} {width}");
}
}
}
}
#[test]
fn wrapping_a_long_word_in_a_narrow_box_stops_at_the_line_limit() {
// One character per line: the old wrapper re-measured and copied the rest of the word for
// each of the 100,000 lines before checking the limit.
let word = "w".repeat(100_000);
let lines = wrap_lines(&word, Font::Helvetica, 10.0, 1.0);
assert!(lines.len() <= 2002, "{}", lines.len());
assert!(lines.iter().all(|l| l == "w"));
// A long paragraph in a very wide box is one line.
let para = vec!["word"; 50_000].join(" ");
assert_eq!(wrap_lines(&para, Font::Helvetica, 10.0, 1.0e9), vec![para.clone()]);
}
#[test]
fn center_across_spans_following_empty_center_across_cells() {
let mut styles = StyleTable::default();
+24 -39
View File
@@ -3,57 +3,42 @@ use super::{CellRef, Color, Font, HAlign, MAX_COLS, Page, Point, RangeRef, Sheet
const PAD_PX: f32 = 2.0;
/// Most lines [`wrap_lines`] returns (it may return one more, the rest of a paragraph).
const MAX_WRAP_LINES: usize = 2000;
/// Word-wraps `text` to `width` points.
///
/// Widths are kept as running sums of glyph widths (the sum `text_width` makes), so each
/// character is measured once: re-measuring the line for every word, or the rest of an
/// over-long word for every piece cut from it, made a long paragraph or word quadratic.
pub(super) fn wrap_lines(text: &str, font: Font, size: f32, width: f32) -> Vec<String> {
let units_of = |s: &str| -> u64 { s.chars().map(|c| u64::from(font.char_width(c))).sum() };
let fits = |units: u64| units as f32 * size / 1000.0 <= width;
let space = units_of(" ");
let mut out = Vec::new();
for para in text.split('\n') {
let mut line = String::new();
let mut line_units: u64 = 0;
for word in para.split(' ') {
let word_units = units_of(word);
if line.is_empty() {
line.push_str(word);
line_units = word_units;
} else if fits(line_units + space + word_units) {
line.push(' ');
line.push_str(word);
line_units += space + word_units;
let cand = if line.is_empty() { word.to_string() } else { format!("{line} {word}") };
if line.is_empty() || gridcraft_pdf::text_width(font, size, &cand) <= width {
line = cand;
} else {
out.push(std::mem::take(&mut line));
line.push_str(word);
line_units = word_units;
line = word.to_string();
}
// Break a single over-long word by characters: each piece is the longest that fits
// (at least one character).
if !fits(line_units) && line.chars().nth(1).is_some() {
let mut piece = String::new();
let mut units: u64 = 0;
for c in line.chars() {
let w = u64::from(font.char_width(c));
if !piece.is_empty() && !fits(units + w) {
out.push(std::mem::take(&mut piece));
units = 0;
if out.len() > MAX_WRAP_LINES {
return out;
}
// Break a single over-long word by characters. The cut that fits is found by binary
// search (each try measures one candidate prefix), so an over-long word costs
// O(n log n) measurements instead of re-measuring the whole cut per character for
// every emitted line (`a` × 100k pasted text would be cubic worst case).
while gridcraft_pdf::text_width(font, size, &line) > width && line.chars().count() > 1 {
let chars: Vec<char> = line.chars().collect();
let (mut lo, mut hi) = (1usize, chars.len());
while lo < hi {
let mid = lo + (hi - lo).div_ceil(2);
let cut: String = chars[..mid].iter().collect();
if gridcraft_pdf::text_width(font, size, &cut) <= width {
lo = mid;
} else {
hi = mid - 1;
}
piece.push(c);
units += w;
}
line = piece;
line_units = units;
if lo == chars.len() {
break;
}
out.push(chars[..lo].iter().collect());
line = chars[lo..].iter().collect();
}
if out.len() > MAX_WRAP_LINES {
if out.len() > 2000 {
return out;
}
}
+4 -4
View File
@@ -398,10 +398,10 @@ fn move_columns(s: &mut Session, p: &Json) -> Result<Json> {
{
return Err(EngineError::Other("To prevent possible loss of data, columns cannot be shifted off of the worksheet.".into()));
}
// The checks must authorize the sheet this command modifies, not whatever sheet is active:
// both get the index `target_sheet` resolved (from `sheet`, or a `Sheet!` range or cell).
check_lines_protection(s, &json!({"cols": RangeRef::cols(before, before).a1(), "sheet": sheet}), Axis::Cols, before, count, true)?;
check_lines_protection(s, &json!({"cols": RangeRef::cols(from, end - 1).a1(), "sheet": sheet}), Axis::Cols, from, count, false)?;
// The checks must authorize the sheet this command modifies (`target_sheet`), not whatever
// sheet is active: both carry the command's own `sheet` parameter on.
check_lines_protection(s, &json!({"cols": RangeRef::cols(before, before).a1(), "sheet": p.get("sheet")}), Axis::Cols, before, count, true)?;
check_lines_protection(s, &json!({"cols": RangeRef::cols(from, end - 1).a1(), "sheet": p.get("sheet")}), Axis::Cols, from, count, false)?;
let moved_from = if before <= from { from + count } else { from };
if moved_from.checked_add(count).is_none_or(|end| end > MAX_COLS) {
-36
View File
@@ -40,27 +40,6 @@ pub fn specs() -> Vec<CommandSpec> {
cmd!("view.showZeros", "Show Zeros", [], None, "{on?}", has_doc, |s, p| sheet_flag(s, p, "zeros")),
cmd!("view.showOutlineSymbols", "Show Outline Symbols", [], Some("Ctrl+8"), "{on?}", has_doc, |s, p| sheet_flag(s, p, "outlineSymbols")),
cmd!("view.showObjects", "Show Objects", [], Some("Ctrl+6"), "{on?}", has_doc, show_objects),
cmd!(
"view.showSheetTabs",
"Show Sheet Tabs",
[],
None,
"{on?} (File › Options › Advanced, this workbook; toggles without `on`)",
has_doc,
|s, p| { workbook_view_flag(s, p, "sheetTabs") }
),
cmd!(
"view.showHorizontalScrollBar",
"Show Horizontal Scroll Bar",
[],
None,
"{on?} (this workbook; toggles without `on`)",
has_doc,
|s, p| { workbook_view_flag(s, p, "horizontal") }
),
cmd!("view.showVerticalScrollBar", "Show Vertical Scroll Bar", [], None, "{on?} (this workbook; toggles without `on`)", has_doc, |s, p| {
workbook_view_flag(s, p, "vertical")
}),
cmd!("view.rightToLeft", "Sheet Right-to-Left", ["Page Layout", "Sheet Options"], None, "{on?}", has_doc, |s, p| sheet_flag(s, p, "rtl")),
cmd!("view.zoom", "Zoom", ["View", "Zoom"], None, "{percent: 10..400}", has_doc, zoom),
cmd!("view.zoom100", "100%", ["View", "Zoom"], None, "{}", has_doc, |s, _| zoom(s, &json!({"percent": 100}))),
@@ -210,21 +189,6 @@ fn sheet_flag(s: &mut Session, p: &Json, which: &str) -> Result<Json> {
})
}
/// File › Options › Advanced › Display options for this workbook: the sheet tabs and the two
/// scroll bars, saved with the workbook (`<workbookView showSheetTabs … />`).
fn workbook_view_flag(s: &mut Session, p: &Json, which: &str) -> Result<Json> {
edit(s, |cx| {
let view = &mut cx.wb.view;
let slot = match which {
"sheetTabs" => &mut view.show_sheet_tabs,
"horizontal" => &mut view.show_horizontal_scroll,
_ => &mut view.show_vertical_scroll,
};
*slot = bool_param(p, "on").unwrap_or(!*slot);
Ok(json!({"on": *slot}))
})
}
/// Ctrl+6: hides every object, or shows them again. Placeholders (from a file) count as shown.
fn show_objects(s: &mut Session, p: &Json) -> Result<Json> {
use gridcraft_model::ShowObjects;
+1 -3
View File
@@ -156,9 +156,7 @@ pub fn range_text(wb: &Workbook, sheet: usize, r: RangeRef) -> String {
}
first = false;
let t = cell_text(wb, sh, CellRef::new(row, col));
// Like Excel, only text with a tab or line break is quoted (its quotes doubled);
// `say "hi"` is copied as it is (#2225).
if t.contains(['\t', '\n', '\r']) {
if t.contains(['\t', '\n', '"']) {
out.push('"');
out.push_str(&t.replace('"', "\"\""));
out.push('"');
+1 -120
View File
@@ -1,4 +1,4 @@
//! Opening and saving files: XLSX, legacy XLS import, XLSB and ODS data import, SYLK import, CSV/TSV, JSON (debug), HTML export.
//! Opening and saving files: XLSX, legacy XLS import, XLSB and ODS data import, CSV/TSV, JSON (debug), HTML export.
use std::fmt::Write as _;
@@ -7,8 +7,6 @@ use gridcraft_model::{Sheet, Workbook};
use crate::{EngineError, Result};
mod sylk;
/// File formats GridCraft reads or writes.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum FileKind {
@@ -21,8 +19,6 @@ pub enum FileKind {
Tsv,
Json,
Html,
/// SYLK symbolic link text (import only).
Sylk,
}
impl FileKind {
@@ -37,7 +33,6 @@ impl FileKind {
"tsv" | "tab" | "txt" => FileKind::Tsv,
"json" | "scjson" => FileKind::Json,
"html" | "htm" => FileKind::Html,
"slk" => FileKind::Sylk,
_ => return None,
})
}
@@ -296,9 +291,6 @@ fn read_plain(name: &str, bytes: &[u8], csv_as_text: bool) -> Result<(Workbook,
})?;
return Ok((wb, report.warnings));
}
if let Some(read) = read_html_table(name, kind, bytes, csv_as_text) {
return read;
}
match kind {
Some(FileKind::Json) => {
let mut wb: Workbook = serde_json::from_slice(bytes).map_err(|e| EngineError::Other(format!("not a GridCraft JSON workbook: {e}")))?;
@@ -318,11 +310,6 @@ fn read_plain(name: &str, bytes: &[u8], csv_as_text: bool) -> Result<(Workbook,
}
Ok((wb, warnings))
}
Some(FileKind::Sylk) => {
let (mut wb, warnings) = sylk::read(name, bytes)?;
rename_first_sheet(&mut wb, name);
Ok((wb, warnings))
}
Some(FileKind::Tsv) => {
let opts = gridcraft_xlsx::CsvOptions { delimiter: b'\t', parse_values: !csv_as_text, ..Default::default() };
let (mut wb, report) = gridcraft_xlsx::read_csv_with_report(bytes, &opts).map_err(|e| EngineError::Other(e.to_string()))?;
@@ -359,112 +346,9 @@ fn part_too_large_message(name: &str, part: &str, size: u64) -> String {
)
}
/// How much of a text file is read to tell whether it is a web page.
const HTML_SNIFF_BYTES: usize = 64 << 10;
/// A web page that holds a table, whatever its extension: a web app's "Export to Excel" is often
/// HTML saved as `.xls` (#3166). Excel opens its table as a sheet (after warning that the format
/// and extension don't match): the cells read like typed input (or as text with `csv_as_text`),
/// header cells (`<th>`) are bold and centred, and the sheet takes the name the page gives it
/// (`<x:Name>`) or the file's. `None` when the bytes aren't such a page.
fn read_html_table(name: &str, kind: Option<FileKind>, bytes: &[u8], csv_as_text: bool) -> Option<Result<(Workbook, Vec<String>)>> {
// Every other text file (CSV, TSV, JSON) passes through here: look at the start only, and decode
// the whole file once it is known to be a page within the size a text import takes.
if matches!(kind, Some(FileKind::Json)) || bytes.len() > gridcraft_xlsx::MAX_CSV_BYTES {
return None;
}
let start = gridcraft_xlsx::decode_text(bytes.get(..HTML_SNIFF_BYTES).unwrap_or(bytes));
// Comments an exporter writes before the page (`<!-- ... -->`) don't make it something else.
let mut rest = start.trim_start_matches('\u{feff}').trim_start();
while let Some(after) = rest.strip_prefix("<!--") {
match after.find("-->").and_then(|end| after.get(end + 3..)) {
Some(more) => rest = more.trim_start(),
None => break,
}
}
let head: String = rest.chars().take(16).collect::<String>().to_ascii_lowercase();
if !["<!doctype html", "<html", "<table", "<head", "<body", "<meta"].iter().any(|t| head.starts_with(t)) {
return None;
}
// Decoded as a CSV is (BOM, UTF-8, else Windows-1252/1256), so a legacy page keeps its letters.
let text = gridcraft_xlsx::decode_text(bytes);
let rows = crate::clip_html::table_cells(&text, gridcraft_core::MAX_ROWS as usize, gridcraft_core::MAX_COLS as usize);
if rows.iter().all(Vec::is_empty) {
return None;
}
// The cells go through the CSV reader, so numbers, dates and text read exactly as they do there.
let mut tsv = String::new();
for row in &rows {
let fields: Vec<String> = row
.iter()
.map(|cell| {
// The number the page recorded (`x:num`) only when values are read; as text, the
// cell keeps the text it shows.
let field = match cell.num.filter(|_| !csv_as_text) {
Some(n) => gridcraft_core::number_to_text(n),
// Whitespace is already collapsed; non-breaking spaces stay, as in Excel.
None => cell.text.clone(),
};
if field.contains(['\t', '\n', '\r', '"']) { format!("\"{}\"", field.replace('"', "\"\"")) } else { field }
})
.collect();
tsv.push_str(&fields.join("\t"));
tsv.push('\n');
}
let opts = gridcraft_xlsx::CsvOptions { delimiter: b'\t', parse_values: !csv_as_text, ..Default::default() };
let read = gridcraft_xlsx::read_csv_with_report(tsv.as_bytes(), &opts).map_err(|e| EngineError::Other(e.to_string()));
Some(read.map(|(mut wb, report)| {
wb.csv_delimiter = None;
// Header cells are bold and centred on top of the style the CSV reader gave them, so a
// date in a header keeps its date format.
let headers: Vec<CellRef> = rows
.iter()
.enumerate()
.flat_map(|(r, row)| row.iter().enumerate().filter(|(_, cell)| cell.header && !cell.text.trim().is_empty()).map(move |(c, _)| CellRef::new(r as u32, c as u32)))
.collect();
let mut bold: std::collections::HashMap<gridcraft_model::StyleId, gridcraft_model::StyleId> = std::collections::HashMap::new();
for at in headers {
let Some(base) = wb.sheet(0).map(|sh| sh.style_id(at)) else { continue };
let id = *bold.entry(base).or_insert_with(|| {
wb.styles.derive(base, |st| {
st.font.bold = true;
st.align.h = gridcraft_model::HAlign::Center;
})
});
if let Some(sh) = wb.sheet_mut(0) {
sh.set_style(at, id);
}
}
let page_name = find_between(&text, "<x:Name>", "</x:Name>").map(|n| crate::clip_html::normalized(n.trim()));
match page_name.filter(|n| !n.is_empty()) {
Some(sheet) => set_first_sheet_name(&mut wb, &sheet),
None => rename_first_sheet(&mut wb, name),
}
let mut warnings = report.warnings;
if !matches!(kind, Some(FileKind::Html)) {
warnings.insert(0, format!("'{name}' is a web page (HTML), not a workbook in the format its name says: its table was opened as a sheet. Save as .xlsx to keep your changes."));
}
(wb, warnings)
}))
}
/// The text between the first `open` and the `close` after it, matched case-insensitively.
fn find_between<'a>(text: &'a str, open: &str, close: &str) -> Option<&'a str> {
let lower = text.to_ascii_lowercase();
let start = lower.find(&open.to_ascii_lowercase())?.checked_add(open.len())?;
let len = lower.get(start..)?.find(&close.to_ascii_lowercase())?;
text.get(start..start.checked_add(len)?)
}
/// A CSV opens as a sheet named after the file (like Excel).
fn rename_first_sheet(wb: &mut Workbook, path: &str) {
let stem = std::path::Path::new(path).file_stem().and_then(std::ffi::OsStr::to_str).unwrap_or("Sheet1");
set_first_sheet_name(wb, stem);
}
/// Names the first sheet `name`, without the characters a sheet name can't hold and cut to 31
/// characters; an empty or reserved result keeps the default name.
fn set_first_sheet_name(wb: &mut Workbook, stem: &str) {
let clean: String = stem.chars().filter(|c| !matches!(c, ':' | '\\' | '/' | '?' | '*' | '[' | ']')).take(31).collect();
let clean = clean.trim_matches('\'').to_string();
// "History" is reserved (see `Workbook::check_sheet_name`): keep the default name instead.
@@ -505,9 +389,6 @@ pub fn save_bytes_as(wb: &Workbook, path: &str, kind: FileKind) -> Result<Vec<u8
}
FileKind::Xlsb => Err(EngineError::Other("XLSB is supported for data import only. Save as .xlsx or another supported export format.".into())),
FileKind::Ods => Err(EngineError::Other("ODS is supported for data import only. Save as .xlsx or another supported export format.".into())),
FileKind::Sylk => {
Err(EngineError::Other("SYLK (.slk) is supported for import only. Save as .xlsx or another supported export format.".into()))
}
// A workbook read from CSV keeps the separator it was read with (a `;` file stays `;`).
FileKind::Csv => wb.sheet(sheet).map(|sh| csv(wb, sh, wb.csv_delimiter.unwrap_or(b','))).transpose().map(Option::unwrap_or_default),
FileKind::Tsv => wb.sheet(sheet).map(|sh| csv(wb, sh, b'\t')).transpose().map(Option::unwrap_or_default),
-328
View File
@@ -1,328 +0,0 @@
//! SYLK (`.slk`) import: the symbolic link text format older spreadsheets and some legacy systems
//! export. One record per line, fields separated by `;` (`;;` is a literal semicolon):
//!
//! - `ID` starts the file; `E` ends it.
//! - `C;Y<row>;X<col>;K<value>;E<formula>` is a cell. `Y` and `X` may be left out to stay on the
//! current row or column. `K` is a number, `"text"`, `TRUE`/`FALSE` or an error; `E` is a
//! formula in R1C1 notation; `S;R<row>;C<col>` shares the formula of another cell.
//! - `P;P<code>` adds a number format to the file's list; `F;P<n>;Y<row>;X<col>` gives a cell the
//! n-th format (0-based), and `F;W<first> <last> <width>` sets column widths in characters.
//!
//! Other records (fonts, options, window settings) are skipped. Formulas are converted to A1 with
//! the same relative and absolute references, as Excel does when it opens the file.
use std::collections::HashMap;
use std::sync::Arc;
use gridcraft_core::{CellError, CellRef, MAX_COLS, MAX_ROWS, Value, col_to_letters};
use gridcraft_model::{Cell, Formula, NumFmt, Sheet, Style, Workbook};
use crate::{EngineError, Result};
/// The largest SYLK file read, as for other text imports: past it the file is refused.
const MAX_BYTES: usize = 512 << 20;
/// Column-width records kept from one file; each can cover every column.
const MAX_WIDTH_RECORDS: usize = 1024;
/// Reads a SYLK file into a one-sheet workbook. Returns the workbook and its warnings.
pub fn read(name: &str, bytes: &[u8]) -> Result<(Workbook, Vec<String>)> {
if bytes.len() > MAX_BYTES {
return Err(EngineError::Other(format!("We can't open '{name}': the SYLK file is larger than {} MB.", MAX_BYTES >> 20)));
}
let text = match std::str::from_utf8(bytes) {
Ok(s) => s.to_string(),
// Older exports are in a single-byte code page: keep each byte as its Latin-1 character.
Err(_) => bytes.iter().map(|&b| char::from(b)).collect(),
};
let text = text.strip_prefix('\u{feff}').unwrap_or(&text);
if !text.starts_with("ID") {
return Err(EngineError::Other(format!("We can't open '{name}': it isn't a SYLK file (it doesn't start with an ID record).")));
}
let mut sheet = Sheet::new("Sheet1");
let mut formats: Vec<String> = Vec::new();
// Cells' number formats by index into `formats`, applied once every format is known.
let mut cell_formats: Vec<(CellRef, usize)> = Vec::new();
let mut formulas: HashMap<CellRef, String> = HashMap::new();
let mut widths: Vec<(u32, u32, f64)> = Vec::new();
let (mut row, mut col) = (0u32, 0u32);
let mut skipped = 0usize;
for line in text.lines() {
let fields = split_fields(line);
let Some((kind, rest)) = fields.split_first() else { continue };
match kind.as_str() {
"C" => {
let (mut value, mut formula, mut shared) = (None, None, None::<(Option<u32>, Option<u32>)>);
let mut bad = false;
for f in rest {
let (tag, arg) = f.split_at(f.chars().next().map_or(0, char::len_utf8));
match tag {
"Y" => match position(arg, MAX_ROWS) {
Some(r) => row = r,
None => bad = true,
},
"X" => match position(arg, MAX_COLS) {
Some(c) => col = c,
None => bad = true,
},
"K" => value = Some(parse_value(arg)),
"E" => formula = Some(arg.to_string()),
"S" => shared = Some((None, None)),
// In a shared-formula cell, R and C name the cell whose formula it shares.
"R" if shared.is_some() => shared = shared.map(|(_, c)| (position(arg, MAX_ROWS), c)),
"C" if shared.is_some() => shared = shared.map(|(r, _)| (r, position(arg, MAX_COLS))),
_ => {}
}
}
if bad {
skipped += 1;
continue;
}
let at = CellRef::new(row, col);
if let Some((Some(r), Some(c))) = shared {
formula = formulas.get(&CellRef::new(r, c)).cloned();
}
let mut cell = sheet.cell(at).cloned().unwrap_or_else(|| Cell::value(Value::Empty));
if let Some(v) = value {
cell.value = v;
}
if let Some(r1c1) = formula {
let a1 = r1c1_formula_to_a1(&r1c1, at);
let f = Formula::new(&a1);
if f.expr.is_some() {
cell.formula = Some(Arc::new(f));
formulas.insert(at, r1c1);
} else {
skipped += 1;
}
}
sheet.set_cell(at, cell);
}
"F" => {
let mut format = None;
for f in rest {
let (tag, arg) = f.split_at(f.chars().next().map_or(0, char::len_utf8));
match tag {
"Y" => row = position(arg, MAX_ROWS).unwrap_or(row),
"X" => col = position(arg, MAX_COLS).unwrap_or(col),
"P" => format = arg.parse::<usize>().ok(),
"W" => {
let mut parts = arg.split_whitespace();
if let (Some(first), Some(last), Some(width)) = (parts.next(), parts.next(), parts.next())
&& let (Some(first), Some(last), Ok(width)) =
(position(first, MAX_COLS), position(last, MAX_COLS), width.parse::<f64>())
&& first <= last
&& (0.0..=255.0).contains(&width)
// A file of width records can't make the import loop over every column
// again and again.
&& widths.len() < MAX_WIDTH_RECORDS
{
widths.push((first, last, width));
}
}
_ => {}
}
}
if let Some(format) = format {
cell_formats.push((CellRef::new(row, col), format));
}
}
// Number formats only: `P;F…` and `P;E…` are fonts.
"P" => {
if let Some(code) = rest.first().and_then(|f| f.strip_prefix('P')) {
formats.push(code.to_string());
}
}
"E" => break,
_ => {}
}
}
for (first, last, width) in widths {
let size = crate::cmd::format::chars_to_points(width) as f32;
for c in first..=last {
sheet.cols.entry(c).or_default().size = Some(size);
}
}
let mut wb = Workbook::new();
let mut styles = HashMap::new();
for (at, index) in cell_formats {
let Some(code) = formats.get(index).filter(|code| !code.is_empty() && *code != "General") else { continue };
let style = *styles.entry(index).or_insert_with(|| wb.styles.intern(Style { num_fmt: NumFmt(Arc::from(code.as_str())), ..Style::default() }));
let mut cell = sheet.cell(at).cloned().unwrap_or_else(|| Cell::value(Value::Empty));
cell.style = style;
sheet.set_cell(at, cell);
}
if let Some(first) = wb.sheet_mut(0) {
sheet.name = std::mem::take(&mut first.name);
*first = sheet;
}
let mut warnings = Vec::new();
if skipped > 0 {
warnings.push(format!("{name}: {skipped} SYLK records GridCraft couldn't read were skipped"));
}
Ok((wb, warnings))
}
/// A record's fields: `;` separates them and `;;` is a literal semicolon.
fn split_fields(line: &str) -> Vec<String> {
let mut fields = vec![String::new()];
let mut chars = line.trim_end_matches('\r').chars().peekable();
while let Some(c) = chars.next() {
if c == ';' {
if chars.peek() == Some(&';') {
chars.next();
if let Some(f) = fields.last_mut() {
f.push(';');
}
} else {
fields.push(String::new());
}
} else if let Some(f) = fields.last_mut() {
f.push(c);
}
}
fields
}
/// A 1-based row or column number → 0-based, within `max`.
fn position(arg: &str, max: u32) -> Option<u32> {
arg.trim().parse::<u32>().ok().filter(|n| (1..=max).contains(n)).map(|n| n - 1)
}
/// A `K` value: `"text"` (everything between the first and the last quote, as written), a
/// boolean, an error or a number.
fn parse_value(arg: &str) -> Value {
if let Some(text) = arg.strip_prefix('"') {
let text = text.strip_suffix('"').unwrap_or(text);
return Value::Text(Arc::from(text));
}
if arg.eq_ignore_ascii_case("TRUE") || arg.eq_ignore_ascii_case("FALSE") {
return Value::Bool(arg.eq_ignore_ascii_case("TRUE"));
}
if let Some(e) = CellError::parse(arg) {
return Value::Error(e);
}
match arg.trim().parse::<f64>() {
Ok(n) if n.is_finite() => Value::Number(n),
_ => Value::Text(Arc::from(arg)),
}
}
/// One part of an R1C1 reference: `R`/`C` followed by an absolute number, a bracketed offset or
/// nothing (the cell's own row or column).
#[derive(Clone, Copy, Debug, PartialEq)]
enum Part {
Absolute(u32),
Relative(i64),
}
impl Part {
/// The 0-based index and whether it is absolute, or `None` when it falls off the sheet.
fn resolve(self, base: u32, max: u32) -> Option<(u32, bool)> {
match self {
Part::Absolute(n) => (1..=max).contains(&n).then(|| (n - 1, true)),
Part::Relative(k) => i64::from(base).checked_add(k).and_then(|v| u32::try_from(v).ok()).filter(|v| *v < max).map(|v| (v, false)),
}
}
}
/// Parses the number after `R` or `C` at `i`: digits, `[offset]` or nothing. Returns the part and
/// the index after it, or `None` when the text isn't a reference part.
fn parse_part(chars: &[char], i: usize) -> Option<(Part, usize)> {
match chars.get(i) {
Some('[') => {
let close = chars[i..].iter().position(|&c| c == ']')? + i;
let inner: String = chars[i + 1..close].iter().collect();
Some((Part::Relative(inner.parse().ok()?), close + 1))
}
Some(c) if c.is_ascii_digit() => {
let end = chars[i..].iter().position(|c| !c.is_ascii_digit()).map_or(chars.len(), |p| p + i);
let n: String = chars[i..end].iter().collect();
Some((Part::Absolute(n.parse().unwrap_or(u32::MAX)), end))
}
_ => Some((Part::Relative(0), i)),
}
}
fn is_word_char(c: char) -> bool {
c.is_alphanumeric() || matches!(c, '_' | '.' | '\\' | '?' | '$')
}
/// Rewrites the R1C1 references in a SYLK formula as A1 references for the cell at `at`:
/// `RC[-1]` → `B2`, `R1C1` → `$A$1`, `R[-2]C:R[-1]C` → `D2:D3`, a whole column `C4` → `$D:$D` and
/// a whole row `R3` → `$3:$3`. Text in quotes and quoted sheet names are left alone; a reference
/// off the sheet becomes `#REF!`.
pub(crate) fn r1c1_formula_to_a1(formula: &str, at: CellRef) -> String {
let chars: Vec<char> = formula.chars().collect();
let mut out = String::with_capacity(formula.len());
let mut i = 0;
while i < chars.len() {
let c = chars[i];
if c == '"' || c == '\'' {
// Copy the quoted run as it is; a doubled quote stays inside it.
let mut j = i + 1;
while j < chars.len() && !(chars[j] == c && chars.get(j + 1) != Some(&c)) {
j += if chars[j] == c { 2 } else { 1 };
}
let stop = (j + 1).min(chars.len());
out.extend(&chars[i..stop]);
i = stop;
continue;
}
let starts_word = i == 0 || !is_word_char(chars[i - 1]);
if starts_word
&& matches!(c, 'R' | 'r' | 'C' | 'c')
&& let Some((text, next)) = reference_at(&chars, i, at)
{
out.push_str(&text);
i = next;
continue;
}
// Copy the rest of a word as it is, so `ROUND` or `Sheet2` isn't read as a reference.
if is_word_char(c) {
let end = (i..chars.len()).find(|&j| !is_word_char(chars[j])).unwrap_or(chars.len());
out.extend(&chars[i..end]);
i = end;
} else {
out.push(c);
i += 1;
}
}
out
}
/// The A1 text of the R1C1 reference starting at `i`, and the index after it, or `None` when the
/// word there isn't a reference (a function or a name).
fn reference_at(chars: &[char], i: usize, at: CellRef) -> Option<(String, usize)> {
let mut j = i;
let mut row = None;
let mut col = None;
if matches!(chars.get(j), Some('R' | 'r')) {
let (part, next) = parse_part(chars, j + 1)?;
row = Some(part);
j = next;
}
if matches!(chars.get(j), Some('C' | 'c')) {
let (part, next) = parse_part(chars, j + 1)?;
col = Some(part);
j = next;
}
if row.is_none() && col.is_none() {
return None;
}
// A reference ends the word: `ROUND(`, `RATE` or `R2D2` are names.
if chars.get(j).is_some_and(|&c| is_word_char(c) || c == '(' || c == '[' || c == '!') {
return None;
}
let row_text = |p: Part| p.resolve(at.row, MAX_ROWS).map(|(r, abs)| format!("{}{}", if abs { "$" } else { "" }, r + 1));
let col_text = |p: Part| p.resolve(at.col, MAX_COLS).map(|(c, abs)| format!("{}{}", if abs { "$" } else { "" }, col_to_letters(c)));
// One side of a range (`R2:R4`, `C1:C3`) is written alone; a lone row or column is the whole of it.
let prev = chars[..i].iter().rev().find(|c| !c.is_whitespace());
let in_range = prev == Some(&':') || chars[j..].iter().find(|c| !c.is_whitespace()) == Some(&':');
let text = match (row, col) {
(Some(r), Some(c)) => row_text(r).zip(col_text(c)).map(|(r, c)| format!("{c}{r}")),
(Some(r), None) => row_text(r).map(|r| if in_range { r } else { format!("{r}:{r}") }),
(None, Some(c)) => col_text(c).map(|c| if in_range { c } else { format!("{c}:{c}") }),
(None, None) => None,
};
Some((text.unwrap_or_else(|| "#REF!".into()), j))
}
-157
View File
@@ -604,160 +604,3 @@ fn opening_google_sheets_export_unwraps_only_formulas_gridcraft_calculates() {
assert_eq!(text("A2").as_deref(), Some("IFERROR(__XLUDF.DUMMYFUNCTION(\"GOOGLETRANSLATE(\"\"hi\"\",\"\"en\"\",\"\"fr\"\")\"),\"salut\")"));
assert_eq!(opened.sheets[0].cells.get(CellRef::parse("A2").unwrap()).map(|c| c.value.clone()), Some(Value::text("salut")));
}
#[test]
fn a_web_page_saved_as_xls_opens_its_table() {
// #3166: a web app's "Export to Excel" is an HTML table saved as .xls; it opened as lines of
// markup. Excel opens the table: values read as typed, <th> bold and centred, <x:Name> names
// the sheet.
let page = "\u{feff}<html xmlns:x=\"urn:schemas-microsoft-com:office:excel\"><head><meta charset=\"UTF-8\">\
<!--[if gte mso 9]><xml><x:ExcelWorksheets><x:ExcelWorksheet><x:Name>Orders</x:Name></x:ExcelWorksheet></x:ExcelWorksheets></xml><![endif]-->\
</head><body><table><thead><tr><th>Item</th><th>Qty</th><th>Code</th></tr></thead><tbody>\
<tr><td>Kiwi &amp;\n Lime</td><td>12</td><td>007</td></tr><tr><td>Plum</td><td x:num=\"4.5\">4,50</td><td>042</td></tr>\
</tbody></table></body></html>";
let (wb, warnings) = open_bytes("orders.xls", page.as_bytes()).unwrap();
assert!(warnings.first().is_some_and(|w| w.contains("is a web page (HTML)")), "{warnings:?}");
let sh = wb.sheet(0).unwrap();
assert_eq!(sh.name, "Orders");
let v = |a: &str| sh.value(CellRef::parse(a).unwrap());
assert_eq!(v("A1"), gridcraft_core::Value::text("Item"));
assert_eq!(v("A2"), gridcraft_core::Value::text("Kiwi & Lime"));
assert_eq!(v("B2"), gridcraft_core::Value::number(12.0));
assert_eq!(v("C2"), gridcraft_core::Value::number(7.0), "read like typed input, as Excel does");
assert_eq!(v("B3"), gridcraft_core::Value::number(4.5), "the number the page recorded");
let style = |a: &str| wb.styles.get(sh.style_id(CellRef::parse(a).unwrap())).clone();
assert!(style("A1").font.bold && style("A1").align.h == gridcraft_model::HAlign::Center);
assert!(!style("A2").font.bold);
// Opened as text, codes keep their zeros; a page named .html gets no warning.
let (wb, warnings) = open_bytes_with("orders.html", page.as_bytes(), None, true).unwrap();
assert!(warnings.is_empty(), "{warnings:?}");
assert_eq!(wb.sheet(0).unwrap().value(CellRef::parse("C2").unwrap()), gridcraft_core::Value::text("007"));
// Plain text that merely starts with a tag-like word is still read as CSV.
let (wb, _) = open_bytes("notes.csv", b"<b>,1\nx,2\n").unwrap();
assert_eq!(wb.sheet(0).unwrap().value(CellRef::parse("B2").unwrap()), gridcraft_core::Value::number(2.0));
}
#[test]
fn a_web_page_table_keeps_its_characters_text_and_header_formats() {
// A page saved in Windows-1252 keeps its accented letters (it read as replacement characters).
let mut page = b"<html><body><table><tr><th>Caf".to_vec();
page.push(0xE9);
page.extend_from_slice(b"</th><th>2024-01-15</th></tr><tr><td>Plum</td><td x:num=\"4.5\">4,50</td></tr></table></body></html>");
let (wb, _) = open_bytes("menu.xls", &page).unwrap();
let sh = wb.sheet(0).unwrap();
assert_eq!(sh.value(CellRef::parse("A1").unwrap()), gridcraft_core::Value::text("Café"));
// A date in a header cell stays a date, bold and centred (its format was replaced).
let b1 = wb.styles.get(sh.style_id(CellRef::parse("B1").unwrap()));
assert!(matches!(sh.value(CellRef::parse("B1").unwrap()), gridcraft_core::Value::Number(_)));
assert!(b1.font.bold && b1.align.h == gridcraft_model::HAlign::Center);
assert_ne!(b1.num_fmt.as_str(), "General", "the date keeps its date format");
assert_eq!(sh.value(CellRef::parse("B2").unwrap()), gridcraft_core::Value::number(4.5));
// Opened as text, a cell keeps the text it shows, not the number the page recorded for it.
let (wb, _) = open_bytes_with("menu.xls", &page, None, true).unwrap();
assert_eq!(wb.sheet(0).unwrap().value(CellRef::parse("B2").unwrap()), gridcraft_core::Value::text("4,50"));
}
#[test]
fn a_web_page_table_opens_after_a_leading_comment_and_keeps_nonbreaking_spaces() {
// An exporter's comment before <html> still makes a web page; `&nbsp;` stays a non-breaking
// space, as Excel keeps it, while ordinary whitespace collapses.
let page = "<!-- exported by a web app -->\n<html><body><table><tr><td>New&nbsp;&nbsp;York</td><td>a \n b</td></tr></table></body></html>";
let (wb, warnings) = open_bytes("cities.xls", page.as_bytes()).unwrap();
assert!(warnings.first().is_some_and(|w| w.contains("is a web page (HTML)")), "{warnings:?}");
let sh = wb.sheet(0).unwrap();
assert_eq!(sh.value(CellRef::parse("A1").unwrap()), gridcraft_core::Value::text("New\u{a0}\u{a0}York"));
assert_eq!(sh.value(CellRef::parse("B1").unwrap()), gridcraft_core::Value::text("a b"));
}
/// A small SYLK file written the way spreadsheets export one: a header, number formats, column
/// widths, cells that carry on from the last row or column, formulas in R1C1 and a shared formula.
const SYLK: &str = "ID;PTEST;N;E\r\nP;PGeneral\r\nP;P0.00\r\nP;Pyyyy\\-mm\\-dd\r\nF;W1 1 18\r\nF;W2 3 9\r\nB;Y5;X7\r\n\
C;Y1;X1;K\"Item\"\r\nC;X2;K\"Qty\"\r\nC;X4;K\"Total\"\r\n\
C;Y2;X1;K\"Bolts;; small\"\r\nC;X2;K12\r\nF;P1;F$2G;X3\r\nC;X3;K0.35\r\nC;X4;K4.2;ERC[-2]*RC[-1]\r\n\
C;X5;K52;ER2C2+R[1]C[-3]\r\nC;X6;KTRUE\r\nC;X7;K8.4;ERC[-3]*2\r\n\
C;Y3;X1;K\"Nuts \"\"hex\"\"\"\r\nC;X2;K40\r\nC;X3;K0.12\r\nC;X4;K4.8;ERC[-2]*RC[-1]\r\nC;X5;K\"big\";EIF(R4C4>5,\"big\",\"small\")\r\nC;X7;K9.6;S;R2;C7\r\n\
C;Y4;X1;K\"Sum\"\r\nC;X4;K9;ESUM(R[-2]C:R[-1]C)\r\nC;X5;K#DIV/0!;E1/0\r\nC;X7;K18;ESUM(C4)\r\n\
F;P2;FG0G;Y5;X2\r\nC;Y5;X2;K45000\r\nC;X3;K\"R2C3\";E\"R2C3\"\r\nE\r\n";
#[test]
fn sylk_files_open_with_values_formulas_formats_and_widths() {
let (wb, warnings) = open_bytes("Parts list.slk", SYLK.as_bytes()).unwrap();
assert!(warnings.is_empty(), "{warnings:?}");
let sh = wb.sheet(0).unwrap();
assert_eq!(sh.name, "Parts list", "the sheet is named after the file");
let at = |a: &str| CellRef::parse(a).unwrap();
let formula = |a: &str| sh.cell(at(a)).and_then(|c| c.formula.as_ref()).map(|f| f.text.clone());
let text = |s: &str| gridcraft_core::Value::Text(s.into());
assert_eq!(sh.value(at("A1")), text("Item"));
assert_eq!(sh.value(at("A2")), text("Bolts; small"), ";; is a semicolon");
assert_eq!(sh.value(at("A3")), text("Nuts \"\"hex\"\""), "text is kept between the outer quotes as written");
assert_eq!(sh.value(at("B2")), gridcraft_core::Value::Number(12.0));
assert_eq!(sh.value(at("F2")), gridcraft_core::Value::Bool(true));
assert_eq!(sh.value(at("E4")), gridcraft_core::Value::Error(gridcraft_core::CellError::Div0));
assert_eq!(sh.value(at("D4")), gridcraft_core::Value::Number(9.0), "a formula keeps the value the file cached");
assert_eq!(formula("D2").as_deref(), Some("B2*C2"));
assert_eq!(formula("E2").as_deref(), Some("$B$2+B3"));
assert_eq!(formula("E3").as_deref(), Some("IF($D$4>5,\"big\",\"small\")"));
assert_eq!(formula("D4").as_deref(), Some("SUM(D2:D3)"));
assert_eq!(formula("G3").as_deref(), Some("D3*2"), "a shared formula is the other cell's, moved");
assert_eq!(formula("G4").as_deref(), Some("SUM($D:$D)"), "C4 alone is the whole column");
assert_eq!(formula("C5").as_deref(), Some("\"R2C3\""), "text in quotes isn't a reference");
// `F;P1;X3` formats the cell on the current row only.
let fmt = |a: &str| wb.styles.get(sh.style_id(at(a))).num_fmt.0.to_string();
assert_eq!(fmt("C2"), "0.00");
assert_eq!(fmt("C3"), "General");
assert_eq!(fmt("B5"), "yyyy\\-mm\\-dd");
// Widths are in characters, like the Column Width dialog's.
let chars = |c: u32| gridcraft_model::points_to_chars(f64::from(sh.col_width(c)));
assert_eq!((chars(0), chars(1), chars(2)), (18.0, 9.0, 9.0));
assert_eq!(sh.cols.get(&3).and_then(|i| i.size), None, "column D keeps the default width");
}
#[test]
fn sylk_r1c1_formulas_become_a1() {
let b2 = CellRef::parse("B2").unwrap();
for (r1c1, a1) in [
("RC", "B2"),
("R[1]C[-1]", "A3"),
("R1C1:R2C2", "$A$1:$B$2"),
("R3", "$3:$3"),
("R1:R[2]", "$1:4"),
("C[1]", "C:C"),
("SUM(C3:C4)", "SUM($C:$D)"),
("ROUND(RC[1],2)+RATE(1,2,3)", "ROUND(C2,2)+RATE(1,2,3)"),
("'R1C1 sheet'!R1C1", "'R1C1 sheet'!$A$1"),
("Sheet2!RC", "Sheet2!B2"),
("R2D2+rc", "R2D2+B2"),
("R[-5]C", "#REF!"),
("\"RC\"&R1C1", "\"RC\"&$A$1"),
] {
assert_eq!(sylk::r1c1_formula_to_a1(r1c1, b2), a1, "{r1c1}");
}
}
#[test]
fn sylk_rejects_other_files_and_survives_damage() {
assert!(open_bytes("not.slk", b"A,B\n1,2\n").is_err(), "a SYLK file starts with ID");
// Positions off the sheet, broken numbers, a missing end and a format past the list.
let (wb, warnings) =
open_bytes("damaged.slk", b"ID;P\nC;Y0;X1;K1\nC;Y2;X99999;K2\nC;Y3;X1;K1e999\nF;P7;Y3;X1\nF;W5 2 9\nC;Y4;X1;K5;E(((\n").unwrap();
let sh = wb.sheet(0).unwrap();
assert_eq!(sh.cells.iter().count(), 2, "{:?}", sh.cells.iter().collect::<Vec<_>>());
assert_eq!(sh.value(CellRef::parse("A3").unwrap()), gridcraft_core::Value::Text("1e999".into()));
assert_eq!(warnings.len(), 1, "skipped records are reported: {warnings:?}");
}
#[test]
fn sylk_survives_huge_relative_offsets_and_width_floods() {
// Relative offsets at the edge of i64 fall off the sheet instead of overflowing.
let text = b"ID;P\nC;Y1;X1;K0;ER[9223372036854775807]C\nC;Y2;X1;K0;ER[-9223372036854775808]C[-9223372036854775808]\nE\n";
assert!(open_bytes("offsets.slk", text).is_ok());
// Width records over every column, many times over: the import keeps a bounded number.
let mut flood = b"ID;P\n".to_vec();
for _ in 0..5000 {
flood.extend_from_slice(b"F;W1 16384 12\n");
}
flood.extend_from_slice(b"C;Y1;X1;K1\nE\n");
let (wb, _) = open_bytes("widths.slk", &flood).unwrap();
assert_eq!(wb.sheet(0).unwrap().value(CellRef::parse("A1").unwrap()), gridcraft_core::Value::Number(1.0));
}
+9 -13
View File
@@ -355,19 +355,15 @@ fn ranked_value(raw: Value, lookup: &ValueLookup<'_>, context: &ShowContext<'_>)
let Some((_, parent)) = context.row_path.split_last() else { return Value::Empty };
let Some(me) = raw.as_f64() else { return raw };
let siblings = context.siblings.get(parent).cloned().unwrap_or_default();
// Excel's ranks are dense: ties share a rank and the next value takes the next one
// (5, 4, 3, 3, 4 rank 1, 2, 3, 3, 2), so count the distinct larger values.
let mut larger: Vec<f64> = siblings
.into_iter()
.filter_map(|sibling| {
let mut path = parent.to_vec();
path.push(sibling);
number(&lookup.get(&path, context.col_path, context.value_index)).filter(|value| *value > me)
})
.collect();
larger.sort_by(f64::total_cmp);
larger.dedup();
Value::number(larger.len() as f64 + 1.0)
let mut rank = 1u64;
for sibling in siblings {
let mut path = parent.to_vec();
path.push(sibling);
if number(&lookup.get(&path, context.col_path, context.value_index)).is_some_and(|value| value > me) {
rank += 1;
}
}
Value::number(rank as f64)
}
fn show_value(definition: &PivotValue, raw: Value, lookup: &ValueLookup<'_>, context: &ShowContext<'_>) -> Value {
+2 -3
View File
@@ -197,8 +197,7 @@ impl Acc {
}
}
/// Excel's caption verb for a function ("Sum of Sales"). Count Numbers is captioned "Count of
/// Sales", as Excel names it and as the XLSX reader names a value field saved without a name.
/// Excel's caption verb for a function ("Sum of Sales").
pub fn func_caption(f: PivotFunc) -> &'static str {
match f {
PivotFunc::Sum => "Sum",
@@ -207,7 +206,7 @@ pub fn func_caption(f: PivotFunc) -> &'static str {
PivotFunc::Max => "Max",
PivotFunc::Min => "Min",
PivotFunc::Product => "Product",
PivotFunc::CountNumbers => "Count",
PivotFunc::CountNumbers => "Count Numbers",
PivotFunc::StdDev => "StdDev",
PivotFunc::StdDevP => "StdDevp",
PivotFunc::Var => "Var",
-26
View File
@@ -128,32 +128,6 @@ impl Selection {
}
}
impl Selection {
/// Ctrl+. (Next Corner of Selection): the active cell moves clockwise to the next corner of
/// the current area (top-left, top-right, bottom-right, bottom-left) and becomes the anchor,
/// so Shift+arrows then move the opposite corner. The area stays selected. From a cell that
/// isn't a corner the next corner is the top-left one; a single cell stays where it is.
pub fn next_corner(&mut self) {
self.next_corner_by(Some);
}
/// [`Selection::next_corner`] where `stop` says where each corner takes the active cell: the
/// top-left cell of a merged cell the corner is in, or `None` for a corner that can't be
/// selected (it is skipped). Corners that end up on the active cell are one stop with it.
pub fn next_corner_by(&mut self, stop: impl Fn(CellRef) -> Option<CellRef>) {
let r = self.current();
let corners = [r.start, CellRef::new(r.start.row, r.end.col), r.end, CellRef::new(r.end.row, r.start.col)];
let next = match corners.iter().rposition(|c| stop(*c) == Some(self.active)) {
Some(i) => (1..=4).filter_map(|k| corners.get((i + k) % 4)).filter_map(|c| stop(*c)).find(|c| *c != self.active),
None => corners.iter().find_map(|c| stop(*c)),
};
if let Some(next) = next {
self.active = next;
self.anchor = next;
}
}
}
/// Ctrl+arrow: jump to the edge of the current data region (Excel's End mode).
pub fn jump(sheet: &Sheet, from: CellRef, dr: i64, dc: i64) -> CellRef {
if dr == 0 && dc == 0 {
-151
View File
@@ -5297,42 +5297,6 @@ fn move_columns_checks_the_protection_of_the_named_sheet() {
assert_eq!(v(&s, "A1"), Value::text("A"));
}
#[test]
fn move_columns_checks_the_protection_of_a_sheet_named_by_its_range() {
// A `Sheet!` range or cell picks the sheet the move modifies; the protection checks must
// authorize that sheet too, not the active one.
let mut s = s();
s.execute("home.insertSheet", json!({"name": "P"})).unwrap();
s.execute("range.setValues", json!({"range": "A1", "values": [[1, 2, 3, 4]]})).unwrap();
s.execute("review.protectSheet", json!({})).unwrap();
s.execute("sheet.activate", json!({"sheet": 0})).unwrap();
for key in ["range", "cell"] {
let err = s.execute("sheet.moveColumns", json!({key: "P!A1", "cols": "B:C", "before": 4})).expect_err("moving columns on a protected sheet");
assert!(err.to_string().contains("protected"), "{err}");
}
let d = s.doc().unwrap();
let sh = &d.wb.sheets[d.wb.sheet_index("P").unwrap()];
let pv: Vec<Value> = (0..4).map(|c| sh.value(CellRef::new(0, c))).collect();
assert_eq!(pv, vec![Value::number(1.0), Value::number(2.0), Value::number(3.0), Value::number(4.0)]);
}
#[test]
fn sorting_cannot_unlock_a_locked_cell_by_moving_equal_content_onto_it() {
// An unlocked 2 sorted onto a locked 2 leaves the content as it was, but would make the cell
// editable; the sort is refused instead.
let mut s = s();
s.execute("range.setValues", json!({"range": "A1", "values": [[2], [2], [1]]})).unwrap();
s.execute("home.lockCell", json!({"range": "A1", "on": false})).unwrap();
s.execute("home.lockCell", json!({"range": "A3", "on": false})).unwrap();
s.execute("review.protectSheet", json!({"sort": true})).unwrap();
let before = s.doc().unwrap().wb.clone();
let err =
s.execute("data.sort", json!({"range": "A1:A3", "header": false, "keys": [{"column": "A", "order": "asc"}]})).expect_err("sort unlocking A2");
assert!(err.to_string().contains("protected sheet"), "{err}");
assert_eq!(*s.doc().unwrap().wb, *before);
assert!(s.execute("cell.set", json!({"cell": "A2", "input": "9"})).is_err(), "A2 stays locked");
}
#[test]
fn a_script_cannot_switch_windows() {
// Stored scripts are scoped to their workbook: a step that activates another window would
@@ -5457,118 +5421,3 @@ fn a_view_kept_through_redo_doesnt_make_the_saved_workbook_dirty() {
assert_eq!(d.wb.active().unwrap().view, gridcraft_model::SheetViewMode::PageBreakPreview);
assert!(!d.is_dirty(), "back at the saved edits, the view alone isn't a change");
}
#[test]
fn workbook_display_options_are_commands() {
// #3101: Show sheet tabs / horizontal / vertical scroll bar, toggling without `on`, saved with
// the workbook and undoable.
let mut s = Session::new();
s.new_workbook();
let view = |s: &Session| s.doc().unwrap().wb.view;
assert_eq!(s.execute("view.showSheetTabs", json!({})).unwrap()["on"], json!(false));
assert_eq!(s.execute("view.showHorizontalScrollBar", json!({"on": false})).unwrap()["on"], json!(false));
assert_eq!(s.execute("view.showVerticalScrollBar", json!({"on": false})).unwrap()["on"], json!(false));
let v = view(&s);
assert!(!v.show_sheet_tabs && !v.show_horizontal_scroll && !v.show_vertical_scroll);
let (back, _) = gridcraft_xlsx::read_xlsx(&gridcraft_xlsx::write_xlsx(&s.doc().unwrap().wb).unwrap()).unwrap();
assert_eq!(back.view, v, "saved with the workbook");
s.execute("edit.undo", json!({})).unwrap();
assert!(view(&s).show_vertical_scroll);
assert_eq!(s.execute("view.showSheetTabs", json!({})).unwrap()["on"], json!(true), "toggles back");
}
/// A link from a file can sit on a cell with nothing stored in it, past the used range (or on a
/// sheet with no cells at all). Clear Hyperlinks, and Remove Hyperlink in the cell menu, must
/// still remove it.
#[test]
fn clear_hyperlinks_removes_links_on_cells_with_nothing_stored() {
let mut s = s();
s.execute("cell.set", json!({"cell": "A1", "input": "x"})).unwrap();
let link = || gridcraft_model::Hyperlink { target: "https://example.com".into(), tooltip: None, external: true };
let (a1, c5) = (CellRef::parse("A1").unwrap(), CellRef::parse("C5").unwrap());
std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb).sheet_mut(0).unwrap().hyperlinks.insert(c5, link());
s.execute("edit.clearHyperlinks", json!({"range": "C5"})).unwrap();
assert!(!s.doc().unwrap().wb.active().unwrap().hyperlinks.contains_key(&c5), "the link past the used range is removed");
// No cells at all.
s.execute("edit.clearAll", json!({"range": "A1"})).unwrap();
std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb).sheet_mut(0).unwrap().hyperlinks.insert(a1, link());
s.execute("edit.clearHyperlinks", json!({"range": "A1"})).unwrap();
assert!(!s.doc().unwrap().wb.active().unwrap().hyperlinks.contains_key(&a1), "the link on an empty sheet is removed");
}
/// Notes and links aren't cell contents, so the cell comparison that guards a protected sheet
/// doesn't see them go: Clear Hyperlinks and Clear Comments refuse locked cells themselves,
/// stored or not.
#[test]
fn clearing_links_and_notes_respects_sheet_protection() {
let mut s = s();
s.execute("cell.set", json!({"cell": "A1", "input": "x"})).unwrap();
let link = || gridcraft_model::Hyperlink { target: "https://example.com".into(), tooltip: None, external: true };
let (a1, c5) = (CellRef::parse("A1").unwrap(), CellRef::parse("C5").unwrap());
{
let sh = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb).sheet_mut(0).unwrap();
sh.hyperlinks.insert(a1, link());
sh.hyperlinks.insert(c5, link());
}
s.execute("review.newNote", json!({"cell": "C5", "text": "note", "author": "Example"})).unwrap();
s.execute("review.protectSheet", json!({})).unwrap();
for (cmd, range) in [("edit.clearHyperlinks", "A1"), ("edit.clearHyperlinks", "C5"), ("edit.clearComments", "C5")] {
assert!(s.execute(cmd, json!({"range": range})).is_err(), "{cmd} on locked {range}");
}
let sh = s.doc().unwrap().wb.active().unwrap().clone();
assert!(sh.hyperlinks.contains_key(&a1) && sh.hyperlinks.contains_key(&c5) && sh.comments.contains_key(&c5), "nothing removed");
s.execute("review.unprotectSheet", json!({})).unwrap();
s.execute("edit.clearHyperlinks", json!({"range": "A1:C5"})).unwrap();
s.execute("edit.clearComments", json!({"range": "C5"})).unwrap();
let sh = s.doc().unwrap().wb.active().unwrap().clone();
assert!(sh.hyperlinks.is_empty() && sh.comments.is_empty());
}
/// A removed link or note is a change to its cell even when nothing is stored there, so a
/// recorded macro's footprint lists it.
#[test]
fn clearing_links_and_notes_records_their_cells() {
let mut s = s();
let c5 = CellRef::parse("C5").unwrap();
std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb)
.sheet_mut(0)
.unwrap()
.hyperlinks
.insert(c5, gridcraft_model::Hyperlink { target: "https://example.com".into(), tooltip: None, external: true });
s.execute("review.newNote", json!({"cell": "D7", "text": "note", "author": "Example"})).unwrap();
s.doc_mut().unwrap().touched = Some(Vec::new());
s.execute("edit.clearHyperlinks", json!({"range": "C5"})).unwrap();
s.execute("edit.clearComments", json!({"range": "D7"})).unwrap();
let touched: Vec<String> = s.doc().unwrap().touched.clone().unwrap_or_default().into_iter().map(|(_, c)| c.a1()).collect();
assert!(touched.contains(&"C5".to_string()) && touched.contains(&"D7".to_string()), "{touched:?}");
}
/// The locked-cell check for notes and links keeps Allow Edit Ranges: a range without a password
/// allows them at once, one with a password once it's unlocked in this session.
#[test]
fn clearing_links_and_notes_keeps_allow_edit_ranges() {
let mut s = s();
s.execute("cell.set", json!({"cell": "A1", "input": "x"})).unwrap();
let link = || gridcraft_model::Hyperlink { target: "https://example.com".into(), tooltip: None, external: true };
let (a1, c5) = (CellRef::parse("A1").unwrap(), CellRef::parse("C5").unwrap());
{
let sh = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb).sheet_mut(0).unwrap();
sh.hyperlinks.insert(a1, link());
sh.hyperlinks.insert(c5, link());
}
s.execute("review.newNote", json!({"cell": "C5", "text": "note", "author": "Example"})).unwrap();
// Ranges are added before protecting: a protected sheet refuses `add`.
s.execute("review.allowEditRanges", json!({"action": "add", "title": "Open", "range": "A1"})).unwrap();
s.execute("review.allowEditRanges", json!({"action": "add", "title": "Kept", "range": "C5", "password": "example"})).unwrap();
s.execute("review.protectSheet", json!({})).unwrap();
s.execute("edit.clearHyperlinks", json!({"range": "A1"})).unwrap();
assert!(s.execute("edit.clearComments", json!({"range": "C5"})).is_err(), "a password range stays locked until unlocked");
s.execute("review.allowEditRanges", json!({"action": "unlock", "title": "Kept", "password": "example"})).unwrap();
s.execute("edit.clearHyperlinks", json!({"range": "C5"})).unwrap();
s.execute("edit.clearComments", json!({"range": "C5"})).unwrap();
let sh = s.doc().unwrap().wb.active().unwrap().clone();
assert!(sh.hyperlinks.is_empty() && sh.comments.is_empty());
}
+1 -167
View File
@@ -760,13 +760,6 @@ fn groupby(args: &[Arg], c: &mut dyn Ctx) -> R<Value> {
let total = opt_count(args, 4)?.unwrap_or(1);
let sort = opt_count(args, 5)?.unwrap_or(1);
let filter = opt_arr(args, 6)?;
let relationship = opt_count(args, 7)?.unwrap_or(0);
// A total depth of N adds subtotals for the first N - 1 key columns, so it can't exceed the
// key columns, and a flat table (field_relationship 1) has no levels to subtotal.
let depth = total.unsigned_abs() as usize;
if depth > rf.cols.max(1) || (depth > 1 && relationship == 1) {
return Err(CellError::Value);
}
let p = prepare(&[&rf], &vals, fh, filter.as_ref())?;
let keys: Vec<Vec<Value>> = p.rows.iter().map(|&r| key_row(&rf, r)).collect();
let mut groups: HashMap<String, Vec<usize>> = HashMap::new();
@@ -798,22 +791,13 @@ fn groupby(args: &[Arg], c: &mut dyn Ctx) -> R<Value> {
lines
.sort_by(|a, b| cmp_keys(&[a.get(ci).cloned().unwrap_or(Value::Empty)], &[b.get(ci).cloned().unwrap_or(Value::Empty)], &[sort.signum()]));
}
if depth > 1 {
let mut rows_of = |keys: &[Value]| groups.get(&row_key(keys)).cloned().unwrap_or_default();
// The header and total lines count too, so the whole result is checked before it is built.
let around = usize::from(p.show_headers) + usize::from(total != 0);
let width = rf.cols.saturating_add(vals.cols);
let fits = |n: usize| check_output_size(n.saturating_add(around), width);
lines = with_subtotals(lines, depth, rf.cols, total < 0, &fits, &mut rows_of, &mut agg_row)?;
}
let mut out = Vec::new();
if p.show_headers {
let mut h = p.field_headers.clone();
h.extend(p.value_headers.iter().cloned());
out.push(h);
}
// With subtotals Excel calls the last line "Grand Total".
let mut total_line = vec![Value::from(if depth > 1 { "Grand Total" } else { "Total" })];
let mut total_line = vec![Value::from("Total")];
total_line.resize(rf.cols, Value::Empty);
total_line.extend(agg_row(&p.rows));
if total < 0 {
@@ -828,84 +812,6 @@ fn groupby(args: &[Arg], c: &mut dyn Ctx) -> R<Value> {
from_rows(out, &Value::Empty)
}
/// GROUPBY's subtotals: `lines` (`key_cols` keys, then values) with the lines of each group of a
/// key level kept together in the order the groups first appear, and for every key level above
/// `depth` a line after the group (before it when `before`): the group's keys, blanks for the deeper
/// keys, and the values aggregated over the group's source rows (`rows_of` maps a line's keys to them).
///
/// No recursion, so a deep total depth can't exhaust the stack: each line gets the id of its key
/// prefix at every level (ids count up as prefixes first appear), a stable sort by those ids brings
/// each group together, and one walk adds a subtotal where a group starts or ends. `fits` gets the
/// line count as the groups are found, so an oversized result is refused before it is built.
fn with_subtotals(
mut lines: Vec<Vec<Value>>,
depth: usize,
key_cols: usize,
before: bool,
fits: &dyn Fn(usize) -> R<()>,
rows_of: &mut dyn FnMut(&[Value]) -> Vec<usize>,
agg: &mut dyn FnMut(&[usize]) -> Vec<Value>,
) -> R<Vec<Vec<Value>>> {
let levels = depth.saturating_sub(1);
if levels == 0 {
return Ok(lines);
}
let mut ids: HashMap<(usize, String), usize> = HashMap::new();
let mut paths: Vec<Vec<usize>> = Vec::with_capacity(lines.len());
for line in &lines {
let mut parent = usize::MAX;
let mut path = Vec::with_capacity(levels);
for level in 0..levels {
let next = ids.len();
parent = *ids.entry((parent, row_key(line.get(level..=level).unwrap_or_default()))).or_insert(next);
path.push(parent);
}
fits(lines.len().saturating_add(ids.len()))?;
paths.push(path);
}
let mut group_rows: Vec<Vec<usize>> = vec![Vec::new(); ids.len()];
for (line, path) in lines.iter().zip(&paths) {
let rows = rows_of(line.get(..key_cols).unwrap_or_default());
for &id in path {
if let Some(g) = group_rows.get_mut(id) {
g.extend_from_slice(&rows);
}
}
}
let mut subtotal = |keys: &[Value], level: usize, id: usize| -> Vec<Value> {
let mut line: Vec<Value> = keys.iter().take(level + 1).cloned().collect();
line.resize(key_cols, Value::Empty);
line.extend(agg(group_rows.get(id).map(Vec::as_slice).unwrap_or_default()));
line
};
let mut order: Vec<usize> = (0..lines.len()).collect();
order.sort_by(|&a, &b| paths.get(a).cmp(&paths.get(b)));
let mut out = Vec::with_capacity(lines.len().saturating_add(ids.len()));
let mut prev: Option<(Vec<Value>, &[usize])> = None;
for &i in &order {
let (Some(line), Some(path)) = (lines.get_mut(i).map(std::mem::take), paths.get(i)) else { continue };
// The first level at which this line leaves the previous line's groups.
let split = prev.as_ref().map_or(0, |(_, p)| p.iter().zip(path).take_while(|(a, b)| a == b).count());
if before {
for (level, &id) in path.iter().enumerate().skip(split) {
out.push(subtotal(&line, level, id));
}
} else if let Some((keys, p)) = &prev {
for (level, &id) in p.iter().enumerate().skip(split).rev() {
out.push(subtotal(keys, level, id));
}
}
prev = Some((line.get(..key_cols).unwrap_or_default().to_vec(), path));
out.push(line);
}
if !before && let Some((keys, p)) = &prev {
for (level, &id) in p.iter().enumerate().rev() {
out.push(subtotal(keys, level, id));
}
}
Ok(out)
}
/// Height and width of a PIVOTBY grid: `header_rows` (one per column field, plus the value-name row) and
/// `body_rows` (row keys plus the total row), each `lead` row-field cells wide plus `value_cols` cells per column key.
fn pivot_dims(header_rows: usize, body_rows: usize, col_keys: usize, value_cols: usize, lead: usize) -> (usize, usize) {
@@ -1359,78 +1265,6 @@ mod tests {
is_err(ev("GROUPBY", vec![rf(region), av(col(&[1.0])), t("SUM")]), CellError::Value);
}
#[test]
fn groupby_subtotals_past_the_size_limit_are_refused_before_they_are_built() {
// One row with 4000 key columns at depth 4000 asks for 3999 subtotal lines, each 4001 cells
// wide: 16 million cells, over the limit. It is #NUM! before any subtotal line exists (it
// used to build them all, nesting 4000 levels deep, and refuse them afterwards).
let keys = av(arr(vec![(0..4000).map(|i| nv(f64::from(i))).collect()]));
is_err(ev("GROUPBY", vec![keys, av(col(&[1.0])), t("SUM"), empty(), n(4000.0)]), CellError::Num);
}
#[test]
fn groupby_total_depth_adds_subtotals() {
// #1456 item 1, values as Excel 365 shows them.
let keys2 = || av(arr(vec![vec![tv("a"), tv("x")], vec![tv("a"), tv("y")], vec![tv("b"), tv("x")]]));
let vals3 = || av(col(&[1.0, 2.0, 3.0]));
let e = Value::Empty;
let r = ev("GROUPBY", vec![keys2(), vals3(), t("SUM"), empty(), n(2.0)]);
assert_eq!(
rows_of(&r),
vec![
vec![tv("a"), tv("x"), nv(1.0)],
vec![tv("a"), tv("y"), nv(2.0)],
vec![tv("a"), e.clone(), nv(3.0)],
vec![tv("b"), tv("x"), nv(3.0)],
vec![tv("b"), e.clone(), nv(3.0)],
vec![tv("Grand Total"), e.clone(), nv(6.0)],
]
);
let r = ev("GROUPBY", vec![keys2(), vals3(), t("SUM"), empty(), n(-2.0)]);
assert_eq!(
rows_of(&r),
vec![
vec![tv("Grand Total"), e.clone(), nv(6.0)],
vec![tv("a"), e.clone(), nv(3.0)],
vec![tv("a"), tv("x"), nv(1.0)],
vec![tv("a"), tv("y"), nv(2.0)],
vec![tv("b"), e.clone(), nv(3.0)],
vec![tv("b"), tv("x"), nv(3.0)],
]
);
let keys3 = || {
av(arr(vec![
vec![tv("a"), tv("x"), tv("p")],
vec![tv("a"), tv("x"), tv("q")],
vec![tv("a"), tv("y"), tv("p")],
vec![tv("b"), tv("x"), tv("p")],
]))
};
let vals4 = || av(col(&[1.0, 2.0, 3.0, 4.0]));
let r = ev("GROUPBY", vec![keys3(), vals4(), t("SUM"), empty(), n(3.0)]);
let line = |k: [&str; 3], v: f64| k.iter().map(|s| if s.is_empty() { Value::Empty } else { tv(s) }).chain([nv(v)]).collect::<Vec<_>>();
assert_eq!(
rows_of(&r),
vec![
line(["a", "x", "p"], 1.0),
line(["a", "x", "q"], 2.0),
line(["a", "x", ""], 3.0),
line(["a", "y", "p"], 3.0),
line(["a", "y", ""], 3.0),
line(["a", "", ""], 6.0),
line(["b", "x", "p"], 4.0),
line(["b", "x", ""], 4.0),
line(["b", "", ""], 4.0),
line(["Grand Total", "", ""], 10.0),
]
);
// Depth 1 keeps "Total"; a depth past the key columns, or subtotals of a flat table, are errors.
let r = ev("GROUPBY", vec![keys2(), vals3(), t("SUM"), empty(), n(1.0)]);
assert_eq!(rows_of(&r).last().unwrap(), &vec![tv("Total"), e, nv(6.0)]);
is_err(ev("GROUPBY", vec![av(arr(vec![vec![tv("a")], vec![tv("b")]])), av(col(&[1.0, 2.0])), t("SUM"), empty(), n(2.0)]), CellError::Value);
is_err(ev("GROUPBY", vec![keys2(), vals3(), t("SUM"), empty(), n(2.0), empty(), empty(), n(1.0)]), CellError::Value);
}
#[test]
fn pivotby() {
let (region, item, amt) = sales();
-12
View File
@@ -95,10 +95,6 @@ pub struct CalcSettings {
pub max_change: f64,
/// Use precision as displayed.
pub precision_as_displayed: bool,
/// R1C1 reference style (`<calcPr refMode="R1C1"/>`): kept with the file and written back;
/// GridCraft itself shows and takes A1 references.
#[serde(default)]
pub r1c1: bool,
}
impl Default for CalcSettings {
@@ -109,7 +105,6 @@ impl Default for CalcSettings {
max_iterations: Self::DEFAULT_ITERATIONS,
max_change: Self::DEFAULT_CHANGE,
precision_as_displayed: false,
r1c1: false,
}
}
}
@@ -205,12 +200,6 @@ pub struct Workbook {
pub calc: CalcSettings,
#[serde(default, skip_serializing_if = "is_all")]
pub show_objects: ShowObjects,
/// Trust Center › Privacy Options › "Remove personal information from file properties on save"
/// (`<workbookPr filterPrivacy="1"/>`): kept with the file, and while it is on the author and
/// company are left out of the XLSX files GridCraft writes, as Excel leaves them empty. Other
/// formats aren't filtered: the workbook's own serialization still holds them.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub remove_personal_info: bool,
/// Read and set with [`Workbook::active_sheet`] and [`Workbook::set_active_sheet`].
pub active_sheet: ActiveSheet,
/// The workbook window's display settings (`<workbookView>`), kept across an open + save.
@@ -349,7 +338,6 @@ impl Workbook {
date_system: DateSystem::D1900,
calc: CalcSettings::default(),
show_objects: ShowObjects::All,
remove_personal_info: false,
active_sheet: ActiveSheet::default(),
view: WorkbookView::default(),
props: DocProps::default(),
+12 -1
View File
@@ -425,6 +425,9 @@ impl NumberFormat {
/// The `TEXT()` worksheet function: formats a value with a format code. Numeric text is
/// converted to a number first; other text and booleans (as `TRUE`/`FALSE`) go through the text
/// section, or are returned as is.
/// Longest text a cell holds, in characters (Excel's limit), and so the longest `TEXT` result.
const MAX_CELL_TEXT_CHARS: usize = 32_767;
pub fn text_function(v: &Value, code: &str, sys: DateSystem) -> Result<String, CellError> {
if code.is_empty() {
return Ok(String::new());
@@ -440,7 +443,15 @@ pub fn text_function(v: &Value, code: &str, sys: DateSystem) -> Result<String, C
Value::Bool(b) => return Ok(fmt.format_text(if b { "TRUE" } else { "FALSE" }).text),
Value::Text(t) => match gridcraft_core::parse::parse_number_text(&t) {
Some(n) => n,
None => return Ok(fmt.format_text(&t).text),
// Every `@` repeats the text: a result past a cell's limit is `#VALUE!`, so nested
// TEXT calls can't multiply it further.
None => {
let out = fmt.format_text(&t).text;
if out.len() > MAX_CELL_TEXT_CHARS && out.chars().count() > MAX_CELL_TEXT_CHARS {
return Err(CellError::Value);
}
return Ok(out);
}
},
Value::Array(_) => return Err(CellError::Value),
};
+4
View File
@@ -396,6 +396,10 @@ fn text_fn() {
assert_eq!(text_function(&Value::Number(1234.5), "$#,##0.00", S), Ok("$1,234.50".into()));
assert_eq!(text_function(&Value::from("5"), "0.00", S), Ok("5.00".into()));
assert_eq!(text_function(&Value::from("abc"), "0.00", S), Ok("abc".into()));
// Each `@` repeats the text: up to a cell's 32,767 characters, then #VALUE!.
let at = "@".repeat(255);
assert_eq!(text_function(&Value::from("x".repeat(128).as_str()), &at, S).map(|s| s.len()), Ok(128 * 255));
assert_eq!(text_function(&Value::from("x".repeat(32_000).as_str()), &at, S), Err(CellError::Value));
assert_eq!(text_function(&Value::Error(CellError::NA), "0", S), Err(CellError::NA));
assert_eq!(text_function(&Value::Number(46302.0), "yyyy", S), Ok("2026".into()));
assert_eq!(text_function(&Value::Number(0.5), "General", S), Ok("0.5".into()));
+1 -6
View File
@@ -107,9 +107,7 @@ impl SheetApp {
pub fn logic(&mut self, ctx: &egui::Context) {
self.ensure_text_width(ctx);
// A keytip control request lives one frame: the ribbon consumes it during `ui`, and
// anything that never reached a control (collapsed ribbon, tab switch) is dropped. One
// made with a switch to the control's tab waits, with its window, for the next `ui` of
// that window.
// anything that never reached a control (collapsed ribbon, tab switch) is dropped.
self.ui.keytip_invoke = None;
// Floating pictures' textures go with their workbook, even when no sheet is painted.
let open: Vec<u64> = self.session.documents().iter().map(|d| d.uid).collect();
@@ -196,9 +194,6 @@ impl SheetApp {
let t0 = now_ms();
// A script may have switched windows since the last frame.
self.follow_active_window();
// This window's key tip request that waited for this frame (its tab or ribbon was just
// shown); any other window's request was dropped before it.
self.ui.keytip_invoke = self.ui.keytip_invoke_next.take();
text_box::before_ui(self, &ctx);
// Another window came forward without going through a command (which settles drafts
// first): the draft goes into its own window instead of floating over this one. If its
+3 -58
View File
@@ -48,41 +48,12 @@ impl SheetApp {
}
/// Ribbon and menu Paste: paste the text on the system clipboard, as Cmd/Ctrl+V does. With no
/// text there but a picture, the picture is pasted. With neither, no host clipboard (web), or
/// the text that was there when GridCraft last copied without publishing, GridCraft's own
/// clipboard is pasted.
/// text there, no host clipboard (web), or the text that was there when GridCraft last copied
/// without publishing, GridCraft's own clipboard is pasted.
pub fn paste_from_clipboard(&mut self) {
match self.system_clipboard_text() {
Some(text) if self.session.clipboard.is_none() || self.clipboard_text_at_copy.as_ref() != Some(&text) => self.paste_external(text),
Some(_) => self.run_or_alert("edit.paste", json!({})),
None => match self.services.paste_image.as_mut().and_then(|read| read()) {
Some(image) => self.paste_picture(image),
None => self.run_or_alert("edit.paste", json!({})),
},
}
}
/// Paste a picture from the system clipboard (a screenshot, an image copied in a browser) as
/// a floating picture whose top-left corner is the active cell, as Excel does.
pub fn paste_picture(&mut self, image: image::RgbaImage) {
match encode_png(&image) {
Ok(png) => self.run_or_alert("insert.picture", json!({"base64": gridcraft_engine::io::base64_encode(&png), "mime": "image/png"})),
Err(e) => self.alert(e),
}
}
/// Cmd/Ctrl+V with a picture and no text on the clipboard (egui's `Event::PasteImage`).
pub fn paste_color_image(&mut self, image: &egui::ColorImage) {
let [w, h] = image.size;
// A side too large for u32 is too large to paste; check before copying the pixels.
let (w, h) = (u32::try_from(w).unwrap_or(u32::MAX), u32::try_from(h).unwrap_or(u32::MAX));
if let Err(e) = check_picture_size(w, h) {
return self.alert(e);
}
let rgba = image.pixels.iter().flat_map(|c| c.to_srgba_unmultiplied()).collect();
match image::RgbaImage::from_raw(w, h, rgba) {
Some(image) => self.paste_picture(image),
None => self.alert("The picture on the clipboard can't be read.".into()),
_ => self.run_or_alert("edit.paste", json!({})),
}
}
@@ -104,29 +75,3 @@ impl SheetApp {
}
}
}
/// Largest clipboard picture pasted, per side and in total (an 8K screenshot fits).
const MAX_PICTURE_SIDE: u32 = 16_384;
const MAX_PICTURE_PIXELS: u64 = 64 * 1024 * 1024;
/// Refuse empty clipboard pictures and sizes that would exhaust memory.
fn check_picture_size(w: u32, h: u32) -> Result<(), String> {
if w == 0 || h == 0 {
return Err("The picture on the clipboard is empty.".into());
}
if w > MAX_PICTURE_SIDE || h > MAX_PICTURE_SIDE || u64::from(w) * u64::from(h) > MAX_PICTURE_PIXELS {
return Err("The picture on the clipboard is too large to paste.".into());
}
Ok(())
}
/// PNG bytes of a clipboard picture, refusing sizes that would exhaust memory.
fn encode_png(image: &image::RgbaImage) -> Result<Vec<u8>, String> {
let (w, h) = image.dimensions();
check_picture_size(w, h)?;
let mut png = Vec::new();
image
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
.map_err(|e| format!("The picture on the clipboard can't be pasted: {e}"))?;
Ok(png)
}
+2 -17
View File
@@ -163,19 +163,8 @@ impl SheetApp {
Ok(Json::Null)
}
"view.expandFormulaBar" => {
// `lines` is where a drag of the bar's bottom edge left it: one line collapses it,
// more expands it to that height, which expanding returns to afterwards.
if let Some(lines) = p.get("lines").and_then(Json::as_u64) {
let lines = usize::try_from(lines).unwrap_or(usize::MAX).clamp(1, crate::formula_bar::MAX_DRAG_LINES);
self.ui.formula_bar_expanded = lines > 1;
if lines > 1 {
self.ui.formula_bar_lines = lines;
}
Ok(json!({"on": self.ui.formula_bar_expanded, "lines": self.ui.formula_bar_lines}))
} else {
self.ui.formula_bar_expanded = p.get("on").and_then(Json::as_bool).unwrap_or(!self.ui.formula_bar_expanded);
Ok(json!({"on": self.ui.formula_bar_expanded}))
}
self.ui.formula_bar_expanded = p.get("on").and_then(Json::as_bool).unwrap_or(!self.ui.formula_bar_expanded);
Ok(json!({"on": self.ui.formula_bar_expanded}))
}
"view.darkMode" => {
let current = if self.ui.system_theme { self.effective_dark } else { self.ui.dark };
@@ -202,10 +191,6 @@ impl SheetApp {
self.ui.autocomplete = p.get("on").and_then(Json::as_bool).unwrap_or(!self.ui.autocomplete);
Ok(json!({"on": self.ui.autocomplete}))
}
"options.highlightActiveRow" => {
self.ui.highlight_active_row = p.get("on").and_then(Json::as_bool).unwrap_or(!self.ui.highlight_active_row);
Ok(json!({"on": self.ui.highlight_active_row}))
}
"view.interfaceScale" => {
// `{"percent": 125}` or `{"percent": "auto"}`; out-of-range values are clamped.
let percent = match p.get("percent") {
+1 -16
View File
@@ -59,22 +59,7 @@ impl SheetApp {
// Route to the ribbon control that owns this keytip: it opens its dropdown or
// runs its click handler with the right params (the engine command alone would
// be missing them).
//
// A control on another tab (a legacy menu key such as Alt+I, S for the Symbol
// gallery) shows its tab first, so the control is drawn and takes the request.
keytips::Action::Control(id) => {
// A collapsed ribbon draws no controls either: it is expanded the same way.
let other_tab = gridcraft_engine::find_command(id).and_then(|c| c.menu.first()).filter(|t| **t != self.ui.ribbon_tab);
if other_tab.is_some() || self.ui.ribbon_collapsed {
if let Some(tab) = other_tab {
self.run_or_alert("ui.ribbonTab", json!({"tab": tab}));
}
self.ui.ribbon_collapsed = false;
self.ui.keytip_invoke_next = Some(id);
} else {
self.ui.keytip_invoke = Some(id);
}
}
keytips::Action::Control(id) => self.ui.keytip_invoke = Some(id),
}
}
}
+2 -20
View File
@@ -2,8 +2,7 @@ use super::{Key, Language, Modifiers, SheetApp, Ui, dim, i18n, json, section, ve
/// Excel's File › Options page: General (display theme, interface language and Show formula bar),
/// Editing (AutoComplete) and Save (the autosave checkbox toggling `app.ui.autosave`), plus Formulas
/// (calculation mode and iterative calculation) and the display options for this workbook (sheet
/// tabs and scroll bars) when a document is active. Theme, language, formula
/// (calculation mode and iterative calculation) when a document is active. Theme, language, formula
/// bar, AutoComplete and calculation changes run their commands through `run_or_alert`; calculation
/// settings are saved with the workbook.
pub(super) fn options(app: &mut SheetApp, ui: &mut Ui) {
@@ -15,10 +14,6 @@ pub(super) fn options(app: &mut SheetApp, ui: &mut Ui) {
if ui.checkbox(&mut formula_bar, lang.tr("Show formula bar")).changed() {
app.run_or_alert("view.formulaBar", json!({"on": formula_bar}));
}
let mut highlight_row = app.ui.highlight_active_row;
if ui.checkbox(&mut highlight_row, lang.tr("Highlight the active cell's row")).changed() {
app.run_or_alert("options.highlightActiveRow", json!({"on": highlight_row}));
}
ui.add_space(18.0);
section(ui, lang, "Options|Editing");
let mut autocomplete = app.ui.autocomplete;
@@ -31,20 +26,7 @@ pub(super) fn options(app: &mut SheetApp, ui: &mut Ui) {
if ui.checkbox(&mut autosave, lang.tr("Save .xlsx workbooks automatically after every change")).changed() {
app.toggle_autosave();
}
let Some((calc, view)) = app.session.active().map(|d| (d.wb.calc.clone(), d.wb.view)) else { return };
ui.add_space(18.0);
// Excel's Advanced › Display options for this workbook: saved with the workbook (#3101).
section(ui, lang, "Display options for this workbook");
for (on, label, id) in [
(view.show_horizontal_scroll, "Show horizontal scroll bar", "view.showHorizontalScrollBar"),
(view.show_vertical_scroll, "Show vertical scroll bar", "view.showVerticalScrollBar"),
(view.show_sheet_tabs, "Show sheet tabs", "view.showSheetTabs"),
] {
let mut on = on;
if ui.checkbox(&mut on, lang.tr(label)).changed() {
app.run_or_alert(id, json!({"on": on}));
}
}
let Some(calc) = app.session.active().map(|d| d.wb.calc.clone()) else { return };
ui.add_space(18.0);
section(ui, lang, "Formulas");
ui.label(lang.tr("Workbook calculation"));
+2 -9
View File
@@ -316,12 +316,11 @@ pub(crate) fn paint_objects(app: &SheetApp, p: &Painter, geo: &Geo, wb: &Workboo
p.add(PathShape::convex_polygon(vec![pos2(r.center().x, r.top()), r.right_bottom(), r.left_bottom()], fill, Stroke::new(1.0, line)));
}
ShapeKind::Ink => {
let pt = gridcraft_engine::cmd::draw::ink_width(sp);
let w = pt * geo.z;
let w = gridcraft_engine::cmd::draw::ink_width(sp) * geo.z;
let pts: Vec<Pos2> =
gridcraft_engine::cmd::draw::ink_points(sp).iter().map(|q| pos2(r.left() + q[0] * geo.z, r.top() + q[1] * geo.z)).collect();
if pts.len() >= 2 {
let col = ink_color(line, pt);
let col = if w > 8.0 { line.gamma_multiply(0.45) } else { line };
p.add(PathShape::line(pts, PathStroke::new(w, col)));
}
label = false;
@@ -557,9 +556,3 @@ pub fn hit(app: &SheetApp, geo: &Geo, sh: &Sheet, p: Pos2) -> Option<(ObjRef, Re
}
None
}
/// The colour ink is painted in: a highlighter (a stroke over 8 pt wide) is translucent, by its
/// width in points, so the live pen stroke and the committed ink agree at every zoom.
pub(crate) fn ink_color(line: Color32, width_pt: f32) -> Color32 {
if width_pt > 8.0 { line.gamma_multiply(0.45) } else { line }
}
+1 -6
View File
@@ -263,17 +263,12 @@ pub fn handle(app: &mut SheetApp, ctx: &egui::Context, method: &str, p: &Json) -
("ui.ribbonTab", "Ribbon Tab", ""),
("app.checkForUpdates", "Check for Updates…", ""),
("view.collapseRibbon", "Collapse Ribbon", ""),
(
"view.expandFormulaBar",
"Expand Formula Bar",
"{on?: bool} (toggles without it) → {on}; {lines: int} sets the expanded height (1 collapses) → {on, lines}",
),
("view.expandFormulaBar", "Expand Formula Bar", "{on?: bool} (toggles without it) → {on}"),
("ui.pythonMode", "Python Mode", "{on?: bool} (toggles without it) → {on}; a refused start is an error"),
("view.darkMode", "Dark Mode", ""),
("view.theme", "Display Theme", ""),
("view.animations", "Animations", "{on?: bool} (toggles without it) → {on}"),
("options.autoComplete", "Enable AutoComplete for cell values", "{on?: bool} (toggles without it) → {on}"),
("options.highlightActiveRow", "Highlight the active cell's row", "{on?: bool} (toggles without it) → {on}"),
("view.interfaceScale", "Interface Size", "{percent: 75–200 | \"auto\"} (100 = auto; clamped)"),
("ui.dialog", "Open Dialog", ""),
("ui.backstage", "File (Backstage view)", "{page?: home|new|open|info|saveAs|print|export|options|account|close}"),
-2
View File
@@ -564,8 +564,6 @@ impl Dialog {
d.values.insert("text".into(), json!(text));
d.values.insert("tooltip".into(), json!(link.tooltip));
d.original_link_text = Some(text);
// As in Excel, the dialog on a linked cell edits that link.
d.title = "Edit Hyperlink".into();
}
d
}
+1 -15
View File
@@ -40,21 +40,7 @@ pub(super) fn find(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog) {
d.values.insert("matchCase".into(), json!(case));
d.values.insert("wholeCell".into(), json!(whole));
d.values.insert("within".into(), json!(if wb { "workbook" } else { "sheet" }));
// As in Excel, a selected range (more than one cell) limits the search on the sheet to it.
// One selected cell, merged or not, searches the sheet, as in Excel.
let range_selected = app.session.active().is_some_and(|doc| {
let sel = &doc.selection;
let one_merged = sel.ranges.len() == 1 && doc.wb.active().and_then(|sh| sh.merge_at(sel.current().start)).is_some_and(|m| m == sel.current());
!sel.is_single_cell() && !one_merged
});
let within = if wb {
"workbook"
} else if range_selected {
"selection"
} else {
"sheet"
};
let base = json!({"what": what, "matchCase": case, "wholeCell": whole, "within": within});
let base = json!({"what": what, "matchCase": case, "wholeCell": whole, "within": if wb { "workbook" } else { "sheet" }});
ui.horizontal(|ui| {
if d.tab == "Replace" {
if ui.button("Replace All").clicked() {
+20 -55
View File
@@ -5,8 +5,6 @@ use super::{Dialog, Field, Json, Key, SheetApp, Stroke, json, theme, vec2};
/// come from the dialog's `fields`, so the letter shortcuts and the control channel see the same.
pub(super) fn paste_special(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) {
let lang = app.ui.language;
// L, the Paste Link button's access key, pastes at once as the button does.
let link_now = d.values.remove(PASTE_LINK_NOW).is_some_and(|v| v == json!(true));
let t = theme::Tokens::get(ui.ctx());
type Choice = (&'static str, &'static str, Vec<(&'static str, &'static str)>);
let choices: Vec<Choice> = d
@@ -58,7 +56,7 @@ pub(super) fn paste_special(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialo
ui.separator();
ui.horizontal(|ui| {
// Excel's Paste Link pastes references to the copied cells straight away.
if ui.button(lang.tr("Paste Link")).on_hover_text(lang.tr("Paste formulas that refer to the copied cells")).clicked() || link_now {
if ui.button(lang.tr("Paste Link")).on_hover_text(lang.tr("Paste formulas that refer to the copied cells")).clicked() {
d.values.insert("what".into(), json!("all"));
d.values.insert("operation".into(), json!("none"));
d.values.insert("link".into(), json!(true));
@@ -76,45 +74,6 @@ pub(super) fn paste_special(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialo
});
}
/// Set by the Paste Link access key for the next frame of the dialog, which then pastes.
const PASTE_LINK_NOW: &str = "pasteLinkNow";
/// What one of Paste Special's access keys does: choose an option of a group, or flip a box.
#[derive(Clone, Copy)]
enum Mnemonic {
Set(&'static str, &'static str),
Toggle(&'static str),
/// The Paste Link button: pastes at once.
PasteLink,
}
/// Excel's access keys in Paste Special (#1482): the Paste choices, the operations, the two check
/// boxes and the Paste Link button, each the letter Excel underlines.
fn mnemonic_action(key: Key) -> Option<(char, Mnemonic)> {
use Mnemonic::{Set, Toggle};
Some(match key {
Key::A => ('a', Set("what", "all")),
Key::F => ('f', Set("what", "formulas")),
Key::V => ('v', Set("what", "values")),
Key::T => ('t', Set("what", "formats")),
Key::C => ('c', Set("what", "comments")),
Key::N => ('n', Set("what", "validation")),
Key::X => ('x', Set("what", "allExceptBorders")),
Key::W => ('w', Set("what", "columnWidths")),
Key::R => ('r', Set("what", "formulasAndNumberFormats")),
Key::U => ('u', Set("what", "valuesAndNumberFormats")),
Key::O => ('o', Set("operation", "none")),
Key::D => ('d', Set("operation", "add")),
Key::S => ('s', Set("operation", "subtract")),
Key::M => ('m', Set("operation", "multiply")),
Key::I => ('i', Set("operation", "divide")),
Key::B => ('b', Toggle("skipBlanks")),
Key::E => ('e', Toggle("transpose")),
Key::L => ('l', Mnemonic::PasteLink),
_ => return None,
})
}
/// Paste Special's legacy mnemonics select options; Enter still performs the paste.
pub(super) fn paste_special_shortcuts(d: &mut Dialog, ctx: &egui::Context) {
if ctx.text_edit_focused() {
@@ -129,7 +88,14 @@ pub(super) fn paste_special_shortcuts(d: &mut Dialog, ctx: &egui::Context) {
});
let mac = ctx.os() == egui::os::OperatingSystem::Mac;
let allowed = |m: egui::Modifiers| !m.ctrl && !m.command && !m.mac_cmd && !(m.alt && (mac || right_alt));
let mnemonic = |key| mnemonic_action(key).map(|(letter, _)| letter);
let mnemonic = |key| match key {
Key::T => Some('t'),
Key::V => Some('v'),
Key::F => Some('f'),
Key::C => Some('c'),
Key::E => Some('e'),
_ => None,
};
let mut text_to_consume = Vec::new();
for event in ctx.input(|i| i.events.clone()) {
let egui::Event::Key { key, pressed: true, repeat, modifiers, .. } = event else { continue };
@@ -141,18 +107,17 @@ pub(super) fn paste_special_shortcuts(d: &mut Dialog, ctx: &egui::Context) {
if repeat {
continue;
}
match mnemonic_action(key).map(|(_, action)| action) {
Some(Mnemonic::Set(field, value)) => {
d.values.insert(field.into(), json!(value));
}
Some(Mnemonic::Toggle(field)) => {
let on = d.values.get(field).and_then(Json::as_bool).unwrap_or(false);
d.values.insert(field.into(), json!(!on));
}
Some(Mnemonic::PasteLink) => {
d.values.insert(PASTE_LINK_NOW.into(), json!(true));
}
None => {}
if key == Key::E {
let transpose = d.values.get("transpose").and_then(Json::as_bool).unwrap_or(false);
d.values.insert("transpose".into(), json!(!transpose));
} else {
let what = match key {
Key::T => "formats",
Key::V => "values",
Key::F => "formulas",
_ => "comments",
};
d.values.insert("what".into(), json!(what));
}
}
// Browsers can deliver Text before or after Key. Act only on Key, then remove
+5 -79
View File
@@ -12,14 +12,6 @@ use crate::theme::{self, Tokens};
/// Lines the formula bar (and the in-cell editor) grows to before it scrolls.
pub const MAX_LINES: usize = 5;
/// The most lines a drag of the bar's bottom edge can give it (the window caps it at half its
/// height first).
pub const MAX_DRAG_LINES: usize = 100;
/// How far the bar's bottom edge reaches up into the bar as a resize handle (its bottom margin).
const HANDLE: f32 = 4.0;
/// What an expanded bar leaves below it: the status bar (26) and sheet tabs (32), and the
/// worksheet's column headings and two rows, so the grid never disappears in a short window.
const KEEP_BELOW: f32 = 26.0 + 32.0 + crate::grid::COL_HEADER_H + 2.0 * gridcraft_engine::model::DEFAULT_ROW_HEIGHT;
/// The bar's height at one line.
const ONE_LINE: f32 = 30.0;
/// What the bar adds around the field's rows: the panel's margins (3 + 3), the field's (3 + 3)
@@ -48,22 +40,11 @@ pub struct BarLayout {
pub visible_lines: usize,
/// The caret while editing in the bar.
pub caret: Option<Rect>,
/// The bottom edge that resizes the bar.
pub handle: Rect,
}
impl Default for BarLayout {
fn default() -> Self {
BarLayout {
name_box: Rect::NOTHING,
buttons: Rect::NOTHING,
field: Rect::NOTHING,
height: 0.0,
lines: 1,
visible_lines: 1,
caret: None,
handle: Rect::NOTHING,
}
BarLayout { name_box: Rect::NOTHING, buttons: Rect::NOTHING, field: Rect::NOTHING, height: 0.0, lines: 1, visible_lines: 1, caret: None }
}
}
@@ -92,15 +73,13 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) {
let font = theme::ui_font(13.0);
let row_h = ui.ctx().fonts_mut(|f| f.row_height(&font));
let last = BarLayout::get(ui.ctx());
// Height follows the content's line count (last frame's), 1 to MAX_LINES; expanded (the
// chevron), it is the height the bottom edge was dragged to. It eases only when that target
// changes.
let most = max_lines(ui.ctx().content_rect().height(), ui.available_rect_before_wrap().height(), row_h);
let lines = if app.ui.formula_bar_expanded { app.ui.formula_bar_lines.clamp(1, most) } else { last.lines.clamp(1, MAX_LINES) };
// Height follows the content's line count (last frame's), 1 to MAX_LINES; the chevron pins it
// at MAX_LINES. It eases only when that target changes.
let lines = if app.ui.formula_bar_expanded { MAX_LINES } else { last.lines.clamp(1, MAX_LINES) };
let target = one_line(row_h) + (lines - 1) as f32 * row_h;
let h = ui.ctx().animate_value_with_time(egui::Id::unique("formula_bar.height"), target, EASE_SECONDS);
let mut layout = BarLayout { height: h, ..Default::default() };
let panel = egui::Panel::top("formula_bar")
egui::Panel::top("formula_bar")
.exact_size(h)
.frame(egui::Frame::NONE.fill(t.window).inner_margin(egui::Margin { left: 8, right: 8, top: 3, bottom: 3 }))
.show(ui, |ui| {
@@ -177,62 +156,9 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) {
}
});
});
layout.handle = resize_handle(app, ui, panel.response.rect, row_h, most);
ui.ctx().data_mut(|d| d.insert_temp(BarLayout::id(), layout));
}
/// The most lines the bar takes in a window `window_h` tall with `room` left under the ribbon:
/// half the window, as in Excel (never fewer than the chevron's [`MAX_LINES`]), and no more than
/// leaves [`KEEP_BELOW`] for the status bar, sheet tabs and a few rows of the grid.
fn max_lines(window_h: f32, room: f32, row_h: f32) -> usize {
let lines_in = |h: f32| {
let fit = ((h - one_line(row_h)) / row_h).floor();
if fit.is_finite() && fit > 0.0 { fit as usize + 1 } else { 1 }
};
lines_in(window_h * 0.5).clamp(MAX_LINES, MAX_DRAG_LINES).min(lines_in(room - KEEP_BELOW))
}
/// The lines a bar whose top is at `top` shows with its bottom edge dragged to `y`, in whole lines.
fn dragged_lines(y: f32, top: f32, row_h: f32, most: usize) -> usize {
let extra = ((y - top - one_line(row_h)) / row_h).round();
if extra.is_finite() && extra > 0.0 { (extra as usize + 1).min(most) } else { 1 }
}
/// The bar's bottom edge: dragging it sets the bar's height in whole lines, as in Excel. The bar
/// follows the pointer; the release runs `view.expandFormulaBar` with the lines, so one drag is
/// one step (one line collapses the bar).
fn resize_handle(app: &mut SheetApp, ui: &mut egui::Ui, bar: Rect, row_h: f32, most: usize) -> Rect {
let handle = Rect::from_x_y_ranges(bar.x_range(), (bar.bottom() - HANDLE)..=bar.bottom());
let resp = ui.interact(handle, egui::Id::unique("formula_bar.resize"), Sense::drag());
let name = app.ui.language.tr("Resize formula bar");
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, name));
if resp.hovered() || resp.dragged() {
ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeVertical);
}
// The state before the drag: the preview changes it, the release puts it back for the command.
let before = resp.id.with("before");
if resp.drag_started() {
let state = (app.ui.formula_bar_expanded, app.ui.formula_bar_lines);
ui.ctx().data_mut(|d| d.insert_temp(before, state));
}
if let Some(p) = resp.interact_pointer_pos().filter(|_| resp.dragged() || resp.drag_stopped()) {
let lines = dragged_lines(p.y, bar.top(), row_h, most);
if resp.drag_stopped() {
if let Some((expanded, kept)) = ui.ctx().data_mut(|d| d.remove_temp::<(bool, usize)>(before)) {
(app.ui.formula_bar_expanded, app.ui.formula_bar_lines) = (expanded, kept);
}
app.run_or_alert("view.expandFormulaBar", json!({"lines": lines}));
} else {
app.ui.formula_bar_expanded = lines > 1;
if lines > 1 {
app.ui.formula_bar_lines = lines;
}
ui.ctx().request_repaint();
}
}
handle
}
/// Resolve ownership on every frame; never cache a spill range in presentation state.
pub(crate) fn active_spill_anchor(app: &SheetApp) -> Option<gridcraft_engine::core::CellRef> {
let d = app.session.active()?;
-6
View File
@@ -22,8 +22,6 @@ type PagesCache = ((u64, u64, usize), Vec<(RangeRef, u32)>);
/// Cached Show Details cells, keyed by (doc uid, revision, sheet, pivot).
type DetailsCache = ((u64, u64, usize, usize), Option<std::collections::HashSet<(u32, u32)>>);
mod active_row;
pub use active_row::active_row_band;
mod cell_paint;
mod cursor;
mod editor;
@@ -103,10 +101,6 @@ pub enum Drag {
Select,
Rows(u32),
Cols(u32),
/// Point mode over the row headers: the formula's reference runs from this row to the pointer.
PointRows(u32),
/// Point mode over the column headers: the formula's reference runs from this column to the pointer.
PointCols(u32),
/// Reordering one contiguous whole-column selection by its header.
MoveCols {
source: RangeRef,
-28
View File
@@ -1,28 +0,0 @@
//! "Highlight the active cell's row" (File ▸ Options ▸ General): a tint across the whole row of
//! the active cell, so the eye keeps its place in a wide table.
use super::geo::Geo;
use super::quadrant::QuadrantPaint;
use super::{Rect, Sheet, pos2};
/// The tinted band of `row` inside one quadrant (`rows` x `cols`): none when the row is outside
/// the quadrant or hidden.
pub fn active_row_band(geo: &Geo, sh: &Sheet, row: u32, rows: (u32, u32), cols: (u32, u32)) -> Option<Rect> {
let height = sh.row_height(row) * geo.z;
if row < rows.0 || row > rows.1 || height <= 0.0 {
return None;
}
let top = geo.y(sh, row);
let right = geo.x(sh, cols.1) + sh.col_width(cols.1) * geo.z;
Some(Rect::from_min_max(pos2(geo.x(sh, cols.0), top), pos2(right, top + height)))
}
/// Paints the band of one quadrant, when the preference is on. With `within`, only the part of it
/// inside that area (text running past its cell has just cleared it).
pub(super) fn paint_active_row(q: &QuadrantPaint<'_>, within: Option<Rect>) {
let Some(band) = q.active_row.and_then(|row| active_row_band(q.geo, q.sheet, row, q.rows, q.cols)) else { return };
let part = within.map_or(band, |area| band.intersect(area));
if part.is_positive() {
q.painter.rect_filled(part, 0.0, q.tokens.active_row);
}
}
+2 -2
View File
@@ -194,7 +194,7 @@ pub(super) fn merged_edges(wb: &Workbook, si: usize, sh: &Sheet, geo: &Geo, area
if sh.col_width(col) == 0.0 {
continue;
}
let (x0, x1) = (geo.x(sh, col), geo.x_end(sh, col));
let (x0, x1) = (geo.x(sh, col), geo.x(sh, col.saturating_add(1)));
let top = look(wb, si, sh, CellRef::new(m.start.row, col), cf).0.style.borders.top;
let bottom = look(wb, si, sh, CellRef::new(m.end.row, col), cf).0.style.borders.bottom;
out.push((top, [pos2(x0, rect.top()), pos2(x1, rect.top())]));
@@ -205,7 +205,7 @@ pub(super) fn merged_edges(wb: &Workbook, si: usize, sh: &Sheet, geo: &Geo, area
if sh.row_height(row) == 0.0 {
continue;
}
let (y0, y1) = (geo.y(sh, row), geo.y_end(sh, row));
let (y0, y1) = (geo.y(sh, row), geo.y(sh, row.saturating_add(1)));
let left = look(wb, si, sh, CellRef::new(row, m.start.col), cf).0.style.borders.left;
let right = look(wb, si, sh, CellRef::new(row, m.end.col), cf).0.style.borders.right;
out.push((left, [pos2(rect.left(), y0), pos2(rect.left(), y1)]));
+1 -1
View File
@@ -48,7 +48,7 @@ fn drag_cursor(drag: &Drag, rtl: bool) -> Option<CursorIcon> {
match drag {
Drag::None => None,
Drag::Select => Some(CursorIcon::Cell),
Drag::Rows(_) | Drag::Cols(_) | Drag::PointRows(_) | Drag::PointCols(_) => Some(CursorIcon::Default),
Drag::Rows(_) | Drag::Cols(_) => Some(CursorIcon::Default),
Drag::Fill { .. } => Some(CursorIcon::Crosshair),
Drag::Move { .. } | Drag::MoveCols { .. } | Drag::Object { handle: None, .. } => Some(CursorIcon::Move),
Drag::ResizeCol { .. } => Some(CursorIcon::ResizeColumn),
+4 -14
View File
@@ -158,21 +158,11 @@ impl Geo {
self.cells.top() + self.frozen_h + (self.ri.start(row) as f32 - self.ri.start(self.fr) as f32 - self.scroll.y) * self.z
}
}
/// Screen x of `col`'s right edge. Not `x(col + 1)`: across the freeze line (and between
/// pages) the next column is laid out elsewhere (#2544).
pub fn x_end(&self, sh: &Sheet, col: u32) -> f32 {
self.x(sh, col) + sh.col_width(col) * self.z
}
/// Screen y of `row`'s bottom edge (see `x_end`).
pub fn y_end(&self, sh: &Sheet, row: u32) -> f32 {
self.y(sh, row) + sh.row_height(row) * self.z
}
/// Columns `a` and `b` are on the same side of the freeze line, so they scroll together.
pub fn same_col_pane(&self, a: u32, b: u32) -> bool {
(a < self.fc) == (b < self.fc)
}
pub fn cell_rect(&self, sh: &Sheet, c: CellRef) -> Rect {
Rect::from_min_max(pos2(self.x(sh, c.col), self.y(sh, c.row)), pos2(self.x_end(sh, c.col), self.y_end(sh, c.row)))
Rect::from_min_max(
pos2(self.x(sh, c.col), self.y(sh, c.row)),
pos2(self.x(sh, c.col) + sh.col_width(c.col) * self.z, self.y(sh, c.row) + sh.row_height(c.row) * self.z),
)
}
pub fn range_rect(&self, sh: &Sheet, r: RangeRef) -> Rect {
let a = self.cell_rect(sh, r.start);
+12 -99
View File
@@ -225,10 +225,10 @@ fn handle_draw_tool(app: &mut SheetApp, input: &InteractionInput<'_>) -> bool {
))
})
.collect();
// The line under the pointer is the ink the stroke will commit (#1786).
let theme = app.session.active().map(|d| d.wb.theme.clone()).unwrap_or_default();
let (color, width) = pen_ink(&app.session.draw_color, app.session.draw_width, &theme);
input.ui.painter().add(egui::epaint::PathShape::line(pts, egui::epaint::PathStroke::new(width * geo.z, color)));
input
.ui
.painter()
.add(egui::epaint::PathShape::line(pts, egui::epaint::PathStroke::new(2.0 * geo.z, Color32::from_rgb(0x1F, 0x5F, 0xC9))));
}
if resp.drag_stopped() && app.grid.ink.len() >= 2 {
let pts: Vec<[f32; 2]> = std::mem::take(&mut app.grid.ink);
@@ -249,17 +249,6 @@ fn handle_draw_tool(app: &mut SheetApp, input: &InteractionInput<'_>) -> bool {
false
}
/// The colour and width (points) of the ink a pen stroke commits, as `draw.stroke` stores it (the
/// pen's colour, pen blue by default; its width, 2 pt by default, kept to 0.5–40 pt and stored
/// whole) and as Ink shapes paint it (a highlighter, over 8 pt, is translucent).
pub(crate) fn pen_ink(color: &str, width: f32, theme: &gridcraft_engine::model::Theme) -> (Color32, f32) {
let pen_blue = gridcraft_engine::model::Color::rgb(0x1F, 0x5F, 0xC9);
let line = gridcraft_engine::model::Color::from_hex(color).unwrap_or(pen_blue);
let width = if width <= 0.0 { 2.0 } else { width }.clamp(0.5, 40.0).round().max(1.0);
let color = line.resolve(theme).map(crate::theme::color32).unwrap_or(Color32::from_rgb(0x1F, 0x5F, 0xC9));
(crate::chartview::ink_color(color, width), width)
}
fn handle_header_autofit(app: &mut SheetApp, input: &InteractionInput<'_>) -> bool {
let (geo, sh, sel, pos) = (input.geo, input.sheet, &input.selection, input.position);
let (in_col_header, in_row_header) = (input.in_column_header, input.in_row_header);
@@ -318,7 +307,6 @@ fn handle_column_header_press(app: &mut SheetApp, input: &InteractionInput<'_>,
return;
}
if point_header(app, wb, sh, RangeRef::cols(c, c), mods.shift, mods.command) {
point_header_first_move(app, input);
return;
}
// An armed Format Painter paints the columns dragged over instead of moving them.
@@ -349,7 +337,6 @@ fn handle_row_header_press(app: &mut SheetApp, input: &InteractionInput<'_>, p:
}
let r = geo.row_at(sh, p.y);
if point_header(app, wb, sh, RangeRef::rows(r, r), mods.shift, mods.command) {
point_header_first_move(app, input);
return;
}
let range = if mods.shift { RangeRef::rows(sel.anchor.row.min(r), sel.anchor.row.max(r)) } else { RangeRef::rows(r, r) };
@@ -625,10 +612,6 @@ fn handle_dragging(app: &mut SheetApp, input: &InteractionInput<'_>) {
app.session.run("selection.set", json!({"range": RangeRef::cols(anchor.min(c), anchor.max(c)).a1(), "active": sel.active.a1()}));
scroll_by(app, vec2(auto.x, 0.0));
}
drag @ (Drag::PointRows(_) | Drag::PointCols(_)) => {
point_header_to(app, geo, sh, wb, &drag, p);
scroll_by(app, if matches!(drag, Drag::PointRows(_)) { vec2(0.0, auto.y) } else { vec2(auto.x, 0.0) });
}
Drag::MoveCols { source, .. } => {
let col = geo.col_at(sh, p.x.clamp(geo.cells.left() + 1.0, geo.cells.right() - 1.0));
let midpoint = geo.x(sh, col) + sh.col_width(col) * geo.z * 0.5;
@@ -705,14 +688,6 @@ fn handle_release(app: &mut SheetApp, input: &InteractionInput<'_>) {
}
// Whole rows or columns clicked or dragged over by their headers.
Drag::Rows(_) | Drag::Cols(_) => format_painter_apply(app),
// The release may be the first frame to report the pointer's last position.
drag @ (Drag::PointRows(_) | Drag::PointCols(_)) => {
if resp.drag_stopped()
&& let Some(p) = pos
{
point_header_to(app, geo, sh, wb, &drag, p);
}
}
// 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 } => {
@@ -744,9 +719,9 @@ fn handle_release(app: &mut SheetApp, input: &InteractionInput<'_>) {
}
Drag::Move { to: Some(to), .. } => {
let src = sel.current();
// Dropping on cells that hold data asks first, as in Excel.
if to != src.start {
crate::macro_ui::call(app, "GridCraft", "range.move", json!({"range": src.a1(), "to": to.a1()}));
let _ = app.session.run("edit.cut", json!({"range": src.a1()}));
let _ = app.run("edit.paste", json!({"at": to.a1()}));
}
}
Drag::MoveCols { source, before: Some(before) } => {
@@ -776,13 +751,11 @@ fn format_painter_apply(app: &mut SheetApp) {
}
}
/// Point mode on a header: inserts a whole-row or whole-column reference instead of selecting,
/// and dragging on from there extends it.
/// 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 edit_sheet = ed.sheet;
let active_sheet = wb.active_sheet();
let pressed = range;
let range = if extend {
ed.point
.and_then(|point| crate::editor::formula_refs(&ed.text).into_iter().find(|(start, end, ..)| (*start, *end) == point))
@@ -803,7 +776,9 @@ fn point_header(app: &mut SheetApp, wb: &Workbook, sh: &Sheet, range: RangeRef,
} else {
range
};
let reference = header_reference(ed, wb, sh, 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 {
@@ -812,53 +787,9 @@ fn point_header(app: &mut SheetApp, wb: &Workbook, sh: &Sheet, range: RangeRef,
ed.point_anchor = None;
ed.point_cell = Some(range.start);
ed.request_focus = true;
// The drag keeps the end of the reference the press didn't land on.
app.grid.drag = if range.is_full_cols() {
Drag::PointCols(if range.start.col == pressed.start.col { range.end.col } else { range.start.col })
} else {
Drag::PointRows(if range.start.row == pressed.start.row { range.end.row } else { range.start.row })
};
true
}
fn header_reference(ed: &EditState, wb: &Workbook, sh: &Sheet, range: RangeRef) -> String {
let reference = range.a1();
if ed.sheet != wb.active_sheet() { format!("{}!{reference}", gridcraft_engine::formula::quote_sheet(&sh.name)) } else { reference }
}
/// The frame that starts a drag reports no `dragged()`, so its movement is applied here.
fn point_header_first_move(app: &mut SheetApp, input: &InteractionInput<'_>) {
if input.response.drag_started()
&& let Some(p) = input.position
{
let drag = app.grid.drag.clone();
point_header_to(app, input.geo, input.sheet, input.workbook, &drag, p);
}
}
/// Point mode dragged over the headers: the pointed reference runs from the anchor to the pointer.
fn point_header_to(app: &mut SheetApp, geo: &Geo, sh: &Sheet, wb: &Workbook, drag: &Drag, p: Pos2) {
// The pointer's end moves with Shift+arrow next; the opposite corner keeps it a whole column or row.
let (range, pointer, opposite) = match *drag {
Drag::PointCols(anchor) => {
let col = geo.col_at(sh, p.x.clamp(geo.cells.left() + 1.0, geo.cells.right() - 1.0));
let range = RangeRef::cols(anchor.min(col), anchor.max(col));
(range, CellRef::new(range.start.row, col), CellRef::new(range.end.row, anchor))
}
Drag::PointRows(anchor) => {
let row = geo.row_at(sh, p.y.clamp(geo.cells.top() + 1.0, geo.cells.bottom() - 1.0));
let range = RangeRef::rows(anchor.min(row), anchor.max(row));
(range, CellRef::new(row, range.start.col), CellRef::new(anchor, range.end.col))
}
_ => return,
};
let Some(ed) = app.editor.as_mut().filter(|ed| ed.point_at_caret()) else { return };
let reference = header_reference(ed, wb, sh, range);
ed.insert_ref(&reference);
ed.point_cell = Some(pointer);
ed.point_anchor = Some((wb.active_sheet(), opposite));
}
fn select_to_pointer(app: &mut SheetApp, geo: &Geo, sh: &Sheet, wb: &Workbook, p: Pos2) {
let cell = CellRef::new(
geo.row_at(sh, p.y.clamp(geo.cells.top() + 1.0, geo.cells.bottom() - 1.0)),
@@ -960,7 +891,7 @@ fn freeze_boundary(geo: &Geo, sh: &Sheet, p: Pos2, cols: bool) -> u32 {
return 0;
}
let c = geo.col_at(sh, p.x);
let (l, r) = (geo.x(sh, c), geo.x_end(sh, c));
let (l, r) = (geo.x(sh, c), geo.x(sh, c.saturating_add(1).min(MAX_COLS - 1)));
let b = if p.x - l > r - p.x { c.saturating_add(1) } else { c };
let room = geo.ci.at((((geo.cells.width() - 40.0) / geo.z).max(0.0)) as f64);
b.min(room).min(MAX_COLS - 1)
@@ -969,7 +900,7 @@ fn freeze_boundary(geo: &Geo, sh: &Sheet, p: Pos2, cols: bool) -> u32 {
return 0;
}
let rr = geo.row_at(sh, p.y);
let (t, b2) = (geo.y(sh, rr), geo.y_end(sh, rr));
let (t, b2) = (geo.y(sh, rr), geo.y(sh, rr.saturating_add(1).min(MAX_ROWS - 1)));
let b = if p.y - t > b2 - p.y { rr.saturating_add(1) } else { rr };
let room = geo.ri.at((((geo.cells.height() - 40.0) / geo.z).max(0.0)) as f64);
b.min(room).min(MAX_ROWS - 1)
@@ -1107,27 +1038,9 @@ pub fn point_move(app: &mut SheetApp, ed: &mut EditState, dr: i64, dc: i64, exte
to
};
let text = if extend { RangeRef::new(anchor, to).a1() } else { to.a1() };
let text = if ed.sheet != d.wb.active_sheet() { format!("{}!{text}", gridcraft_engine::formula::quote_sheet(&sheet.name)) } else { text };
let sheet = d.wb.active_sheet();
ed.insert_ref(&text);
ed.point_cell = Some(to);
ed.point_anchor = Some((sheet, anchor));
app.grid.ensure_visible = true;
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_pen_preview_is_the_ink_the_stroke_commits() {
// #1786: the preview was always blue at 2 pt, whatever the pen.
let theme = gridcraft_engine::model::Theme::default();
assert_eq!(pen_ink("#C00000", 3.0, &theme), (Color32::from_rgb(0xC0, 0, 0), 3.0));
assert_eq!(pen_ink("", 0.0, &theme), (Color32::from_rgb(0x1F, 0x5F, 0xC9), 2.0), "no pen chosen yet: pen blue, 2 pt");
assert_eq!(pen_ink("not a colour", 0.2, &theme), (Color32::from_rgb(0x1F, 0x5F, 0xC9), 1.0));
// A highlighter is wide and translucent, as the committed ink is painted.
let (c, w) = pen_ink("#FFFF00", 12.0, &theme);
assert_eq!((c, w), (Color32::from_rgb(0xFF, 0xFF, 0).gamma_multiply(0.45), 12.0));
}
}
+5 -3
View File
@@ -51,9 +51,11 @@ pub(super) fn keyboard(app: &mut SheetApp, ctx: &egui::Context, resp: &egui::Res
app.copy_to_clipboard(ctx, "edit.cut");
}
egui::Event::Paste(text) => app.paste_external(text),
// A picture with no text on the clipboard (a screenshot, a browser image) is pasted as
// a floating picture at the active cell, as Excel does.
egui::Event::PasteImage(image) => app.paste_color_image(&image),
// GridCraft has no image-clipboard pipeline and no cell-picture insert-from-clipboard
// command. Choosing to ignore `PasteImage` is explicit: inserting the bytes as a
// floating picture without a chosen anchor/size would be a new feature, not parity.
// A text representation, when the clipboard has one, still arrives as `Paste` above.
egui::Event::PasteImage(_) => {}
// PRIMARY middle-click paste belongs to egui's TextEdit; the grid has no caret to
// insert text at, so a middle click on a cell is deliberately not a paste.
egui::Event::MiddleClickPaste { .. } => {}
-16
View File
@@ -129,9 +129,6 @@ pub(super) fn context_menu(app: &mut SheetApp, ui: &mut egui::Ui) {
egui::Frame::popup(ui.style()).show(ui, |ui| {
ui.set_min_width(200.0);
let insert_copied = insert_copied_item(app);
// On a cell with a link Excel offers to edit, open or remove it (#2120).
let link =
app.session.active().and_then(|d| d.wb.active().and_then(|sh| sh.hyperlinks.get(&d.selection.active)).map(|l| l.target.clone()));
let mut items: Vec<(&str, &str)> = vec![
("Cut", "edit.cut"),
("Copy", "edit.copy"),
@@ -157,14 +154,6 @@ pub(super) fn context_menu(app: &mut SheetApp, ui: &mut egui::Ui) {
if let Some(item) = insert_copied {
items.insert(6, item);
}
if link.is_some()
&& let Some(i) = items.iter().position(|(_, id)| *id == "ui:insertLink")
{
items.splice(
i..=i,
[("Edit Hyperlink…", "ui:insertLink"), ("Open Hyperlink", "ui:openLink"), ("Remove Hyperlink", "edit.clearHyperlinks")],
);
}
if show_details_offered(app)
&& let Some(i) = items.iter().position(|(_, id)| *id == "data.sortDescending")
{
@@ -189,11 +178,6 @@ pub(super) fn context_menu(app: &mut SheetApp, ui: &mut egui::Ui) {
"ui:newNote" => app.open_dialog("comment", json!({"threaded": false})),
"ui:defineName" => app.open_dialog("defineName", json!({})),
"ui:insertLink" => app.open_dialog("insertLink", json!({})),
"ui:openLink" => {
if let Some(target) = &link {
super::interact::follow_link(app, target);
}
}
"ui:pickList" => app.open_dialog("pickList", json!({})),
"ui:filterValue" => {
if let Some(d) = app.session.active() {
+17 -21
View File
@@ -1,4 +1,3 @@
use super::active_row::paint_active_row;
use super::cell_paint::{
Look, MergedArea, cell_text_y, center_across_end, col32, display_text, look, merged_edges, paint_border, paint_cf_icon, painted_halign,
rotated_text_pos, shrink_font, wrapped_cell_text_y,
@@ -103,8 +102,6 @@ pub(crate) struct QuadrantPaint<'a> {
pub(crate) cols: (u32, u32),
pub(crate) cf: &'a mut CfCache,
pub(crate) tokens: &'a Tokens,
/// The row to tint, when "Highlight the active cell's row" is on.
pub(crate) active_row: Option<u32>,
}
/// Paint a worksheet quadrant, including backgrounds, borders, and text.
@@ -121,8 +118,6 @@ pub(crate) fn paint_quadrant(mut quadrant: QuadrantPaint<'_>) {
let cells = visible_cells(&quadrant);
let looks = paint_backgrounds(&mut quadrant, &cells, &merges);
let outside = outside_text_cells(&mut quadrant, &merges);
// Over the fills and under the text and borders.
paint_active_row(&quadrant, None);
paint_cells(&mut quadrant, &looks, &outside, &merges);
paint_borders(&mut quadrant, &looks, &merges);
paint_sparklines(&quadrant, range);
@@ -147,17 +142,21 @@ fn paint_gridlines(q: &QuadrantPaint<'_>) {
if sh.show_gridlines {
let stroke = Stroke::new(1.0, t.gridline);
let top = geo.y(sh, r0);
let bottom = geo.y_end(sh, r1);
let bottom = geo.y(sh, r1 + 1);
let left = geo.x(sh, c0);
let right = geo.x_end(sh, c1);
let xs = (c0..=c1).filter(|&c| sh.col_width(c) > 0.0).map(|c| geo.x(sh, c)).chain([right]);
for x in xs {
let x = x.round() - 0.5;
let right = geo.x(sh, c1 + 1);
for c in c0..=c1 + 1 {
if c <= c1 && sh.col_width(c) == 0.0 {
continue;
}
let x = geo.x(sh, c).round() - 0.5;
p.line_segment([pos2(x, top), pos2(x, bottom)], stroke);
}
let ys = (r0..=r1).filter(|&r| sh.row_height(r) > 0.0).map(|r| geo.y(sh, r)).chain([bottom]);
for y in ys {
let y = y.round() - 0.5;
for r in r0..=r1 + 1 {
if r <= r1 && sh.row_height(r) == 0.0 {
continue;
}
let y = geo.y(sh, r).round() - 0.5;
p.line_segment([pos2(left, y), pos2(right, y)], stroke);
}
}
@@ -225,6 +224,7 @@ fn outside_text_cells(q: &mut QuadrantPaint<'_>, merges: &[RangeRef]) -> Vec<(Ce
// Text cells just outside the quadrant whose text may overflow into it: the nearest non-empty
// cell left of `c0` and right of `c1` in each row (within the same frozen / scrolled pane).
let mut outside: Vec<(CellRef, Rect, Look, Option<CfLook>)> = Vec::new();
let same_pane = |col: u32| (col < geo.fc) == (c0 < geo.fc);
for r in r0..=r1 {
if sh.row_height(r) == 0.0 {
continue;
@@ -232,7 +232,7 @@ fn outside_text_cells(q: &mut QuadrantPaint<'_>, merges: &[RangeRef]) -> Vec<(Ce
let left = (c0.saturating_sub(20)..c0).rev().map(|col| CellRef::new(r, col)).find(|&n| !empty_ok(n));
let right = (c1.saturating_add(1)..=c1.saturating_add(20).min(MAX_COLS - 1)).map(|col| CellRef::new(r, col)).find(|&n| !empty_ok(n));
for n in [left, right].into_iter().flatten() {
if geo.same_col_pane(n.col, c0) && sh.col_width(n.col) > 0.0 && in_merge(n).is_none() {
if same_pane(n.col) && sh.col_width(n.col) > 0.0 && in_merge(n).is_none() {
let (lk, cfl) = look(wb, si, sh, n, cf);
outside.push((n, geo.cell_rect(sh, n), lk, cfl));
}
@@ -333,9 +333,7 @@ fn paint_cell(q: &QuadrantPaint<'_>, painted: &(CellRef, Rect, Look, Option<CfLo
let z = geo.z;
let (c0, c1) = q.cols;
let in_merge = |cell| merges.iter().find(|m| m.contains(cell)).copied();
// Text overflows only into empty cells of its own pane: the frozen and scrolled columns move
// apart when scrolling (#2544).
let empty_ok = |cell: CellRef| geo.same_col_pane(cell.col, c.col) && sh.value_ref(cell).is_none_or(Value::is_empty) && in_merge(cell).is_none();
let empty_ok = |cell| sh.value_ref(cell).is_none_or(Value::is_empty) && in_merge(cell).is_none();
if paint_cell_decorations(q, c, *rect, lk, cfl.as_ref()) {
return;
}
@@ -456,12 +454,12 @@ fn paint_cell(q: &QuadrantPaint<'_>, painted: &(CellRef, Rect, Look, Option<CfLo
let mut right = rect.right();
let mut left = rect.left();
if across_end > c.col {
right = geo.x_end(sh, across_end);
right = geo.x(sh, across_end + 1);
}
if matches!(halign, HAlign::Left | HAlign::Center | HAlign::Justify) {
let mut cc = c.col + 1;
while right - rect.left() < w + 2.0 * pad && cc < MAX_COLS && empty_ok(CellRef::new(c.row, cc)) && cc <= c1 + 20 {
right = geo.x_end(sh, cc);
right = geo.x(sh, cc + 1);
cc += 1;
}
}
@@ -480,11 +478,9 @@ fn paint_cell(q: &QuadrantPaint<'_>, painted: &(CellRef, Rect, Look, Option<CfLo
let extra_r = Rect::from_min_max(pos2(rect.right() - 1.0, inner.top()), inner.max);
if extra_l.width() > 0.0 {
p.rect_filled(extra_l, 0.0, t.grid_bg);
paint_active_row(q, Some(extra_l));
}
if extra_r.width() > 0.0 {
p.rect_filled(extra_r, 0.0, t.grid_bg);
paint_active_row(q, Some(extra_r));
}
}
}
+2 -24
View File
@@ -20,7 +20,7 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) {
let available = ui.available_rect_before_wrap();
// A right-to-left sheet has its vertical scrollbar on the left.
let rtl = app.session.active().and_then(|d| d.wb.active()).is_some_and(|s| s.right_to_left);
let bar = vertical_bar_width(app, available);
let bar = 14.0_f32.min(available.width());
let rect = if rtl {
Rect::from_min_max(pos2(available.left() + bar, available.top()), available.max)
} else {
@@ -213,12 +213,6 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) {
update_clipboard_repaint(app, ui, now);
}
/// The vertical scroll bar's width: none when the workbook hides it (File › Options ›
/// Display options for this workbook), and the grid takes its room.
fn vertical_bar_width(app: &SheetApp, available: Rect) -> f32 {
if app.session.active().is_none_or(|d| d.wb.view.show_vertical_scroll) { 14.0_f32.min(available.width()) } else { 0.0 }
}
fn vertical_scroll_track(available: Rect, grid: Rect, rtl: bool) -> (f32, f32) {
if rtl { (available.left(), grid.left()) } else { (grid.right(), available.right()) }
}
@@ -410,19 +404,7 @@ fn paint_panes(input: PanesInput<'_>, cf: &mut CfCache) -> Vec<PaneQuad> {
continue;
}
let p = painter.with_clip_rect(*clip);
let active_row = app.ui.highlight_active_row.then_some(sel.active.row);
paint_quadrant(QuadrantPaint {
painter: &p,
geo,
workbook: wb,
sheet_index: si,
sheet: sh,
rows: *rows,
cols: *cols,
cf,
tokens: t,
active_row,
});
paint_quadrant(QuadrantPaint { painter: &p, geo, workbook: wb, sheet_index: si, sheet: sh, rows: *rows, cols: *cols, cf, tokens: t });
paint_selection(SelectionPaintInput { painter: &p, geo, sheet: sh, selection: sel, outline, tokens: t, app, rows: *rows, cols: *cols });
}
// Freeze lines.
@@ -438,10 +420,6 @@ fn paint_panes(input: PanesInput<'_>, cf: &mut CfCache) -> Vec<PaneQuad> {
/// The worksheet is painted virtually, so it needs its own scrollbar rather than a
/// ScrollArea containing all million rows. Keep the scroll extent stable during a drag.
fn vertical_scrollbar(app: &mut SheetApp, ui: &mut egui::Ui, sh: &Sheet, geo: &Geo, track_x: (f32, f32), limit: f32, t: &Tokens) {
// A workbook that hides its vertical scroll bar leaves it no width.
if track_x.1 <= track_x.0 {
return;
}
let track = Rect::from_min_max(pos2(track_x.0, geo.cells.top() + geo.frozen_h), pos2(track_x.1, geo.cells.bottom()));
if track.height() <= 0.0 || track.width() <= 0.0 {
return;
-10
View File
@@ -335,16 +335,6 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Options|General" => "Obecné",
"Options|Save" => "Uložit",
"Show formula bar" => "Zobrazit řádek vzorců",
"Highlight the active cell's row" => "Zvýraznit řádek aktivní buňky",
"Display options for this workbook" => "Možnosti zobrazení pro tento sešit",
"Show horizontal scroll bar" => "Zobrazit vodorovný posuvník",
"Show vertical scroll bar" => "Zobrazit svislý posuvník",
"Show sheet tabs" => "Zobrazit karty listů",
"Edit Hyperlink…" => "Upravit hypertextový odkaz…",
"Edit Hyperlink" => "Upravit hypertextový odkaz",
"Open Hyperlink" => "Otevřít hypertextový odkaz",
"Remove Hyperlink" => "Odebrat hypertextový odkaz",
"Resize formula bar" => "Změnit velikost řádku vzorců",
"Enable AutoComplete for cell values" => "Povolit automatické dokončování hodnot buněk",
"Save .xlsx workbooks automatically after every change" => "Automaticky ukládat sešity .xlsx po každé změně",
"Workbook calculation" => "Výpočet sešitu",
-10
View File
@@ -350,16 +350,6 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Options|General" => "Allgemein",
"Options|Save" => "Speichern",
"Show formula bar" => "Bearbeitungsleiste anzeigen",
"Highlight the active cell's row" => "Zeile der aktiven Zelle hervorheben",
"Display options for this workbook" => "Optionen für die Anzeige dieser Arbeitsmappe",
"Show horizontal scroll bar" => "Horizontale Bildlaufleiste anzeigen",
"Show vertical scroll bar" => "Vertikale Bildlaufleiste anzeigen",
"Show sheet tabs" => "Blattregisterkarten anzeigen",
"Edit Hyperlink…" => "Link bearbeiten…",
"Edit Hyperlink" => "Link bearbeiten",
"Open Hyperlink" => "Link öffnen",
"Remove Hyperlink" => "Link entfernen",
"Resize formula bar" => "Größe der Bearbeitungsleiste ändern",
"Enable AutoComplete for cell values" => "AutoVervollständigen für Zellwerte aktivieren",
"Save .xlsx workbooks automatically after every change" => ".xlsx-Arbeitsmappen nach jeder Änderung automatisch speichern",
"Workbook calculation" => "Arbeitsmappenberechnung",
-10
View File
@@ -337,16 +337,6 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Options|General" => "General",
"Options|Save" => "Guardar",
"Show formula bar" => "Mostrar barra de fórmulas",
"Highlight the active cell's row" => "Resaltar la fila de la celda activa",
"Display options for this workbook" => "Opciones de presentación para este libro",
"Show horizontal scroll bar" => "Mostrar barra de desplazamiento horizontal",
"Show vertical scroll bar" => "Mostrar barra de desplazamiento vertical",
"Show sheet tabs" => "Mostrar pestañas de hojas",
"Edit Hyperlink…" => "Modificar hipervínculo…",
"Edit Hyperlink" => "Modificar hipervínculo",
"Open Hyperlink" => "Abrir hipervínculo",
"Remove Hyperlink" => "Quitar hipervínculo",
"Resize formula bar" => "Cambiar el tamaño de la barra de fórmulas",
"Enable AutoComplete for cell values" => "Habilitar Autocompletar para valores de celda",
"Save .xlsx workbooks automatically after every change" => "Guardar automáticamente los libros .xlsx tras cada cambio",
"Workbook calculation" => "Cálculo del libro",
-1
View File
@@ -410,7 +410,6 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Options|Save" => "Salvataggio",
"Options|Editing" => "Modifica",
"Show formula bar" => "Mostra barra della formula",
"Highlight the active cell's row" => "Evidenzia la riga della cella attiva",
"Enable AutoComplete for cell values" => "Abilita Completamento automatico per i valori delle celle",
"Save .xlsx workbooks automatically after every change" => "Salva automaticamente le cartelle di lavoro .xlsx dopo ogni modifica",
"Workbook calculation" => "Calcolo della cartella di lavoro",
-10
View File
@@ -426,16 +426,6 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Options|General" => "일반",
"Options|Save" => "저장",
"Show formula bar" => "수식 입력줄 표시",
"Highlight the active cell's row" => "활성 셀의 행 강조 표시",
"Display options for this workbook" => "이 통합 문서의 표시 옵션",
"Show horizontal scroll bar" => "가로 스크롤 막대 표시",
"Show vertical scroll bar" => "세로 스크롤 막대 표시",
"Show sheet tabs" => "시트 탭 표시",
"Edit Hyperlink…" => "하이퍼링크 편집…",
"Edit Hyperlink" => "하이퍼링크 편집",
"Open Hyperlink" => "하이퍼링크 열기",
"Remove Hyperlink" => "하이퍼링크 제거",
"Resize formula bar" => "수식 입력줄 크기 조정",
"Enable AutoComplete for cell values" => "셀 값에 자동 완성 사용",
"Save .xlsx workbooks automatically after every change" => "변경할 때마다 .xlsx 통합 문서를 자동으로 저장",
"Workbook calculation" => "통합 문서 계산",
-10
View File
@@ -416,16 +416,6 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Options|General" => "Geral",
"Options|Save" => "Salvar",
"Show formula bar" => "Mostrar barra de fórmulas",
"Highlight the active cell's row" => "Realçar a linha da célula ativa",
"Display options for this workbook" => "Opções de exibição para esta pasta de trabalho",
"Show horizontal scroll bar" => "Mostrar barra de rolagem horizontal",
"Show vertical scroll bar" => "Mostrar barra de rolagem vertical",
"Show sheet tabs" => "Mostrar guias de planilhas",
"Edit Hyperlink…" => "Editar hiperlink…",
"Edit Hyperlink" => "Editar hiperlink",
"Open Hyperlink" => "Abrir hiperlink",
"Remove Hyperlink" => "Remover hiperlink",
"Resize formula bar" => "Redimensionar a barra de fórmulas",
"Enable AutoComplete for cell values" => "Habilitar AutoConclusão para valores de célula",
"Save .xlsx workbooks automatically after every change" => "Salvar pastas de trabalho .xlsx automaticamente após cada alteração",
"Workbook calculation" => "Cálculo da pasta de trabalho",
-10
View File
@@ -426,16 +426,6 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Options|General" => "Общие",
"Options|Save" => "Сохранение",
"Show formula bar" => "Показывать строку формул",
"Highlight the active cell's row" => "Выделять строку активной ячейки",
"Display options for this workbook" => "Параметры отображения для этой книги",
"Show horizontal scroll bar" => "Показывать горизонтальную полосу прокрутки",
"Show vertical scroll bar" => "Показывать вертикальную полосу прокрутки",
"Show sheet tabs" => "Показывать ярлычки листов",
"Edit Hyperlink…" => "Изменить гиперссылку…",
"Edit Hyperlink" => "Изменить гиперссылку",
"Open Hyperlink" => "Открыть гиперссылку",
"Remove Hyperlink" => "Удалить гиперссылку",
"Resize formula bar" => "Изменить размер строки формул",
"Enable AutoComplete for cell values" => "Включить автозавершение значений ячеек",
"Save .xlsx workbooks automatically after every change" => "Автоматически сохранять книги .xlsx после каждого изменения",
"Workbook calculation" => "Вычисления в книге",
-10
View File
@@ -418,16 +418,6 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Options|General" => "常规",
"Options|Save" => "保存",
"Show formula bar" => "显示编辑栏",
"Highlight the active cell's row" => "突出显示活动单元格所在行",
"Display options for this workbook" => "此工作簿的显示选项",
"Show horizontal scroll bar" => "显示水平滚动条",
"Show vertical scroll bar" => "显示垂直滚动条",
"Show sheet tabs" => "显示工作表标签",
"Edit Hyperlink…" => "编辑超链接…",
"Edit Hyperlink" => "编辑超链接",
"Open Hyperlink" => "打开超链接",
"Remove Hyperlink" => "删除超链接",
"Resize formula bar" => "调整编辑栏大小",
"Enable AutoComplete for cell values" => "为单元格值启用记忆式键入",
"Save .xlsx workbooks automatically after every change" => "每次更改后自动保存 .xlsx 工作簿",
"Workbook calculation" => "工作簿计算",
-2
View File
@@ -115,8 +115,6 @@ fn lookup_number_formats(ctx: LookupContext) -> Option<Shortcut> {
Num7 if ctrl && s => borders("outside"),
Minus if ctrl && s => borders("none"),
Num8 if ctrl && s => run("selection.currentRegion"),
// Ctrl+. (Control on a Mac too): Next Corner of Selection.
Period if ctrl && !s => run("selection.nextCorner"),
Semicolon if ctrl && s => Shortcut::Time,
Quote if ctrl && s => Shortcut::ValueAbove,
Quote if ctrl => Shortcut::FormulaAbove,
@@ -48,13 +48,6 @@ pub fn is_group_level(seq: &str) -> bool {
matches!(lookup(seq), Some(Entry::Continue)) || (lookup(seq).is_none() && has_deeper(seq))
}
/// False under a legacy Excel 2003 menu letter that has no ribbon tab (Alt+I, D, V, T): Excel
/// paints no badges for those, so the ribbon stays as it is while the sequence is typed.
pub fn paints_badges(prefix: &str) -> bool {
let Some(root) = prefix.get(..1) else { return true };
SEQUENCES.iter().any(|(seq, entry)| matches!(entry, Entry::Tab(_)) && seq.starts_with(root))
}
fn matches_target(action: &Action, target: &str) -> bool {
match action {
Action::Command(id) | Action::CommandWith(id, _) | Action::Pending(id) | Action::Control(id) => *id == target,
@@ -221,22 +214,6 @@ mod tests {
assert!(!has_deeper("X"));
}
#[test]
fn legacy_menu_letters_finish_without_badges() {
assert_eq!(lookup("IR"), Some(Entry::Finish(Action::Command("home.insertRows"))));
assert_eq!(lookup("DS"), Some(Entry::Finish(Action::Dialog("sort"))));
assert_eq!(lookup("EAA"), Some(Entry::Finish(Action::Command("edit.clearAll"))));
for root in ["I", "D", "V", "T"] {
assert!(is_group_level(root), "{root} waits for the menu letter");
assert!(!paints_badges(root), "{root} has no tab, so no badges");
}
assert!(paints_badges(""), "tab badges");
assert!(paints_badges("HO"));
assert!(paints_badges("J"), "the Draw tab's two-letter unit");
assert!(paints_badges("E"), "E and O stay aliases of Home");
assert_eq!(sequences_for("home.insertRows"), vec!["HIR", "IR"], "the ribbon sequence stays first");
}
#[test]
fn group_extension_paints_only_continue_levels() {
assert_eq!(group_extension("H", &["dialog:pasteSpecial"]), vec!["HV"]);
@@ -372,48 +372,5 @@ pub const SEQUENCES: &[(&str, Entry)] = &[
("FN", Entry::Finish(Action::Command("file.new"))), // Excel: New page; New page
("FO", Entry::Finish(Action::Command("file.open"))), // Excel: Open page; fires immediately in Excel; deeper aliases dropped as unreachable
("FP", Entry::Finish(Action::Command("file.print"))), // Excel: Print page; containment(Print)
("IR", Entry::Finish(Action::Command("home.insertRows"))), // Excel: Insert > Rows; Legacy Excel 2003 menu path (#941).
("IC", Entry::Finish(Action::Command("home.insertColumns"))), // Excel: Insert > Columns; Legacy Excel 2003 menu path (#941).
("IE", Entry::Finish(Action::Dialog("insertCells"))), // Excel: Insert > Cells; Legacy Excel 2003 menu path (#941).
("IW", Entry::Finish(Action::Command("home.insertSheet"))), // Excel: Insert > Worksheet; Legacy Excel 2003 menu path (#941).
("IF", Entry::Finish(Action::Dialog("insertFunction"))), // Excel: Insert > Function; Legacy Excel 2003 menu path (#941).
("IND", Entry::Finish(Action::Dialog("defineName"))), // Excel: Insert > Name > Define; Legacy Excel 2003 menu path (#941).
("IS", Entry::Finish(Action::Control("insert.symbol"))), // Excel: Insert > Symbol; Legacy Excel 2003 menu path (#941).
("ED", Entry::Finish(Action::Dialog("deleteCells"))), // Excel: Edit > Delete; Legacy Excel 2003 menu path (#941).
("EM", Entry::Finish(Action::Dialog("moveSheet"))), // Excel: Edit > Move or Copy Sheet; Legacy Excel 2003 menu path (#941).
("EL", Entry::Finish(Action::Command("home.deleteSheet"))), // Excel: Edit > Delete Sheet; Legacy Excel 2003 menu path (#941).
("EAA", Entry::Finish(Action::Command("edit.clearAll"))), // Excel: Edit > Clear > All; Legacy Excel 2003 menu path (#941).
("EAC", Entry::Finish(Action::Command("edit.clearContents"))), // Excel: Edit > Clear > Contents; Legacy Excel 2003 menu path (#941).
("EAM", Entry::Finish(Action::Command("edit.clearComments"))), // Excel: Edit > Clear > Comments; Legacy Excel 2003 menu path (#941).
("EF", Entry::Finish(Action::Dialog("find"))), // Excel: Edit > Find; Legacy Excel 2003 menu path (#941).
("EG", Entry::Finish(Action::Dialog("goTo"))), // Excel: Edit > Go To; Legacy Excel 2003 menu path (#941).
("ORA", Entry::Finish(Action::Command("home.autofitRowHeight"))), // Excel: Format > Row > AutoFit; Legacy Excel 2003 menu path (#941).
("ORE", Entry::Finish(Action::Dialog("rowHeight"))), // Excel: Format > Row > Height; Legacy Excel 2003 menu path (#941).
("ORH", Entry::Finish(Action::Command("home.hideRows"))), // Excel: Format > Row > Hide; Legacy Excel 2003 menu path (#941).
("ORU", Entry::Finish(Action::Command("home.unhideRows"))), // Excel: Format > Row > Unhide; Legacy Excel 2003 menu path (#941).
("OCW", Entry::Finish(Action::Dialog("columnWidth"))), // Excel: Format > Column > Width; Legacy Excel 2003 menu path (#941).
("OCH", Entry::Finish(Action::Command("home.hideColumns"))), // Excel: Format > Column > Hide; Legacy Excel 2003 menu path (#941).
("OCU", Entry::Finish(Action::Command("home.unhideColumns"))), // Excel: Format > Column > Unhide; Legacy Excel 2003 menu path (#941).
("OHR", Entry::Finish(Action::Dialog("renameSheet"))), // Excel: Format > Sheet > Rename; Legacy Excel 2003 menu path (#941).
("OHH", Entry::Finish(Action::Command("sheet.hide"))), // Excel: Format > Sheet > Hide; Legacy Excel 2003 menu path (#941).
("OHU", Entry::Finish(Action::Command("sheet.unhide"))), // Excel: Format > Sheet > Unhide; Legacy Excel 2003 menu path (#941).
("OE", Entry::Finish(Action::Command("home.formatCells"))), // Excel: Format > Cells; Legacy Excel 2003 menu path (#941).
("OD", Entry::Finish(Action::Command("home.conditionalFormat"))), // Excel: Format > Conditional Formatting; Legacy Excel 2003 menu path (#941).
("DFF", Entry::Finish(Action::Command("data.filter"))), // Excel: Data > Filter > AutoFilter; Legacy Excel 2003 menu path (#941).
("DS", Entry::Finish(Action::Dialog("sort"))), // Excel: Data > Sort; Legacy Excel 2003 menu path (#941).
("DGG", Entry::Finish(Action::Command("data.group"))), // Excel: Data > Group and Outline > Group; Legacy Excel 2003 menu path (#941).
("DGU", Entry::Finish(Action::Command("data.ungroup"))), // Excel: Data > Group and Outline > Ungroup; Legacy Excel 2003 menu path (#941).
("DGS", Entry::Finish(Action::Command("data.showDetail"))), // Excel: Data > Group and Outline > Show Detail; Legacy Excel 2003 menu path (#941).
("DGH", Entry::Finish(Action::Command("data.hideDetail"))), // Excel: Data > Group and Outline > Hide Detail; Legacy Excel 2003 menu path (#941).
("DL", Entry::Finish(Action::Command("data.validation"))), // Excel: Data > Validation; Legacy Excel 2003 menu path (#941).
("DT", Entry::Finish(Action::Command("data.dataTable"))), // Excel: Data > Table (what-if data table); Legacy Excel 2003 menu path (#941).
("VN", Entry::Finish(Action::Command("view.normal"))), // Excel: View > Normal; Legacy Excel 2003 menu path (#941).
("VP", Entry::Finish(Action::Command("view.pageBreakPreview"))), // Excel: View > Page Break Preview; Legacy Excel 2003 menu path (#941).
("VZ", Entry::Finish(Action::Dialog("zoom"))), // Excel: View > Zoom; Legacy Excel 2003 menu path (#941).
("TO", Entry::Finish(Action::Dialog("options"))), // Excel: Tools > Options; Legacy Excel 2003 menu path (#941).
("TUA", Entry::Finish(Action::Command("formulas.removeArrows"))), // Excel: Tools > Formula Auditing > Remove All Arrows; Legacy Excel 2003 menu path (#941).
("TUD", Entry::Finish(Action::Command("formulas.traceDependents"))), // Excel: Tools > Formula Auditing > Trace Dependents; Legacy Excel 2003 menu path (#941).
("TUT", Entry::Finish(Action::Command("formulas.tracePrecedents"))), // Excel: Tools > Formula Auditing > Trace Precedents; Legacy Excel 2003 menu path (#941).
("FU", Entry::Finish(Action::Dialog("pageSetup"))), // Excel: File > Page Setup; Legacy Excel 2003 menu path (#941).
("Q", Entry::Finish(Action::Pending("ui.search"))), // pending; Excel: Search / Tell Me; Alt+Q search box does not exist yet (issue #49)
("Q", Entry::Finish(Action::Pending("ui.search"))), // pending; Excel: Search / Tell Me; Alt+Q search box does not exist yet (issue #49)
];
-301
View File
@@ -2582,307 +2582,6 @@
"excel": "Print page",
"note": "containment(Print)"
},
{
"seq": "IR",
"kind": "command",
"target": "home.insertRows",
"excel": "Insert > Rows",
"note": "Legacy Excel 2003 menu path (#941)."
},
{
"seq": "IC",
"kind": "command",
"target": "home.insertColumns",
"excel": "Insert > Columns",
"note": "Legacy Excel 2003 menu path (#941)."
},
{
"seq": "IE",
"kind": "dialog",
"target": "insertCells",
"excel": "Insert > Cells",
"note": "Legacy Excel 2003 menu path (#941)."
},
{
"seq": "IW",
"kind": "command",
"target": "home.insertSheet",
"excel": "Insert > Worksheet",
"note": "Legacy Excel 2003 menu path (#941)."
},
{
"seq": "IF",
"kind": "dialog",
"target": "insertFunction",
"excel": "Insert > Function",
"note": "Legacy Excel 2003 menu path (#941)."
},
{
"seq": "IND",
"kind": "dialog",
"target": "defineName",
"excel": "Insert > Name > Define",
"note": "Legacy Excel 2003 menu path (#941)."
},
{
"seq": "IS",
"kind": "control",
"target": "insert.symbol",
"excel": "Insert > Symbol",
"note": "Legacy Excel 2003 menu path (#941)."
},
{
"seq": "ED",
"kind": "dialog",
"target": "deleteCells",
"excel": "Edit > Delete",
"note": "Legacy Excel 2003 menu path (#941)."
},
{
"seq": "EM",
"kind": "dialog",
"target": "moveSheet",
"excel": "Edit > Move or Copy Sheet",
"note": "Legacy Excel 2003 menu path (#941)."
},
{
"seq": "EL",
"kind": "command",
"target": "home.deleteSheet",
"excel": "Edit > Delete Sheet",
"note": "Legacy Excel 2003 menu path (#941)."
},
{
"seq": "EAA",
"kind": "command",
"target": "edit.clearAll",
"excel": "Edit > Clear > All",
"note": "Legacy Excel 2003 menu path (#941)."
},
{
"seq": "EAC",
"kind": "command",
"target": "edit.clearContents",
"excel": "Edit > Clear > Contents",
"note": "Legacy Excel 2003 menu path (#941)."
},
{
"seq": "EAM",
"kind": "command",
"target": "edit.clearComments",
"excel": "Edit > Clear > Comments",
"note": "Legacy Excel 2003 menu path (#941)."
},
{
"seq": "EF",
"kind": "dialog",
"target": "find",
"excel": "Edit > Find",
"note": "Legacy Excel 2003 menu path (#941)."
},
{
"seq": "EG",
"kind": "dialog",
"target": "goTo",
"excel": "Edit > Go To",
"note": "Legacy Excel 2003 menu path (#941)."
},
{
"seq": "ORA",
"kind": "command",
"target": "home.autofitRowHeight",
"excel": "Format > Row > AutoFit",
"note": "Legacy Excel 2003 menu path (#941)."
},
{
"seq": "ORE",
"kind": "dialog",
"target": "rowHeight",
"excel": "Format > Row > Height",
"note": "Legacy Excel 2003 menu path (#941)."
},
{
"seq": "ORH",
"kind": "command",
"target": "home.hideRows",
"excel": "Format > Row > Hide",
"note": "Legacy Excel 2003 menu path (#941)."
},
{
"seq": "ORU",
"kind": "command",
"target": "home.unhideRows",
"excel": "Format > Row > Unhide",
"note": "Legacy Excel 2003 menu path (#941)."
},
{
"seq": "OCW",
"kind": "dialog",
"target": "columnWidth",
"excel": "Format > Column > Width",
"note": "Legacy Excel 2003 menu path (#941)."
},
{
"seq": "OCH",
"kind": "command",
"target": "home.hideColumns",
"excel": "Format > Column > Hide",
"note": "Legacy Excel 2003 menu path (#941)."
},
{
"seq": "OCU",
"kind": "command",
"target": "home.unhideColumns",
"excel": "Format > Column > Unhide",
"note": "Legacy Excel 2003 menu path (#941)."
},
{
"seq": "OHR",
"kind": "dialog",
"target": "renameSheet",
"excel": "Format > Sheet > Rename",
"note": "Legacy Excel 2003 menu path (#941)."
},
{
"seq": "OHH",
"kind": "command",
"target": "sheet.hide",
"excel": "Format > Sheet > Hide",
"note": "Legacy Excel 2003 menu path (#941)."
},
{
"seq": "OHU",
"kind": "command",
"target": "sheet.unhide",
"excel": "Format > Sheet > Unhide",
"note": "Legacy Excel 2003 menu path (#941)."
},
{
"seq": "OE",
"kind": "command",
"target": "home.formatCells",
"excel": "Format > Cells",
"note": "Legacy Excel 2003 menu path (#941)."
},
{
"seq": "OD",
"kind": "command",
"target": "home.conditionalFormat",
"excel": "Format > Conditional Formatting",
"note": "Legacy Excel 2003 menu path (#941)."
},
{
"seq": "DFF",
"kind": "command",
"target": "data.filter",
"excel": "Data > Filter > AutoFilter",
"note": "Legacy Excel 2003 menu path (#941)."
},
{
"seq": "DS",
"kind": "dialog",
"target": "sort",
"excel": "Data > Sort",
"note": "Legacy Excel 2003 menu path (#941)."
},
{
"seq": "DGG",
"kind": "command",
"target": "data.group",
"excel": "Data > Group and Outline > Group",
"note": "Legacy Excel 2003 menu path (#941)."
},
{
"seq": "DGU",
"kind": "command",
"target": "data.ungroup",
"excel": "Data > Group and Outline > Ungroup",
"note": "Legacy Excel 2003 menu path (#941)."
},
{
"seq": "DGS",
"kind": "command",
"target": "data.showDetail",
"excel": "Data > Group and Outline > Show Detail",
"note": "Legacy Excel 2003 menu path (#941)."
},
{
"seq": "DGH",
"kind": "command",
"target": "data.hideDetail",
"excel": "Data > Group and Outline > Hide Detail",
"note": "Legacy Excel 2003 menu path (#941)."
},
{
"seq": "DL",
"kind": "command",
"target": "data.validation",
"excel": "Data > Validation",
"note": "Legacy Excel 2003 menu path (#941)."
},
{
"seq": "DT",
"kind": "command",
"target": "data.dataTable",
"excel": "Data > Table (what-if data table)",
"note": "Legacy Excel 2003 menu path (#941)."
},
{
"seq": "VN",
"kind": "command",
"target": "view.normal",
"excel": "View > Normal",
"note": "Legacy Excel 2003 menu path (#941)."
},
{
"seq": "VP",
"kind": "command",
"target": "view.pageBreakPreview",
"excel": "View > Page Break Preview",
"note": "Legacy Excel 2003 menu path (#941)."
},
{
"seq": "VZ",
"kind": "dialog",
"target": "zoom",
"excel": "View > Zoom",
"note": "Legacy Excel 2003 menu path (#941)."
},
{
"seq": "TO",
"kind": "dialog",
"target": "options",
"excel": "Tools > Options",
"note": "Legacy Excel 2003 menu path (#941)."
},
{
"seq": "TUA",
"kind": "command",
"target": "formulas.removeArrows",
"excel": "Tools > Formula Auditing > Remove All Arrows",
"note": "Legacy Excel 2003 menu path (#941)."
},
{
"seq": "TUD",
"kind": "command",
"target": "formulas.traceDependents",
"excel": "Tools > Formula Auditing > Trace Dependents",
"note": "Legacy Excel 2003 menu path (#941)."
},
{
"seq": "TUT",
"kind": "command",
"target": "formulas.tracePrecedents",
"excel": "Tools > Formula Auditing > Trace Precedents",
"note": "Legacy Excel 2003 menu path (#941)."
},
{
"seq": "FU",
"kind": "dialog",
"target": "pageSetup",
"excel": "File > Page Setup",
"note": "Legacy Excel 2003 menu path (#941)."
},
{
"seq": "Q",
"kind": "command",
-13
View File
@@ -88,18 +88,12 @@ pub struct UiState {
pub ribbon_tab: String,
pub ribbon_collapsed: bool,
pub formula_bar_expanded: bool,
/// Lines the expanded formula bar shows: the height its bottom edge was last dragged to.
/// Expanding it again (the chevron, Ctrl+Shift+U) returns to that height.
pub formula_bar_lines: usize,
pub status_bar: bool,
/// Motion in the UI: the selection outline glides to a new selection (View ▸ Animations).
pub animations: bool,
/// Enable AutoComplete for cell values (File ▸ Options ▸ Editing): typed text is completed
/// from the one matching entry in the same column.
pub autocomplete: bool,
/// Tint the active cell's whole row (File ▸ Options ▸ General), on every sheet and workbook, so
/// the eye keeps its place across a wide table. A preference: nothing goes into the file.
pub highlight_active_row: bool,
/// AutoSave (the title-bar switch): save saved-to-file .xlsx workbooks shortly after every
/// change. A preference like the rest of this struct — it stays on across restarts (#1901).
pub autosave: bool,
@@ -108,10 +102,6 @@ pub struct UiState {
/// [`keytips::Action::Control`], consumed (and cleared) by the control's render site.
#[serde(skip)]
pub keytip_invoke: Option<&'static str>,
/// A keytip control request for the next frame: its tab was switched to this frame, so its
/// control is drawn only then.
#[serde(skip)]
pub keytip_invoke_next: Option<&'static str>,
/// Interface language. English by default (so tests and headless renders never depend on the
/// host's locale); the desktop app starts from [`i18n::Language::system`] when `ui.json` has
/// no usable language (see [`i18n::saved_language`]). An unknown saved value reads as English
@@ -147,15 +137,12 @@ impl Default for UiState {
ribbon_tab: "Home".into(),
ribbon_collapsed: false,
formula_bar_expanded: false,
formula_bar_lines: formula_bar::MAX_LINES,
status_bar: true,
animations: true,
autocomplete: true,
highlight_active_row: false,
autosave: false,
recent: vec![],
keytip_invoke: None,
keytip_invoke_next: None,
language: i18n::Language::En,
interface_scale: 100,
}
+4 -10
View File
@@ -459,7 +459,10 @@ pub fn parse_refs(text: &str) -> Vec<(Option<String>, RangeRef)> {
text.split(',')
.filter_map(|part| {
let part = part.trim();
let (sheet, body) = gridcraft_engine::core::split_sheet(part);
let (sheet, body) = match part.rsplit_once('!') {
Some((s, b)) => (Some(s.trim().trim_matches('\'').replace("''", "'")), b),
None => (None, part),
};
RangeRef::parse(&body.replace('$', "")).map(|r| (sheet, r))
})
.take(500)
@@ -2396,15 +2399,6 @@ pub fn paint_overlay(p: &egui::Painter, geo: &crate::grid::Geo, sh: &gridcraft_e
mod tests {
use super::*;
#[test]
fn example_references_unquote_sheet_names_as_formulas_do() {
// #2012: one shared reading of `'Bob''s'!A1` and `'a!b'!A1`.
let refs = parse_refs("'Bob''s'!$A$1:$B$2, 'a!b'!C3, D4");
let sheets: Vec<Option<String>> = refs.iter().map(|(s, _)| s.clone()).collect();
assert_eq!(sheets, [Some("Bob's".to_string()), Some("a!b".to_string()), None]);
assert_eq!(refs.iter().map(|(_, r)| r.a1()).collect::<Vec<_>>(), ["A1:B2", "C3", "D4"]);
}
#[test]
fn an_edited_input_keeps_a_shape_a_macro_may_declare() {
let range = NamedInput { rows: [2, 10], cols: [3, 3], header: true, col_types: vec![ColType::Number; 3], ..NamedInput::default() };
+1 -17
View File
@@ -162,26 +162,10 @@ pub(crate) fn paint_pages(input: PagePaintInput<'_>, cf: &mut CfCache) -> Vec<gr
}
painted += 1;
let pc = p.with_clip_rect(clip);
let active_row = app.ui.highlight_active_row.then_some(sel.active.row);
grid::paint_quadrant(grid::QuadrantPaint {
painter: &pc,
geo,
workbook: wb,
sheet_index: si,
sheet: sh,
rows,
cols,
cf,
tokens: t,
active_row,
});
grid::paint_quadrant(grid::QuadrantPaint { painter: &pc, geo, workbook: wb, sheet_index: si, sheet: sh, rows, cols, cf, tokens: t });
if !pg.is_data_page(i, j) {
// "Click to add data" pages: faded until something is typed there.
pc.rect_filled(clip, 0.0, t.grid_bg.gamma_multiply(0.75));
// The fade would all but hide the active row's band: it goes on again.
if let Some(band) = active_row.and_then(|row| grid::active_row_band(geo, sh, row, rows, cols)) {
pc.rect_filled(band, 0.0, t.active_row);
}
let size = (14.0 * geo.z / DISPLAY_SCALE).clamp(8.0, 22.0);
pc.text(clip.center(), Align2::CENTER_CENTER, tr(app, "Click to add data"), theme::ui_font(size), Color32::from_gray(150));
}
+1 -9
View File
@@ -27,15 +27,7 @@ pub(super) fn act(app: &mut SheetApp, id: &str, params: serde_json::Value) {
/// Keytips are painted, not focusable widgets: the grid retains keyboard focus.
pub(super) fn keytip(app: &SheetApp, ui: &Ui, sequence: &str, at: egui::Pos2) {
let Some(rest) = app
.keytips
.prefix()
.filter(|prefix| keytip_table::paints_badges(prefix))
.and_then(|prefix| sequence.strip_prefix(prefix))
.filter(|s| !s.is_empty())
else {
return;
};
let Some(rest) = app.keytips.prefix().and_then(|prefix| sequence.strip_prefix(prefix)).filter(|s| !s.is_empty()) else { return };
let t = Tokens::get(ui.ctx());
let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::unique("ribbon_keytips"))).with_clip_rect(ui.clip_rect());
let text = painter.layout_no_wrap(rest.into(), theme::ui_bold(10.5), t.text);
+1 -3
View File
@@ -213,9 +213,7 @@ pub(super) fn insert(app: &mut SheetApp, ui: &mut Ui) {
});
let sym = big_button(ui, Icon::Symbol, "Symbol", "Symbol", true);
control_keytip(app, ui, &sym, "insert.symbol");
// Opened by its key tip too (Alt+I, S): the gallery picks the symbol, nothing is inserted before.
let forced = invoke(app, "insert.symbol");
egui::Popup::menu(&sym).open_memory(popup_command(forced, sym.clicked())).show(|ui| {
egui::Popup::menu(&sym).show(|ui| {
ui.set_width(260.0);
ui.horizontal_wrapped(|ui| {
for ch in [
-3
View File
@@ -32,9 +32,6 @@ pub struct Services {
/// gets it from egui's `Event::Paste`). `None` from the host, or no service (web), pastes
/// GridCraft's own clipboard.
pub paste_text: Option<Box<dyn FnMut() -> Option<String>>>,
/// Read the picture on the system clipboard for the ribbon and menu Paste commands when it
/// holds no text (Cmd/Ctrl+V gets it from egui's `Event::PasteImage`).
pub paste_image: Option<Box<dyn FnMut() -> Option<image::RgbaImage>>>,
/// Web: deliver a file to the user (name, bytes).
pub download: Option<Download>,
/// Web: start an asynchronous file pick; the bytes arrive later in `inbox`.
+152 -176
View File
@@ -100,40 +100,29 @@ fn tab_split_handle(app: &mut SheetApp, ui: &mut Ui, split: Rect, bar: (f32, f32
/// Draws the worksheet tab strip: navigation, scroll/reveal, rename/drag/move with optional copy, context menu, new-sheet button, split handle and grid scrollbar.
pub fn sheet_tabs(app: &mut SheetApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
// Only the controls drawn this frame have a rectangle: a hidden one keeps no stale geometry.
app.grid.hscroll = None;
app.grid.tab_split = None;
// File › Options › Advanced › Display options for this workbook (#3101): with neither the
// sheet tabs nor the horizontal scroll bar, the bar isn't there at all.
let view = app.session.active().map(|d| d.wb.view).unwrap_or_default();
if !view.show_sheet_tabs && !view.show_horizontal_scroll {
return;
}
egui::Panel::bottom("sheet_tabs").exact_size(32.0).frame(egui::Frame::NONE.fill(t.tab_bar)).show(ui, |ui| {
let rect = ui.max_rect();
ui.painter().line_segment([rect.left_top(), rect.right_top()], Stroke::new(1.0, t.ribbon_border));
ui.horizontal_centered(|ui| {
ui.spacing_mut().item_spacing.x = 0.0;
ui.add_space(8.0);
if view.show_sheet_tabs {
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, 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, app.ui.language.tr("All sheets"), vec2(24.0, 24.0));
all.widget_info(|| egui::WidgetInfo::labeled(egui::Role::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();
if let crate::ribbon::ComboResult::Picked(i) = crate::ribbon::editable_combo(ui, &all, "sheet_navigator", &active, &rows, false)
&& let Ok(i) = i.parse::<usize>()
{
activate_sheet(app, i);
}
ui.add_space(8.0);
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, 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, app.ui.language.tr("All sheets"), vec2(24.0, 24.0));
all.widget_info(|| egui::WidgetInfo::labeled(egui::Role::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();
if let crate::ribbon::ComboResult::Picked(i) = crate::ribbon::editable_combo(ui, &all, "sheet_navigator", &active, &rows, false)
&& let Ok(i) = i.parse::<usize>()
{
activate_sheet(app, i);
}
ui.add_space(8.0);
let Some(d) = app.session.active() else { return };
let (bar_left, bar_width) = (ui.cursor().left(), ui.available_width());
let uid = d.uid;
@@ -154,8 +143,6 @@ pub fn sheet_tabs(app: &mut SheetApp, ui: &mut Ui) {
// After the handle at the scrollbar's left edge is dragged, the tabs and New sheet
// take the share it was dragged to.
let tabs_width = match share {
// Without the scroll bar the tabs have the whole bar.
_ if !view.show_horizontal_scroll => (bar_width - NEW_SHEET_WIDTH).max(0.0),
Some(share) => (bar_width * share - NEW_SHEET_WIDTH).max(0.0),
None => (bar_width * 0.6).max(100.0),
};
@@ -168,159 +155,154 @@ pub fn sheet_tabs(app: &mut SheetApp, ui: &mut Ui) {
data.insert_temp(reveal_id, reveal_key);
changed
});
if view.show_sheet_tabs {
ui.allocate_ui_with_layout(vec2(tabs_width, 30.0), egui::Layout::left_to_right(egui::Align::Center), |ui| {
// Translate vertical wheel movement into horizontal
// worksheet-tab scrolling.
let wheel_scroll = if ui.rect_contains_pointer(ui.max_rect()) {
ui.input(|i| {
let d = i.smooth_scroll_delta;
if d.x.abs() > d.y.abs() { d.x } else { d.y }
})
} else {
0.0
};
ui.allocate_ui_with_layout(vec2(tabs_width, 30.0), egui::Layout::left_to_right(egui::Align::Center), |ui| {
// Translate vertical wheel movement into horizontal
// worksheet-tab scrolling.
let wheel_scroll = if ui.rect_contains_pointer(ui.max_rect()) {
ui.input(|i| {
let d = i.smooth_scroll_delta;
if d.x.abs() > d.y.abs() { d.x } else { d.y }
})
} else {
0.0
};
egui::ScrollArea::horizontal()
.id_salt("worksheet_tabs_horizontal")
.auto_shrink([true, false])
.min_scrolled_width(0.0)
.scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysHidden)
.show(ui, |ui| {
if wheel_scroll.abs() > 0.0 {
ui.scroll_with_delta(vec2(wheel_scroll, 0.0));
}
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 0.0;
for (i, name, color, active) in sheets {
let renaming = app.grid.renaming_tab == Some(i);
let font = if active { theme::ui_bold(13.0) } else { theme::ui_font(13.0) };
let tw = ui.painter().layout_no_wrap(name.clone(), font.clone(), t.text).size().x;
let w = (tw + 32.0).max(72.0);
let (r, resp) = ui.allocate_exact_size(vec2(w, 30.0), Sense::click_and_drag());
if active && reveal_active {
resp.scroll_to_me(None);
egui::ScrollArea::horizontal()
.id_salt("worksheet_tabs_horizontal")
.auto_shrink([true, false])
.min_scrolled_width(0.0)
.scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysHidden)
.show(ui, |ui| {
if wheel_scroll.abs() > 0.0 {
ui.scroll_with_delta(vec2(wheel_scroll, 0.0));
}
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 0.0;
for (i, name, color, active) in sheets {
let renaming = app.grid.renaming_tab == Some(i);
let font = if active { theme::ui_bold(13.0) } else { theme::ui_font(13.0) };
let tw = ui.painter().layout_no_wrap(name.clone(), font.clone(), t.text).size().x;
let w = (tw + 32.0).max(72.0);
let (r, resp) = ui.allocate_exact_size(vec2(w, 30.0), Sense::click_and_drag());
if active && reveal_active {
resp.scroll_to_me(None);
}
let r = r.shrink2(vec2(0.0, 1.0));
if active {
ui.painter().rect_filled(Rect::from_min_max(pos2(r.left(), r.top() - 2.0), r.max), 4.0, t.tab_active);
ui.painter().rect_stroke(
Rect::from_min_max(pos2(r.left(), r.top() - 2.0), r.max),
4.0,
Stroke::new(1.0, t.ribbon_border),
StrokeKind::Inside,
);
} else if resp.hovered() {
ui.painter().rect_filled(r, 4.0, t.hover);
}
if let Some(c) = color {
let band = Rect::from_min_size(pos2(r.left() + 6.0, r.bottom() - 4.0), vec2(r.width() - 12.0, 3.0));
ui.painter().rect_filled(band, 1.0, theme::color32(c));
} else if active {
let band = Rect::from_center_size(pos2(r.center().x, r.bottom() - 3.0), vec2(tw.min(48.0), 2.5));
ui.painter().rect_filled(band, 1.0, t.accent);
}
if renaming {
let mut text = app.grid.rename_text.clone();
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(r.shrink2(vec2(6.0, 5.0))));
let ed = child
.add(egui::TextEdit::singleline(&mut text).font(theme::ui_font(13.0)).desired_width(r.width() - 12.0))
.accessible_name(app.ui.language.tr("Sheet Name"));
app.grid.rename_text = text.clone();
if rename_needs_focus(ed.has_focus(), ed.lost_focus()) {
ed.request_focus();
}
let r = r.shrink2(vec2(0.0, 1.0));
if active {
ui.painter().rect_filled(Rect::from_min_max(pos2(r.left(), r.top() - 2.0), r.max), 4.0, t.tab_active);
ui.painter().rect_stroke(
Rect::from_min_max(pos2(r.left(), r.top() - 2.0), r.max),
4.0,
Stroke::new(1.0, t.ribbon_border),
StrokeKind::Inside,
);
} else if resp.hovered() {
ui.painter().rect_filled(r, 4.0, t.hover);
}
if let Some(c) = color {
let band = Rect::from_min_size(pos2(r.left() + 6.0, r.bottom() - 4.0), vec2(r.width() - 12.0, 3.0));
ui.painter().rect_filled(band, 1.0, theme::color32(c));
} else if active {
let band = Rect::from_center_size(pos2(r.center().x, r.bottom() - 3.0), vec2(tw.min(48.0), 2.5));
ui.painter().rect_filled(band, 1.0, t.accent);
}
if renaming {
let mut text = app.grid.rename_text.clone();
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(r.shrink2(vec2(6.0, 5.0))));
let ed = child
.add(egui::TextEdit::singleline(&mut text).font(theme::ui_font(13.0)).desired_width(r.width() - 12.0))
.accessible_name(app.ui.language.tr("Sheet Name"));
app.grid.rename_text = text.clone();
if rename_needs_focus(ed.has_focus(), ed.lost_focus()) {
ed.request_focus();
if ed.lost_focus() {
if !ui.input(|i| i.key_pressed(egui::Key::Escape)) && text != name {
app.run_or_alert("sheet.rename", json!({"sheet": i, "name": text}));
}
if ed.lost_focus() {
if !ui.input(|i| i.key_pressed(egui::Key::Escape)) && text != name {
app.run_or_alert("sheet.rename", json!({"sheet": i, "name": text}));
}
app.grid.renaming_tab = None;
}
} else {
ui.painter().text(r.center(), Align2::CENTER_CENTER, &name, font, if active { t.accent } else { t.text });
app.grid.renaming_tab = None;
}
// 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);
} else {
ui.painter().text(r.center(), Align2::CENTER_CENTER, &name, font, if active { t.accent } else { t.text });
}
// 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() {
app.grid.renaming_tab = Some(i);
app.grid.rename_text = name.clone();
}
// Drag to reorder; with the copy modifier held, drop a copy instead.
let copy = drop_copies_sheet(ui.input(|inp| inp.modifiers), ui.ctx().os() == egui::os::OperatingSystem::Mac);
if resp.dragged() && copy {
ui.ctx().set_cursor_icon(egui::CursorIcon::Copy);
}
if resp.drag_stopped()
&& let Some(p) = ui.input(|inp| inp.pointer.interact_pos())
{
let dx = p.x - r.center().x;
let steps = (dx / w).round() as i64;
if let Some(params) = tab_drop(i, steps, copy) {
app.run_or_alert("sheet.move", params);
}
if resp.double_clicked() {
}
resp.context_menu(|ui| {
ui.set_min_width(180.0);
if ui.button(app.ui.language.tr("Insert Sheet")).clicked() {
app.run_or_alert("home.insertSheet", json!({"before": i}));
}
if ui.button(app.ui.language.tr("Delete")).clicked() {
app.run_or_alert("home.deleteSheet", json!({"sheet": i}));
}
if ui.button(app.ui.language.tr("Rename")).clicked() {
app.grid.renaming_tab = Some(i);
app.grid.rename_text = name.clone();
}
// Drag to reorder; with the copy modifier held, drop a copy instead.
let copy = drop_copies_sheet(ui.input(|inp| inp.modifiers), ui.ctx().os() == egui::os::OperatingSystem::Mac);
if resp.dragged() && copy {
ui.ctx().set_cursor_icon(egui::CursorIcon::Copy);
if ui.button(app.ui.language.tr("Move or Copy…")).clicked() {
app.open_dialog("moveSheet", json!({"sheet": i}));
}
if resp.drag_stopped()
&& let Some(p) = ui.input(|inp| inp.pointer.interact_pos())
{
let dx = p.x - r.center().x;
let steps = (dx / w).round() as i64;
if let Some(params) = tab_drop(i, steps, copy) {
app.run_or_alert("sheet.move", params);
}
if ui.button(app.ui.language.tr("Duplicate")).clicked() {
app.run_or_alert("sheet.move", json!({"sheet": i, "to": i + 1, "copy": true}));
}
resp.context_menu(|ui| {
ui.set_min_width(180.0);
if ui.button(app.ui.language.tr("Insert Sheet")).clicked() {
app.run_or_alert("home.insertSheet", json!({"before": i}));
}
if ui.button(app.ui.language.tr("Delete")).clicked() {
app.run_or_alert("home.deleteSheet", json!({"sheet": i}));
}
if ui.button(app.ui.language.tr("Rename")).clicked() {
app.grid.renaming_tab = Some(i);
app.grid.rename_text = name.clone();
}
if ui.button(app.ui.language.tr("Move or Copy…")).clicked() {
app.open_dialog("moveSheet", json!({"sheet": i}));
}
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(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, app.ui.language.tr("No Color")) {
app.run_or_alert("sheet.tabColor", json!({"sheet": i, "color": c}));
ui.close();
}
});
ui.separator();
if ui.button(app.ui.language.tr("Hide")).clicked() {
app.run_or_alert("sheet.hide", json!({"sheet": i}));
}
if ui.button(app.ui.language.tr("Unhide…")).clicked() {
app.run_or_alert("sheet.unhide", json!({}));
}
ui.separator();
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(app.ui.language.tr("Select All Sheets")).clicked() {
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, app.ui.language.tr("No Color")) {
app.run_or_alert("sheet.tabColor", json!({"sheet": i, "color": c}));
ui.close();
}
});
}
});
ui.separator();
if ui.button(app.ui.language.tr("Hide")).clicked() {
app.run_or_alert("sheet.hide", json!({"sheet": i}));
}
if ui.button(app.ui.language.tr("Unhide…")).clicked() {
app.run_or_alert("sheet.unhide", json!({}));
}
ui.separator();
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(app.ui.language.tr("Select All Sheets")).clicked() {
ui.close();
}
});
}
});
});
ui.add_space(6.0);
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!({}));
}
}
if !view.show_horizontal_scroll {
return;
});
});
ui.add_space(6.0);
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.
let avail = ui.available_rect_before_wrap();
@@ -330,14 +312,8 @@ pub fn sheet_tabs(app: &mut SheetApp, ui: &mut Ui) {
let split_left =
share.map_or(avail.left(), |share| (bar_left + bar_width * share).min(right - SPLIT_WIDTH - MIN_TRACK).max(avail.left()));
let split = Rect::from_min_max(pos2(split_left, avail.top()), pos2(split_left + SPLIT_WIDTH, avail.bottom()));
// Without sheet tabs there is nothing to split: the scroll bar takes the bar.
let track_left = if view.show_sheet_tabs {
tab_split_handle(app, ui, split, (bar_left, bar_width), right, uid, &t);
split.right()
} else {
avail.left()
};
let track = Rect::from_min_max(pos2(track_left, avail.center().y - 7.0), pos2(right, avail.center().y + 7.0));
tab_split_handle(app, ui, split, (bar_left, bar_width), right, uid, &t);
let track = Rect::from_min_max(pos2(split.right(), avail.center().y - 7.0), pos2(right, avail.center().y + 7.0));
crate::grid::horizontal_scrollbar(app, ui, track, &t);
});
});
-3
View File
@@ -63,8 +63,6 @@ pub struct Tokens {
pub header_sel_text: Color32,
pub header_all_bg: Color32,
pub sel_fill: Color32,
/// The tint over the active cell's row (File ▸ Options ▸ General).
pub active_row: Color32,
pub sel_border: Color32,
pub input_bg: Color32,
pub input_border: Color32,
@@ -219,7 +217,6 @@ impl Tokens {
header_sel_text: pick(dark, Color32::from_rgb(0x0B, 0x5E, 0x31), Color32::from_rgb(0x6F, 0xD3, 0x9A)),
header_all_bg: pick(dark, Color32::from_rgb(0xC8, 0xE6, 0xD3), Color32::from_rgb(0x2E, 0x55, 0x3E)),
sel_fill: pick(dark, Color32::from_rgba_unmultiplied(0x10, 0x7C, 0x41, 0x1C), Color32::from_rgba_unmultiplied(0x10, 0x7C, 0x41, 0x24)),
active_row: pick(dark, Color32::from_rgba_unmultiplied(0x10, 0x7C, 0x41, 0x2C), Color32::from_rgba_unmultiplied(0x6F, 0xD3, 0x9A, 0x30)),
sel_border: Color32::from_rgb(0x10, 0x7C, 0x41),
input_bg: pick(dark, Color32::WHITE, Color32::from_rgb(0x1F, 0x1F, 0x1F)),
input_border: pick(dark, Color32::from_rgb(0xD1, 0xD1, 0xD1), Color32::from_rgb(0x50, 0x50, 0x50)),
+1 -18
View File
@@ -31,8 +31,6 @@ pub struct WindowUi {
ribbon_tab: String,
/// The status bar's Ready/Enter/Edit, which follows the window's cell draft.
mode: gridcraft_engine::Mode,
/// A key tip's control request waiting for the frame that draws this window's ribbon.
keytip_invoke_next: Option<&'static str>,
}
impl WindowUi {
@@ -72,7 +70,6 @@ impl SheetApp {
open_switch_windows,
ribbon_tab,
mode,
keytip_invoke_next,
} = w;
std::mem::swap(&mut self.editor, editor);
std::mem::swap(&mut self.text_box_editor, text_box_editor);
@@ -89,7 +86,6 @@ impl SheetApp {
std::mem::swap(&mut self.open_switch_windows, open_switch_windows);
std::mem::swap(&mut self.ui.ribbon_tab, ribbon_tab);
std::mem::swap(&mut self.session.mode, mode);
std::mem::swap(&mut self.ui.keytip_invoke_next, keytip_invoke_next);
}
/// The window whose interface state is in the per-window fields.
@@ -102,14 +98,6 @@ impl SheetApp {
/// in (a window never seen before starts fresh). A closed window's state is dropped.
pub fn follow_active_window(&mut self) {
let active = self.session.active_window();
// A window hidden while parked (from another window's View › Hide) isn't drawn either:
// its waiting key tip request goes, as it does for a window hidden as it is left.
let session = &self.session;
for (id, parked) in &mut self.parked {
if session.window(*id).is_some_and(|w| w.hidden) {
parked.keytip_invoke_next = None;
}
}
if active == self.ui_window {
return;
}
@@ -120,12 +108,7 @@ impl SheetApp {
};
let mut outgoing = WindowUi::fresh(&self.grid, &self.ui.ribbon_tab);
self.swap_window_ui(&mut outgoing);
if let Some(window) = self.session.window(previous) {
// A hidden window isn't drawn: a key tip request waiting for its next frame would
// fire whenever it is shown again, so it goes.
if window.hidden {
outgoing.keytip_invoke_next = None;
}
if self.session.window(previous).is_some() {
self.parked.insert(previous, outgoing);
}
if let Some(mut incoming) = active.and_then(|w| self.parked.remove(&w)) {
@@ -1,150 +0,0 @@
//! "Highlight the active cell's row": a global preference, off by default, that tints the whole
//! row of the active cell and leaves the workbook alone.
use egui::{Shape, vec2};
use gridcraft_engine::Session;
use gridcraft_engine::core::CellRef;
use gridcraft_ui_egui::theme::Tokens;
use gridcraft_ui_egui::{SheetApp, UiState, grid::Geo, grid::active_row_band};
use serde_json::json;
fn harness(session: Session) -> egui_kittest::Harness<'static, SheetApp> {
let mut h = crate::common::builder().with_size(vec2(1200.0, 800.0)).build_ui_state(
|ui, app: &mut SheetApp| {
app.logic(ui.ctx());
app.ui(ui);
},
crate::common::app(session),
);
h.run_steps(4);
h
}
fn book(active: &str) -> Session {
let mut session = Session::new();
session.new_workbook();
session.execute("selection.set", json!({"range": active})).unwrap();
session
}
/// The rectangles painted with the highlight's own colour.
fn tinted(h: &egui_kittest::Harness<'_, SheetApp>) -> Vec<egui::Rect> {
let tint = Tokens::get(&h.ctx).active_row;
h.output()
.shapes
.iter()
.filter_map(|clipped| match &clipped.shape {
Shape::Rect(r) if r.fill == tint => Some(r.rect),
_ => None,
})
.collect()
}
#[test]
fn it_is_off_by_default_and_paints_nothing() {
assert!(!UiState::default().highlight_active_row);
let h = harness(book("C5"));
assert!(tinted(&h).is_empty());
}
#[test]
fn turned_on_it_tints_the_active_row_across_the_columns() {
let mut h = harness(book("C5"));
assert_eq!(h.state_mut().run("options.highlightActiveRow", json!({"on": true})), Ok(json!({"on": true})));
h.run_steps(2);
let bands = tinted(&h);
assert_eq!(bands.len(), 1, "{bands:?}");
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);
let cell = geo.cell_rect(sheet, CellRef::parse("C5").unwrap());
assert_eq!((bands[0].top(), bands[0].bottom()), (cell.top(), cell.bottom()));
assert!(bands[0].left() <= geo.cells.left() + 1.0, "starts at the first column: {:?}", bands[0]);
assert!(bands[0].right() >= geo.cells.right() - 1.0, "reaches the last visible column: {:?}", bands[0]);
}
#[test]
fn the_band_follows_the_active_cell_and_only_the_row() {
let mut h = harness(book("C5"));
h.state_mut().run("options.highlightActiveRow", json!({"on": true})).unwrap();
h.state_mut().session.execute("selection.set", json!({"range": "E9"})).unwrap();
h.run_steps(2);
let bands = tinted(&h);
assert_eq!(bands.len(), 1, "a row, and no column band: {bands:?}");
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);
assert_eq!(bands[0].top(), geo.cell_rect(sheet, CellRef::parse("E9").unwrap()).top());
}
#[test]
fn it_applies_to_every_sheet_and_workbook() {
let mut h = harness(book("C5"));
h.state_mut().run("options.highlightActiveRow", json!({"on": true})).unwrap();
h.state_mut().session.execute("home.insertSheet", json!({"name": "Other"})).unwrap();
h.run_steps(2);
assert_eq!(tinted(&h).len(), 1, "another sheet");
h.state_mut().session.new_workbook();
h.run_steps(2);
assert_eq!(tinted(&h).len(), 1, "another workbook");
}
#[test]
fn turning_it_off_clears_the_band_and_the_toggle_leaves_the_workbook_alone() {
let mut h = harness(book("C5"));
let dirty = h.state().session.active().unwrap().is_dirty();
assert_eq!(h.state_mut().run("options.highlightActiveRow", json!({})), Ok(json!({"on": true})), "no parameters: it toggles");
h.run_steps(2);
assert_eq!(h.state().session.active().unwrap().is_dirty(), dirty);
assert_eq!(h.state_mut().run("options.highlightActiveRow", json!({})), Ok(json!({"on": false})));
h.run_steps(2);
assert!(tinted(&h).is_empty());
assert_eq!(h.state().session.active().unwrap().is_dirty(), dirty);
}
#[test]
fn the_preference_survives_a_restart_and_older_settings_read_as_off() {
let state = UiState { highlight_active_row: true, ..UiState::default() };
let saved = serde_json::to_string(&state).unwrap();
assert!(serde_json::from_str::<UiState>(&saved).unwrap().highlight_active_row);
assert!(!serde_json::from_str::<UiState>("{}").unwrap().highlight_active_row);
}
#[test]
fn a_hidden_row_or_a_row_outside_the_quadrant_has_no_band() {
let mut session = book("A1");
session.execute("home.rowHeight", json!({"rows": "5:5", "height": 0})).unwrap();
let h = harness(session);
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);
assert_eq!(active_row_band(&geo, sheet, 4, (0, 20), (0, 5)), None, "hidden");
assert_eq!(active_row_band(&geo, sheet, 30, (0, 20), (0, 5)), None, "outside");
assert!(active_row_band(&geo, sheet, 6, (0, 20), (0, 5)).is_some());
}
#[test]
fn in_page_layout_the_band_shows_on_a_click_to_add_data_page_too() {
let mut h = harness(book("C5"));
h.state_mut().run("options.highlightActiveRow", json!({"on": true})).unwrap();
h.state_mut().session.execute("view.pageLayout", json!({})).unwrap();
h.run_steps(3);
let bands = tinted(&h);
// Each page paints its own part of the row; the faded page paints it again over the fade.
let repeated = bands.iter().filter(|b| bands.iter().filter(|o| o == b).count() == 2).count();
assert_eq!(repeated, 2, "{bands:?}");
assert!(bands.iter().all(|b| b.top() == bands[0].top()), "one row: {bands:?}");
}
#[test]
fn with_frozen_panes_the_band_crosses_the_frozen_columns() {
let mut h = harness(book("C5"));
h.state_mut().session.execute("selection.set", json!({"range": "C3"})).unwrap();
h.state_mut().session.execute("view.freezePanes", json!({})).unwrap();
h.state_mut().session.execute("selection.set", json!({"range": "E7"})).unwrap();
h.state_mut().run("options.highlightActiveRow", json!({"on": true})).unwrap();
h.run_steps(2);
let bands = tinted(&h);
assert_eq!(bands.len(), 2, "the frozen columns' pane and the scrolling one: {bands:?}");
assert_eq!(bands[0].top(), bands[1].top());
}
-37
View File
@@ -160,40 +160,3 @@ fn merged_range_draws_the_outline_from_its_edge_cells() {
assert_eq!(ys.iter().filter(|y| (**y - max_y).abs() < 0.5).count(), 4, "bottom edge");
assert!(max_x - min_x > 100.0 && max_y - min_y > 40.0, "the outline spans the whole merged area");
}
/// #2544: a merged range in frozen columns keeps its whole outline while the others scroll: the
/// bottom edge under its last column ends at that column's own right edge, the freeze line.
#[test]
fn frozen_merged_range_keeps_its_outline_when_scrolled() {
let mut h = harness();
h.state_mut().run("home.borders", json!({"range": "A2:B3", "preset": "thickOutside", "color": "#C81E1E"})).unwrap();
h.state_mut().run("home.mergeCells", json!({"range": "A2:B3"})).unwrap();
h.state_mut().run("view.freezePanes", json!({"cell": "C1"})).unwrap();
h.state_mut().run("selection.set", json!({"range": "H10"})).unwrap();
h.state_mut().view_mut().unwrap().scroll.x = 300.0;
h.run_steps(6);
let freeze_x = {
let app = h.state();
let sheet = app.session.active().unwrap().wb.active().unwrap();
let geo = gridcraft_ui_egui::grid::Geo::new(sheet, app.grid.rect.unwrap(), app.view().scroll);
geo.cells.left() + geo.frozen_w
};
let red = Color32::from_rgb(0xC8, 0x1E, 0x1E);
let right_ends: Vec<f32> = h
.output()
.shapes
.iter()
.filter_map(|shape| match &shape.shape {
Shape::LineSegment { points, stroke } if stroke.color == red && (points[0].y - points[1].y).abs() < 0.5 => {
Some(points[0].x.max(points[1].x))
}
_ => None,
})
.collect();
assert_eq!(right_ends.len(), 4, "top and bottom edges, one segment per column: {right_ends:?}");
assert_eq!(
right_ends.iter().filter(|x| (**x - freeze_x).abs() < 1.0).count(),
2,
"B's segments end at the freeze line: {right_ends:?} vs {freeze_x}"
);
}
-77
View File
@@ -482,80 +482,3 @@ fn the_cut_text_pastes_again_once_the_clipboard_has_held_other_text() {
h.run_steps(2);
assert_eq!(value_at(&h, "H1"), Some(Value::from("Name")), "the same text copied afresh pastes");
}
/// The floating pictures on the active sheet: (anchor cell, width, height, mime).
fn pictures(h: &Harness) -> Vec<(String, f32, f32, String)> {
let sh = h.state().session.active().unwrap().wb.active().unwrap();
sh.images.iter().map(|i| (i.anchor.cell.a1(), i.anchor.width, i.anchor.height, i.mime.clone())).collect()
}
#[test]
fn cmd_v_with_a_picture_on_the_clipboard_inserts_it_at_the_active_cell() {
// A screenshot or a browser image: the clipboard has pixels and no text (#699, #3018, #3182).
let mut h = harness(Services::default());
h.state_mut().run("selection.set", json!({"cell": "C5"})).unwrap();
let image = egui::ColorImage::new([4, 3], vec![egui::Color32::from_rgb(200, 30, 30); 12]);
h.input_mut().events.push(Event::PasteImage(std::sync::Arc::new(image)));
h.run_steps(2);
assert!(h.state().message.is_none(), "{:?}", h.state().message);
assert_eq!(pictures(&h), vec![("C5".to_string(), 4.0, 3.0, "image/png".to_string())]);
let sh = h.state().session.active().unwrap().wb.active().unwrap();
let png = image::load_from_memory(&sh.images[0].data).unwrap().to_rgba8();
assert_eq!(png.get_pixel(3, 2).0, [200, 30, 30, 255], "the pixels survive the PNG round trip");
// One undo step removes it.
h.state_mut().run("edit.undo", json!({})).unwrap();
assert!(pictures(&h).is_empty());
}
#[test]
fn a_malformed_clipboard_picture_is_refused_without_inserting() {
let mut h = harness(Services::default());
let mut image = egui::ColorImage::new([4, 3], vec![egui::Color32::WHITE; 12]);
image.pixels.truncate(5);
h.input_mut().events.push(Event::PasteImage(std::sync::Arc::new(image)));
h.run_steps(2);
assert!(pictures(&h).is_empty());
assert!(h.state().message.is_some(), "the user is told the paste failed");
dismiss(&mut h);
h.input_mut().events.push(Event::PasteImage(std::sync::Arc::new(egui::ColorImage::new([0, 0], Vec::new()))));
h.run_steps(2);
assert!(pictures(&h).is_empty());
// Wider than the 16384-pixel limit: refused before its pixels are copied.
dismiss(&mut h);
let wide = egui::ColorImage::new([20_000, 1], vec![egui::Color32::WHITE; 20_000]);
h.input_mut().events.push(Event::PasteImage(std::sync::Arc::new(wide)));
h.run_steps(2);
assert!(pictures(&h).is_empty());
let message = format!("{:?}", h.state().message);
assert!(message.contains("too large"), "{message}");
}
#[test]
fn ribbon_paste_pastes_a_clipboard_picture_when_there_is_no_text() {
let reads = Rc::new(RefCell::new(0));
let count = reads.clone();
let (_, mut services) = system_clipboard("");
services.paste_image = Some(Box::new(move || {
*count.borrow_mut() += 1;
Some(image::RgbaImage::from_pixel(5, 2, image::Rgba([0, 0, 255, 255])))
}));
let mut h = harness(services);
h.state_mut().run("selection.set", json!({"cell": "B7"})).unwrap();
click_paste(&mut h);
assert_eq!(*reads.borrow(), 1);
assert_eq!(pictures(&h), vec![("B7".to_string(), 5.0, 2.0, "image/png".to_string())]);
}
#[test]
fn ribbon_paste_prefers_clipboard_text_over_a_picture() {
let (_, mut services) = system_clipboard("words");
services.paste_image = Some(Box::new(|| Some(image::RgbaImage::new(1, 1))));
let mut h = harness(services);
h.state_mut().run("selection.set", json!({"cell": "D2"})).unwrap();
click_paste(&mut h);
assert_eq!(value_at(&h, "D2"), Some(Value::from("words")));
assert!(pictures(&h).is_empty());
}
@@ -100,36 +100,3 @@ fn a_quick_right_click_between_pixels_keeps_the_menu_open_on_retina() {
h.run_steps(4);
assert!(h.state().grid.context_menu.is_some(), "the menu stays open");
}
#[test]
fn a_linked_cell_offers_to_edit_open_and_remove_its_link() {
// #2120: the menu only offered "Link…", and its dialog was titled Insert Hyperlink.
use egui_kittest::kittest::Queryable as _;
let mut h = harness();
h.state_mut().session.execute("insert.link", serde_json::json!({"cell": "B2", "target": "#Sheet1!D9", "text": "Go"})).unwrap();
h.run_steps(2);
quick_right_click(&mut h, "B2");
assert!(h.query_by_label("Edit Hyperlink…").is_some() && h.query_by_label("Open Hyperlink").is_some(), "linked cell menu");
assert!(h.query_by_label("Link…").is_none());
// Open Hyperlink follows it (an internal reference selects its cell).
h.get_by_label("Open Hyperlink").click();
h.run_steps(4);
assert_eq!(h.state().session.active().unwrap().selection.active, CellRef::parse("D9").unwrap());
// Edit Hyperlink opens the link's own dialog.
quick_right_click(&mut h, "B2");
h.get_by_label("Edit Hyperlink…").click();
h.run_steps(4);
let d = h.state().dialog.as_ref().expect("the link dialog");
assert_eq!(d.title, "Edit Hyperlink");
assert_eq!(d.values.get("target").and_then(|v| v.as_str()), Some("#Sheet1!D9"));
h.state_mut().dialog = None;
h.run_steps(2);
// Remove Hyperlink takes the link away; a cell without one offers Link… again.
quick_right_click(&mut h, "B2");
h.get_by_label("Remove Hyperlink").click();
h.run_steps(4);
let sh = h.state().session.active().unwrap().wb.active().unwrap().clone();
assert!(!sh.hyperlinks.contains_key(&CellRef::parse("B2").unwrap()));
quick_right_click(&mut h, "B2");
assert!(h.query_by_label("Link…").is_some() && h.query_by_label("Edit Hyperlink…").is_none());
}
-27
View File
@@ -172,33 +172,6 @@ fn moving_a_selection_keeps_move_and_still_moves_cells() {
assert_eq!(sh.value(CellRef::parse("E5").unwrap()), Value::Number(2.0));
}
#[test]
fn moving_a_selection_onto_data_asks_before_replacing_it() {
// #3136: the drop replaced the cells there without asking.
let mut h = harness();
h.state_mut().session.execute("selection.set", json!({"range": "A2:B2"})).unwrap();
h.run_steps(2);
let r = selection_rect(&h);
press(&mut h, pos2(r.left() + 10.0, r.top() + 1.0));
let target = cell(&h, "A1").center();
move_with_cursor(&mut h, target, CursorIcon::Move);
button(&mut h, target, false);
h.run_steps(1);
let q = h.state().macros.question.clone().expect("a question before replacing A1:B1");
assert_eq!(q.text, "There's already data here. Do you want to replace it?");
let sh = h.state().session.active().unwrap().wb.active().unwrap();
assert_eq!(sh.value(CellRef::parse("A1").unwrap()), Value::text("A"));
assert_eq!(sh.value(CellRef::parse("A2").unwrap()), Value::Number(1.0));
// OK replaces them.
h.key_press(egui::Key::Enter);
h.run_steps(2);
assert!(h.state().macros.question.is_none());
let sh = h.state().session.active().unwrap().wb.active().unwrap();
assert_eq!(sh.value(CellRef::parse("A1").unwrap()), Value::Number(1.0));
assert_eq!(sh.value(CellRef::parse("B1").unwrap()), Value::Number(2.0));
assert_eq!(sh.value(CellRef::parse("A2").unwrap()), Value::Empty);
}
#[test]
fn resizing_headers_keeps_the_axis_cursor_when_leaving_the_header() {
for cols in [true, false] {
@@ -34,27 +34,3 @@ fn typing_right_after_ctrl_f_fills_find_what() {
fn typing_right_after_ctrl_h_fills_find_what() {
assert_eq!(type_after_shortcut(Key::H), Some(json!("abc")));
}
/// With a range selected, Find All in the dialog lists only the matches inside it, as in Excel
/// (#3224); with one cell selected it searches the whole sheet.
#[test]
fn find_all_with_a_range_selected_searches_only_the_range() {
use egui_kittest::kittest::Queryable;
for (range, expected) in [("B1:B3", 2), ("C5", 4)] {
let mut h = harness();
let app = h.state_mut();
app.session.execute("range.setValues", json!({"range": "A1:B3", "values": [["pear", "pear"], ["fig", "fig"], ["pear", "pear"]]})).unwrap();
app.session.execute("selection.set", json!({"range": range})).unwrap();
h.run_steps(2);
h.input_mut().events.push(Event::Key { key: Key::F, physical_key: Some(Key::F), pressed: true, repeat: false, modifiers: CTRL });
h.input_mut().events.push(Event::Key { key: Key::F, physical_key: Some(Key::F), pressed: false, repeat: false, modifiers: CTRL });
h.run_steps(6);
h.input_mut().events.push(Event::Text("pear".into()));
h.run_steps(4);
h.get_by_label("Find All").click();
h.run_steps(4);
let d = h.state().dialog.as_ref().expect("find dialog is open");
let count = d.result.as_ref().map(|r| r["count"].clone());
assert_eq!(count, Some(json!(expected)), "{range} selected");
}
}
+1 -78
View File
@@ -1,6 +1,6 @@
//! Ctrl+Shift+U (Control+Shift+U in Excel for Mac) expands and collapses the formula bar.
use egui::{Event, Key, Modifiers, vec2};
use egui::{Event, Key, Modifiers};
use gridcraft_engine::Session;
use gridcraft_ui_egui::SheetApp;
use gridcraft_ui_egui::keymap::{Shortcut, lookup};
@@ -84,80 +84,3 @@ fn expand_formula_bar_command_sets_or_toggles_the_state() {
assert_eq!(h.state_mut().run("view.expandFormulaBar", json!({})), Ok(json!({"on": false})));
assert!(!expanded(&h));
}
fn drag(h: &mut Harness, from: egui::Pos2, to: egui::Pos2) {
use egui::PointerButton;
h.input_mut().events.extend([
Event::PointerMoved(from),
Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE },
]);
h.step();
for i in 1..=6 {
h.input_mut().events.push(Event::PointerMoved(from + (to - from) * (i as f32 / 6.0)));
h.step();
}
h.input_mut().events.push(Event::PointerButton { pos: to, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
h.run_steps(20);
}
fn bar(h: &Harness) -> gridcraft_ui_egui::formula_bar::BarLayout {
gridcraft_ui_egui::formula_bar::BarLayout::get(&h.ctx)
}
#[test]
fn dragging_the_bottom_edge_sets_the_height_that_expanding_returns_to() {
let mut h = harness();
h.run_steps(20);
let start = bar(&h);
assert_eq!(start.visible_lines, 1, "one line of text shows a one-line bar");
assert!(
start.handle.height() > 0.0 && (start.handle.bottom() - start.field.bottom()).abs() <= 4.0,
"the handle is the bar's bottom edge: {start:?}"
);
// Drag the edge down about five and a half rows: the bar snaps to whole lines.
let row = 17.0;
drag(&mut h, start.handle.center(), start.handle.center() + vec2(0.0, 5.5 * row));
let lines = h.state().ui.formula_bar_lines;
assert!(expanded(&h), "a taller bar is expanded");
assert!((6..=7).contains(&lines), "about six more lines: {lines}");
assert_eq!(bar(&h).visible_lines, lines, "the field shows the dragged lines: {:?}", bar(&h));
// Collapse, then expand: back to the dragged height, not the default five lines.
ctrl_shift_u(&mut h, CTRL_SHIFT);
h.run_steps(20);
assert!(!expanded(&h));
assert_eq!(bar(&h).visible_lines, 1);
ctrl_shift_u(&mut h, CTRL_SHIFT);
h.run_steps(20);
assert_eq!(bar(&h).visible_lines, lines, "expanding returns to the dragged height");
// Dragging the edge up to one line collapses the bar and keeps the expanded height.
let edge = bar(&h).handle.center();
drag(&mut h, edge, edge - vec2(0.0, 20.0 * row));
assert!(!expanded(&h), "a one-line bar is collapsed");
assert_eq!(bar(&h).visible_lines, 1);
assert_eq!(h.state().ui.formula_bar_lines, lines);
}
#[test]
fn expand_formula_bar_with_lines_sets_the_expanded_height() {
let mut h = harness();
assert_eq!(h.state_mut().run("view.expandFormulaBar", json!({"lines": 8})), Ok(json!({"on": true, "lines": 8})));
assert_eq!(h.state_mut().run("view.expandFormulaBar", json!({"lines": 1})), Ok(json!({"on": false, "lines": 8})), "one line collapses");
assert_eq!(h.state_mut().run("view.expandFormulaBar", json!({})), Ok(json!({"on": true})));
assert_eq!(h.state().ui.formula_bar_lines, 8, "expanding returns to the set height");
assert_eq!(h.state_mut().run("view.expandFormulaBar", json!({"lines": 0})), Ok(json!({"on": false, "lines": 8})), "zero lines is one line");
assert_eq!(h.state_mut().run("view.expandFormulaBar", json!({"lines": u64::MAX})), Ok(json!({"on": true, "lines": 100})), "capped");
}
#[test]
fn an_expanded_formula_bar_leaves_room_for_the_worksheet_in_a_short_window() {
// At 400 pt the title bar and ribbon leave about 236 pt: half the window as a formula bar
// would leave the status bar and sheet tabs no room for any of the grid.
let mut session = Session::new();
session.new_workbook();
let mut h = crate::common::harness_with(crate::common::builder().with_size(vec2(1200.0, 400.0)), crate::common::app(session), 4);
h.state_mut().run("view.expandFormulaBar", json!({"lines": 100})).unwrap();
h.run_steps(60);
let cells = h.state().grid.cells_rect.expect("the grid is drawn");
assert!(cells.height() >= 40.0, "a couple of rows stay visible under a {} pt bar: {cells:?}", bar(&h).height);
assert!(bar(&h).visible_lines > 1, "the bar is still expanded");
}
@@ -173,148 +173,3 @@ fn shift_does_not_extend_a_row_reference_across_sheets() {
[("6:6".into(), Some("Other sheet".into())),]
);
}
fn press_and_drag(h: &mut egui_kittest::Harness<'_, SheetApp>, from: Pos2, to: Pos2, modifiers: Modifiers) {
h.input_mut().events.push(Event::ModifiersChanged(modifiers));
h.input_mut()
.events
.extend([Event::PointerMoved(from), Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers }]);
h.step();
drag_to(h, to);
}
fn drag_to(h: &mut egui_kittest::Harness<'_, SheetApp>, to: Pos2) {
h.input_mut().events.push(Event::PointerMoved(to));
h.step();
}
fn release(h: &mut egui_kittest::Harness<'_, SheetApp>, at: Pos2) {
h.input_mut().events.push(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
h.run_steps(2);
h.input_mut().events.push(Event::ModifiersChanged(Modifiers::NONE));
}
fn text<'a>(h: &'a egui_kittest::Harness<'_, SheetApp>) -> &'a str {
h.state().editor.as_ref().map_or("", |ed| ed.text.as_str())
}
#[test]
fn dragging_over_column_headers_extends_the_reference() {
let mut h = harness();
let (c, e, a, d) = (header_center(&h, false, 2), header_center(&h, false, 4), header_center(&h, false, 0), header_center(&h, false, 3));
press_and_drag(&mut h, c, e, Modifiers::NONE);
assert_eq!(text(&h), "=SUM(C:E");
// Back past the press: the reference follows the pointer instead of keeping E.
drag_to(&mut h, a);
assert_eq!(text(&h), "=SUM(A:C");
// The release position counts even without a movement frame before it.
release(&mut h, d);
assert_eq!(text(&h), "=SUM(C:D");
assert_eq!(h.state().session.active().unwrap().selection.current().a1(), "F8");
}
#[test]
fn dragging_over_row_headers_extends_the_reference() {
let mut h = harness();
let (r3, r6, r2) = (header_center(&h, true, 2), header_center(&h, true, 5), header_center(&h, true, 1));
press_and_drag(&mut h, r3, r6, Modifiers::NONE);
assert_eq!(text(&h), "=SUM(3:6");
release(&mut h, r2);
assert_eq!(text(&h), "=SUM(2:3");
assert_eq!(h.state().session.active().unwrap().selection.current().a1(), "F8");
}
#[test]
fn header_drag_on_another_sheet_keeps_the_qualifier() {
let mut h = harness();
h.state_mut().session.execute("home.insertSheet", json!({"name": "Other sheet"})).unwrap();
h.run_steps(2);
let (b, d) = (header_center(&h, false, 1), header_center(&h, false, 3));
press_and_drag(&mut h, b, d, Modifiers::NONE);
assert_eq!(text(&h), "=SUM('Other sheet'!B:D");
}
#[test]
fn header_drag_with_command_keeps_the_earlier_argument() {
let mut h = harness();
let (a, e, f) = (header_center(&h, false, 0), header_center(&h, false, 4), header_center(&h, false, 5));
click(&mut h, a);
press_and_drag(&mut h, e, f, Modifiers { mac_cmd: true, command: true, ..Modifiers::NONE });
assert_eq!(text(&h), "=SUM(A:A,E:F");
}
#[test]
fn header_drag_with_shift_keeps_the_far_end_of_the_reference() {
let mut h = harness();
let (c, e, d) = (header_center(&h, false, 2), header_center(&h, false, 4), header_center(&h, false, 3));
click(&mut h, c);
press_and_drag(&mut h, e, d, Modifiers { shift: true, ..Modifiers::NONE });
assert_eq!(text(&h), "=SUM(C:D");
}
#[test]
fn clicks_on_headers_replace_the_reference_after_a_drag_too() {
let mut h = harness();
let (c, e, g) = (header_center(&h, false, 2), header_center(&h, false, 4), header_center(&h, false, 6));
press_and_drag(&mut h, c, e, Modifiers::NONE);
release(&mut h, e);
assert_eq!(text(&h), "=SUM(C:E");
click(&mut h, g);
assert_eq!(text(&h), "=SUM(G:G");
}
fn shift_arrow(h: &mut egui_kittest::Harness<'_, SheetApp>, key: egui::Key) {
let modifiers = Modifiers { shift: true, ..Modifiers::NONE };
h.input_mut().events.push(Event::ModifiersChanged(modifiers));
h.step();
for pressed in [true, false] {
h.input_mut().events.push(Event::Key { key, physical_key: None, pressed, repeat: false, modifiers });
h.step();
}
h.input_mut().events.push(Event::ModifiersChanged(Modifiers::NONE));
h.run_steps(2);
}
#[test]
fn shift_arrow_after_a_header_drag_moves_the_dragged_end() {
let mut h = harness();
let (c, e) = (header_center(&h, false, 2), header_center(&h, false, 4));
press_and_drag(&mut h, c, e, Modifiers::NONE);
release(&mut h, e);
assert_eq!(text(&h), "=SUM(C:E");
shift_arrow(&mut h, egui::Key::ArrowRight);
assert_eq!(text(&h), "=SUM(C:F");
// Dragged leftwards, the dragged end is the first column.
let mut h = harness();
let (e, c) = (header_center(&h, false, 4), header_center(&h, false, 2));
press_and_drag(&mut h, e, c, Modifiers::NONE);
release(&mut h, c);
assert_eq!(text(&h), "=SUM(C:E");
shift_arrow(&mut h, egui::Key::ArrowLeft);
assert_eq!(text(&h), "=SUM(B:E");
let mut h = harness();
let (r1, r3) = (header_center(&h, true, 0), header_center(&h, true, 2));
press_and_drag(&mut h, r1, r3, Modifiers::NONE);
release(&mut h, r3);
assert_eq!(text(&h), "=SUM(1:3");
shift_arrow(&mut h, egui::Key::ArrowDown);
assert_eq!(text(&h), "=SUM(1:4");
}
#[test]
fn shift_arrow_pointing_on_another_sheet_keeps_the_qualifier() {
let mut h = harness();
h.state_mut().session.execute("home.insertSheet", json!({"name": "Other sheet"})).unwrap();
h.run_steps(2);
let (b, d) = (header_center(&h, false, 1), header_center(&h, false, 3));
press_and_drag(&mut h, b, d, Modifiers::NONE);
release(&mut h, d);
assert_eq!(text(&h), "=SUM('Other sheet'!B:D");
let app = h.state_mut();
let mut ed = app.editor.take().unwrap();
gridcraft_ui_egui::grid::point_move(app, &mut ed, 0, 1, true, false);
app.editor = Some(ed);
assert_eq!(text(&h), "=SUM('Other sheet'!B:E");
}
-33
View File
@@ -66,36 +66,3 @@ fn dragging_the_handles_freezes_moves_and_unfreezes() {
drag(&mut h, rows.center(), pos2(rows.center().x, g.cells.top() - 5.0));
assert_eq!(freeze(&h), None);
}
/// The last frozen column ends at the freeze line however far the other columns scroll, while
/// the next column's left edge moves with the scroll (#2544).
#[test]
fn last_frozen_column_ends_at_the_freeze_line_when_scrolled() {
let mut h = harness();
h.state_mut().run("view.freezePanes", serde_json::json!({"cell": "C3"})).unwrap();
h.state_mut().view_mut().unwrap().scroll = egui::vec2(300.0, 200.0);
h.run_steps(2);
let g = geo(&h);
let sh = h.state().session.active().unwrap().wb.active().unwrap().clone();
let split = g.cells.min + egui::vec2(g.frozen_w, g.frozen_h);
assert!((g.x_end(&sh, 1) - split.x).abs() < 0.01 && (g.y_end(&sh, 1) - split.y).abs() < 0.01);
assert!(g.x(&sh, 2) < split.x && g.y(&sh, 2) < split.y, "C and row 3 are scrolled under the frozen panes");
assert!(g.same_col_pane(0, 1) && g.same_col_pane(2, 9) && !g.same_col_pane(1, 2));
}
/// Dropping the column handle on the left part of the last frozen column while scrolled freezes
/// up to its left edge: the column's right edge is the freeze line, not the next column's
/// scrolled position (#2544).
#[test]
fn dragging_the_column_handle_when_scrolled_measures_the_last_frozen_column() {
let mut h = harness();
h.state_mut().run("view.freezePanes", serde_json::json!({"cell": "C1"})).unwrap();
h.state_mut().view_mut().unwrap().scroll = egui::vec2(300.0, 0.0);
h.run_steps(2);
let g = geo(&h);
let sh = h.state().session.active().unwrap().wb.active().unwrap().clone();
let (cols, _) = freeze_handles(&g);
assert!(g.x(&sh, 2) < g.x(&sh, 1), "C is scrolled left of B");
drag(&mut h, cols.center(), pos2(g.x(&sh, 1) + 10.0, g.cells.top() + 40.0));
assert_eq!(freeze(&h), Some((0, 1)));
}
-45
View File
@@ -1,45 +0,0 @@
//! Committed ink is painted as the pen previews it, at any zoom.
use egui::Shape;
use egui::epaint::ColorMode;
use gridcraft_engine::Session;
use gridcraft_ui_egui::SheetApp;
use serde_json::json;
/// The colour of the ink stroke drawn from (40, 40) to (200, 40) sheet points, `width` points
/// wide: the two-point horizontal path whose width is `width` at its own scale (160 pt long).
fn painted_ink(h: &egui_kittest::Harness<'static, SheetApp>, width: f32) -> Option<egui::Color32> {
h.output().shapes.iter().find_map(|s| match &s.shape {
Shape::Path(p) if p.points.len() == 2 && (p.points[0].y - p.points[1].y).abs() < 0.01 => {
let z = (p.points[1].x - p.points[0].x).abs() / 160.0;
match p.stroke.color {
ColorMode::Solid(c) if z > 0.0 && (p.stroke.width - width * z).abs() < 0.01 => Some(c),
_ => None,
}
}
_ => None,
})
}
#[test]
fn ink_translucency_follows_the_pen_not_the_zoom() {
// A highlighter (over 8 pt) is translucent and a pen isn't, whatever the zoom: the committed
// stroke used its width on screen, so at 50% a 12 pt highlighter turned opaque and at 200% a
// 6 pt pen turned translucent, unlike the pen's preview.
for (width, zoom, translucent) in [(12.0_f32, 50, true), (6.0, 200, false), (12.0, 100, true), (6.0, 100, false)] {
let mut s = Session::new();
s.new_workbook();
s.execute("view.zoom", json!({"percent": zoom})).unwrap();
s.execute("draw.stroke", json!({"points": [[40, 40], [200, 40]], "color": "#FFFF00", "width": width})).unwrap();
let mut h = crate::common::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state(
|ui, app: &mut SheetApp| {
app.logic(&ui.ctx().clone());
app.ui(ui);
},
crate::common::app(s),
);
h.run_steps(4);
let c = painted_ink(&h, width).unwrap_or_else(|| panic!("the {width} pt stroke is painted at {zoom}%"));
assert_eq!(c.a() < 255, translucent, "{width} pt at {zoom}%: {c:?}");
}
}
@@ -100,11 +100,6 @@ fn windows_and_mac_variants() {
assert_eq!(lookup(Key::ArrowLeft, Some(Key::Num4), alt_shift, false), run("data.ungroup"));
assert_eq!(lookup(Key::PageDown, Some(Key::Num3), Modifiers::ALT, false), Some(Shortcut::ScreenRight));
assert_eq!(lookup(Key::PageDown, Some(Key::Num3), CTRL_SHIFT, false), None);
// Ctrl+. is Next Corner of Selection, with Control on a Mac too (#1442); Cmd+Shift+. stays
// Increase Font Size.
assert_eq!(lookup(Key::Period, Some(Key::Period), cmd, false), run("selection.nextCorner"));
assert_eq!(lookup(Key::Period, Some(Key::Period), ctrl, true), run("selection.nextCorner"));
assert_eq!(lookup(Key::Period, Some(Key::Period), Modifiers { shift: true, ..mac_cmd }, true), run("home.increaseFontSize"));
// Plain keys stay with the grid (typing, navigation).
assert_eq!(lookup(Key::A, Some(Key::A), Modifiers::NONE, false), None);
assert_eq!(lookup(Key::F2, Some(Key::F2), Modifiers::NONE, false), None);
+1 -303
View File
@@ -2754,308 +2754,6 @@ fn keytip_fp_() {
assert!(h.state().session.journal.iter().any(|(id, _)| id == "file.print"), "FP must journal file.print");
}
#[test]
fn keytip_ir_() {
let mut h = harness(blank());
sequence(&mut h, "IR", false);
assert!(h.state().session.journal.iter().any(|(id, _)| id == "home.insertRows"), "IR must journal home.insertRows");
}
#[test]
fn keytip_ic_() {
let mut h = harness(blank());
sequence(&mut h, "IC", false);
assert!(h.state().session.journal.iter().any(|(id, _)| id == "home.insertColumns"), "IC must journal home.insertColumns");
}
#[test]
fn keytip_ie_() {
let mut h = harness(blank());
sequence(&mut h, "IE", false);
assert!(h.state().dialog.is_some(), "IE must open the insertCells dialog");
}
#[test]
fn keytip_iw_() {
let mut h = harness(blank());
sequence(&mut h, "IW", false);
assert!(h.state().session.journal.iter().any(|(id, _)| id == "home.insertSheet"), "IW must journal home.insertSheet");
}
#[test]
fn keytip_if_() {
let mut h = harness(blank());
sequence(&mut h, "IF", false);
assert!(h.state().dialog.is_some(), "IF must open the insertFunction dialog");
}
#[test]
fn keytip_ind_() {
let mut h = harness(blank());
sequence(&mut h, "IND", false);
assert!(h.state().dialog.is_some(), "IND must open the defineName dialog");
}
#[test]
fn keytip_is_() {
let mut h = harness(blank());
sequence(&mut h, "IS", false);
assert!(h.state().message.is_none() && h.state().toast.is_none(), "IS must route to its control without an error box");
assert_eq!(h.state().keytips.prefix(), None);
}
#[test]
fn keytip_ed_() {
let mut h = harness(blank());
sequence(&mut h, "ED", false);
assert!(h.state().dialog.is_some(), "ED must open the deleteCells dialog");
}
#[test]
fn keytip_em_() {
let mut h = harness(blank());
sequence(&mut h, "EM", false);
assert!(h.state().dialog.is_some(), "EM must open the moveSheet dialog");
}
#[test]
fn keytip_el_() {
let mut h = harness(blank());
sequence(&mut h, "EL", false);
assert!(h.state().message.is_some() || h.state().toast.is_some(), "EL must invoke home.deleteSheet and surface its missing-params error");
}
#[test]
fn keytip_eaa_() {
let mut h = harness(blank());
sequence(&mut h, "EAA", false);
assert!(h.state().session.journal.iter().any(|(id, _)| id == "edit.clearAll"), "EAA must journal edit.clearAll");
}
#[test]
fn keytip_eac_() {
let mut h = harness(blank());
sequence(&mut h, "EAC", false);
assert!(h.state().session.journal.iter().any(|(id, _)| id == "edit.clearContents"), "EAC must journal edit.clearContents");
}
#[test]
fn keytip_eam_() {
let mut h = harness(blank());
sequence(&mut h, "EAM", false);
assert!(h.state().session.journal.iter().any(|(id, _)| id == "edit.clearComments"), "EAM must journal edit.clearComments");
}
#[test]
fn keytip_ef_() {
let mut h = harness(blank());
sequence(&mut h, "EF", false);
assert!(h.state().dialog.is_some(), "EF must open the find dialog");
}
#[test]
fn keytip_eg_() {
let mut h = harness(blank());
sequence(&mut h, "EG", false);
assert!(h.state().dialog.is_some(), "EG must open the goTo dialog");
}
#[test]
fn keytip_ora_() {
let mut h = harness(blank());
sequence(&mut h, "ORA", false);
assert!(h.state().session.journal.iter().any(|(id, _)| id == "home.autofitRowHeight"), "ORA must journal home.autofitRowHeight");
}
#[test]
fn keytip_ore_() {
let mut h = harness(blank());
sequence(&mut h, "ORE", false);
assert!(h.state().dialog.is_some(), "ORE must open the rowHeight dialog");
}
#[test]
fn keytip_orh_() {
let mut h = harness(blank());
sequence(&mut h, "ORH", false);
assert!(h.state().session.journal.iter().any(|(id, _)| id == "home.hideRows"), "ORH must journal home.hideRows");
}
#[test]
fn keytip_oru_() {
let mut h = harness(blank());
sequence(&mut h, "ORU", false);
assert!(h.state().session.journal.iter().any(|(id, _)| id == "home.unhideRows"), "ORU must journal home.unhideRows");
}
#[test]
fn keytip_ocw_() {
let mut h = harness(blank());
sequence(&mut h, "OCW", false);
assert!(h.state().dialog.is_some(), "OCW must open the columnWidth dialog");
}
#[test]
fn keytip_och_() {
let mut h = harness(blank());
sequence(&mut h, "OCH", false);
assert!(h.state().session.journal.iter().any(|(id, _)| id == "home.hideColumns"), "OCH must journal home.hideColumns");
}
#[test]
fn keytip_ocu_() {
let mut h = harness(blank());
sequence(&mut h, "OCU", false);
assert!(h.state().session.journal.iter().any(|(id, _)| id == "home.unhideColumns"), "OCU must journal home.unhideColumns");
}
#[test]
fn keytip_ohr_() {
let mut h = harness(blank());
sequence(&mut h, "OHR", false);
assert!(h.state().dialog.is_some(), "OHR must open the renameSheet dialog");
}
#[test]
fn keytip_ohh_() {
let mut h = harness(blank());
sequence(&mut h, "OHH", false);
assert!(h.state().message.is_some() || h.state().toast.is_some(), "OHH must invoke sheet.hide and surface its missing-params error");
}
#[test]
fn keytip_ohu_() {
let mut h = harness(blank());
sequence(&mut h, "OHU", false);
assert!(h.state().message.is_some() || h.state().toast.is_some(), "OHU must invoke sheet.unhide and surface its missing-params error");
}
#[test]
fn keytip_oe_() {
let mut h = harness(blank());
sequence(&mut h, "OE", false);
assert!(h.state().session.journal.iter().any(|(id, _)| id == "home.formatCells"), "OE must journal home.formatCells");
}
#[test]
fn keytip_od_() {
let mut h = harness(blank());
sequence(&mut h, "OD", false);
assert!(h.state().session.journal.iter().any(|(id, _)| id == "home.conditionalFormat"), "OD must journal home.conditionalFormat");
}
#[test]
fn keytip_dff_() {
let mut h = harness(blank());
sequence(&mut h, "DFF", false);
assert!(h.state().session.journal.iter().any(|(id, _)| id == "data.filter"), "DFF must journal data.filter");
}
#[test]
fn keytip_ds_() {
let mut h = harness(blank());
sequence(&mut h, "DS", false);
assert!(h.state().dialog.is_some(), "DS must open the sort dialog");
}
#[test]
fn keytip_dgg_() {
let mut h = harness(blank());
sequence(&mut h, "DGG", false);
assert!(h.state().session.journal.iter().any(|(id, _)| id == "data.group"), "DGG must journal data.group");
}
#[test]
fn keytip_dgu_() {
let mut h = harness(blank());
sequence(&mut h, "DGU", false);
assert!(h.state().session.journal.iter().any(|(id, _)| id == "data.ungroup"), "DGU must journal data.ungroup");
}
#[test]
fn keytip_dgs_() {
let mut h = harness(blank());
sequence(&mut h, "DGS", false);
assert!(h.state().session.journal.iter().any(|(id, _)| id == "data.showDetail"), "DGS must journal data.showDetail");
}
#[test]
fn keytip_dgh_() {
let mut h = harness(blank());
sequence(&mut h, "DGH", false);
assert!(h.state().session.journal.iter().any(|(id, _)| id == "data.hideDetail"), "DGH must journal data.hideDetail");
}
#[test]
fn keytip_dl_() {
let mut h = harness(blank());
sequence(&mut h, "DL", false);
assert!(h.state().session.journal.iter().any(|(id, _)| id == "data.validation"), "DL must journal data.validation");
}
#[test]
fn keytip_dt_() {
let mut h = harness(blank());
sequence(&mut h, "DT", false);
assert!(h.state().message.is_some() || h.state().toast.is_some(), "DT must invoke data.dataTable and surface its missing-params error");
}
#[test]
fn keytip_vn_() {
let mut h = harness(blank());
sequence(&mut h, "VN", false);
assert!(h.state().session.journal.iter().any(|(id, _)| id == "view.normal"), "VN must journal view.normal");
}
#[test]
fn keytip_vp_() {
let mut h = harness(blank());
sequence(&mut h, "VP", false);
assert!(h.state().session.journal.iter().any(|(id, _)| id == "view.pageBreakPreview"), "VP must journal view.pageBreakPreview");
}
#[test]
fn keytip_vz_() {
let mut h = harness(blank());
sequence(&mut h, "VZ", false);
assert!(h.state().dialog.is_some(), "VZ must open the zoom dialog");
}
#[test]
fn keytip_to_() {
let mut h = harness(blank());
sequence(&mut h, "TO", false);
assert!(h.state().backstage.is_some(), "TO must open the Options page");
}
#[test]
fn keytip_tua_() {
let mut h = harness(blank());
sequence(&mut h, "TUA", false);
assert!(h.state().session.journal.iter().any(|(id, _)| id == "formulas.removeArrows"), "TUA must journal formulas.removeArrows");
}
#[test]
fn keytip_tud_() {
let mut h = harness(blank());
sequence(&mut h, "TUD", false);
assert!(h.state().message.is_none() && h.state().toast.is_none(), "TUD must run formulas.traceDependents cleanly");
}
#[test]
fn keytip_tut_() {
let mut h = harness(blank());
sequence(&mut h, "TUT", false);
assert!(h.state().message.is_none() && h.state().toast.is_none(), "TUT must run formulas.tracePrecedents cleanly");
}
#[test]
fn keytip_fu_() {
let mut h = harness(blank());
sequence(&mut h, "FU", false);
assert!(h.state().dialog.is_some(), "FU must open the pageSetup dialog");
}
#[test]
fn keytip_q_() {
let mut h = harness(blank());
@@ -3068,7 +2766,7 @@ fn keytip_q_() {
#[test]
fn generated_test_count_matches_the_table() {
const EXPECTED: usize = 410;
const EXPECTED: usize = 367;
assert_eq!(gridcraft_ui_egui::keytips::keytip_table::SEQUENCES.len(), EXPECTED);
}
-112
View File
@@ -1,112 +0,0 @@
//! Excel 2003 menu access keys that open a ribbon control rather than run a command (#941).
use egui::os::OperatingSystem;
use egui::{Event, Key, Modifiers};
use egui_kittest::kittest::Queryable as _;
use gridcraft_engine::Session;
use gridcraft_ui_egui::SheetApp;
use serde_json::json;
fn press(h: &mut egui_kittest::Harness<'static, SheetApp>, key: Key) {
let ev = |pressed| Event::Key { key, physical_key: None, pressed, repeat: false, modifiers: Modifiers::NONE };
h.input_mut().events.extend([ev(true), Event::Text(key.name().to_ascii_lowercase()), ev(false)]);
h.run_steps(2);
}
#[test]
fn alt_i_s_opens_the_symbol_gallery_and_leaves_the_cell_alone() {
// Insert > Symbol picks a symbol; running insert.symbol with no symbol set the active cell
// to its own text again, re-reading it (a Python cell lost its code, text could turn into a
// number). From the Home tab the keys show the Insert tab's gallery instead.
let mut s = Session::new();
s.new_workbook();
s.execute("cell.set", json!({"cell": "A1", "input": "'007"})).unwrap();
let mut h = crate::common::os_harness(s, OperatingSystem::Nix);
h.run_steps(4);
for key in [Key::F10, Key::I, Key::S] {
press(&mut h, key);
}
h.run_steps(4);
assert!(!h.state().session.journal.iter().any(|(id, _)| id == "insert.symbol"), "nothing is inserted until a symbol is picked");
assert_eq!(h.state().ui.ribbon_tab, "Insert");
assert!(h.query_by_label("€").is_some(), "the Symbol gallery is open");
}
#[test]
fn alt_i_s_expands_a_collapsed_ribbon_on_the_insert_tab() {
// With the Insert tab already chosen but the ribbon collapsed, the gallery's control isn't
// drawn: the keys expand the ribbon first.
let mut s = Session::new();
s.new_workbook();
let mut h = crate::common::os_harness(s, OperatingSystem::Nix);
h.state_mut().run("ui.ribbonTab", json!({"tab": "Insert"})).unwrap();
h.state_mut().ui.ribbon_collapsed = true;
h.run_steps(4);
for key in [Key::F10, Key::I, Key::S] {
press(&mut h, key);
}
h.run_steps(4);
assert!(!h.state().ui.ribbon_collapsed);
assert!(h.query_by_label("€").is_some(), "the Symbol gallery is open");
assert!(!h.state().session.journal.iter().any(|(id, _)| id == "insert.symbol"));
}
#[test]
fn a_waiting_key_tip_request_stays_with_its_window() {
// A request waiting for the frame that draws its control belongs to the window whose keys
// made it: another window drawn first must not take it.
let mut s = Session::new();
s.new_workbook();
let mut h = crate::common::os_harness(s, OperatingSystem::Nix);
h.run_steps(2);
let first = h.state().session.active_window().unwrap();
h.state_mut().run("view.newWindow", json!({})).unwrap();
let second = h.state().session.active_window().unwrap();
h.state_mut().show_window(second);
h.state_mut().ui.keytip_invoke_next = Some("insert.symbol");
h.state_mut().show_window(first);
assert_eq!(h.state().ui.keytip_invoke_next, None, "the first window doesn't see the second window's request");
h.state_mut().show_window(second);
assert_eq!(h.state().ui.keytip_invoke_next, Some("insert.symbol"));
}
#[test]
fn a_hidden_window_drops_its_waiting_key_tip_request() {
// A hidden window isn't drawn, so its waiting request would fire when it was shown again,
// long after the keys: hiding the window drops it.
let mut s = Session::new();
s.new_workbook();
let mut h = crate::common::os_harness(s, OperatingSystem::Nix);
h.run_steps(2);
let first = h.state().session.active_window().unwrap();
h.state_mut().run("view.newWindow", json!({})).unwrap();
let second = h.state().session.active_window().unwrap();
h.state_mut().show_window(second);
h.state_mut().ui.keytip_invoke_next = Some("insert.symbol");
h.state_mut().run("view.hideWindow", json!({"window": second})).unwrap();
h.state_mut().show_window(first);
h.state_mut().run("view.unhideWindow", json!({"window": second})).unwrap();
h.state_mut().show_window(second);
assert_eq!(h.state().ui.keytip_invoke_next, None);
}
#[test]
fn a_window_hidden_from_another_window_drops_its_parked_request() {
// Hidden while another window is in front (its request already parked), the window still
// loses the request, so showing it again doesn't open the control.
let mut s = Session::new();
s.new_workbook();
let mut h = crate::common::os_harness(s, OperatingSystem::Nix);
h.run_steps(2);
let first = h.state().session.active_window().unwrap();
h.state_mut().run("view.newWindow", json!({})).unwrap();
let second = h.state().session.active_window().unwrap();
h.state_mut().show_window(second);
h.state_mut().ui.keytip_invoke_next = Some("insert.symbol");
h.state_mut().enter_window(first);
h.state_mut().run("view.hideWindow", json!({"window": second})).unwrap();
h.state_mut().show_window(first);
h.state_mut().run("view.unhideWindow", json!({"window": second})).unwrap();
h.state_mut().show_window(second);
assert_eq!(h.state().ui.keytip_invoke_next, None);
}
-3
View File
@@ -7,7 +7,6 @@
mod common;
mod accounting_fill;
mod active_row_highlight;
mod advanced_filter;
mod argument_names;
mod autosave_persist;
@@ -59,13 +58,11 @@ mod hyperlink_ctrl_click;
mod hyperlink_dialog;
mod in_cell_editor_size;
mod in_cell_editor_wrap;
mod ink_render;
mod insert_copied_cells;
mod interaction;
mod interface_scale;
mod keyboard_shortcuts;
mod keytips_generated;
mod legacy_keytips;
mod macro_editor;
mod macros_ui;
mod menu_hover;
@@ -54,50 +54,3 @@ fn paste_link_pastes_references_at_once() {
assert_eq!(c1["value"], json!(2.0));
assert!(c1["formula"].as_str().is_some_and(|f| f.contains("A1")), "{c1}");
}
fn press(h: &mut Harness, key: egui::Key) {
for pressed in [true, false] {
h.input_mut().events.push(egui::Event::Key { key, physical_key: Some(key), pressed, repeat: false, modifiers: egui::Modifiers::NONE });
}
h.run_steps(2);
}
fn value(h: &Harness, field: &str) -> serde_json::Value {
h.state().dialog.as_ref().and_then(|d| d.values.get(field).cloned()).unwrap_or_default()
}
#[test]
fn every_option_has_its_excel_access_key() {
// #1482: only F, V, T, C and E worked.
let mut h = harness();
for (key, field, want) in [
(egui::Key::N, "what", json!("validation")),
(egui::Key::X, "what", json!("allExceptBorders")),
(egui::Key::W, "what", json!("columnWidths")),
(egui::Key::R, "what", json!("formulasAndNumberFormats")),
(egui::Key::U, "what", json!("valuesAndNumberFormats")),
(egui::Key::A, "what", json!("all")),
(egui::Key::D, "operation", json!("add")),
(egui::Key::S, "operation", json!("subtract")),
(egui::Key::I, "operation", json!("divide")),
(egui::Key::O, "operation", json!("none")),
(egui::Key::M, "operation", json!("multiply")),
(egui::Key::B, "skipBlanks", json!(true)),
] {
press(&mut h, key);
assert_eq!(value(&h, field), want, "{key:?}");
}
press(&mut h, egui::Key::B);
assert_eq!(value(&h, "skipBlanks"), json!(false), "B flips Skip blanks back");
// V then Enter: values multiplied, as with the mouse.
press(&mut h, egui::Key::V);
press(&mut h, egui::Key::Enter);
assert!(h.state().dialog.is_none());
assert_eq!(cell(&mut h, "C1")["value"], json!(20.0));
// L is the Paste Link button: it pastes at once.
let mut h = harness();
press(&mut h, egui::Key::L);
h.run_steps(2);
assert!(h.state().dialog.is_none());
assert!(cell(&mut h, "C1")["formula"].as_str().is_some_and(|f| f.contains("A1")));
}
-67
View File
@@ -151,70 +151,3 @@ fn each_workbook_keeps_its_own_tab_share() {
assert!(track(&h).left() < 400.0, "a new workbook starts at the default share: {:?}", track(&h));
assert_eq!(h.state().grid.tab_shares.len(), 1, "only the dragged workbook has a share");
}
/// The tab bar of a workbook whose window has `command` run with `{on: false}` first.
fn harness_without(command: &str) -> Harness {
let mut s = Session::new();
s.new_workbook();
s.execute("sheet.rename", json!({"sheet": 0, "name": "Tab00"})).unwrap();
s.execute(command, json!({"on": false})).unwrap();
let mut h = crate::common::builder().with_step_dt(1.0 / 60.0).with_size(vec2(900.0, 120.0)).build_ui_state(
move |ui, app: &mut SheetApp| {
app.grid.cells_rect = Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(ui.max_rect().width(), 80.0)));
gridcraft_ui_egui::tabs::sheet_tabs(app, ui);
},
crate::common::app(s),
);
h.run_steps(20);
h
}
fn shows_tab_name(h: &Harness) -> bool {
h.output().shapes.iter().any(|s| matches!(&s.shape, Shape::Text(t) if t.galley.job.text == "Tab00"))
}
#[test]
fn display_options_hide_the_sheet_tabs_and_the_horizontal_scroll_bar() {
// #3101: File › Options › Advanced, Display options for this workbook.
let h = harness_without("view.showSheetTabs");
assert!(!shows_tab_name(&h), "no sheet tabs");
assert!(h.state().grid.tab_split.is_none(), "nothing to split without tabs");
let bar = h.state().grid.hscroll.expect("the scroll bar stays");
assert!(bar.left() < 40.0, "the scroll bar takes the bar from its left edge: {bar:?}");
let h = harness_without("view.showHorizontalScrollBar");
assert!(shows_tab_name(&h), "the tabs stay");
assert!(h.state().grid.hscroll.is_none() && h.state().grid.tab_split.is_none(), "no scroll bar and no split handle");
// Both off: no bar at all.
let mut s = Session::new();
s.new_workbook();
s.execute("view.showSheetTabs", json!({"on": false})).unwrap();
s.execute("view.showHorizontalScrollBar", json!({"on": false})).unwrap();
let mut h = crate::common::builder()
.with_size(vec2(900.0, 120.0))
.build_ui_state(|ui, app: &mut SheetApp| gridcraft_ui_egui::tabs::sheet_tabs(app, ui), crate::common::app(s));
h.run_steps(5);
assert!(!shows_tab_name(&h) && h.state().grid.hscroll.is_none() && h.state().grid.tab_split.is_none(), "no bar at all");
}
#[test]
fn hiding_the_controls_clears_their_cached_rectangles() {
// Turned off on a live window, the controls drop the rectangles of the frame that drew them.
let mut h = harness(900.0, 1);
assert!(h.state().grid.tab_split.is_some() && h.state().grid.hscroll.is_some(), "both controls drawn first");
h.state_mut().run("view.showSheetTabs", json!({"on": false})).unwrap();
h.run_steps(5);
assert!(h.state().grid.tab_split.is_none(), "no split handle once the tabs are hidden");
assert!(h.state().grid.hscroll.is_some(), "the scroll bar stays");
h.state_mut().run("view.showSheetTabs", json!({"on": true})).unwrap();
h.state_mut().run("view.showHorizontalScrollBar", json!({"on": false})).unwrap();
h.run_steps(5);
assert!(h.state().grid.hscroll.is_none() && h.state().grid.tab_split.is_none(), "no scroll bar and no split handle");
h.state_mut().run("view.showSheetTabs", json!({"on": false})).unwrap();
h.run_steps(5);
assert!(h.state().grid.hscroll.is_none() && h.state().grid.tab_split.is_none(), "no bar at all");
}
@@ -53,18 +53,6 @@ fn text_clips(h: &Harness, text: &str) -> Vec<Rect> {
.collect()
}
/// Painted extents of the text shapes whose text is `text`, cut to their clip rects.
fn text_visible_rects(h: &Harness, text: &str) -> Vec<Rect> {
h.output()
.shapes
.iter()
.filter_map(|s| match &s.shape {
Shape::Text(t) if t.galley.job.text == text => Some(t.visual_bounding_rect().intersect(s.clip_rect)),
_ => None,
})
.collect()
}
fn cell_rect(h: &Harness, cell: &str) -> Rect {
let app = h.state();
let sheet = app.session.active().unwrap().wb.active().unwrap();
@@ -402,71 +390,3 @@ fn wrapped_text_clipping_survives_partial_vertical_scroll() {
clip={clip:?}, expected={expected_bottom:.2}"
);
}
/// Issue #2544: text in a frozen column keeps its whole cell while the other columns scroll.
#[test]
fn frozen_column_text_keeps_its_cell_when_scrolled() {
let mut h = harness(|s| {
s.execute("cell.set", json!({"cell": "A1", "input": LEFT})).unwrap();
s.execute("cell.set", json!({"cell": "C1", "input": "v"})).unwrap();
s.execute("view.freezeFirstColumn", json!({})).unwrap();
});
for col in [1, 2, 3, 8] {
scroll_to_col(&mut h, col);
let a1 = cell_rect(&h, "A1");
let clips = text_clips(&h, LEFT);
assert_eq!(clips.len(), 1, "A1 is drawn once: {clips:?}");
assert!(
(clips[0].left() - a1.left()).abs() <= 0.5 && (clips[0].right() - a1.right()).abs() <= 0.5,
"A1's text spans A1 at column {col}: {clips:?} vs {a1:?}"
);
let visible = text_visible_rects(&h, LEFT);
assert!(visible.len() == 1 && visible[0].is_positive(), "A1's text shows inside its clip at column {col}: {visible:?}");
}
}
/// Issue #2544: with two frozen columns, A1's text keeps overflowing across the whole of empty B1.
#[test]
fn frozen_text_overflows_to_the_freeze_line_when_scrolled() {
let mut h = harness(|s| {
s.execute("cell.set", json!({"cell": "A1", "input": LEFT})).unwrap();
s.execute("view.freezePanes", json!({"cell": "C1"})).unwrap();
});
for col in [2, 3, 4, 10] {
scroll_to_col(&mut h, col);
let (a1, b1) = (cell_rect(&h, "A1"), cell_rect(&h, "B1"));
let clips = text_clips(&h, LEFT);
assert_eq!(clips.len(), 1, "A1 is drawn once: {clips:?}");
assert!(
(clips[0].left() - a1.left()).abs() <= 0.5 && (clips[0].right() - b1.right()).abs() <= 0.5,
"A1's text spans A1:B1 at column {col}: {clips:?} vs {a1:?} {b1:?}"
);
}
}
/// Issue #2544: the frozen columns' gridlines reach the freeze line, and none is drawn inside
/// them at a scrolled column's edge.
#[test]
fn frozen_column_gridlines_stay_put_when_scrolled() {
let mut h = harness(|s| {
s.execute("view.freezePanes", json!({"cell": "C1"})).unwrap();
});
scroll_to_col(&mut h, 6);
let (a5, b5) = (cell_rect(&h, "A5"), cell_rect(&h, "B5"));
let frozen = |s: &egui::epaint::ClippedShape| s.clip_rect.right() <= b5.right() + 0.5 && s.clip_rect.left() <= a5.left();
let segments: Vec<[egui::Pos2; 2]> = h
.output()
.shapes
.iter()
.filter(|s| frozen(s))
.filter_map(|s| match &s.shape {
Shape::LineSegment { points, .. } => Some(*points),
_ => None,
})
.collect();
let row5 = segments.iter().find(|p| (p[0].y - (a5.top() - 0.5).round()).abs() <= 1.0 && p[0].y == p[1].y);
assert!(row5.is_some_and(|p| p[1].x >= b5.right() - 1.0), "row 5's gridline reaches the freeze line: {row5:?} vs {b5:?}");
let verticals: Vec<f32> = segments.iter().filter(|p| p[0].x == p[1].x && p[0].y < b5.bottom()).map(|p| p[0].x).collect();
let expected = [a5.left(), a5.right(), b5.right()].map(|x| x.round() - 0.5);
assert!(verticals.iter().all(|x| expected.iter().any(|e| (x - e).abs() <= 0.5)), "only A and B's edges: {verticals:?} vs {expected:?}");
}
+2 -4
View File
@@ -366,11 +366,9 @@ fn is_ref(f: &str) -> bool {
/// 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<usize>, String, Option<&'a DefinedName>)> {
let Ok(Expr::Name(n)) = gridcraft_formula::parse(f.trim()) else { return None };
// The parser has already unquoted the sheet (`'Bob''s'!Name` → `Bob's!Name`, #2012): split
// at the last `!` (`'a!b'!Name`) and keep the name as is (`' Sales '` keeps its spaces).
match n.rsplit_once('!') {
match n.split_once('!') {
Some((s, bare)) => {
let i = wb.sheet_index(s)?;
let i = wb.sheet_index(s.trim_matches('\''))?;
Some((Some(i), bare.to_string(), wb.name(bare, i)))
}
None => {
+2 -8
View File
@@ -25,13 +25,10 @@ pub const XF_EXT_URI: &str = "{C7286773-470A-42A8-94C5-96B5CB345126}";
/// The bags for one checkbox cell control, referenced as `xfComplement i="0"`: Checkbox (0),
/// XFControls → 0 (1), XFComplement → 1 (2), XFComplements mapping index 0 → 2 (3). Every
/// reference points to an earlier bag, as [MS-XLSX] 2.6.230 requires. The XFComplements bag is
/// the one `xfComplement` indexes into, and Excel finds it by its `extRef`: without
/// `extRef="XFComplementsMapperExtRef"` it repairs the workbook ("Format from /xl/styles.xml")
/// and drops every checkbox (#2630).
/// reference points to an earlier bag, as [MS-XLSX] 2.6.230 requires.
pub fn checkbox_part() -> String {
format!(
"<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n<FeaturePropertyBags xmlns=\"{NS}\" count=\"4\"><bag type=\"Checkbox\"/><bag type=\"XFControls\"><bagId k=\"CellControl\">0</bagId></bag><bag type=\"XFComplement\"><bagId k=\"XFControls\">1</bagId></bag><bag type=\"XFComplements\" extRef=\"XFComplementsMapperExtRef\"><a k=\"MappedFeaturePropertyBags\"><bagId>2</bagId></a></bag></FeaturePropertyBags>"
"<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n<FeaturePropertyBags xmlns=\"{NS}\" count=\"4\"><bag type=\"Checkbox\"/><bag type=\"XFControls\"><bagId k=\"CellControl\">0</bagId></bag><bag type=\"XFComplement\"><bagId k=\"XFControls\">1</bagId></bag><bag type=\"XFComplements\"><a k=\"MappedFeaturePropertyBags\"><bagId>2</bagId></a></bag></FeaturePropertyBags>"
)
}
@@ -76,9 +73,6 @@ mod tests {
fn written_chain_resolves_and_hostile_chains_do_not() {
let root = crate::xml::parse(checkbox_part().as_bytes()).unwrap_or_default();
assert_eq!(checkbox_complements(&root), vec![true]);
// #2630: Excel repairs the file unless the mapping bag carries this extRef.
let mapper = root.kids("bag").find(|b| b.attr("type") == Some("XFComplements"));
assert_eq!(mapper.and_then(|b| b.attr("extRef")), Some("XFComplementsMapperExtRef"));
// Out-of-range, wrong-type and self references resolve to false without panicking.
let bad = format!(
"<FeaturePropertyBags xmlns=\"{NS}\"><bag type=\"XFControls\"><bagId k=\"CellControl\">99</bagId></bag><bag type=\"XFComplement\"><bagId k=\"XFControls\">1</bagId></bag><bag type=\"XFComplements\"><a k=\"MappedFeaturePropertyBags\"><bagId>1</bagId><bagId>4294967295</bagId><bagId>x</bagId></a></bag></FeaturePropertyBags>"
+1 -1
View File
@@ -47,7 +47,7 @@ use gridcraft_core::DateSystem;
use gridcraft_model::Workbook;
pub use crypto::{DecryptError, check_encryption, decrypt};
pub use csv::{MAX_CSV_BYTES, decode_text, read_csv, read_csv_with_report, write_csv, write_csv_limited};
pub use csv::{MAX_CSV_BYTES, read_csv, read_csv_with_report, write_csv, write_csv_limited};
pub use ods::read_ods;
pub use package::MAX_PART;
pub use passthrough::rebase_links;
+1 -1
View File
@@ -317,7 +317,7 @@ impl Import {
return Err(IoError::TooLarge("ODS contains more than 1024 sheets".into()));
}
let original = attr(reader, e, TABLE, b"name")?.unwrap_or_default();
let mut name = crate::read::SheetNames::of(&self.wb).unique(original.trim_matches('\''), self.wb.sheets.len());
let mut name = crate::read::unique_name(&self.wb, original.trim_matches('\''), self.wb.sheets.len());
if self.wb.check_sheet_name(&name, None).is_err() {
name = self.wb.next_sheet_name();
}
+8 -41
View File
@@ -1,6 +1,5 @@
//! Reading the workbook part and everything hanging off it.
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use gridcraft_core::{DateSystem, RangeRef};
@@ -201,7 +200,6 @@ fn read_workbook_properties(cx: &mut Ctx<'_>, wb: &mut Workbook, xml: &El) {
if p.flag("date1904", false) {
cx.date_system = DateSystem::D1904;
}
wb.remove_personal_info = p.flag("filterPrivacy", false);
wb.show_objects = match p.attr("showObjects") {
Some("none") => ShowObjects::None,
Some("placeholders") => ShowObjects::Placeholders,
@@ -223,7 +221,6 @@ fn read_workbook_properties(cx: &mut Ctx<'_>, wb: &mut Workbook, xml: &El) {
wb.calc.set_max_change(d);
}
wb.calc.precision_as_displayed = !c.flag("fullPrecision", true);
wb.calc.r1c1 = c.attr("refMode") == Some("R1C1");
}
if let Some(p) = xml.child("workbookProtection") {
wb.protected_structure = p.flag("lockStructure", false);
@@ -307,7 +304,6 @@ pub fn read_xlsx<'a>(bytes: &'a [u8], known_function: Option<&'a dyn Fn(&str) ->
let mut file_to_model: Vec<Option<usize>> = vec![];
let mut pivot_caches = crate::pivot::CachesIn::new(&wb_xml, &wb_rels);
let sheet_els: Vec<El> = wb_xml.child("sheets").map(|s| s.kids("sheet").cloned().collect()).unwrap_or_default();
let mut names = SheetNames::default();
for (i, se) in sheet_els.iter().enumerate() {
let name = se.attr("name").unwrap_or("").to_string();
let rel = se.attr("id").and_then(|id| wb_rels.iter().find(|r| r.id == id)).cloned();
@@ -326,7 +322,7 @@ pub fn read_xlsx<'a>(bytes: &'a [u8], known_function: Option<&'a dyn Fn(&str) ->
file_to_model.push(None);
continue;
}
let name = names.unique(&name, i);
let name = unique_name(&wb, &name, i);
let mut sheet = match crate::sheet_read::read_sheet(&mut cx, &rel.target, &name) {
Ok(s) => s,
Err(IoError::TooLarge(m)) => return Err(IoError::TooLarge(m)),
@@ -459,44 +455,15 @@ pub fn read_xlsx<'a>(bytes: &'a [u8], known_function: Option<&'a dyn Fn(&str) ->
Ok((wb, cx.report))
}
/// The sheet names an import has assigned so far, for repairing repeated ones.
#[derive(Default)]
pub(crate) struct SheetNames {
/// Assigned names, ASCII-lowercased.
taken: HashSet<String>,
/// For each lowercased stem, the next suffix to try: a file full of repeated names would
/// otherwise retry every occupied suffix for every sheet.
next: HashMap<String, u64>,
}
impl SheetNames {
pub(crate) fn of(wb: &Workbook) -> Self {
SheetNames { taken: wb.sheets.iter().map(|s| s.name.to_ascii_lowercase()).collect(), next: HashMap::new() }
pub(crate) fn unique_name(wb: &Workbook, name: &str, i: usize) -> String {
let mut base: String = name.chars().filter(|c| !matches!(c, ':' | '\\' | '/' | '?' | '*' | '[' | ']')).take(31).collect();
if base.trim().is_empty() {
base = format!("Sheet{}", i + 1);
}
/// A valid name for the i-th sheet, unique among the names assigned so far, which it joins.
pub(crate) fn unique(&mut self, name: &str, i: usize) -> String {
let mut base: String = name.chars().filter(|c| !matches!(c, ':' | '\\' | '/' | '?' | '*' | '[' | ']')).take(31).collect();
if base.trim().is_empty() {
base = format!("Sheet{}", i + 1);
}
let name = if self.taken.contains(&base.to_ascii_lowercase()) {
let stem: String = base.chars().take(25).collect();
let next = self.next.entry(stem.to_ascii_lowercase()).or_insert(2);
let found = (*next..).find(|n| !self.taken.contains(&format!("{stem} ({n})").to_ascii_lowercase()));
match found {
Some(n) => {
*next = n + 1;
format!("{stem} ({n})")
}
None => base,
}
} else {
base
};
self.taken.insert(name.to_ascii_lowercase());
name
if wb.sheet_index(&base).is_none() {
return base;
}
(2..).map(|n| format!("{} ({n})", base.chars().take(25).collect::<String>())).find(|n| wb.sheet_index(n).is_none()).unwrap_or(base)
}
/// Strips a sheet qualifier (`'My Sheet'!$A$1` → `$A$1`).
+25 -49
View File
@@ -142,22 +142,13 @@ pub fn read(cx: &mut Ctx<'_>, workbook_rels: &[Rel]) -> Result<Vec<Option<Arc<Ce
let Some(image_rel_list) = read_xml(cx, &image_rels_path, "richValueRels")? else { return Ok(vec![]) };
let image_rels = cx.pkg.rels(&image_rels_path)?;
let structures = entries(cx, &structures, "s")?;
// Each structure's key layout, worked out once: every value would otherwise walk its
// structure's keys again. Only local-image structures matter here.
let layouts: Vec<Option<Layout>> = structures.iter().map(|s| Layout::of(s)).collect();
let image_rel_list = entries(cx, &image_rel_list, "rel")?;
// Relationships by id, a repeated id recorded as invalid (`None`), so a value's lookup is
// one probe rather than a scan of the whole table for duplicates.
let mut rels_by_id: HashMap<&str, Option<&Rel>> = HashMap::new();
for rel in &image_rels {
rels_by_id.entry(rel.id.as_str()).and_modify(|r| *r = None).or_insert(Some(rel));
}
let values = entries(cx, &data, "rv")?;
let mut pictures = Vec::with_capacity(values.len());
let mut cache = HashMap::new();
let mut total = 0usize;
for value in values {
let picture = read_value(cx, value, &layouts, &image_rel_list, &rels_by_id, &mut cache, &mut total)?;
let picture = read_value(cx, value, &structures, &image_rel_list, &image_rels, &mut cache, &mut total)?;
if picture.is_none() {
cx.warn("some rich values are unsupported or malformed; their scalar fallbacks were retained (cell pictures may be missing)");
}
@@ -197,56 +188,43 @@ pub fn read(cx: &mut Ctx<'_>, workbook_rels: &[Rel]) -> Result<Vec<Option<Arc<Ce
.collect())
}
/// Where a `_localImage` structure keeps the values a picture needs.
struct Layout {
keys: usize,
/// Index of the one `_rvRel:LocalImageIdentifier` key of type `i`, if exactly one.
image: Option<usize>,
/// Index of the one `Text` key of type `s`, if exactly one.
text: Option<usize>,
}
impl Layout {
fn of(structure: &El) -> Option<Layout> {
if structure.attr("t") != Some("_localImage") {
return None;
}
let keys: Vec<&El> = structure.kids("k").collect();
let one = |name: &str, ty: &str| {
let mut matches = keys.iter().enumerate().filter(|(_, k)| k.attr("n") == Some(name));
let (i, key) = matches.next()?;
(matches.next().is_none() && key.attr("t") == Some(ty)).then_some(i)
};
Some(Layout { keys: keys.len(), image: one("_rvRel:LocalImageIdentifier", "i"), text: one("Text", "s") })
}
}
fn read_value(
cx: &mut Ctx<'_>,
value: &El,
layouts: &[Option<Layout>],
structures: &[&El],
image_rel_list: &[&El],
rels_by_id: &HashMap<&str, Option<&Rel>>,
image_rels: &[Rel],
cache: &mut HashMap<(String, String), Arc<CellPicture>>,
total: &mut usize,
) -> Result<Option<Arc<CellPicture>>, IoError> {
let Some(Some(layout)) = value.attr_u32("s").and_then(|n| layouts.get(n as usize)) else { return Ok(None) };
let values: Vec<_> = value.kids("v").collect();
if layout.keys != values.len() {
let Some(structure) = value.attr_u32("s").and_then(|n| structures.get(n as usize)) else { return Ok(None) };
if structure.attr("t") != Some("_localImage") {
return Ok(None);
}
let lookup = |slot: Option<usize>| slot.and_then(|i| values.get(i)).map(|v| v.text.as_str());
let Some(rel_id) =
lookup(layout.image).and_then(|v| v.trim().parse::<usize>().ok()).and_then(|n| image_rel_list.get(n)).and_then(|rel| rel.attr("id"))
let keys: Vec<_> = structure.kids("k").collect();
let values: Vec<_> = value.kids("v").collect();
if keys.len() != values.len() {
return Ok(None);
}
let lookup = |name: &str, ty: &str| {
let mut matches = keys.iter().zip(&values).filter(|(k, _)| k.attr("n") == Some(name));
let (key, value) = matches.next()?;
(matches.next().is_none() && key.attr("t") == Some(ty)).then_some(value.text.as_str())
};
let Some(rel_id) = lookup("_rvRel:LocalImageIdentifier", "i")
.and_then(|v| v.trim().parse::<usize>().ok())
.and_then(|n| image_rel_list.get(n))
.and_then(|rel| rel.attr("id"))
else {
return Ok(None);
};
let Some(found) = rels_by_id.get(rel_id) else { return Ok(None) };
let Some(rel) = found.filter(|rel| rel.kind == "image" && !rel.external) else {
let mut matches = image_rels.iter().filter(|rel| rel.id == rel_id);
let Some(rel) = matches.next() else { return Ok(None) };
if matches.next().is_some() || rel.kind != "image" || rel.external {
cx.warn("a cell picture has an external or invalid image relationship; kept its scalar fallback");
return Ok(None);
};
let alt = lookup(layout.text).unwrap_or("").to_string();
}
let alt = lookup("Text", "s").unwrap_or("").to_string();
let key = (normalize(&rel.target), alt.clone());
if let Some(picture) = cache.get(&key) {
return Ok(Some(picture.clone()));
@@ -371,9 +349,7 @@ fn metadata(count: usize, dynamic: bool) -> String {
}
xml.push_str("<metadataType name=\"XLRICHVALUE\" minSupportedVersion=\"120000\" copy=\"1\" pasteAll=\"1\" pasteValues=\"1\" merge=\"1\" splitFirst=\"1\" rowColShift=\"1\" clearFormats=\"1\" clearComments=\"1\" assign=\"1\" cellMeta=\"0\"/></metadataTypes>");
if dynamic {
// Lower case, as the dynamic-array-only metadata in `write.rs` spells it: Excel refuses
// a workbook whose XLDAPR extension URI is in upper case (#2339).
xml.push_str("<futureMetadata name=\"XLDAPR\" count=\"1\"><bk><extLst><ext uri=\"{bdbb8cdc-fa1e-496e-a857-3c3f30c029c3}\"><xda:dynamicArrayProperties fDynamic=\"1\" fCollapsed=\"0\"/></ext></extLst></bk></futureMetadata>");
xml.push_str("<futureMetadata name=\"XLDAPR\" count=\"1\"><bk><extLst><ext uri=\"{BDBB8CDC-FA1E-496E-A857-3C3F30C029C3}\"><xda:dynamicArrayProperties fDynamic=\"1\" fCollapsed=\"0\"/></ext></extLst></bk></futureMetadata>");
}
let _ = write!(xml, "<futureMetadata name=\"XLRICHVALUE\" count=\"{count}\">");
for i in 0..count {
+1 -6
View File
@@ -370,12 +370,7 @@ fn write_sheet_printing<'a>(s: &mut String, sheet: &'a Sheet, rels: &mut Rels, o
}
// Unmodelled content kept from the source file (printer settings, controls, OLE objects…).
let kept = crate::passthrough::SheetKept::write(sheet, rels, out);
// Margins are kept as f32: widen each through its shortest decimal, so 0.3 is written as
// `0.3`, as Excel writes it, and not as `0.30000001192092896` (#1609).
let m = |i: usize| {
let inches = p.margins.get(i).copied().unwrap_or(0.75);
num(inches.to_string().parse::<f64>().unwrap_or(f64::from(inches)))
};
let m = |i: usize| num(p.margins.get(i).copied().unwrap_or(0.75) as f64);
let _ =
write!(s, "<pageMargins left=\"{}\" right=\"{}\" top=\"{}\" bottom=\"{}\" header=\"{}\" footer=\"{}\"/>", m(0), m(1), m(2), m(3), m(4), m(5));
s.push_str("<pageSetup");
+6 -51
View File
@@ -1,5 +1,3 @@
use std::collections::HashMap;
use super::*;
/// Valid table name: letters, digits, `_` and `.`, starting with a letter, `_` or `\`
@@ -23,24 +21,16 @@ pub(super) fn table_name(name: &str, used: &mut HashSet<String>, n: u32) -> Stri
pub(super) fn table_columns(t: &Table) -> Vec<String> {
let w = t.range.width() as usize;
let mut used = HashSet::new();
// The next suffix to try per lowercased base, so repeated headers don't retry every suffix
// already handed out.
let mut next: HashMap<String, u32> = HashMap::new();
(0..w)
.map(|i| {
let base = t.columns.get(i).map(|c| c.name.clone()).filter(|n| !n.trim().is_empty()).unwrap_or_else(|| format!("Column{}", i + 1));
let lower = base.to_lowercase();
if used.insert(lower.clone()) {
return base;
}
let k = next.entry(lower).or_insert(2);
loop {
let name = format!("{base}{k}");
*k += 1;
if used.insert(name.to_lowercase()) {
return name;
}
let mut name = base.clone();
let mut k = 2;
while !used.insert(name.to_lowercase()) {
name = format!("{base}{k}");
k += 1;
}
name
})
.collect()
}
@@ -381,38 +371,3 @@ pub(super) fn vml_xml(sheet: &Sheet, n: u32) -> String {
s.push_str("</xml>");
s
}
#[cfg(test)]
mod tests {
use gridcraft_model::TableColumn;
use super::*;
#[test]
fn repeated_headers_are_renamed_without_retrying_suffixes() {
let col = |n: &str| TableColumn { name: n.into(), totals: TotalsFn::None, totals_label: None, formula: None, unique_name: None, xml: None };
let mut columns: Vec<TableColumn> = (0..10_000).map(|_| col("Total")).collect();
columns[1] = col("total3");
let t = Table {
id: 1,
name: "T".into(),
range: RangeRef::parse("A1:NTP2").unwrap(),
header_row: true,
totals_row: false,
columns,
style: String::new(),
banded_rows: false,
banded_cols: false,
first_col: false,
last_col: false,
filter_button: false,
filter: vec![],
table_type: None,
connection_id: None,
};
let names = table_columns(&t);
assert_eq!(names.len(), 10_000);
assert_eq!(&names[..4], ["Total", "total3", "Total2", "Total4"]);
assert_eq!(names.iter().map(|n| n.to_lowercase()).collect::<HashSet<_>>().len(), 10_000);
}
}

Some files were not shown because too many files have changed in this diff Show More