#3057: select visible workbook views and normalize tab ratios

This commit is contained in:
scks1
2026-11-01 08:06:44 -05:00
parent 9276827b1a
commit 60a267b4ef
7 changed files with 214 additions and 8 deletions
+3 -1
View File
@@ -2,7 +2,7 @@
**Stage: alpha** · next: beta, ~25 points of "ready for real work" and ~160–240 h away
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-25 · **Change:** minor (AutoSum over a selected row puts its total to the right, and fills a selection's empty last row and column; earlier: macro engine and `macro.*` commands; macro interface on the Automate tab) · **Target:** Microsoft Excel (Microsoft 365)
> **Last reviewed:** 2026-11-01 · **Last updated:** 2026-11-01 · **Change:** trivial (XLSX workbook-view selection, bounded tab ratios and sheet-edit regressions; readiness estimates unchanged) · **Target:** Microsoft Excel (Microsoft 365)
GridCraft aims at full Microsoft Excel parity, and at being better in places: faster, open (XLSX
is the native format), fully scriptable by agents (MCP, control channel, CLI), and on the web.
@@ -135,6 +135,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
| Date | What landed |
|---|---|
| 2026-11-01 | XLSX keeps the first visible workbook window's active sheet and display settings together, falling back to the first view when all are hidden. Tab ratios are normalized to 0–1000 on import and export; invalid numeric text uses 600. Synthetic round trips cover isolated attributes, missing views, and active tabs after sheet deletion/reordering; firstSheet remains separate #2264 work (#3057). |
| 2026-10-25 | AutoSum over a selection does what Excel does: a selected row gets one total to its right (in its last cell if that is empty), not a one-cell total under each cell; a block whose last column is empty gets each row's total there, one whose last row is empty each column's, and with both the corner gets the total of the block; otherwise totals go under each column as before |
| 2026-10-24 | Macro interface ([macros.md](docs/macros.md#in-the-window)): an Automate › Macros group (Record Macro, which turns into Stop Recording, Use Relative References, Run Macro with recent macros); "Recording · N steps" with Stop in the status bar; a Record Macro dialog (checked name, free Ctrl+Shift shortcut, description); the Macros dialog lists macros beside Automate scripts and a VBA project, with Run, Step Into, Edit, Create, Delete and Options; Ctrl+Shift+letter macro shortcuts; questions that offer Run, Overwrite, Shift Down or Shift Right; a step editor with a breakpoint gutter, drag to reorder, a command picker, reference boxes that switch between relative tokens and absolute references, Inputs and Output tabs, a debug bar (Check, Run, Step, Continue, Run to Here, Stop with keep or undo) and grid overlays for inputs, writes, conflicts and the current step |
| 2026-10-24 | Go To lists the named ranges of every sheet (`Sheet2!Name` for a name scoped to another sheet; hidden names, constants and formulas left out) and the references entered before, a double-click goes to an entry, and the Reference box takes several names or references separated by commas (`Price, Type`, `B14:C22,F19:G30`) |
@@ -244,6 +245,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
| Date | Change | Summary |
|---|---|---|
| 2026-11-01 | trivial | Workbook views: selected window settings stay together, tab ratios are bounded, and regressions cover isolated settings and sheet edits; readiness estimates unchanged (#3057) |
| 2026-10-25 | minor | Progress log: AutoSum over a selected row, and a selection's empty last row and column |
| 2026-10-24 | minor | Macro interface on the Automate tab; progress log row |
| 2026-10-24 | minor | Macro engine and `macro.*` commands; catalog 275 / 291 → 276 / 292 (View › Macros › Record Macro); progress log row |
+30
View File
@@ -2403,6 +2403,36 @@ fn deleting_a_3d_span_end_sheet_keeps_the_rest() {
}
}
#[test]
fn workbook_view_roundtrips_after_sheet_deletion_and_reordering() {
let view = gridcraft_model::WorkbookView { show_sheet_tabs: false, show_horizontal_scroll: true, show_vertical_scroll: false, tab_ratio: 350 };
for (initial_active, command, params, expected_order, expected_active) in [
("Third", "home.deleteSheet", json!({"sheet": "First"}), vec!["Second", "Third"], 1),
("Third", "home.deleteSheet", json!({"sheet": "Third"}), vec!["First", "Second"], 1),
("First", "home.deleteSheet", json!({"sheet": "Third"}), vec!["First", "Second"], 0),
("Third", "sheet.move", json!({"sheet": "Third", "to": 0}), vec!["Third", "First", "Second"], 0),
("Third", "sheet.move", json!({"sheet": "First", "to": 2}), vec!["Second", "Third", "First"], 2),
] {
let mut session = s();
session.execute("sheet.rename", json!({"name": "First"})).unwrap();
for name in ["Second", "Third"] {
session.execute("home.insertSheet", json!({"name": name})).unwrap();
}
std::sync::Arc::make_mut(&mut session.doc_mut().unwrap().wb).view = view;
session.execute("sheet.activate", json!({"sheet": initial_active})).unwrap();
session.execute(command, params).unwrap();
let wb = &session.doc().unwrap().wb;
assert_eq!(wb.active_sheet(), expected_active, "{command}");
assert_eq!(wb.sheets.iter().map(|s| s.name.as_str()).collect::<Vec<_>>(), expected_order, "{command}");
let (back, _) = gridcraft_xlsx::read_xlsx(&gridcraft_xlsx::write_xlsx(wb).unwrap()).unwrap();
assert_eq!(back.sheets.iter().map(|s| s.name.as_str()).collect::<Vec<_>>(), expected_order, "{command} after reopening");
assert_eq!(back.active_sheet(), expected_active, "activeTab follows {command}");
assert_eq!(back.active().unwrap().name, expected_order[expected_active], "the active sheet is not a stale index");
assert_eq!(back.view, view, "workbook display settings survive {command}");
}
}
#[test]
fn xlsx_roundtrip_through_engine() {
let mut s = s();
+5 -1
View File
@@ -33,7 +33,8 @@ pub enum ShowObjects {
/// The workbook window's display settings from `<workbookView>` (Excel: File › Options ›
/// Advanced, "Display options for this workbook"): whether the window shows the sheet tabs
/// and the two scroll bars, and how much of the horizontal bar the tabs take (`tabRatio`,
/// thousandths of the bar; the format's default is 600).
/// thousandths of the bar; the format's default is 600). XLSX I/O normalizes this
/// share to 0–1000, including values supplied directly through the model.
///
/// The values are modelled so an open + save keeps them — a workbook Excel saved with the
/// tabs hidden must not come back with them shown and the settings gone. The app does not
@@ -66,6 +67,9 @@ impl Default for WorkbookView {
}
impl WorkbookView {
/// The entire bar, in thousandths: the upper bound used by XLSX I/O.
pub const MAX_TAB_RATIO: u32 = 1000;
fn is_default(&self) -> bool {
*self == WorkbookView::default()
}
+9 -2
View File
@@ -223,6 +223,13 @@ fn read_workbook_properties(cx: &mut Ctx<'_>, wb: &mut Workbook, xml: &El) {
}
}
/// The single window modelled by GridCraft: prefer the first visible window,
/// including the format's default visibility, and otherwise retain the first one.
fn selected_workbook_view(workbook: &El) -> Option<&El> {
let views = workbook.child("bookViews")?;
views.kids("workbookView").find(|view| view.attr("visibility").unwrap_or("visible") == "visible").or_else(|| views.child("workbookView"))
}
pub fn read_xlsx(bytes: &[u8]) -> Result<(Workbook, ReadReport), IoError> {
let pkg = Package::open(bytes)?;
let mut cx = Ctx {
@@ -270,7 +277,7 @@ pub fn read_xlsx(bytes: &[u8]) -> Result<(Workbook, ReadReport), IoError> {
}
read_workbook_properties(&mut cx, &mut wb, &wb_xml);
let view_el = wb_xml.path(&["bookViews", "workbookView"]);
let view_el = selected_workbook_view(&wb_xml);
let active_tab = view_el.and_then(|v| v.attr_u32("activeTab")).unwrap_or(0) as usize;
if let Some(v) = view_el {
// The window's display settings (Excel's "Display options for this workbook").
@@ -280,7 +287,7 @@ pub fn read_xlsx(bytes: &[u8]) -> Result<(Workbook, ReadReport), IoError> {
show_sheet_tabs: v.flag("showSheetTabs", true),
show_horizontal_scroll: v.flag("showHorizontalScroll", true),
show_vertical_scroll: v.flag("showVerticalScroll", true),
tab_ratio: v.attr_u32("tabRatio").unwrap_or(600),
tab_ratio: v.attr_u32("tabRatio").unwrap_or(600).min(WorkbookView::MAX_TAB_RATIO),
};
}
+140
View File
@@ -1533,3 +1533,143 @@ fn workbook_view_settings_survive_a_round_trip() {
z.by_name("xl/workbook.xml").unwrap().read_to_string(&mut xml).unwrap();
assert!(xml.contains("<workbookView activeTab=\"0\"/>"), "defaults stay unwritten: {xml}");
}
/// Three original empty worksheets let view selection be checked through `activeTab`,
/// independently of the flags and ratio carried by the same window.
fn workbook_with_window_views(views: &str) -> Vec<u8> {
let workbook = format!(
r#"<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><bookViews>{views}</bookViews><sheets><sheet name="First" sheetId="1" r:id="rId1"/><sheet name="Second" sheetId="2" r:id="rId2"/><sheet name="Third" sheetId="3" r:id="rId3"/></sheets></workbook>"#
);
let rels = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/><Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet2.xml"/><Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet3.xml"/></Relationships>"#;
let sheet = br#"<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><sheetData/></worksheet>"#;
super::make_zip(&[
("[Content_Types].xml", super::CT.as_bytes()),
("_rels/.rels", super::ROOT_RELS.as_bytes()),
("xl/workbook.xml", workbook.as_bytes()),
("xl/_rels/workbook.xml.rels", rels.as_bytes()),
("xl/worksheets/sheet1.xml", sheet),
("xl/worksheets/sheet2.xml", sheet),
("xl/worksheets/sheet3.xml", sheet),
])
}
fn saved_workbook_view_xml(wb: &Workbook) -> String {
let bytes = write_xlsx(wb).unwrap();
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
let mut xml = String::new();
zip.by_name("xl/workbook.xml").unwrap().read_to_string(&mut xml).unwrap();
xml
}
#[test]
fn workbook_view_attributes_round_trip_independently() {
for (attribute, value, expected) in [
("showSheetTabs", "0", WorkbookView { show_sheet_tabs: false, ..Default::default() }),
("showSheetTabs", "1", WorkbookView::default()),
("showHorizontalScroll", "0", WorkbookView { show_horizontal_scroll: false, ..Default::default() }),
("showHorizontalScroll", "1", WorkbookView::default()),
("showVerticalScroll", "0", WorkbookView { show_vertical_scroll: false, ..Default::default() }),
("showVerticalScroll", "1", WorkbookView::default()),
("tabRatio", "250", WorkbookView { tab_ratio: 250, ..Default::default() }),
("tabRatio", "600", WorkbookView::default()),
] {
let view = format!("<workbookView {attribute}=\"{value}\"/>");
let (wb, _) = read_xlsx(&workbook_with_window_views(&view)).unwrap();
assert_eq!(wb.view, expected, "only {attribute} is supplied");
let xml = saved_workbook_view_xml(&wb);
assert_eq!(xml.contains(&format!("{attribute}=\"{value}\"")), expected != WorkbookView::default(), "{xml}");
for other in ["showSheetTabs", "showHorizontalScroll", "showVerticalScroll", "tabRatio"] {
if other != attribute {
assert!(!xml.contains(&format!("{other}=")), "unrelated defaults stay omitted: {xml}");
}
}
let (back, _) = read_xlsx(&write_xlsx(&wb).unwrap()).unwrap();
assert_eq!(back.view, expected, "{attribute}={value} survives a round trip");
}
}
#[test]
fn workbook_view_selects_first_visible_window_consistently() {
let cases = [
(
r#"<workbookView visibility="hidden" activeTab="0" showSheetTabs="0" showHorizontalScroll="0" showVerticalScroll="0" tabRatio="111"/><workbookView visibility="veryHidden" activeTab="1" tabRatio="222"/><workbookView visibility="visible" activeTab="2" showSheetTabs="1" showHorizontalScroll="0" showVerticalScroll="1" tabRatio="750"/><workbookView visibility="visible" activeTab="0" tabRatio="250"/>"#,
2,
WorkbookView { show_sheet_tabs: true, show_horizontal_scroll: false, show_vertical_scroll: true, tab_ratio: 750 },
),
// Visibility defaults to visible, so this view takes precedence over a later explicit one.
(
r#"<workbookView visibility="hidden" activeTab="0" showSheetTabs="0" tabRatio="111"/><workbookView activeTab="1" showVerticalScroll="0" tabRatio="350"/><workbookView visibility="visible" activeTab="2" tabRatio="900"/>"#,
1,
WorkbookView { show_vertical_scroll: false, tab_ratio: 350, ..Default::default() },
),
// No visible window: retain the first window's settings and active sheet as one unit.
(
r#"<workbookView visibility="hidden" activeTab="1" showSheetTabs="0" tabRatio="100"/><workbookView visibility="veryHidden" activeTab="2" tabRatio="900"/>"#,
1,
WorkbookView { show_sheet_tabs: false, tab_ratio: 100, ..Default::default() },
),
];
for (views, active, view) in cases {
let (wb, _) = read_xlsx(&workbook_with_window_views(views)).unwrap();
assert_eq!(wb.active_sheet(), active, "activeTab must come from the selected view: {views}");
assert_eq!(wb.view, view, "all settings must come from the selected view: {views}");
let (back, _) = read_xlsx(&write_xlsx(&wb).unwrap()).unwrap();
assert_eq!(back.active_sheet(), active);
assert_eq!(back.view, view);
}
}
#[test]
fn workbook_view_without_a_window_uses_defaults() {
for bytes in [super::minimal("", &[], "", ""), workbook_with_window_views("")] {
let (wb, _) = read_xlsx(&bytes).unwrap();
assert_eq!(wb.active_sheet(), 0);
assert_eq!(wb.view, WorkbookView::default());
let xml = saved_workbook_view_xml(&wb);
assert!(xml.contains("<workbookView activeTab=\"0\"/>"), "missing views keep defaults omitted: {xml}");
let (back, _) = read_xlsx(&write_xlsx(&wb).unwrap()).unwrap();
assert_eq!(back.active_sheet(), 0);
assert_eq!(back.view, WorkbookView::default());
}
}
#[test]
fn workbook_view_tab_ratio_normalizes_imported_values() {
for (raw, expected) in [
(None, 600),
(Some("0"), 0),
(Some("600"), 600),
(Some("1000"), 1000),
(Some("1001"), 1000),
(Some("4294967295"), 1000),
(Some("4294967296"), 600),
(Some("-1"), 600),
(Some("invalid"), 600),
(Some(""), 600),
] {
let attr = raw.map(|value| format!(" tabRatio=\"{value}\"")).unwrap_or_default();
let views = format!("<workbookView{attr}/>");
let (wb, _) = read_xlsx(&workbook_with_window_views(&views)).unwrap();
assert_eq!(wb.view.tab_ratio, expected, "tabRatio={raw:?}");
let xml = saved_workbook_view_xml(&wb);
if expected == 600 {
assert!(!xml.contains("tabRatio="), "the default is omitted: {xml}");
} else {
assert!(xml.contains(&format!("tabRatio=\"{expected}\"")), "{xml}");
}
let (back, _) = read_xlsx(&write_xlsx(&wb).unwrap()).unwrap();
assert_eq!(back.view.tab_ratio, expected);
}
}
#[test]
fn workbook_view_writer_clamps_out_of_range_model_values() {
for raw in [1001, u32::MAX] {
let mut wb = Workbook::new();
wb.view.tab_ratio = raw;
let xml = saved_workbook_view_xml(&wb);
assert!(xml.contains("tabRatio=\"1000\""), "an out-of-range model value is bounded on write: {xml}");
let (back, _) = read_xlsx(&write_xlsx(&wb).unwrap()).unwrap();
assert_eq!(back.view.tab_ratio, 1000);
}
}
+4 -3
View File
@@ -5,7 +5,7 @@ use std::fmt::Write as _;
use gridcraft_core::RangeRef;
use gridcraft_formula::quote_sheet;
use gridcraft_model::{CalcMode, CalcSettings, PhoneticString, ShowObjects, Style, Visibility, Workbook};
use gridcraft_model::{CalcMode, CalcSettings, PhoneticString, ShowObjects, Style, Visibility, Workbook, WorkbookView};
use crate::package::ZipOut;
use crate::xml::{esc, esc_attr};
@@ -223,8 +223,9 @@ fn workbook_view_attrs(wb: &Workbook) -> String {
if !wb.view.show_vertical_scroll {
attrs.push_str(" showVerticalScroll=\"0\"");
}
if wb.view.tab_ratio != 600 {
let _ = write!(attrs, " tabRatio=\"{}\"", wb.view.tab_ratio);
let tab_ratio = wb.view.tab_ratio.min(WorkbookView::MAX_TAB_RATIO);
if tab_ratio != 600 {
let _ = write!(attrs, " tabRatio=\"{tab_ratio}\"");
}
attrs
}
+23 -1
View File
@@ -1,6 +1,6 @@
# File-format parity
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-26 · **Change:** minor (`file.exportCsv {values: true}` writes raw values for automation; .xls dialog sheets are skipped instead of read as worksheets; `.xls` import reads each sheet's display options from WINDOW2: right-to-left, gridlines, headings, zeros, formulas; XLSX text boxes and shapes keep their paragraphs' right-to-left direction on save; CSV/TSV/TXT can open with every field kept as text via `file.open {csvAsText}`; formulas over Excel's 8,192 characters, and `%` or call chains past the nesting limit, are parse errors instead of stack overflows when a file is opened; XLSX PivotTables read and write `rowGrandTotals`/`colGrandTotals` the way Excel does, so a table saved with only one grand total opens with the right one; HTML export keeps right-to-left; CSV and text import detects likely Windows-1256 Arabic when invalid UTF-8 has Arabic byte runs; other invalid UTF-8 remains Windows-1252; HTML export places pictures and charts, as inline SVG, at their anchors) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
> **Last reviewed:** 2026-11-01 · **Last updated:** 2026-11-01 · **Change:** trivial (XLSX workbook-view selection, normalized tab ratios and sheet-edit regressions; fidelity estimates unchanged) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
Every format Excel opens or saves, with GridCraft's read and write support, fidelity and tests.
Part of [target-app-parity.md](target-app-parity.md). **File formats: ~50% ready, 80–120 h**
@@ -14,6 +14,27 @@ support comes from `crates/engine/src/io.rs` (`FileKind`), `crates/xlsx/src` and
Legacy scalar `INDEX(range, MATCH(...))` formulas remain ordinary formulas on save, including when
shared formulas are expanded during import (`array_export.rs`, #1505).
## Workbook window settings
GridCraft keeps one modelled workbook window. On XLSX import it selects the first visible
`workbookView` (omitted `visibility` means visible), or the first view when none is visible.
The active tab and display options come from that same view and survive the next save.
GridCraft normalizes `tabRatio` to 0–1000 on import and export; malformed or overflowing
unsigned-integer text uses the default 600. The valid endpoints 0 and 1000 survive, and
600 plus the default shown flags stay omitted from output. Synthetic regressions in
`crates/xlsx/src/tests/roundtrip.rs` cover each attribute independently, selection, fallback
and round trips (#3057). Engine regressions exercise real sheet deletion and reordering
commands and check that the saved active tab identifies the current sheet after reopening,
with the workbook display options preserved.
The format's [workbook-view defaults](https://learn.microsoft.com/en-us/dotnet/api/documentformat.openxml.spreadsheet.workbookview?view=openxml-3.0.1)
include visible windows and a tab ratio of 600. UI controls and rendering of these settings
remain separate #3057 work.
`firstSheet` records the leftmost visible tab, independently of `activeTab`. It is not modelled
by this branch. Supporting it needs its own view state and sheet-edit handling, tracked in
#2264; these regressions cover the active tab and existing display fields without introducing
that separate feature.
## Workbook formats
XLSX column declarations are limited to 1,000,000 expanded column assignments per worksheet,
@@ -110,6 +131,7 @@ refresh. Counted under features (Get & Transform, ~10% ready), not here.
| Date | Change | Summary |
|---|---|---|
| 2026-11-01 | trivial | XLSX selects the first visible workbook view as one unit and bounds imported/exported tab ratios; isolated attributes and sheet-edit round trips covered, with firstSheet left to #2264 (#3057) |
| 2026-10-21 | minor | HTML (Save as Web Page) writes pictures (`data:` URI `<img>`) and charts (inline SVG from the chart crate's drawing primitives, text included) at their anchors; the table uses the sheet's column widths and row heights when the sheet has either, and output for sheets without pictures or charts is unchanged |
| 2026-10-20 | trivial | XLSX shared strings: Japanese phonetic runs (ふりがな, `<rPh sb="…" eb="…">` with `<phoneticPr>`) are read into the workbook and written back next to the string with the same text, so an open + save no longer deletes them; bad offsets and readings are dropped with a warning, a `<phoneticPr>` without runs is kept on its own, and when duplicate `<si>` items share a text but not a reading only the first reading is written (reported on open) (`phonetic_tests.rs`) |
| 2026-10-20 | minor | A workbook theme's major/minor per-script typefaces (`<a:font script="Arab" typeface="…"/>`, also Hebr, Jpan and the rest) are read and written back in file order after `<a:cs>`; they were dropped before. A hostile file is held to 64 entries of 256 characters |