mirror of
https://github.com/storytold/gridcraft.git
synced 2026-11-02 07:06:48 -06:00
@@ -69,6 +69,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
),
|
||||
cmd!(query "formulas.tracePrecedents", "Trace Precedents", ["Formulas", "Formula Auditing"], None, "{cell?} → ranges", has_doc, trace_precedents),
|
||||
cmd!(query "formulas.traceDependents", "Trace Dependents", ["Formulas", "Formula Auditing"], None, "{cell?} → cells", has_doc, trace_dependents),
|
||||
cmd!(
|
||||
query "formulas.traceError",
|
||||
"Trace Error",
|
||||
["Formulas", "Formula Auditing"],
|
||||
None,
|
||||
"{cell?} → {sheet, cell, error, source: {sheet, cell}, arrows: [{from: {sheet, range}, to: {sheet, cell}, kind: \"error\"|\"precedent\"}], truncated}: from the cell, follows the first reference that shows the same error (in a range, its first cell showing it) back to the cell the error starts in, `source`. `error` arrows link the cells on the way; `precedent` arrows lead from the source's own references into it. A cell without an error gives `error` and `source` null and no arrows. A chain longer than 10,000 links stops there with `truncated` true and `source` null.",
|
||||
has_doc,
|
||||
trace_error
|
||||
),
|
||||
cmd!(noundo "formulas.selectPrecedents", "Select Precedents", ["Formulas", "Formula Auditing"], Some("Ctrl+["), "{cell?, all?: true = every level (Ctrl+Shift+[)} → selects the precedent cells", has_doc, select_precedents),
|
||||
cmd!(noundo "formulas.selectDependents", "Select Dependents", ["Formulas", "Formula Auditing"], Some("Ctrl+]"), "{cell?, all?: true = every level (Ctrl+Shift+])}", has_doc, select_dependents),
|
||||
cmd!(noundo "formulas.removeArrows", "Remove Arrows", ["Formulas", "Formula Auditing"], None, "{}", has_doc, |_, _| ok()),
|
||||
@@ -653,6 +662,79 @@ fn trace_dependents(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
Ok(Json::Array(v))
|
||||
}
|
||||
|
||||
/// Trace Error goes back at most this many links; a longer chain stops there.
|
||||
const TRACE_ERROR_LINKS: usize = 10_000;
|
||||
|
||||
/// Trace Error: from the cell, the first reference in its formula that shows the same error
|
||||
/// (in a range, the range's first cell showing it, row by row), then that cell's, and so on to the
|
||||
/// cell the error starts in: one none of whose references shows it, or one without references.
|
||||
/// Arrows link the cells on the way, and lead from the source's own references into it.
|
||||
fn trace_error(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
use std::collections::HashSet;
|
||||
let d = s.doc()?;
|
||||
let at = cell_param(s, p, "cell")?.unwrap_or(d.selection.active);
|
||||
let sheet = d.wb.active_sheet();
|
||||
let name = |si: usize| d.wb.sheet(si).map(|sh| sh.name.clone());
|
||||
let cell = |(si, c): (usize, CellRef)| json!({"sheet": name(si), "cell": c.a1()});
|
||||
let Some(error) = d.wb.sheet(sheet).and_then(|sh| sh.value(at).as_error()) else {
|
||||
return Ok(json!({"sheet": name(sheet), "cell": at.a1(), "error": null, "source": null, "arrows": [], "truncated": false}));
|
||||
};
|
||||
// A value spilled by a dynamic array comes from its anchor's formula.
|
||||
let precedents = |(si, c): (usize, CellRef)| {
|
||||
let anchor = d.wb.sheet(si).filter(|sh| sh.cell(c).is_none_or(|x| x.formula.is_none())).and_then(|sh| sh.spill_anchor(c));
|
||||
d.calc.graph.precedents_of((si, anchor.unwrap_or(c)))
|
||||
};
|
||||
// The walk keeps to cells it hasn't been to, so a circular reference ends it.
|
||||
let mut chain = vec![(sheet, at)];
|
||||
let mut seen: HashSet<(usize, CellRef)> = HashSet::from([(sheet, at)]);
|
||||
let mut truncated = false;
|
||||
let mut last = (sheet, at);
|
||||
while let Some(next) = precedents(last).iter().find_map(|a| first_showing(&d.wb, a, error)) {
|
||||
if !seen.insert(next) {
|
||||
break;
|
||||
}
|
||||
if chain.len() > TRACE_ERROR_LINKS {
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
chain.push(next);
|
||||
last = next;
|
||||
}
|
||||
let mut arrows = Vec::new();
|
||||
// A chain cut short hasn't reached its source.
|
||||
if !truncated {
|
||||
let mut drawn = HashSet::new();
|
||||
for a in precedents(last) {
|
||||
if drawn.insert(a) {
|
||||
arrows.push(json!({"from": {"sheet": name(a.sheet), "range": a.range.a1()}, "to": cell(last), "kind": "precedent"}));
|
||||
}
|
||||
}
|
||||
}
|
||||
// From the source outwards, as the error travels.
|
||||
for pair in chain.windows(2).rev() {
|
||||
if let [to, from] = pair {
|
||||
arrows.push(json!({"from": {"sheet": name(from.0), "range": from.1.a1()}, "to": cell(*to), "kind": "error"}));
|
||||
}
|
||||
}
|
||||
let source = if truncated { Json::Null } else { cell(last) };
|
||||
Ok(json!({"sheet": name(sheet), "cell": at.a1(), "error": error.as_str(), "source": source, "arrows": arrows, "truncated": truncated}))
|
||||
}
|
||||
|
||||
/// The first cell of an area, row by row, that shows `error`: a stored cell or a value spilled
|
||||
/// into the area by a dynamic array.
|
||||
fn first_showing(wb: &Workbook, a: &gridcraft_calc::Area, error: CellError) -> Option<(usize, CellRef)> {
|
||||
let sh = wb.sheet(a.sheet)?;
|
||||
let holds = |c: &Cell| !c.value.is_empty() || c.formula.is_some();
|
||||
let stored = sh.cells.iter_range(a.range).find(|(_, c)| holds(c) && c.value.as_error() == Some(error)).map(|(at, _)| at);
|
||||
let spilled = sh
|
||||
.spill
|
||||
.range(a.range.start..)
|
||||
.take_while(|(at, _)| **at <= a.range.end)
|
||||
.find(|(at, v)| a.range.contains(**at) && v.as_error() == Some(error) && !sh.cell(**at).is_some_and(holds))
|
||||
.map(|(at, _)| *at);
|
||||
stored.into_iter().chain(spilled).min().map(|c| (a.sheet, c))
|
||||
}
|
||||
|
||||
/// Excel's Ctrl+[ / Ctrl+]: select the precedent (or dependent) cells. When they all live on
|
||||
/// another sheet, jump there. `all` walks every level (Ctrl+Shift+[ / Ctrl+Shift+]).
|
||||
fn select_trace_cells(s: &mut Session, p: &Json, precedents: bool) -> Result<Json> {
|
||||
|
||||
@@ -30,5 +30,6 @@ mod sparse_clear_invalidation;
|
||||
mod stored_list_shape;
|
||||
mod table_resize_overlap;
|
||||
mod table_total_row_last_row;
|
||||
mod trace_error;
|
||||
mod workbook_author;
|
||||
mod wrap_row_height;
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
//! Formulas › Formula Auditing › Error Checking › Trace Error: from a cell showing an error, the
|
||||
//! first reference that shows the same error, back to the cell the error starts in, with arrows
|
||||
//! along the way and from the source's own references into it. The sheet and the expected
|
||||
//! sources and arrows are the ones measured in Excel.
|
||||
|
||||
use gridcraft_engine::Session;
|
||||
use serde_json::{Value as Json, json};
|
||||
|
||||
/// The measured sheet. A4 is a typed `#N/A`; I1's own formula gives `#REF!`.
|
||||
fn measured() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
for (cell, input) in [
|
||||
("A1", "=1/0"),
|
||||
("A2", "5"),
|
||||
("B1", "=A1+A2"),
|
||||
("C1", "=B1*2"),
|
||||
("D1", "=SUM(C1,A2)"),
|
||||
("A4", "#N/A"),
|
||||
("B4", "=A4"),
|
||||
("C4", "=B4&\"x\""),
|
||||
("A6", "=SQRT(-1)"),
|
||||
("A7", "3"),
|
||||
("B6", "=A6+A7"),
|
||||
("E1", "10"),
|
||||
("E2", "=E1*2"),
|
||||
("F1", "=A1+A4"),
|
||||
("F2", "=A4+A1"),
|
||||
("G1", "=IFERROR(A1,0)"),
|
||||
("G2", "=G1+B1"),
|
||||
("H1", "0"),
|
||||
("H2", "=10/H1"),
|
||||
("H3", "=H2+E1"),
|
||||
("H4", "=H3*1"),
|
||||
("I1", "=#REF!"),
|
||||
("I2", "=I1+1"),
|
||||
("J1", "=XLOOKUP(99,A2:A3,A2:A3)"),
|
||||
("J2", "=J1"),
|
||||
("A9", "=A10"),
|
||||
("A10", "=1/0"),
|
||||
("B9", "=SUM(A9:A10)"),
|
||||
("C9", "=B9"),
|
||||
] {
|
||||
s.execute("cell.set", json!({"cell": cell, "input": input})).unwrap();
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
fn trace(s: &mut Session, cell: &str) -> Json {
|
||||
s.execute("formulas.traceError", json!({"cell": cell})).unwrap()
|
||||
}
|
||||
|
||||
/// An end of an arrow as `A1`, or `Sheet2!A1` off Sheet1.
|
||||
fn end(v: &Json) -> String {
|
||||
let at = v["range"].as_str().or(v["cell"].as_str()).unwrap();
|
||||
match v["sheet"].as_str().unwrap() {
|
||||
"Sheet1" => at.to_string(),
|
||||
sheet => format!("{sheet}!{at}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The arrows as `from → to`, with ` (precedent)` after the source's own references.
|
||||
fn arrows(r: &Json) -> Vec<String> {
|
||||
r["arrows"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|a| {
|
||||
let kind = match a["kind"].as_str().unwrap() {
|
||||
"error" => "",
|
||||
"precedent" => " (precedent)",
|
||||
k => panic!("unknown kind {k}"),
|
||||
};
|
||||
format!("{} → {}{kind}", end(&a["from"]), end(&a["to"]))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trace_error_finds_the_measured_sources_and_arrows() {
|
||||
let mut s = measured();
|
||||
for (cell, error, source, expected) in [
|
||||
("C1", "#DIV/0!", "A1", vec!["A1 → B1", "B1 → C1"]),
|
||||
("D1", "#DIV/0!", "A1", vec!["A1 → B1", "B1 → C1", "C1 → D1"]),
|
||||
("B1", "#DIV/0!", "A1", vec!["A1 → B1"]),
|
||||
("A1", "#DIV/0!", "A1", vec![]),
|
||||
("A6", "#NUM!", "A6", vec![]),
|
||||
("C4", "#N/A", "A4", vec!["A4 → B4", "B4 → C4"]),
|
||||
// Two errors: the first reference showing the cell's own error.
|
||||
("F1", "#DIV/0!", "A1", vec!["A1 → F1"]),
|
||||
("F2", "#N/A", "A4", vec!["A4 → F2"]),
|
||||
// IFERROR in G1 hides the error, so the error comes through B1.
|
||||
("G2", "#DIV/0!", "A1", vec!["A1 → B1", "B1 → G2"]),
|
||||
// H1 feeds the source H2 without an error of its own; E1 feeds H3 and has no arrow.
|
||||
("H4", "#DIV/0!", "H2", vec!["H1 → H2 (precedent)", "H2 → H3", "H3 → H4"]),
|
||||
("H3", "#DIV/0!", "H2", vec!["H1 → H2 (precedent)", "H2 → H3"]),
|
||||
// XLOOKUP's two A2:A3 arguments make one range arrow into the source.
|
||||
("J2", "#N/A", "J1", vec!["A2:A3 → J1 (precedent)", "J1 → J2"]),
|
||||
// In A9:A10 the first cell showing the error is A9, itself fed by A10.
|
||||
("C9", "#DIV/0!", "A10", vec!["A10 → A9", "A9 → B9", "B9 → C9"]),
|
||||
("I2", "#REF!", "I1", vec!["I1 → I2"]),
|
||||
] {
|
||||
let r = trace(&mut s, cell);
|
||||
assert_eq!((r["sheet"].as_str(), r["cell"].as_str()), (Some("Sheet1"), Some(cell)));
|
||||
assert_eq!(r["error"], error, "{cell}");
|
||||
assert_eq!(end(&r["source"]), source, "{cell}");
|
||||
assert_eq!(arrows(&r), expected, "{cell}");
|
||||
assert_eq!(r["truncated"], false, "{cell}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cell_without_an_error_has_no_source_and_no_arrows() {
|
||||
let mut s = measured();
|
||||
for cell in ["E2", "A2", "Z99", "G1"] {
|
||||
let r = trace(&mut s, cell);
|
||||
assert_eq!(r, json!({"sheet": "Sheet1", "cell": cell, "error": null, "source": null, "arrows": [], "truncated": false}));
|
||||
}
|
||||
// Without `cell`, the active cell.
|
||||
s.execute("selection.set", json!({"range": "C1"})).unwrap();
|
||||
let r = s.execute("formulas.traceError", json!({})).unwrap();
|
||||
assert_eq!((r["cell"].as_str(), arrows(&r).len()), (Some("C1"), 2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tracing_changes_nothing_and_twice_gives_the_same_arrows() {
|
||||
let mut s = measured();
|
||||
let (wb, undo) = (s.doc().unwrap().wb.clone(), s.doc().unwrap().undo.len());
|
||||
let first = trace(&mut s, "C1");
|
||||
assert_eq!(trace(&mut s, "C1"), first);
|
||||
let d = s.doc().unwrap();
|
||||
assert!(std::sync::Arc::ptr_eq(&d.wb, &wb), "the workbook is untouched");
|
||||
assert_eq!(d.undo.len(), undo, "no undo step");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cell_on_another_sheet_is_refused_and_bad_params_fail() {
|
||||
let mut s = measured();
|
||||
s.execute("home.insertSheet", json!({"name": "Two"})).unwrap();
|
||||
s.execute("sheet.activate", json!({"sheet": "Sheet1"})).unwrap();
|
||||
for cell in [json!("Two!A1"), json!("not a cell"), json!(5)] {
|
||||
assert!(s.execute("formulas.traceError", json!({"cell": cell})).is_err(), "{cell}");
|
||||
}
|
||||
assert!(s.execute("formulas.traceError", json!({"cell": "Sheet1!C1"})).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_error_is_followed_across_sheets() {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("home.insertSheet", json!({"name": "Data 2"})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "A1", "input": "=1/B1"})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "B1", "input": "0"})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "C1", "input": "=A1"})).unwrap();
|
||||
s.execute("sheet.activate", json!({"sheet": "Sheet1"})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "B2", "input": "='Data 2'!C1*2"})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "C2", "input": "=B2"})).unwrap();
|
||||
let r = trace(&mut s, "C2");
|
||||
assert_eq!(end(&r["source"]), "Data 2!A1");
|
||||
assert_eq!(arrows(&r), ["Data 2!B1 → Data 2!A1 (precedent)", "Data 2!A1 → Data 2!C1", "Data 2!C1 → B2", "B2 → C2"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_the_cells_own_error_is_followed() {
|
||||
let mut s = measured();
|
||||
// K1 shows #DIV/0!; A4 shows #N/A, another error, so K1 is the source and A4 only feeds it.
|
||||
s.execute("cell.set", json!({"cell": "K1", "input": "=IF(ISNA(A4),1/0,0)"})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "K2", "input": "=K1+F2"})).unwrap();
|
||||
let r = trace(&mut s, "K2");
|
||||
assert_eq!(r["error"], "#DIV/0!");
|
||||
assert_eq!(end(&r["source"]), "K1");
|
||||
assert_eq!(arrows(&r), ["A4 → K1 (precedent)", "K1 → K2"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_spilled_error_leads_to_the_references_of_its_array_formula() {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [[1, 1], [2, 0]]})).unwrap();
|
||||
// D1 spills 1 and #DIV/0! into D1:D2.
|
||||
s.execute("cell.set", json!({"cell": "D1", "input": "=A1:A2/B1:B2"})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "E1", "input": "=SUM(D1:D2)"})).unwrap();
|
||||
let r = trace(&mut s, "E1");
|
||||
assert_eq!(end(&r["source"]), "D2", "{r}");
|
||||
assert_eq!(arrows(&r), ["A1:A2 → D2 (precedent)", "B1:B2 → D2 (precedent)", "D2 → E1"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_circular_reference_ends_the_walk() {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("cell.set", json!({"cell": "A1", "input": "=B1+1"})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "B1", "input": "=A1+1"})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "C1", "input": "=C1"})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "D1", "input": "=B1"})).unwrap();
|
||||
let r = trace(&mut s, "D1");
|
||||
assert!(r["error"].is_string(), "{r}");
|
||||
assert_eq!(end(&r["source"]), "A1");
|
||||
assert_eq!(arrows(&r), ["B1 → A1 (precedent)", "A1 → B1", "B1 → D1"]);
|
||||
// A cell that reads itself is its own source.
|
||||
let r = trace(&mut s, "C1");
|
||||
assert_eq!(end(&r["source"]), "C1");
|
||||
assert_eq!(arrows(&r), ["C1 → C1 (precedent)"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_long_chain_is_walked_to_its_source_and_a_longer_one_stops_at_the_limit() {
|
||||
// On a 1 MiB stack, as on the desktop app's main thread: the walk must not recurse.
|
||||
std::thread::Builder::new()
|
||||
.stack_size(1 << 20)
|
||||
.spawn(|| {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
let rows = 10_002;
|
||||
let formulas: Vec<[String; 1]> = (1..=rows).map(|r| [if r == 1 { "=1/0".to_string() } else { format!("=A{}", r - 1) }]).collect();
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": formulas})).unwrap();
|
||||
// 10,000 links, the most Trace Error walks: it reaches A1.
|
||||
let r = trace(&mut s, "A10001");
|
||||
assert_eq!((end(&r["source"]), r["truncated"].clone()), ("A1".to_string(), json!(false)));
|
||||
let list = arrows(&r);
|
||||
assert_eq!(list.len(), 10_000);
|
||||
assert_eq!((list.first().map(String::as_str), list.last().map(String::as_str)), (Some("A1 → A2"), Some("A10000 → A10001")));
|
||||
// One more and the walk stops short of the source.
|
||||
let r = trace(&mut s, "A10002");
|
||||
assert_eq!((r["source"].clone(), r["truncated"].clone()), (json!(null), json!(true)));
|
||||
let list = arrows(&r);
|
||||
assert_eq!(list.len(), 10_000);
|
||||
assert_eq!(list.first().map(String::as_str), Some("A2 → A3"), "no arrows into a cell that isn't the source");
|
||||
})
|
||||
.unwrap()
|
||||
.join()
|
||||
.unwrap();
|
||||
}
|
||||
@@ -47,7 +47,11 @@ impl SheetApp {
|
||||
return r;
|
||||
}
|
||||
let before = self.session.active_window();
|
||||
let zoom_from = matches!(id, "view.zoom" | "view.zoom100" | "view.zoomToSelection").then(|| crate::grid::Trace::key(self));
|
||||
let r = self.session.run(id, params);
|
||||
if let Some(from) = zoom_from {
|
||||
crate::grid::Trace::keep_through_zoom(self, from);
|
||||
}
|
||||
if id.starts_with("file.") && self.session.active_window() != before {
|
||||
// File › New or Open from the Backstage view: the window it was in goes back to its sheet.
|
||||
self.backstage = None;
|
||||
|
||||
+62
-11
@@ -153,23 +153,74 @@ pub struct FillTag {
|
||||
pub undo_len: usize,
|
||||
}
|
||||
|
||||
/// Trace Precedents / Trace Dependents arrows: they stay on the cell they were traced from, on its
|
||||
/// sheet, while the selection moves. They go away when anything changes the workbook
|
||||
/// (`revision`): Excel removes them on edits to the traced formula and on inserted, deleted or
|
||||
/// moved cells, this also on a new value in a precedent.
|
||||
/// Trace Precedents, Trace Dependents and Trace Error arrows: they stay on the cells they were
|
||||
/// traced from while the selection moves. They go away when anything but a zoom changes the
|
||||
/// workbook (`revision`): Excel removes them on edits to the traced formula and on inserted,
|
||||
/// deleted or moved cells, this also on a new value in a precedent.
|
||||
pub struct Trace {
|
||||
pub uid: u64,
|
||||
pub revision: u64,
|
||||
pub sheet: usize,
|
||||
pub cell: CellRef,
|
||||
/// The command's result: `{"sheet", "range"}` precedents or `{"sheet", "cell"}` dependents.
|
||||
pub items: serde_json::Value,
|
||||
pub arrows: Vec<TraceArrow>,
|
||||
}
|
||||
|
||||
/// One trace arrow, from a cell or range (boxed) to a cell, each on its sheet. An arrow is drawn
|
||||
/// on a sheet that holds both its ends.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct TraceArrow {
|
||||
pub from: (usize, RangeRef),
|
||||
pub to: (usize, RangeRef),
|
||||
/// A link between two cells showing the error Trace Error follows, drawn in the error colour.
|
||||
pub error: bool,
|
||||
}
|
||||
|
||||
impl Trace {
|
||||
/// A trace of the active cell, just run.
|
||||
pub fn new(app: &SheetApp, items: serde_json::Value) -> Option<Trace> {
|
||||
app.session.active().map(|d| Trace { uid: d.uid, revision: d.revision, sheet: d.wb.active_sheet(), cell: d.selection.active, items })
|
||||
/// The arrows of a trace of the active cell, from the command's result: `{"sheet", "range"}`
|
||||
/// precedents (Trace Precedents), `{"sheet", "cell"}` dependents (Trace Dependents), or
|
||||
/// `{"arrows": [{"from", "to", "kind"}]}` (Trace Error). Ends on a sheet the workbook no
|
||||
/// longer has are left out.
|
||||
pub fn new(app: &SheetApp, result: serde_json::Value) -> Option<Trace> {
|
||||
let d = app.session.active()?;
|
||||
let traced = (d.wb.active_sheet(), RangeRef::new(d.selection.active, d.selection.active));
|
||||
let end = |v: &serde_json::Value| {
|
||||
let sheet = match v["sheet"].as_str() {
|
||||
Some(name) => d.wb.sheet_index(name)?,
|
||||
None => traced.0,
|
||||
};
|
||||
Some((sheet, RangeRef::parse(v["range"].as_str().or(v["cell"].as_str())?)?))
|
||||
};
|
||||
let arrows = match result.get("arrows").and_then(|a| a.as_array()) {
|
||||
Some(list) => {
|
||||
list.iter().filter_map(|a| Some(TraceArrow { from: end(&a["from"])?, to: end(&a["to"])?, error: a["kind"] == "error" })).collect()
|
||||
}
|
||||
None => result
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|it| {
|
||||
let (from, to) = if it.get("range").is_some() { (end(it)?, traced) } else { (traced, end(it)?) };
|
||||
Some(TraceArrow { from, to, error: false })
|
||||
})
|
||||
.collect(),
|
||||
};
|
||||
Some(Trace { uid: d.uid, revision: d.revision, arrows })
|
||||
}
|
||||
|
||||
/// The active document's uid and revision, to hand [`Trace::keep_through_zoom`] after a zoom.
|
||||
pub fn key(app: &SheetApp) -> Option<(u64, u64)> {
|
||||
app.session.active().map(|d| (d.uid, d.revision))
|
||||
}
|
||||
|
||||
/// Zooming changes the workbook (its revision) but moves no cell: arrows that were shown
|
||||
/// before a zoom stay after it, as in Excel (#3097). `before` is [`Trace::key`] from then.
|
||||
pub fn keep_through_zoom(app: &mut SheetApp, before: Option<(u64, u64)>) {
|
||||
let now = Trace::key(app);
|
||||
if let Some(t) = app.grid.trace.as_mut()
|
||||
&& before == Some((t.uid, t.revision))
|
||||
&& let Some((uid, revision)) = now
|
||||
&& uid == t.uid
|
||||
{
|
||||
t.revision = revision;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+172
-26
@@ -1,4 +1,3 @@
|
||||
#[cfg(test)]
|
||||
use super::Trace;
|
||||
use super::cursor::update_cursor;
|
||||
use super::editor::{in_cell_editor, list_picker, validation_arrow};
|
||||
@@ -358,7 +357,9 @@ fn update_zoom_glide(app: &mut SheetApp, ui: &egui::Ui, place: (u64, usize), zoo
|
||||
let glides = app.ui.animations && ui.ctx().global_style().animation_time > 0.0 && !app.shots.in_flight();
|
||||
let share = egui::emath::exponential_smooth_factor(0.9, 0.1, ui.input(|i| i.stable_dt).min(0.1));
|
||||
let z = if glides { zoom_glide_step(shown, to, share) } else { to };
|
||||
let from = Trace::key(app);
|
||||
let _ = app.session.run("view.zoom", json!({"percent": z}));
|
||||
Trace::keep_through_zoom(app, from);
|
||||
zooming.glide = (z != to).then_some((to, z));
|
||||
ui.ctx().request_repaint();
|
||||
}
|
||||
@@ -725,32 +726,33 @@ pub(crate) fn paint_overlays(input: OverlayInput<'_>) {
|
||||
if let Some(o) = &app.grid.macro_overlay {
|
||||
crate::macro_editor::paint_overlay(p, geo, sh, o);
|
||||
}
|
||||
// Trace precedents / dependents arrows.
|
||||
// Trace Precedents, Trace Dependents and Trace Error arrows.
|
||||
if let Some(trace) = &app.grid.trace
|
||||
&& app.session.active().is_some_and(|d| d.uid == trace.uid && d.revision == trace.revision)
|
||||
&& trace.sheet == si
|
||||
&& let Some(items) = trace.items.as_array()
|
||||
{
|
||||
let blue = Color32::from_rgb(0x1F, 0x5F, 0xC9);
|
||||
let active = geo.cell_rect(sh, trace.cell).center();
|
||||
for it in items {
|
||||
if it["sheet"].as_str().is_some_and(|s| !s.eq_ignore_ascii_case(&sh.name)) {
|
||||
continue;
|
||||
}
|
||||
let target = it["range"].as_str().or(it["cell"].as_str()).and_then(RangeRef::parse);
|
||||
let Some(t) = target else { continue };
|
||||
let tr = geo.range_rect(sh, t);
|
||||
if !t.is_single() {
|
||||
for pane in geo.range_panes(t) {
|
||||
p.with_clip_rect(pane.intersect(p.clip_rect())).rect_stroke(tr, 0.0, Stroke::new(1.5, blue), StrokeKind::Inside);
|
||||
let t = Tokens::get(p.ctx());
|
||||
let clip = p.clip_rect();
|
||||
for a in trace.arrows.iter().filter(|a| a.from.0 == si && a.to.0 == si) {
|
||||
let colour = if a.error { t.sheet_trace_error } else { t.sheet_trace_arrow };
|
||||
let (fr, tr) = (geo.range_rect(sh, a.from.1), geo.range_rect(sh, a.to.1));
|
||||
for (range, rect) in [(a.from.1, fr), (a.to.1, tr)] {
|
||||
if !range.is_single() {
|
||||
for pane in geo.range_panes(range) {
|
||||
p.with_clip_rect(pane.intersect(clip)).rect_stroke(rect, 0.0, Stroke::new(1.5, colour), StrokeKind::Inside);
|
||||
}
|
||||
}
|
||||
}
|
||||
let (from, to) = if it.get("range").is_some() { (tr.center(), active) } else { (active, tr.center()) };
|
||||
p.line_segment([from, to], Stroke::new(1.5, blue));
|
||||
p.circle_filled(from, 3.0, blue);
|
||||
let (from, to) = (fr.center(), tr.center());
|
||||
// An arrow wholly out of view costs nothing (a long Trace Error chain has thousands),
|
||||
// and one from a cell to itself has no direction.
|
||||
if !Rect::from_two_pos(from, to).expand(9.0).intersects(clip) || from.distance(to) < 1.0 {
|
||||
continue;
|
||||
}
|
||||
p.line_segment([from, to], Stroke::new(1.5, colour));
|
||||
p.circle_filled(from, 3.0, colour);
|
||||
let d = (to - from).normalized();
|
||||
let n = vec2(-d.y, d.x);
|
||||
p.add(egui::Shape::convex_polygon(vec![to, to - d * 9.0 + n * 4.0, to - d * 9.0 - n * 4.0], blue, Stroke::NONE));
|
||||
p.add(egui::Shape::convex_polygon(vec![to, to - d * 9.0 + n * 4.0, to - d * 9.0 - n * 4.0], colour, Stroke::NONE));
|
||||
}
|
||||
}
|
||||
let _ = (wb, si);
|
||||
@@ -840,24 +842,25 @@ mod trace_arrow_tests {
|
||||
app.grid.trace = Trace::new(app, r);
|
||||
}
|
||||
|
||||
/// The arrows `paint_overlays` draws on the active sheet, as (tail, head) points.
|
||||
fn arrows(app: &mut SheetApp) -> (Geo, Vec<(Pos2, Pos2)>) {
|
||||
/// The lines `paint_overlays` draws on the active sheet scrolled by `scroll`, as (tail, head)
|
||||
/// points in the colour given.
|
||||
fn lines_in(app: &mut SheetApp, colour: Color32, scroll: egui::Vec2) -> (Geo, Vec<(Pos2, Pos2)>) {
|
||||
let d = app.session.active().unwrap();
|
||||
let (wb, si, sel) = (d.wb.clone(), d.wb.active_sheet(), d.selection.clone());
|
||||
let sh = wb.sheet(si).unwrap();
|
||||
let geo = Geo::new(sh, Rect::from_min_size(pos2(0.0, 0.0), vec2(1200.0, 800.0)), vec2(0.0, 0.0));
|
||||
let screen = Rect::from_min_size(pos2(0.0, 0.0), vec2(1200.0, 800.0));
|
||||
let geo = Geo::new(sh, screen, scroll);
|
||||
let ctx = egui::Context::default();
|
||||
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| {
|
||||
let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), ..Default::default() }, |ui| {
|
||||
let p = ui.painter().clone();
|
||||
paint_overlays(OverlayInput { app, ui, painter: &p, geo: &geo, workbook: &wb, sheet_index: si, sheet: sh, selection: &sel });
|
||||
});
|
||||
// No renderer consumes the font atlas upload here.
|
||||
out.textures_delta.clear();
|
||||
let blue = Color32::from_rgb(0x1F, 0x5F, 0xC9);
|
||||
let mut lines = Vec::new();
|
||||
for s in out.shapes {
|
||||
if let egui::Shape::LineSegment { points, stroke } = s.shape
|
||||
&& stroke.color == blue
|
||||
&& stroke.color == colour
|
||||
{
|
||||
lines.push((points[0], points[1]));
|
||||
}
|
||||
@@ -865,6 +868,32 @@ mod trace_arrow_tests {
|
||||
(geo, lines)
|
||||
}
|
||||
|
||||
/// The precedent and dependent arrows `paint_overlays` draws on the active sheet.
|
||||
fn arrows(app: &mut SheetApp) -> (Geo, Vec<(Pos2, Pos2)>) {
|
||||
lines_in(app, Tokens::light().sheet_trace_arrow, vec2(0.0, 0.0))
|
||||
}
|
||||
|
||||
/// The arrows Trace Error draws between cells showing the error.
|
||||
fn error_arrows(app: &mut SheetApp) -> (Geo, Vec<(Pos2, Pos2)>) {
|
||||
lines_in(app, Tokens::light().sheet_trace_error, vec2(0.0, 0.0))
|
||||
}
|
||||
|
||||
/// Cell centres for an arrow from `from` to `to`.
|
||||
fn link(app: &SheetApp, geo: &Geo, from: &str, to: &str) -> (Pos2, Pos2) {
|
||||
(center(app, geo, from), center(app, geo, to))
|
||||
}
|
||||
|
||||
/// The H column of the sheet measured in Excel, H4 selected: Trace Error on H4 leads to H2,
|
||||
/// whose own reference H1 has no error.
|
||||
fn h_column() -> SheetApp {
|
||||
let mut app = app();
|
||||
for (c, v) in [("E1", "10"), ("H1", "0"), ("H2", "=10/H1"), ("H3", "=H2+E1"), ("H4", "=H3*1")] {
|
||||
app.run("cell.set", json!({"cell": c, "input": v})).unwrap();
|
||||
}
|
||||
select(&mut app, "H4");
|
||||
app
|
||||
}
|
||||
|
||||
fn center(app: &SheetApp, geo: &Geo, cell: &str) -> Pos2 {
|
||||
let d = app.session.active().unwrap();
|
||||
let sh = d.wb.sheet(d.wb.active_sheet()).unwrap();
|
||||
@@ -945,4 +974,121 @@ mod trace_arrow_tests {
|
||||
assert_eq!(revisions[0], revisions[1]);
|
||||
assert!(arrows(&mut app).1.is_empty(), "Book1's arrows are not drawn on Book2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn precedent_arrows_stay_through_a_zoom() {
|
||||
let mut app = app();
|
||||
app.run("home.insertSheet", json!({"name": "Two"})).unwrap();
|
||||
app.run("sheet.activate", json!({"sheet": "Sheet1"})).unwrap();
|
||||
app.run("cell.set", json!({"cell": "B2", "input": "1"})).unwrap();
|
||||
app.run("cell.set", json!({"cell": "F7", "input": "=B2"})).unwrap();
|
||||
select(&mut app, "F7");
|
||||
trace(&mut app, "formulas.tracePrecedents");
|
||||
app.run("view.zoom", json!({"percent": 75})).unwrap();
|
||||
let (geo, lines) = arrows(&mut app);
|
||||
assert_eq!(lines, [(center(&app, &geo, "B2"), center(&app, &geo, "F7"))], "#3097");
|
||||
// Zooming another sheet leaves them too, and they are back on returning to their sheet.
|
||||
app.run("sheet.activate", json!({"sheet": "Two"})).unwrap();
|
||||
app.run("view.zoom", json!({"percent": 200})).unwrap();
|
||||
assert!(arrows(&mut app).1.is_empty());
|
||||
app.run("sheet.activate", json!({"sheet": "Sheet1"})).unwrap();
|
||||
let (geo, lines) = arrows(&mut app);
|
||||
assert_eq!(lines, [(center(&app, &geo, "B2"), center(&app, &geo, "F7"))]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trace_error_draws_the_links_to_the_source_in_the_error_colour() {
|
||||
let mut app = h_column();
|
||||
trace(&mut app, "formulas.traceError");
|
||||
select(&mut app, "A1");
|
||||
let (geo, red) = error_arrows(&mut app);
|
||||
assert_eq!(red, [link(&app, &geo, "H2", "H3"), link(&app, &geo, "H3", "H4")], "H2 → H3 → H4, from the source outwards");
|
||||
let (geo, blue) = arrows(&mut app);
|
||||
assert_eq!(blue, [link(&app, &geo, "H1", "H2")], "the source's own reference, like a precedent arrow; none from E1");
|
||||
assert_ne!(Tokens::light().sheet_trace_error, Tokens::light().sheet_trace_arrow);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trace_error_boxes_a_range_feeding_the_source() {
|
||||
let mut app = app();
|
||||
for (c, v) in [("A2", "5"), ("J1", "=XLOOKUP(99,A2:A3,A2:A3)"), ("J2", "=J1")] {
|
||||
app.run("cell.set", json!({"cell": c, "input": v})).unwrap();
|
||||
}
|
||||
select(&mut app, "J2");
|
||||
trace(&mut app, "formulas.traceError");
|
||||
let (geo, red) = error_arrows(&mut app);
|
||||
assert_eq!(red, [link(&app, &geo, "J1", "J2")]);
|
||||
let d = app.session.active().unwrap();
|
||||
let range = geo.range_rect(d.wb.sheet(0).unwrap(), RangeRef::parse("A2:A3").unwrap());
|
||||
let (_, blue) = arrows(&mut app);
|
||||
assert_eq!(blue, [(range.center(), center(&app, &geo, "J1"))], "one arrow from the middle of A2:A3, not two");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_arrows_and_edits_clear_trace_error_arrows() {
|
||||
let mut app = h_column();
|
||||
trace(&mut app, "formulas.traceError");
|
||||
assert_eq!(error_arrows(&mut app).1.len(), 2);
|
||||
// What the ribbon's Remove Arrows button does.
|
||||
app.grid.trace = None;
|
||||
assert!(error_arrows(&mut app).1.is_empty() && arrows(&mut app).1.is_empty());
|
||||
trace(&mut app, "formulas.traceError");
|
||||
app.run("cell.set", json!({"cell": "H1", "input": "2"})).unwrap();
|
||||
assert!(error_arrows(&mut app).1.is_empty(), "a change to the workbook removes them");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trace_error_arrows_follow_the_zoom() {
|
||||
let mut app = h_column();
|
||||
trace(&mut app, "formulas.traceError");
|
||||
let (_, before) = error_arrows(&mut app);
|
||||
for id in ["view.zoom", "view.zoom100", "view.zoomToSelection"] {
|
||||
app.run(id, json!({"percent": 150, "viewWidth": 1200.0, "viewHeight": 800.0})).unwrap();
|
||||
}
|
||||
app.run("view.zoom", json!({"percent": 150})).unwrap();
|
||||
let (geo, red) = error_arrows(&mut app);
|
||||
assert_eq!(red, [link(&app, &geo, "H2", "H3"), link(&app, &geo, "H3", "H4")], "still there after zooming: {red:?}");
|
||||
let length = |(a, b): (Pos2, Pos2)| a.distance(b);
|
||||
assert!((length(red[0]) / length(before[0]) - 1.5).abs() < 0.05, "1.5 times as long at 150%: {red:?} {before:?}");
|
||||
assert_eq!(arrows(&mut app).1.len(), 1);
|
||||
// Undoing the zoom is an edit like any other.
|
||||
app.run("edit.undo", json!({})).unwrap();
|
||||
assert!(error_arrows(&mut app).1.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trace_error_arrows_stay_on_the_sheets_they_link() {
|
||||
let mut app = app();
|
||||
app.run("home.insertSheet", json!({"name": "Two"})).unwrap();
|
||||
for (c, v) in [("A1", "=1/0"), ("A2", "=A1")] {
|
||||
app.run("cell.set", json!({"cell": c, "input": v})).unwrap();
|
||||
}
|
||||
app.run("sheet.activate", json!({"sheet": "Sheet1"})).unwrap();
|
||||
for (c, v) in [("B1", "=Two!A2"), ("C1", "=B1")] {
|
||||
app.run("cell.set", json!({"cell": c, "input": v})).unwrap();
|
||||
}
|
||||
select(&mut app, "C1");
|
||||
trace(&mut app, "formulas.traceError");
|
||||
let (geo, red) = error_arrows(&mut app);
|
||||
assert_eq!(red, [link(&app, &geo, "B1", "C1")], "Two!A2 → B1 crosses sheets and isn't drawn");
|
||||
app.run("sheet.activate", json!({"sheet": "Two"})).unwrap();
|
||||
let (geo, red) = error_arrows(&mut app);
|
||||
assert_eq!(red, [link(&app, &geo, "A1", "A2")], "the part of the chain on Two shows there");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_long_chain_paints_only_the_arrows_in_view() {
|
||||
let mut app = app();
|
||||
let formulas: Vec<[String; 1]> = (1..=3000).map(|r| [if r == 1 { "=1/0".to_string() } else { format!("=A{}", r - 1) }]).collect();
|
||||
app.run("range.setValues", json!({"range": "A1", "values": formulas})).unwrap();
|
||||
select(&mut app, "A3000");
|
||||
trace(&mut app, "formulas.traceError");
|
||||
assert_eq!(app.grid.trace.as_ref().map(|t| t.arrows.len()), Some(2999));
|
||||
let colour = Tokens::light().sheet_trace_error;
|
||||
let (geo, top) = lines_in(&mut app, colour, vec2(0.0, 0.0));
|
||||
assert!((30..=60).contains(&top.len()), "the rows on screen: {}", top.len());
|
||||
assert_eq!(top.first().copied(), Some(link(&app, &geo, "A1", "A2")));
|
||||
let (_, further) = lines_in(&mut app, colour, vec2(0.0, 20.0 * 1500.0));
|
||||
assert!((30..=60).contains(&further.len()), "{}", further.len());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,6 +143,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Remove Arrows" => "Odebrat šipky",
|
||||
"Show Formulas" => "Zobrazit vzorce",
|
||||
"Error Checking" => "Kontrola chyb",
|
||||
"Trace Error" => "Sledovat chybu",
|
||||
"Circular References" => "Cyklické odkazy",
|
||||
"Evaluate Formula" => "Vyhodnotit vzorec",
|
||||
"Watch Window" => "Okno sledování",
|
||||
|
||||
@@ -157,6 +157,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Remove Arrows" => "Pfeile entfernen",
|
||||
"Show Formulas" => "Formeln anzeigen",
|
||||
"Error Checking" => "Fehlerüberprüfung",
|
||||
"Trace Error" => "Spur zum Fehler",
|
||||
"Circular References" => "Zirkelbezüge",
|
||||
"Evaluate Formula" => "Formel auswerten",
|
||||
"Watch Window" => "Überwachungsfenster",
|
||||
|
||||
@@ -142,6 +142,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Remove Arrows" => "Κατάργηση βελών",
|
||||
"Show Formulas" => "Εμφάνιση τύπων",
|
||||
"Error Checking" => "Έλεγχος σφαλμάτων",
|
||||
"Trace Error" => "Εντοπισμός σφάλματος",
|
||||
"Circular References" => "Κυκλικές αναφορές",
|
||||
"Evaluate Formula" => "Αξιολόγηση τύπου",
|
||||
"Watch Window" => "Παράθυρο παρακολούθησης",
|
||||
|
||||
@@ -146,6 +146,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Remove Arrows" => "Quitar flechas",
|
||||
"Show Formulas" => "Mostrar fórmulas",
|
||||
"Error Checking" => "Comprobación de errores",
|
||||
"Trace Error" => "Rastrear error",
|
||||
"Circular References" => "Referencias circulares",
|
||||
"Evaluate Formula" => "Evaluar fórmula",
|
||||
"Watch Window" => "Ventana Inspección",
|
||||
|
||||
@@ -157,6 +157,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Remove Arrows" => "Supprimer les flèches",
|
||||
"Show Formulas" => "Afficher les formules",
|
||||
"Error Checking" => "Vérification des erreurs",
|
||||
"Trace Error" => "Repérer une erreur",
|
||||
"Circular References" => "Références circulaires",
|
||||
"Evaluate Formula" => "Évaluation de formule",
|
||||
"Watch Window" => "Fenêtre Espion",
|
||||
|
||||
@@ -150,6 +150,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Remove Arrows" => "Rimuovi frecce",
|
||||
"Show Formulas" => "Mostra formule",
|
||||
"Error Checking" => "Controllo errori",
|
||||
"Trace Error" => "Individua errore",
|
||||
"Circular References" => "Riferimenti circolari",
|
||||
"Evaluate Formula" => "Valuta formula",
|
||||
"Watch Window" => "Finestra controlli",
|
||||
|
||||
@@ -154,6 +154,7 @@ pub(super) const CORE: &[(&str, &str)] = &[
|
||||
("Remove Arrows", "矢印の解除"),
|
||||
("Show Formulas", "数式の表示"),
|
||||
("Error Checking", "エラーチェック"),
|
||||
("Trace Error", "エラーのトレース"),
|
||||
("Circular References", "循環参照"),
|
||||
("Evaluate Formula", "数式の検証"),
|
||||
("Watch Window", "ウォッチ ウィンドウ"),
|
||||
|
||||
@@ -152,6 +152,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Remove Arrows" => "화살표 제거",
|
||||
"Show Formulas" => "수식 표시",
|
||||
"Error Checking" => "오류 검사",
|
||||
"Trace Error" => "오류 추적",
|
||||
"Circular References" => "순환 참조",
|
||||
"Evaluate Formula" => "수식 계산",
|
||||
"Watch Window" => "감시 창",
|
||||
|
||||
@@ -151,6 +151,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Remove Arrows" => "Pijlen verwijderen",
|
||||
"Show Formulas" => "Formules weergeven",
|
||||
"Error Checking" => "Foutopsporing",
|
||||
"Trace Error" => "Fout traceren",
|
||||
"Circular References" => "Kringverwijzingen",
|
||||
"Evaluate Formula" => "Formule evalueren",
|
||||
"Watch Window" => "Bewakingsvenster",
|
||||
|
||||
@@ -146,6 +146,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Remove Arrows" => "Usuń strzałki",
|
||||
"Show Formulas" => "Pokaż formuły",
|
||||
"Error Checking" => "Sprawdzanie błędów",
|
||||
"Trace Error" => "Śledź błąd",
|
||||
"Circular References" => "Odwołania cykliczne",
|
||||
"Evaluate Formula" => "Szacuj formułę",
|
||||
"Watch Window" => "Okno czujki",
|
||||
|
||||
@@ -149,6 +149,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Remove Arrows" => "Remover Setas",
|
||||
"Show Formulas" => "Exibir Fórmulas",
|
||||
"Error Checking" => "Verificação de Erros",
|
||||
"Trace Error" => "Rastrear Erro",
|
||||
"Circular References" => "Referências Circulares",
|
||||
"Evaluate Formula" => "Avaliar Fórmula",
|
||||
"Watch Window" => "Janela de Inspeção",
|
||||
|
||||
@@ -153,6 +153,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Remove Arrows" => "Eliminare săgeți",
|
||||
"Show Formulas" => "Afișare formule",
|
||||
"Error Checking" => "Verificare erori",
|
||||
"Trace Error" => "Trasare eroare",
|
||||
"Circular References" => "Referințe circulare",
|
||||
"Evaluate Formula" => "Evaluare formulă",
|
||||
"Watch Window" => "Fereastră de urmărire",
|
||||
|
||||
@@ -152,6 +152,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Remove Arrows" => "Убрать стрелки",
|
||||
"Show Formulas" => "Показать формулы",
|
||||
"Error Checking" => "Проверка ошибок",
|
||||
"Trace Error" => "Источник ошибки",
|
||||
"Circular References" => "Циклические ссылки",
|
||||
"Evaluate Formula" => "Вычислить формулу",
|
||||
"Watch Window" => "Окно контрольных значений",
|
||||
|
||||
@@ -147,6 +147,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Remove Arrows" => "Ukloni strelice",
|
||||
"Show Formulas" => "Prikaži formule",
|
||||
"Error Checking" => "Provera grešaka",
|
||||
"Trace Error" => "Prati grešku",
|
||||
"Circular References" => "Kružne reference",
|
||||
"Evaluate Formula" => "Proceni formulu",
|
||||
"Watch Window" => "Prozor za praćenje",
|
||||
|
||||
@@ -145,6 +145,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Remove Arrows" => "Okları Kaldır",
|
||||
"Show Formulas" => "Formülleri Göster",
|
||||
"Error Checking" => "Hata Denetimi",
|
||||
"Trace Error" => "Hatayı İzle",
|
||||
"Circular References" => "Döngüsel Başvurular",
|
||||
"Evaluate Formula" => "Formülü Değerlendir",
|
||||
"Watch Window" => "Gözcü Penceresi",
|
||||
|
||||
@@ -148,6 +148,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Remove Arrows" => "Прибрати стрілки",
|
||||
"Show Formulas" => "Показати формули",
|
||||
"Error Checking" => "Перевірка помилок",
|
||||
"Trace Error" => "Джерело помилки",
|
||||
"Circular References" => "Циклічні посилання",
|
||||
"Evaluate Formula" => "Обчислити формулу",
|
||||
"Watch Window" => "Окно контрольних значень",
|
||||
|
||||
@@ -154,6 +154,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Remove Arrows" => "Bỏ mũi tên",
|
||||
"Show Formulas" => "Hiện công thức",
|
||||
"Error Checking" => "Kiểm tra lỗi",
|
||||
"Trace Error" => "Truy tìm lỗi",
|
||||
"Circular References" => "Tham chiếu vòng",
|
||||
"Evaluate Formula" => "Đánh giá công thức",
|
||||
"Watch Window" => "Cửa sổ theo dõi",
|
||||
|
||||
@@ -152,6 +152,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Remove Arrows" => "删除箭头",
|
||||
"Show Formulas" => "显示公式",
|
||||
"Error Checking" => "错误检查",
|
||||
"Trace Error" => "追踪错误",
|
||||
"Circular References" => "循环引用",
|
||||
"Evaluate Formula" => "公式求值",
|
||||
"Watch Window" => "监视窗口",
|
||||
|
||||
@@ -180,6 +180,14 @@ pub(super) fn formulas(app: &mut SheetApp, ui: &mut Ui) {
|
||||
if ui.button(format!("{}…", lang.tr("Error Checking"))).clicked() {
|
||||
app.open_dialog("errorChecking", json!({}));
|
||||
}
|
||||
// A cell without an error, or one its error starts in without references, has no
|
||||
// arrows: the ones shown stay.
|
||||
if ui.button(lang.tr("Trace Error")).clicked()
|
||||
&& let Ok(r) = app.run("formulas.traceError", json!({}))
|
||||
&& r["arrows"].as_array().is_some_and(|a| !a.is_empty())
|
||||
{
|
||||
app.grid.trace = crate::grid::Trace::new(app, r);
|
||||
}
|
||||
let circular = circular_references(app);
|
||||
ui.add_enabled_ui(!circular.is_empty(), |ui| {
|
||||
ui.menu_button(lang.tr("Circular References"), |ui| {
|
||||
|
||||
@@ -387,7 +387,9 @@ pub fn status_bar(app: &mut SheetApp, ui: &mut Ui) {
|
||||
let slider = ui.add(egui::Slider::new(&mut z, 10.0..=400.0).show_value(false).logarithmic(true));
|
||||
ui.ctx().accesskit_node_builder(slider.id, |node| node.set_label(app.ui.language.tr("Zoom")));
|
||||
if slider.changed() {
|
||||
let from = crate::grid::Trace::key(app);
|
||||
let _ = app.session.run("view.zoom", json!({"percent": z.round()}));
|
||||
crate::grid::Trace::keep_through_zoom(app, from);
|
||||
}
|
||||
if crate::widgets::icon_button(ui, Icon::Minus, t.text_dim, "Zoom Out", vec2(18.0, 18.0)).clicked() {
|
||||
let z = ((zoom as f32 / 10.0).ceil() * 10.0 - 10.0).max(10.0);
|
||||
|
||||
@@ -114,6 +114,10 @@ pub struct Tokens {
|
||||
pub sheet_macro_footprint: Color32,
|
||||
pub sheet_macro_conflict: Color32,
|
||||
pub sheet_macro_target: Color32,
|
||||
/// Trace arrows on the sheet: precedents and dependents, and the links Trace Error follows
|
||||
/// between cells showing an error. Sheet colours.
|
||||
pub sheet_trace_arrow: Color32,
|
||||
pub sheet_trace_error: Color32,
|
||||
}
|
||||
|
||||
/// The eight category colours, in the order of [`Tokens::cat`].
|
||||
@@ -146,6 +150,9 @@ const SHEET_MACRO_INPUT: Color32 = rgb(0x2E6FD8);
|
||||
const SHEET_MACRO_FOOTPRINT: Color32 = rgb(0x23803D);
|
||||
const SHEET_MACRO_CONFLICT: Color32 = rgb(0xC42B1C);
|
||||
const SHEET_MACRO_TARGET: Color32 = rgb(0x6B3FC0);
|
||||
/// Trace arrows on the sheet, the same in both themes.
|
||||
const SHEET_TRACE_ARROW: Color32 = rgb(0x1F5FC9);
|
||||
const SHEET_TRACE_ERROR: Color32 = rgb(0xD13438);
|
||||
|
||||
/// Code colours for one background; plain text uses the surface's own text colour.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
@@ -244,6 +251,8 @@ impl Tokens {
|
||||
sheet_macro_footprint: SHEET_MACRO_FOOTPRINT,
|
||||
sheet_macro_conflict: SHEET_MACRO_CONFLICT,
|
||||
sheet_macro_target: SHEET_MACRO_TARGET,
|
||||
sheet_trace_arrow: SHEET_TRACE_ARROW,
|
||||
sheet_trace_error: SHEET_TRACE_ERROR,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -395,6 +404,8 @@ mod contrast_tests {
|
||||
] {
|
||||
add(&format!("{name} outline on sheet"), c, t.grid_bg, GLYPH);
|
||||
}
|
||||
add("trace arrow on sheet", t.sheet_trace_arrow, t.grid_bg, GLYPH);
|
||||
add("trace error arrow on sheet", t.sheet_trace_error, t.grid_bg, GLYPH);
|
||||
// The macro step editor: breakpoint dots in the gutter, error markers, the current step.
|
||||
add("breakpoint on window", t.breakpoint, t.window, GLYPH);
|
||||
add("breakpoint on current step", t.breakpoint, t.accent_soft, GLYPH);
|
||||
@@ -439,6 +450,7 @@ mod contrast_tests {
|
||||
(l.sheet_macro_input, l.sheet_macro_footprint, l.sheet_macro_conflict, l.sheet_macro_target),
|
||||
(d.sheet_macro_input, d.sheet_macro_footprint, d.sheet_macro_conflict, d.sheet_macro_target)
|
||||
);
|
||||
assert_eq!((l.sheet_trace_arrow, l.sheet_trace_error), (d.sheet_trace_arrow, d.sheet_trace_error));
|
||||
for c in Cat::ALL {
|
||||
assert_ne!(l.cat(c), d.cat(c), "{c:?} has a dark-mode value of its own");
|
||||
}
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
//! Formulas › Error Checking is a split button, as in Excel: the button opens the Error Checking
|
||||
//! dialog, and its arrow opens a menu whose Circular References submenu lists a cell on each
|
||||
//! circular reference and selects the one picked, on its own sheet.
|
||||
//! dialog, and its arrow opens a menu whose Trace Error item draws arrows back to the cell an error
|
||||
//! starts in, and whose Circular References submenu lists a cell on each circular reference and
|
||||
//! selects the one picked, on its own sheet.
|
||||
|
||||
use egui::{Event, Shape, pos2, vec2};
|
||||
use egui_kittest::kittest::{NodeT as _, Queryable as _};
|
||||
use gridcraft_engine::Session;
|
||||
use gridcraft_ui_egui::SheetApp;
|
||||
use gridcraft_ui_egui::i18n::Language;
|
||||
use gridcraft_ui_egui::theme::Tokens;
|
||||
use serde_json::json;
|
||||
|
||||
type Harness = egui_kittest::Harness<'static, SheetApp>;
|
||||
@@ -160,3 +163,123 @@ fn circular_references_scrolls_a_list_taller_than_the_window() {
|
||||
click(&mut h, last.center());
|
||||
assert_eq!(h.state().session.active().unwrap().selection.active.a1(), "A100");
|
||||
}
|
||||
|
||||
/// The Formulas tab in `language` over part of the sheet measured in Excel, C1 selected: C1 shows
|
||||
/// A1's `#DIV/0!` through B1, and H4 shows H2's through H3.
|
||||
fn trace_harness(language: Language) -> Harness {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
for (cell, input) in [
|
||||
("A1", "=1/0"),
|
||||
("A2", "5"),
|
||||
("B1", "=A1+A2"),
|
||||
("C1", "=B1*2"),
|
||||
("E1", "10"),
|
||||
("H1", "0"),
|
||||
("H2", "=10/H1"),
|
||||
("H3", "=H2+E1"),
|
||||
("H4", "=H3*1"),
|
||||
] {
|
||||
s.execute("cell.set", json!({"cell": cell, "input": input})).unwrap();
|
||||
}
|
||||
s.execute("selection.set", json!({"range": "C1"})).unwrap();
|
||||
let mut app = crate::common::app(s);
|
||||
app.ui.language = language;
|
||||
app.ui.ribbon_tab = "Formulas".into();
|
||||
let mut h = crate::common::builder().with_size(vec2(1400.0, 800.0)).build_ui_state(
|
||||
|ui, app: &mut SheetApp| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
},
|
||||
app,
|
||||
);
|
||||
h.run_steps(4);
|
||||
h
|
||||
}
|
||||
|
||||
/// Opens the Error Checking menu, whose button reads `text`.
|
||||
fn open_menu_labelled(h: &mut Harness, text: &str) {
|
||||
let (_, right, y) = h
|
||||
.output()
|
||||
.shapes
|
||||
.iter()
|
||||
.find_map(|s| match &s.shape {
|
||||
Shape::Text(t) if t.galley.job.text == text => Some((t.pos.x, t.pos.x + t.galley.size().x, t.pos.y + t.galley.size().y / 2.0)),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or_else(|| panic!("the Formulas tab shows {text}"));
|
||||
click(h, pos2(right + 13.0, y));
|
||||
}
|
||||
|
||||
/// The trace arrows the app keeps, as (from, to, drawn in the error colour).
|
||||
fn traced(h: &Harness) -> Vec<(String, String, bool)> {
|
||||
let trace = h.state().grid.trace.as_ref();
|
||||
trace.map(|t| t.arrows.iter().map(|a| (a.from.1.a1(), a.to.1.a1(), a.error)).collect()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Line segments painted in `colour`.
|
||||
fn painted(h: &Harness, colour: egui::Color32) -> usize {
|
||||
h.output().shapes.iter().filter(|s| matches!(&s.shape, Shape::LineSegment { stroke, .. } if stroke.color == colour)).count()
|
||||
}
|
||||
|
||||
fn link(from: &str, to: &str, error: bool) -> (String, String, bool) {
|
||||
(from.to_string(), to.to_string(), error)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trace_error_draws_the_arrows_to_the_source_and_remove_arrows_clears_them() {
|
||||
let mut h = trace_harness(Language::En);
|
||||
open_menu_labelled(&mut h, "Error Checking");
|
||||
// As in Excel: Error Checking…, Trace Error, Circular References.
|
||||
let y = |h: &Harness, l: &str| h.get_by_label(l).rect().center().y;
|
||||
assert!(y(&h, "Error Checking…") < y(&h, "Trace Error") && y(&h, "Trace Error") < y(&h, CIRCULAR));
|
||||
assert!(!h.get_by_label("Trace Error").accesskit_node().is_disabled());
|
||||
let undo = h.state().session.active().unwrap().undo.len();
|
||||
h.get_by_label("Trace Error").click();
|
||||
h.run_steps(4);
|
||||
assert_eq!(traced(&h), [link("A1", "B1", true), link("B1", "C1", true)]);
|
||||
let red = Tokens::light().sheet_trace_error;
|
||||
assert_eq!(painted(&h, red), 2, "two red arrows on the sheet");
|
||||
assert!(h.state().dialog.is_none() && h.state().message.is_none(), "no dialog, no message");
|
||||
assert_eq!(h.state().session.active().unwrap().undo.len(), undo, "no undo step");
|
||||
// H4: the source H2's own reference H1 gets a blue arrow; the selection moving leaves them.
|
||||
h.state_mut().run("selection.set", json!({"range": "H4"})).unwrap();
|
||||
open_menu_labelled(&mut h, "Error Checking");
|
||||
h.get_by_label("Trace Error").click();
|
||||
h.run_steps(4);
|
||||
h.state_mut().run("selection.set", json!({"range": "A20"})).unwrap();
|
||||
h.run_steps(4);
|
||||
assert_eq!(traced(&h), [link("H1", "H2", false), link("H2", "H3", true), link("H3", "H4", true)]);
|
||||
assert_eq!((painted(&h, red), painted(&h, Tokens::light().sheet_trace_arrow)), (2, 1));
|
||||
h.get_by_label("Remove Arrows").click();
|
||||
h.run_steps(4);
|
||||
assert!(h.state().grid.trace.is_none());
|
||||
assert_eq!(painted(&h, red), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trace_error_on_a_cell_without_arrows_keeps_the_ones_shown() {
|
||||
let mut h = trace_harness(Language::En);
|
||||
open_menu_labelled(&mut h, "Error Checking");
|
||||
h.get_by_label("Trace Error").click();
|
||||
h.run_steps(4);
|
||||
// E1 has no error, and A1's error starts there without references: nothing to draw.
|
||||
for cell in ["E1", "A1"] {
|
||||
h.state_mut().run("selection.set", json!({"range": cell})).unwrap();
|
||||
open_menu_labelled(&mut h, "Error Checking");
|
||||
h.get_by_label("Trace Error").click();
|
||||
h.run_steps(4);
|
||||
assert_eq!(traced(&h), [link("A1", "B1", true), link("B1", "C1", true)], "{cell}");
|
||||
assert!(h.state().message.is_none(), "{cell}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_german_menu_says_spur_zum_fehler() {
|
||||
let mut h = trace_harness(Language::De);
|
||||
open_menu_labelled(&mut h, Language::De.tr("Error Checking"));
|
||||
assert_eq!(Language::De.tr("Trace Error"), "Spur zum Fehler");
|
||||
h.get_by_label("Spur zum Fehler").click();
|
||||
h.run_steps(4);
|
||||
assert_eq!(traced(&h), [link("A1", "B1", true), link("B1", "C1", true)]);
|
||||
}
|
||||
|
||||
@@ -276,3 +276,26 @@ fn page_and_point_units_scroll_the_grid_by_their_converted_distance() {
|
||||
let point = h.state().view().scroll.y;
|
||||
assert!(point > 0.0 && point < page, "40 points scroll less than a page: point {point}, page {page}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Zooming moves no cell: trace arrows shown before a Ctrl+wheel zoom are still shown after its
|
||||
/// glide (#3097), while an edit still removes them.
|
||||
fn trace_arrows_stay_through_a_wheel_zoom() {
|
||||
let mut h = animated();
|
||||
h.state_mut().run("cell.set", json!({"cell": "B2", "input": "1"})).unwrap();
|
||||
h.state_mut().run("cell.set", json!({"cell": "F7", "input": "=B2"})).unwrap();
|
||||
h.state_mut().run("selection.set", json!({"range": "F7"})).unwrap();
|
||||
let r = h.state_mut().run("formulas.tracePrecedents", json!({})).unwrap();
|
||||
let app = h.state_mut();
|
||||
app.grid.trace = gridcraft_ui_egui::grid::Trace::new(app, r);
|
||||
let current = |h: &Harness| {
|
||||
let d = h.state().session.active().unwrap();
|
||||
h.state().grid.trace.as_ref().is_some_and(|t| (t.uid, t.revision) == (d.uid, d.revision))
|
||||
};
|
||||
assert!(current(&h));
|
||||
let seen = turn_and_watch(&mut h, 1.0, 40);
|
||||
assert!(seen.windows(2).filter(|w| w[0] != w[1]).count() > 1, "the zoom glides over several frames: {seen:?}");
|
||||
assert!(current(&h), "the arrows stay at {}%", zoom(&h));
|
||||
h.state_mut().run("cell.set", json!({"cell": "B2", "input": "2"})).unwrap();
|
||||
assert!(!current(&h), "an edit removes them");
|
||||
}
|
||||
|
||||
+4
-2
@@ -1,6 +1,6 @@
|
||||
# UI parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-01 · **Change:** minor (Page Setup sets the page order, Down, then over or Over, then down, which printing, the print preview, Page Break Preview and Page Layout view follow; earlier: Chart Design and Format Chart Area: data tables, legend keys and borders; earlier: Formulas › Function Library as labelled buttons with category menus; Recently Used and the Insert Function categories; Esc leaves a File-page text field before closing the view; checkbox cells print as boxes; Ctrl+arrow and Ctrl+Shift+arrow while pointing a formula reference; editing a hyperlink loads its existing fields; AutoSum over a selected row puts its total to the right; ribbon catalog count 276 / 292) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-01 · **Change:** minor (Formulas › Error Checking › Trace Error; trace arrows stay through a zoom (#3097); earlier: Page Setup sets the page order, Down, then over or Over, then down, which printing, the print preview, Page Break Preview and Page Layout view follow; earlier: Formulas › Function Library as labelled buttons with category menus; Recently Used and the Insert Function categories; Esc leaves a File-page text field before closing the view; earlier: checkbox cells print as boxes; Ctrl+arrow and Ctrl+Shift+arrow while pointing a formula reference; editing a hyperlink loads its existing fields; AutoSum over a selected row puts its total to the right; ribbon catalog count 276 / 292) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
|
||||
|
||||
Interaction, controls and feel, against Excel. Part of [target-app-parity.md](target-app-parity.md).
|
||||
**UI/UX fidelity: ~55% ready, 50–80 h** (estimated). Ribbon command coverage is measured
|
||||
@@ -25,7 +25,7 @@ Sources: `crates/ui-egui/src` (ribbon, grid, editor, formula bar, dialogs, panes
|
||||
| Formula bar with Name Box, expand, function hints | yes | yes; grows with its content from one to five lines (Alt+Enter, wrapped text, or viewing a multi-line cell), then scrolls with the caret in view, the Name Box and buttons staying on the first line; the chevron pins it at five; the in-cell editor shows every line | done; no drag-to-resize handle |
|
||||
| Sheet tabs: rename, reorder, colour, hide, scroll | yes | yes | Ctrl-drag (Cmd or Option on macOS) drops a copy (#971); active tab revealed on navigation and resize (#681); manual wheel/trackpad browsing preserved; rename focus bug on macOS (#145) |
|
||||
| Formula bar with Name Box, expand, function hints | yes | yes | done; dynamic-array spill children show the source formula muted and read-only, using current spill ownership |
|
||||
| Formulas › Error Checking arrow: Circular References | yes | yes: the button opens Error Checking and its arrow opens a menu with Error Checking… and Circular References, which lists a cell on each circular reference as `Sheet!A1`, up to 100, and selects the one picked, on its own sheet (unavailable when there are none) | done; the Error Checking… item and the limit of 100 are GridCraft's own |
|
||||
| Formulas › Error Checking arrow: Trace Error, Circular References | yes | yes: the button opens Error Checking and its arrow opens a menu with Error Checking…, Trace Error and Circular References. Trace Error (`formulas.traceError`, 11-01) follows the active cell's error back through the first reference showing the same error (in a range, its first cell showing it) to the cell it starts in, and draws red arrows along the way and blue ones from that cell's own references, as measured in Excel; on a cell without an error it draws nothing and the arrows shown stay. Circular References lists a cell on each circular reference as `Sheet!A1`, up to 100, and selects the one picked, on its own sheet (unavailable when there are none) | done; the Error Checking… item and the limit of 100 are GridCraft's own; a new trace replaces the arrows shown, where Excel adds to them, and there is no Remove Precedent Arrows |
|
||||
| Sheet tabs: rename, reorder, colour, hide, scroll | yes | yes | active tab revealed on navigation and resize (#681); manual wheel/trackpad browsing preserved; rename focus bug on macOS (#145) |
|
||||
| Sheet tabs: rename, reorder, colour, hide, scroll | yes | yes | active tab revealed on navigation and resize (#681); New sheet (+) right after the last tab, the strip capped at 60% of the bar (#943); manual wheel/trackpad browsing preserved; next/previous skips Hidden and VeryHidden sheets and retains the current sheet and selection when no visible destination exists; rename focus bug on macOS (#145) |
|
||||
| Status bar: Sum/Count/Average and more, view buttons, zoom | yes | yes; a right-click on the status bar opens a menu that turns the statistics on and off (Average, Count, Numerical Count, Minimum, Maximum, Sum; `view.statusBar`) | done for the statistics; the other status bar options are not customizable |
|
||||
@@ -198,6 +198,8 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12.
|
||||
|---|---|---|
|
||||
| 2026-11-01 | minor | Chart Design › Add Chart Element offers Data Table with/no legend keys and None; Format Chart Area has a Data Table section with visibility, legend keys, horizontal/vertical borders and outline. Both use engine commands. |
|
||||
| 2026-11-01 | minor | Page Setup has a Page order choice (Down, then over, the default, or Over, then down) with a small picture of the order, also when Print Titles opens it on its Sheet tab; OK sets it with the margins in one undo step. Printing, PDF export, the print preview, Page Break Preview's page numbers and Page Layout view's headers and footers follow the order. The dialog is in German now too |
|
||||
| 2026-11-01 | minor | Formulas › Error Checking › Trace Error (`formulas.traceError`): red arrows from the cell an error starts in to the selected cell, through the first reference showing the same error at each step, and blue arrows from that cell's own references, as measured in Excel; the command returns the source and the arrows |
|
||||
| 2026-11-01 | minor | Trace arrows stay when the sheet is zoomed (View › Zoom, the status bar, Ctrl+wheel), as in Excel; every zoom used to remove them (#3097) |
|
||||
| 2026-10-31 | minor | Esc in a File-page text field leaves the field and cancels editing; the next Esc returns to the sheet (#3035) |
|
||||
| 2026-10-31 | minor | Recently Used lists the functions most recently entered in a formula from any source, kept with the preferences; the Insert Function dialog is titled as in Excel, its category list (Most Recently Used, All, each category) works, and its category column follows the widest name shown |
|
||||
| 2026-10-31 | minor | Formulas › Function Library: Insert Function, the AutoSum split button and three columns of labelled small buttons with category colours; category menus end with Insert Function…; More Functions cascades; Home › Editing shares the AutoSum menu; MR key tip for Recently Used |
|
||||
|
||||
Reference in New Issue
Block a user