G4: two windows on one workbook without copying it; Arrange All

- The workbook's active sheet is an atomic set in place (Workbook::active_sheet /
  set_active_sheet), so a window swap or sheet switch never clones the
  Arc<Workbook> or marks it dirty. Selections already live in DocState and the
  window views. Save records the sheet of the most recently active window.
- A dialog taken out of its window while it runs goes back to that window when
  a command run from it switched windows (dialogs::show, ui.dialog.confirm).
- view.arrangeAll {arrange?: tiled|horizontal|vertical|cascade, activeWorkbook?}
  returns the visible windows, active first; the desktop moves and sizes the
  native windows over the main window's monitor. New Window and Arrange All
  buttons on the View ribbon; Arrange Windows dialog.
- Docs: ui-parity, hardware-parity, gaps #20, target-app-parity, ROADMAP.
This commit is contained in:
aiongg
2026-10-22 16:50:10 +00:00
parent d4db58cde4
commit 2ed3df2bd6
56 changed files with 620 additions and 224 deletions
+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
@@ -158,7 +158,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)?))
@@ -210,7 +210,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)?))
@@ -502,7 +502,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 {
@@ -554,7 +554,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 {
@@ -741,7 +741,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() {
@@ -754,7 +754,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;
@@ -1258,7 +1258,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)})),
}
@@ -1266,7 +1266,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 {
@@ -1305,7 +1305,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)?;
+11 -16
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
@@ -414,23 +414,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();
}
}
@@ -558,7 +553,7 @@ pub(super) fn go_to(s: &mut Session, p: &Json) -> Result<Json> {
// A defined name or table?
let d = s.doc()?;
let resolved = if RangeRef::parse(split_sheet(reference).1).is_none() {
let sheet = d.wb.active_sheet;
let sheet = d.wb.active_sheet();
if let Some(n) = d.wb.name(reference, sheet) {
n.formula.clone()
} else if let Some((si, ti)) = d.wb.table(reference) {
@@ -1535,7 +1530,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 };
@@ -1565,7 +1560,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())) };
@@ -1587,7 +1582,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)> =
@@ -1659,7 +1654,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(()) };
@@ -1739,7 +1734,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()
}
+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!(
@@ -276,7 +284,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),
@@ -442,7 +450,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 {
@@ -464,7 +472,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)?;
@@ -546,7 +554,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;
@@ -564,7 +572,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;
@@ -630,7 +638,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)> = {
@@ -743,7 +751,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);
@@ -802,6 +810,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);
@@ -835,7 +862,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)),
@@ -944,7 +971,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>()}))
}
@@ -1117,7 +1144,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)
@@ -1210,7 +1237,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
@@ -185,9 +185,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
@@ -1011,7 +1011,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()))
+8 -8
View File
@@ -102,7 +102,7 @@ fn auto_sum(s: &mut Session, p: &Json) -> Result<Json> {
(CellRef::new(out_row, c), format!("={func}({})", RangeRef::new(CellRef::new(sel.start.row, c), CellRef::new(data_end_row, c)).a1()))
})
.collect();
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();
@@ -400,7 +400,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));
@@ -477,7 +477,7 @@ fn create_from_selection(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))
}
@@ -486,7 +486,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();
@@ -499,7 +499,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();
@@ -570,7 +570,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| {
@@ -636,7 +636,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 => {
@@ -675,7 +675,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
@@ -356,7 +356,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
@@ -819,7 +819,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),
@@ -968,7 +968,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);
@@ -993,7 +993,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"))?,
@@ -1037,7 +1037,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}))
@@ -1107,7 +1107,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}))
@@ -1125,7 +1125,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 {
@@ -1148,7 +1148,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() {
@@ -1164,7 +1164,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)?;
@@ -1205,7 +1205,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() {
@@ -1703,7 +1703,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
@@ -63,7 +63,7 @@ fn inspect(s: &mut Session, _: &Json) -> Result<Json> {
"title": d.display_title(),
"path": d.path,
"dirty": d.is_dirty(),
"activeSheet": wb.active_sheet,
"activeSheet": wb.active_sheet(),
"sheets": sheets,
"selection": d.selection.a1(),
"activeCell": d.selection.active.a1(),
+3 -3
View File
@@ -271,7 +271,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;
@@ -280,7 +280,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).
@@ -562,7 +562,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()))
+8 -8
View File
@@ -206,7 +206,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));
@@ -616,7 +616,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") {
@@ -654,7 +654,7 @@ fn insert_pivot(s: &mut Session, p: &Json) -> Result<Json> {
let (si, anchor) = match target {
Some(t) => t,
None => {
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() {
@@ -704,7 +704,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();
@@ -1395,7 +1395,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));
@@ -1529,7 +1529,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 {
@@ -1612,7 +1612,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");
@@ -1993,7 +1993,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()
}
+3 -3
View File
@@ -370,13 +370,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;
}
@@ -387,7 +387,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
@@ -687,7 +687,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() {
@@ -697,7 +697,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)
@@ -785,8 +785,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;
@@ -900,12 +900,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() {
@@ -942,8 +942,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)
})
@@ -964,7 +964,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)
})
}
@@ -978,7 +978,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
@@ -299,7 +299,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
@@ -139,7 +139,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")),
};
@@ -190,7 +190,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.
+2 -2
View File
@@ -194,14 +194,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 {
+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();
}
+70 -5
View File
@@ -188,7 +188,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();
@@ -1608,7 +1608,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();
@@ -3843,7 +3843,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)));
@@ -4550,11 +4550,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.
@@ -4564,6 +4564,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();
+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;
}
+3 -3
View File
@@ -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);
}
}