Go To: lists the named ranges of every sheet and the references entered, and goes to several at once

The Go To dialog lists the named ranges of the workbook and of every
sheet, a name scoped to another sheet as Sheet2!Name, without hidden
names, constants or formulas. After them it lists the references
entered in the dialog, newest first, and a double-click on an entry
goes there.

edit.goTo takes several names or references separated by commas
(Price, Type or B14:C22,F19:G30) and selects them all, follows a name
that refers to several areas, and goes to Sheet2!Name on Sheet2. With
record: true it keeps a reference that holds no name among the recent
ones, and with list: true it returns the names and the recent
references.
This commit is contained in:
dotCooCoo
2026-10-24 01:22:19 -07:00
parent 72c1d57d1e
commit 983d3d7e6a
7 changed files with 377 additions and 22 deletions
+1
View File
@@ -135,6 +135,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
| Date | What landed |
|---|---|
| 2026-10-24 | Go To lists the named ranges of every sheet (`Sheet2!Name` for a name scoped to another sheet; hidden names, constants and formulas left out) and the references entered before, a double-click goes to an entry, and the Reference box takes several names or references separated by commas (`Price, Type`, `B14:C22,F19:G30`) |
| 2026-10-23 | Accounting negatives in text: `$(1,234)`, `$ (5)` and `(5)%` read as negative numbers when typed, coerced by arithmetic or passed to VALUE, like Excel 365; `(-5)`, `((5))`, `$5%` and dates or times in parentheses stay text |
| 2026-10-23 | Calc: `INDEX(A1:C3,2)` (one index on a reference of several rows and columns) is `#REF!` as in Excel, instead of the whole row; `INDEX(A1:C3,2,)` still gives the row |
| 2026-10-22 | Formula hardening: digits too many for a finite number are text, so `"1 <400 digits>/<400 digits>"` no longer coerces to NaN; a NaN column index in VLOOKUP, HLOOKUP and the D-functions is `#VALUE!` instead of an index underflow; REGEXREPLACE stops building a result as soon as it can only be too long (a worst case went from 1 GB to 7 MB) (#2163, #2162) |
+255 -15
View File
@@ -36,7 +36,7 @@ pub fn specs() -> Vec<CommandSpec> {
cmd!(noundo "selection.currentRegion", "Select Current Region", [], Some("Ctrl+Shift+8"), "{}", has_doc, select_region),
cmd!(noundo "selection.row", "Select Entire Row", [], Some("Shift+Space"), "{}", has_doc, select_row),
cmd!(noundo "selection.column", "Select Entire Column", [], Some("Ctrl+Space"), "{}", has_doc, select_col),
cmd!(noundo "edit.goTo", "Go To…", ["Home", "Editing", "Find & Select"], Some("Ctrl+G"), "{reference: \"B5\" | \"Sheet2!A1:C3\" | name}", has_doc, go_to),
cmd!(noundo "edit.goTo", "Go To…", ["Home", "Editing", "Find & Select"], Some("Ctrl+G"), "{reference: \"B5\" | \"Sheet2!A1:C3\" | name | \"Sheet2!Name\" | \"B14:C22,F19:G30\" | \"Price, Type\", record?: bool} (record: keep a reference without names among the recent ones) or {list: true} → {names, recent}", has_doc, go_to),
cmd!(noundo "edit.goToSpecial", "Go To Special…", ["Home", "Editing", "Find & Select"], None, "{kind: blanks|constants|formulas|comments|lastCell|currentRegion|visible|conditionalFormats|dataValidation|errors|numbers|text}", has_doc, go_to_special),
cmd!(noundo "edit.undo", "Undo", ["Edit"], Some("Cmd+Z"), "{steps?: 1}", can_undo, undo),
cmd!(noundo "edit.redo", "Redo", ["Edit"], Some("Cmd+Y"), "{steps?: 1}", can_redo, redo),
@@ -577,26 +577,153 @@ fn select_col(s: &mut Session, _: &Json) -> Result<Json> {
Ok(json!({"selection": d.selection.a1()}))
}
/// How many references the Go To dialog keeps for going back to.
const GO_TO_RECENT: usize = 10;
/// The most areas one Go To selects.
const GO_TO_MAX_AREAS: usize = 2000;
/// How deep a name that refers to other names is followed.
const GO_TO_NAME_DEPTH: usize = 8;
pub(super) fn go_to(s: &mut Session, p: &Json) -> Result<Json> {
if bool_param(p, "list") == Some(true) {
let d = s.doc()?;
return Ok(json!({"names": go_to_names(&d.wb, d.wb.active_sheet), "recent": d.go_to_recent}));
}
let Some(reference) = str_param(p, "reference").or_else(|| str_param(p, "range")) else { return Err(bad("edit.goTo", "missing `reference`")) };
// A defined name or table?
let d = s.doc()?;
let resolved = if RangeRef::parse(split_sheet(reference).1).is_none() {
let sheet = d.wb.active_sheet;
if let Some(n) = d.wb.name(reference, sheet) {
n.formula.clone()
} else if let Some((si, ti)) = d.wb.table(reference) {
// As in Excel, a table's name stands for its data, without the header and totals rows.
let t = d.wb.sheet(si).and_then(|sh| sh.tables.get(ti)).map(|t| t.data_range().unwrap_or(t.range).a1()).unwrap_or_default();
format!("{}!{}", gridcraft_core::quote_sheet(&d.wb.sheet(si).map(|s| s.name.clone()).unwrap_or_default()), t)
} else {
let mut target: Option<usize> = None;
let mut areas: Vec<RangeRef> = Vec::new();
let mut named = false;
for part in reference_parts(reference) {
let Some((sheet, found, by_name)) = go_to_target(&d.wb, d.wb.active_sheet, part, 0) else {
return Err(EngineError::Other("Reference isn't valid.".into()));
};
// One selection holds the areas of one sheet.
if target.is_some_and(|t| t != sheet) {
return Err(EngineError::Other("Reference isn't valid.".into()));
}
} else {
reference.to_string()
target = Some(sheet);
named |= by_name;
areas.extend(found);
if areas.len() > GO_TO_MAX_AREAS {
return Err(EngineError::Other(format!("Go To selects at most {GO_TO_MAX_AREAS} areas.")));
}
}
let Some(sheet) = target else { return Err(EngineError::Other("Reference isn't valid.".into())) };
let range = areas.iter().map(RangeRef::a1).collect::<Vec<_>>().join(",");
let out = selection_set(s, &json!({"sheet": sheet, "range": range}))?;
if bool_param(p, "record") == Some(true) && !named {
let d = s.doc_mut()?;
let prefix = gridcraft_core::quote_sheet(&d.wb.sheet(sheet).map(|sh| sh.name.clone()).unwrap_or_default());
let entry = areas.iter().map(|r| format!("{prefix}!{}", r.a1())).collect::<Vec<_>>().join(",");
d.go_to_recent.retain(|r| !r.eq_ignore_ascii_case(&entry));
d.go_to_recent.insert(0, entry);
d.go_to_recent.truncate(GO_TO_RECENT);
}
Ok(out)
}
/// The parts of a Go To reference, split at the commas outside quoted sheet names and
/// parentheses: `Price, Type` or `B14:C22,F19:G30`. The parts are read one at a time, so a caller
/// that stops at an invalid part or at `GO_TO_MAX_AREAS` does not split the rest of the text.
fn reference_parts(text: &str) -> impl Iterator<Item = &str> {
let (mut depth, mut quoted, mut start, mut done) = (0usize, false, 0, false);
let mut chars = text.char_indices();
std::iter::from_fn(move || {
if done {
return None;
}
for (i, c) in chars.by_ref() {
match c {
'\'' => quoted = !quoted,
'(' if !quoted => depth += 1,
')' if !quoted => depth = depth.saturating_sub(1),
',' if !quoted && depth == 0 => {
let part = text.get(start..i).unwrap_or("").trim();
start = i + 1;
return Some(part);
}
_ => {}
}
}
done = true;
Some(text.get(start..).unwrap_or("").trim())
})
}
/// Where one part of a Go To reference points, seen from sheet `sheet`: the sheet, its areas,
/// and whether a defined name or a table gave them. `Sheet2!Name` is the name scoped to Sheet2.
/// A name counts when what it refers to is a reference; a constant or a formula is not one.
fn go_to_target(wb: &gridcraft_model::Workbook, sheet: usize, part: &str, depth: usize) -> Option<(usize, Vec<RangeRef>, bool)> {
if part.is_empty() || depth > GO_TO_NAME_DEPTH {
return None;
}
let (prefix, body) = split_sheet(part);
let on = match &prefix {
Some(n) => wb.sheet_index(n)?,
None => sheet,
};
let resolved = resolved.replace('$', "");
selection_set(s, &json!({"range": resolved}))
if let Some(r) = RangeRef::parse(&body.replace('$', "")) {
return Some((on, vec![r], false));
}
let name = match prefix {
Some(_) => wb.names.iter().find(|n| n.scope == Some(on) && n.name.eq_ignore_ascii_case(body)),
None => wb.name(body, sheet),
};
if let Some(n) = name {
return name_areas(wb, n, sheet, depth).map(|(t, areas)| (t, areas, true));
}
if prefix.is_none()
&& let Some((si, ti)) = wb.table(body)
{
// As in Excel, a table's name stands for its data, without the header and totals rows.
let t = wb.sheet(si)?.tables.get(ti)?;
return Some((si, vec![t.data_range().unwrap_or(t.range)], true));
}
None
}
/// The sheet and areas defined name `n` refers to, seen from sheet `sheet`, when that is a
/// reference.
fn name_areas(wb: &gridcraft_model::Workbook, n: &gridcraft_model::DefinedName, sheet: usize, depth: usize) -> Option<(usize, Vec<RangeRef>)> {
let from = n.scope.unwrap_or(sheet);
let formula = n.formula.trim().trim_start_matches('=');
let mut target: Option<usize> = None;
let mut areas = Vec::new();
for p in reference_parts(formula) {
let (s, found, _) = go_to_target(wb, from, p, depth + 1)?;
if target.is_some_and(|t| t != s) {
return None;
}
target = Some(s);
areas.extend(found);
if areas.len() > GO_TO_MAX_AREAS {
return None;
}
}
target.map(|t| (t, areas))
}
/// The names the Go To dialog lists, seen from sheet `sheet`: the named ranges of the workbook and
/// of every sheet, in alphabetical order. A name scoped to another sheet is written `Sheet2!Name`,
/// and the active sheet's own name hides the workbook name it shares. Hidden names and names that
/// do not refer to a range are left out.
pub fn go_to_names(wb: &gridcraft_model::Workbook, sheet: usize) -> Vec<String> {
let own: std::collections::HashSet<String> = wb.names.iter().filter(|n| n.scope == Some(sheet)).map(|n| n.name.to_ascii_lowercase()).collect();
let mut out: Vec<String> = wb
.names
.iter()
.filter(|n| !n.hidden && name_areas(wb, n, sheet, 0).is_some())
.filter_map(|n| match n.scope {
None if own.contains(&n.name.to_ascii_lowercase()) => None,
None => Some(n.name.clone()),
Some(s) if s == sheet => Some(n.name.clone()),
Some(s) => Some(format!("{}!{}", gridcraft_core::quote_sheet(&wb.sheet(s)?.name), n.name)),
})
.collect();
out.sort_by_key(|n| n.to_lowercase());
out.dedup_by(|a, b| a.eq_ignore_ascii_case(b));
out
}
fn go_to_special(s: &mut Session, p: &Json) -> Result<Json> {
@@ -1786,6 +1913,119 @@ fn begin_edit(s: &mut Session, _: &Json) -> Result<Json> {
mod tests {
use super::*;
/// Sheet1 and Sheet2 with a workbook range name, a name scoped to each sheet, a constant, a
/// formula and a hidden name.
fn go_to_workbook() -> Session {
let mut s = Session::new();
s.new_workbook();
s.execute("home.insertSheet", json!({"name": "Sheet2"})).unwrap();
s.execute("selection.set", json!({"sheet": 0})).unwrap();
let define = |s: &mut Session, name: &str, refers: &str, scope: &str| {
s.execute("formulas.defineName", json!({"name": name, "refersTo": refers, "scope": scope})).unwrap();
};
define(&mut s, "Price", "=Sheet1!$B$2:$B$5", "Workbook");
define(&mut s, "type", "=Sheet1!$D$2:$D$5", "Workbook");
define(&mut s, "Areas", "=Sheet1!$A$1,Sheet1!$C$3:$C$4", "Workbook");
define(&mut s, "Local", "=Sheet2!$C$3", "Sheet2");
define(&mut s, "Own", "=Sheet1!$E$1", "Sheet1");
define(&mut s, "Rate", "=0.07", "Workbook");
define(&mut s, "Total", "=SUM(Sheet1!$B$2:$B$5)", "Workbook");
let wb = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb);
wb.names.push(gridcraft_model::DefinedName {
name: "Secret".into(),
scope: None,
formula: "Sheet1!$Z$9".into(),
comment: String::new(),
hidden: true,
});
s
}
/// "The Go to popup window shows named ranges on every worksheet in your workbook." Names
/// that are not ranges, and hidden names, are not listed.
#[test]
fn go_to_lists_the_named_ranges_of_every_sheet() {
let mut s = go_to_workbook();
let list = s.execute("edit.goTo", json!({"list": true})).unwrap();
assert_eq!(list["names"], json!(["Areas", "Own", "Price", "Sheet2!Local", "type"]), "{list}");
assert_eq!(list["recent"], json!([]), "{list}");
// Seen from Sheet2, its own name is bare and Sheet1's is qualified.
s.execute("selection.set", json!({"sheet": 1})).unwrap();
let list = s.execute("edit.goTo", json!({"list": true})).unwrap();
assert_eq!(list["names"], json!(["Areas", "Local", "Price", "Sheet1!Own", "type"]), "{list}");
}
#[test]
fn go_to_a_name_scoped_to_another_sheet_goes_to_that_sheet() {
let mut s = go_to_workbook();
let out = s.execute("edit.goTo", json!({"reference": "Sheet2!Local"})).unwrap();
assert_eq!(out["selection"], "C3", "{out}");
assert_eq!(s.doc().unwrap().wb.active_sheet, 1);
// A name of several areas selects all of them.
let out = s.execute("edit.goTo", json!({"reference": "Areas"})).unwrap();
assert_eq!(out["selection"], "A1,C3:C4", "{out}");
assert_eq!(s.doc().unwrap().wb.active_sheet, 0);
}
/// "You can enter multiple named ranges or cell references in the Reference box. Separating
/// each with a comma ... Excel will highlight all the ranges."
#[test]
fn go_to_selects_every_reference_and_name_in_a_list() {
let mut s = go_to_workbook();
let out = s.execute("edit.goTo", json!({"reference": "B14:C22,F19:G30,H21:H29"})).unwrap();
assert_eq!(out["selection"], "B14:C22,F19:G30,H21:H29", "{out}");
let out = s.execute("edit.goTo", json!({"reference": "Price, Type"})).unwrap();
assert_eq!(out["selection"], "B2:B5,D2:D5", "{out}");
let out = s.execute("edit.goTo", json!({"reference": "Sheet2!$A$1,Sheet2!B2"})).unwrap();
assert_eq!(out["selection"], "A1,B2", "{out}");
assert_eq!(s.doc().unwrap().wb.active_sheet, 1);
// Areas on two sheets, and a part that is not a reference, leave the selection alone.
for bad in ["A1,Sheet1!B2", "A1,Rate", "A1,", "Total"] {
assert!(s.execute("edit.goTo", json!({"reference": bad})).is_err(), "{bad}");
assert_eq!(s.doc().unwrap().selection.a1(), "A1,B2", "{bad}");
}
}
#[test]
fn go_to_reads_the_parts_of_a_long_reference_one_at_a_time() {
let mut parts = reference_parts("A1, 'Q1, Q2'!B2,SUM(C3,D4)");
assert_eq!(parts.next(), Some("A1"));
assert_eq!(parts.next(), Some("'Q1, Q2'!B2"));
assert_eq!(parts.next(), Some("SUM(C3,D4)"));
assert_eq!(parts.next(), None);
assert_eq!(reference_parts("").collect::<Vec<_>>(), vec![""]);
let mut s = go_to_workbook();
let err = s.execute("edit.goTo", json!({"reference": "A1,".repeat(GO_TO_MAX_AREAS + 1)})).unwrap_err();
assert!(err.to_string().contains("at most"), "{err}");
let bad = format!("Total{}", ",".repeat(1_000_000));
assert!(s.execute("edit.goTo", json!({"reference": bad})).is_err());
}
/// "The Go to box keeps track of ranges as you enter them, and you can return to any of them."
#[test]
fn go_to_keeps_the_references_entered_newest_first() {
let mut s = go_to_workbook();
s.execute("edit.goTo", json!({"reference": "B14:C22,F19:G30", "record": true})).unwrap();
s.execute("edit.goTo", json!({"reference": "Sheet2!A5", "record": true})).unwrap();
// Names are listed already; a call without `record` (the Name Box) is not kept.
s.execute("edit.goTo", json!({"reference": "Price", "record": true})).unwrap();
s.execute("edit.goTo", json!({"reference": "Z9"})).unwrap();
s.execute("edit.goTo", json!({"reference": "b14:c22,f19:g30", "record": true})).unwrap();
let list = s.execute("edit.goTo", json!({"list": true})).unwrap();
assert_eq!(list["recent"], json!(["Sheet1!B14:C22,Sheet1!F19:G30", "Sheet2!A5"]), "{list}");
// Going back from Sheet2 to a recent reference returns to its sheet.
s.execute("selection.set", json!({"sheet": 1})).unwrap();
let out = s.execute("edit.goTo", json!({"reference": "Sheet1!B14:C22,Sheet1!F19:G30", "record": true})).unwrap();
assert_eq!(out["selection"], "B14:C22,F19:G30", "{out}");
assert_eq!(s.doc().unwrap().wb.active_sheet, 0);
for i in 0..GO_TO_RECENT + 2 {
s.execute("edit.goTo", json!({"reference": format!("A{}", i + 1), "record": true})).unwrap();
}
let list = s.execute("edit.goTo", json!({"list": true})).unwrap();
assert_eq!(list["recent"].as_array().map(Vec::len), Some(GO_TO_RECENT), "{list}");
assert_eq!(list["recent"][0], format!("Sheet1!A{}", GO_TO_RECENT + 2), "{list}");
}
#[test]
fn cell_set_takes_numbers_for_input_and_value() {
// #697: a JSON number for `input` used to clear the cell instead of entering it.
+4
View File
@@ -199,6 +199,9 @@ pub struct DocState {
/// workbook is opened from a file, carried by content pasted from such a workbook, and never
/// changed by a trust decision.
pub trust: Trust,
/// The references `edit.goTo` with `record: true` went to in this document, newest first,
/// without names, each area written with its sheet (`Sheet1!B14:C22,Sheet1!F19:G30`).
pub go_to_recent: Vec<String>,
}
static NEXT_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
@@ -285,6 +288,7 @@ impl DocState {
content_kind: None,
scripts_trusted,
trust,
go_to_recent: vec![],
}
}
pub fn is_dirty(&self) -> bool {
+22 -6
View File
@@ -2320,12 +2320,28 @@ fn zoom(ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool)
}
fn go_to(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool) {
let names: Vec<String> = app.session.active().map(|doc| doc.wb.names.iter().map(|n| n.name.clone()).collect()).unwrap_or_default();
for n in names {
if ui.selectable_label(false, &n).clicked() {
d.values.insert("reference".into(), json!(n));
// The named ranges of every sheet, then the references gone to before, newest first.
let entries: Vec<String> = app
.session
.active()
.map(|doc| {
let mut v = gridcraft_engine::cmd::edit::go_to_names(&doc.wb, doc.wb.active_sheet);
v.extend(doc.go_to_recent.iter().cloned());
v
})
.unwrap_or_default();
let current = d.values.get("reference").and_then(Json::as_str).unwrap_or("").to_string();
egui::ScrollArea::vertical().max_height(160.0).show(ui, |ui| {
for n in entries {
let resp = ui.selectable_label(current == n, &n);
if resp.clicked() || resp.double_clicked() {
d.values.insert("reference".into(), json!(n));
}
if resp.double_clicked() {
*confirm = true;
}
}
}
});
let mut r = d.values.get("reference").and_then(Json::as_str).unwrap_or("").to_string();
ui.horizontal(|ui| {
ui.label("Reference:");
@@ -2339,7 +2355,7 @@ fn go_to(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bo
let mut open = true;
ok_cancel(ui, confirm, &mut open);
if *confirm {
app.run_or_alert("edit.goTo", json!({"reference": r}));
app.run_or_alert("edit.goTo", json!({"reference": r, "record": true}));
app.grid.ensure_visible = true;
}
if !open {
+89
View File
@@ -0,0 +1,89 @@
//! The Go To dialog lists the named ranges of every sheet and the references entered before; a
//! double-click on an entry goes there.
use egui::{Event, Key, Modifiers, PointerButton, Pos2, vec2};
use egui_kittest::kittest::Queryable;
use gridcraft_engine::Session;
use gridcraft_ui_egui::SheetApp;
use gridcraft_ui_egui::control::{Outcome, handle};
use serde_json::{Value as Json, json};
type Harness = egui_kittest::Harness<'static, SheetApp>;
fn harness() -> Harness {
let mut session = Session::new();
session.new_workbook();
session.execute("home.insertSheet", json!({"name": "Sheet2"})).unwrap();
session.execute("selection.set", json!({"sheet": 0})).unwrap();
for (name, refers, scope) in [("Price", "=Sheet1!$B$2:$B$5", "Workbook"), ("Local", "=Sheet2!$C$3", "Sheet2"), ("Rate", "=0.07", "Workbook")] {
session.execute("formulas.defineName", json!({"name": name, "refersTo": refers, "scope": scope})).unwrap();
}
let mut h = egui_kittest::Harness::builder().with_step_dt(1.0 / 60.0).with_size(vec2(1200.0, 800.0)).build_ui_state(
|ui, app: &mut SheetApp| {
app.logic(ui.ctx());
app.ui(ui);
},
SheetApp::new(session, Default::default()),
);
h.run_steps(4);
h
}
fn control(h: &mut Harness, method: &str, params: Json) -> Json {
let ctx = h.ctx.clone();
let out = match handle(h.state_mut(), &ctx, method, &params) {
Outcome::Done(v) => v,
Outcome::Screenshot { .. } => json!(null),
};
h.run_steps(2);
out
}
fn button(h: &mut Harness, pos: Pos2, pressed: bool) {
h.input_mut()
.events
.extend([Event::PointerMoved(pos), Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }]);
h.step();
}
fn double_click(h: &mut Harness, label: &str) {
let pos = h.get_by_label(label).rect().center();
button(h, pos, true);
button(h, pos, false);
button(h, pos, true);
button(h, pos, false);
h.run_steps(2);
}
fn place(h: &Harness) -> (usize, String) {
let d = h.state().session.active().unwrap();
(d.wb.active_sheet, d.selection.a1())
}
#[test]
fn double_clicking_a_name_scoped_to_another_sheet_goes_there() {
let mut h = harness();
control(&mut h, "ui.dialog", json!({"name": "goTo"}));
assert!(h.query_by_label("Price").is_some(), "a workbook range name is listed");
assert!(h.query_by_label("Rate").is_none(), "a constant is not a named range");
double_click(&mut h, "Sheet2!Local");
assert!(h.state().dialog.is_none(), "the dialog closed");
assert_eq!(place(&h), (1, "C3".to_string()));
}
#[test]
fn a_reference_entered_is_listed_and_a_double_click_returns_to_it() {
let mut h = harness();
control(&mut h, "ui.dialog", json!({"name": "goTo"}));
control(&mut h, "ui.dialog.set", json!({"field": "reference", "value": "B14:C22,F19:G30"}));
h.key_press(Key::Enter);
h.run_steps(2);
assert!(h.state().dialog.is_none(), "Enter went");
assert_eq!(place(&h), (0, "B14:C22,F19:G30".to_string()));
control(&mut h, "selection.set", json!({"sheet": 1}));
control(&mut h, "ui.dialog", json!({"name": "goTo"}));
double_click(&mut h, "Sheet1!B14:C22,Sheet1!F19:G30");
assert!(h.state().dialog.is_none(), "the dialog closed");
assert_eq!(place(&h), (0, "B14:C22,F19:G30".to_string()));
}
+1
View File
@@ -38,6 +38,7 @@ mod formula_locale;
mod formula_point_headers;
mod formula_popup;
mod freeze_handles;
mod go_to_dialog;
mod header_autofit;
mod header_column_drag;
mod host_chrome;
+5 -1
View File
@@ -1,6 +1,6 @@
# UI parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-23 · **Change:** minor (New Formatting Rule's formula box takes references clicked or dragged on the grid, #2348; Page Up and Ctrl+Up into frozen rows, Ctrl+Left and Alt+Page Up into frozen columns bring the scrolled area back; Auto Fill Options button after a left-drag of the fill handle (#1403); Show Outline Symbols, Ctrl+8; a too-wide number in a repeat-fill format such as Accounting shows #### instead of losing its leading digits; Esc and Enter close a data validation alert over the cell editor; Ctrl+Enter fill gives each cell the automatic number format typing gives it; keytip letters typed with Alt held, #1846; Number Stored as Text indicator and Convert to Number) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (Go To lists the named ranges of every sheet and the references entered before, and takes several names or references separated by commas; New Formatting Rule's formula box takes references clicked or dragged on the grid, #2348; Page Up and Ctrl+Up into frozen rows, Ctrl+Left and Alt+Page Up into frozen columns bring the scrolled area back; Auto Fill Options button after a left-drag of the fill handle (#1403); Show Outline Symbols, Ctrl+8; a too-wide number in a repeat-fill format such as Accounting shows #### instead of losing its leading digits; Esc and Enter close a data validation alert over the cell editor; Ctrl+Enter fill gives each cell the automatic number format typing gives it; keytip letters typed with Alt held, #1846; Number Stored as Text indicator and Convert to Number) · **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
@@ -152,6 +152,9 @@ Seek, Scenario Manager, Data Analysis, Forecast Sheet and others exist. Missing
Solver, Power Query editor, Insert Object, Equation editor, Protect/Allow Edit Ranges,
Workbook Links, Accessibility checker. Like Excel's, every dialog opens centred and moves by its
title bar, keeping its place while it is open (#1250).
Go To lists the named ranges of every sheet (a name scoped to another sheet as `Sheet2!Name`) and
the references entered before; a double-click goes to one. Its Reference box takes several names
or references separated by commas (`Price, Type`, `B14:C22,F19:G30`) and selects them all.
New Formatting Rule's formula box points like the formula bar: a click on the grid writes the
cell's absolute reference at the caret (`=$B$2`) and a drag a range, leaving the selection the rule
applies to as it is (#2348). Other dialogs' reference fields don't take clicks yet.
@@ -178,6 +181,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12.
| Date | Change | Summary |
|---|---|---|
| 2026-10-24 | minor | Go To lists the named ranges on every sheet, as Excel's does ("The Go to popup window shows named ranges on every worksheet in your workbook"): a name scoped to another sheet appears as `Sheet2!Name`, and hidden names, constants and formulas are left out. It keeps the references entered (newest first, ten at most), and a double-click on an entry goes there. The Reference box and `edit.goTo` take several names or references separated by commas (`Price, Type`, `B14:C22,F19:G30,H21:H29`) and select them all; `edit.goTo {list: true}` returns the list |
| 2026-10-23 | minor | Conditional Formatting ▸ New Rule, formula: clicking a cell on the grid writes its absolute reference at the formula box's caret (`=$B$2`, an empty box starts with `=`), a drag writes a range (`$B$2:$C$4`) and a second click replaces the reference just written, as in Excel ("Selecting cells on the worksheet inserts absolute cell references"). The selection the rule applies to doesn't move, and the box keeps the keyboard. After an operand (`=A1>5`) the click does nothing (#2348) |
| 2026-10-23 | minor | Frozen panes: Page Up, Ctrl+Up and Shift variants that move from the scrolled rows into the frozen ones bring the scrolled rows back to the top, so the rows in between are seen again (Page Up from far down used to end on row 1 with the view still a page down); Ctrl+Left and Alt+Page Up do the same for frozen columns. Other ways of selecting a frozen cell (click, Find, Go To, Undo, formula pointing) leave the view where it is |
| 2026-10-23 | minor | After a left-drag of the fill handle an Auto Fill Options button sits below the filled cells; its menu redoes the fill as Copy Cells, Fill Series, Fill Formatting Only or Fill Without Formatting (undoes the drag's fill, then `edit.autoFill` in that mode). It goes away when the workbook changes or the selection moves (#1403) |