mirror of
https://github.com/storytold/gridcraft.git
synced 2026-10-24 10:46:50 -06:00
Integration: track t05
# Conflicts: # crates/ui-egui/tests/keyboard_shortcuts.rs # docs/ui-parity.md
This commit is contained in:
@@ -21,6 +21,7 @@ This page is the summary. The detail is in:
|
||||
|
||||
| Number | Value | Kind |
|
||||
|---|---|---|
|
||||
| 2026-10-14 | Editing a cell with text that would read back as a number, boolean, date or formula (entered with a leading apostrophe) shows the apostrophe in the editor and formula bar, so Enter unchanged keeps it text (#1362) |
|
||||
| **Feature breadth** (weighted) | **~75%** | estimated, anchored on two measurements: ribbon catalog 274 / 290 (94.5%), worksheet functions 513 / 523 (98.1%; 504 working, 96.4%) |
|
||||
| **Ready for real work** | **~50%** | estimated from written weights (51.6); [target-app-parity.md](docs/target-app-parity.md) |
|
||||
| **Mainstream practitioner** | **~50%** | estimated: typical analyst/finance user, weekly features, × 0.90 interaction × 0.92 stability × 0.88 file exchange; [method](docs/target-app-parity.md#mainstream-practitioner) |
|
||||
|
||||
@@ -166,14 +166,28 @@ fn parse_plain(t: &str) -> Option<(f64, Option<&'static str>)> {
|
||||
Some((n, fmt))
|
||||
}
|
||||
|
||||
/// A plain ASCII number: digits, with an optional `.digits` fraction when `frac` is set. Rejects
|
||||
/// `nan`, `inf`, signs and exponents that `f64::from_str` would accept.
|
||||
fn plain_number(s: &str, frac: bool) -> Option<f64> {
|
||||
let (int, dec) = match s.split_once('.') {
|
||||
Some((i, d)) if frac => (i, Some(d)),
|
||||
_ => (s, None),
|
||||
};
|
||||
let digits = |x: &str| !x.is_empty() && x.bytes().all(|c| c.is_ascii_digit());
|
||||
if !digits(int) || dec.is_some_and(|d| !digits(d)) {
|
||||
return None;
|
||||
}
|
||||
s.parse().ok()
|
||||
}
|
||||
|
||||
/// `1/2` (only with a whole part, else it's a date), `1 1/2`, `0 3/4`.
|
||||
fn parse_fraction(t: &str) -> Option<f64> {
|
||||
let (whole, frac) = t.split_once(' ')?;
|
||||
let neg = whole.starts_with('-');
|
||||
let w: f64 = whole.trim_start_matches('-').parse().ok()?;
|
||||
let w = plain_number(whole.trim_start_matches('-'), false)?;
|
||||
let (a, b) = frac.trim().split_once('/')?;
|
||||
let a: f64 = a.parse().ok()?;
|
||||
let b: f64 = b.parse().ok()?;
|
||||
let a = plain_number(a, false)?;
|
||||
let b = plain_number(b, false)?;
|
||||
if b == 0.0 || !w.is_finite() {
|
||||
return None;
|
||||
}
|
||||
@@ -204,12 +218,12 @@ fn parse_time(t: &str) -> Option<(f64, bool, bool)> {
|
||||
if parts.len() == 1 && ampm.is_none() {
|
||||
return None;
|
||||
}
|
||||
if parts.len() > 3 || parts.iter().any(|p| p.is_empty()) {
|
||||
if parts.len() > 3 || parts.iter().any(|p| p.is_empty() || p.starts_with('+')) {
|
||||
return None;
|
||||
}
|
||||
let mut h: f64 = parts.first()?.parse().ok()?;
|
||||
let m: f64 = parts.get(1).map_or(Some(0.0), |p| p.parse().ok())?;
|
||||
let s: f64 = parts.get(2).map_or(Some(0.0), |p| p.parse().ok())?;
|
||||
let mut h = plain_number(parts.first()?, false)?;
|
||||
let m = parts.get(1).map_or(Some(0.0), |p| plain_number(p, false))?;
|
||||
let s = parts.get(2).map_or(Some(0.0), |p| plain_number(p, true))?;
|
||||
if m >= 60.0 || s >= 60.0 || h < 0.0 || m < 0.0 || s < 0.0 {
|
||||
return None;
|
||||
}
|
||||
@@ -231,7 +245,13 @@ fn parse_time(t: &str) -> Option<(f64, bool, bool)> {
|
||||
|
||||
fn parse_date(t: &str, sys: DateSystem) -> Option<(f64, &'static str)> {
|
||||
let t = t.trim().trim_end_matches(',');
|
||||
let num = |s: &str| s.trim().parse::<i64>().ok();
|
||||
let num = |s: &str| {
|
||||
let s = s.trim();
|
||||
if s.is_empty() || !s.bytes().all(|b| b.is_ascii_digit()) {
|
||||
return None;
|
||||
}
|
||||
s.parse::<i64>().ok()
|
||||
};
|
||||
// ISO yyyy-mm-dd or yyyy/mm/dd
|
||||
let seps: &[char] = &['/', '-', '.'];
|
||||
let parts: Vec<&str> = t.split(seps).collect();
|
||||
@@ -346,6 +366,21 @@ mod tests {
|
||||
parse_input(s, DateSystem::D1900)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_digit_time_and_fraction_parts_stay_text() {
|
||||
for s in ["1:nan", "nan:30", "1:30:NaN", "1 nan/2", "1 1/nan", "inf:00", "1e1:00", "+5:00"] {
|
||||
assert_eq!(p(s).value, Value::text(s), "{s}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signed_date_parts_stay_text() {
|
||||
for s in ["+3/4/2026", "3/+4/2026", "3/4/+2026", "1/1/2020 +5:00"] {
|
||||
assert_eq!(p(s).value, Value::text(s), "{s}");
|
||||
}
|
||||
assert_eq!(p("3/4/2026").value, Value::Number(46085.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn month_name_short_year() {
|
||||
for s in ["Mar 4, 26", "Mar 4 26", "March 4 26", "4 March 26", "Mar 4, 2026"] {
|
||||
|
||||
+166
-42
@@ -11,6 +11,11 @@
|
||||
const MAX_HTML: usize = 16 * 1024 * 1024;
|
||||
/// Widest `colspan` honoured (the sheet's column count).
|
||||
const MAX_SPAN: usize = gridcraft_core::MAX_COLS as usize;
|
||||
/// Tallest `rowspan` honoured (the sheet's row count).
|
||||
const MAX_ROW_SPAN: usize = gridcraft_core::MAX_ROWS as usize;
|
||||
/// Most cells, span placeholders included, one paste reads from the HTML. Past it the rest of the
|
||||
/// 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 and the number the source recorded for it, if any.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
@@ -19,25 +24,28 @@ pub(crate) struct HtmlCell {
|
||||
pub num: Option<f64>,
|
||||
}
|
||||
|
||||
/// The cells of the first table in `html`, row by row; a cell spanning columns is followed by
|
||||
/// empty cells so positions line up with the tab-separated text. Empty when there is no table.
|
||||
pub(crate) fn table_cells(html: &str) -> Vec<Vec<HtmlCell>> {
|
||||
let mut rows: Vec<Vec<HtmlCell>> = Vec::new();
|
||||
/// The cells of the first table in `html`, row by row, read no further than `max_rows` rows of
|
||||
/// `max_cols` cells (the pasted text's shape, itself clamped to the sheet) and [`MAX_HTML_CELLS`]
|
||||
/// in all. A cell spanning columns is followed by empty cells, and a column covered by a `rowspan`
|
||||
/// from above holds an empty cell, so positions line up with the tab-separated text. Empty when
|
||||
/// there is no table.
|
||||
pub(crate) fn table_cells(html: &str, max_rows: usize, max_cols: usize) -> Vec<Vec<HtmlCell>> {
|
||||
let mut grid = Grid::new(max_rows, max_cols);
|
||||
if html.len() > MAX_HTML {
|
||||
return rows;
|
||||
return grid.rows;
|
||||
}
|
||||
let mut cell: Option<(HtmlCell, usize)> = None;
|
||||
let mut cell: Option<OpenCell> = None;
|
||||
let mut in_table = false;
|
||||
let mut rest = html;
|
||||
while !rest.is_empty() {
|
||||
while !rest.is_empty() && !grid.full {
|
||||
let Some(lt) = rest.find('<') else {
|
||||
if let Some((c, _)) = cell.as_mut() {
|
||||
c.text.push_str(rest);
|
||||
if let Some(c) = cell.as_mut() {
|
||||
c.cell.text.push_str(rest);
|
||||
}
|
||||
break;
|
||||
};
|
||||
if let Some((c, _)) = cell.as_mut() {
|
||||
c.text.push_str(rest.get(..lt).unwrap_or_default());
|
||||
if let Some(c) = cell.as_mut() {
|
||||
c.cell.text.push_str(rest.get(..lt).unwrap_or_default());
|
||||
}
|
||||
rest = rest.get(lt..).unwrap_or_default();
|
||||
if let Some(after) = rest.strip_prefix("<!--") {
|
||||
@@ -65,49 +73,131 @@ pub(crate) fn table_cells(html: &str) -> Vec<Vec<HtmlCell>> {
|
||||
in_table = true;
|
||||
}
|
||||
("table", true) => {
|
||||
finish(&mut rows, &mut cell);
|
||||
if !rows.is_empty() {
|
||||
grid.finish(&mut cell);
|
||||
if !grid.rows.is_empty() {
|
||||
break;
|
||||
}
|
||||
in_table = false;
|
||||
}
|
||||
("tr", false) if in_table => {
|
||||
finish(&mut rows, &mut cell);
|
||||
rows.push(Vec::new());
|
||||
grid.finish(&mut cell);
|
||||
grid.start_row();
|
||||
}
|
||||
("td" | "th", false) if in_table => {
|
||||
finish(&mut rows, &mut cell);
|
||||
if rows.is_empty() {
|
||||
rows.push(Vec::new());
|
||||
}
|
||||
grid.finish(&mut cell);
|
||||
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 span = attr(attrs, "colspan").and_then(|v| v.trim().parse::<usize>().ok()).unwrap_or(1).clamp(1, MAX_SPAN);
|
||||
cell = Some((HtmlCell { text: String::new(), num }, span));
|
||||
let colspan = span(attrs, "colspan", MAX_SPAN);
|
||||
let rowspan = span(attrs, "rowspan", MAX_ROW_SPAN);
|
||||
cell = Some(OpenCell { cell: HtmlCell { text: String::new(), num }, colspan, rowspan });
|
||||
}
|
||||
("td" | "th", true) | ("tr", true) => finish(&mut rows, &mut cell),
|
||||
("td" | "th", true) | ("tr", true) => grid.finish(&mut cell),
|
||||
("br", _) => {
|
||||
if let Some((c, _)) = cell.as_mut() {
|
||||
c.text.push(' ');
|
||||
if let Some(c) = cell.as_mut() {
|
||||
c.cell.text.push(' ');
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
finish(&mut rows, &mut cell);
|
||||
rows
|
||||
grid.finish(&mut cell);
|
||||
grid.rows
|
||||
}
|
||||
|
||||
/// Closes the open cell (if any) into the last row, decoding its text.
|
||||
fn finish(rows: &mut Vec<Vec<HtmlCell>>, cell: &mut Option<(HtmlCell, usize)>) {
|
||||
let Some((mut c, span)) = cell.take() else { return };
|
||||
c.text = decode(&c.text);
|
||||
if rows.is_empty() {
|
||||
rows.push(Vec::new());
|
||||
/// A `colspan`/`rowspan` attribute: at least 1, at most `max` (the sheet's extent), so a hostile
|
||||
/// "999999999999" is clamped before anything is expanded.
|
||||
fn span(attrs: &str, name: &str, max: usize) -> usize {
|
||||
attr(attrs, name).and_then(|v| v.trim().parse::<usize>().ok()).unwrap_or(1).clamp(1, max)
|
||||
}
|
||||
|
||||
/// A cell whose closing tag hasn't been seen yet.
|
||||
struct OpenCell {
|
||||
cell: HtmlCell,
|
||||
colspan: usize,
|
||||
rowspan: usize,
|
||||
}
|
||||
|
||||
/// The table being read, with its size limits.
|
||||
struct Grid {
|
||||
rows: Vec<Vec<HtmlCell>>,
|
||||
max_rows: usize,
|
||||
max_cols: usize,
|
||||
/// Cells still allowed before the table is cut short.
|
||||
budget: usize,
|
||||
/// Per column: the row index (exclusive) down to which a `rowspan` from above covers it.
|
||||
covered_until: Vec<usize>,
|
||||
/// The row or cell budget ran out: nothing more is read.
|
||||
full: bool,
|
||||
}
|
||||
|
||||
impl Grid {
|
||||
fn new(max_rows: usize, max_cols: usize) -> Self {
|
||||
let max_rows = max_rows.min(MAX_ROW_SPAN);
|
||||
let max_cols = max_cols.min(MAX_SPAN);
|
||||
let budget = MAX_HTML_CELLS.min(max_rows.saturating_mul(max_cols));
|
||||
Grid { rows: Vec::new(), max_rows, max_cols, budget, covered_until: Vec::new(), full: false }
|
||||
}
|
||||
if let Some(row) = rows.last_mut() {
|
||||
row.push(c);
|
||||
let room = MAX_SPAN.saturating_sub(row.len());
|
||||
row.extend(std::iter::repeat_n(HtmlCell::default(), span.saturating_sub(1).min(room)));
|
||||
|
||||
/// Opens a new row, or marks the grid full when the pasted text has no more rows.
|
||||
fn start_row(&mut self) {
|
||||
if self.rows.len() >= self.max_rows {
|
||||
self.full = true;
|
||||
} else {
|
||||
self.rows.push(Vec::new());
|
||||
}
|
||||
}
|
||||
|
||||
/// Appends one cell to the current row; false when the row or the cell budget is used up.
|
||||
fn push(&mut self, cell: HtmlCell) -> bool {
|
||||
let Some(row) = self.rows.last_mut() else { return false };
|
||||
if row.len() >= self.max_cols {
|
||||
return false;
|
||||
}
|
||||
let Some(left) = self.budget.checked_sub(1) else {
|
||||
self.full = true;
|
||||
return false;
|
||||
};
|
||||
self.budget = left;
|
||||
row.push(cell);
|
||||
true
|
||||
}
|
||||
|
||||
/// Closes the open cell (if any) into the current row, decoding its text: first past the
|
||||
/// columns a `rowspan` above still covers, then the cell, then a placeholder per extra column.
|
||||
fn finish(&mut self, open: &mut Option<OpenCell>) {
|
||||
let Some(OpenCell { mut cell, colspan, rowspan }) = open.take() else { return };
|
||||
if self.rows.is_empty() {
|
||||
self.start_row();
|
||||
}
|
||||
let Some(r) = self.rows.len().checked_sub(1) else { return };
|
||||
while self.covered_until.get(self.col()).is_some_and(|&until| until > r) {
|
||||
if !self.push(HtmlCell::default()) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
let start = self.col();
|
||||
cell.text = decode(&cell.text);
|
||||
if !self.push(cell) {
|
||||
return;
|
||||
}
|
||||
for _ in 1..colspan {
|
||||
if !self.push(HtmlCell::default()) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if rowspan > 1 {
|
||||
let (end, until) = (self.col(), r.saturating_add(rowspan));
|
||||
if self.covered_until.len() < end {
|
||||
self.covered_until.resize(end, 0);
|
||||
}
|
||||
for u in self.covered_until.get_mut(start..end).into_iter().flatten() {
|
||||
*u = (*u).max(until);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The column the next cell of the current row lands in.
|
||||
fn col(&self) -> usize {
|
||||
self.rows.last().map_or(0, Vec::len)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -276,7 +366,7 @@ mod tests {
|
||||
let excel = r#"<html><head><style>td{mso-number-format:General}</style></head><body><table>
|
||||
<!--StartFragment--><tr height=20><td class=xl65 x:num="10000">Rp10,000.00</td><td x:str>Name</td></tr>
|
||||
<tr><td colspan=2 x:num="2.5">2.50 & up</td><td>x</td></tr><!--EndFragment--></table></body></html>"#;
|
||||
let rows = table_cells(excel);
|
||||
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) });
|
||||
assert_eq!(rows[0][1], HtmlCell { text: "Name".into(), num: None });
|
||||
@@ -286,10 +376,10 @@ mod tests {
|
||||
|
||||
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), vec![vec![HtmlCell { text: "Rp10,000.00".into(), num: Some(10000.0) }]]);
|
||||
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), vec![vec![HtmlCell { text: "1500".into(), num: Some(1500.0) }]]);
|
||||
assert_eq!(table_cells(quoted_gt, 1, 1), vec![vec![HtmlCell { text: "1500".into(), num: Some(1500.0) }]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -306,10 +396,44 @@ mod tests {
|
||||
"<style>",
|
||||
"<table><table><td>",
|
||||
] {
|
||||
let _ = table_cells(html);
|
||||
let _ = table_cells(html, MAX_ROW_SPAN, MAX_SPAN);
|
||||
}
|
||||
assert_eq!(table_cells("<table><td x:num='1e999'>1</td></table>")[0][0].num, None);
|
||||
assert!(table_cells("<table><tr><td colspan=999999999>a</td></tr></table>")[0].len() <= MAX_SPAN);
|
||||
assert_eq!(table_cells("<table><td x:num='1e999'>1</td></table>", 1, 1)[0][0].num, None);
|
||||
assert!(table_cells("<table><tr><td colspan=999999999>a</td></tr></table>", 1, usize::MAX)[0].len() <= MAX_SPAN);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn huge_spans_stay_within_the_cell_budget() {
|
||||
// #2171: 100,000 rows of `colspan=16384` once expanded to ~1.6 billion cells (OOM). Even
|
||||
// with the sheet's full extent as the limit, at most MAX_HTML_CELLS are materialized.
|
||||
let row = "<tr><td colspan=16384 rowspan=99999999999 x:num='1'>1</td><td colspan=4294967296>2</td></tr>";
|
||||
let html = format!("<table>{}</table>", row.repeat(100_000));
|
||||
let rows = table_cells(&html, usize::MAX, usize::MAX);
|
||||
let cells: usize = rows.iter().map(Vec::len).sum();
|
||||
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) });
|
||||
|
||||
// Bounded by the pasted text's shape, the same markup yields just that shape.
|
||||
let rows = table_cells(&html, 3, 2);
|
||||
assert_eq!(rows.iter().map(Vec::len).collect::<Vec<_>>(), [2, 2, 2]);
|
||||
assert_eq!(rows[0][0].num, Some(1.0));
|
||||
assert!(table_cells(&html, 0, 5).is_empty());
|
||||
assert!(table_cells(&html, 4, 0).iter().all(Vec::is_empty));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rowspan_keeps_later_rows_in_their_columns() {
|
||||
// A merged A1:A2 is one `rowspan=2` cell; row 2 omits it, and its text has an empty field there.
|
||||
let html = r#"<table><tr><td rowspan=2 colspan=2>Merged</td><td x:num="1">1</td></tr>
|
||||
<tr><td x:num="2">2</td></tr><tr><td x:num="3">3</td><td>c</td></tr></table>"#;
|
||||
let rows = table_cells(html, 3, 3);
|
||||
assert_eq!(rows.iter().map(Vec::len).collect::<Vec<_>>(), [3, 3, 2]);
|
||||
assert_eq!(rows[0][2].num, Some(1.0));
|
||||
assert_eq!((rows[1][0].clone(), rows[1][1].clone()), (HtmlCell::default(), HtmlCell::default()));
|
||||
assert_eq!(rows[1][2].num, Some(2.0));
|
||||
assert_eq!(rows[2][0].num, Some(3.0), "the span ends after its second row");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -165,7 +165,17 @@ pub(crate) fn input_to_cell(input: &str, sheet: usize, at: CellRef, wb: &mut gri
|
||||
}
|
||||
return Ok(Some(cell));
|
||||
}
|
||||
let parsed = gridcraft_core::parse::parse_input(input, wb.date_system);
|
||||
let mut parsed = gridcraft_core::parse::parse_input(input, wb.date_system);
|
||||
// Like Excel's automatic percent entry: a bare number of 1 or more typed into a cell already
|
||||
// formatted as a percentage is a percent (3 -> 3%); smaller numbers are taken as fractions.
|
||||
if parsed.format.is_none()
|
||||
&& !input.contains('%')
|
||||
&& wb.styles.get(style).num_fmt.as_str().ends_with('%')
|
||||
&& let Value::Number(n) = parsed.value
|
||||
&& n.abs() >= 1.0
|
||||
{
|
||||
parsed.value = Value::Number(n / 100.0);
|
||||
}
|
||||
let mut style = style;
|
||||
if let Some(code) = parsed.format {
|
||||
let cur = wb.styles.get(style).num_fmt.as_str().to_string();
|
||||
@@ -834,10 +844,11 @@ 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);
|
||||
let rows: Vec<Vec<String>> = parse_tsv(text);
|
||||
// The HTML flavour, when the host passed it: the number behind each cell's shown text.
|
||||
let html = str_param(p, "html").map(crate::clip_html::table_cells).unwrap_or_default();
|
||||
let h = rows.len();
|
||||
let w = rows.iter().map(Vec::len).max().unwrap_or(0);
|
||||
// The HTML flavour, when the host passed it: the number behind each cell's shown text. Read
|
||||
// only as far as the text's own shape, so spans in the markup can't expand without bound.
|
||||
let html = str_param(p, "html").map(|html| crate::clip_html::table_cells(html, h, w)).unwrap_or_default();
|
||||
edit(s, |cx| {
|
||||
for (ri, row) in rows.iter().enumerate() {
|
||||
for (ci, v) in row.iter().enumerate() {
|
||||
@@ -977,6 +988,10 @@ fn paste_special(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let src_wb = clip.wb.clone();
|
||||
let Some(src_sheet) = src_wb.sheet(clip.sheet) else { return Err(EngineError::Other("The copied sheet no longer exists.".into())) };
|
||||
let src_sheet_name = src_sheet.name.clone();
|
||||
if cut_move && at.offset((tiles_r * h).saturating_sub(1) as i64, (tiles_c * w).saturating_sub(1) as i64).is_none() {
|
||||
// Check the whole footprint before the source is emptied, so a move that overflows the grid loses nothing.
|
||||
return Err(EngineError::Other("The cut area doesn't fit at this position; nothing was moved.".into()));
|
||||
}
|
||||
edit(s, |cx| {
|
||||
// Styles from another workbook must be re-interned.
|
||||
let mut style_map = std::collections::HashMap::new();
|
||||
@@ -993,6 +1008,13 @@ fn paste_special(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let source = cx.sheet_mut(clip.sheet)?;
|
||||
moved_pictures = source.cell_pictures.iter().filter(|(c, _)| src.contains(**c)).map(|(c, p)| (*c, p.clone())).collect();
|
||||
let taken = source.take_cells(src);
|
||||
// Notes, hyperlinks and merged areas sit beside the cells, and a cut takes them
|
||||
// with the block: the destination is written from the clipboard snapshot taken
|
||||
// before the cut, so removing them here loses nothing and leaves the old place
|
||||
// clean instead of leaving one merged block and one note behind.
|
||||
source.comments.retain(|c, _| !src.contains(*c));
|
||||
source.hyperlinks.retain(|c, _| !src.contains(*c));
|
||||
source.merges.retain(|m| !src.contains_range(m));
|
||||
for (c, _) in &taken {
|
||||
cx.touch(clip.sheet, *c);
|
||||
}
|
||||
@@ -1172,6 +1194,9 @@ fn paste_special(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
if let Some(cm) = src_sheet.comments.get(&sc).cloned() {
|
||||
cx.sheet_mut(dest_sheet)?.comments.insert(dest, cm);
|
||||
}
|
||||
if let Some(h) = src_sheet.hyperlinks.get(&sc).cloned() {
|
||||
cx.sheet_mut(dest_sheet)?.hyperlinks.insert(dest, h);
|
||||
}
|
||||
cx.sheet_mut(dest_sheet)?.set_cell(dest, new);
|
||||
}
|
||||
_ => cx.sheet_mut(dest_sheet)?.set_cell(dest, new),
|
||||
@@ -1366,7 +1391,7 @@ fn fill_dir(s: &mut Session, p: &Json, dr: i64, dc: i64) -> Result<Json> {
|
||||
None => return Ok(Json::Null),
|
||||
}
|
||||
}
|
||||
if r.count() > 5_000_000 {
|
||||
if r.count() > crate::fill::MAX_FILL_CELLS {
|
||||
return Err(bad("edit.fill", "range too large"));
|
||||
}
|
||||
// With one row selected, Fill Down copies from the row above (Excel behaviour).
|
||||
@@ -1403,9 +1428,15 @@ fn auto_fill(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
"extend" => crate::fill::FillMode::Extend,
|
||||
_ => crate::fill::FillMode::Series,
|
||||
};
|
||||
if target.count() > 5_000_000 || src.count() > 5_000_000 {
|
||||
if target.count() > crate::fill::MAX_FILL_CELLS || src.count() > crate::fill::MAX_FILL_CELLS {
|
||||
return Err(bad("edit.autoFill", "range too large"));
|
||||
}
|
||||
// Excel refuses a target that doesn't hold the source in a corner. Such targets used to fill
|
||||
// every source lane across the whole target extent: billions of cells for whole rows (`2:3`)
|
||||
// into whole columns (`A:A`).
|
||||
if !crate::fill::holds_source_in_corner(src, target) {
|
||||
return Err(bad("edit.autoFill", "the target must contain the source in one of its corners"));
|
||||
}
|
||||
edit(s, |cx| {
|
||||
crate::fill::fill(cx, sheet, src, target, mode)?;
|
||||
*cx.sel = crate::Selection { active: src.start, anchor: src.start, ranges: vec![target.union(&src)] };
|
||||
@@ -1590,10 +1621,24 @@ fn replace(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
Ok(json!({"replaced": n}))
|
||||
}
|
||||
|
||||
/// Whether case-folding `s` maps every char to exactly one char of the same UTF-8 length, so
|
||||
/// byte positions in the folded text still line up with the original's character boundaries.
|
||||
fn folds_in_place(s: &str) -> bool {
|
||||
s.chars().all(|c| {
|
||||
let mut folded = c.to_lowercase();
|
||||
let first = folded.next();
|
||||
folded.next().is_none() && first.is_some_and(|l| l.len_utf8() == c.len_utf8())
|
||||
})
|
||||
}
|
||||
|
||||
fn replace_ci(text: &str, what: &str, with: &str) -> String {
|
||||
let lower = text.to_lowercase();
|
||||
let w = what.to_lowercase();
|
||||
if lower.len() != text.len() || w.is_empty() {
|
||||
// Slicing `text` at positions found in `lower` is only sound when folding kept every byte
|
||||
// boundary: a length check alone misses foldings that keep the total length while shifting
|
||||
// boundaries (U+212A KELVIN SIGN shrinks 3→1 bytes, U+0130 grows 2→3), where the tail used
|
||||
// to be dropped. Those fall back to the case-sensitive replace, as length changes already do.
|
||||
if lower.len() != text.len() || w.is_empty() || !folds_in_place(text) || !folds_in_place(what) {
|
||||
return text.replace(what, with);
|
||||
}
|
||||
let mut out = String::new();
|
||||
@@ -1793,8 +1838,44 @@ mod tests {
|
||||
assert_eq!(get(&mut s, "E1")["value"], json!(5.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cut_paste_that_overflows_the_grid_moves_nothing() {
|
||||
// #997: the source was emptied first and the cells that fell off the grid were skipped.
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [[1], [2]]})).unwrap();
|
||||
let get = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap();
|
||||
s.execute("edit.cut", json!({"range": "A1:A2"})).unwrap();
|
||||
// Transposed, the 2 rows become 2 columns and the second one is past XFD.
|
||||
assert!(s.execute("edit.pasteSpecial", json!({"what": "all", "transpose": true, "at": "XFD1"})).is_err());
|
||||
assert_eq!(get(&mut s, "A1")["value"], json!(1.0));
|
||||
assert_eq!(get(&mut s, "A2")["value"], json!(2.0));
|
||||
// Not transposed, the second row would be past the last row.
|
||||
assert!(s.execute("edit.pasteSpecial", json!({"what": "all", "at": "A1048576"})).is_err());
|
||||
assert_eq!(get(&mut s, "A1")["value"], json!(1.0));
|
||||
assert_eq!(get(&mut s, "A2")["value"], json!(2.0));
|
||||
// Not transposed it still fits in column XFD.
|
||||
s.execute("edit.pasteSpecial", json!({"what": "all", "at": "XFD1"})).unwrap();
|
||||
assert_eq!(get(&mut s, "XFD1")["value"], json!(1.0));
|
||||
assert_eq!(get(&mut s, "XFD2")["value"], json!(2.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replace_case_insensitive() {
|
||||
assert_eq!(replace_ci("Apple apple APPLE", "apple", "pear"), "pear pear pear");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replace_case_insensitive_keeps_text_when_folding_moves_boundaries() {
|
||||
// U+212A KELVIN SIGN folds to `k` (3→1 bytes) while U+0130 folds to `i` plus a
|
||||
// combining dot (2→3 bytes): the folded text is the same length but its byte positions
|
||||
// no longer line up with the original, and the old slicing dropped the tail.
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
let text = "\u{212A}\u{0130}\u{0130}";
|
||||
s.execute("cell.set", json!({"cell": "A1", "input": text})).unwrap();
|
||||
s.execute("edit.replace", json!({"what": "k", "with": "x"})).unwrap();
|
||||
let sh = s.doc().unwrap().wb.active().unwrap();
|
||||
assert_eq!(sh.value(CellRef::new(0, 0)), Value::text(text));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -488,8 +488,15 @@ fn scenario_show(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let (_, cells) = cell_list(&d.wb, &sc.changing, CMD, MAX_SCENARIO_CELLS)?;
|
||||
edit(s, |cx| {
|
||||
for (c, v) in cells.iter().zip(sc.values.iter()) {
|
||||
let input = super::edit::json_to_input(v);
|
||||
let cell = super::edit::input_to_cell(&input, si, *c, &mut cx.wb)?;
|
||||
// Stored numbers are values, not typed text: entering them through the parser would
|
||||
// apply percent entry (9 -> 9%) to a Percentage-formatted cell.
|
||||
let cell = match v.as_f64() {
|
||||
Some(n) => {
|
||||
let style = cx.wb.sheet(si).map(|sh| sh.style_id(*c)).unwrap_or_default();
|
||||
Some(Cell { value: Value::Number(n), formula: None, style })
|
||||
}
|
||||
None => super::edit::input_to_cell(&super::edit::json_to_input(v), si, *c, &mut cx.wb)?,
|
||||
};
|
||||
let sh = cx.sheet_mut(si)?;
|
||||
match cell {
|
||||
Some(cell) => sh.set_cell(*c, cell),
|
||||
@@ -1199,6 +1206,17 @@ mod tests {
|
||||
assert!(!r.converged);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scenario_show_restores_a_percentage_cells_stored_number() {
|
||||
let mut s = s();
|
||||
s.execute("home.numberFormat", json!({"range": "B2", "format": "Percentage"})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "B2", "input": "5"})).unwrap();
|
||||
s.execute("data.scenarioAdd", json!({"name": "Low", "changing": "B2"})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "B2", "input": "9"})).unwrap();
|
||||
s.execute("data.scenarioShow", json!({"name": "Low"})).unwrap();
|
||||
assert_eq!(n(&s, "B2"), 0.05);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scenarios() {
|
||||
let mut s = s();
|
||||
|
||||
@@ -33,6 +33,25 @@ pub fn format(v: &Value, code: &str, wb: &Workbook) -> Formatted {
|
||||
format_value(v, &parsed(code), wb.date_system)
|
||||
}
|
||||
|
||||
/// Whether typing `t` back into a General cell would not give the text `t` again (it would parse
|
||||
/// as a number, boolean, error, date or formula, or lose a leading apostrophe). Such text is shown
|
||||
/// for editing with a leading `'`, as Excel does for text carrying a quote prefix, so that
|
||||
/// committing the editor unchanged keeps the cell as text.
|
||||
fn text_needs_quote_prefix(t: &str, sys: gridcraft_core::date::DateSystem) -> bool {
|
||||
if t.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let mut chars = t.chars();
|
||||
let (first, second) = (chars.next(), chars.next());
|
||||
if matches!(first, Some('=' | '\'')) {
|
||||
return true;
|
||||
}
|
||||
if matches!(first, Some('+' | '-')) && second.is_some_and(|c| c.is_ascii_alphabetic() || c == '(') {
|
||||
return true;
|
||||
}
|
||||
!matches!(gridcraft_core::parse::parse_input(t, sys).value, Value::Text(ref x) if x.as_ref() == t)
|
||||
}
|
||||
|
||||
/// What the cell editor and the formula bar start from. Same as `Cell::input_text`, except that a
|
||||
/// constant number in a date or time format is shown as a date (as the grid shows it), not as its
|
||||
/// serial. When the formatted text would not read back as the same date (for example
|
||||
@@ -41,6 +60,13 @@ pub fn format(v: &Value, code: &str, wb: &Workbook) -> Formatted {
|
||||
pub fn edit_text(wb: &Workbook, sheet: &Sheet, c: CellRef) -> String {
|
||||
let raw = sheet.input_text(c);
|
||||
let Some(cell) = sheet.cell(c) else { return raw };
|
||||
if let Value::Text(t) = &cell.value
|
||||
&& cell.formula.is_none()
|
||||
&& wb.styles.get(cell.style).num_fmt.as_str() != "@"
|
||||
&& text_needs_quote_prefix(t, wb.date_system)
|
||||
{
|
||||
return format!("'{raw}");
|
||||
}
|
||||
let Value::Number(n) = &cell.value else { return raw };
|
||||
let code = wb.styles.get(cell.style).num_fmt.as_str();
|
||||
if cell.formula.is_some() || !parsed(code).is_date() {
|
||||
|
||||
+101
-9
@@ -45,6 +45,18 @@ fn lists() -> Vec<Vec<String>> {
|
||||
]
|
||||
}
|
||||
|
||||
/// The most cells one fill may write: the size cap the fill commands put on their ranges.
|
||||
pub const MAX_FILL_CELLS: u64 = 5_000_000;
|
||||
|
||||
/// Whether `target` is a shape Excel's AutoFill accepts for `src`: it contains the source and the
|
||||
/// source sits in one of its corners. Excel refuses a target that doesn't contain the source
|
||||
/// (`2:3` into `A:A`, `H4:H9` into `D:F`) or holds it away from every corner (`H4:H9` into `H:H`).
|
||||
pub fn holds_source_in_corner(src: RangeRef, target: RangeRef) -> bool {
|
||||
target.contains_range(&src)
|
||||
&& (target.start.row == src.start.row || target.end.row == src.end.row)
|
||||
&& (target.start.col == src.start.col || target.end.col == src.end.col)
|
||||
}
|
||||
|
||||
/// Fills `target` (which contains `src` at one edge) from the pattern in `src`.
|
||||
pub fn fill(cx: &mut Ctx, sheet: usize, src: RangeRef, target: RangeRef, mode: FillMode) -> Result<()> {
|
||||
let custom = cx.wb.custom_lists.clone();
|
||||
@@ -56,6 +68,18 @@ pub fn fill(cx: &mut Ctx, sheet: usize, src: RangeRef, target: RangeRef, mode: F
|
||||
} else {
|
||||
target.end.col > src.end.col || target.start.col == src.start.col
|
||||
};
|
||||
// Every source lane writes one cell per step past the source: bound that product, not the
|
||||
// source and target separately (whole rows into whole columns was ~17 billion writes).
|
||||
let (lane_count, steps) = if vertical {
|
||||
let steps = if forward { target.end.row.saturating_sub(src.end.row) } else { src.start.row.saturating_sub(target.start.row) };
|
||||
(src.width(), steps)
|
||||
} else {
|
||||
let steps = if forward { target.end.col.saturating_sub(src.end.col) } else { src.start.col.saturating_sub(target.start.col) };
|
||||
(src.height(), steps)
|
||||
};
|
||||
if u64::from(lane_count).saturating_mul(u64::from(steps)) > MAX_FILL_CELLS {
|
||||
return Err(crate::EngineError::Other("The fill is too large.".into()));
|
||||
}
|
||||
let lanes: Vec<u32> = if vertical { (src.start.col..=src.end.col).collect() } else { (src.start.row..=src.end.row).collect() };
|
||||
let src_len = if vertical { src.height() } else { src.width() };
|
||||
let mut writes: Vec<(CellRef, Option<Cell>)> = Vec::new();
|
||||
@@ -178,6 +202,7 @@ enum Pattern {
|
||||
last: f64,
|
||||
first: f64,
|
||||
months: i64,
|
||||
sys: gridcraft_core::DateSystem,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -212,7 +237,7 @@ impl Pattern {
|
||||
&& (a.month != b.month || a.year != b.year)
|
||||
{
|
||||
let months = (b.year as i64 - a.year as i64) * 12 + b.month as i64 - a.month as i64;
|
||||
return Pattern::Months { last, first, months };
|
||||
return Pattern::Months { last, first, months, sys };
|
||||
}
|
||||
}
|
||||
// Least-squares step for 2+ values (Excel uses a linear trend).
|
||||
@@ -286,12 +311,15 @@ impl Pattern {
|
||||
}
|
||||
Pattern::TextNum { prefix, suffix, last, first, step, width } => {
|
||||
let base = if forward { *last } else { *first };
|
||||
let n = base + step * k * sign;
|
||||
// Text numbers come from input and can sit near i64::MAX (`X 9223372036854775807`):
|
||||
// an overflowing series yields nothing — the cell keeps its source value — rather
|
||||
// than wrapping in release or panicking in debug.
|
||||
let n = step.checked_mul(k)?.checked_mul(sign)?.checked_add(base)?;
|
||||
// `Qtr3`, `Quarter 3` wrap after 4 like `Q3`.
|
||||
let quarter = suffix.is_empty()
|
||||
&& (1..=4).contains(&base)
|
||||
&& ["q", "qtr", "quarter"].contains(&prefix.trim_end().to_ascii_lowercase().as_str());
|
||||
let n = if quarter { (n - 1).rem_euclid(4) + 1 } else { n.abs() };
|
||||
let n = if quarter { n.checked_sub(1)?.rem_euclid(4) + 1 } else { n.checked_abs()? };
|
||||
Some(Value::text(format!("{prefix}{n:0width$}{}", ordinal_suffix(n, suffix), width = *width)))
|
||||
}
|
||||
Pattern::List { list, last, first, step, upper, lower } => {
|
||||
@@ -310,9 +338,9 @@ impl Pattern {
|
||||
s
|
||||
}))
|
||||
}
|
||||
Pattern::Months { last, first, months } => {
|
||||
Pattern::Months { last, first, months, sys } => {
|
||||
let base = if forward { *last } else { *first };
|
||||
let s = add_months_clamped(gridcraft_core::DateSystem::D1900, base, months.checked_mul(k * sign)?)?;
|
||||
let s = add_months_clamped(*sys, base, months.checked_mul(k * sign)?)?;
|
||||
Some(Value::number(s))
|
||||
}
|
||||
}
|
||||
@@ -455,13 +483,13 @@ pub fn flash_fill(cx: &mut Ctx, sheet: usize, at: CellRef) -> Result<usize> {
|
||||
// Header row: skip a first row whose target is text and source looks like a header? Keep simple.
|
||||
let mut examples = Vec::new();
|
||||
let mut todo = Vec::new();
|
||||
// Flash Fill works on the text cells show (a date as formatted, not its serial number).
|
||||
for r in top..=bottom {
|
||||
let src = sh.value(CellRef::new(r, src_col)).display();
|
||||
let dst = sh.value(CellRef::new(r, at.col));
|
||||
if dst.is_empty() {
|
||||
let src = crate::display::cell_text(&cx.wb, sh, CellRef::new(r, src_col));
|
||||
if sh.value(CellRef::new(r, at.col)).is_empty() {
|
||||
todo.push((r, src));
|
||||
} else {
|
||||
examples.push((src, dst.display()));
|
||||
examples.push((src, crate::display::cell_text(&cx.wb, sh, CellRef::new(r, at.col))));
|
||||
}
|
||||
}
|
||||
if examples.is_empty() {
|
||||
@@ -616,6 +644,18 @@ fn synthesize(src: &str, dst: &str) -> Vec<Program> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn flash_fill_reads_shown_text() {
|
||||
let mut s = crate::Session::new();
|
||||
s.new_workbook();
|
||||
let values = serde_json::json!([["2024-01-15", "15/01/2024"], ["2023-12-03", null]]);
|
||||
s.execute("range.setValues", serde_json::json!({"range": "A1", "values": values})).unwrap();
|
||||
s.execute("selection.set", serde_json::json!({"cell": "B2"})).unwrap();
|
||||
s.execute("data.flashFill", serde_json::json!({})).unwrap();
|
||||
let sh = s.doc().unwrap().wb.active().unwrap();
|
||||
assert_eq!(sh.value(CellRef::new(1, 1)), Value::from("03/12/2023"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flash_programs() {
|
||||
let p = learn(&[("John Smith".into(), "Smith, John".into())]).unwrap();
|
||||
@@ -665,4 +705,56 @@ mod tests {
|
||||
let growth: Vec<String> = (1..6).map(|r| crate::display::cell_text(&d.wb, sh, CellRef::new(r, 0))).collect();
|
||||
assert_eq!(growth, ["2.5", "1.25", "", "", ""]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_series_near_i64_max_neither_wraps_nor_panics() {
|
||||
let mut s = crate::Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("cell.set", serde_json::json!({"cell": "A1", "input": "X 0"})).unwrap();
|
||||
s.execute("cell.set", serde_json::json!({"cell": "A2", "input": "X 9223372036854775807"})).unwrap();
|
||||
// The detected step is i64::MAX, so the next value overflows i64. The cell keeps its
|
||||
// source value instead of panicking in debug or wrapping in release.
|
||||
s.execute("edit.autoFill", serde_json::json!({"source": "A1:A2", "target": "A1:A3"})).unwrap();
|
||||
let d = s.doc().unwrap();
|
||||
let sh = d.wb.active().unwrap();
|
||||
assert_eq!(crate::display::cell_text(&d.wb, sh, CellRef::new(2, 0)), "X 0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn autofill_refuses_targets_that_do_not_extend_the_source() {
|
||||
// Whole rows into whole columns once filled every source column down the whole sheet
|
||||
// (~17 billion writes) and aborted the process on a 24-51 GB allocation (fuzz seeds 1064,
|
||||
// 1114, 1775). Excel refuses every one of these shapes.
|
||||
let mut s = crate::Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("range.setValues", serde_json::json!({"range": "A2", "values": [[1, "Item 1"], [2, "Item 2"]]})).unwrap();
|
||||
s.execute("range.setValues", serde_json::json!({"range": "H4", "values": [[10], [20]]})).unwrap();
|
||||
let before = s.doc().unwrap().wb.active().unwrap().cells.len();
|
||||
for (mode, source, target) in [
|
||||
("series", "2:3", "A:A"),
|
||||
("alternate", "2:3", "A:A"),
|
||||
("series", "8:10", "C:E"),
|
||||
("copy", "24:24", "B:D"),
|
||||
("values", "H4:H9", "D:F"),
|
||||
("series", "H4:H9", "H:H"),
|
||||
("series", "A2:A3", "A1:A5"),
|
||||
("series", "A2:A6", "A2:A4"),
|
||||
("series", "B2:B3", "A2:A3"),
|
||||
] {
|
||||
let r = s.execute("edit.autoFill", serde_json::json!({"mode": mode, "source": source, "target": target}));
|
||||
assert!(r.is_err(), "{source} -> {target} should be refused");
|
||||
}
|
||||
assert_eq!(s.doc().unwrap().wb.active().unwrap().cells.len(), before, "a refused fill writes nothing");
|
||||
// Targets holding the source in a corner still fill, whole rows and diagonal drags included.
|
||||
s.execute("edit.autoFill", serde_json::json!({"source": "2:3", "target": "2:6"})).unwrap();
|
||||
s.execute("edit.autoFill", serde_json::json!({"source": "A2:A3", "target": "A1:A3"})).unwrap();
|
||||
let sh = s.doc().unwrap().wb.active().unwrap();
|
||||
let text = |a: &str| crate::display::cell_text(&s.doc().unwrap().wb, sh, CellRef::parse(a).unwrap());
|
||||
assert_eq!([text("A1"), text("A6"), text("B6")], ["0", "5", "Item 5"]);
|
||||
assert!(holds_source_in_corner(RangeRef::parse("B5").unwrap(), RangeRef::parse("B3:B5").unwrap()));
|
||||
assert!(holds_source_in_corner(RangeRef::parse("A:A").unwrap(), RangeRef::parse("A:C").unwrap()));
|
||||
assert!(holds_source_in_corner(RangeRef::parse("B5:C9").unwrap(), RangeRef::parse("B5:D13").unwrap()));
|
||||
assert!(holds_source_in_corner(RangeRef::parse("B1:B2").unwrap(), RangeRef::parse("A1:B4").unwrap()));
|
||||
assert!(!holds_source_in_corner(RangeRef::parse("A:A").unwrap(), RangeRef::parse("1:1").unwrap()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,6 +78,20 @@ fn pasted_currency_text_keeps_the_number_from_the_clipboard_html() {
|
||||
assert_eq!(v(&s, "A4"), Value::text("Rp10,000.00"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pasting_html_with_huge_spans_reads_only_the_text_shape() {
|
||||
// #2171: a web page's clipboard HTML with enormous colspan/rowspan crashed paste (OOM). The
|
||||
// HTML is now read only as far as the tab-separated text reaches; its numbers still apply.
|
||||
let mut s = s();
|
||||
let row = "<tr><td colspan=16384 rowspan=1048576 x:num='5'>Rp5</td><td colspan=999999999999>x</td></tr>";
|
||||
let html = format!("<table>{}</table>", row.repeat(100_000));
|
||||
let r = s.execute("edit.paste", json!({"at": "B2", "text": "Rp5\tnext\nlast", "html": html})).unwrap();
|
||||
assert_eq!(r, json!({"rows": 2, "cols": 2}));
|
||||
assert_eq!(v(&s, "B2"), Value::number(5.0));
|
||||
assert_eq!(v(&s, "C2"), Value::text("next"));
|
||||
assert_eq!(v(&s, "B3"), Value::text("last"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editing_a_name_renames_it_in_formulas() {
|
||||
// Name Manager › Edit…: `oldName` edits the name in place; formulas that use it follow.
|
||||
@@ -3144,6 +3158,33 @@ fn fill_series_year_clamps_leap_day() {
|
||||
assert_eq!(v(&s, "A5"), ymd(2032, 2, 29));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn autofill_month_pattern_uses_workbook_date_system() {
|
||||
use std::sync::Arc;
|
||||
|
||||
use gridcraft_core::date::serial_from_ymd;
|
||||
use gridcraft_core::{DateSystem, RangeRef};
|
||||
|
||||
for sys in [DateSystem::D1900, DateSystem::D1904] {
|
||||
for (source, target, seeds, expected) in [
|
||||
("A1:A2", "A1:A6", ["2026-01-31", "2026-03-31"], ["2026-01-31", "2026-03-31", "2026-05-31", "2026-07-31", "2026-09-30", "2026-11-30"]),
|
||||
("A5:A6", "A1:A6", ["2026-09-30", "2026-11-30"], ["2026-01-30", "2026-03-30", "2026-05-30", "2026-07-30", "2026-09-30", "2026-11-30"]),
|
||||
] {
|
||||
let mut s = s();
|
||||
Arc::make_mut(&mut s.doc_mut().unwrap().wb).date_system = sys;
|
||||
let start = RangeRef::parse(source).unwrap().start.a1();
|
||||
s.execute("range.setValues", json!({"range": start, "values": [[seeds[0]], [seeds[1]]]})).unwrap();
|
||||
s.execute("edit.autoFill", json!({"source": source, "target": target})).unwrap();
|
||||
for (row, date) in expected.iter().enumerate() {
|
||||
let parts: Vec<i64> = date.split('-').map(|p| p.parse().unwrap()).collect();
|
||||
let expected = Value::Number(serial_from_ymd(sys, parts[0], parts[1], parts[2]).unwrap());
|
||||
let cell = CellRef::new(row as u32, 0).a1();
|
||||
assert_eq!(v(&s, &cell), expected, "{sys:?} {source} -> {target}, {cell}: {date}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn autofill_month_pattern_clamps_to_month_end() {
|
||||
let mut s = s();
|
||||
@@ -3172,6 +3213,36 @@ fn notes_comments_and_links_survive_a_json_save() {
|
||||
assert_eq!(after.cells, before.cells);
|
||||
}
|
||||
|
||||
/// A cut moves the block's notes, hyperlinks and merged areas with it. The source's cells and
|
||||
/// pictures were removed but the block's merges and notes were not, so one merged block became
|
||||
/// two (an empty one left at the old place) and a note ended up on both the old and the new
|
||||
/// cell. Hyperlinks were not transferred at all.
|
||||
#[test]
|
||||
fn a_cut_moves_the_sources_notes_hyperlinks_and_merges() {
|
||||
let mut s = s();
|
||||
s.execute("cell.set", json!({"cell": "A1", "input": "x"})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "B1", "input": "y"})).unwrap();
|
||||
s.execute("review.newNote", json!({"cell": "A1", "text": "note", "author": "Example"})).unwrap();
|
||||
s.execute("insert.link", json!({"cell": "A1", "target": "https://example.com"})).unwrap();
|
||||
s.execute("home.mergeCells", json!({"range": "A1:B1"})).unwrap();
|
||||
|
||||
s.execute("edit.cut", json!({"range": "A1:B1"})).unwrap();
|
||||
s.execute("edit.paste", json!({"at": "D1"})).unwrap();
|
||||
|
||||
let d = s.doc().unwrap();
|
||||
let sh = d.wb.active().unwrap();
|
||||
let (a1, d1) = (CellRef::parse("A1").unwrap(), CellRef::parse("D1").unwrap());
|
||||
assert_eq!(sh.merges, vec![gridcraft_core::RangeRef::parse("D1:E1").unwrap()], "the old merge must not stay behind");
|
||||
assert!(!sh.comments.contains_key(&a1), "the note moved");
|
||||
assert!(sh.comments.contains_key(&d1), "the note is at the new cell");
|
||||
assert!(!sh.hyperlinks.contains_key(&a1), "the hyperlink moved");
|
||||
assert!(sh.hyperlinks.contains_key(&d1), "the hyperlink is at the new cell");
|
||||
// The cells themselves moved as before; E1 is covered by the pasted merge (merging
|
||||
// A1:B1 cleared B1, so only the anchor's content travels).
|
||||
assert_eq!(v(&s, "D1"), Value::text("x"));
|
||||
assert!(v(&s, "A1").is_empty() && v(&s, "B1").is_empty());
|
||||
}
|
||||
|
||||
/// Insert › Link sorts a target as Excel's dialog does: a sheet reference, a defined name or `#…` is
|
||||
/// a place in this document, anything else an existing file or web page. A file saved as a
|
||||
/// `location` opens nothing in Excel (#800).
|
||||
@@ -3615,6 +3686,29 @@ fn paste_special_validation_moves_relative_references() {
|
||||
assert!(ok(&mut s, "C3", "20"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editing_quote_prefixed_text_keeps_it_text() {
|
||||
let mut s = s();
|
||||
for (i, input) in ["'00123", "'=1+1", "'TRUE", "''abc"].iter().enumerate() {
|
||||
let cell = format!("A{}", i + 1);
|
||||
s.execute("cell.set", json!({"cell": cell, "input": input})).unwrap();
|
||||
let at = CellRef::parse(&cell).unwrap();
|
||||
let before = v(&s, &cell);
|
||||
let edit = {
|
||||
let d = s.doc().unwrap();
|
||||
crate::display::edit_text(&d.wb, d.wb.active().unwrap(), at)
|
||||
};
|
||||
assert_eq!(&edit, input, "edit text of {cell}");
|
||||
// Committing the editor unchanged leaves the cell as it was.
|
||||
s.execute("cell.set", json!({"cell": cell, "input": edit})).unwrap();
|
||||
assert_eq!(v(&s, &cell), before, "{cell}");
|
||||
}
|
||||
// Plain text is shown as typed, without a prefix.
|
||||
s.execute("cell.set", json!({"cell": "B1", "input": "hello"})).unwrap();
|
||||
let d = s.doc().unwrap();
|
||||
assert_eq!(crate::display::edit_text(&d.wb, d.wb.active().unwrap(), CellRef::parse("B1").unwrap()), "hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn csv_as_text_keeps_identifier_columns_as_written() {
|
||||
let csv = "gtin,zip,flag,day\n8435392892278,01234,TRUE,2026-03-04\n";
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
//! Typing a bare number into a Percentage-formatted cell enters it as a percent (issue #1967).
|
||||
|
||||
use gridcraft_engine::display::cell_text;
|
||||
use serde_json::json;
|
||||
|
||||
fn enter(input: &str) -> String {
|
||||
let mut s = gridcraft_engine::Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("home.numberFormat", json!({"range": "A1", "format": "Percentage"})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "A1", "input": input})).unwrap();
|
||||
let d = s.doc().unwrap();
|
||||
cell_text(&d.wb, d.wb.active().unwrap(), gridcraft_engine::core::CellRef::new(0, 0))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bare_number_in_percent_cell_is_a_percent() {
|
||||
assert_eq!(enter("3"), "3.00%");
|
||||
assert_eq!(enter("0.03"), "3.00%");
|
||||
assert_eq!(enter("3%"), "3.00%");
|
||||
}
|
||||
@@ -160,6 +160,44 @@ impl EditState {
|
||||
true
|
||||
}
|
||||
|
||||
/// Ctrl+Shift+A, as in Excel: right after a function name, or after its `(`, inserts the
|
||||
/// argument names (optional ones without their brackets) and the closing parenthesis, and
|
||||
/// the `(` too when missing. Anywhere else nothing changes and it returns false.
|
||||
pub fn insert_argument_names(&mut self) -> bool {
|
||||
if !self.is_formula() {
|
||||
return false;
|
||||
}
|
||||
let caret_b = self.byte_caret();
|
||||
let (Some(before), Some(after)) = (self.text.get(..caret_b), self.text.get(caret_b..)) else { return false };
|
||||
// An odd number of quotes before the caret: it is inside a text literal.
|
||||
if before.matches('"').count() % 2 == 1 {
|
||||
return false;
|
||||
}
|
||||
let open = before.ends_with('(');
|
||||
if !open && after.starts_with('(') {
|
||||
return false;
|
||||
}
|
||||
let head = before.strip_suffix('(').unwrap_or(before);
|
||||
let name: String =
|
||||
head.chars().rev().take_while(|c| c.is_ascii_alphanumeric() || *c == '.' || *c == '_').collect::<String>().chars().rev().collect();
|
||||
if name.is_empty() || name.starts_with(|c: char| c.is_ascii_digit()) {
|
||||
return false;
|
||||
}
|
||||
let Some(signature) = crate::formula_locale::signature(&name, self.locale) else { return false };
|
||||
let sep = self.locale.list_separator();
|
||||
let inner = signature.split_once('(').map(|(_, rest)| rest.trim_end().trim_end_matches(')')).unwrap_or("");
|
||||
let args: Vec<&str> = inner.split(sep).map(|a| a.trim().trim_start_matches('[').trim_end_matches(']')).filter(|a| !a.is_empty()).collect();
|
||||
let close = if open && after.starts_with(')') { "" } else { ")" };
|
||||
let inserted = format!("{}{}{close}", if open { "" } else { "(" }, args.join(&sep.to_string()));
|
||||
self.text = format!("{before}{inserted}{after}");
|
||||
self.caret += inserted.chars().count();
|
||||
self.autocomplete.clear();
|
||||
self.completion = None;
|
||||
self.caret_to_end = false;
|
||||
self.sync_caret = true;
|
||||
true
|
||||
}
|
||||
|
||||
/// The function call the caret is inside and the argument index (for the hint tooltip).
|
||||
pub fn current_function(&self) -> Option<(String, usize)> {
|
||||
if !self.is_formula() {
|
||||
|
||||
@@ -274,6 +274,22 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font:
|
||||
if ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, Key::F4)) {
|
||||
cycle_anchor(&mut ed);
|
||||
}
|
||||
// Ctrl+Shift+A (Control+Shift+A on a Mac) inserts the argument names. Consumed anywhere,
|
||||
// as in Excel, so it never falls through to the text's select-all.
|
||||
if ui.input_mut(|i| i.consume_key(egui::Modifiers::CTRL | egui::Modifiers::SHIFT, Key::A)) {
|
||||
let (text, caret) = (ed.text.clone(), ed.caret);
|
||||
if ed.insert_argument_names()
|
||||
&& let Some(mut state) = egui::TextEdit::load_state(ui.ctx(), id)
|
||||
{
|
||||
// The text edit records an undo point only once typing pauses: mark the text
|
||||
// before the shortcut so one Undo takes back just the inserted names.
|
||||
let mut undoer = state.undoer();
|
||||
let cursor = state.cursor.char_range().unwrap_or(egui::text::CCursorRange::one(egui::text::CCursor::new(caret)));
|
||||
undoer.add_undo(&(cursor, text));
|
||||
state.set_undoer(undoer);
|
||||
state.store(ui.ctx(), id);
|
||||
}
|
||||
}
|
||||
}
|
||||
// The text widget.
|
||||
let refs = formula_refs(&ed.text);
|
||||
|
||||
@@ -2911,6 +2911,9 @@ fn keyboard(app: &mut SheetApp, ctx: &egui::Context, resp: &egui::Response, geo:
|
||||
if !m.alt && (m.ctrl || m.command || (m.shift && *key == egui::Key::Space)))
|
||||
});
|
||||
let page = ((geo.cells.height() / geo.z) / sh.default_row_height).floor().max(1.0) as i64;
|
||||
// Alt shortcuts type their character too on Windows and Linux (Alt+= sends Text("=")): once
|
||||
// a shortcut ran, that text must not start an edit over what it just did.
|
||||
let mut shortcut_ran = false;
|
||||
for ev in events {
|
||||
match ev {
|
||||
egui::Event::Copy => {
|
||||
@@ -2920,7 +2923,7 @@ fn keyboard(app: &mut SheetApp, ctx: &egui::Context, resp: &egui::Response, geo:
|
||||
app.copy_to_clipboard(ctx, "edit.cut");
|
||||
}
|
||||
egui::Event::Paste(text) => app.paste_external(text),
|
||||
egui::Event::Text(_) if shortcut_pressed => {}
|
||||
egui::Event::Text(_) if shortcut_pressed || shortcut_ran => {}
|
||||
egui::Event::Text(text) if text.chars().all(|c| !c.is_control()) && !text.is_empty() => {
|
||||
app.begin_edit(Some(text), false);
|
||||
if let Some(ed) = app.editor.as_mut() {
|
||||
@@ -2931,6 +2934,7 @@ fn keyboard(app: &mut SheetApp, ctx: &egui::Context, resp: &egui::Response, geo:
|
||||
egui::Event::Key { key, physical_key, pressed: true, modifiers: m, .. } => {
|
||||
use egui::Key;
|
||||
if crate::keymap::dispatch(app, ctx, key, physical_key, m, geo, sh) {
|
||||
shortcut_ran = true;
|
||||
continue;
|
||||
}
|
||||
let cmd = m.command;
|
||||
@@ -3001,7 +3005,9 @@ fn keyboard(app: &mut SheetApp, ctx: &egui::Context, resp: &egui::Response, geo:
|
||||
}
|
||||
Key::F9 => app.run_or_alert("formulas.calculateNow", json!({})),
|
||||
Key::F4 if !cmd => {}
|
||||
_ if cmd => crate::ribbon::shortcut(app, key, m),
|
||||
_ if cmd && !crate::keymap::altgr_text(ctx, m, ctx.os() == egui::os::OperatingSystem::Mac) => {
|
||||
crate::ribbon::shortcut(app, key, m)
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,12 +169,16 @@ pub fn lookup(key: Key, physical: Option<Key>, m: Modifiers, mac: bool) -> Optio
|
||||
})
|
||||
}
|
||||
|
||||
/// AltGr arrives as Ctrl+Alt on Windows: a key that also typed a character (AltGr+= is `~` on a German
|
||||
/// layout; a user-defined Ctrl+Alt+A hotkey may type `ä`) is text, not a shortcut.
|
||||
pub(crate) fn altgr_text(ctx: &egui::Context, m: Modifiers, mac: bool) -> bool {
|
||||
!mac && m.alt && m.ctrl && ctx.input(|i| i.events.iter().any(|e| matches!(e, egui::Event::Text(_))))
|
||||
}
|
||||
|
||||
/// Handles a key press in the grid. Returns whether it was a shortcut.
|
||||
pub(crate) fn dispatch(app: &mut SheetApp, ctx: &egui::Context, key: Key, physical: Option<Key>, m: Modifiers, geo: &Geo, sh: &Sheet) -> bool {
|
||||
let mac = ctx.os() == egui::os::OperatingSystem::Mac;
|
||||
// AltGr arrives as Ctrl+Alt on Windows: a key that also typed a character (AltGr+= is `~`
|
||||
// on a German layout) is text, not a shortcut.
|
||||
if !mac && m.alt && m.ctrl && ctx.input(|i| i.events.iter().any(|e| matches!(e, egui::Event::Text(_)))) {
|
||||
if altgr_text(ctx, m, mac) {
|
||||
return false;
|
||||
}
|
||||
let Some(shortcut) = lookup(key, physical, m, mac) else { return false };
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
//! Ctrl+Shift+A (Control+Shift+A on a Mac) after a function name inserts its argument names, as
|
||||
//! in Excel.
|
||||
use egui::{Context, Event, Key, Modifiers, RawInput, Rect, pos2, vec2};
|
||||
use gridcraft_engine::Session;
|
||||
use gridcraft_ui_egui::SheetApp;
|
||||
|
||||
fn frame(ctx: &Context, app: &mut SheetApp, time: f64, events: Vec<Event>) {
|
||||
let mut output = ctx.run_ui(
|
||||
RawInput { screen_rect: Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(1200.0, 800.0))), time: Some(time), events, ..Default::default() },
|
||||
|ui| {
|
||||
let ctx = ui.ctx().clone();
|
||||
app.logic(&ctx);
|
||||
app.ui(ui);
|
||||
},
|
||||
);
|
||||
output.textures_delta.clear();
|
||||
}
|
||||
|
||||
fn key(key: Key, modifiers: Modifiers) -> Event {
|
||||
Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }
|
||||
}
|
||||
|
||||
fn ctrl_shift_a() -> Event {
|
||||
key(Key::A, Modifiers::CTRL | Modifiers::SHIFT)
|
||||
}
|
||||
|
||||
/// A cell editor holding `text`, caret at the end.
|
||||
fn editing(text: &str) -> (Context, SheetApp) {
|
||||
let ctx = Context::default();
|
||||
let mut session = Session::new();
|
||||
session.new_workbook();
|
||||
let mut app = SheetApp::new(session, Default::default());
|
||||
for t in 0..4 {
|
||||
frame(&ctx, &mut app, f64::from(t) * 0.01, vec![]);
|
||||
}
|
||||
app.begin_edit(Some(text.into()), false);
|
||||
for t in 4..8 {
|
||||
frame(&ctx, &mut app, f64::from(t) * 0.01, vec![]);
|
||||
}
|
||||
(ctx, app)
|
||||
}
|
||||
|
||||
fn text(app: &SheetApp) -> &str {
|
||||
app.editor.as_ref().map(|e| e.text.as_str()).unwrap_or("<no editor>")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn after_the_opening_parenthesis_inserts_the_argument_names() {
|
||||
let (ctx, mut app) = editing("=SUMIF(");
|
||||
frame(&ctx, &mut app, 0.1, vec![ctrl_shift_a()]);
|
||||
frame(&ctx, &mut app, 0.11, vec![]);
|
||||
assert_eq!(text(&app), "=SUMIF(range,criteria,sum_range)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn after_a_bare_function_name_also_adds_the_parentheses() {
|
||||
for (typed, expected) in
|
||||
[("=SUMIF", "=SUMIF(range,criteria,sum_range)"), ("=1+sumif", "=1+sumif(range,criteria,sum_range)"), ("=TODAY", "=TODAY()")]
|
||||
{
|
||||
let (ctx, mut app) = editing(typed);
|
||||
frame(&ctx, &mut app, 0.1, vec![ctrl_shift_a()]);
|
||||
frame(&ctx, &mut app, 0.11, vec![]);
|
||||
assert_eq!(text(&app), expected, "after {typed}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anywhere_else_the_shortcut_changes_nothing() {
|
||||
for typed in ["=SUMIF(A1", "=SUMIF(A1,", "=NOTAFUNCTION", "=A1+", "=\"SUMIF(", "SUMIF("] {
|
||||
let (ctx, mut app) = editing(typed);
|
||||
frame(&ctx, &mut app, 0.1, vec![ctrl_shift_a()]);
|
||||
frame(&ctx, &mut app, 0.11, vec![]);
|
||||
assert_eq!(text(&app), typed, "after {typed}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_undo_takes_back_the_inserted_names_only() {
|
||||
let (ctx, mut app) = editing("=SUMI");
|
||||
// Typed just before the shortcut: the text edit's own undo has not settled on it yet.
|
||||
frame(&ctx, &mut app, 0.1, vec![Event::Text("F(".into())]);
|
||||
frame(&ctx, &mut app, 0.11, vec![]);
|
||||
assert_eq!(text(&app), "=SUMIF(");
|
||||
frame(&ctx, &mut app, 0.12, vec![ctrl_shift_a()]);
|
||||
frame(&ctx, &mut app, 0.13, vec![]);
|
||||
assert_eq!(text(&app), "=SUMIF(range,criteria,sum_range)");
|
||||
frame(&ctx, &mut app, 0.14, vec![key(Key::Z, Modifiers::COMMAND)]);
|
||||
frame(&ctx, &mut app, 0.15, vec![]);
|
||||
assert_eq!(text(&app), "=SUMIF(");
|
||||
}
|
||||
@@ -262,3 +262,47 @@ fn ctrl_6_is_show_objects_and_nothing_else() {
|
||||
let claims: Vec<&str> = Session::new().commands().iter().filter(|c| c.shortcut == Some("Ctrl+6")).map(|c| c.id).collect();
|
||||
assert_eq!(claims, ["view.showObjects"]);
|
||||
}
|
||||
|
||||
/// Alt+= as Windows and Linux report it: the key press, then the `=` the OS types with it.
|
||||
fn alt_equals(h: &mut Harness) {
|
||||
h.input_mut().events.push(Event::Key {
|
||||
key: Key::Equals,
|
||||
physical_key: Some(Key::Equals),
|
||||
pressed: true,
|
||||
repeat: false,
|
||||
modifiers: Modifiers::ALT,
|
||||
});
|
||||
h.input_mut().events.push(Event::Text("=".into()));
|
||||
h.run_steps(2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alt_equals_autosum_does_not_also_type_the_equals_sign() {
|
||||
let input = |h: &Harness, a1: &str| h.state().session.active().unwrap().wb.active().unwrap().input_text(CellRef::parse(a1).unwrap());
|
||||
// Column above, row to the left, and a block (totals below each column).
|
||||
for (selected, cell, formula) in [("A4", "A4", "=SUM(A1:A3)"), ("C3", "C3", "=SUM(A3:B3)"), ("A1:B3", "B4", "=SUM(B1:B3)")] {
|
||||
let mut h = harness(book(selected));
|
||||
alt_equals(&mut h);
|
||||
assert_eq!(input(&h, cell), formula, "Alt+= on {selected}");
|
||||
assert!(h.state().editor.is_none(), "Alt+= on {selected} started an edit");
|
||||
// Enter moves on: there is no stray `=` edit to commit.
|
||||
key(&mut h, Key::Enter, Modifiers::NONE);
|
||||
assert!(h.state().message.is_none(), "Alt+= on {selected}: {:?}", h.state().message);
|
||||
assert_eq!(input(&h, cell), formula);
|
||||
}
|
||||
// Nothing to sum: AutoSum's own edit, `=SUM()`, not `=`.
|
||||
let mut h = harness(book("E10"));
|
||||
alt_equals(&mut h);
|
||||
assert_eq!(h.state().editor.as_ref().map(|e| e.text.clone()).as_deref(), Some("=SUM()"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctrl_alt_letter_that_typed_text_is_not_select_all() {
|
||||
let mut h = harness(book("B2"));
|
||||
let ctrl_alt = Modifiers { alt: true, ..CTRL };
|
||||
h.input_mut().events.push(Event::Key { key: Key::A, physical_key: Some(Key::A), pressed: true, repeat: false, modifiers: ctrl_alt });
|
||||
h.input_mut().events.push(Event::Text("\u{e4}".into()));
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().session.active().unwrap().selection.current().a1(), "B2");
|
||||
assert_eq!(h.state().editor.as_ref().unwrap().text, "\u{e4}");
|
||||
}
|
||||
|
||||
+8
-1
@@ -49,6 +49,7 @@ this logic, with one undo step.
|
||||
| Cursor while dragging cells, headers, freeze handles and objects | stable for the active drag (#936); Draw tools keep their crosshair; header arrows and a custom cell cursor remain unimplemented |
|
||||
| Selection outline glides to a new selection (Excel: "Provide feedback with animation") | done (10-11): 0.12 s, decelerating, with the fill and fill handle; View ▸ Animations (`view.animations`) turns it off. The timing is an estimate, not yet measured against Excel; the system's reduced-motion setting isn't followed yet |
|
||||
| Fill handle: drag to fill series, double-click to fill down, right-drag menu | fill done (Ctrl+drag swaps copy and series); right-drag menu partial: Copy Cells, Fill Series, Fill Formatting Only, Fill Without Formatting, Series…; Fill Days/Weekdays/Months/Years, Linear/Growth Trend and Flash Fill missing |
|
||||
| Fill handle: drag to fill series, double-click to fill down, right-drag menu | fill done (Ctrl+drag swaps copy and series); month patterns use the workbook's 1900 or 1904 date system; right-drag menu missing |
|
||||
| Drag-move and drag-copy cells (border drag, Ctrl/Option, Shift to insert) | done (M3) |
|
||||
| In-cell editing, Enter/Tab movement, Alt/Option+Enter line breaks | done |
|
||||
| Point mode (click/drag/arrow references), reference colouring, F4 | done; Command/Ctrl-click adds separate arguments with the edit's locale separator, and Shift-click extends the current reference from a fixed anchor on the same sheet. Pointer-event regressions cover repeated/reversed selections, manual separators, sheet switches and keyboard continuation. |
|
||||
@@ -132,11 +133,14 @@ otherwise. Number-format and border keys match the physical key (Ctrl+Shift+4, w
|
||||
| Shift+F10, Alt+Shift+F10, Ctrl+F1 | Shift+F10, Cmd+Option+R | context menu, Error Checking, ribbon | **added** |
|
||||
| F1, F3, F4 (repeat), F6 / Shift+F6, F8 / Shift+F8, F11, End mode | same | help, Paste Name, Repeat, pane cycling, extend/add mode, chart sheet | missing (no such feature yet) |
|
||||
| Ctrl+8, Ctrl+Q, Ctrl+Shift+A, Ctrl+A after a function name, Ctrl+Shift+U, Ctrl+Alt+P | same | outline symbols, Quick Analysis, argument names, Function Arguments, formula bar height, tooltips | missing |
|
||||
| Ctrl+Shift+A after a function name | Control+Shift+A | argument names: `=SUMIF(` becomes `=SUMIF(range,criteria,sum_range)`, the `(` added when missing; one Undo takes them back | **added** |
|
||||
| Ctrl+6, Ctrl+8, Ctrl+Q, Ctrl+A after a function name, Ctrl+Shift+U, Ctrl+Alt+P | same | objects, outline symbols, Quick Analysis, Function Arguments, formula bar height, tooltips | missing |
|
||||
| Ctrl+Shift+Page Up / Down, Ctrl+Alt+5, Ctrl+Shift+F1, Ctrl+F5–F10, Alt+F8 / F11 / F12 | same | group sheets, shape cycling, full screen, window keys, macros, VBA, Power Query | missing |
|
||||
| — | Cmd+= (calculate), Cmd+T (references while editing; F4 works), Shift+F7, Cmd+Shift+L | Mac-only extras | missing (Cmd+= is egui's zoom key; no thesaurus or Modify Cell Style) |
|
||||
|
||||
On Windows, AltGr arrives as Ctrl+Alt: a Ctrl+Alt key that also types a character is treated as
|
||||
text, so AltGr symbols (e.g. `~` on a German layout) never trigger a shortcut.
|
||||
text, so AltGr symbols (e.g. `~` on a German layout) and user-defined Ctrl+Alt+letter hotkeys that type
|
||||
a character (e.g. Ctrl+Alt+A for `ä`) never trigger a shortcut, Ctrl+letter ones like Select All included.
|
||||
|
||||
## Dialogs
|
||||
|
||||
@@ -173,14 +177,17 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12.
|
||||
| 2026-10-19 | minor | Ctrl+Enter (`range.fill`) gives each cell what typing the input there gives: a General cell gets the automatic number format (`5%`, `$5`, `3/4/2026`, `=TODAY()`) even when it already held a value, and a cell with its own format, a formatted row or column, or Text keeps it. Cells that held a value used to keep General (`5%` showed 0.05), and empty cells took the active cell's format |
|
||||
| 2026-10-19 | trivial | While a mouse button is held (a click or a drag selecting cells) the selection outline is drawn on its cells at once instead of gliding after the pointer, which read as lag; keyboard moves still glide |
|
||||
| 2026-10-18 | minor | Keytip letters typed with left Alt held continue the sequence (Alt+H, O, A / Alt+H, O, I ran nothing before: Alt+letter cancelled keytips); other Alt combinations, AltGr and macOS Option are unchanged (#1846) |
|
||||
| 2026-10-17 | minor | A Ctrl+Alt+letter key that types a character (a user-defined Windows hotkey such as Ctrl+Alt+A for `ä`) types it instead of running the Ctrl+letter shortcut, e.g. Select All (#1530) |
|
||||
| 2026-10-16 | minor | Right-drag on the fill handle opens the fill menu on release: Copy Cells, Fill Series, Fill Formatting Only, Fill Without Formatting, Series…; Esc cancels the drag or closes the menu without filling |
|
||||
| 2026-10-16 | minor | With the active cell in a Table whose header row is scrolled out of view, the column headings show the header names and their filter buttons in place of the letters (#1423) |
|
||||
| 2026-10-16 | minor | Ctrl+6 hides every object (charts, pictures, shapes, slicers) and shows them again; hidden objects are not drawn, take no clicks and drop their selection, and the setting is saved with the workbook (`workbookPr showObjects`) |
|
||||
| 2026-10-16 | minor | Ctrl+Shift+A (Control+Shift+A on a Mac) after a function name, or after its `(`, inserts the argument names and the closing parenthesis, as in Excel; one Undo in the editor takes them back |
|
||||
| 2026-10-15 | minor | Number-format named colors use their RGB values; indexed `[Color1]`–`[Color56]` resolve through the shared legacy palette in grid and PDF (#1452) |
|
||||
| 2026-10-14 | minor | Spill-child formula bar uses the anchor formula with disabled theme text and blocks formula-bar edit entry; selection and recalculation restore ordinary behavior |
|
||||
| 2026-10-13 | minor | Dragging a sheet tab with Ctrl held (Cmd or Option on macOS) drops a copy of the sheet where the tab is released, with the copy cursor (#971) |
|
||||
| 2026-10-13 | trivial | More Colors… and Recent Colors (#928) |
|
||||
| 2026-10-13 | minor | Grid drags keep their own cursor over selection borders, fill handles and outside the grid; hover resumes on release (#936) |
|
||||
| 2026-10-13 | minor | AutoFill month patterns retain the workbook's date system, preventing shifted dates in 1904 workbooks when filling forward or backward |
|
||||
| 2026-10-12 | minor | The File view's recent list tells when each workbook was last changed, read off the UI thread |
|
||||
| 2026-10-13 | minor | Horizontal scrollbar in the sheet tab bar: drag, track click pages, frozen columns excluded from the page (#840) |
|
||||
| 2026-10-13 | minor | Formula-entry Command/Ctrl-click adds arguments and Shift-click extends references; ordinary replacement and dragging retain their behavior. Readiness estimates unchanged. |
|
||||
|
||||
Reference in New Issue
Block a user