Compare commits
1 Commits
main
...
claude/gri
| Author | SHA1 | Date |
|---|---|---|
|
|
a8df1b81f3 |
|
|
@ -83,6 +83,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||||
has_doc,
|
has_doc,
|
||||||
auto_fill
|
auto_fill
|
||||||
),
|
),
|
||||||
|
cmd!(
|
||||||
|
"edit.autoFillToData",
|
||||||
|
"AutoFill to Adjacent Data",
|
||||||
|
[],
|
||||||
|
None,
|
||||||
|
"{source?: \"B1:B4\", mode?: series|alternate|copy|formats|values}: the fill-handle double-click: fills the source down to the bottom of the data region around it, stopping above the first filled cell under it; {target: null} when there is nothing to fill",
|
||||||
|
has_doc,
|
||||||
|
auto_fill_to_data
|
||||||
|
),
|
||||||
cmd!(
|
cmd!(
|
||||||
"edit.fillSeries",
|
"edit.fillSeries",
|
||||||
"Series…",
|
"Series…",
|
||||||
|
|
@ -1352,6 +1361,19 @@ fn auto_fill(s: &mut Session, p: &Json) -> Result<Json> {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn auto_fill_to_data(s: &mut Session, p: &Json) -> Result<Json> {
|
||||||
|
let sheet = target_sheet(s, p)?;
|
||||||
|
let src = str_param(p, "source").and_then(RangeRef::parse).map_or_else(|| target_range(s, p), Ok)?;
|
||||||
|
let sh = s.doc()?.wb.sheets.get(sheet).ok_or_else(|| bad("edit.autoFillToData", "no such sheet"))?;
|
||||||
|
let Some(target) = crate::selection::fill_down_extent(sh, src) else { return Ok(json!({"target": null})) };
|
||||||
|
let mut params = json!({"source": src.a1(), "target": target.a1(), "sheet": sheet});
|
||||||
|
if let Some(mode) = str_param(p, "mode") {
|
||||||
|
params["mode"] = json!(mode);
|
||||||
|
}
|
||||||
|
auto_fill(s, ¶ms)?;
|
||||||
|
Ok(json!({"target": target.a1()}))
|
||||||
|
}
|
||||||
|
|
||||||
fn fill_series(s: &mut Session, p: &Json) -> Result<Json> {
|
fn fill_series(s: &mut Session, p: &Json) -> Result<Json> {
|
||||||
let sheet = target_sheet(s, p)?;
|
let sheet = target_sheet(s, p)?;
|
||||||
let r = target_range(s, p)?;
|
let r = target_range(s, p)?;
|
||||||
|
|
|
||||||
|
|
@ -186,11 +186,16 @@ pub fn jump(sheet: &Sheet, from: CellRef, dr: i64, dc: i64) -> CellRef {
|
||||||
|
|
||||||
/// The contiguous data region around `c` (Ctrl+A / Ctrl+Shift+8, AutoSum, sort, tables).
|
/// The contiguous data region around `c` (Ctrl+A / Ctrl+Shift+8, AutoSum, sort, tables).
|
||||||
pub fn current_region(sheet: &Sheet, c: CellRef) -> RangeRef {
|
pub fn current_region(sheet: &Sheet, c: CellRef) -> RangeRef {
|
||||||
|
current_region_of(sheet, RangeRef::cell(c))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`current_region`] grown from a whole range rather than one cell.
|
||||||
|
pub fn current_region_of(sheet: &Sheet, from: RangeRef) -> RangeRef {
|
||||||
let filled = |c: CellRef| sheet.cell(c).is_some_and(|x| !x.value.is_empty() || x.formula.is_some()) || sheet.spill.contains_key(&c);
|
let filled = |c: CellRef| sheet.cell(c).is_some_and(|x| !x.value.is_empty() || x.formula.is_some()) || sheet.spill.contains_key(&c);
|
||||||
// Whether a row (or column) has a filled cell between two columns (rows), inclusive.
|
// Whether a row (or column) has a filled cell between two columns (rows), inclusive.
|
||||||
let row_has = |row: u32, a: u32, b: u32| (a..=b).any(|col| filled(CellRef::new(row, col)));
|
let row_has = |row: u32, a: u32, b: u32| (a..=b).any(|col| filled(CellRef::new(row, col)));
|
||||||
let col_has = |col: u32, a: u32, b: u32| (a..=b).any(|row| filled(CellRef::new(row, col)));
|
let col_has = |col: u32, a: u32, b: u32| (a..=b).any(|row| filled(CellRef::new(row, col)));
|
||||||
let (mut r0, mut r1, mut c0, mut c1) = (c.row, c.row, c.col, c.col);
|
let (mut r0, mut r1, mut c0, mut c1) = (from.start.row, from.end.row, from.start.col, from.end.col);
|
||||||
// Grow one side at a time while the line next to it (diagonals included) has data. Each
|
// Grow one side at a time while the line next to it (diagonals included) has data. Each
|
||||||
// pass scans only the lines next to the region, so tall regions take linear time.
|
// pass scans only the lines next to the region, so tall regions take linear time.
|
||||||
loop {
|
loop {
|
||||||
|
|
@ -216,6 +221,27 @@ pub fn current_region(sheet: &Sheet, c: CellRef) -> RangeRef {
|
||||||
RangeRef::new(CellRef::new(r0, c0), CellRef::new(r1, c1))
|
RangeRef::new(CellRef::new(r0, c0), CellRef::new(r1, c1))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Where a double-click on the fill handle fills `src` to, as observed in Excel: down to the last
|
||||||
|
/// row of the data region around it (its current region, so a column of data on either side, or
|
||||||
|
/// one reached only diagonally, counts), stopping above the first filled cell under the source.
|
||||||
|
/// `None` when there is nothing to fill.
|
||||||
|
pub fn fill_down_extent(sheet: &Sheet, src: RangeRef) -> Option<RangeRef> {
|
||||||
|
let region = current_region_of(sheet, src);
|
||||||
|
let mut last = region.end.row;
|
||||||
|
if last <= src.end.row {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
// A filled cell under the source ends the fill just above it. Only stored cells are visited,
|
||||||
|
// so a tall region costs what the sheet holds, not its height.
|
||||||
|
let under = RangeRef::new(CellRef::new(src.end.row + 1, src.start.col), CellRef::new(last, src.end.col));
|
||||||
|
let first_cell = sheet.cells.iter_range(under).find(|(_, x)| !x.value.is_empty() || x.formula.is_some()).map(|(c, _)| c.row);
|
||||||
|
let first_spill = sheet.spill.range(under.start..=under.end).map(|(c, _)| *c).find(|c| under.contains(*c)).map(|c| c.row);
|
||||||
|
if let Some(r) = first_cell.into_iter().chain(first_spill).min() {
|
||||||
|
last = r - 1;
|
||||||
|
}
|
||||||
|
(last > src.end.row).then(|| RangeRef::new(src.start, CellRef::new(last, src.end.col)))
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
|
||||||
|
|
@ -734,6 +734,127 @@ fn fill_series() {
|
||||||
assert_eq!(v(&s, "C2"), Value::from("Item 10"));
|
assert_eq!(v(&s, "C2"), Value::from("Item 10"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The fill-handle double-click (#877), case by case as observed in Excel 365 for Windows.
|
||||||
|
#[test]
|
||||||
|
#[allow(clippy::disallowed_methods)] // times the tall fill; native-only test
|
||||||
|
fn fill_handle_double_click_fills_to_adjacent_data() {
|
||||||
|
// The column of a cell, rows 1..=n, with `.` for empty.
|
||||||
|
let col = |s: &Session, c: &str, n: u32| -> String {
|
||||||
|
(1..=n)
|
||||||
|
.map(|r| match v(s, &format!("{c}{r}")) {
|
||||||
|
Value::Empty => ".".to_string(),
|
||||||
|
Value::Number(x) => format!("{x}"),
|
||||||
|
Value::Text(t) => t.to_string(),
|
||||||
|
x => format!("{x:?}"),
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(",")
|
||||||
|
};
|
||||||
|
let nums = |s: &mut Session, c: &str, rows: &[u32]| {
|
||||||
|
for r in rows {
|
||||||
|
s.execute("cell.set", json!({"cell": format!("{c}{r}"), "input": r.to_string()})).unwrap();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let dbl = |s: &mut Session, src: &str| s.execute("edit.autoFillToData", json!({"source": src})).unwrap();
|
||||||
|
|
||||||
|
// The issue's sheet: a 2, 4, 6, 8 series next to 1..10 continues to row 10.
|
||||||
|
let mut s = self::s();
|
||||||
|
nums(&mut s, "A", &(1..=10).collect::<Vec<_>>());
|
||||||
|
s.execute("range.setValues", json!({"range": "B1", "values": [[2], [4], [6], [8]]})).unwrap();
|
||||||
|
assert_eq!(dbl(&mut s, "B1:B4"), json!({"target": "B1:B10"}));
|
||||||
|
assert_eq!(col(&s, "B", 12), "2,4,6,8,10,12,14,16,18,20,.,.");
|
||||||
|
assert_eq!(s.doc().unwrap().selection.current().a1(), "B1:B10");
|
||||||
|
// Undo takes the whole fill back in one step.
|
||||||
|
s.execute("edit.undo", json!({})).unwrap();
|
||||||
|
assert_eq!(col(&s, "B", 12), "2,4,6,8,.,.,.,.,.,.,.,.");
|
||||||
|
|
||||||
|
// Its last cell alone copies.
|
||||||
|
assert_eq!(dbl(&mut s, "B4"), json!({"target": "B4:B10"}));
|
||||||
|
assert_eq!(col(&s, "B", 12), "2,4,6,8,8,8,8,8,8,8,.,.");
|
||||||
|
|
||||||
|
// Data only on the right counts.
|
||||||
|
let mut s = self::s();
|
||||||
|
nums(&mut s, "D", &(1..=8).collect::<Vec<_>>());
|
||||||
|
s.execute("cell.set", json!({"cell": "C1", "input": "5"})).unwrap();
|
||||||
|
dbl(&mut s, "C1");
|
||||||
|
assert_eq!(col(&s, "C", 10), "5,5,5,5,5,5,5,5,.,.");
|
||||||
|
|
||||||
|
// A filled cell under the source stops the fill above it.
|
||||||
|
let mut s = self::s();
|
||||||
|
nums(&mut s, "A", &(1..=10).collect::<Vec<_>>());
|
||||||
|
s.execute("cell.set", json!({"cell": "B1", "input": "7"})).unwrap();
|
||||||
|
s.execute("cell.set", json!({"cell": "B6", "input": "x"})).unwrap();
|
||||||
|
dbl(&mut s, "B1");
|
||||||
|
assert_eq!(col(&s, "B", 12), "7,7,7,7,7,x,.,.,.,.,.,.");
|
||||||
|
// ... and right under it, nothing happens.
|
||||||
|
let mut s = self::s();
|
||||||
|
nums(&mut s, "A", &(1..=6).collect::<Vec<_>>());
|
||||||
|
s.execute("range.setValues", json!({"range": "B1", "values": [[7], [9]]})).unwrap();
|
||||||
|
assert_eq!(dbl(&mut s, "B1"), json!({"target": null}));
|
||||||
|
assert_eq!(col(&s, "B", 8), "7,9,.,.,.,.,.,.");
|
||||||
|
|
||||||
|
// A gap in the neighbouring column ends the region.
|
||||||
|
let mut s = self::s();
|
||||||
|
nums(&mut s, "A", &[1, 2, 3, 4, 5, 7, 8, 9, 10]);
|
||||||
|
s.execute("cell.set", json!({"cell": "B1", "input": "7"})).unwrap();
|
||||||
|
dbl(&mut s, "B1");
|
||||||
|
assert_eq!(col(&s, "B", 12), "7,7,7,7,7,.,.,.,.,.,.,.");
|
||||||
|
|
||||||
|
// The longer side wins: 3 rows on the left, 9 on the right.
|
||||||
|
let mut s = self::s();
|
||||||
|
nums(&mut s, "A", &[1, 2, 3]);
|
||||||
|
nums(&mut s, "C", &(1..=9).collect::<Vec<_>>());
|
||||||
|
s.execute("cell.set", json!({"cell": "B1", "input": "7"})).unwrap();
|
||||||
|
dbl(&mut s, "B1");
|
||||||
|
assert_eq!(col(&s, "B", 12), "7,7,7,7,7,7,7,7,7,.,.,.");
|
||||||
|
|
||||||
|
// Data reached only diagonally belongs to the region too (A1:A3, then C4:C9).
|
||||||
|
let mut s = self::s();
|
||||||
|
nums(&mut s, "A", &[1, 2, 3]);
|
||||||
|
nums(&mut s, "C", &[4, 5, 6, 7, 8, 9]);
|
||||||
|
s.execute("cell.set", json!({"cell": "B1", "input": "7"})).unwrap();
|
||||||
|
dbl(&mut s, "B1");
|
||||||
|
assert_eq!(col(&s, "B", 12), "7,7,7,7,7,7,7,7,7,.,.,.");
|
||||||
|
|
||||||
|
// A column further away doesn't.
|
||||||
|
let mut s = self::s();
|
||||||
|
nums(&mut s, "D", &(1..=9).collect::<Vec<_>>());
|
||||||
|
s.execute("cell.set", json!({"cell": "B1", "input": "7"})).unwrap();
|
||||||
|
assert_eq!(dbl(&mut s, "B1"), json!({"target": null}));
|
||||||
|
|
||||||
|
// From the middle of the data, and at its end (nothing to do).
|
||||||
|
let mut s = self::s();
|
||||||
|
nums(&mut s, "A", &(1..=10).collect::<Vec<_>>());
|
||||||
|
s.execute("cell.set", json!({"cell": "B5", "input": "7"})).unwrap();
|
||||||
|
dbl(&mut s, "B5");
|
||||||
|
assert_eq!(col(&s, "B", 12), ".,.,.,.,7,7,7,7,7,7,.,.");
|
||||||
|
let mut s = self::s();
|
||||||
|
nums(&mut s, "A", &[1, 2, 3]);
|
||||||
|
s.execute("cell.set", json!({"cell": "B3", "input": "7"})).unwrap();
|
||||||
|
assert_eq!(dbl(&mut s, "B3"), json!({"target": null}));
|
||||||
|
|
||||||
|
// Formulas fill with their references moving.
|
||||||
|
let mut s = self::s();
|
||||||
|
nums(&mut s, "A", &(1..=5).collect::<Vec<_>>());
|
||||||
|
s.execute("cell.set", json!({"cell": "B1", "input": "=A1*2"})).unwrap();
|
||||||
|
dbl(&mut s, "B1");
|
||||||
|
assert_eq!(v(&s, "B5"), Value::Number(10.0));
|
||||||
|
|
||||||
|
// A whole data column a million rows tall is fine (and quick: only stored cells are visited).
|
||||||
|
let mut s = self::s();
|
||||||
|
let tall: Vec<Vec<f64>> = (0..200_000).map(|i| vec![i as f64]).collect();
|
||||||
|
s.execute("range.setValues", json!({"range": "A1", "values": tall})).unwrap();
|
||||||
|
s.execute("cell.set", json!({"cell": "B1", "input": "1"})).unwrap();
|
||||||
|
let t = std::time::Instant::now();
|
||||||
|
assert_eq!(dbl(&mut s, "B1"), json!({"target": "B1:B200000"}));
|
||||||
|
assert!(t.elapsed().as_secs_f64() < 20.0, "double-click fill of 200,000 rows took {:?}", t.elapsed());
|
||||||
|
assert_eq!(v(&s, "B200000"), Value::Number(1.0));
|
||||||
|
|
||||||
|
// Programmatic calls with nothing selected or empty params don't fail.
|
||||||
|
let mut s = self::s();
|
||||||
|
assert_eq!(s.execute("edit.autoFillToData", json!({})).unwrap(), json!({"target": null}));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn ctrl_drag_fill_swaps_copy_and_series() {
|
fn ctrl_drag_fill_swaps_copy_and_series() {
|
||||||
// Issue #264: Ctrl+drag fills a lone number as a series, and copies what a plain drag extends.
|
// Issue #264: Ctrl+drag fills a lone number as a series, and copies what a plain drag extends.
|
||||||
|
|
|
||||||
|
|
@ -748,6 +748,7 @@ const CURATED: &[(&str, usize)] = &[
|
||||||
("edit.fillDown", 2),
|
("edit.fillDown", 2),
|
||||||
("edit.fillRight", 1),
|
("edit.fillRight", 1),
|
||||||
("edit.autoFill", 2),
|
("edit.autoFill", 2),
|
||||||
|
("edit.autoFillToData", 1),
|
||||||
("edit.fillSeries", 1),
|
("edit.fillSeries", 1),
|
||||||
("home.bold", 1),
|
("home.bold", 1),
|
||||||
("home.fillColor", 1),
|
("home.fillColor", 1),
|
||||||
|
|
@ -845,6 +846,7 @@ fn curated_params(rng: &mut Rng, wb: &Workbook, id: &str) -> Json {
|
||||||
let tgt = RangeRef::new(src.start, CellRef::new(src.end.row + rng.int(0, 6) as u32, src.end.col + rng.int(0, 1) as u32));
|
let tgt = RangeRef::new(src.start, CellRef::new(src.end.row + rng.int(0, 6) as u32, src.end.col + rng.int(0, 1) as u32));
|
||||||
json!({"source": src.a1(), "target": tgt.a1(), "mode": rng.pick(&["series", "copy", "alternate", "formats", "values"])})
|
json!({"source": src.a1(), "target": tgt.a1(), "mode": rng.pick(&["series", "copy", "alternate", "formats", "values"])})
|
||||||
}
|
}
|
||||||
|
"edit.autoFillToData" => json!({"source": near_range(rng).a1(), "mode": rng.pick(&["series", "copy"])}),
|
||||||
"edit.fillSeries" => json!({"range": near_range(rng).a1(), "type": rng.pick(&["linear", "growth", "autofill"]), "step": rng.int(-2, 3)}),
|
"edit.fillSeries" => json!({"range": near_range(rng).a1(), "type": rng.pick(&["linear", "growth", "autofill"]), "step": rng.int(-2, 3)}),
|
||||||
"home.bold" => json!({"range": range(rng)}),
|
"home.bold" => json!({"range": range(rng)}),
|
||||||
"home.fillColor" => json!({"range": range(rng), "color": color(rng)}),
|
"home.fillColor" => json!({"range": range(rng), "color": color(rng)}),
|
||||||
|
|
|
||||||
|
|
@ -222,22 +222,59 @@ pub fn formula_refs(text: &str) -> Vec<(usize, usize, RangeRef, Option<String>,
|
||||||
} else {
|
} else {
|
||||||
i = j + 1;
|
i = j + 1;
|
||||||
}
|
}
|
||||||
let key = body.get(start..end).unwrap_or("").replace('$', "").to_ascii_uppercase();
|
let idx = colour_index(&mut seen, body.get(start..end).unwrap_or(""));
|
||||||
let idx = match seen.iter().position(|k| *k == key) {
|
|
||||||
Some(k) => k,
|
|
||||||
None => {
|
|
||||||
seen.push(key);
|
|
||||||
seen.len() - 1
|
|
||||||
}
|
|
||||||
};
|
|
||||||
out.push((start + offset, end + offset, range, sheet, idx % REF_COLORS.len()));
|
out.push((start + offset, end + offset, range, sheet, idx % REF_COLORS.len()));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
// Whole columns and rows (`A:A`, `$B:$D`, `2:2`, `Sheet2!3:5`) are references too.
|
||||||
|
if let Some(t) = toks.get(j)
|
||||||
|
&& let Some(range) = whole_line_ref(&t.tok, toks.get(j + 1).map(|t| &t.tok), toks.get(j + 2).map(|t| &t.tok))
|
||||||
|
&& let Some(last) = toks.get(j + 2)
|
||||||
|
{
|
||||||
|
let end = last.end;
|
||||||
|
let idx = colour_index(&mut seen, body.get(start..end).unwrap_or(""));
|
||||||
|
out.push((start + offset, end + offset, range, sheet, idx % REF_COLORS.len()));
|
||||||
|
i = j + 3;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
i += 1;
|
i += 1;
|
||||||
}
|
}
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The colour slot of a reference: the same text (ignoring `$` and case) keeps its colour.
|
||||||
|
fn colour_index(seen: &mut Vec<String>, text: &str) -> usize {
|
||||||
|
let key = text.replace('$', "").to_ascii_uppercase();
|
||||||
|
match seen.iter().position(|k| *k == key) {
|
||||||
|
Some(k) => k,
|
||||||
|
None => {
|
||||||
|
seen.push(key);
|
||||||
|
seen.len() - 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `A:C` or `2:5` as three tokens (the lexer splits them around the colon): whole columns or
|
||||||
|
/// whole rows, the same words the formula parser accepts there.
|
||||||
|
fn whole_line_ref(
|
||||||
|
a: &gridcraft_engine::formula::lexer::Tok,
|
||||||
|
colon: Option<&gridcraft_engine::formula::lexer::Tok>,
|
||||||
|
b: Option<&gridcraft_engine::formula::lexer::Tok>,
|
||||||
|
) -> Option<RangeRef> {
|
||||||
|
use gridcraft_engine::formula::lexer::Tok;
|
||||||
|
if !matches!(colon, Some(Tok::Colon | Tok::Trim(_))) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let word = |t: &Tok| match t {
|
||||||
|
Tok::Word(w) => Some(w.clone()),
|
||||||
|
Tok::Number(n) if n.fract() == 0.0 && *n >= 1.0 => Some(format!("{n}")),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
let (w1, w2) = (word(a)?, word(b?)?);
|
||||||
|
let range = RangeRef::parse(&format!("{w1}:{w2}"))?;
|
||||||
|
(range.is_full_cols() || range.is_full_rows()).then_some(range)
|
||||||
|
}
|
||||||
|
|
||||||
/// Excel-style AutoComplete: the one distinct text entry in the same column (contiguous data
|
/// Excel-style AutoComplete: the one distinct text entry in the same column (contiguous data
|
||||||
/// above and below) that starts with `typed` (case-insensitive). `None` when ambiguous.
|
/// above and below) that starts with `typed` (case-insensitive). `None` when ambiguous.
|
||||||
pub fn column_completion(sheet: &gridcraft_engine::model::Sheet, at: CellRef, typed: &str) -> Option<String> {
|
pub fn column_completion(sheet: &gridcraft_engine::model::Sheet, at: CellRef, typed: &str) -> Option<String> {
|
||||||
|
|
@ -329,4 +366,31 @@ mod tests {
|
||||||
assert_eq!(r[1].2.a1(), "B2:C3");
|
assert_eq!(r[1].2.a1(), "B2:C3");
|
||||||
assert_eq!(r[3].3.as_deref(), Some("Sheet2"));
|
assert_eq!(r[3].3.as_deref(), Some("Sheet2"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whole columns and rows are coloured and outlined like any reference (#873, #875).
|
||||||
|
#[test]
|
||||||
|
fn whole_column_and_row_refs() {
|
||||||
|
use gridcraft_engine::core::{MAX_COLS, MAX_ROWS};
|
||||||
|
let text = "=SUM(A:A)+SUM(2:2)+COUNT($B:$D,Sheet2!3:5,a:a)";
|
||||||
|
let r = formula_refs(text);
|
||||||
|
let spans: Vec<&str> = r.iter().map(|x| &text[x.0..x.1]).collect();
|
||||||
|
assert_eq!(spans, ["A:A", "2:2", "$B:$D", "Sheet2!3:5", "a:a"]);
|
||||||
|
assert_eq!(r[0].2, RangeRef::cols(0, 0));
|
||||||
|
assert_eq!(r[1].2, RangeRef::rows(1, 1));
|
||||||
|
assert_eq!(r[2].2, RangeRef::cols(1, 3));
|
||||||
|
assert_eq!((r[3].2, r[3].3.as_deref()), (RangeRef::rows(2, 4), Some("Sheet2")));
|
||||||
|
// Distinct references get distinct colours; the same one (any case or `$`) keeps its own.
|
||||||
|
assert_eq!([r[0].4, r[1].4, r[2].4, r[3].4], [0, 1, 2, 3]);
|
||||||
|
assert_eq!(r[4].4, r[0].4);
|
||||||
|
// The outline spans the whole sheet's height or width.
|
||||||
|
assert_eq!(r[0].2.end.row, MAX_ROWS - 1);
|
||||||
|
assert_eq!(r[1].2.end.col, MAX_COLS - 1);
|
||||||
|
// Mixed with cells, in order of appearance.
|
||||||
|
let r = formula_refs("=A1+B:B*C2:D3+10:10");
|
||||||
|
assert_eq!(r.iter().map(|x| x.2.a1()).collect::<Vec<_>>(), ["A1", "B:B", "C2:D3", "10:10"]);
|
||||||
|
// Not references: a name, numbers around a colon in text, beyond the sheet, half a range.
|
||||||
|
for text in ["=SUM(Sales)", "=\"1:2\"&A", "=SUM(XFE:XFE)", "=SUM(0:0)", "=SUM(1048577:1048577)", "=SUM(A:", "=A:"] {
|
||||||
|
assert!(formula_refs(text).is_empty(), "{text}: {:?}", formula_refs(text));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -422,25 +422,7 @@ fn cycle_anchor(ed: &mut crate::editor::EditState) {
|
||||||
for (a, b, ..) in formula_refs(&ed.text) {
|
for (a, b, ..) in formula_refs(&ed.text) {
|
||||||
if a <= caret_b && caret_b <= b {
|
if a <= caret_b && caret_b <= b {
|
||||||
let Some(r) = ed.text.get(a..b) else { return };
|
let Some(r) = ed.text.get(a..b) else { return };
|
||||||
let cycled: String = r
|
let cycled = cycle_ref(r);
|
||||||
.split(':')
|
|
||||||
.map(|part| {
|
|
||||||
let p = part.split('!').next_back().unwrap_or(part);
|
|
||||||
let prefix = &part[..part.len() - p.len()];
|
|
||||||
let bare = p.replace('$', "");
|
|
||||||
let letters: String = bare.chars().take_while(|c| c.is_ascii_alphabetic()).collect();
|
|
||||||
let digits = &bare[letters.len()..];
|
|
||||||
let state = (p.starts_with('$'), p[letters.len().min(p.len())..].contains('$') || p.trim_start_matches('$').contains('$'));
|
|
||||||
let next = match state {
|
|
||||||
(false, false) => format!("${letters}${digits}"),
|
|
||||||
(true, true) => format!("{letters}${digits}"),
|
|
||||||
(false, true) => format!("${letters}{digits}"),
|
|
||||||
(true, false) => format!("{letters}{digits}"),
|
|
||||||
};
|
|
||||||
format!("{prefix}{next}")
|
|
||||||
})
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.join(":");
|
|
||||||
let (Some(x), Some(y)) = (ed.text.get(..a), ed.text.get(b..)) else { return };
|
let (Some(x), Some(y)) = (ed.text.get(..a), ed.text.get(b..)) else { return };
|
||||||
ed.text = format!("{x}{cycled}{y}");
|
ed.text = format!("{x}{cycled}{y}");
|
||||||
ed.caret = ed.text.get(..a + cycled.len()).map(|s| s.chars().count()).unwrap_or(ed.caret);
|
ed.caret = ed.text.get(..a + cycled.len()).map(|s| s.chars().count()).unwrap_or(ed.caret);
|
||||||
|
|
@ -452,6 +434,46 @@ fn cycle_anchor(ed: &mut crate::editor::EditState) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// F4 on one reference: `A1` → `$A$1` → `A$1` → `$A1` → `A1`. Whole columns and rows have one
|
||||||
|
/// coordinate, so they toggle: `B:D` ⇄ `$B:$D`, `2:3` ⇄ `$2:$3` (as Excel does).
|
||||||
|
fn cycle_ref(r: &str) -> String {
|
||||||
|
let unqualified = |part: &str| part.split('!').next_back().unwrap_or(part).to_string();
|
||||||
|
let first = unqualified(r.split(':').next().unwrap_or(r));
|
||||||
|
let first_bare = first.replace('$', "");
|
||||||
|
let whole_line = r.contains(':') && (first_bare.chars().all(|c| c.is_ascii_alphabetic()) || first_bare.chars().all(|c| c.is_ascii_digit()));
|
||||||
|
if whole_line {
|
||||||
|
let anchored = first.starts_with('$');
|
||||||
|
return r
|
||||||
|
.split(':')
|
||||||
|
.map(|part| {
|
||||||
|
let p = unqualified(part);
|
||||||
|
let prefix = &part[..part.len() - p.len()];
|
||||||
|
let bare = p.replace('$', "");
|
||||||
|
if anchored { format!("{prefix}{bare}") } else { format!("{prefix}${bare}") }
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(":");
|
||||||
|
}
|
||||||
|
r.split(':')
|
||||||
|
.map(|part| {
|
||||||
|
let p = part.split('!').next_back().unwrap_or(part);
|
||||||
|
let prefix = &part[..part.len() - p.len()];
|
||||||
|
let bare = p.replace('$', "");
|
||||||
|
let letters: String = bare.chars().take_while(|c| c.is_ascii_alphabetic()).collect();
|
||||||
|
let digits = &bare[letters.len()..];
|
||||||
|
let state = (p.starts_with('$'), p[letters.len().min(p.len())..].contains('$') || p.trim_start_matches('$').contains('$'));
|
||||||
|
let next = match state {
|
||||||
|
(false, false) => format!("${letters}${digits}"),
|
||||||
|
(true, true) => format!("{letters}${digits}"),
|
||||||
|
(false, true) => format!("${letters}{digits}"),
|
||||||
|
(true, false) => format!("{letters}{digits}"),
|
||||||
|
};
|
||||||
|
format!("{prefix}{next}")
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(":")
|
||||||
|
}
|
||||||
|
|
||||||
fn function_names() -> Vec<String> {
|
fn function_names() -> Vec<String> {
|
||||||
static NAMES: std::sync::OnceLock<Vec<String>> = std::sync::OnceLock::new();
|
static NAMES: std::sync::OnceLock<Vec<String>> = std::sync::OnceLock::new();
|
||||||
NAMES
|
NAMES
|
||||||
|
|
@ -474,3 +496,32 @@ pub fn function_signature(name: &str) -> Option<String> {
|
||||||
pub fn function_description(name: &str) -> Option<String> {
|
pub fn function_description(name: &str) -> Option<String> {
|
||||||
function_info(name).and_then(|f| f["description"].as_str().map(str::to_string))
|
function_info(name).and_then(|f| f["description"].as_str().map(str::to_string))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::cycle_ref;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn f4_cycles_cells_through_four_states() {
|
||||||
|
let mut r = "A1".to_string();
|
||||||
|
let mut seen = vec![];
|
||||||
|
for _ in 0..4 {
|
||||||
|
r = cycle_ref(&r);
|
||||||
|
seen.push(r.clone());
|
||||||
|
}
|
||||||
|
assert_eq!(seen, ["$A$1", "A$1", "$A1", "A1"]);
|
||||||
|
assert_eq!(cycle_ref("B2:C3"), "$B$2:$C$3");
|
||||||
|
assert_eq!(cycle_ref("Sheet2!B2"), "Sheet2!$B$2");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// As observed in Excel: F4 on a whole column or row toggles its `$` (#873).
|
||||||
|
#[test]
|
||||||
|
fn f4_toggles_whole_columns_and_rows() {
|
||||||
|
assert_eq!(cycle_ref("B:B"), "$B:$B");
|
||||||
|
assert_eq!(cycle_ref("$B:$B"), "B:B");
|
||||||
|
assert_eq!(cycle_ref("2:3"), "$2:$3");
|
||||||
|
assert_eq!(cycle_ref("$2:$3"), "2:3");
|
||||||
|
assert_eq!(cycle_ref("Sheet2!C:E"), "Sheet2!$C:$E");
|
||||||
|
assert_eq!(cycle_ref("'My Sheet'!$4:$4"), "'My Sheet'!4:4");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,12 @@ pub enum Drag {
|
||||||
Select,
|
Select,
|
||||||
Rows(u32),
|
Rows(u32),
|
||||||
Cols(u32),
|
Cols(u32),
|
||||||
|
/// Point mode while editing a formula: dragging over the column (`cols`) or row headers from
|
||||||
|
/// `anchor` puts `B:D` or `2:5` into the formula.
|
||||||
|
PointLines {
|
||||||
|
cols: bool,
|
||||||
|
anchor: u32,
|
||||||
|
},
|
||||||
/// Reordering one contiguous whole-column selection by its header.
|
/// Reordering one contiguous whole-column selection by its header.
|
||||||
MoveCols {
|
MoveCols {
|
||||||
source: RangeRef,
|
source: RangeRef,
|
||||||
|
|
@ -118,6 +124,12 @@ pub struct GridState {
|
||||||
/// The selection outline on its way to a new selection.
|
/// The selection outline on its way to a new selection.
|
||||||
pub glide: crate::selection_glide::SelectionGlide,
|
pub glide: crate::selection_glide::SelectionGlide,
|
||||||
pub last_click: Option<(CellRef, f64)>,
|
pub last_click: Option<(CellRef, f64)>,
|
||||||
|
/// The grid acted on the current press when the button went down (#900): its drag start or
|
||||||
|
/// click must not act again. Cleared when the button is up.
|
||||||
|
pub press_consumed: bool,
|
||||||
|
/// The cell a plain press selected: releasing over it without dragging is a click, which
|
||||||
|
/// toggles its checkbox or follows its hyperlink.
|
||||||
|
pub click_cell: Option<CellRef>,
|
||||||
pub hover_cell: Option<CellRef>,
|
pub hover_cell: Option<CellRef>,
|
||||||
pub autosave: bool,
|
pub autosave: bool,
|
||||||
pub last_autosave: f64,
|
pub last_autosave: f64,
|
||||||
|
|
@ -1775,6 +1787,10 @@ pub(crate) fn paint_headers(
|
||||||
const OBJECT_RESIZE_RADIUS: f32 = 10.0;
|
const OBJECT_RESIZE_RADIUS: f32 = 10.0;
|
||||||
|
|
||||||
fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &Geo, sh: &Sheet, wb: &Workbook) {
|
fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &Geo, sh: &Sheet, wb: &Workbook) {
|
||||||
|
// A press the grid acted on lasts through its release, however the release is handled.
|
||||||
|
if ui.input(|i| !i.pointer.any_down() && !i.pointer.any_pressed() && !i.pointer.any_released()) {
|
||||||
|
app.grid.press_consumed = false;
|
||||||
|
}
|
||||||
// Text selection and editing must not also move the object or edit worksheet cells.
|
// Text selection and editing must not also move the object or edit worksheet cells.
|
||||||
if app.text_box_editor.is_some() || app.grid.hf_edit.is_some() {
|
if app.text_box_editor.is_some() || app.grid.hf_edit.is_some() {
|
||||||
return;
|
return;
|
||||||
|
|
@ -1966,38 +1982,67 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Double-click the fill handle: a double-click is also a click, so run it before the handle press returns.
|
// A double-click on the fill handle fills down as far as the data next to the source goes
|
||||||
|
// (#877). It arrives with the second release, after that press armed a fill drag.
|
||||||
if resp.double_clicked()
|
if resp.double_clicked()
|
||||||
&& let Some(p) = pos
|
&& app.editor.is_none()
|
||||||
|
&& let Some(p) = pointer.press_origin().map(|p| geo.mirror_pos(p)).or(pos)
|
||||||
&& on_handle(p)
|
&& on_handle(p)
|
||||||
{
|
{
|
||||||
// Fill down as far as the neighbouring column goes.
|
|
||||||
let src = sel.current();
|
|
||||||
let neighbour = if src.start.col > 0 { src.start.col - 1 } else { src.end.col + 1 };
|
|
||||||
let mut last = src.end.row;
|
|
||||||
while last + 1 < MAX_ROWS && sh.value_ref(CellRef::new(last + 1, neighbour)).is_some_and(|v| !v.is_empty()) && last - src.end.row < 1_000_000
|
|
||||||
{
|
|
||||||
last += 1;
|
|
||||||
}
|
|
||||||
if last > src.end.row {
|
|
||||||
let target = RangeRef::new(src.start, CellRef::new(last, src.end.col));
|
|
||||||
let _ = app.run("edit.autoFill", json!({"source": src.a1(), "target": target.a1()}));
|
|
||||||
}
|
|
||||||
app.grid.drag = Drag::None;
|
app.grid.drag = Drag::None;
|
||||||
app.grid.drag_select = false;
|
app.grid.drag_select = false;
|
||||||
resp.request_focus();
|
app.grid.click_cell = None;
|
||||||
|
app.run_or_alert("edit.autoFillToData", json!({"source": sel.current().a1()}));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A press acts when the button goes down, as in Excel (#900): the cell under it is selected
|
||||||
|
// at once and a drag extends from it. Targets that behave like buttons (the outline band) or
|
||||||
|
// whose press may still turn into a drag of something else (the selection border, a selected
|
||||||
|
// column header, an object) wait for the drag to start or for the click.
|
||||||
|
let press_now = resp.hovered() && pointer.primary_pressed() && app.grid.drag == Drag::None;
|
||||||
|
let follow_up = !press_now && (resp.drag_started() || primary_clicked) && app.grid.drag == Drag::None && !app.grid.press_consumed;
|
||||||
|
if press_now {
|
||||||
|
app.grid.press_consumed = true;
|
||||||
|
app.grid.click_cell = None;
|
||||||
|
}
|
||||||
|
// Releasing a plain press without dragging away from its cell clicks it.
|
||||||
|
if primary_clicked
|
||||||
|
&& !press_now
|
||||||
|
&& let Some(c) = app.grid.click_cell.take()
|
||||||
|
&& let Some(p) = pos
|
||||||
|
&& in_cells
|
||||||
|
&& geo.cell_at(sh, p) == c
|
||||||
|
&& app.editor.is_none()
|
||||||
|
{
|
||||||
|
click_cell(app, geo, sh, c, p, mods);
|
||||||
|
}
|
||||||
|
// Leaves this press to its drag start or its click.
|
||||||
|
let defer = |app: &mut SheetApp| {
|
||||||
|
if press_now && !primary_clicked {
|
||||||
|
app.grid.press_consumed = false;
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Press: decide the drag mode.
|
// Press: decide the drag mode.
|
||||||
if resp.drag_started() || (primary_clicked && app.grid.drag == Drag::None) {
|
if press_now || follow_up {
|
||||||
// Where the press began (a drag is only recognised after the pointer has moved).
|
// Where the press began (a drag is only recognised after the pointer has moved).
|
||||||
let Some(p) = pointer.press_origin().map(|p| geo.mirror_pos(p)).or(pos) else { return };
|
let Some(p) = pointer.press_origin().map(|p| geo.mirror_pos(p)).or(pos) else { return };
|
||||||
|
// Objects take their press on its drag start or click (above), and a header or footer
|
||||||
|
// in Page Layout view on its click (`paper_view`).
|
||||||
|
if (crate::chartview::hit(app, geo, sh, p).is_some() || crate::paper_view::over_header_footer(geo, geo.mirror_pos(p))) && defer(app) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
// The outline band's − / + and level buttons act on release, like buttons.
|
// The outline band's − / + and level buttons act on release, like buttons.
|
||||||
if let Some(action) = crate::outline::hit(geo, sh, p) {
|
if let Some(action) = crate::outline::hit(geo, sh, p) {
|
||||||
if primary_clicked {
|
if primary_clicked {
|
||||||
let (command, params) = action.command();
|
let (command, params) = action.command();
|
||||||
app.run_or_alert(command, params);
|
app.run_or_alert(command, params);
|
||||||
|
} else {
|
||||||
|
defer(app);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -2016,17 +2061,20 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let c = geo.col_at(sh, p.x);
|
let c = geo.col_at(sh, p.x);
|
||||||
if point_header(app, wb, sh, RangeRef::cols(c, c)) {
|
// Point mode: a column header puts the whole column into the formula (`C:C`).
|
||||||
|
if point_lines(app, wb, sh, true, c, c) {
|
||||||
|
app.grid.drag = Drag::PointLines { cols: true, anchor: c };
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if resp.drag_started()
|
if !mods.any() && sel.ranges.len() == 1 && sel.current().is_full_cols() && sel.current().contains(CellRef::new(0, c)) {
|
||||||
&& !mods.any()
|
// A selected column: dragging it moves the columns, a click selects just it.
|
||||||
&& sel.ranges.len() == 1
|
if resp.drag_started() {
|
||||||
&& sel.current().is_full_cols()
|
app.grid.drag = Drag::MoveCols { source: sel.current(), before: None };
|
||||||
&& sel.current().contains(CellRef::new(0, c))
|
return;
|
||||||
{
|
}
|
||||||
app.grid.drag = Drag::MoveCols { source: sel.current(), before: None };
|
if defer(app) {
|
||||||
return;
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
let range = if mods.shift { RangeRef::cols(sel.anchor.col.min(c), sel.anchor.col.max(c)) } else { RangeRef::cols(c, c) };
|
let range = if mods.shift { RangeRef::cols(sel.anchor.col.min(c), sel.anchor.col.max(c)) } else { RangeRef::cols(c, c) };
|
||||||
select_ranges(app, range, mods.command, CellRef::new(geo.row_at(sh, geo.cells.top() + 1.0), c));
|
select_ranges(app, range, mods.command, CellRef::new(geo.row_at(sh, geo.cells.top() + 1.0), c));
|
||||||
|
|
@ -2039,7 +2087,8 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let r = geo.row_at(sh, p.y);
|
let r = geo.row_at(sh, p.y);
|
||||||
if point_header(app, wb, sh, RangeRef::rows(r, r)) {
|
if point_lines(app, wb, sh, false, r, r) {
|
||||||
|
app.grid.drag = Drag::PointLines { cols: false, anchor: r };
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let range = if mods.shift { RangeRef::rows(sel.anchor.row.min(r), sel.anchor.row.max(r)) } else { RangeRef::rows(r, r) };
|
let range = if mods.shift { RangeRef::rows(sel.anchor.row.min(r), sel.anchor.row.max(r)) } else { RangeRef::rows(r, r) };
|
||||||
|
|
@ -2091,9 +2140,15 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &
|
||||||
app.grid.drag = Drag::Fill { target: None };
|
app.grid.drag = Drag::Fill { target: None };
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if on_cur_border(p) && resp.drag_started() {
|
if on_cur_border(p) {
|
||||||
app.grid.drag = Drag::Move { grab: c, to: None };
|
// The selection's border drags it to a new place; a click on it selects the cell.
|
||||||
return;
|
if resp.drag_started() {
|
||||||
|
app.grid.drag = Drag::Move { grab: c, to: None };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if defer(app) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Double-click edits.
|
// Double-click edits.
|
||||||
let now = crate::now_ms();
|
let now = crate::now_ms();
|
||||||
|
|
@ -2117,18 +2172,10 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &
|
||||||
select_ranges(app, RangeRef::cell(c), true, c);
|
select_ranges(app, RangeRef::cell(c), true, c);
|
||||||
} else {
|
} else {
|
||||||
let _ = app.session.run("selection.set", json!({"cell": c.a1()}));
|
let _ = app.session.run("selection.set", json!({"cell": c.a1()}));
|
||||||
if primary_clicked && wb.styles.get(sh.style_id(c)).num_fmt.as_str() == "checkbox" {
|
if primary_clicked {
|
||||||
let _ = app.run("cell.toggleCheckbox", json!({"cell": c.a1()}));
|
click_cell(app, geo, sh, c, p, mods);
|
||||||
}
|
} else {
|
||||||
// A click on a hyperlink's text follows it (like Excel); elsewhere in the cell selects.
|
app.grid.click_cell = Some(c);
|
||||||
if let Some(h) = sh.hyperlinks.get(&c).cloned()
|
|
||||||
&& primary_clicked
|
|
||||||
&& !mods.any()
|
|
||||||
{
|
|
||||||
let r = geo.cell_rect(sh, c);
|
|
||||||
if p.x < r.left() + r.width() * 0.8 {
|
|
||||||
follow_link(app, &h.target);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
app.grid.drag = Drag::Select;
|
app.grid.drag = Drag::Select;
|
||||||
|
|
@ -2182,6 +2229,11 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &
|
||||||
app.session.run("selection.set", json!({"range": RangeRef::cols(anchor.min(c), anchor.max(c)).a1(), "active": sel.active.a1()}));
|
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));
|
scroll_by(app, vec2(auto.x, 0.0));
|
||||||
}
|
}
|
||||||
|
Drag::PointLines { cols, anchor } => {
|
||||||
|
let to = if cols { geo.col_at(sh, p.x) } else { geo.row_at(sh, p.y) };
|
||||||
|
point_lines(app, wb, sh, cols, anchor, to);
|
||||||
|
scroll_by(app, if cols { vec2(auto.x, 0.0) } else { vec2(0.0, auto.y) });
|
||||||
|
}
|
||||||
Drag::MoveCols { source, .. } => {
|
Drag::MoveCols { source, .. } => {
|
||||||
let col = geo.col_at(sh, p.x.clamp(geo.cells.left() + 1.0, geo.cells.right() - 1.0));
|
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;
|
let midpoint = geo.x(sh, col) + sh.col_width(col) * geo.z * 0.5;
|
||||||
|
|
@ -2324,22 +2376,14 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if primary_clicked || resp.drag_started() {
|
// The grid takes the keyboard, except from a formula being pointed at (its press already
|
||||||
|
// handed focus back to the editor).
|
||||||
|
if (primary_clicked || resp.drag_started()) && app.editor.is_none() {
|
||||||
resp.request_focus();
|
resp.request_focus();
|
||||||
}
|
}
|
||||||
keyboard(app, &ctx, resp, geo, sh);
|
keyboard(app, &ctx, resp, geo, sh);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Point mode on a header: inserts the whole-column or whole-row reference instead of selecting.
|
|
||||||
fn point_header(app: &mut SheetApp, wb: &Workbook, sh: &Sheet, range: RangeRef) -> bool {
|
|
||||||
let Some(ed) = app.editor.as_mut().filter(|ed| ed.can_point()) else { return false };
|
|
||||||
let r = range.a1();
|
|
||||||
let r = if ed.sheet != wb.active_sheet { format!("{}!{r}", gridcraft_engine::formula::quote_sheet(&sh.name)) } else { r };
|
|
||||||
ed.insert_ref(&r);
|
|
||||||
ed.request_focus = true;
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
fn select_to_pointer(app: &mut SheetApp, geo: &Geo, sh: &Sheet, wb: &Workbook, p: Pos2) {
|
fn select_to_pointer(app: &mut SheetApp, geo: &Geo, sh: &Sheet, wb: &Workbook, p: Pos2) {
|
||||||
let cell = CellRef::new(
|
let cell = CellRef::new(
|
||||||
geo.row_at(sh, p.y.clamp(geo.cells.top() + 1.0, geo.cells.bottom() - 1.0)),
|
geo.row_at(sh, p.y.clamp(geo.cells.top() + 1.0, geo.cells.bottom() - 1.0)),
|
||||||
|
|
@ -2369,6 +2413,23 @@ fn select_to_pointer(app: &mut SheetApp, geo: &Geo, sh: &Sheet, wb: &Workbook, p
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Point mode on the headers: puts whole columns (`cols`, `B:D`) or rows (`2:5`) from `a` to `b`
|
||||||
|
/// into the formula being edited, as Excel does for a click or drag on the headings. `false`
|
||||||
|
/// (nothing changed) when not editing a formula where a reference can go.
|
||||||
|
fn point_lines(app: &mut SheetApp, wb: &Workbook, sh: &Sheet, cols: bool, a: u32, b: u32) -> bool {
|
||||||
|
let Some(ed) = app.editor.as_mut() else { return false };
|
||||||
|
if !ed.can_point() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let range = if cols { RangeRef::cols(a.min(b), a.max(b)) } else { RangeRef::rows(a.min(b), a.max(b)) };
|
||||||
|
let text = range.a1();
|
||||||
|
let text = if ed.sheet != wb.active_sheet { format!("{}!{text}", gridcraft_engine::formula::quote_sheet(&sh.name)) } else { text };
|
||||||
|
ed.insert_ref(&text);
|
||||||
|
ed.point_cell = None;
|
||||||
|
ed.request_focus = true;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
fn coalesce_last_undo(app: &mut SheetApp) {
|
fn coalesce_last_undo(app: &mut SheetApp) {
|
||||||
// Merge consecutive drag steps into one undo entry.
|
// Merge consecutive drag steps into one undo entry.
|
||||||
if let Some(d) = app.session.active_mut()
|
if let Some(d) = app.session.active_mut()
|
||||||
|
|
@ -2524,6 +2585,23 @@ fn delete_selected_object(app: &mut SheetApp, sh: &Sheet) -> bool {
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What a click on a cell does beyond selecting it: toggles its checkbox, or follows its
|
||||||
|
/// hyperlink when the click is on the link's text (like Excel; elsewhere in the cell it selects).
|
||||||
|
fn click_cell(app: &mut SheetApp, geo: &Geo, sh: &Sheet, c: CellRef, p: Pos2, mods: egui::Modifiers) {
|
||||||
|
let checkbox = app.session.active().is_some_and(|d| d.wb.styles.get(sh.style_id(c)).num_fmt.as_str() == "checkbox");
|
||||||
|
if checkbox {
|
||||||
|
let _ = app.run("cell.toggleCheckbox", json!({"cell": c.a1()}));
|
||||||
|
}
|
||||||
|
if let Some(h) = sh.hyperlinks.get(&c).cloned()
|
||||||
|
&& !mods.any()
|
||||||
|
{
|
||||||
|
let r = geo.cell_rect(sh, c);
|
||||||
|
if p.x < r.left() + r.width() * 0.8 {
|
||||||
|
follow_link(app, &h.target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// A click on a hyperlink. References select; web and e-mail addresses ask first (the dialog's
|
/// A click on a hyperlink. References select; web and e-mail addresses ask first (the dialog's
|
||||||
/// OK runs `link.open`); every other kind is refused with a message. The decision is the
|
/// OK runs `link.open`); every other kind is refused with a message. The decision is the
|
||||||
/// engine's ([`gridcraft_engine::links::classify`]), the same one `link.open` applies.
|
/// engine's ([`gridcraft_engine::links::classify`]), the same one `link.open` applies.
|
||||||
|
|
|
||||||
|
|
@ -306,6 +306,13 @@ pub(crate) fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Respo
|
||||||
editor(app, ui, geo, &pg);
|
editor(app, ui, geo, &pg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether `p` (screen) is over a header or footer zone, which a click edits in place.
|
||||||
|
pub fn over_header_footer(geo: &crate::grid::Geo, p: egui::Pos2) -> bool {
|
||||||
|
let Some(pg) = geo.paper.as_ref() else { return false };
|
||||||
|
let (x, y) = (geo.scroll.x as f64 + ((p.x - geo.cells.left()) / geo.z) as f64, geo.scroll.y as f64 + ((p.y - geo.cells.top()) / geo.z) as f64);
|
||||||
|
geo.cells.contains(p) && pg.hf_zone_at(x, y).is_some()
|
||||||
|
}
|
||||||
|
|
||||||
/// The `pageLayout.headerFooter` parameter holding one kind of page's header or footer.
|
/// The `pageLayout.headerFooter` parameter holding one kind of page's header or footer.
|
||||||
fn hf_key(kind: HfPage, footer: bool) -> &'static str {
|
fn hf_key(kind: HfPage, footer: bool) -> &'static str {
|
||||||
match (kind, footer) {
|
match (kind, footer) {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
//! Double-clicking the fill handle fills down to the neighbouring column's last used row,
|
//! Double-clicking the fill handle fills down to the bottom of the data beside it (`edit.autoFillToData`),
|
||||||
//! even with the pointer up for a frame between the two clicks.
|
//! even with the pointer up for a frame between the two clicks.
|
||||||
|
|
||||||
use egui::{Event, Modifiers, PointerButton, Pos2, vec2};
|
use egui::{Event, Modifiers, PointerButton, Pos2, vec2};
|
||||||
|
|
@ -55,5 +55,5 @@ fn double_click_on_the_fill_handle_fills_down_with_a_frame_between_the_clicks()
|
||||||
|
|
||||||
assert_eq!([number(&h, 3), number(&h, 4), number(&h, 5)], [Value::Number(40.0), Value::Number(50.0), Value::Number(60.0)]);
|
assert_eq!([number(&h, 3), number(&h, 4), number(&h, 5)], [Value::Number(40.0), Value::Number(50.0), Value::Number(60.0)]);
|
||||||
assert_eq!(h.state().session.active().unwrap().selection.a1(), "B1:B6");
|
assert_eq!(h.state().session.active().unwrap().selection.a1(), "B1:B6");
|
||||||
assert_eq!(h.state().session.journal.iter().filter(|(id, _)| id == "edit.autoFill").count(), 1);
|
assert_eq!(h.state().session.journal.iter().filter(|(id, _)| id == "edit.autoFillToData").count(), 1);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,348 @@
|
||||||
|
//! A press on a cell selects it when the button goes down, as in Excel (#900), and a double-click
|
||||||
|
//! on the fill handle fills down to the data next to it (#877).
|
||||||
|
|
||||||
|
use egui::{Event, Modifiers, PointerButton, Pos2};
|
||||||
|
use gridcraft_engine::Session;
|
||||||
|
use gridcraft_engine::core::{CellRef, RangeRef, Value};
|
||||||
|
use gridcraft_ui_egui::{SheetApp, grid::Geo};
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
type Harness = egui_kittest::Harness<'static, SheetApp>;
|
||||||
|
|
||||||
|
fn harness(session: Session) -> Harness {
|
||||||
|
// 60 frames a second, so two clicks a few frames apart are a double-click to egui.
|
||||||
|
let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).with_step_dt(1.0 / 60.0).build_ui_state(
|
||||||
|
|ui, app: &mut SheetApp| {
|
||||||
|
let ctx = ui.ctx().clone();
|
||||||
|
app.logic(&ctx);
|
||||||
|
app.ui(ui);
|
||||||
|
},
|
||||||
|
SheetApp::new(session, Default::default()),
|
||||||
|
);
|
||||||
|
h.run_steps(4);
|
||||||
|
h
|
||||||
|
}
|
||||||
|
|
||||||
|
fn blank() -> Session {
|
||||||
|
let mut s = Session::new();
|
||||||
|
s.new_workbook();
|
||||||
|
s
|
||||||
|
}
|
||||||
|
|
||||||
|
fn geo(h: &Harness) -> Geo {
|
||||||
|
let app = h.state();
|
||||||
|
let sheet = app.session.active().unwrap().wb.active().unwrap();
|
||||||
|
Geo::new(sheet, app.grid.rect.unwrap(), app.view().scroll)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rect(h: &Harness, a1: &str) -> egui::Rect {
|
||||||
|
let app = h.state();
|
||||||
|
let sheet = app.session.active().unwrap().wb.active().unwrap();
|
||||||
|
geo(h).cell_rect(sheet, CellRef::parse(a1).unwrap())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn frame(h: &mut Harness, event: Event) {
|
||||||
|
h.input_mut().events.push(event);
|
||||||
|
h.run_steps(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn button(pos: Pos2, pressed: bool, modifiers: Modifiers) -> Event {
|
||||||
|
Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn down(h: &mut Harness, at: Pos2, modifiers: Modifiers) {
|
||||||
|
h.input_mut().events.push(Event::ModifiersChanged(modifiers));
|
||||||
|
frame(h, Event::PointerMoved(at));
|
||||||
|
frame(h, button(at, true, modifiers));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn up(h: &mut Harness, at: Pos2, modifiers: Modifiers) {
|
||||||
|
frame(h, button(at, false, modifiers));
|
||||||
|
h.input_mut().events.push(Event::ModifiersChanged(Modifiers::NONE));
|
||||||
|
h.run_steps(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Waits out the double-click window (the grid times it on the wall clock).
|
||||||
|
fn pause(h: &mut Harness) {
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(500));
|
||||||
|
h.run_steps(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn selection(h: &Harness) -> (String, String, Vec<String>) {
|
||||||
|
let s = &h.state().session.active().unwrap().selection;
|
||||||
|
(s.active.a1(), s.anchor.a1(), s.ranges.iter().map(RangeRef::a1).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn value(h: &Harness, a1: &str) -> Value {
|
||||||
|
h.state().session.active().unwrap().wb.active().unwrap().value(CellRef::parse(a1).unwrap())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_press_selects_before_the_release() {
|
||||||
|
let mut h = harness(blank());
|
||||||
|
let c3 = rect(&h, "C3").center();
|
||||||
|
down(&mut h, c3, Modifiers::NONE);
|
||||||
|
assert_eq!(selection(&h).0, "C3", "the cell is selected while the button is still down");
|
||||||
|
up(&mut h, c3, Modifiers::NONE);
|
||||||
|
assert_eq!(selection(&h), ("C3".into(), "C3".into(), vec!["C3".into()]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_press_that_wobbles_inside_its_cell_still_selects_it() {
|
||||||
|
let mut h = harness(blank());
|
||||||
|
let r = rect(&h, "C3");
|
||||||
|
let at = r.left_top() + egui::vec2(4.0, 4.0);
|
||||||
|
down(&mut h, at, Modifiers::NONE);
|
||||||
|
// Further than egui's click distance, but still inside C3: a drag that never leaves the cell.
|
||||||
|
for step in 1..=6 {
|
||||||
|
frame(&mut h, Event::PointerMoved(at + egui::vec2(step as f32 * 5.0, step as f32 * 1.5)));
|
||||||
|
}
|
||||||
|
up(&mut h, at + egui::vec2(30.0, 9.0), Modifiers::NONE);
|
||||||
|
assert_eq!(selection(&h), ("C3".into(), "C3".into(), vec!["C3".into()]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_long_press_without_moving_selects_the_cell() {
|
||||||
|
let mut h = harness(blank());
|
||||||
|
let c3 = rect(&h, "C3").center();
|
||||||
|
down(&mut h, c3, Modifiers::NONE);
|
||||||
|
// Longer than egui's longest click: the release is no longer a click.
|
||||||
|
h.run_steps(60);
|
||||||
|
up(&mut h, c3, Modifiers::NONE);
|
||||||
|
assert_eq!(selection(&h).2, vec!["C3".to_string()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dragging_from_the_press_extends_live_then_stays() {
|
||||||
|
let mut h = harness(blank());
|
||||||
|
let c3 = rect(&h, "C3").center();
|
||||||
|
down(&mut h, c3, Modifiers::NONE);
|
||||||
|
let e5 = rect(&h, "E5").center();
|
||||||
|
frame(&mut h, Event::PointerMoved(c3.lerp(e5, 0.5)));
|
||||||
|
frame(&mut h, Event::PointerMoved(e5));
|
||||||
|
assert_eq!(selection(&h), ("C3".into(), "C3".into(), vec!["C3:E5".into()]), "the range follows the pointer while held");
|
||||||
|
up(&mut h, e5, Modifiers::NONE);
|
||||||
|
assert_eq!(selection(&h), ("C3".into(), "C3".into(), vec!["C3:E5".into()]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shift_and_ctrl_presses_act_on_the_press() {
|
||||||
|
let mut h = harness(blank());
|
||||||
|
let b2 = rect(&h, "B2").center();
|
||||||
|
down(&mut h, b2, Modifiers::NONE);
|
||||||
|
up(&mut h, b2, Modifiers::NONE);
|
||||||
|
pause(&mut h);
|
||||||
|
let d4 = rect(&h, "D4").center();
|
||||||
|
down(&mut h, d4, Modifiers::SHIFT);
|
||||||
|
assert_eq!(selection(&h).2, vec!["B2:D4".to_string()], "Shift+press extends at once");
|
||||||
|
up(&mut h, d4, Modifiers::SHIFT);
|
||||||
|
assert_eq!(selection(&h).2, vec!["B2:D4".to_string()]);
|
||||||
|
pause(&mut h);
|
||||||
|
let f6 = rect(&h, "F6").center();
|
||||||
|
let ctrl = Modifiers { ctrl: true, command: true, ..Modifiers::NONE };
|
||||||
|
down(&mut h, f6, ctrl);
|
||||||
|
assert_eq!(selection(&h).2, vec!["B2:D4".to_string(), "F6".to_string()], "Ctrl+press adds an area at once");
|
||||||
|
up(&mut h, f6, ctrl);
|
||||||
|
assert_eq!(selection(&h).2, vec!["B2:D4".to_string(), "F6".to_string()], "and the release doesn't add it twice");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_checkbox_toggles_on_the_click_not_the_press() {
|
||||||
|
let mut s = blank();
|
||||||
|
s.execute("insert.checkbox", json!({"range": "B2"})).unwrap();
|
||||||
|
let mut h = harness(s);
|
||||||
|
let b2 = rect(&h, "B2").center();
|
||||||
|
down(&mut h, b2, Modifiers::NONE);
|
||||||
|
assert_eq!(selection(&h).0, "B2");
|
||||||
|
assert_eq!(value(&h, "B2"), Value::Bool(false), "pressing doesn't toggle yet");
|
||||||
|
up(&mut h, b2, Modifiers::NONE);
|
||||||
|
assert_eq!(value(&h, "B2"), Value::Bool(true), "the click toggles once");
|
||||||
|
pause(&mut h);
|
||||||
|
// A press that is dragged off the cell is not a click.
|
||||||
|
down(&mut h, b2, Modifiers::NONE);
|
||||||
|
let d2 = rect(&h, "D2").center();
|
||||||
|
frame(&mut h, Event::PointerMoved(d2));
|
||||||
|
up(&mut h, d2, Modifiers::NONE);
|
||||||
|
assert_eq!(value(&h, "B2"), Value::Bool(true));
|
||||||
|
assert_eq!(selection(&h).2, vec!["B2:D2".to_string()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn double_click_still_edits_the_cell() {
|
||||||
|
let mut s = blank();
|
||||||
|
s.execute("cell.set", json!({"cell": "C3", "input": "hello"})).unwrap();
|
||||||
|
let mut h = harness(s);
|
||||||
|
let c3 = rect(&h, "C3").center();
|
||||||
|
for _ in 0..2 {
|
||||||
|
down(&mut h, c3, Modifiers::NONE);
|
||||||
|
frame(&mut h, button(c3, false, Modifiers::NONE));
|
||||||
|
}
|
||||||
|
h.run_steps(2);
|
||||||
|
let editor = h.state().editor.as_ref().expect("a double-click edits");
|
||||||
|
assert_eq!((editor.cell.a1(), editor.text.as_str()), ("C3".to_string(), "hello"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_click_on_the_selection_border_selects_the_cell_and_a_drag_moves_it() {
|
||||||
|
let mut s = blank();
|
||||||
|
s.execute("cell.set", json!({"cell": "B2", "input": "7"})).unwrap();
|
||||||
|
s.execute("selection.set", json!({"range": "B2"})).unwrap();
|
||||||
|
let mut h = harness(s);
|
||||||
|
// On B2's right border, which belongs to the move handle: nothing happens on the press.
|
||||||
|
let r = rect(&h, "B2");
|
||||||
|
let border = egui::pos2(r.right(), r.center().y);
|
||||||
|
down(&mut h, border, Modifiers::NONE);
|
||||||
|
assert_eq!(selection(&h).0, "B2");
|
||||||
|
let d4 = rect(&h, "D4").center();
|
||||||
|
frame(&mut h, Event::PointerMoved(border + egui::vec2(20.0, 0.0)));
|
||||||
|
frame(&mut h, Event::PointerMoved(d4));
|
||||||
|
up(&mut h, d4, Modifiers::NONE);
|
||||||
|
assert_eq!(value(&h, "B2"), Value::Empty, "dragging the border moved the cell");
|
||||||
|
assert_eq!(value(&h, "D4"), Value::Number(7.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn double_clicking_the_fill_handle_fills_to_the_data_beside_it() {
|
||||||
|
// The sheet from #877: 1..10 in A, 2, 4, 6, 8 in B1:B4.
|
||||||
|
let mut s = blank();
|
||||||
|
let a: Vec<Vec<f64>> = (1..=10).map(|i| vec![i as f64]).collect();
|
||||||
|
s.execute("range.setValues", json!({"range": "A1", "values": a})).unwrap();
|
||||||
|
s.execute("range.setValues", json!({"range": "B1", "values": [[2], [4], [6], [8]]})).unwrap();
|
||||||
|
s.execute("selection.set", json!({"range": "B1:B4"})).unwrap();
|
||||||
|
let mut h = harness(s);
|
||||||
|
let handle = rect(&h, "B4").right_bottom() - egui::vec2(1.0, 1.0);
|
||||||
|
frame(&mut h, Event::PointerMoved(handle));
|
||||||
|
frame(&mut h, button(handle, true, Modifiers::NONE));
|
||||||
|
frame(&mut h, button(handle, false, Modifiers::NONE));
|
||||||
|
// A few frames between the clicks, as a real double-click has (still well inside egui's window).
|
||||||
|
h.run_steps(4);
|
||||||
|
frame(&mut h, button(handle, true, Modifiers::NONE));
|
||||||
|
frame(&mut h, button(handle, false, Modifiers::NONE));
|
||||||
|
h.run_steps(3);
|
||||||
|
assert_eq!(value(&h, "B10"), Value::Number(20.0));
|
||||||
|
assert_eq!(value(&h, "B11"), Value::Empty);
|
||||||
|
assert_eq!(selection(&h).2, vec!["B1:B10".to_string()]);
|
||||||
|
// One undo step.
|
||||||
|
h.state_mut().run("edit.undo", json!({})).unwrap();
|
||||||
|
assert_eq!(value(&h, "B5"), Value::Empty);
|
||||||
|
assert_eq!(value(&h, "B4"), Value::Number(8.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn header_point(h: &Harness, col: Option<&str>, row: Option<u32>) -> Pos2 {
|
||||||
|
let g = geo(h);
|
||||||
|
let app = h.state();
|
||||||
|
let sheet = app.session.active().unwrap().wb.active().unwrap();
|
||||||
|
match (col, row) {
|
||||||
|
(Some(c), _) => {
|
||||||
|
let x = g.cell_rect(sheet, CellRef::parse(&format!("{c}1")).unwrap()).center().x;
|
||||||
|
egui::pos2(x, g.cells.top() - g.header_h / 2.0)
|
||||||
|
}
|
||||||
|
(_, Some(r)) => {
|
||||||
|
let y = g.cell_rect(sheet, CellRef::new(r - 1, 0)).center().y;
|
||||||
|
egui::pos2(g.cells.left() - g.header_w / 2.0, y)
|
||||||
|
}
|
||||||
|
_ => unreachable!(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn start_typing(h: &mut Harness, cell: &str, text: &str) {
|
||||||
|
h.state_mut().session.execute("selection.set", json!({"cell": cell})).unwrap();
|
||||||
|
h.run_steps(2);
|
||||||
|
h.input_mut().events.push(Event::Text(text.into()));
|
||||||
|
h.run_steps(3);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn editor_text(h: &Harness) -> String {
|
||||||
|
h.state().editor.as_ref().map(|e| e.text.clone()).unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn enter(h: &mut Harness) {
|
||||||
|
h.input_mut().events.push(Event::Key { key: egui::Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE });
|
||||||
|
h.input_mut().events.push(Event::Key { key: egui::Key::Enter, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE });
|
||||||
|
h.run_steps(3);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn formula(h: &Harness, a1: &str) -> Option<String> {
|
||||||
|
let sheet = h.state().session.active().unwrap().wb.active().unwrap();
|
||||||
|
sheet.cell(CellRef::parse(a1).unwrap()).and_then(|c| c.formula.as_ref().map(|f| f.text.clone()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// While typing a formula, the headings point like cells do, as observed in Excel: a column
|
||||||
|
/// heading puts `B:B` in, a drag across headings `B:D`, a row heading `3:3` (#872).
|
||||||
|
#[test]
|
||||||
|
fn headings_put_whole_columns_and_rows_into_a_formula() {
|
||||||
|
let mut s = blank();
|
||||||
|
s.execute("range.setValues", json!({"range": "B1", "values": [[1, 10, 100], [2, 20, 200], [3, 30, 300]]})).unwrap();
|
||||||
|
let mut h = harness(s);
|
||||||
|
|
||||||
|
start_typing(&mut h, "F1", "=SUM(");
|
||||||
|
let b = header_point(&h, Some("B"), None);
|
||||||
|
down(&mut h, b, Modifiers::NONE);
|
||||||
|
assert_eq!(editor_text(&h), "=SUM(B:B", "the press points");
|
||||||
|
up(&mut h, b, Modifiers::NONE);
|
||||||
|
assert_eq!(editor_text(&h), "=SUM(B:B");
|
||||||
|
assert_eq!(h.state().session.active().unwrap().selection.active.a1(), "F1", "the selection stays on the edited cell");
|
||||||
|
h.input_mut().events.push(Event::Text(")".into()));
|
||||||
|
h.run_steps(2);
|
||||||
|
enter(&mut h);
|
||||||
|
assert_eq!(formula(&h, "F1").as_deref(), Some("SUM(B:B)"));
|
||||||
|
assert_eq!(value(&h, "F1"), Value::Number(6.0));
|
||||||
|
|
||||||
|
// A drag across the headings widens it, and back again.
|
||||||
|
pause(&mut h);
|
||||||
|
start_typing(&mut h, "F2", "=SUM(");
|
||||||
|
let (b, c, d) = (header_point(&h, Some("B"), None), header_point(&h, Some("C"), None), header_point(&h, Some("D"), None));
|
||||||
|
down(&mut h, b, Modifiers::NONE);
|
||||||
|
frame(&mut h, Event::PointerMoved(c));
|
||||||
|
frame(&mut h, Event::PointerMoved(d));
|
||||||
|
assert_eq!(editor_text(&h), "=SUM(B:D");
|
||||||
|
frame(&mut h, Event::PointerMoved(c));
|
||||||
|
assert_eq!(editor_text(&h), "=SUM(B:C");
|
||||||
|
up(&mut h, c, Modifiers::NONE);
|
||||||
|
h.input_mut().events.push(Event::Text(")".into()));
|
||||||
|
h.run_steps(2);
|
||||||
|
enter(&mut h);
|
||||||
|
assert_eq!(value(&h, "F2"), Value::Number(66.0));
|
||||||
|
|
||||||
|
// A row heading.
|
||||||
|
pause(&mut h);
|
||||||
|
start_typing(&mut h, "F4", "=SUM(");
|
||||||
|
let r3 = header_point(&h, None, Some(3));
|
||||||
|
down(&mut h, r3, Modifiers::NONE);
|
||||||
|
up(&mut h, r3, Modifiers::NONE);
|
||||||
|
assert_eq!(editor_text(&h), "=SUM(3:3");
|
||||||
|
h.input_mut().events.push(Event::Text(")".into()));
|
||||||
|
h.run_steps(2);
|
||||||
|
enter(&mut h);
|
||||||
|
assert_eq!(value(&h, "F4"), Value::Number(333.0));
|
||||||
|
|
||||||
|
// Not in point mode (after a reference was typed), a heading click ends the edit and selects.
|
||||||
|
pause(&mut h);
|
||||||
|
start_typing(&mut h, "F5", "=A1");
|
||||||
|
let c = header_point(&h, Some("C"), None);
|
||||||
|
down(&mut h, c, Modifiers::NONE);
|
||||||
|
up(&mut h, c, Modifiers::NONE);
|
||||||
|
assert_ne!(editor_text(&h), "=A1C:C");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pointing at cells while typing a formula still works with presses: the press puts the
|
||||||
|
/// reference in, a drag widens it, and the keyboard stays with the formula.
|
||||||
|
#[test]
|
||||||
|
fn cells_point_into_a_formula_on_the_press() {
|
||||||
|
let mut s = blank();
|
||||||
|
s.execute("range.setValues", json!({"range": "B1", "values": [[1], [2], [3]]})).unwrap();
|
||||||
|
let mut h = harness(s);
|
||||||
|
start_typing(&mut h, "D1", "=SUM(");
|
||||||
|
let (b1, b3) = (rect(&h, "B1").center(), rect(&h, "B3").center());
|
||||||
|
down(&mut h, b1, Modifiers::NONE);
|
||||||
|
assert_eq!(editor_text(&h), "=SUM(B1");
|
||||||
|
frame(&mut h, Event::PointerMoved(b1.lerp(b3, 0.5)));
|
||||||
|
frame(&mut h, Event::PointerMoved(b3));
|
||||||
|
up(&mut h, b3, Modifiers::NONE);
|
||||||
|
assert_eq!(editor_text(&h), "=SUM(B1:B3");
|
||||||
|
h.input_mut().events.push(Event::Text(")".into()));
|
||||||
|
h.run_steps(2);
|
||||||
|
enter(&mut h);
|
||||||
|
assert_eq!(formula(&h, "D1").as_deref(), Some("SUM(B1:B3)"));
|
||||||
|
assert_eq!(value(&h, "D1"), Value::Number(6.0));
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# UI parity
|
# UI parity
|
||||||
|
|
||||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (horizontal scrollbar in the sheet tab bar, #840) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
|
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (presses select on button-down, fill-handle double-click, whole-line references in point mode; observed against Windows Excel) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
|
||||||
|
|
||||||
Interaction, controls and feel, against Excel. Part of [target-app-parity.md](target-app-parity.md).
|
Interaction, controls and feel, against Excel. Part of [target-app-parity.md](target-app-parity.md).
|
||||||
**UI/UX fidelity: ~55% ready, 50–80 h** (estimated). Ribbon command coverage is measured
|
**UI/UX fidelity: ~55% ready, 50–80 h** (estimated). Ribbon command coverage is measured
|
||||||
|
|
@ -43,12 +43,12 @@ this logic, with one undo step.
|
||||||
|
|
||||||
| Behaviour | Status |
|
| Behaviour | Status |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Click/drag/Shift/Ctrl selection, multi-area selection | rectangular drag works in one direction only in some cases (#28) |
|
| Click/drag/Shift/Ctrl selection, multi-area selection | done; a press selects when the button goes down and a drag extends from it, as in Excel; clicks on checkboxes and links act on release (#900). Rectangular drag works in one direction only in some cases (#28) |
|
||||||
| 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 |
|
| Selection outline glides to a new selection (Excel: "Provide feedback with animation") | done (10-11): 0.12 s, decelerating, with the fill and fill handle; View ▸ Animations (`view.animations`) turns it off. The timing is an estimate, not yet measured against Excel; the system's reduced-motion setting isn't followed yet |
|
||||||
| Fill handle: drag to fill series, double-click to fill down, right-drag menu | fill done (Ctrl+drag swaps copy and series); right-drag menu missing |
|
| Fill handle: drag to fill series, double-click to fill down, right-drag menu | fill done (Ctrl+drag swaps copy and series); double-click fills to the bottom of the data region around the source and stops above a filled cell, matching 13 cases observed in Excel (`edit.autoFillToData`, #877); right-drag menu missing |
|
||||||
| Drag-move and drag-copy cells (border drag, Ctrl/Option, Shift to insert) | done (M3) |
|
| 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 |
|
| In-cell editing, Enter/Tab movement, Alt/Option+Enter line breaks | done |
|
||||||
| Point mode (click/drag/arrow references), reference colouring, F4 | done |
|
| Point mode (click/drag/arrow references), reference colouring, F4 | done; headings point whole columns and rows (`B:D`, `3:3`), which are coloured and outlined, and F4 toggles them `$B:$D` ⇄ `B:D` as in Excel (#872, #873, #875) |
|
||||||
| AutoComplete in columns, function autocomplete and argument tips | done; cell helpers clip to the worksheet and hide when the editor scrolls out (#200) |
|
| AutoComplete in columns, function autocomplete and argument tips | done; cell helpers clip to the worksheet and hide when the editor scrolls out (#200) |
|
||||||
| Header resize and double-click AutoFit | done (#42, #88 for header dividers) |
|
| Header resize and double-click AutoFit | done (#42, #88 for header dividers) |
|
||||||
| Outline symbols for grouped rows / columns (band beside the headers, brackets, − / +, level buttons 1…n+1) | done (#373): clicks run `data.hideDetail` / `data.showDetail` / `data.showLevels`, frozen panes and right-to-left sheets included; summary rows/columns are always below/right (the "Summary rows below detail" setting isn't modelled), no "Show outline symbols" option yet, and a block of cells still groups rows without the Rows / Columns dialog |
|
| Outline symbols for grouped rows / columns (band beside the headers, brackets, − / +, level buttons 1…n+1) | done (#373): clicks run `data.hideDetail` / `data.showDetail` / `data.showLevels`, frozen panes and right-to-left sheets included; summary rows/columns are always below/right (the "Summary rows below detail" setting isn't modelled), no "Show outline symbols" option yet, and a block of cells still groups rows without the Rows / Columns dialog |
|
||||||
|
|
@ -161,6 +161,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12.
|
||||||
|
|
||||||
| Date | Change | Summary |
|
| Date | Change | Summary |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
| 2026-10-13 | minor | Presses select on button-down (#900); fill-handle double-click fills to the adjacent data (#877); headings point `A:A`/`2:2` into formulas, coloured, outlined and toggled by F4 (#872, #873, #875) |
|
||||||
| 2026-10-12 | minor | The File view's recent list tells when each workbook was last changed, read off the UI thread |
|
| 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 | Horizontal scrollbar in the sheet tab bar: drag, track click pages, frozen columns excluded from the page (#840) |
|
||||||
| 2026-10-12 | minor | Ctrl+wheel zoom steps along a ladder of percentages about 20% apart that never skips 100%, gliding to each step with animations on, so a notch out undoes a notch in (it drifted: 100 → 122 → 101); Excel steps 15 points, see decisions.md |
|
| 2026-10-12 | minor | Ctrl+wheel zoom steps along a ladder of percentages about 20% apart that never skips 100%, gliding to each step with animations on, so a notch out undoes a notch in (it drifted: 100 → 122 → 101); Excel steps 15 points, see decisions.md |
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue