Merge pull request #2619 from aiongg/g4

Window per workbook (4/4): two windows on one workbook without copying it; Arrange All
This commit is contained in:
aiongg
2026-10-26 00:00:24 -04:00
committed by GitHub
60 changed files with 646 additions and 250 deletions
+2
View File
@@ -147,6 +147,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
| 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) |
| 2026-10-22 | View › New Window and Arrange All on the ribbon. Arrange All tiles, stacks side by side or top to bottom, or cascades the native windows over the main window's monitor, the active one first. Two windows on one workbook show different sheets without copying the workbook or marking it changed; Save records the sheet of the window used last. A dialog stays in the window it was opened in |
| 2026-10-22 | On the desktop every workbook window is a native window of its own: New, Open and New Window open one, each keeps its own cell draft, dialog and ribbon, and the macOS menu bar acts on the window focused last. Closing one of several windows on a workbook doesn't ask; closing a workbook's last window, or quitting, asks about unsaved changes in that window. The last window closed quits on Windows and Linux; macOS keeps running with the menu bar. First craft-libs `craft-window` use (window list and viewport driver) |
| 2026-10-22 | View › New Window opens a second window on the same workbook (it used to open a copy whose Save overwrote the original): each window has its own selection, active sheet and scroll, titles read "Book1:1" / "Book1:2", and Switch Windows, Hide, Unhide and Close act per window. The control channel, CLI `send` and MCP tools take an optional `window` or `doc` id and default to the active window; `window.list` (MCP `list_windows`) lists the windows |
| 2026-10-22 | Typing in a cell and then choosing File › New no longer strands the first workbook: the entry is committed into its own workbook before any File or window command (a refused entry stays open and stops the command), View › Switch Windows and the macOS Window menu list the open workbooks with unsaved markers, `view.switchWindows {doc}` switches by a stable workbook id, and the macOS Quit item asks about unsaved workbooks |
@@ -245,6 +246,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
| 2026-10-23 | minor | Progress log: accounting negatives in number text |
| 2026-10-23 | minor | Progress log: INDEX with one index on a 2-D reference is #REF! |
| 2026-10-22 | trivial | Progress log: formula hardening (#2163, #2162) |
| 2026-10-22 | minor | Progress log: Arrange All; two windows on one workbook without copying it |
| 2026-10-22 | minor | Progress log: a native window per workbook window |
| 2026-10-22 | minor | Progress log: New Window shares the workbook; window ids for scripts and MCP |
| 2026-10-22 | minor | Progress log: drafts stay in their workbook, Switch Windows, macOS Quit asks to save |
+1 -1
View File
@@ -1182,7 +1182,7 @@ mod tests {
fn v(s: &Session, a: &str) -> Value {
let d = s.doc().unwrap();
let (sh, body) = crate::cmd::split_sheet(a);
let si = sh.map(|n| d.wb.sheet_index(&n).unwrap()).unwrap_or(d.wb.active_sheet);
let si = sh.map(|n| d.wb.sheet_index(&n).unwrap()).unwrap_or(d.wb.active_sheet());
d.wb.sheet(si).unwrap().value(CellRef::parse(body).unwrap())
}
fn n(s: &Session, a: &str) -> f64 {
+9 -9
View File
@@ -167,7 +167,7 @@ fn data_target(s: &Session, p: &Json, cmd: &str) -> Result<(usize, RangeRef)> {
{
return Err(bad(cmd, format!("`{t}` names a sheet other than `sheet`")));
}
if sheet != d.wb.active_sheet && p.get("range").is_none() {
if sheet != d.wb.active_sheet() && p.get("range").is_none() {
return Err(bad(cmd, "missing `range`: the selection is on the active sheet"));
}
Ok((sheet, data_range(s, p)?))
@@ -219,7 +219,7 @@ fn sort_target(s: &Session, p: &Json) -> Result<(usize, RangeRef)> {
{
return Err(bad("data.sort", format!("`{t}` names a sheet other than `sheet`")));
}
if sheet != d.wb.active_sheet && p.get("range").is_none() {
if sheet != d.wb.active_sheet() && p.get("range").is_none() {
return Err(bad("data.sort", "missing `range`: the selection is on the active sheet"));
}
Ok((sheet, data_range(s, p)?))
@@ -511,7 +511,7 @@ fn toggle_filter(s: &mut Session, p: &Json) -> Result<Json> {
let (sheet, r) = data_target(s, p, "data.filter")?;
super::refuse_unless_allowed(s, sheet, "Filtering", |p| p.autofilter)?;
// The active cell belongs to the active sheet: on another sheet the table is the one at `range`.
let at = if sheet == s.doc()?.wb.active_sheet { s.doc()?.selection.active } else { r.start };
let at = if sheet == s.doc()?.wb.active_sheet() { s.doc()?.selection.active } else { r.start };
let has = s.doc()?.wb.sheet(sheet).is_some_and(|sh| sh.autofilter.is_some());
let in_table = s.doc()?.wb.sheet(sheet).and_then(|sh| sh.table_at(at).map(|t| t.name.clone()));
if let Some(name) = in_table {
@@ -563,7 +563,7 @@ fn install_filter(s: &mut Session, p: &Json) -> Result<Json> {
}
fn filter_by(s: &mut Session, p: &Json) -> Result<Json> {
let sheet = s.doc()?.wb.active_sheet;
let sheet = s.doc()?.wb.active_sheet();
super::refuse_unless_allowed(s, sheet, "Filtering", |p| p.autofilter)?;
let Some(sh) = s.doc()?.wb.sheet(sheet) else { return Err(EngineError::NoDocument) };
let af = match &sh.autofilter {
@@ -768,7 +768,7 @@ pub(crate) fn apply_filter(wb: &mut Workbook, sheet: usize) {
}
fn clear_filter(s: &mut Session, _: &Json) -> Result<Json> {
let sheet = s.doc()?.wb.active_sheet;
let sheet = s.doc()?.wb.active_sheet();
edit(s, |cx| {
let sh = cx.sheet_mut(sheet)?;
if let Some(a) = sh.autofilter.as_mut() {
@@ -781,7 +781,7 @@ fn clear_filter(s: &mut Session, _: &Json) -> Result<Json> {
}
fn reapply(s: &mut Session, _: &Json) -> Result<Json> {
let sheet = s.doc()?.wb.active_sheet;
let sheet = s.doc()?.wb.active_sheet();
edit(s, |cx| {
apply_filter(&mut cx.wb, sheet);
cx.structural = true;
@@ -1285,7 +1285,7 @@ fn validate_cmd(s: &mut Session, p: &Json) -> Result<Json> {
let d = s.doc()?;
let at = cell_param(s, p, "cell")?.unwrap_or(d.selection.active);
let input = str_param(p, "input").unwrap_or("");
match check_validation(&d.wb, d.wb.active_sheet, at, input) {
match check_validation(&d.wb, d.wb.active_sheet(), at, input) {
None => Ok(json!({"ok": true})),
Some((dv, msg)) => Ok(json!({"ok": false, "message": msg, "title": dv.error_title, "style": format!("{:?}", dv.error_style)})),
}
@@ -1293,7 +1293,7 @@ fn validate_cmd(s: &mut Session, p: &Json) -> Result<Json> {
fn circle_invalid(s: &mut Session, _: &Json) -> Result<Json> {
let d = s.doc()?;
let sheet = d.wb.active_sheet;
let sheet = d.wb.active_sheet();
let Some(sh) = d.wb.sheet(sheet) else { return Err(EngineError::NoDocument) };
let mut bad_cells = Vec::new();
for dv in &sh.validations {
@@ -1332,7 +1332,7 @@ fn outline_lines(s: &Session, p: &Json) -> Result<(bool, u32, u32)> {
fn group(s: &mut Session, p: &Json, on: bool) -> Result<Json> {
let si = target_sheet(s, p)?;
// The selection belongs to the active sheet, so another sheet needs its lines.
if si != s.doc()?.wb.active_sheet && p.get("rows").is_none() && p.get("cols").is_none() {
if si != s.doc()?.wb.active_sheet() && p.get("rows").is_none() && p.get("cols").is_none() {
return Err(bad(if on { "data.group" } else { "data.ungroup" }, "missing `rows` or `cols`: the selection is on the active sheet"));
}
let (rows, a, b) = outline_lines(s, p)?;
+2 -2
View File
@@ -72,7 +72,7 @@ fn stroke(s: &mut Session, p: &Json) -> Result<Json> {
str_param(p, "color").map(str::to_string).unwrap_or_else(|| if s.draw_color.is_empty() { "#1F5FC9".into() } else { s.draw_color.clone() });
let width = f64_param(p, "width").map(|w| w as f32).unwrap_or(if s.draw_width <= 0.0 { 2.0 } else { s.draw_width }).clamp(0.5, 40.0);
let d = s.doc()?;
let si = d.wb.active_sheet;
let si = d.wb.active_sheet();
let sh = d.wb.sheet(si).ok_or(EngineError::NoDocument)?;
let cell = CellRef::new(sh.row_at(miny), sh.col_at(minx));
let dx = (minx - sh.col_left(cell.col)) as f32;
@@ -115,7 +115,7 @@ pub fn ink_points(sh: &Shape) -> Vec<[f32; 2]> {
}
fn ink_to_shape(s: &mut Session, p: &Json) -> Result<Json> {
let si = s.doc()?.wb.active_sheet;
let si = s.doc()?.wb.active_sheet();
let id = u32_param(p, "id");
edit(s, |cx| {
let sh = cx.sheet_mut(si)?;
+16 -21
View File
@@ -237,7 +237,7 @@ fn cell_set(s: &mut Session, p: &Json) -> Result<Json> {
{
return Err(EngineError::Other(m));
}
let active_sheet = s.doc()?.wb.active_sheet;
let active_sheet = s.doc()?.wb.active_sheet();
edit(s, |cx| {
if array {
// Legacy Ctrl+Shift+Enter array formula over the selection, when the selection holds
@@ -438,23 +438,18 @@ fn selection_set(s: &mut Session, p: &Json) -> Result<Json> {
impl crate::DocState {
/// Switches the active sheet, remembering each sheet's selection.
pub fn wb_switch_sheet(&mut self, i: usize) {
if i >= self.wb.sheets.len() || i == self.wb.active_sheet {
if i >= self.wb.sheets.len() || i == self.wb.active_sheet() {
return;
}
let cur = self.wb.active_sheet;
let cur = self.wb.active_sheet();
if self.sheet_selections.len() < self.wb.sheets.len() {
self.sheet_selections.resize(self.wb.sheets.len(), crate::Selection::default());
}
if let Some(slot) = self.sheet_selections.get_mut(cur) {
*slot = self.selection.clone();
}
// The active sheet is view state: change it without making the workbook dirty.
let dirty = self.is_dirty();
let wb = std::sync::Arc::make_mut(&mut self.wb);
wb.active_sheet = i;
if !dirty {
self.saved = self.wb.clone();
}
// View state: set in place, so the workbook is neither copied nor made dirty.
self.wb.set_active_sheet(i);
self.selection = self.sheet_selections.get(i).cloned().unwrap_or_default();
}
}
@@ -587,7 +582,7 @@ 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}));
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`")) };
let d = s.doc()?;
@@ -595,7 +590,7 @@ pub(super) fn go_to(s: &mut Session, p: &Json) -> Result<Json> {
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 {
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.
@@ -1701,7 +1696,7 @@ fn find(s: &mut Session, p: &Json) -> Result<Json> {
let workbook = str_param(p, "within") == Some("workbook");
s.last_find = Some(p.clone());
let d = s.doc()?;
let sheets: Vec<usize> = if workbook { (0..d.wb.sheets.len()).collect() } else { vec![d.wb.active_sheet] };
let sheets: Vec<usize> = if workbook { (0..d.wb.sheets.len()).collect() } else { vec![d.wb.active_sheet()] };
let mut hits: Vec<(usize, CellRef, String)> = Vec::new();
for si in sheets {
let Some(sh) = d.wb.sheet(si) else { continue };
@@ -1731,7 +1726,7 @@ fn find(s: &mut Session, p: &Json) -> Result<Json> {
return Ok(json!({"count": list.len(), "results": list}));
}
// Next hit after the active cell.
let cur = (d.wb.active_sheet, d.selection.active);
let cur = (d.wb.active_sheet(), d.selection.active);
let key = |si: usize, c: CellRef| if by_cols { (si, c.col, c.row) } else { (si, c.row, c.col) };
let next = hits.iter().find(|(si, c, _)| key(*si, *c) > key(cur.0, cur.1)).or_else(|| hits.first()).cloned();
let Some((si, c, t)) = next else { return Err(EngineError::Other("not found".into())) };
@@ -1753,7 +1748,7 @@ fn replace(s: &mut Session, p: &Json) -> Result<Json> {
let workbook = str_param(p, "within") == Some("workbook");
let active = s.doc()?.selection.active;
let n = edit(s, |cx| {
let sheets: Vec<usize> = if workbook { (0..cx.wb.sheets.len()).collect() } else { vec![cx.wb.active_sheet] };
let sheets: Vec<usize> = if workbook { (0..cx.wb.sheets.len()).collect() } else { vec![cx.wb.active_sheet()] };
let mut n = 0;
for si in sheets {
let cells: Vec<(CellRef, String)> =
@@ -1825,7 +1820,7 @@ fn replace_ci(text: &str, what: &str, with: &str) -> String {
fn format_painter(s: &mut Session, p: &Json) -> Result<Json> {
if let Some(dest) = str_param(p, "apply").and_then(RangeRef::parse) {
let Some((wb, sheet, src, sticky)) = s.format_painter.clone() else { return Err(EngineError::Other("Pick up a format first.".into())) };
let dest_sheet = s.doc()?.wb.active_sheet;
let dest_sheet = s.doc()?.wb.active_sheet();
let same = std::sync::Arc::ptr_eq(&wb, &s.doc()?.wb) || s.doc()?.wb.styles == wb.styles;
edit(s, |cx| {
let Some(src_sheet) = wb.sheet(sheet) else { return Ok(()) };
@@ -1905,7 +1900,7 @@ fn format_painter(s: &mut Session, p: &Json) -> Result<Json> {
}
let sticky = bool_param(p, "sticky").unwrap_or(false);
let d = s.doc()?;
s.format_painter = Some((d.wb.clone(), d.wb.active_sheet, d.selection.current(), sticky));
s.format_painter = Some((d.wb.clone(), d.wb.active_sheet(), d.selection.current(), sticky));
ok()
}
@@ -1965,11 +1960,11 @@ mod tests {
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);
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);
assert_eq!(s.doc().unwrap().wb.active_sheet(), 0);
}
/// A name matches in any case and any script, as a formula matches it.
@@ -1996,7 +1991,7 @@ mod tests {
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);
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}");
@@ -2035,7 +2030,7 @@ mod tests {
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);
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();
}
+39 -12
View File
@@ -142,7 +142,15 @@ pub fn specs() -> Vec<CommandSpec> {
cmd!(noundo "view.customViews", "Custom Views…", ["View", "Workbook Views"], None, "{save?: name, show?: name, delete?: name} → list", has_doc, custom_views),
cmd!(noundo "view.split", "Split", ["View", "Window"], None, "{} (toggles a split at the active cell; shown as frozen panes in this version)", has_doc, split),
cmd!(noundo "view.newWindow", "New Window", ["View", "Window"], None, "{} → {window, doc, title}: opens another window on the active workbook (the same workbook: saving from either window saves it)", has_doc, new_window),
cmd!(noundo "view.arrangeAll", "Arrange All", ["View", "Window"], None, "{}", has_doc, |_, _| ok()),
cmd!(
noundo "view.arrangeAll",
"Arrange All",
["View", "Window"],
None,
"{arrange?: tiled|horizontal|vertical|cascade, activeWorkbook?: bool} → {arrange, windows}: lays out the visible windows (tiled by default; `activeWorkbook` only the active workbook's), the active window first",
has_doc,
arrange_all
),
cmd!(noundo "view.hideWindow", "Hide", ["View", "Window"], None, "{window?, doc?} (the active window by default)", has_doc, hide_window),
cmd!(noundo "view.unhideWindow", "Unhide…", ["View", "Window"], None, "{window?, doc?, index?} (the most recently hidden window by default)", has_doc, unhide_window),
cmd!(
@@ -302,7 +310,7 @@ pub fn specs() -> Vec<CommandSpec> {
fn find_chart(s: &Session, p: &Json) -> Result<(usize, usize)> {
let d = s.doc()?;
let si = d.wb.active_sheet;
let si = d.wb.active_sheet();
let sh = d.wb.sheet(si).ok_or(EngineError::NoDocument)?;
let idx = match u32_param(p, "chart") {
Some(id) => sh.charts.iter().position(|c| c.id == id),
@@ -468,7 +476,7 @@ fn select_data(s: &mut Session, p: &Json) -> Result<Json> {
let (sheet_name, body) = split_sheet(range);
let src_si = match sheet_name {
Some(n) => d.wb.sheet_index(&n).ok_or_else(|| bad("chart.selectData", "no such sheet"))?,
None => d.wb.active_sheet,
None => d.wb.active_sheet(),
};
let r = RangeRef::parse(body).ok_or_else(|| bad("chart.selectData", "not a range"))?;
if r.count() > 1_000_000 {
@@ -490,7 +498,7 @@ fn select_data(s: &mut Session, p: &Json) -> Result<Json> {
let (sheet_name, body) = split_sheet(t);
let si = match sheet_name {
Some(n) => d.wb.sheet_index(&n).ok_or_else(|| bad("chart.selectData", "no such sheet"))?,
None => d.wb.active_sheet,
None => d.wb.active_sheet(),
};
let r = RangeRef::parse(body).ok_or_else(|| bad("chart.selectData", "`categories` is not a range"))?;
let name = d.wb.sheet(si).map(|sh| sh.name.clone()).ok_or(EngineError::NoDocument)?;
@@ -572,7 +580,7 @@ fn shape_outline(s: &mut Session, p: &Json) -> Result<Json> {
}
fn with_shape(s: &mut Session, p: &Json, f: impl FnOnce(&mut Shape)) -> Result<Json> {
let si = s.doc()?.wb.active_sheet;
let si = s.doc()?.wb.active_sheet();
let id = u32_param(p, "id");
edit(s, |cx| {
let shapes = &mut cx.sheet_mut(si)?.shapes;
@@ -590,7 +598,7 @@ fn arrange(s: &mut Session, p: &Json, forward: bool) -> Result<Json> {
let id = u32_param(p, "id").ok_or_else(|| bad("arrange", "missing `id`"))?;
let kind = str_param(p, "kind").unwrap_or("shape").to_string();
let extreme = bool_param(p, "toFront").or(bool_param(p, "toBack")).unwrap_or(false);
let si = s.doc()?.wb.active_sheet;
let si = s.doc()?.wb.active_sheet();
fn reorder<T>(v: &mut Vec<T>, i: usize, forward: bool, extreme: bool) {
if i >= v.len() {
return;
@@ -656,7 +664,7 @@ fn align(s: &mut Session, p: &Json) -> Result<Json> {
let ids: Vec<u32> =
p.get("ids").and_then(Json::as_array).map(|a| a.iter().filter_map(|v| v.as_u64().map(|x| x as u32)).collect()).unwrap_or_default();
let how = str_param(p, "align").unwrap_or("left").to_string();
let si = s.doc()?.wb.active_sheet;
let si = s.doc()?.wb.active_sheet();
edit(s, |cx| {
let sheet = cx.sheet_mut(si)?;
let geo: Vec<(u32, f64, f64, f32, f32)> = {
@@ -769,7 +777,7 @@ fn custom_views(s: &mut Session, p: &Json) -> Result<Json> {
let mut views = stash_map(&d.wb, VIEWS_NAME);
if let Some(name) = str_param(p, "save") {
let sh = d.wb.active().ok_or(EngineError::NoDocument)?;
views[name] = json!({"sheet": d.wb.active_sheet, "selection": d.selection.a1(), "zoom": sh.zoom, "freeze": sh.freeze});
views[name] = json!({"sheet": d.wb.active_sheet(), "selection": d.selection.a1(), "zoom": sh.zoom, "freeze": sh.freeze});
let v = views.clone();
edit(s, |cx| {
stash_set(&mut cx.wb, VIEWS_NAME, &v);
@@ -828,6 +836,25 @@ pub(crate) fn window_param(s: &Session, cmd: &str, p: &Json) -> Result<u64> {
s.target_window(t).map_err(|e| bad(cmd, e.to_string()))
}
/// View › Arrange All. The engine has no window geometry: it picks the windows and the layout,
/// and the interface moves its native windows ([`Session::window_list`] order, active first).
fn arrange_all(s: &mut Session, p: &Json) -> Result<Json> {
let arrange = match str_param(p, "arrange").unwrap_or("tiled") {
a @ ("tiled" | "horizontal" | "vertical" | "cascade") => a,
other => return Err(bad("view.arrangeAll", format!("`arrange` must be tiled, horizontal, vertical or cascade, not `{other}`"))),
};
let active = s.active_window();
let doc = active.and_then(|w| s.window(w)).map(|w| w.doc_uid);
let only_active = p.get("activeWorkbook").and_then(Json::as_bool).unwrap_or(false);
let mut windows: Vec<u64> = s.windows().iter().filter(|w| !w.hidden && (!only_active || Some(w.doc_uid) == doc)).map(|w| w.id).collect();
if let Some(at) = windows.iter().position(|w| Some(*w) == active)
&& let Some(first) = windows.get_mut(..=at)
{
first.rotate_right(1);
}
Ok(json!({"arrange": arrange, "windows": windows}))
}
fn hide_window(s: &mut Session, p: &Json) -> Result<Json> {
let id = window_param(s, "view.hideWindow", p)?;
s.hide_window(id);
@@ -861,7 +888,7 @@ fn switch_windows(s: &mut Session, p: &Json) -> Result<Json> {
fn table_resize(s: &mut Session, p: &Json) -> Result<Json> {
let r = str_param(p, "range").and_then(RangeRef::parse).ok_or_else(|| bad("table.resize", "missing `range`"))?;
let d = s.doc()?;
let si = d.wb.active_sheet;
let si = d.wb.active_sheet();
let sh = d.wb.sheet(si).ok_or(EngineError::NoDocument)?;
let ti = match str_param(p, "table") {
Some(n) => sh.tables.iter().position(|t| t.name.eq_ignore_ascii_case(n)),
@@ -970,7 +997,7 @@ fn from_table_range(s: &mut Session, p: &Json) -> Result<Json> {
let d = s.doc()?;
let sh = d.wb.active().ok_or(EngineError::NoDocument)?;
let r = if r.is_single() { crate::selection::current_region(sh, r.start) } else { r };
let text = crate::display::range_text(&d.wb, d.wb.active_sheet, r);
let text = crate::display::range_text(&d.wb, d.wb.active_sheet(), r);
from_csv(s, &json!({"text": text, "delimiter": "\t", "sheet": format!("{} (2)", sh.name).chars().take(31).collect::<String>()}))
}
@@ -1220,7 +1247,7 @@ fn theme_fonts(s: &mut Session, p: &Json) -> Result<Json> {
fn background(s: &mut Session, p: &Json) -> Result<Json> {
if bool_param(p, "clear").unwrap_or(false) {
let si = s.doc()?.wb.active_sheet;
let si = s.doc()?.wb.active_sheet();
return edit(s, |cx| {
cx.sheet_mut(si)?.images.retain(|i| i.alt != "Sheet background");
Ok(Json::Null)
@@ -1313,7 +1340,7 @@ fn watch_window(s: &mut Session, p: &Json) -> Result<Json> {
.iter()
.map(|w| {
let (sheet, body) = split_sheet(w);
let si = sheet.and_then(|n| d.wb.sheet_index(&n)).unwrap_or(d.wb.active_sheet);
let si = sheet.and_then(|n| d.wb.sheet_index(&n)).unwrap_or(d.wb.active_sheet());
let c = CellRef::parse(body).unwrap_or_default();
let sh = d.wb.sheet(si);
json!({"cell": w, "value": sh.map(|s| s.value(c).display()), "formula": sh.and_then(|s| s.cell(c)).and_then(|x| x.formula.as_ref()).map(|f| format!("={}", f.text))})
+2 -2
View File
@@ -186,9 +186,9 @@ fn close(s: &mut Session, p: &Json) -> Result<Json> {
fn export(s: &mut Session, p: &Json, ext: &str) -> Result<Json> {
let path = str_param(p, "path").ok_or_else(|| bad("file.export", "missing `path`"))?.to_string();
let d = s.doc()?;
let mut wb = (*d.wb).clone();
let wb = (*d.wb).clone();
if p.get("sheet").is_some() {
wb.active_sheet = target_sheet(s, p)?;
wb.set_active_sheet(target_sheet(s, p)?);
}
let target = if path.ends_with(&format!(".{ext}")) { path.clone() } else { format!("{path}.{ext}") };
let bytes = crate::io::save_bytes(&wb, &target)?;
+5 -5
View File
@@ -68,7 +68,7 @@ fn range_on(s: &Session, t: &str) -> Result<(usize, RangeRef)> {
let (sh, body) = split_sheet(t);
let si = match sh {
Some(n) => d.wb.sheet_index(&n).ok_or_else(|| bad(CMD, format!("no sheet named `{n}`")))?,
None => d.wb.active_sheet,
None => d.wb.active_sheet(),
};
let r = RangeRef::parse(body.trim()).ok_or_else(|| bad(CMD, format!("not a range: `{t}`")))?;
let r = if r.is_full_cols() || r.is_full_rows() {
@@ -101,7 +101,7 @@ fn source(s: &Session, p: &Json) -> Result<(usize, Vec<CellRef>, Vec<CellRef>)>
None => {
let sh = d.wb.active().ok_or(EngineError::NoDocument)?;
let sel = d.selection.current();
(d.wb.active_sheet, if sel.is_single() { current_region(sh, d.selection.active) } else { sel })
(d.wb.active_sheet(), if sel.is_single() { current_region(sh, d.selection.active) } else { sel })
}
};
if r.width() < 2 {
@@ -307,7 +307,7 @@ fn forecast_sheet(s: &mut Session, p: &Json) -> Result<Json> {
let stats_col = width + 1;
let chart_col = if opts.statistics { stats_col + 3 } else { width + 1 };
let at = s.doc()?.wb.active_sheet;
let at = s.doc()?.wb.active_sheet();
let index = edit(s, |cx| {
let at = at.min(cx.wb.sheets.len());
let bold = cx.wb.styles.derive(StyleId::DEFAULT, |st| st.font.bold = true);
@@ -440,7 +440,7 @@ fn forecast_sheet(s: &mut Session, p: &Json) -> Result<Json> {
*sc += 1;
}
}
cx.wb.active_sheet = at;
cx.wb.set_active_sheet(at);
*cx.sel = crate::Selection::at(CellRef::new(0, 0));
cx.structural = true;
Ok(at)
@@ -502,7 +502,7 @@ mod tests {
assert_eq!(r["periods"], 4);
let wb = &s.doc().unwrap().wb;
// Inserted before the data sheet and activated.
assert_eq!(wb.active_sheet, 0);
assert_eq!(wb.active_sheet(), 0);
let sh = wb.sheet(0).unwrap();
assert_eq!(sh.name, r["sheet"].as_str().unwrap());
assert_eq!(value(&s, 0, "C1"), Value::text("Forecast(Sales)"));
+1 -1
View File
@@ -1027,7 +1027,7 @@ fn line_targets(s: &Session, p: &Json, rows: bool) -> Result<(usize, Vec<(u32, u
return Ok((sheet, lines));
}
let d = s.doc()?;
if sheet != d.wb.active_sheet {
if sheet != d.wb.active_sheet() {
return Err(bad(key, format!("missing `{key}`: the selection is on the active sheet")));
}
Ok((sheet, d.selection.ranges.iter().map(|r| if rows { (r.start.row, r.end.row) } else { (r.start.col, r.end.col) }).collect()))
+10 -10
View File
@@ -197,7 +197,7 @@ fn auto_sum(s: &mut Session, p: &Json) -> Result<Json> {
};
if !sel.is_single() {
let Some(writes) = selection_totals(sh, sel, &func)? else { return ok() };
let sheet = d.wb.active_sheet;
let sheet = d.wb.active_sheet();
edit(s, |cx| {
for (c, f) in &writes {
let cell = super::edit::input_to_cell(f, sheet, *c, &mut cx.wb)?.unwrap_or_default();
@@ -498,7 +498,7 @@ fn name_manager(s: &mut Session, _: &Json) -> Result<Json> {
.iter()
.filter(|n| !n.hidden)
.map(|n| {
let sheet = n.scope.unwrap_or(d.wb.active_sheet);
let sheet = n.scope.unwrap_or(d.wb.active_sheet());
// parse_name, not evaluate's parse: a name's top-level commas are unions.
let v = gridcraft_formula::parse_name(&n.formula)
.map_or(Value::Error(CellError::Name), |e| gridcraft_calc::recalc::evaluate_expr(&d.wb, sheet, CellRef::default(), &e));
@@ -580,7 +580,7 @@ fn create_from_selection(s: &mut Session, p: &Json) -> Result<Json> {
/// active sheet's own, without hidden names, in alphabetical order. A sheet's name hides the
/// workbook name it shares.
pub fn pasteable_names(wb: &Workbook) -> Vec<(String, String)> {
let sheet = wb.active_sheet;
let sheet = wb.active_sheet();
let shown = |local: bool| wb.names.iter().filter(move |n| !n.hidden && n.scope.is_some() == local && n.scope.is_none_or(|i| i == sheet));
let local: std::collections::HashSet<String> = shown(true).map(|n| gridcraft_formula::fold_name(&n.name)).collect();
let mut out: Vec<(String, String)> = shown(true).map(|n| (n.name.clone(), format!("={}", n.formula))).collect();
@@ -614,7 +614,7 @@ fn paste_name(s: &mut Session, p: &Json) -> Result<Json> {
if at.row.saturating_add(rows) > gridcraft_core::MAX_ROWS || at.col.saturating_add(2) > gridcraft_core::MAX_COLS {
return Err(EngineError::Other("There isn't room on the sheet for the list of names.".into()));
}
let sheet = d.wb.active_sheet;
let sheet = d.wb.active_sheet();
let range = RangeRef::new(at, CellRef::new(at.row + rows - 1, at.col + 1));
edit(s, |cx| {
for (row, (name, refers)) in (at.row..).zip(&names) {
@@ -634,7 +634,7 @@ fn paste_name(s: &mut Session, p: &Json) -> Result<Json> {
fn trace_precedents(s: &mut Session, p: &Json) -> Result<Json> {
let d = s.doc()?;
let at = cell_param(s, p, "cell")?.unwrap_or(d.selection.active);
let areas = d.calc.graph.precedents_of((d.wb.active_sheet, at));
let areas = d.calc.graph.precedents_of((d.wb.active_sheet(), at));
let v: Vec<Json> = areas.iter().map(|a| json!({"sheet": d.wb.sheet(a.sheet).map(|s| s.name.clone()), "range": a.range.a1()})).collect();
Ok(Json::Array(v))
}
@@ -643,7 +643,7 @@ fn trace_dependents(s: &mut Session, p: &Json) -> Result<Json> {
let d = s.doc()?;
let at = cell_param(s, p, "cell")?.unwrap_or(d.selection.active);
let mut deps = Vec::new();
d.calc.graph.dependents(d.wb.active_sheet, at, &mut deps);
d.calc.graph.dependents(d.wb.active_sheet(), at, &mut deps);
deps.sort();
deps.dedup();
let v: Vec<Json> = deps.iter().map(|(si, c)| json!({"sheet": d.wb.sheet(*si).map(|s| s.name.clone()), "cell": c.a1()})).collect();
@@ -656,7 +656,7 @@ fn select_trace_cells(s: &mut Session, p: &Json, precedents: bool) -> Result<Jso
use std::collections::BTreeSet;
let d = s.doc()?;
let at = cell_param(s, p, "cell")?.unwrap_or(d.selection.active);
let sheet = d.wb.active_sheet;
let sheet = d.wb.active_sheet();
let all = bool_param(p, "all").unwrap_or(false);
// (sheet, a1) pairs to select; direct level keeps whole precedent ranges, transitive cells.
let mut found: BTreeSet<(usize, String)> = BTreeSet::new();
@@ -727,7 +727,7 @@ fn select_dependents(s: &mut Session, p: &Json) -> Result<Json> {
}
fn show_formulas(s: &mut Session, p: &Json) -> Result<Json> {
let sheet = s.doc()?.wb.active_sheet;
let sheet = s.doc()?.wb.active_sheet();
let cur = s.doc()?.wb.sheet(sheet).is_some_and(|sh| sh.show_formulas);
let on = bool_param(p, "on").unwrap_or(!cur);
edit(s, |cx| {
@@ -797,7 +797,7 @@ fn error_checking(s: &mut Session, _: &Json) -> Result<Json> {
fn evaluate_formula(s: &mut Session, p: &Json) -> Result<Json> {
let d = s.doc()?;
let at = cell_param(s, p, "cell")?.unwrap_or(d.selection.active);
let sheet = d.wb.active_sheet;
let sheet = d.wb.active_sheet();
let text = match str_param(p, "formula") {
Some(f) => f.trim_start_matches('=').to_string(),
None => {
@@ -836,7 +836,7 @@ fn evaluate(s: &mut Session, p: &Json) -> Result<Json> {
let at = cell_param(s, p, "cell")?.unwrap_or(d.selection.active);
let expr = crate::cmd::edit::parse_typed_formula(f.strip_prefix('=').unwrap_or(f))?;
crate::cmd::edit::check_arg_counts(&expr)?;
let v = gridcraft_calc::recalc::evaluate_expr(&d.wb, d.wb.active_sheet, at, &expr);
let v = gridcraft_calc::recalc::evaluate_expr(&d.wb, d.wb.active_sheet(), at, &expr);
Ok(crate::cmd::inspect::value_json(&v))
}
+11 -11
View File
@@ -371,7 +371,7 @@ fn find_table(s: &Session, p: &Json) -> Result<(usize, usize)> {
if let Some(n) = str_param(p, "table") {
return d.wb.table(n).ok_or_else(|| bad("table", format!("no table named `{n}`")));
}
let sheet = d.wb.active_sheet;
let sheet = d.wb.active_sheet();
let sh = d.wb.sheet(sheet).ok_or(EngineError::NoDocument)?;
let ti = sh
.tables
@@ -835,7 +835,7 @@ fn insert_chart(s: &mut Session, p: &Json) -> Result<Json> {
fn find_chart(s: &Session, p: &Json) -> Result<(usize, usize)> {
let d = s.doc()?;
let sheet = d.wb.active_sheet;
let sheet = d.wb.active_sheet();
let sh = d.wb.sheet(sheet).ok_or(EngineError::NoDocument)?;
let idx = match u32_param(p, "chart") {
Some(id) => sh.charts.iter().position(|c| c.id == id),
@@ -984,7 +984,7 @@ fn insert_sparkline(s: &mut Session, p: &Json) -> Result<Json> {
_ => SparklineKind::Line,
};
let markers = bool_param(p, "markers").unwrap_or(false);
let sheet = s.doc()?.wb.active_sheet;
let sheet = s.doc()?.wb.active_sheet();
edit(s, |cx| {
let sh = cx.sheet_mut(sheet)?;
sh.sparklines.retain(|x| x.cell != loc);
@@ -1009,7 +1009,7 @@ fn insert_picture(s: &mut Session, p: &Json) -> Result<Json> {
if placement == "cell" {
let mime = validate_cell_picture(&data)?;
let d = s.doc()?;
let sheet = d.wb.active_sheet;
let sheet = d.wb.active_sheet();
let sh = d.wb.active().ok_or(EngineError::NoDocument)?;
let at = match str_param(p, "at") {
Some(a) => CellRef::parse(a).ok_or_else(|| bad("insert.picture", "invalid cell address"))?,
@@ -1053,7 +1053,7 @@ fn insert_picture(s: &mut Session, p: &Json) -> Result<Json> {
mime,
alt: str_param(p, "alt").unwrap_or("").into(),
};
let sheet = d.wb.active_sheet;
let sheet = d.wb.active_sheet();
edit(s, |cx| {
cx.sheet_mut(sheet)?.images.push(img);
Ok(json!({"image": id}))
@@ -1123,7 +1123,7 @@ fn insert_shape(s: &mut Session, p: &Json) -> Result<Json> {
text: str_param(p, "text").unwrap_or("").into(),
format: Default::default(),
};
let sheet = d.wb.active_sheet;
let sheet = d.wb.active_sheet();
edit(s, |cx| {
cx.sheet_mut(sheet)?.shapes.push(shape);
Ok(json!({"shape": id}))
@@ -1141,7 +1141,7 @@ fn shape_set_text(s: &mut Session, p: &Json) -> Result<Json> {
return Err(bad("shape.setText", "`text` is longer than 32,767 characters"));
}
let d = s.doc()?;
let sheet = d.wb.active_sheet;
let sheet = d.wb.active_sheet();
let sh = d.wb.active().ok_or_else(|| bad("shape.setText", "no such text box"))?;
let shape = sh.shapes.iter().find(|shape| shape.id == id).ok_or_else(|| bad("shape.setText", "no such text box"))?;
if shape.kind != ShapeKind::TextBox {
@@ -1164,7 +1164,7 @@ fn shape_set_text(s: &mut Session, p: &Json) -> Result<Json> {
fn delete_object(s: &mut Session, p: &Json) -> Result<Json> {
let id = u32_param(p, "id").ok_or_else(|| bad("object.delete", "missing `id`"))?;
let kind = str_param(p, "kind").unwrap_or("").to_string();
let sheet = s.doc()?.wb.active_sheet;
let sheet = s.doc()?.wb.active_sheet();
edit(s, |cx| {
let sh = cx.sheet_mut(sheet)?;
match kind.as_str() {
@@ -1180,7 +1180,7 @@ fn delete_object(s: &mut Session, p: &Json) -> Result<Json> {
fn move_object(s: &mut Session, p: &Json) -> Result<Json> {
let id = u32_param(p, "id").ok_or_else(|| bad("object.move", "missing `id`"))?;
let kind = str_param(p, "kind").unwrap_or("").to_string();
let sheet = s.doc()?.wb.active_sheet;
let sheet = s.doc()?.wb.active_sheet();
let at = cell_param(s, p, "at")?;
edit(s, |cx| {
let sh = cx.sheet_mut(sheet)?;
@@ -1221,7 +1221,7 @@ fn set_anchor_mode(s: &mut Session, p: &Json) -> Result<Json> {
return Err(bad("object.setAnchorMode", "kind must be chart, image or shape"));
}
let mode = anchor_mode_param(p).ok_or_else(|| bad("object.setAnchorMode", "mode must be moveAndSize, moveOnly or absolute"))?;
let sheet = s.doc()?.wb.active_sheet;
let sheet = s.doc()?.wb.active_sheet();
edit(s, |cx| {
let sh = cx.sheet_mut(sheet)?;
let anchor = match kind.as_str() {
@@ -1730,7 +1730,7 @@ fn insert_icon(s: &mut Session, p: &Json) -> Result<Json> {
text: name,
format: Default::default(),
};
let sheet = d.wb.active_sheet;
let sheet = d.wb.active_sheet();
edit(s, |cx| {
cx.sheet_mut(sheet)?.shapes.push(shape);
Ok(json!({"shape": id}))
+1 -1
View File
@@ -64,7 +64,7 @@ fn inspect(s: &mut Session, _: &Json) -> Result<Json> {
"path": d.path,
"dirty": d.is_dirty(),
"trust": d.trust,
"activeSheet": wb.active_sheet,
"activeSheet": wb.active_sheet(),
"sheets": sheets,
"selection": d.selection.a1(),
"activeCell": d.selection.active.a1(),
+3 -3
View File
@@ -296,7 +296,7 @@ pub(crate) fn target_sheet(s: &Session, p: &Json) -> Result<usize> {
{
return d.wb.sheet_index(&sh).ok_or_else(|| bad("range", format!("no sheet named `{sh}`")));
}
Ok(d.wb.active_sheet)
Ok(d.wb.active_sheet())
}
pub(crate) use gridcraft_core::split_sheet;
@@ -305,7 +305,7 @@ pub(crate) use gridcraft_core::split_sheet;
/// a cell reference, or that names a sheet other than the active one, is an error rather than a
/// silent fallback to some other cell.
pub(crate) fn cell_param(s: &Session, p: &Json, key: &str) -> Result<Option<CellRef>> {
cell_param_on(s, p, key, s.doc()?.wb.active_sheet)
cell_param_on(s, p, key, s.doc()?.wb.active_sheet())
}
/// Like [`cell_param`], for a command that operates on `sheet` (a `Sheet!` prefix must name it).
@@ -593,7 +593,7 @@ pub fn auto_row_height(wb: &mut Workbook, si: usize, row: u32) {
impl Ctx<'_> {
pub fn sheet_index(&self) -> usize {
self.wb.active_sheet
self.wb.active_sheet()
}
pub fn sheet_mut(&mut self, i: usize) -> Result<&mut gridcraft_model::Sheet> {
self.wb.sheet_mut(i).ok_or_else(|| EngineError::Other("no such sheet".into()))
+13 -13
View File
@@ -224,7 +224,7 @@ fn other(msg: impl Into<String>) -> EngineError {
/// (sheet, index) of the pivot a call targets.
fn find_pivot(s: &Session, p: &Json) -> Result<(usize, usize)> {
let d = s.doc()?;
let active = d.wb.active_sheet;
let active = d.wb.active_sheet();
if let Some(v) = p.get("pivot").filter(|v| !v.is_null()) {
let mut order: Vec<usize> = vec![active];
order.extend((0..d.wb.sheets.len()).filter(|i| *i != active));
@@ -634,7 +634,7 @@ fn insert_pivot(s: &mut Session, p: &Json) -> Result<Json> {
return Err(other("The workbook is protected and cannot be changed."));
}
let source = source_param(s, p)?;
let active = s.doc()?.wb.active_sheet;
let active = s.doc()?.wb.active_sheet();
// Validate the source now (header names, size).
let src = pv::read_source(&s.doc()?.wb, &source, active).map_err(other)?;
let name = match str_param(p, "name") {
@@ -713,7 +713,7 @@ fn insert_pivot(s: &mut Session, p: &Json) -> Result<Json> {
sh.pivots.push(pt);
let pi = sh.pivots.len() - 1;
let area = pv::refresh(&mut cx.wb, si, pi, replace).map_err(other)?;
cx.wb.active_sheet = si;
cx.wb.set_active_sheet(si);
*cx.sel = crate::Selection::at(anchor);
cx.structural = true;
let sheet_name = cx.wb.sheet(si).map(|s| s.name.clone()).unwrap_or_default();
@@ -738,7 +738,7 @@ fn insert_pivot(s: &mut Session, p: &Json) -> Result<Json> {
/// Inserts a new, empty sheet before the active one (Excel's place for a new PivotTable or a
/// Show Details sheet); returns its index.
fn insert_sheet_before_active(cx: &mut Ctx) -> usize {
let at = cx.wb.active_sheet.min(cx.wb.sheets.len());
let at = cx.wb.active_sheet().min(cx.wb.sheets.len());
let nm = cx.wb.next_sheet_name();
cx.wb.sheets.insert(at, Arc::new(Sheet::new(nm)));
for n in cx.wb.names.iter_mut() {
@@ -756,7 +756,7 @@ fn insert_sheet_before_active(cx: &mut Ctx) -> usize {
fn show_details(s: &mut Session, p: &Json) -> Result<Json> {
let d = s.doc()?;
let at = cell_param(s, p, "cell")?.unwrap_or(d.selection.active);
let active = d.wb.active_sheet;
let active = d.wb.active_sheet();
let sh = d.wb.active().ok_or(EngineError::NoDocument)?;
let pt = sh
.pivots
@@ -825,7 +825,7 @@ fn show_details(s: &mut Session, p: &Json) -> Result<Json> {
}
}
let name = sh.name.clone();
cx.wb.active_sheet = si;
cx.wb.set_active_sheet(si);
*cx.sel = crate::Selection::at(CellRef::new(0, 0));
cx.structural = true;
Ok(json!({"sheet": name, "records": count}))
@@ -1534,7 +1534,7 @@ fn chart_pivot(s: &Session, p: &Json) -> Result<(usize, usize)> {
let sh = gridcraft_core::unquote_sheet(sh);
(d.wb.sheet_index(&sh), a)
}
None => (Some(d.wb.active_sheet), t),
None => (Some(d.wb.active_sheet()), t),
};
if let (Some(si), Some(c)) = (sheet, CellRef::parse(addr.trim().replace('$', "").as_str())) {
let hit = d.wb.sheet(si).and_then(|sh| sh.pivots.iter().position(|pt| pt.last_range.is_some_and(|r| r.contains(c)) || pt.anchor == c));
@@ -1668,7 +1668,7 @@ fn suggestions(wb: &Workbook, src: &pv::Source) -> Vec<Json> {
fn recommended(s: &mut Session, p: &Json) -> Result<Json> {
let source = source_param(s, p)?;
let active = s.doc()?.wb.active_sheet;
let active = s.doc()?.wb.active_sheet();
let src = pv::read_source(&s.doc()?.wb, &source, active).map_err(other)?;
let list = suggestions(&s.doc()?.wb, &src);
let Some(apply) = p.get("apply").and_then(Json::as_u64) else {
@@ -1751,7 +1751,7 @@ mod tests {
assert_eq!(r["fields"], json!(["Region", "Product", "Date", "Units", "Sales"]));
let d = s.doc().unwrap();
assert_eq!(d.wb.sheets.len(), 2);
assert_eq!(d.wb.active_sheet, 0, "new sheet goes before the current one");
assert_eq!(d.wb.active_sheet(), 0, "new sheet goes before the current one");
assert_eq!(d.wb.active().unwrap().name, "Sheet2");
assert!(text(&s, "A3").starts_with("PivotTable1"));
assert_eq!(pt(&s).source, "Sheet1!$A$1:$E$13");
@@ -2132,7 +2132,7 @@ mod tests {
}
fn plotted(s: &Session) -> Vec<Vec<Option<f64>>> {
let d = s.doc().unwrap();
let data = gridcraft_chart::resolve(&d.wb, d.wb.active_sheet, &pivot_chart(s));
let data = gridcraft_chart::resolve(&d.wb, d.wb.active_sheet(), &pivot_chart(s));
data.series.into_iter().map(|x| x.values).collect()
}
@@ -2530,7 +2530,7 @@ mod tests {
assert_eq!(r["records"], 2);
let d = s.doc().unwrap();
assert_eq!(d.wb.sheets.len(), 3);
assert_eq!(d.wb.active_sheet, 0, "the new sheet goes before the PivotTable's");
assert_eq!(d.wb.active_sheet(), 0, "the new sheet goes before the PivotTable's");
assert_eq!(d.wb.active().unwrap().name, "Sheet3");
assert_eq!(
sheet_rows(&mut s),
@@ -2670,7 +2670,7 @@ mod tests {
s.execute("pivot.moveField", json!({"field": "Product", "from": "columns", "to": "rows"})).unwrap();
}
let d = s.doc().unwrap();
let si = d.wb.active_sheet;
let si = d.wb.active_sheet();
let pt = &d.wb.active().unwrap().pivots[0];
let r = pt.last_range.unwrap();
let cells = crate::pivot::detail_cells(&d.wb, pt, si).unwrap();
@@ -2688,6 +2688,6 @@ mod tests {
s.execute("range.setValues", json!({"range": "Sheet1!A2", "values": [["Central"]]})).unwrap();
let d = s.doc().unwrap();
let pt = &d.wb.active().unwrap().pivots[0];
assert!(crate::pivot::detail_cells(&d.wb, pt, d.wb.active_sheet).unwrap_err().contains("out of date"));
assert!(crate::pivot::detail_cells(&d.wb, pt, d.wb.active_sheet()).unwrap_err().contains("out of date"));
}
}
+3 -3
View File
@@ -388,13 +388,13 @@ pub fn layouts(wb: &Workbook, opts: &PrintOptions) -> Vec<SheetLayout> {
let sheets: Vec<usize> = if opts.all_sheets {
(0..wb.sheets.len()).filter(|i| wb.sheet(*i).is_some_and(|s| s.visibility == Visibility::Visible)).collect()
} else {
vec![wb.active_sheet]
vec![wb.active_sheet()]
};
for si in sheets {
if pages >= MAX_PAGES {
break;
}
let ovr = if si == wb.active_sheet { opts.range } else { None };
let ovr = if si == wb.active_sheet() { opts.range } else { None };
if opts.all_sheets && ovr.is_none() && wb.sheet(si).is_some_and(|s| print_range(s).is_none()) {
continue;
}
@@ -405,7 +405,7 @@ pub fn layouts(wb: &Workbook, opts: &PrintOptions) -> Vec<SheetLayout> {
}
}
if out.is_empty() && opts.all_sheets {
out.extend(layout_sheet(wb, wb.active_sheet, opts, None));
out.extend(layout_sheet(wb, wb.active_sheet(), opts, None));
}
out
}
+3 -3
View File
@@ -90,7 +90,7 @@ fn has_comments(s: &Session) -> std::result::Result<(), String> {
fn step_comment(s: &mut Session, forward: bool) -> Result<Json> {
let list = comment_cells(s);
let d = s.doc()?;
let here = (d.wb.active_sheet, d.selection.active);
let here = (d.wb.active_sheet(), d.selection.active);
let target = if forward {
list.iter().find(|c| **c > here).or_else(|| list.first())
} else {
@@ -374,7 +374,7 @@ mod tests {
fn at(s: &Session) -> (usize, String) {
let d = s.doc().unwrap();
(d.wb.active_sheet, d.selection.active.a1())
(d.wb.active_sheet(), d.selection.active.a1())
}
#[test]
@@ -443,7 +443,7 @@ mod tests {
);
let prot: SheetProtection = serde_json::from_str(&old).unwrap();
assert_eq!((prot.legacy_password_hash, prot.password.is_none()), (Some(password_hash("old")), true));
let i = s.doc().unwrap().wb.active_sheet;
let i = s.doc().unwrap().wb.active_sheet();
let wb = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb);
std::sync::Arc::make_mut(&mut wb.sheets[i]).protection = Some(prot);
assert!(s.execute("review.unprotectSheet", json!({"password": "new"})).is_err());
+10 -10
View File
@@ -707,7 +707,7 @@ fn insert_sheet(s: &mut Session, p: &Json) -> Result<Json> {
let before = p.get("before").and_then(Json::as_u64).map(|v| v as usize);
let idx = edit_doc(d, |cx| {
// Excel inserts before the active sheet; the "+" button appends after it.
let at = before.unwrap_or(cx.wb.active_sheet + 1).min(cx.wb.sheets.len());
let at = before.unwrap_or(cx.wb.active_sheet() + 1).min(cx.wb.sheets.len());
cx.wb.sheets.insert(at, Arc::new(Sheet::new(name.clone())));
// Names scoped to later sheets shift.
for n in cx.wb.names.iter_mut() {
@@ -717,7 +717,7 @@ fn insert_sheet(s: &mut Session, p: &Json) -> Result<Json> {
*sc += 1;
}
}
cx.wb.active_sheet = at;
cx.wb.set_active_sheet(at);
*cx.sel = crate::Selection::default();
cx.structural = true;
Ok(at)
@@ -805,8 +805,8 @@ fn delete_sheet(s: &mut Session, p: &Json) -> Result<Json> {
}
}
}
if cx.wb.active_sheet >= cx.wb.sheets.len() || cx.wb.active_sheet > i {
cx.wb.active_sheet = cx.wb.active_sheet.saturating_sub(1).min(cx.wb.sheets.len().saturating_sub(1));
if cx.wb.active_sheet() >= cx.wb.sheets.len() || cx.wb.active_sheet() > i {
cx.wb.set_active_sheet(cx.wb.active_sheet().saturating_sub(1).min(cx.wb.sheets.len().saturating_sub(1)));
}
*cx.sel = crate::Selection::default();
cx.structural = true;
@@ -920,12 +920,12 @@ fn move_sheet(s: &mut Session, p: &Json) -> Result<Json> {
}
cx.wb.names.extend(copies);
cx.wb.sheets.insert(at, Arc::new(sh));
cx.wb.active_sheet = at;
cx.wb.set_active_sheet(at);
} else {
let sh = cx.wb.sheets.remove(i);
let at = to.min(n - 1);
cx.wb.sheets.insert(at, sh);
cx.wb.active_sheet = at;
cx.wb.set_active_sheet(at);
// Sheet-level names follow their sheet.
for nm in cx.wb.names.iter_mut() {
if let Some(sc) = nm.scope.as_mut() {
@@ -962,8 +962,8 @@ fn hide_sheet(s: &mut Session, p: &Json) -> Result<Json> {
}
edit(s, |cx| {
cx.sheet_mut(i)?.visibility = Visibility::Hidden;
if cx.wb.active_sheet == i {
cx.wb.active_sheet = cx.wb.sheets.iter().position(|s| s.visibility == Visibility::Visible).unwrap_or(0);
if cx.wb.active_sheet() == i {
cx.wb.set_active_sheet(cx.wb.sheets.iter().position(|s| s.visibility == Visibility::Visible).unwrap_or(0));
}
Ok(Json::Null)
})
@@ -984,7 +984,7 @@ fn unhide_sheet(s: &mut Session, p: &Json) -> Result<Json> {
let i = target_sheet(s, p)?;
edit(s, |cx| {
cx.sheet_mut(i)?.visibility = Visibility::Visible;
cx.wb.active_sheet = i;
cx.wb.set_active_sheet(i);
Ok(Json::Null)
})
}
@@ -998,7 +998,7 @@ fn activate(s: &mut Session, p: &Json) -> Result<Json> {
fn step_sheet(s: &mut Session, dir: isize) -> Result<Json> {
let d = s.doc()?;
let mut i = d.wb.active_sheet;
let mut i = d.wb.active_sheet();
while let Some(next) = i.checked_add_signed(dir) {
let Some(sheet) = d.wb.sheet(next) else { break };
if sheet.visibility == Visibility::Visible {
+3 -3
View File
@@ -165,7 +165,7 @@ fn slicer_toggle(s: &mut Session, p: &Json) -> Result<Json> {
let (idx, id) = find_slicer(s, p)?;
let value = str_param(p, "value").ok_or_else(|| bad("slicer.toggle", "missing `value`"))?.to_string();
let multi = bool_param(p, "multi").unwrap_or(false);
let sheet = s.doc()?.wb.active_sheet;
let sheet = s.doc()?.wb.active_sheet();
let (table, column, current) = {
let d = s.doc()?;
let sl = d.wb.active().and_then(|sh| sh.slicers.get(idx)).ok_or_else(|| bad("slicer.toggle", "no slicer"))?;
@@ -204,7 +204,7 @@ fn slicer_toggle(s: &mut Session, p: &Json) -> Result<Json> {
fn slicer_select_all(s: &mut Session, p: &Json) -> Result<Json> {
let (idx, id) = find_slicer(s, p)?;
let sheet = s.doc()?.wb.active_sheet;
let sheet = s.doc()?.wb.active_sheet();
edit(s, |cx| {
if let Some(sl) = cx.sheet_mut(sheet)?.slicers.get_mut(idx) {
sl.selected = None;
@@ -217,7 +217,7 @@ fn slicer_select_all(s: &mut Session, p: &Json) -> Result<Json> {
fn slicer_delete(s: &mut Session, p: &Json) -> Result<Json> {
let (idx, id) = find_slicer(s, p)?;
let sheet = s.doc()?.wb.active_sheet;
let sheet = s.doc()?.wb.active_sheet();
// Absolute column of the slicer, so its filter criterion can be removed too.
let abs_col = s.doc().ok().and_then(|d| {
let sh = d.wb.active()?;
+6 -6
View File
@@ -301,7 +301,7 @@ fn parse_model(s: &Session, p: &Json) -> Result<Model> {
let d = s.doc()?;
let wb = &d.wb;
let obj_text = str_param(p, "objective").or_else(|| str_param(p, "setObjective")).or_else(|| str_param(p, "set")).unwrap_or("");
let (sheet, obj) = cells_of(wb, obj_text, wb.active_sheet, 1).map_err(|_| EngineError::Other("The objective must be a single cell.".into()))?;
let (sheet, obj) = cells_of(wb, obj_text, wb.active_sheet(), 1).map_err(|_| EngineError::Other("The objective must be a single cell.".into()))?;
let objective = *obj.first().ok_or_else(|| EngineError::Other("The objective must be a single cell.".into()))?;
let sh = wb.sheet(sheet).ok_or(EngineError::NoDocument)?;
if sh.cell(objective).is_none_or(|c| c.formula.is_none()) {
@@ -1913,8 +1913,8 @@ fn write_reports(cx: &mut Ctx, at: usize, built: Vec<(String, Lines)>) -> Vec<St
*sc += count;
}
}
if cx.wb.active_sheet >= at {
cx.wb.active_sheet += count;
if cx.wb.active_sheet() >= at {
cx.wb.set_active_sheet(cx.wb.active_sheet().saturating_add(count));
}
cx.structural = true;
names
@@ -1939,7 +1939,7 @@ fn report(s: &mut Session, p: &Json) -> Result<Json> {
let params = match p.get("model") {
Some(m @ Json::Object(_)) => m.clone(),
Some(Json::Null) | None => {
let mut params = saved_model(&d.wb, d.wb.active_sheet).unwrap_or_else(|| json!({}));
let mut params = saved_model(&d.wb, d.wb.active_sheet()).unwrap_or_else(|| json!({}));
if let (Some(base), Some(over)) = (params.as_object_mut(), p.as_object()) {
for (k, v) in over {
base.insert(k.clone(), v.clone());
@@ -1988,7 +1988,7 @@ fn report(s: &mut Session, p: &Json) -> Result<Json> {
fn run(s: &mut Session, p: &Json) -> Result<Json> {
let d = s.doc()?;
let active = d.wb.active_sheet;
let active = d.wb.active_sheet();
let mut params = p.clone();
if bool_param(p, "saved").unwrap_or(false) {
let saved = saved_model(&d.wb, active).ok_or_else(|| EngineError::Other("This sheet has no saved Solver model.".into()))?;
@@ -2145,7 +2145,7 @@ mod tests {
/// The first saved-model name scoped to the active sheet.
fn saved(s: &Session) -> String {
let d = s.doc().unwrap();
d.wb.names.iter().find(|n| n.scope == Some(d.wb.active_sheet)).map(|n| n.name.clone()).unwrap_or_default()
d.wb.names.iter().find(|n| n.scope == Some(d.wb.active_sheet())).map(|n| n.name.clone()).unwrap_or_default()
}
fn set(s: &mut Session, cells: &[(&str, &str)]) {
for (c, v) in cells {
+2 -2
View File
@@ -149,7 +149,7 @@ fn freeze_panes(s: &mut Session, p: &Json) -> Result<Json> {
}
let at = match cell_param_on(s, p, "cell", si)? {
Some(c) => c,
None if si == d.wb.active_sheet => d.selection.active,
None if si == d.wb.active_sheet() => d.selection.active,
// The selection belongs to the active sheet.
None => return Err(bad("view.freezePanes", "missing `cell`: the selection is on the active sheet")),
};
@@ -225,7 +225,7 @@ fn zoom(s: &mut Session, p: &Json) -> Result<Json> {
let d = s.doc_mut()?;
let dirty = d.is_dirty();
let wb = std::sync::Arc::make_mut(&mut d.wb);
let si = wb.active_sheet;
let si = wb.active_sheet();
if let Some(sh) = wb.sheet_mut(si) {
sh.zoom = z;
}
+7 -7
View File
@@ -101,7 +101,7 @@ fn cell_arg(s: &Session, p: &Json, key: &str, cmd: &str) -> Result<(usize, CellR
let d = s.doc()?;
let t = str_param(p, key).ok_or_else(|| bad(cmd, format!("missing `{key}`")))?;
let (sh, body) = split_sheet(t);
let si = sheet_named(&d.wb, sh, d.wb.active_sheet, cmd)?;
let si = sheet_named(&d.wb, sh, d.wb.active_sheet(), cmd)?;
let c = CellRef::parse(body.trim()).ok_or_else(|| bad(cmd, format!("`{key}` is not a cell: `{t}`")))?;
Ok((si, c))
}
@@ -110,7 +110,7 @@ fn cell_arg(s: &Session, p: &Json, key: &str, cmd: &str) -> Result<(usize, CellR
/// are trimmed to the used range.
pub(crate) fn parse_range(wb: &Workbook, t: &str, cmd: &str) -> Result<(usize, RangeRef)> {
let (sh, body) = split_sheet(t);
let si = sheet_named(wb, sh, wb.active_sheet, cmd)?;
let si = sheet_named(wb, sh, wb.active_sheet(), cmd)?;
let r = RangeRef::parse(body.trim()).ok_or_else(|| bad(cmd, format!("not a range: `{t}`")))?;
Ok((si, trim_to_used(wb, si, r)))
}
@@ -152,7 +152,7 @@ fn cell_list(wb: &Workbook, t: &str, cmd: &str, cap: usize) -> Result<(usize, Ve
return Err(bad(cmd, format!("too many cells (at most {cap})")));
}
}
Ok((sheet.unwrap_or(wb.active_sheet), out))
Ok((sheet.unwrap_or(wb.active_sheet()), out))
}
/// What a JSON value typed into a cell is, as a constant.
@@ -525,7 +525,7 @@ fn scenario_delete(s: &mut Session, p: &Json) -> Result<Json> {
fn scenario_summary(s: &mut Session, p: &Json) -> Result<Json> {
const CMD: &str = "data.scenarioSummary";
let d = s.doc()?;
let si = d.wb.active_sheet;
let si = d.wb.active_sheet();
let sh = d.wb.sheet(si).ok_or(EngineError::NoDocument)?;
let scenarios: Vec<Scenario> = load_scenarios(&d.wb).into_iter().filter(|x| x.sheet.eq_ignore_ascii_case(&sh.name)).collect();
if scenarios.is_empty() {
@@ -799,7 +799,7 @@ fn advanced_filter(s: &mut Session, p: &Json) -> Result<Json> {
Some(t) => parse_range(&d.wb, t, CMD)?,
None => {
let sh = d.wb.active().ok_or(EngineError::NoDocument)?;
(d.wb.active_sheet, crate::selection::current_region(sh, d.selection.active))
(d.wb.active_sheet(), crate::selection::current_region(sh, d.selection.active))
}
};
let criteria = str_param(p, "criteria").filter(|t| !t.trim().is_empty()).map(|t| parse_range(&d.wb, t, CMD)).transpose()?;
@@ -1024,7 +1024,7 @@ fn consolidate(s: &mut Session, p: &Json) -> Result<Json> {
let (si, r) = parse_range(&d.wb, t, CMD)?;
(si, r.start)
}
None => (d.wb.active_sheet, d.selection.active),
None => (d.wb.active_sheet(), d.selection.active),
};
let mut row_keys: Vec<Key> = Vec::new();
let mut col_keys: Vec<Key> = Vec::new();
@@ -1146,7 +1146,7 @@ mod tests {
fn v(s: &Session, a: &str) -> Value {
let d = s.doc().unwrap();
let (sh, body) = super::split_sheet(a);
let si = sh.map(|n| d.wb.sheet_index(&n).unwrap()).unwrap_or(d.wb.active_sheet);
let si = sh.map(|n| d.wb.sheet_index(&n).unwrap()).unwrap_or(d.wb.active_sheet());
d.wb.sheet(si).unwrap().value(CellRef::parse(body).unwrap())
}
fn n(s: &Session, a: &str) -> f64 {
+1 -1
View File
@@ -234,7 +234,7 @@ pub fn save_bytes(wb: &Workbook, path: &str) -> Result<Vec<u8>> {
/// Encodes a workbook as `kind`, whatever `path`'s extension says.
pub fn save_bytes_as(wb: &Workbook, path: &str, kind: FileKind) -> Result<Vec<u8>> {
let sheet = wb.active_sheet;
let sheet = wb.active_sheet();
match kind {
// The extension picks the package kind; only the macro-enabled kinds (.xlsm, .xltm) carry
// the VBA project, so any other name saves without it, as Excel does.
+3 -3
View File
@@ -310,14 +310,14 @@ impl DocState {
!Arc::ptr_eq(&self.wb, &self.saved)
}
pub fn sheet_index(&self) -> usize {
self.wb.active_sheet
self.wb.active_sheet()
}
/// Records the view state that is saved with the file (`sheetView topLeftCell`, the active
/// cell): the first visible cell of the scrolling area per sheet index in `tops`, and the
/// active sheet's active cell. View state never makes the workbook dirty.
pub fn store_view(&mut self, tops: &[(usize, CellRef)]) {
let active = self.selection.active;
let si = self.wb.active_sheet;
let si = self.wb.active_sheet();
let changed = tops.iter().any(|(i, c)| self.wb.sheets.get(*i).is_some_and(|sh| sh.view_top_left != *c))
|| self.wb.sheets.get(si).is_some_and(|sh| sh.view_active != active);
if !changed {
@@ -623,7 +623,7 @@ impl Session {
};
// The recorder keeps top-level calls with the place they ran in.
let place = (self.depth == 0 && self.recorder.is_some()).then(|| match self.active() {
Some(d) => (d.uid, d.wb.active_sheet, d.wb.active().map(|sh| sh.name.clone()).unwrap_or_default(), d.selection.clone()),
Some(d) => (d.uid, d.wb.active_sheet(), d.wb.active().map(|sh| sh.name.clone()).unwrap_or_default(), d.selection.clone()),
None => (0, 0, String::new(), Selection::default()),
});
let doc_uid = before.as_ref().map(|(uid, ..)| *uid);
+1 -1
View File
@@ -37,7 +37,7 @@ pub fn build(name: &str) -> Option<Workbook> {
return None;
}
let mut wb = (*s.doc().ok()?.wb).clone();
wb.active_sheet = 0;
wb.set_active_sheet(0);
if let Some(sh) = wb.sheet_mut(0) {
sh.view_active = gridcraft_core::CellRef::default();
}
+71 -6
View File
@@ -193,7 +193,7 @@ fn chart_category_axis_from_blank_corner_and_select_data() {
assert_eq!(series(&s), [(Some("Sheet1!$D$2:$D$4".to_string()), "Sheet1!$E$2:$E$4".to_string())]);
let d = s.doc().unwrap();
let sh = d.wb.active().unwrap();
let data = gridcraft_chart::resolve(&d.wb, d.wb.active_sheet, sh.charts.last().unwrap());
let data = gridcraft_chart::resolve(&d.wb, d.wb.active_sheet(), sh.charts.last().unwrap());
assert_eq!(data.categories, ["2020", "2021", "2022"]);
// The last series can't be removed; "" clears the labels.
s.execute("chart.selectData", json!({"categories": "", "removeSeries": [0]})).unwrap();
@@ -1818,7 +1818,7 @@ fn slicer_finds_a_named_table_on_another_sheet() {
s.execute("insert.table", json!({"range": "A1:B3", "name": "Sales"})).unwrap();
s.execute("home.insertSheet", json!({"name": "Report"})).unwrap();
let report = s.doc().unwrap().wb.sheet_index("Report").unwrap();
assert_eq!(s.doc().unwrap().wb.active_sheet, report);
assert_eq!(s.doc().unwrap().wb.active_sheet(), report);
// With another sheet active, `table` still names the table, and the slicer goes on the table's sheet.
let r = s.execute("insert.slicer", json!({"table": "Sales", "column": "Region", "at": "D2"})).unwrap();
@@ -2944,7 +2944,7 @@ fn error_checking_quotes_sheet_names_in_circular_references() {
assert_eq!(v(&s, "A1"), Value::Number(2.0));
s.execute("edit.goTo", json!({"reference": reference})).unwrap();
let d = s.doc().unwrap();
assert_eq!((d.wb.active_sheet, d.selection.active.a1()), (1, "B2".to_string()));
assert_eq!((d.wb.active_sheet(), d.selection.active.a1()), (1, "B2".to_string()));
}
/// Undo and redo put cells back without calculating them; the circular references listed are
@@ -4209,7 +4209,7 @@ fn line_sizes_and_freeze_panes_take_a_sheet() {
assert!(data.cols.get(&4).is_some_and(|c| c.hidden));
assert_eq!(data.default_col_width, 80.0);
assert_eq!(sheet(&s, 0), before, "the active sheet is untouched");
assert_eq!(s.doc().unwrap().wb.active_sheet, 0);
assert_eq!(s.doc().unwrap().wb.active_sheet(), 0);
s.execute("view.freezePanes", json!({"cell": "Data!B3"})).unwrap();
assert_eq!(sheet(&s, 1).freeze, Some((2, 1)));
@@ -4916,11 +4916,11 @@ fn switching_windows_swaps_their_views_without_dirtying_the_workbook() {
s.execute("window.activate", json!({"window": first})).unwrap();
let d = s.doc().unwrap();
assert_eq!((d.wb.active_sheet, d.selection.a1()), (0, "B2:C3".to_string()));
assert_eq!((d.wb.active_sheet(), d.selection.a1()), (0, "B2:C3".to_string()));
assert!(!d.is_dirty(), "a window's sheet and selection are not document changes");
s.execute("view.switchWindows", json!({"window": second})).unwrap();
let d = s.doc().unwrap();
assert_eq!((d.wb.active_sheet, d.selection.a1()), (1, "D4".to_string()));
assert_eq!((d.wb.active_sheet(), d.selection.a1()), (1, "D4".to_string()));
assert!(!d.is_dirty());
// A routed command acts in its window and leaves the active one showing.
@@ -4930,6 +4930,71 @@ fn switching_windows_swaps_their_views_without_dirtying_the_workbook() {
assert_eq!(s.doc().unwrap().selection.a1(), "D4");
}
#[test]
fn window_swaps_share_the_workbook_and_save_records_the_latest_window_sheet() {
let mut s = s();
s.execute("home.insertSheet", json!({})).unwrap();
let path = std::env::temp_dir().join(format!("gridcraft-window-arc-{}.xlsx", std::process::id()));
s.execute("file.save", json!({"path": path})).unwrap();
s.execute("sheet.activate", json!({"sheet": 0})).unwrap();
s.execute("selection.set", json!({"range": "B2"})).unwrap();
let first = s.active_window().unwrap();
let second = s.execute("view.newWindow", json!({})).unwrap()["window"].as_u64().unwrap();
s.execute("sheet.activate", json!({"sheet": 1})).unwrap();
s.execute("selection.set", json!({"range": "C3:D4"})).unwrap();
// Swapping windows, as the interface does before drawing each one, never copies the workbook.
let before = s.doc().unwrap().wb.clone();
for _ in 0..3 {
s.show_window(first);
assert_eq!((s.doc().unwrap().wb.active_sheet(), s.doc().unwrap().selection.a1()), (0, "B2".to_string()));
s.activate_window(second);
assert_eq!((s.doc().unwrap().wb.active_sheet(), s.doc().unwrap().selection.a1()), (1, "C3:D4".to_string()));
s.execute("sheet.activate", json!({"sheet": 0})).unwrap();
s.execute("sheet.activate", json!({"sheet": 1})).unwrap();
}
let d = s.doc().unwrap();
assert!(std::sync::Arc::ptr_eq(&before, &d.wb), "a window swap or sheet switch copied the workbook");
assert!(!d.is_dirty());
// A command run in window 2 edits window 2's sheet at its selection.
s.execute("cell.set", json!({"input": "two"})).unwrap();
let d = s.doc().unwrap();
assert_eq!(d.wb.sheets[1].value(CellRef::parse("C3").unwrap()), Value::text("two"));
assert_eq!(d.wb.sheets[0].value(CellRef::parse("B2").unwrap()), Value::Empty);
s.show_window(first);
assert_eq!(s.doc().unwrap().wb.active_sheet(), 0);
// The most recently active window is window 2: the file records its sheet.
s.execute_in(crate::Target::Window(second), "file.save", json!({"path": path})).unwrap();
let mut t = Session::new();
t.execute("file.open", json!({"path": path})).unwrap();
let _ = std::fs::remove_file(&path);
assert_eq!(t.doc().unwrap().wb.active_sheet(), 1);
s.activate_window(first);
s.execute("file.save", json!({"path": path})).unwrap();
let mut t = Session::new();
t.execute("file.open", json!({"path": path})).unwrap();
let _ = std::fs::remove_file(&path);
assert_eq!(t.doc().unwrap().wb.active_sheet(), 0);
}
#[test]
fn arrange_all_picks_the_visible_windows_active_first() {
let mut s = s();
let book1 = s.active_window().unwrap();
let book1b = s.execute("view.newWindow", json!({})).unwrap()["window"].as_u64().unwrap();
s.execute("file.new", json!({})).unwrap();
let book2 = s.active_window().unwrap();
let r = s.execute("view.arrangeAll", json!({})).unwrap();
assert_eq!(r, json!({"arrange": "tiled", "windows": [book2, book1, book1b]}));
s.execute("window.activate", json!({"window": book1b})).unwrap();
s.execute("view.hideWindow", json!({"window": book2})).unwrap();
let r = s.execute("view.arrangeAll", json!({"arrange": "cascade", "activeWorkbook": true})).unwrap();
assert_eq!(r, json!({"arrange": "cascade", "windows": [book1b, book1]}));
assert!(s.execute("view.arrangeAll", json!({"arrange": "diagonal"})).is_err());
}
#[test]
fn hide_unhide_and_switch_by_window_id() {
let mut s = s();
+1 -1
View File
@@ -837,7 +837,7 @@ fn the_recorder_keeps_top_level_calls_with_where_they_ran() {
assert_eq!(recorded_ids(&s), ["cell.set", "view.customViews", "view.customViews"]);
let first = &s.recorder.as_ref().unwrap().steps[0];
let d = s.doc().unwrap();
assert_eq!((first.doc, first.sheet), (d.uid, d.wb.active_sheet));
assert_eq!((first.doc, first.sheet), (d.uid, d.wb.active_sheet()));
assert_eq!(first.sheet_name, d.wb.active().unwrap().name);
assert_eq!(first.selection.a1(), "B2:C3", "the selection before the command ran");
assert_eq!(first.params, json!({"cell": "B2", "input": "10"}));
+12 -18
View File
@@ -2,10 +2,11 @@
//!
//! Every open workbook has one or more windows (View › New Window adds another on the same
//! workbook). A window has its own selection, active sheet and per-sheet selections. The active
//! window's view lives in its [`DocState`] (`selection`, `wb.active_sheet`, `sheet_selections`),
//! where every command reads it; each other window keeps its view in [`WindowState`] until it is
//! activated. Swapping a view in or out never makes the workbook dirty: per-window view state is
//! not a document change.
//! window's view lives in its [`DocState`] (`selection`, `sheet_selections`) and in the workbook's
//! active sheet ([`gridcraft_model::workbook::Workbook::active_sheet`]), where every command reads
//! it; each other window keeps its view in [`WindowState`] until it is activated. Swapping a view
//! in or out never copies the workbook or makes it dirty: the active sheet is set in place through
//! the shared `Arc`, and selections live outside the workbook.
use serde_json::{Value as Json, json};
@@ -15,7 +16,7 @@ use crate::{DocState, EngineError, Result, Selection, Session};
#[derive(Clone, Debug, Default, PartialEq)]
pub struct WindowView {
pub selection: Selection,
pub active_sheet: usize,
pub sheet: usize,
/// The active sheet's name, so a sheet inserted, deleted or moved meanwhile in another
/// window still finds it.
pub sheet_name: String,
@@ -42,27 +43,20 @@ impl WindowView {
fn capture(d: &DocState) -> WindowView {
WindowView {
selection: d.selection.clone(),
active_sheet: d.wb.active_sheet,
sheet_name: d.wb.sheets.get(d.wb.active_sheet).map(|s| s.name.clone()).unwrap_or_default(),
sheet: d.wb.active_sheet(),
sheet_name: d.wb.sheets.get(d.wb.active_sheet()).map(|s| s.name.clone()).unwrap_or_default(),
sheet_selections: d.sheet_selections.clone(),
}
}
fn apply(self, d: &mut DocState) {
let sheets = &d.wb.sheets;
let at = if sheets.get(self.active_sheet).is_some_and(|s| s.name == self.sheet_name) {
self.active_sheet
let at = if sheets.get(self.sheet).is_some_and(|s| s.name == self.sheet_name) {
self.sheet
} else {
sheets.iter().position(|s| s.name == self.sheet_name).unwrap_or(self.active_sheet)
sheets.iter().position(|s| s.name == self.sheet_name).unwrap_or(self.sheet)
};
let at = at.min(sheets.len().saturating_sub(1));
if d.wb.active_sheet != at {
let dirty = d.is_dirty();
std::sync::Arc::make_mut(&mut d.wb).active_sheet = at;
if !dirty {
d.saved = d.wb.clone();
}
}
d.wb.set_active_sheet(at.min(sheets.len().saturating_sub(1)));
d.selection = self.selection;
d.sheet_selections = self.sheet_selections;
}
@@ -373,8 +373,8 @@ fn structure_problem(wb: &Workbook, sel: &gridcraft_engine::Selection) -> Option
if wb.sheets.is_empty() {
return Some("no sheets left".into());
}
if wb.active_sheet >= wb.sheets.len() {
return Some(format!("active sheet {} of {}", wb.active_sheet, wb.sheets.len()));
if wb.active_sheet() >= wb.sheets.len() {
return Some(format!("active sheet {} of {}", wb.active_sheet(), wb.sheets.len()));
}
let mut tables = BTreeSet::new();
for (i, sh) in wb.sheets.iter().enumerate() {
@@ -490,7 +490,7 @@ fn watch_list(wb: &Workbook) -> Option<&str> {
/// reset: undo restores view state as it was, but switching sheets isn't an undoable edit.
fn canonical(wb: &Workbook) -> Workbook {
let mut wb = wb.clone();
wb.active_sheet = 0;
wb.set_active_sheet(0);
wb.names.retain(|n| n.name != WATCH_NAME);
for sh in wb.sheets.iter_mut() {
let sh = Arc::make_mut(sh);
@@ -25,7 +25,7 @@ fn next_stays_on_the_last_visible_sheet() {
for _ in 0..2 {
session.execute("sheet.next", json!({})).unwrap();
let doc = session.active().unwrap();
assert_eq!(doc.wb.active_sheet, 1);
assert_eq!(doc.wb.active_sheet(), 1);
assert_eq!(doc.selection.active.a1(), "C7");
}
}
@@ -36,7 +36,7 @@ fn previous_stays_on_the_first_visible_sheet() {
for _ in 0..2 {
session.execute("sheet.previous", json!({})).unwrap();
let doc = session.active().unwrap();
assert_eq!(doc.wb.active_sheet, 2);
assert_eq!(doc.wb.active_sheet(), 2);
assert_eq!(doc.selection.active.a1(), "C7");
}
}
@@ -45,10 +45,10 @@ fn previous_stays_on_the_first_visible_sheet() {
fn navigation_skips_hidden_sheets_in_both_directions() {
let mut session = workbook(&[1, 2], 0);
session.execute("sheet.next", json!({})).unwrap();
assert_eq!(session.active().unwrap().wb.active_sheet, 3);
assert_eq!(session.active().unwrap().wb.active_sheet(), 3);
session.execute("sheet.previous", json!({})).unwrap();
let doc = session.active().unwrap();
assert_eq!(doc.wb.active_sheet, 0);
assert_eq!(doc.wb.active_sheet(), 0);
assert_eq!(doc.selection.active.a1(), "C7");
}
@@ -58,7 +58,7 @@ fn navigation_with_one_visible_sheet_is_a_noop() {
for command in ["sheet.previous", "sheet.next"] {
session.execute(command, json!({})).unwrap();
let doc = session.active().unwrap();
assert_eq!(doc.wb.active_sheet, 1);
assert_eq!(doc.wb.active_sheet(), 1);
assert_eq!(doc.selection.active.a1(), "C7");
}
}
@@ -73,6 +73,6 @@ fn very_hidden_sheets_are_skipped_at_interior_and_boundary_positions() {
}
for (command, expected) in [("sheet.next", 2), ("sheet.next", 2), ("sheet.previous", 0)] {
session.execute(command, json!({})).unwrap();
assert_eq!(session.active().unwrap().wb.active_sheet, expected);
assert_eq!(session.active().unwrap().wb.active_sheet(), expected);
}
}
+65 -3
View File
@@ -1,6 +1,7 @@
//! Workbooks.
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use gridcraft_core::DateSystem;
use gridcraft_formula::Expr;
@@ -147,7 +148,8 @@ pub struct Workbook {
pub calc: CalcSettings,
#[serde(default, skip_serializing_if = "is_all")]
pub show_objects: ShowObjects,
pub active_sheet: usize,
/// Read and set with [`Workbook::active_sheet`] and [`Workbook::set_active_sheet`].
pub active_sheet: ActiveSheet,
pub props: DocProps,
/// Workbook structure protection.
pub protected_structure: bool,
@@ -205,6 +207,57 @@ impl Default for Workbook {
}
}
/// The active sheet index of a [`Workbook`].
///
/// Every other part of a workbook value is immutable once shared (the engine keeps versions as
/// `Arc<Workbook>` and tells unsaved changes by pointer). The active sheet is the exception: it
/// is the view state of the window showing the workbook, and several windows on one workbook
/// swap their own sheet into it whenever one of them becomes the one commands act on. Keeping it
/// in an atomic lets that swap happen through the shared `Arc` without cloning the workbook.
/// Copies (undo versions, a cloned workbook) take the value at the time of the copy.
#[derive(Default, Serialize, Deserialize)]
#[serde(from = "usize", into = "usize")]
pub struct ActiveSheet(AtomicUsize);
impl ActiveSheet {
pub fn get(&self) -> usize {
self.0.load(Ordering::Relaxed)
}
pub fn set(&self, i: usize) {
self.0.store(i, Ordering::Relaxed);
}
}
impl From<usize> for ActiveSheet {
fn from(i: usize) -> ActiveSheet {
ActiveSheet(AtomicUsize::new(i))
}
}
impl From<ActiveSheet> for usize {
fn from(a: ActiveSheet) -> usize {
a.get()
}
}
impl Clone for ActiveSheet {
fn clone(&self) -> ActiveSheet {
ActiveSheet::from(self.get())
}
}
impl PartialEq for ActiveSheet {
fn eq(&self, other: &ActiveSheet) -> bool {
self.get() == other.get()
}
}
impl std::fmt::Debug for ActiveSheet {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.get().fmt(f)
}
}
impl Workbook {
/// A new workbook with one sheet, "Sheet1".
pub fn new() -> Workbook {
@@ -216,7 +269,7 @@ impl Workbook {
date_system: DateSystem::D1900,
calc: CalcSettings::default(),
show_objects: ShowObjects::All,
active_sheet: 0,
active_sheet: ActiveSheet::default(),
props: DocProps::default(),
protected_structure: false,
structure_password: None,
@@ -239,8 +292,17 @@ impl Workbook {
pub fn sheet_index(&self, name: &str) -> Option<usize> {
self.sheets.iter().position(|s| s.name.eq_ignore_ascii_case(name))
}
/// The index of the sheet the workbook's window shows (the file's active tab).
pub fn active_sheet(&self) -> usize {
self.active_sheet.get()
}
/// Changes the active sheet in place, also through a shared `Arc<Workbook>`: it is view state,
/// so switching sheets or windows neither copies the workbook nor makes it a new version.
pub fn set_active_sheet(&self, i: usize) {
self.active_sheet.set(i);
}
pub fn active(&self) -> Option<&Sheet> {
self.sheet(self.active_sheet)
self.sheet(self.active_sheet())
}
/// A fresh sheet name `SheetN` not in use.
pub fn next_sheet_name(&self) -> String {
+1 -1
View File
@@ -122,7 +122,7 @@ fn main() {
app.open_dialog(name, p);
}
// Page Layout view: edit the first page's centre header (or footer) section.
let active = app.session.active().and_then(|d| d.wb.active().cloned().map(|s| (d.wb.active_sheet, s)));
let active = app.session.active().and_then(|d| d.wb.active().cloned().map(|s| (d.wb.active_sheet(), s)));
if let (Some(part), Some((si, sh))) = (&hf_edit, active) {
let zone = gridcraft_engine::paper::HfZone { footer: part == "footer", section: 1 };
gridcraft_ui_egui::paper_view::begin(&mut app, si, &sh, (0, 0), zone);
+2 -1
View File
@@ -711,9 +711,10 @@ pub fn handle(app: &mut SheetApp, ctx: &egui::Context, method: &str, p: &Json) -
Some(d) => match d.command {
Some(cmd) => {
let params = crate::dialogs::build_params(&d);
let home = app.ui_window();
let r = app.run(cmd, params);
if r.is_err() {
app.dialog = Some(d);
app.return_dialog(home, d);
}
wrap(r)
}
+28 -4
View File
@@ -199,6 +199,20 @@ impl Dialog {
}],
json!({"shift": "down"}),
),
"arrangeAll" => Dialog::form(
"arrangeAll",
"Arrange Windows",
"view.arrangeAll",
vec![
Choice {
key: "arrange",
label: "Arrange",
options: vec![("tiled", "Tiled"), ("horizontal", "Horizontal"), ("vertical", "Vertical"), ("cascade", "Cascade")],
},
Check { key: "activeWorkbook", label: "Windows of active workbook" },
],
json!({"arrange": "tiled", "activeWorkbook": false}),
),
"insertPaste" => Dialog::form(
"insertPaste",
"Insert Paste",
@@ -605,7 +619,7 @@ impl Dialog {
}
"solver" => {
// Starts from the sheet's saved model, else the active cell as objective.
let saved = app.session.active().and_then(|doc| gridcraft_engine::cmd::solver::saved_model(&doc.wb, doc.wb.active_sheet));
let saved = app.session.active().and_then(|doc| gridcraft_engine::cmd::solver::saved_model(&doc.wb, doc.wb.active_sheet()));
let mut values = json!({"objective": active, "goal": "max", "value": "0", "variables": "", "constraints": [], "nonNegative": true, "method": "grg"});
if let (Some(m), Some(v)) = (saved.as_ref().and_then(Json::as_object), values.as_object_mut()) {
for (k, x) in m {
@@ -886,7 +900,7 @@ impl Dialog {
// Chart Design › Select Data: data range, series to keep, category axis labels.
let id = p("chart").as_u64();
let found = app.session.active().and_then(|doc| {
let si = doc.wb.active_sheet;
let si = doc.wb.active_sheet();
let sh = doc.wb.sheet(si)?;
let ch = sh.charts.iter().find(|c| Some(u64::from(c.id)) == id)?;
let data = gridcraft_chart::resolve(&doc.wb, si, ch);
@@ -1254,6 +1268,7 @@ pub(crate) fn build_params(d: &Dialog) -> Json {
/// Takes and draws the active dialog, dispatches its body by name, consumes picture-inbox results, runs OK/confirm commands, handles Esc/close and restores the back-dialog stack.
pub fn show(app: &mut SheetApp, ctx: &egui::Context) {
let Some(mut d) = app.dialog.take() else { return };
let home = app.ui_window();
if d.name == "pasteSpecial" {
paste_special_shortcuts(&mut d, ctx);
}
@@ -1531,6 +1546,15 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) {
} else if confirm {
open = false;
}
if app.ui_window() != home {
// A command run from the dialog switched windows: whatever it opened shows in the new
// window, and this dialog (or the one it returns to) stays in its own.
let keep = if open { Some(d) } else { d.back.take().map(|b| *b) };
if let Some(keep) = keep {
app.return_dialog(home, keep);
}
return;
}
if let Some(child) = app.dialog.as_mut() {
// The body opened another dialog (Name Manager › New…). It takes over; this one waits
// behind it and comes back when it closes, as Excel returns to the Name Manager. Go To
@@ -2186,7 +2210,7 @@ fn insert_function(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confir
let mut open = true;
ok_cancel(ui, confirm, &mut open);
if *confirm && let Some(n) = hits.get(d.list_index).and_then(|f| f["name"].as_str()) {
let n = app.session.active().map(|doc| crate::formula_locale::completion_name(n, locale, &doc.wb, doc.wb.active_sheet)).unwrap_or(n);
let n = app.session.active().map(|doc| crate::formula_locale::completion_name(n, locale, &doc.wb, doc.wb.active_sheet())).unwrap_or(n);
let cur = app.editor.as_ref().map(|e| e.text.clone());
let text = match cur {
Some(t) if t.starts_with('=') => format!("{t}{n}("),
@@ -2350,7 +2374,7 @@ fn go_to_entries(app: &SheetApp) -> Vec<String> {
app.session
.active()
.map(|doc| {
let mut v = gridcraft_engine::cmd::edit::go_to_names(&doc.wb, doc.wb.active_sheet);
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
})
+73 -1
View File
@@ -18,7 +18,7 @@
//!
//! [`Session::windows`]: gridcraft_engine::Session::windows
use craft_window::{Frame, Viewports, WindowEvent, WindowId, WindowList};
use craft_window::{CASCADE_STEP, Frame, Rect, Viewports, WindowEvent, WindowId, WindowList, cascade};
use serde_json::json;
use crate::SheetApp;
@@ -79,6 +79,33 @@ impl DocWindows {
/// the logic then): shows windows created or opened by menu commands, files and scripts.
pub fn logic(&mut self, app: &mut SheetApp, ctx: &egui::Context) {
self.sync(app, ctx);
if let Some((arrange, windows)) = app.arrange_windows.take() {
self.arrange(ctx, &arrange, &windows);
}
}
/// View › Arrange All: moves and sizes the native windows of `windows` (engine ids, the first
/// on top or at the top left) over the root window's monitor.
fn arrange(&self, ctx: &egui::Context, arrange: &str, windows: &[u64]) {
let ids: Vec<WindowId> = windows.iter().filter_map(|w| self.find(*w)).collect();
let (monitor, infos) = ctx.input(|i| (i.viewport().monitor_size, i.raw.viewports.clone()));
// Platforms that don't report the monitor (Wayland, tests) get a common laptop screen.
let size = monitor.filter(|m| m.x > 0.0 && m.y > 0.0).unwrap_or(egui::vec2(1280.0, 800.0));
let mut rects = arrange_rects(arrange, ids.len(), Rect::new(0.0, 0.0, size.x, size.y));
if arrange == "cascade" {
// The active window goes in front: the last step of the cascade.
rects.reverse();
}
for (id, r) in ids.iter().zip(rects) {
let vp = self.viewports.viewport_id(*id);
// Inner size is what can be set: take off the frame the window has now.
let frame = infos.get(&vp).and_then(|v| Some(v.outer_rect?.size() - v.inner_rect?.size())).unwrap_or(egui::Vec2::ZERO);
ctx.send_viewport_cmd_to(vp, egui::ViewportCommand::OuterPosition(egui::pos2(r.x, r.y)));
ctx.send_viewport_cmd_to(vp, egui::ViewportCommand::InnerSize((egui::vec2(r.width, r.height) - frame).max(egui::vec2(240.0, 160.0))));
}
if let Some(first) = ids.first() {
self.viewports.focus(ctx, *first);
}
}
/// Mirrors the engine's visible windows: closed or hidden windows lose their native window
@@ -223,3 +250,48 @@ fn window_title(app: &SheetApp, window: u64) -> String {
let dirty = app.session.window(window).and_then(|w| app.session.documents().iter().find(|d| d.uid == w.doc_uid)).is_some_and(|d| d.is_dirty());
if dirty { format!("{title} — Edited") } else { title }
}
/// Outer rectangles for `n` windows arranged within `bounds` as View › Arrange All's `arrange`
/// says: `tiled` (a grid, the last row's windows widened to fill it), `horizontal` (stacked top
/// to bottom), `vertical` (side by side) or `cascade` (each one title bar below and right of the
/// previous, wrapping at the edges).
pub fn arrange_rects(arrange: &str, n: usize, bounds: Rect) -> Vec<Rect> {
if n == 0 {
return vec![];
}
let count = n as f32;
match arrange {
"horizontal" => {
let h = bounds.height / count;
(0..n).map(|i| Rect::new(bounds.x, bounds.y + h * i as f32, bounds.width, h)).collect()
}
"vertical" => {
let w = bounds.width / count;
(0..n).map(|i| Rect::new(bounds.x + w * i as f32, bounds.y, w, bounds.height)).collect()
}
"cascade" => {
let shift = CASCADE_STEP * (count - 1.0);
let size = [(bounds.width - shift).max(bounds.width * 0.5), (bounds.height - shift).max(bounds.height * 0.5)];
let mut previous = Rect::new(bounds.x - CASCADE_STEP, bounds.y - CASCADE_STEP, size[0], size[1]);
(0..n)
.map(|_| {
previous = cascade(previous, size, bounds, CASCADE_STEP);
previous
})
.collect()
}
_ => {
let cols = (count.sqrt().ceil() as usize).max(1);
let rows = n.div_ceil(cols);
let h = bounds.height / rows as f32;
(0..n)
.map(|i| {
let (row, col) = (i / cols, i % cols);
let in_row = if row + 1 == rows { n - row * cols } else { cols };
let w = bounds.width / in_row as f32;
Rect::new(bounds.x + w * col as f32, bounds.y + h * row as f32, w, h)
})
.collect()
}
}
}
+4 -4
View File
@@ -120,7 +120,7 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) {
let galley = child.painter().layout(text, font.clone(), color, inner.width());
let lines = galley.rows.len();
// Each cell's view starts at its top: the offset is kept per cell, not per bar.
let shown = app.session.active().map(|d| (d.uid, d.wb.active_sheet, d.selection.active.row, d.selection.active.col));
let shown = app.session.active().map(|d| (d.uid, d.wb.active_sheet(), d.selection.active.row, d.selection.active.col));
egui::ScrollArea::vertical().id_salt(id.with(("view", shown))).max_height(inner.height()).auto_shrink([false, true]).show(
&mut child,
|ui| {
@@ -169,7 +169,7 @@ fn active_input(app: &SheetApp) -> String {
}
return sh
.cell(anchor)
.map(|c| crate::formula_locale::display(&c.input_text(), app.ui.language.formula_locale(), &d.wb, d.wb.active_sheet))
.map(|c| crate::formula_locale::display(&c.input_text(), app.ui.language.formula_locale(), &d.wb, d.wb.active_sheet()))
.unwrap_or_default();
}
if let Some(c) = sh.cell(a) {
@@ -177,7 +177,7 @@ fn active_input(app: &SheetApp) -> String {
return String::new();
}
return if let Some(f) = &c.formula {
let shown = crate::formula_locale::display(&c.input_text(), app.ui.language.formula_locale(), &d.wb, d.wb.active_sheet);
let shown = crate::formula_locale::display(&c.input_text(), app.ui.language.formula_locale(), &d.wb, d.wb.active_sheet());
// A legacy array formula shows in braces (`{=A1:A2*2}`), as `Cell::bar_text` does.
if f.array.is_some() { format!("{{{shown}}}") } else { shown }
} else {
@@ -218,7 +218,7 @@ fn name_box(app: &mut SheetApp, ui: &mut egui::Ui, t: &Tokens) -> Rect {
if !target.is_empty() && target != shown {
// A new name for the selection, or a place to go.
let is_ref = gridcraft_engine::core::RangeRef::parse(target.split('!').next_back().unwrap_or("")).is_some();
let known = app.session.active().is_some_and(|d| d.wb.name(&target, d.wb.active_sheet).is_some() || d.wb.table(&target).is_some());
let known = app.session.active().is_some_and(|d| d.wb.name(&target, d.wb.active_sheet()).is_some() || d.wb.table(&target).is_some());
let r = if is_ref || known {
app.run("edit.goTo", json!({"reference": target}))
} else {
+24 -23
View File
@@ -131,7 +131,7 @@ pub struct Trace {
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 })
app.session.active().map(|d| Trace { uid: d.uid, revision: d.revision, sheet: d.wb.active_sheet(), cell: d.selection.active, items })
}
}
@@ -614,7 +614,7 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) {
app.grid.rect = Some(rect);
let Some(d) = app.session.active() else { return };
let wb: Arc<Workbook> = d.wb.clone();
let si = wb.active_sheet;
let si = wb.active_sheet();
let Some(sh) = wb.sheet(si) else { return };
let sel = d.selection.clone();
let place = (d.uid, si);
@@ -1887,7 +1887,7 @@ pub(crate) fn paint_selection(
}
// Marching ants around the copied range.
if let Some(clip) = &app.session.clipboard
&& app.session.active().is_some_and(|d| d.uid == clip.doc_uid && d.wb.active_sheet == clip.sheet)
&& app.session.active().is_some_and(|d| d.uid == clip.doc_uid && d.wb.active_sheet() == clip.sheet)
&& range_in_pane(clip.range, rows, cols)
{
// A multi-area copy marks each area as selected.
@@ -2213,7 +2213,7 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &
if primary_clicked
&& !resp.drag_started()
&& let Some(p) = pos
&& let Some((id, act)) = crate::chartview::slicer_click(geo, sh, wb, wb.active_sheet, p)
&& let Some((id, act)) = crate::chartview::slicer_click(geo, sh, wb, wb.active_sheet(), p)
{
app.selected_chart = Some(id);
match act {
@@ -2423,13 +2423,13 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &
&& ed.can_point()
{
let anchor = if mods.shift && ed.point_at_caret() {
ed.point_anchor.filter(|(sheet, _)| *sheet == wb.active_sheet).map(|(_, cell)| cell).unwrap_or(c)
ed.point_anchor.filter(|(sheet, _)| *sheet == wb.active_sheet()).map(|(_, cell)| cell).unwrap_or(c)
} else {
c
};
let range = RangeRef::new(anchor, c);
let text = if range.is_single() { c.a1() } else { range.a1() };
let sheet_name = (ed.sheet != wb.active_sheet).then(|| sh.name.clone());
let sheet_name = (ed.sheet != wb.active_sheet()).then(|| sh.name.clone());
let r = match sheet_name {
Some(n) => format!("{}!{text}", gridcraft_engine::formula::quote_sheet(&n)),
None => text,
@@ -2441,7 +2441,7 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &
}
ed.request_focus = true;
ed.point_cell = Some(c);
ed.point_anchor = Some((wb.active_sheet, anchor));
ed.point_anchor = Some((wb.active_sheet(), anchor));
app.grid.drag = Drag::Select;
app.grid.drag_select = true;
if resp.drag_started()
@@ -2757,7 +2757,7 @@ fn format_painter_apply(app: &mut SheetApp) {
fn point_header(app: &mut SheetApp, wb: &Workbook, sh: &Sheet, range: RangeRef, extend: bool, add: bool) -> bool {
let Some(ed) = app.editor.as_mut().filter(|ed| ed.can_point()) else { return false };
let edit_sheet = ed.sheet;
let active_sheet = wb.active_sheet;
let active_sheet = wb.active_sheet();
let range = if extend {
ed.point
.and_then(|point| crate::editor::formula_refs(&ed.text).into_iter().find(|(start, end, ..)| (*start, *end) == point))
@@ -2779,7 +2779,8 @@ fn point_header(app: &mut SheetApp, wb: &Workbook, sh: &Sheet, range: RangeRef,
range
};
let reference = range.a1();
let reference = if ed.sheet != wb.active_sheet { format!("{}!{reference}", gridcraft_engine::formula::quote_sheet(&sh.name)) } else { reference };
let reference =
if ed.sheet != wb.active_sheet() { format!("{}!{reference}", gridcraft_engine::formula::quote_sheet(&sh.name)) } else { reference };
if add {
ed.append_ref(&reference);
} else {
@@ -2801,13 +2802,13 @@ fn select_to_pointer(app: &mut SheetApp, geo: &Geo, sh: &Sheet, wb: &Workbook, p
} else if let Some(ed) = app.editor.as_mut()
&& ed.point.is_some()
{
if let Some(start) = ed.point_anchor.filter(|(sheet, _)| *sheet == wb.active_sheet).map(|(_, cell)| cell).or(ed.point_cell) {
if let Some(start) = ed.point_anchor.filter(|(sheet, _)| *sheet == wb.active_sheet()).map(|(_, cell)| cell).or(ed.point_cell) {
let range = RangeRef::new(start, cell);
let text = if range.is_single() { range.start.a1() } else { range.a1() };
let text = if ed.sheet != wb.active_sheet { format!("{}!{text}", gridcraft_engine::formula::quote_sheet(&sh.name)) } else { text };
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 = Some(cell);
ed.point_anchor = Some((wb.active_sheet, start));
ed.point_anchor = Some((wb.active_sheet(), start));
}
} else if let Some(d) = app.session.active() {
// Read the current anchor: starting a drag may have just replaced the selection.
@@ -3302,7 +3303,7 @@ fn filter_menu(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, was_open: bool)
let Some((col, row, at)) = app.grid.filter_menu else { return };
let Some(d) = app.session.active() else { return };
let wb = d.wb.clone();
let si = wb.active_sheet;
let si = wb.active_sheet();
let Some(sh) = wb.sheet(si) else { return };
let _ = geo;
// Tables get an autofilter on demand.
@@ -3516,7 +3517,7 @@ fn context_menu(app: &mut SheetApp, ui: &mut egui::Ui) {
/// and no cell does while the PivotTable's Enable show details option is off.
fn show_details_offered(app: &mut SheetApp) -> bool {
let Some(d) = app.session.active() else { return false };
let (si, at) = (d.wb.active_sheet, d.selection.active);
let (si, at) = (d.wb.active_sheet(), d.selection.active);
let Some((pi, pt)) = d.wb.active().and_then(|sh| sh.pivots.iter().enumerate().find(|(_, pt)| pt.last_range.is_some_and(|r| r.contains(at))))
else {
return false;
@@ -3548,14 +3549,14 @@ const DATE_FILL_MODES: [(&str, &str); 4] = [("Fill Days", "days"), ("Fill Weekda
/// The fill modes the menus list for a fill from `src` on the active sheet.
fn fill_modes(app: &SheetApp, src: RangeRef) -> Vec<(&'static str, &'static str)> {
let dates = app.session.active().is_some_and(|d| gridcraft_engine::fill::starts_with_date(&d.wb, d.wb.active_sheet, src));
let dates = app.session.active().is_some_and(|d| gridcraft_engine::fill::starts_with_date(&d.wb, d.wb.active_sheet(), src));
FILL_MODES.into_iter().chain(DATE_FILL_MODES.into_iter().filter(|_| dates)).collect()
}
/// Remembers the fill just made so the Auto Fill Options button can offer the other ways.
fn set_fill_tag(app: &mut SheetApp, src: RangeRef, target: RangeRef) {
app.grid.fill_tag =
app.session.active().map(|d| FillTag { src, target, uid: d.uid, sheet: d.wb.active_sheet, revision: d.revision, undo_len: d.undo.len() });
app.session.active().map(|d| FillTag { src, target, uid: d.uid, sheet: d.wb.active_sheet(), revision: d.revision, undo_len: d.undo.len() });
}
/// The Auto Fill Options button below the bottom-right corner of cells a left-drag of the fill
@@ -3567,7 +3568,7 @@ fn fill_options_tag(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, sh: &Sheet
let Some(tag) = app.grid.fill_tag.clone() else { return };
let current = app.session.active().is_some_and(|d| {
d.uid == tag.uid
&& d.wb.active_sheet == tag.sheet
&& d.wb.active_sheet() == tag.sheet
&& d.revision == tag.revision
&& d.undo.len() == tag.undo_len
&& d.selection.ranges.len() == 1
@@ -3766,12 +3767,12 @@ pub fn point_move(app: &mut SheetApp, ed: &mut EditState, dr: i64, dc: i64, exte
)
})
.unwrap_or(from);
ed.point_anchor.filter(|(sheet, _)| *sheet == d.wb.active_sheet).map(|(_, cell)| cell).unwrap_or(inferred_anchor)
ed.point_anchor.filter(|(sheet, _)| *sheet == d.wb.active_sheet()).map(|(_, cell)| cell).unwrap_or(inferred_anchor)
} else {
to
};
let text = if extend { RangeRef::new(anchor, to).a1() } else { to.a1() };
let sheet = d.wb.active_sheet;
let sheet = d.wb.active_sheet();
ed.insert_ref(&text);
ed.point_cell = Some(to);
ed.point_anchor = Some((sheet, anchor));
@@ -3920,7 +3921,7 @@ fn list_picker(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, was_open: bool)
let Some(c) = app.grid.list_picker else { return };
let Some(d) = app.session.active() else { return };
let wb = d.wb.clone();
let si = wb.active_sheet;
let si = wb.active_sheet();
let Some(sh) = wb.sheet(si) else { return };
let Some(dv) = sh.validations.iter().find(|x| x.ranges.iter().any(|r| r.contains(c))).cloned() else {
app.grid.list_picker = None;
@@ -4058,7 +4059,7 @@ fn is_checkbox(sh: &Sheet, app: &SheetApp) -> bool {
fn paint_pages(app: &mut SheetApp, p: &Painter, geo: &Geo, sh: &Sheet) {
let Some(d) = app.session.active() else { return };
let key = (d.uid, d.revision, d.wb.active_sheet);
let key = (d.uid, d.revision, d.wb.active_sheet());
if app.grid.pages.as_ref().is_none_or(|(k, _)| *k != key) {
let pages: Vec<(RangeRef, u32)> = app
.session
@@ -4691,7 +4692,7 @@ mod trace_arrow_tests {
/// The arrows `paint_overlays` draws on the active sheet, as (tail, head) points.
fn arrows(app: &mut SheetApp) -> (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 (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 ctx = egui::Context::default();
@@ -4715,7 +4716,7 @@ mod trace_arrow_tests {
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();
let sh = d.wb.sheet(d.wb.active_sheet()).unwrap();
geo.cell_rect(sh, CellRef::parse(cell).unwrap()).center()
}
+19 -8
View File
@@ -374,6 +374,8 @@ pub struct SheetApp {
/// tests, embedding hosts) one window shows the active workbook window, and its close button
/// quits.
pub native_windows: bool,
/// View › Arrange All's layout and windows, waiting for the native windows to move.
pub arrange_windows: Option<(String, Vec<u64>)>,
/// The update check, the startup prompt and a running self-update (desktop only).
#[cfg(not(target_arch = "wasm32"))]
updates_rt: updates_ui::Runtime,
@@ -434,6 +436,7 @@ impl SheetApp {
ui_window: None,
parked: HashMap::new(),
native_windows: false,
arrange_windows: None,
#[cfg(not(target_arch = "wasm32"))]
updates_rt: Default::default(),
}
@@ -543,6 +546,14 @@ impl SheetApp {
// Requests the command made (a dialog, a draft) belong to the window it left active.
self.follow_active_window();
self.after_engine();
if id == "view.arrangeAll"
&& self.native_windows
&& let Ok(r) = &r
{
let arrange = r.get("arrange").and_then(Json::as_str).unwrap_or("tiled").to_string();
let windows = r.get("windows").and_then(Json::as_array).map(|a| a.iter().filter_map(Json::as_u64).collect()).unwrap_or_default();
self.arrange_windows = Some((arrange, windows));
}
// The new chart is selected, as after inserting any object, so Delete removes it, not its data.
if id == "insert.chart"
&& let Ok(r) = &r
@@ -883,7 +894,7 @@ impl SheetApp {
let text = text.map(|text| {
self.session
.active()
.map(|d| formula_locale::display_template(&text, self.ui.language.formula_locale(), &d.wb, d.wb.active_sheet))
.map(|d| formula_locale::display_template(&text, self.ui.language.formula_locale(), &d.wb, d.wb.active_sheet()))
.unwrap_or(text)
});
self.begin_edit(text, false);
@@ -1050,12 +1061,12 @@ impl SheetApp {
/// The active workbook and sheet: what drafts, text boxes and picture targets belong to.
pub fn view_key(&self) -> Option<(u64, usize)> {
self.session.active().map(|d| (d.uid, d.wb.active_sheet))
self.session.active().map(|d| (d.uid, d.wb.active_sheet()))
}
/// The active window and sheet: each window scrolls on its own.
fn scroll_key(&self) -> Option<(u64, usize)> {
Some((self.session.active_window()?, self.session.active()?.wb.active_sheet))
Some((self.session.active_window()?, self.session.active()?.wb.active_sheet()))
}
pub fn view(&self) -> SheetView {
@@ -1126,7 +1137,7 @@ impl SheetApp {
String::new()
} else {
match sh.cell(at) {
Some(c) if c.formula.is_some() => formula_locale::display(&c.input_text(), locale, &d.wb, d.wb.active_sheet),
Some(c) if c.formula.is_some() => formula_locale::display(&c.input_text(), locale, &d.wb, d.wb.active_sheet()),
_ => gridcraft_engine::display::edit_text(&d.wb, sh, at),
}
};
@@ -1134,7 +1145,7 @@ impl SheetApp {
Some(t) => (t, true),
None => (current.clone(), false),
};
let mut ed = editor::EditState::new(d.uid, d.wb.active_sheet, at, text, replace, from_formula_bar);
let mut ed = editor::EditState::new(d.uid, d.wb.active_sheet(), at, text, replace, from_formula_bar);
ed.window = self.session.active_window();
ed.locale = locale;
if replace && self.ui.autocomplete && !ed.is_formula() {
@@ -1226,7 +1237,7 @@ impl SheetApp {
self.toast = Some((msg, now_ms()));
}
// Point mode can leave another sheet active; the edit belongs to the sheet it started on.
if self.session.active().is_some_and(|d| d.wb.active_sheet != ed.sheet)
if self.session.active().is_some_and(|d| d.wb.active_sheet() != ed.sheet)
&& let Err(e) = self.session.run("sheet.activate", json!({"sheet": ed.sheet}))
{
self.message = Some(("GridCraft".into(), clean_error(&e)));
@@ -1287,7 +1298,7 @@ impl SheetApp {
let multi = self.session.active().is_some_and(|d| !d.selection.is_single_cell());
// Any other move (arrows, clicks, another sheet) leaves the selection off the cell the
// last Tab landed on, which ends the run.
let here = self.session.active().map(|d| (d.uid, d.wb.active_sheet, d.selection.active));
let here = self.session.active().map(|d| (d.uid, d.wb.active_sheet(), d.selection.active));
let run = self.tab_run.take().filter(|&(uid, sheet, _, at)| here == Some((uid, sheet, at)));
if multi {
let fwd = dr > 0 || dc > 0;
@@ -1300,7 +1311,7 @@ impl SheetApp {
if dr == 0 && dc != 0 {
let start = run.map(|r| r.2);
self.tab_run = self.session.active().and_then(|d| {
let (uid, sheet) = (d.uid, d.wb.active_sheet);
let (uid, sheet) = (d.uid, d.wb.active_sheet());
let col = start.or(here.map(|h| h.2.col))?;
Some((uid, sheet, col, d.selection.active))
});
+1 -1
View File
@@ -235,7 +235,7 @@ fn format_chart(app: &mut SheetApp, ui: &mut Ui) {
});
});
ui.collapsing("Series", |ui| {
let data = app.session.active().map(|d| gridcraft_chart::resolve(&d.wb, d.wb.active_sheet, &chart));
let data = app.session.active().map(|d| gridcraft_chart::resolve(&d.wb, d.wb.active_sheet(), &chart));
for (i, s) in chart.series.iter().enumerate() {
let name = data.as_ref().and_then(|d| d.series.get(i)).map(|x| x.name.clone()).unwrap_or_else(|| format!("Series {}", i + 1));
let col =
+4 -4
View File
@@ -280,14 +280,14 @@ pub(crate) fn paint_headers_footers(app: &SheetApp, ui: &egui::Ui, p: &Painter,
pub(crate) fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &Geo, sh: &Sheet, wb: &Workbook) {
// Leaving the sheet or the view commits.
if let Some(e) = &app.grid.hf_edit
&& (e.sheet != wb.active_sheet || sh.view != SheetViewMode::PageLayout || geo.paper.is_none())
&& (e.sheet != wb.active_sheet() || sh.view != SheetViewMode::PageLayout || geo.paper.is_none())
{
commit(app);
}
let Some(pg) = geo.paper.clone() else { return };
ruler_margins(app, ui, geo, &pg, sh, wb.active_sheet);
ruler_margins(app, ui, geo, &pg, sh, wb.active_sheet());
if std::mem::take(&mut app.grid.hf_start) {
begin(app, wb.active_sheet, sh, (pg.data_rows.0, pg.data_cols.0), HfZone { footer: false, section: 1 });
begin(app, wb.active_sheet(), sh, (pg.data_rows.0, pg.data_cols.0), HfZone { footer: false, section: 1 });
}
if resp.clicked_by(egui::PointerButton::Primary)
&& let Some(p) = ui.input(|i| i.pointer.interact_pos())
@@ -295,7 +295,7 @@ pub(crate) fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Respo
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);
match pg.hf_zone_at(x, y) {
Some((page, zone)) if geo.cells.contains(p) => begin(app, wb.active_sheet, sh, page, zone),
Some((page, zone)) if geo.cells.contains(p) => begin(app, wb.active_sheet(), sh, page, zone),
_ => {
// A click anywhere else ends the edit; the grid then handles the click.
if app.grid.hf_edit.is_some() {
+12 -2
View File
@@ -2080,7 +2080,7 @@ fn formulas(app: &mut SheetApp, ui: &mut Ui) {
{
let locale = app.ui.language.formula_locale();
let name =
app.session.active().map(|d| crate::formula_locale::completion_name(n, locale, &d.wb, d.wb.active_sheet)).unwrap_or(n);
app.session.active().map(|d| crate::formula_locale::completion_name(n, locale, &d.wb, d.wb.active_sheet())).unwrap_or(n);
let desc = crate::formula_locale::description(n, locale, app.ui.language).unwrap_or_default();
if ui.button(name).on_hover_text(desc).clicked() {
app.run_or_alert("formulas.insertFunction", json!({"name": n}));
@@ -2109,7 +2109,7 @@ fn formulas(app: &mut SheetApp, ui: &mut Ui) {
// The menu lists the names Paste Names lists; clicking one runs `formulas.useInFormula`, which puts it at the caret.
// Listed when the menu opens, and again only after the workbook changes.
let cache = egui::Id::unique("gridcraft.use_in_formula_names");
let key = app.session.active().map(|d| (d.uid, d.revision, d.wb.active_sheet));
let key = app.session.active().map(|d| (d.uid, d.revision, d.wb.active_sheet()));
let cached = ui.ctx().data(|m| m.get_temp::<(Option<(u64, u64, usize)>, std::sync::Arc<Vec<String>>)>(cache)).filter(|(k, _)| *k == key);
let names = match cached {
Some((_, names)) => names,
@@ -2513,6 +2513,16 @@ fn view(app: &mut SheetApp, ui: &mut Ui) {
act(app, "view.zoomToSelection", json!({"viewWidth": r.width(), "viewHeight": r.height()}));
}
sep(ui);
let __k = big_button(ui, Icon::NewFile, "New\nWindow", "New Window", false);
control_keytip(app, ui, &__k, "view.newWindow");
if __k.clicked() {
act(app, "view.newWindow", json!({}));
}
let __k = big_button(ui, Icon::Table, "Arrange\nAll", "Arrange All", false);
control_keytip(app, ui, &__k, "view.arrangeAll");
if __k.clicked() {
app.open_dialog("arrangeAll", json!({}));
}
let fp = big_button(ui, Icon::Freeze, "Freeze\nPanes", "Freeze Panes", true);
for sequence in
keytip_table::group_extension(app.keytips.prefix().unwrap_or_default(), &["view.freezePanes", "view.freezeTopRow", "view.freezeFirstColumn"])
+6 -6
View File
@@ -115,7 +115,7 @@ pub fn sheet_tabs(app: &mut SheetApp, ui: &mut Ui) {
let all = crate::widgets::icon_button(ui, Icon::SheetList, t.text_dim, app.ui.language.tr("All sheets"), vec2(24.0, 24.0));
all.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, app.ui.language.tr("All sheets")));
let items = app.session.active().map(|d| navigator_items(&d.wb.sheets)).unwrap_or_default();
let active = app.session.active().map(|d| d.wb.active_sheet.to_string()).unwrap_or_default();
let active = app.session.active().map(|d| d.wb.active_sheet().to_string()).unwrap_or_default();
let rows: Vec<(&str, String)> = items.iter().map(|(i, name)| (i.as_str(), name.clone())).collect();
if let crate::ribbon::ComboResult::Picked(i) = crate::ribbon::editable_combo(ui, &all, "sheet_navigator", &active, &rows, false)
&& let Ok(i) = i.parse::<usize>()
@@ -135,7 +135,7 @@ pub fn sheet_tabs(app: &mut SheetApp, ui: &mut Ui) {
.iter()
.enumerate()
.filter(|(_, s)| s.visibility == Visibility::Visible)
.map(|(i, s)| (i, s.name.clone(), s.tab_color.and_then(|c| c.resolve(&d.wb.theme)), i == d.wb.active_sheet))
.map(|(i, s)| (i, s.name.clone(), s.tab_color.and_then(|c| c.resolve(&d.wb.theme)), i == d.wb.active_sheet()))
.collect();
// Keep worksheet tabs in their own horizontally scrollable viewport. As in Excel, the
// tabs get up to 60% of the bar and the grid's horizontal scrollbar the rest; a strip
@@ -148,7 +148,7 @@ pub fn sheet_tabs(app: &mut SheetApp, ui: &mut Ui) {
};
// Reveal the selected sheet after navigation or layout changes, but
// allow browsing other tabs without snapping back on every frame.
let reveal_key = egui::Id::unique((d.uid, d.wb.active_sheet, &sheets, tabs_width.to_bits()));
let reveal_key = egui::Id::unique((d.uid, d.wb.active_sheet(), &sheets, tabs_width.to_bits()));
let reveal_id = ui.scope_id().with("worksheet_tabs_reveal");
let reveal_active = ui.ctx().data_mut(|data| {
let changed = data.get_temp::<egui::Id>(reveal_id) != Some(reveal_key);
@@ -352,7 +352,7 @@ pub fn status_bar(app: &mut SheetApp, ui: &mut Ui) {
if !d.calc.circular.is_empty() {
ui.add_space(12.0);
// Only a loop on the active sheet is named, so the address needs no sheet.
let label = match d.calc.circular.iter().find(|(si, _)| *si == d.wb.active_sheet) {
let label = match d.calc.circular.iter().find(|(si, _)| *si == d.wb.active_sheet()) {
Some((_, c)) => format!("Circular References: {}", c.a1()),
None => "Circular References".to_owned(),
};
@@ -586,11 +586,11 @@ mod tests {
let mut app = crate::SheetApp::new(session, Default::default());
app.begin_edit(Some("99".into()), false);
activate_sheet(&mut app, 1);
assert_eq!(app.session.active().unwrap().wb.active_sheet, 0, "a value the validation stops must not leave its sheet");
assert_eq!(app.session.active().unwrap().wb.active_sheet(), 0, "a value the validation stops must not leave its sheet");
assert!(app.editor.is_some(), "the edit stays open to be corrected");
app.editor.as_mut().unwrap().text = "7".into();
activate_sheet(&mut app, 1);
assert_eq!(app.session.active().unwrap().wb.active_sheet, 1, "the corrected value commits and the sheet changes");
assert_eq!(app.session.active().unwrap().wb.active_sheet(), 1, "the corrected value commits and the sheet changes");
let a1 = gridcraft_engine::core::CellRef::parse("A1").unwrap();
assert!(app.session.active().unwrap().wb.sheet(0).unwrap().cell(a1).is_some(), "the value lands on the first sheet");
}
+1 -1
View File
@@ -45,7 +45,7 @@ impl SheetApp {
id,
text: shape.text.clone(),
uid: d.uid,
sheet: d.wb.active_sheet,
sheet: d.wb.active_sheet(),
original: shape.text.clone(),
request_focus: true,
rect: None,
+13
View File
@@ -126,6 +126,19 @@ impl SheetApp {
self.follow_active_window();
}
/// Puts back a dialog that was taken out of window `home`'s fields while it ran. A command
/// run from the dialog may have switched windows meanwhile; the dialog stays in the window it
/// was opened in (dropped if that window closed).
pub(crate) fn return_dialog(&mut self, home: Option<u64>, d: dialogs::Dialog) {
if home == self.ui_window {
self.dialog = Some(d);
} else if let Some(w) = home.and_then(|h| self.parked.get_mut(&h))
&& w.dialog.is_none()
{
w.dialog = Some(d);
}
}
/// Whether a window other than the current one has a cell or text-box draft open.
pub fn parked_window_with_draft(&self) -> Option<u64> {
self.parked.iter().filter(|(_, w)| w.editor.is_some() || w.text_box_editor.is_some()).map(|(id, _)| *id).min()
+113
View File
@@ -307,3 +307,116 @@ fn feedback_from_opening_a_workbook_shows_in_its_new_window() {
desk.app.enter_window(book1);
assert!(desk.app.notice.is_none() && desk.app.message.is_none(), "Book1's window shows neither");
}
/// Book1 with two sheets, saved, shown in two windows: window 1 on Sheet1 at B2, window 2 on
/// Sheet2 at D4. Returns the window ids.
fn two_windows_on_one_workbook(desk: &mut Desk) -> (u64, u64) {
desk.app.run("home.insertSheet", json!({})).unwrap();
let path = std::env::temp_dir().join(format!("gridcraft-two-windows-{}-{:?}.xlsx", std::process::id(), std::thread::current().id()));
desk.app.run("file.save", json!({"path": path})).unwrap();
let _ = std::fs::remove_file(&path);
desk.app.run("sheet.activate", json!({"sheet": 0})).unwrap();
desk.app.run("selection.set", json!({"range": "B2"})).unwrap();
desk.frames(2);
let w1 = desk.app.session.active_window().unwrap();
desk.app.run("view.newWindow", json!({})).unwrap();
desk.frames(3);
let w2 = desk.app.session.active_window().unwrap();
desk.app.run("sheet.activate", json!({"sheet": 1})).unwrap();
desk.app.run("selection.set", json!({"range": "D4"})).unwrap();
desk.frames(3);
(w1, w2)
}
#[test]
fn two_windows_on_one_workbook_show_their_own_sheet_and_each_others_edits() {
let mut desk = Desk::new(true);
let (w1, w2) = two_windows_on_one_workbook(&mut desk);
assert_eq!(desk.windows.shown_windows(), [w1, w2], "New Window opens a native window");
assert_ne!(desk.viewport(w1), desk.viewport(w2));
let titles: Vec<String> = [w1, w2].iter().map(|w| desk.app.session.window_title(*w).unwrap()).collect();
assert!(titles[0].ends_with(".xlsx:1") && titles[1].ends_with(".xlsx:2"), "{titles:?}");
// Drawing both windows every frame swaps their views without copying the workbook.
let wb = desk.app.session.doc().unwrap().wb.clone();
desk.frames(3);
assert!(std::sync::Arc::ptr_eq(&wb, &desk.app.session.doc().unwrap().wb), "a window swap copied the workbook");
assert!(!desk.app.session.doc().unwrap().is_dirty());
for (w, sheet, cell) in [(w1, 0, "B2"), (w2, 1, "D4")] {
desk.app.enter_window(w);
let d = desk.app.session.doc().unwrap();
assert_eq!((d.wb.active_sheet(), d.selection.active), (sheet, CellRef::parse(cell).unwrap()), "window {w}'s sheet and selection");
assert!(desk.app.grid.rect.is_some(), "window {w} drew its grid");
}
// Window 1 refers to Sheet2!D4; window 2 types there; window 1 shows it on the next frame.
desk.app.enter_window(w1);
desk.app.run("cell.set", json!({"cell": "B2", "input": "=Sheet2!D4*2"})).unwrap();
desk.frames(1);
desk.app.enter_window(w2);
desk.app.run("cell.set", json!({"input": "21"})).unwrap();
desk.frames(1);
desk.app.enter_window(w1);
let d = desk.app.session.doc().unwrap();
assert_eq!(d.wb.active_sheet(), 0);
assert_eq!(d.wb.active().unwrap().value(CellRef::parse("B2").unwrap()), Value::Number(42.0));
}
#[test]
fn a_dialog_stays_with_its_window_across_a_switch() {
let mut desk = Desk::new(true);
let (w1, w2) = two_windows_on_one_workbook(&mut desk);
desk.app.enter_window(w1);
desk.app.open_dialog("zoom", json!({}));
desk.frames(2);
assert_eq!(desk.app.dialog.as_ref().map(|d| d.name()), Some("zoom"));
// From the menu bar.
desk.app.run("view.switchWindows", json!({"window": w2})).unwrap();
desk.frames(3);
assert_eq!(desk.app.session.active_window(), Some(w2));
assert!(desk.app.dialog.is_none(), "window 2 shows no dialog");
// The user clicks into window 1, then back into window 2.
for (w, other) in [(w1, w2), (w2, w1)] {
let (v, o) = (desk.viewport(w), desk.viewport(other));
INFOS.with(|i| {
let mut i = i.borrow_mut();
i.entry(v).or_default().focused = Some(true);
i.entry(o).or_default().focused = Some(false);
});
desk.frames(2);
assert_eq!(desk.app.session.active_window(), Some(w));
let expected = if w == w1 { Some("zoom") } else { None };
assert_eq!(desk.app.dialog.as_ref().map(|d| d.name()), expected, "window {w}'s dialog");
}
// A script switching windows while the dialog is open.
desk.frame_with(|app, ctx| {
let _ = gridcraft_ui_egui::control::handle(app, ctx, "engine.execute", &json!({"command": "window.activate", "params": {"window": w1}}));
});
desk.frames(2);
assert_eq!(desk.app.dialog.as_ref().map(|d| d.name()), Some("zoom"));
desk.app.enter_window(w2);
assert!(desk.app.dialog.is_none());
}
#[test]
fn arrange_all_lays_out_the_native_windows_active_first() {
let mut desk = Desk::new(true);
let (w1, w2) = two_windows_on_one_workbook(&mut desk);
INFOS.with(|i| i.borrow_mut().entry(ViewportId::ROOT).or_default().monitor_size = Some(egui::vec2(1000.0, 800.0)));
desk.app.run("view.arrangeAll", json!({"arrange": "vertical"})).unwrap();
let sent = desk.frames(1);
let at = |x: f32| ViewportCommand::OuterPosition(egui::pos2(x, 0.0));
assert!(sent_to(&sent, desk.viewport(w2), &at(0.0)), "the active window goes left: {sent:?}");
assert!(sent_to(&sent, desk.viewport(w1), &at(500.0)));
assert!(sent_to(&sent, desk.viewport(w1), &ViewportCommand::InnerSize(egui::vec2(500.0, 800.0))));
// Without parameters, as a script calls it: tiled.
let r = desk.app.run("view.arrangeAll", json!({})).unwrap();
assert_eq!(r["arrange"], "tiled");
let tiled = gridcraft_ui_egui::doc_windows::arrange_rects("tiled", 3, craft_window::Rect::new(0.0, 0.0, 1000.0, 800.0));
let r = |x, y, w, h| craft_window::Rect::new(x, y, w, h);
assert_eq!(tiled, [r(0.0, 0.0, 500.0, 400.0), r(500.0, 0.0, 500.0, 400.0), r(0.0, 400.0, 1000.0, 400.0)]);
}
@@ -34,7 +34,7 @@ fn commit_after_pointing_on_another_sheet_writes_to_the_origin_sheet() {
assert!(h.state_mut().commit_edit(0, 0, false, false));
let at = CellRef::parse("F8").unwrap();
let wb = &h.state().session.active().unwrap().wb;
assert_eq!(wb.active_sheet, 0, "the origin sheet is shown again");
assert_eq!(wb.active_sheet(), 0, "the origin sheet is shown again");
assert!(wb.sheet(0).unwrap().cell(at).is_some_and(|c| c.formula.is_some()), "formula written on the origin sheet");
assert!(wb.sheet(1).unwrap().cell(at).is_none(), "the pointed-at sheet is untouched");
}
@@ -44,7 +44,7 @@ fn ctrl_enter_after_pointing_on_another_sheet_fills_the_origin_selection() {
let mut h = pointing_on_second_sheet("B2:C3");
assert!(h.state_mut().commit_edit(0, 0, false, true));
let wb = &h.state().session.active().unwrap().wb;
assert_eq!(wb.active_sheet, 0, "the origin sheet is shown again");
assert_eq!(wb.active_sheet(), 0, "the origin sheet is shown again");
for a1 in ["B2", "C2", "B3", "C3"] {
let at = CellRef::parse(a1).unwrap();
assert!(wb.sheet(0).unwrap().cell(at).is_some_and(|c| c.formula.is_some()), "{a1} filled on the origin sheet");
@@ -112,7 +112,7 @@ fn circular_references_lists_the_loops_and_selects_the_cell_picked() {
h.run_steps(4);
let app = h.state();
let d = app.session.active().unwrap();
assert_eq!(d.wb.active_sheet, 1, "picking a cell on Q1 Data goes to Q1 Data");
assert_eq!(d.wb.active_sheet(), 1, "picking a cell on Q1 Data goes to Q1 Data");
assert_eq!(format!("'Q1 Data'!{}", d.selection.active.a1()), other);
}
+1 -1
View File
@@ -57,7 +57,7 @@ fn double_click(h: &mut Harness, label: &str) {
fn place(h: &Harness) -> (usize, String) {
let d = h.state().session.active().unwrap();
(d.wb.active_sheet, d.selection.a1())
(d.wb.active_sheet(), d.selection.a1())
}
#[test]
@@ -113,7 +113,7 @@ fn the_cell_menu_computes_the_report_once_for_all_its_cells() {
// West goes away behind the session's back, so the revision stays the same. A menu that
// computed the report again for D4 would find it out of date and offer Show Details there.
let doc = h.state_mut().session.active_mut().unwrap();
let si = doc.wb.active_sheet;
let si = doc.wb.active_sheet();
std::sync::Arc::make_mut(&mut doc.wb).sheet_mut(si).unwrap().set_value(CellRef::parse("A3").unwrap(), Value::text("East"));
right_click(&mut h, "D4");
assert!(h.query_by_label("Show Details").is_none(), "D4 is a label of the report computed for E4");
+1 -1
View File
@@ -106,7 +106,7 @@ fn filter_closes_when_clicking_another_table_or_sheet() {
click(&mut h, outside, PointerButton::Primary);
assert!(!is_open(&h, Menu::Filter), "filter stayed open after switching table/sheet");
if sheet {
assert_eq!(h.state().session.active().unwrap().wb.active_sheet, 1);
assert_eq!(h.state().session.active().unwrap().wb.active_sheet(), 1);
} else {
assert_eq!(h.state().session.active().unwrap().selection.active.a1(), "H2");
}
+2 -2
View File
@@ -32,7 +32,7 @@ fn harness() -> egui_kittest::Harness<'static, SheetApp> {
#[test]
fn typing_a_name_and_enter_activates_that_sheet() {
let mut h = harness();
assert_eq!(h.state().session.active().unwrap().wb.active_sheet, 0);
assert_eq!(h.state().session.active().unwrap().wb.active_sheet(), 0);
h.get_by_label("All sheets").click();
h.run_steps(4);
h.input_mut().events.push(Event::Text("summ".into()));
@@ -40,5 +40,5 @@ fn typing_a_name_and_enter_activates_that_sheet() {
h.input_mut().events.push(Event::Key { key: Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE });
h.input_mut().events.push(Event::Key { key: Key::Enter, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE });
h.run_steps(4);
assert_eq!(h.state().session.active().unwrap().wb.active_sheet, 3, "Enter on the filtered row must activate Summary");
assert_eq!(h.state().session.active().unwrap().wb.active_sheet(), 3, "Enter on the filtered row must activate Summary");
}
+3 -3
View File
@@ -81,7 +81,7 @@ fn navigation_arrows_reveal_the_selected_tab() {
for _ in 0..29 {
click(&mut h, next);
}
assert_eq!(h.state().session.doc().unwrap().wb.active_sheet, 29);
assert_eq!(h.state().session.doc().unwrap().wb.active_sheet(), 29);
assert_visible(&h, "Tab29");
}
@@ -146,7 +146,7 @@ fn hidden_sheets_long_names_and_window_resize_keep_active_tab_visible() {
h.set_size(vec2(1000.0, 120.0));
h.run_steps(20);
assert_visible(&h, name);
assert_eq!(h.state().session.doc().unwrap().wb.active_sheet, 29);
assert_eq!(h.state().session.doc().unwrap().wb.active_sheet(), 29);
}
#[test]
@@ -190,7 +190,7 @@ fn right_clicking_a_tab_activates_its_sheet_so_the_menu_acts_on_it() {
h.step();
h.input_mut().events.push(Event::PointerButton { pos: at, button: PointerButton::Secondary, pressed: false, modifiers: Modifiers::NONE });
h.run_steps(20);
assert_eq!(h.state().session.doc().unwrap().wb.active_sheet, 3, "right-clicking Tab03 should activate it");
assert_eq!(h.state().session.doc().unwrap().wb.active_sheet(), 3, "right-clicking Tab03 should activate it");
// The menu now offers Unprotect for the protected sheet that was clicked, not Protect for the one that was active.
let texts: Vec<String> = h
.output()
+2 -2
View File
@@ -98,11 +98,11 @@ fn cross_sheet_precedents_jump_to_the_other_sheet() {
s.execute("cell.set", json!({"cell": "A1", "input": "=Src!B2*2"})).unwrap();
// Back on the first sheet, Ctrl+[ from A1 jumps to Src!B2.
let d = s.active().unwrap();
let first = d.wb.active_sheet;
let first = d.wb.active_sheet();
s.execute("selection.set", json!({"range": "A1"})).unwrap();
let mut h = harness(s);
key(&mut h, Key::OpenBracket, Modifiers::CTRL);
let d = h.state().session.active().unwrap();
assert_ne!(d.wb.active_sheet, first, "selection jumped to the precedent's sheet");
assert_ne!(d.wb.active_sheet(), first, "selection jumped to the precedent's sheet");
assert_eq!(d.selection.active, CellRef { row: 1, col: 1 }, "B2 on the Src sheet");
}
+1 -1
View File
@@ -299,7 +299,7 @@ pub fn read_xlsx(bytes: &[u8]) -> Result<(Workbook, ReadReport), IoError> {
cx.warn("the workbook has no worksheets; added an empty sheet");
wb.sheets.push(Arc::new(Sheet::new("Sheet1")));
}
wb.active_sheet = file_to_model.get(active_tab).copied().flatten().unwrap_or(0);
wb.set_active_sheet(file_to_model.get(active_tab).copied().flatten().unwrap_or(0));
// Defined names. Chartex parts refer to their data through hidden `_xlchart.*` names.
let mut chart_names: Vec<(String, String)> = vec![];
+2 -2
View File
@@ -244,8 +244,8 @@ pub fn write_xlsx_named(wb: &Workbook, kind: XlsxKind, book_name: Option<&str>)
// Which sheet is active (must be visible), and at least one visible sheet.
let any_visible = wb.sheets.iter().any(|s| s.visibility == Visibility::Visible);
let active = if wb.sheets.get(wb.active_sheet).is_some_and(|s| s.visibility == Visibility::Visible) {
wb.active_sheet
let active = if wb.sheets.get(wb.active_sheet()).is_some_and(|s| s.visibility == Visibility::Visible) {
wb.active_sheet()
} else {
wb.sheets.iter().position(|s| s.visibility == Visibility::Visible).unwrap_or(0)
};
+1 -1
View File
@@ -206,7 +206,7 @@ pub(crate) fn read_xls_with(bytes: &[u8], mut budget: Budget) -> Result<(Workboo
}
wb.calc.precision_as_displayed = g.precision_as_displayed;
defined_names(&g, &model_index, &mut wb, &mut report);
wb.active_sheet = wb.sheets.iter().position(|s| s.visibility == Visibility::Visible).unwrap_or(0);
wb.set_active_sheet(wb.sheets.iter().position(|s| s.visibility == Visibility::Visible).unwrap_or(0));
// The overview comes first.
report.warnings.insert(
0,
+1 -1
View File
@@ -120,7 +120,7 @@ pub fn read_xlsb(bytes: &[u8]) -> Result<(Workbook, ReadReport), IoError> {
if wb.sheets.is_empty() {
return Err(invalid("no supported worksheets"));
}
wb.active_sheet = wb.sheets.iter().position(|s| s.visibility == Visibility::Visible).unwrap_or(0);
wb.set_active_sheet(wb.sheets.iter().position(|s| s.visibility == Visibility::Visible).unwrap_or(0));
Ok((wb, report))
}
+4 -1
View File
@@ -159,7 +159,9 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha-
**Estimate:** 15–23 h.
### 20. Multiple windows; pen pressure (H)
- One window (#162, PR #171); no pressure or tilt in ink. **Estimate:** 10–14 h.
- Desktop windows done: a native window per workbook window, New Window on the same workbook,
Switch Windows, Arrange All. Left: Arrange All across monitors, one window on the web, no
pressure or tilt in ink. **Estimate:** 5–8 h.
[hardware-parity.md](hardware-parity.md)
### 21. Automation and add-ins (F)
@@ -183,6 +185,7 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha-
|---|---|---|
| 2026-10-24 | minor | §8: PivotTable Enable show details: `pivot.options` `enableShowDetails` turns Show Details off for a PivotTable, read and written in XLSX as `enableDrill` |
| 2026-10-23 | minor | §8: PivotTable Show Details: double-clicking a value cell lists its source records on a new sheet (#2364) |
| 2026-10-22 | minor | §20: a native window per workbook window, New Window on the same workbook and Arrange All on the desktop; left: Arrange All across monitors, the web's one window, pen pressure (estimate 10–14 h → 5–8 h) |
| 2026-10-20 | minor | §11: the Japanese catalog covers the dialogs, messages and menus — 943 keys (~43%), matching the pt-BR coverage; a test keeps the two catalogs in step |
| 2026-10-17 | minor | §6: the five reports it tracked are closed and their PRs merged (#57, #133, #139, #140, #134; PRs #148, #155, #156, #157); the entry stays open for §15's oracle suite |
| 2026-10-15 | minor | §6: Maximum Iterations and Maximum Change in File › Options › Formulas; dropped the stale "no Options dialog yet" |
+3 -2
View File
@@ -1,6 +1,6 @@
# Hardware parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (a native window per workbook window) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (Arrange All) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
The hardware Excel uses, per platform, against GridCraft. Part of
[target-app-parity.md](target-app-parity.md). **Hardware: ~40% ready, 25–40 h** (estimated).
@@ -11,7 +11,7 @@ The hardware Excel uses, per platform, against GridCraft. Part of
| GPU rendering of the grid and charts | DirectX / Metal accelerated drawing | wgpu: Metal · DirectX 12 (default since 10-08; OpenGL when DirectX 12 has no GPU driver or fails to start; Vulkan only on request, so the Intel UHD `igvk64.dll` crash (#120) can't happen; shaders compiled with FXC, so no DXC runtime is needed, #339) · Vulkan/GL · Vulkan/GL · WebGPU or WebGL2 | At parity in kind. Driver problems: screen flashing on Windows 11 (#76): 2–4 h |
| Pen and touch (Draw tab) | Pen pressure and tilt, eraser end, touch mode, Ink to Shape, ink replay | Pen, highlighter, eraser, Ink to Shape with mouse/pen input; **no pressure or tilt**, no touch mode | Pressure-sensitive strokes where winit reports force; touch-friendly ribbon spacing: 4–6 h |
| Trackpad and mouse | Smooth scrolling, pinch zoom, horizontal scroll, Magic Mouse | Smooth scrolling and zoom via egui | Verify pinch zoom on each platform: 1–2 h |
| Multiple windows and monitors | One window per workbook, New Window for the same workbook, per-monitor DPI | A native window per workbook window on the desktop, New Window for the same workbook; new windows cascade from the last one on its monitor (web: one window) | Arrange All across monitors; verify per-monitor DPI with windows on two monitors: 2–3 h |
| Multiple windows and monitors | One window per workbook, New Window for the same workbook, per-monitor DPI | A native window per workbook window on the desktop, New Window for the same workbook; new windows cascade from the last one on its monitor; View › Arrange All tiles, stacks or cascades them over the main window's monitor (web: one window) | Arrange All across monitors; verify per-monitor DPI with windows on two monitors: 2–3 h |
| High-DPI and display scaling | yes | yes (display scale matched to desktop density; View › Interface size 75–200 % on top, #230) | — |
| Accessibility hardware (screen readers, braille displays, switch control) | Narrator, JAWS, NVDA, VoiceOver | AccessKit enabled in eframe; grid cells and ribbon not yet exposed as an accessible tree | Counted under UI ([ui-parity.md](ui-parity.md)) |
| Printers | System print with driver options | Desktop submits through craft-print with printer choice, copies, duplex and grayscale; web browser printing | Counted under print |
@@ -20,6 +20,7 @@ The hardware Excel uses, per platform, against GridCraft. Part of
| Date | Change | Summary |
|---|---|---|
| 2026-10-22 | minor | View › Arrange All over the main window's monitor. |
| 2026-10-22 | minor | Multiple windows: each workbook window is a native window of its own (desktop). |
| 2026-10-12 | minor | Desktop File > Print now discovers printers and submits PDF jobs through pinned craft-print with printer choice, copies, duplex and grayscale; browser printing and PDF export remain available. Remaining print parity includes page ranges and driver-specific tray/options UI; physical printer fidelity needs hardware validation. |
| 2026-10-11 | trivial | Windows: DirectX 12 shaders compiled with FXC, no DXC runtime needed (#339, #354) |
+5 -4
View File
@@ -1,6 +1,6 @@
# Target-app parity: GridCraft vs Microsoft Excel
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-21 · **Change:** minor (Page Setup paper sizes match Japanese Excel: B4/B5/B6 are the JIS sizes, with ISO B4/B5/B6 by name and the Japanese postcards, envelopes and A6 added; no score change) · **Target:** Microsoft Excel (Microsoft 365)
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-22 · **Change:** minor (several windows: a native window per workbook window, New Window on the same workbook, Arrange All; no score change) · **Target:** Microsoft Excel (Microsoft 365)
This is the authoritative parity assessment. [`ROADMAP.md`](../ROADMAP.md) summarizes it,
[`gaps.md`](gaps.md) lists every shortfall, and the area checklists hold the detail:
@@ -102,11 +102,11 @@ performance 8, stability 8, localization 4, hardware 2, platforms 2, ecosystem 2
|---|---|---|---|---|
| Features | 45 | ~59% | 300–485 | Table above |
| File formats | 15 | ~50% | 80–120 | XLSX read/write is broad but unproven on a real corpus and in Excel; XLSB and ODS are values-only import; no XLS, XML Spreadsheet 2003, SYLK, DIF, PRN; VBA dropped; 100 MB XLSX fails (#175). [file-format-parity.md](file-format-parity.md) |
| UI/UX fidelity | 12 | ~55% | 50–80 | Excel-style ribbon, galleries, dialogs, formula bar, status bar; 59 commands with shortcuts vs Excel's 200+; key tips on part of the Home tab only; short context menu; single window. [ui-parity.md](ui-parity.md) |
| UI/UX fidelity | 12 | ~55% | 50–80 | Excel-style ribbon, galleries, dialogs, formula bar, status bar; 59 commands with shortcuts vs Excel's 200+; key tips on part of the Home tab only; short context menu; a native window per workbook window on the desktop (one on the web). [ui-parity.md](ui-parity.md) |
| Performance | 8 | ~35% | 35–55 | [Performance](#performance) |
| Stability | 8 | ~60% | 15–25 | Never-crash rules enforced by lint, every command fuzzed with hostile params, random command sequences fuzzed against undo/redo, XLSX round-trip and full-recalc invariants, last-resort guard in `Session::execute`. Field reports: Windows Intel GPU crash (#120), error on close (#117), freeze opening from Finder (#166), screen flashing (#76) |
| Localization | 4 | ~10% | 100–165 | Ribbon-only catalog in five languages (ja, zh, ko, pt-BR, ru; ~12% of strings each), CJK/Thai/Hebrew font fallback, no shaping or RTL. [localization-parity.md](localization-parity.md) |
| Hardware | 2 | ~40% | 25–40 | GPU UI via wgpu; single-threaded calc; no pen pressure; one window. [hardware-parity.md](hardware-parity.md) |
| Hardware | 2 | ~40% | 25–40 | GPU UI via wgpu; single-threaded calc; no pen pressure; a native window per workbook window, Arrange All on one monitor. [hardware-parity.md](hardware-parity.md) |
| Platforms | 2 | ~80% | 30–60 | [Platforms](#platforms) |
| Ecosystem / add-ins | 2 | ~10% | 25–40 | [Ecosystem and AI](#ecosystem-and-ai) |
| AI features | 2 | ~15% | 30–50 | [Ecosystem and AI](#ecosystem-and-ai) |
@@ -144,7 +144,7 @@ Discounts for what still stops real work:
| Discount | Factor | Evidence |
|---|---|---|
| Interaction fidelity | × 0.90 | 59 command shortcuts vs Excel's 200+; key tips only on part of the Home tab (#43); rectangular selection bug (#28); charts can't be resized (#190); one window (#162); missing horizontal scrollbar report (#176) |
| Interaction fidelity | × 0.90 | 59 command shortcuts vs Excel's 200+; key tips only on part of the Home tab (#43); rectangular selection bug (#28); charts can't be resized (#190); missing horizontal scrollbar report (#176) |
| Stability on real machines | × 0.92 | Never-crash lints, fuzzed commands and the `Session::execute` guard; but field reports of a startup crash on Intel UHD (#120), an error on close (#117), a freeze opening from Finder (#166) and screen flashing (#76) |
| File exchange with Excel users | × 0.88 | Excel opening our output is unverified across features; unmodelled parts dropped on save; advanced chart kinds lose their type; 100 MB files fail (#175); `.xls` and encrypted files are refused with a clear message (#17) rather than opened. Harsher than a drawing app's discount because spreadsheets are exchanged constantly |
@@ -334,6 +334,7 @@ The inventory carried over from the 2026-10-07 ROADMAP.md, updated for what land
| Date | Change | Summary |
|---|---|---|
| 2026-10-22 | minor | Several windows on the desktop: a native window per workbook window, New Window on the same workbook, Switch Windows and Arrange All; "one window" removed from UI/UX fidelity, Hardware and the interaction-fidelity discount; no score change |
| 2026-10-21 | minor | Page Setup paper sizes: `B4`/`B5`/`B6` are the JIS sizes Japanese Excel uses (ISO B4/B5/B6 by name), and Japanese Postcard, Japanese Double Postcard, the chou/kaku envelopes and A6 are added; PDF MediaBox and `file.print` follow, and the Size menu lists them |
| 2026-10-18 | trivial | Stale chart notes: all chart kinds survive XLSX round-trip (PRs #158, #161) and trendlines landed |
| 2026-10-16 | minor | Advanced Filter copy-to clears only its actual output and rejects overflow without changing cells (#1031) |
+3 -2
View File
@@ -34,8 +34,8 @@ Sources: `crates/ui-egui/src` (ribbon, grid, editor, formula bar, dialogs, panes
| Backstage (File) view: Info, New with templates, Open recent, Export, Options | yes | yes (#174, 10-11): the File tab opens a full-window view (Esc or the back arrow return) with Home, New (blank + the three samples), Open (recent + Browse; the recent list tells when each workbook was last changed, 10-12), Info (location, properties, Protect Workbook), Save, Save As, Print (page preview, active sheet or workbook, fit to page, Page Setup), Export (PDF, CSV, web page), Close and Options (`file.options`: theme, language, formula bar, AutoSave, calculation mode and iterative calculation with Maximum Iterations and Maximum Change, 10-15) | no Account, Share or template gallery beyond the samples; Options applies at once (no OK/Cancel dialog) and holds only the settings GridCraft has |
| Dark mode, system appearance | yes | yes | done (10-09) |
| Page Layout view (paper view with rulers and header editing) | yes | yes (#329, 10-11): per-sheet view saved in XLSX, pages with margins and gaps on the print pagination, "Click to add data" pages, rulers (inches or centimetres), headers and footers drawn and edited in place with a Header & Footer tab, whose Different First Page and Different Odd & Even Pages give the first page and the even pages their own header and footer (#344), and whose Scale with Document and Align with Page Margins set whether header and footer text follows the print scale and the side margins, on paper and in the view | margins drag on the rulers (#792, with header and footer margins); header/footer pictures missing (1–3 h). Unverified: with Align with Page Margins off, header and footer text sits 0.7 in from the paper edge, a value not yet measured in the target app |
| Multiple windows per workbook | yes | yes (10-22, desktop): each workbook window is a native window of its own (New, Open and New Window open one; titles "Book1", or "Book1:1" / "Book1:2" for a workbook's second window, with " — Edited" while unsaved). Each window keeps its own cell draft, dialog, Backstage view, message and grid state, and its ribbon acts on it; on macOS the menu bar acts on the window focused last. Leaving a window enters its draft as Enter would. A window's close button closes that window: one of several on a workbook closes without asking, a workbook's last asks Save / Don't Save / Cancel when unsaved. Quit asks about each unsaved workbook in its window, and Cancel stops it. The last window closed quits on Windows and Linux; macOS keeps running with the menu bar until New or Open. Web: one window | no Arrange All; a hidden window (View › Hide) has no native window; every window repaints when one does; see [hardware-parity.md](hardware-parity.md) |
| Switch Windows, New Window, unsaved workbooks on quit | yes | yes (10-22): View › New Window opens another window on the same workbook (no copy: saving from either saves it), each with its own selection, active sheet and scroll; a workbook with several windows titles them "Book1:1", "Book1:2"; File › Close closes one of them without asking and asks only for the last; View › Switch Windows and the macOS Window menu list the open windows (active one checked, unsaved ones marked •) and bring the chosen one forward (`view.switchWindows {window}`, or `{doc}` for a workbook's most recent window); a cell being typed is entered into its own window and workbook before New, Open, Close, Save, Print and window switches (also between two windows on one workbook), and a refused entry stays open and stops the command; quitting (Cmd+Q, the macOS Quit item, the Dock) asks Save / Don't Save / Cancel for each unsaved workbook | |
| Multiple windows per workbook | yes | yes (10-22, desktop): each workbook window is a native window of its own (New, Open and New Window open one; titles "Book1", or "Book1:1" / "Book1:2" for a workbook's second window, with " — Edited" while unsaved). Each window keeps its own cell draft, dialog, Backstage view, message and grid state, and its ribbon acts on it; on macOS the menu bar acts on the window focused last. Leaving a window enters its draft as Enter would. A window's close button closes that window: one of several on a workbook closes without asking, a workbook's last asks Save / Don't Save / Cancel when unsaved. Quit asks about each unsaved workbook in its window, and Cancel stops it. The last window closed quits on Windows and Linux; macOS keeps running with the menu bar until New or Open. View › Arrange All (dialog: Tiled, Horizontal, Vertical, Cascade, Windows of active workbook; `view.arrangeAll` tiles by default) lays the visible windows out over the main window's monitor, the active one first. Web: one window | Arrange All uses one monitor; a hidden window (View › Hide) has no native window; every window repaints when one does; see [hardware-parity.md](hardware-parity.md) |
| Switch Windows, New Window, unsaved workbooks on quit | yes | yes (10-22): View › New Window opens another window on the same workbook (no copy: saving from either saves it), each with its own selection, active sheet and scroll (showing different sheets copies nothing and leaves the workbook unchanged; Save records the sheet of the window used last); a workbook with several windows titles them "Book1:1", "Book1:2"; File › Close closes one of them without asking and asks only for the last; View › Switch Windows and the macOS Window menu list the open windows (active one checked, unsaved ones marked •) and bring the chosen one forward (`view.switchWindows {window}`, or `{doc}` for a workbook's most recent window); a cell being typed is entered into its own window and workbook before New, Open, Close, Save, Print and window switches (also between two windows on one workbook), and a refused entry stays open and stops the command; quitting (Cmd+Q, the macOS Quit item, the Dock) asks Save / Don't Save / Cancel for each unsaved workbook | |
## Grid interaction
@@ -202,6 +202,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12.
| 2026-10-24 | minor | When the fill starts from a date, the right-drag fill menu and the Auto Fill Options button also offer Fill Days, Fill Weekdays (Saturday and Sunday skipped), Fill Months and Fill Years (`edit.autoFill` with `mode` set to `days`, `weekdays`, `months` or `years`). A lone date steps by one unit; two or more dates step by the difference between the first two in that unit, or one unit when they fall in the same one. Fill Months and Fill Years keep the day of the month, clamped to the month's last day. Every fill by months or years (these two, a drag of dates a month apart, and Series by Month or Year) keeps the time of day of a date with a time, which used to be dropped |
| 2026-10-22 | minor | Show Outline Symbols (`view.showOutlineSymbols`, Ctrl+8): a sheet can hide the outline's level buttons and brackets while keeping its outline, as Excel saves with `showOutlineSymbols="0"` (read and written); the grid then gives the bands no room |
| 2026-10-22 | minor | A number in a repeat-fill format (`*`, as in the Accounting format) that is too wide for its column shows #### like any other number, instead of the fill rebuilding the value so its leading digits were cut off (2,509.00 CHF drawn as "509.00 CHF") |
| 2026-10-22 | minor | View › New Window and Arrange All on the View ribbon; Arrange All tiles, stacks or cascades the native windows; two windows on one workbook keep their own sheet without copying the workbook; a dialog opened in one window stays there when a command run from it switches windows |
| 2026-10-22 | minor | One native window per workbook window on the desktop: per-window drafts, dialogs and ribbons; menu bar commands go to the focused window; closing one of several windows on a workbook doesn't ask; Quit asks in each unsaved workbook's window; the last window closed quits (Windows, Linux) or leaves macOS running |
| 2026-10-22 | minor | New Window opens a second window on the same workbook instead of a copy that saved over the original; windows keep their own selection, sheet and scroll; titles Name:1 / Name:2; Switch Windows, Hide and Unhide work by window; File › Close closes one window of several without asking |
| 2026-10-22 | minor | New workbook no longer loses the first one: the cell being typed is entered into its own workbook before any File or window command (an entry the workbook refuses stays open and the command doesn't run; an untouched F2 draft changes nothing), the editor is tied to its workbook, View › Switch Windows and the macOS Window menu list the open workbooks, and the macOS Quit item asks about unsaved workbooks like the close button |