From f69861f7e3a983aa76e792e63a836f5b2d421c66 Mon Sep 17 00:00:00 2001 From: Mikkel Georgsen <7456949+mikl0s@users.noreply.github.com> Date: Sat, 10 Oct 2026 18:39:57 +0200 Subject: [PATCH 01/83] Cell: cell.set without an input is an error cell.set treated a missing input as an empty string, so a call that forgot the input (or sent it under the wrong key) cleared the cell and reported success. It now returns an error when neither `input` nor `value` is given. An explicit empty string still clears the cell. --- crates/engine/src/cmd/edit.rs | 7 +++++-- crates/mcp/src/tests.rs | 12 ++++++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index bb54fe0..a812c65 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -196,8 +196,11 @@ fn cell_set(s: &mut Session, p: &Json) -> Result { Some(c) => c, None => s.doc()?.selection.active, }; - let input = - str_param(p, "input").or_else(|| str_param(p, "value")).map(str::to_string).or_else(|| p.get("value").map(json_to_input)).unwrap_or_default(); + let input = str_param(p, "input") + .or_else(|| str_param(p, "value")) + .map(str::to_string) + .or_else(|| p.get("value").map(json_to_input)) + .ok_or_else(|| bad("cell.set", "missing `input` (or `value`)"))?; let array = bool_param(p, "array").unwrap_or(false); let protected = s.doc()?.wb.sheet(sheet).is_some_and(|sh| sh.is_protected() && s.doc().is_ok_and(|d| d.wb.styles.get(sh.style_id(at)).protection.locked)); diff --git a/crates/mcp/src/tests.rs b/crates/mcp/src/tests.rs index cf590be..1d4abb4 100644 --- a/crates/mcp/src/tests.rs +++ b/crates/mcp/src/tests.rs @@ -339,6 +339,18 @@ fn serve_loop_over_buffers() { assert!(lines[2]["result"]["content"][0]["text"].as_str().unwrap().contains("42")); } +#[test] +fn cell_set_without_input_is_error() { + let mut s = server(); + ok(&mut s, "set_cell", json!({"cell": "B2", "input": "keep"})); + fails(&mut s, "execute_command", json!({"command": "cell.set", "params": {"cell": "B2"}})); + fails(&mut s, "execute_command", json!({"command": "cell.set", "params": {}})); + assert_eq!(ok(&mut s, "get_cell", json!({"cell": "B2"}))["text"], "keep"); + // An explicit empty string still clears. + ok(&mut s, "execute_command", json!({"command": "cell.set", "params": {"cell": "B2", "input": ""}})); + assert_eq!(ok(&mut s, "get_cell", json!({"cell": "B2"}))["text"], ""); +} + #[test] fn remote_backend_over_tcp() { // A fake app: answers engine.execute by running a headless session; closes after the first From 271119d9e32d8f74ac088a7a204638fa04a1e8b4 Mon Sep 17 00:00:00 2001 From: Zarvinx Date: Sun, 11 Oct 2026 00:27:43 +0300 Subject: [PATCH 02/83] Build: satisfy Rust 1.95 clippy --- crates/ui-egui/src/grid.rs | 19 ++++++------------ crates/xlsx/src/sheet_read.rs | 36 +++++++++++++++++------------------ 2 files changed, 23 insertions(+), 32 deletions(-) diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 72d33e2..1b4a0a0 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -1657,14 +1657,12 @@ fn keyboard(app: &mut SheetApp, ctx: &egui::Context, resp: &egui::Response, geo: app.copy_to_clipboard(ctx, "edit.cut"); } egui::Event::Paste(text) => app.run_or_alert("edit.paste", json!({"text": text})), - egui::Event::Text(text) => { - if text.chars().all(|c| !c.is_control()) && !text.is_empty() { - app.begin_edit(Some(text), false); - if let Some(ed) = app.editor.as_mut() { - ed.caret = ed.text.chars().count(); - } - return; + egui::Event::Text(text) if text.chars().all(|c| !c.is_control()) && !text.is_empty() => { + app.begin_edit(Some(text), false); + if let Some(ed) = app.editor.as_mut() { + ed.caret = ed.text.chars().count(); } + return; } egui::Event::Key { key, pressed: true, modifiers: m, .. } => { use egui::Key; @@ -1777,12 +1775,7 @@ fn filter_menu(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, was_open: bool) let Some(sh) = wb.sheet(si) else { return }; let _ = geo; // Tables get an autofilter on demand. - let table_range = sh - .tables - .iter() - .find(|t| t.range.start.row <= at.y as u32 || true) - .filter(|t| col >= t.range.start.col && col <= t.range.end.col) - .map(|t| t.range); + let table_range = sh.tables.first().filter(|t| col >= t.range.start.col && col <= t.range.end.col).map(|t| t.range); if sh.autofilter.is_none() && let Some(r) = table_range { diff --git a/crates/xlsx/src/sheet_read.rs b/crates/xlsx/src/sheet_read.rs index 065558e..8cdd2ca 100644 --- a/crates/xlsx/src/sheet_read.rs +++ b/crates/xlsx/src/sheet_read.rs @@ -142,26 +142,24 @@ pub fn read_sheet(cx: &mut Ctx<'_>, part: &str, name: &str) -> Result sheet.print.row_breaks = e.kids("brk").filter_map(|b| b.attr_u32("id")).filter(|&r| r > 0 && r < MAX_ROWS).collect(), "colBreaks" => sheet.print.col_breaks = e.kids("brk").filter_map(|b| b.attr_u32("id")).filter(|&c| c > 0 && c < MAX_COLS).collect(), - "sheetProtection" => { - if e.flag("sheet", false) { - if e.attr("password").is_some() || e.attr("hashValue").is_some() { - cx.warn(format!("sheet \"{name}\": the protection password was not imported")); - } - sheet.protection = Some(SheetProtection { - password_hash: None, - select_locked: !e.flag("selectLockedCells", false), - select_unlocked: !e.flag("selectUnlockedCells", false), - format_cells: !e.flag("formatCells", true), - format_columns: !e.flag("formatColumns", true), - format_rows: !e.flag("formatRows", true), - insert_columns: !e.flag("insertColumns", true), - insert_rows: !e.flag("insertRows", true), - delete_columns: !e.flag("deleteColumns", true), - delete_rows: !e.flag("deleteRows", true), - sort: !e.flag("sort", true), - autofilter: !e.flag("autoFilter", true), - }); + "sheetProtection" if e.flag("sheet", false) => { + if e.attr("password").is_some() || e.attr("hashValue").is_some() { + cx.warn(format!("sheet \"{name}\": the protection password was not imported")); } + sheet.protection = Some(SheetProtection { + password_hash: None, + select_locked: !e.flag("selectLockedCells", false), + select_unlocked: !e.flag("selectUnlockedCells", false), + format_cells: !e.flag("formatCells", true), + format_columns: !e.flag("formatColumns", true), + format_rows: !e.flag("formatRows", true), + insert_columns: !e.flag("insertColumns", true), + insert_rows: !e.flag("insertRows", true), + delete_columns: !e.flag("deleteColumns", true), + delete_rows: !e.flag("deleteRows", true), + sort: !e.flag("sort", true), + autofilter: !e.flag("autoFilter", true), + }); } "drawing" => { if let Some(rel) = e.attr("id").and_then(|id| rels.iter().find(|r| r.id == id)) { From 27be1753c2d8c1284498a46b4547ccc4d7d7a83c Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sat, 10 Oct 2026 23:43:32 +0200 Subject: [PATCH 03/83] Read the browser clock for NOW(), TODAY() and year-less dates on wasm (#202) --- Cargo.lock | 1 + crates/calc/src/recalc.rs | 14 ++++---------- crates/core/Cargo.toml | 3 +++ crates/core/src/date.rs | 28 ++++++++++++++++++++++++++++ crates/core/src/parse.rs | 14 +++++--------- 5 files changed, 41 insertions(+), 19 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5dce477..e1d8d3e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1469,6 +1469,7 @@ dependencies = [ name = "gridcraft-core" version = "0.4.0" dependencies = [ + "js-sys", "serde", "serde_json", "thiserror 2.0.21", diff --git a/crates/calc/src/recalc.rs b/crates/calc/src/recalc.rs index 4cf0c6a..9df7245 100644 --- a/crates/calc/src/recalc.rs +++ b/crates/calc/src/recalc.rs @@ -919,17 +919,11 @@ impl Calc { } } -/// Local date-time as a serial (1900 system). Wasm without a clock returns a fixed date. -#[allow(clippy::disallowed_methods)] // the clock is read only off wasm +/// Current date-time as a serial (1900 system), from the system clock (browser clock on wasm). +/// UTC; the local offset isn't available without platform calls. Falls back to a fixed date only +/// when no clock is available. pub fn now_serial() -> f64 { - #[cfg(not(target_arch = "wasm32"))] - { - if let Ok(d) = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH) { - // UTC; local offset isn't available without platform calls. - return 25569.0 + d.as_secs_f64() / 86400.0; - } - } - 46302.5 + gridcraft_core::date::unix_now_ms().map_or(46302.5, gridcraft_core::date::serial_from_unix_ms) } /// Evaluates a formula in the context of a cell without storing it (conditional formats, data diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 26cd19f..1decf62 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -12,5 +12,8 @@ workspace = true serde = { workspace = true } thiserror = { workspace = true } +[target.'cfg(target_arch = "wasm32")'.dependencies] +js-sys = "0.3" + [dev-dependencies] serde_json = { workspace = true } diff --git a/crates/core/src/date.rs b/crates/core/src/date.rs index 81fa6c5..3b126b7 100644 --- a/crates/core/src/date.rs +++ b/crates/core/src/date.rs @@ -39,6 +39,26 @@ pub fn days_from_civil(y: i32, m: u32, d: u32) -> i64 { era * 146097 + doe - 719468 } +/// Milliseconds since the Unix epoch (UTC) from the system clock; `None` when the clock is unavailable. +/// On wasm32 `std::time::SystemTime::now` traps, so the browser clock is read through `Date.now()`. +#[allow(clippy::disallowed_methods)] // the std clock is read only off wasm +pub fn unix_now_ms() -> Option { + #[cfg(not(target_arch = "wasm32"))] + { + let d = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).ok()?; + Some(d.as_secs_f64() * 1000.0) + } + #[cfg(target_arch = "wasm32")] + { + Some(js_sys::Date::now()) + } +} + +/// Date serial (1900 system, with fraction) for a Unix time in milliseconds. +pub fn serial_from_unix_ms(ms: f64) -> f64 { + 25569.0 + ms / 86_400_000.0 +} + pub fn civil_from_days(z: i64) -> (i32, u32, u32) { let z = z + 719468; let era = if z >= 0 { z } else { z - 146096 } / 146097; @@ -160,6 +180,14 @@ pub const WEEKDAYS: [&str; 7] = ["Sunday", "Monday", "Tuesday", "Wednesday", "Th mod tests { use super::*; + #[test] + fn serial_from_unix_time() { + assert_eq!(serial_from_unix_ms(0.0), 25569.0); + // 2026-10-10T12:00:00Z + assert_eq!(serial_from_unix_ms(1_791_633_600_000.0), 46305.5); + assert!(unix_now_ms().is_some_and(|ms| serial_from_unix_ms(ms) > 46305.0)); + } + #[test] fn known_serials() { let s = DateSystem::D1900; diff --git a/crates/core/src/parse.rs b/crates/core/src/parse.rs index 7169ae5..b0ed80e 100644 --- a/crates/core/src/parse.rs +++ b/crates/core/src/parse.rs @@ -273,16 +273,12 @@ fn valid_ymd(sys: DateSystem, y: i64, m: i64, d: i64) -> Option { serial_from_ymd(sys, y, m, d) } -/// The year used for dates typed without one. Fixed per process from the system clock when -/// available (wasm without clock falls back to 2026). -#[allow(clippy::disallowed_methods)] // the clock is read only off wasm +/// The year used for dates typed without one, from the system clock (browser clock on wasm). +/// Falls back to 2026 only when no clock is available. pub fn current_year() -> i64 { - #[cfg(not(target_arch = "wasm32"))] - { - if let Ok(d) = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH) { - let days = (d.as_secs() / 86400) as i64; - return crate::date::civil_from_days(days).0 as i64; - } + if let Some(ms) = crate::date::unix_now_ms() { + let days = (ms / 86_400_000.0).floor() as i64; + return crate::date::civil_from_days(days).0 as i64; } 2026 } From 5722dc5355a6e19e3b24889eeffc7a37522d5a35 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sat, 10 Oct 2026 23:45:21 +0200 Subject: [PATCH 04/83] Do not draw the selection over the frozen pane when it lies only in the scrolling pane (#198) --- crates/ui-egui/src/chartview.rs | 5 +- crates/ui-egui/src/grid.rs | 173 ++++++++++++++++++++++++++++---- 2 files changed, 155 insertions(+), 23 deletions(-) diff --git a/crates/ui-egui/src/chartview.rs b/crates/ui-egui/src/chartview.rs index adeeddb..bb40a4a 100644 --- a/crates/ui-egui/src/chartview.rs +++ b/crates/ui-egui/src/chartview.rs @@ -303,7 +303,10 @@ pub fn paint_objects(app: &SheetApp, p: &Painter, geo: &Geo, wb: &Workbook, si: && rf.sheet_name().is_none_or(|n| n.eq_ignore_ascii_case(&sh.name)) { let rr = geo.range_rect(sh, rf.range()); - p.rect_stroke(rr, 0.0, Stroke::new(1.5, Color32::from_rgb(0x2E, 0x6F, 0xD8)), StrokeKind::Inside); + for pane in geo.range_panes(rf.range()) { + let pp = p.with_clip_rect(pane.intersect(p.clip_rect())); + pp.rect_stroke(rr, 0.0, Stroke::new(1.5, Color32::from_rgb(0x2E, 0x6F, 0xD8)), StrokeKind::Inside); + } } } } diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 72d33e2..93a6709 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -186,6 +186,33 @@ impl Geo { let c1 = self.ci.at(left + ((self.cells.width() - self.frozen_w) / self.z) as f64).min(MAX_COLS - 1); (c0, c1) } + /// Screen areas of the panes (frozen rows/columns or the scrolling part) that hold part of `r`. + /// The panes share one coordinate system, so a range that is not clipped to these is also drawn + /// over the frozen rows/columns when it is scrolled under them. + pub fn range_panes(&self, r: RangeRef) -> Vec { + let split = pos2(self.cells.left() + self.frozen_w, self.cells.top() + self.frozen_h); + let mut xs = Vec::new(); + if r.start.col < self.fc { + xs.push((self.cells.left(), split.x)); + } + if r.end.col >= self.fc { + xs.push((split.x, self.cells.right())); + } + let mut ys = Vec::new(); + if r.start.row < self.fr { + ys.push((self.cells.top(), split.y)); + } + if r.end.row >= self.fr { + ys.push((split.y, self.cells.bottom())); + } + ys.iter().flat_map(|&(y0, y1)| xs.iter().map(move |&(x0, x1)| Rect::from_min_max(pos2(x0, y0), pos2(x1, y1)))).collect() + } + /// True when cell `c` is in the scrolling pane but scrolled entirely under the frozen rows or columns. + pub fn hidden_under_frozen(&self, sh: &Sheet, c: CellRef) -> bool { + let r = self.cell_rect(sh, c); + (self.fc > 0 && c.col >= self.fc && r.right() <= self.cells.left() + self.frozen_w) + || (self.fr > 0 && c.row >= self.fr && r.bottom() <= self.cells.top() + self.frozen_h) + } } /// Resolved look of a cell for painting. @@ -396,7 +423,7 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) { } let p = painter.with_clip_rect(*clip); paint_quadrant(&p, &geo, &wb, si, sh, *rows, *cols, &mut cf, &t); - paint_selection(&p, &geo, sh, &sel, &t, app); + paint_selection(&p, &geo, sh, &sel, &t, app, *rows, *cols); } // Freeze lines. if geo.fr > 0 { @@ -901,21 +928,40 @@ fn paint_cf_icon(p: &Painter, r: Rect, set: &str, idx: usize) { } } -fn paint_selection(p: &Painter, geo: &Geo, sh: &Sheet, sel: &gridcraft_engine::Selection, t: &Tokens, app: &SheetApp) { +/// True when `r` covers at least one cell of the pane that shows `rows` x `cols` (inclusive). +/// The panes of a frozen sheet share one coordinate system, so a range that lives only in the +/// scrolling pane would otherwise be drawn over the frozen pane as well. +fn range_in_pane(r: RangeRef, rows: (u32, u32), cols: (u32, u32)) -> bool { + r.start.row <= rows.1 && r.end.row >= rows.0 && r.start.col <= cols.1 && r.end.col >= cols.0 +} + +#[allow(clippy::too_many_arguments)] +fn paint_selection( + p: &Painter, + geo: &Geo, + sh: &Sheet, + sel: &gridcraft_engine::Selection, + t: &Tokens, + app: &SheetApp, + rows: (u32, u32), + cols: (u32, u32), +) { // Fill all areas except the active cell. for r in &sel.ranges { + if !range_in_pane(*r, rows, cols) { + continue; + } if r.is_single() && sel.ranges.len() == 1 { continue; } let rect = geo.range_rect(sh, *r); p.rect_filled(rect, 0.0, t.sel_fill); } - let active = geo.cell_rect(sh, sh.merge_at(sel.active).map(|m| m.start).unwrap_or(sel.active)); - let active = match sh.merge_at(sel.active) { - Some(m) => geo.range_rect(sh, m), - None => active, - }; - if !(sel.ranges.len() == 1 && sel.current().is_single()) { + let active_range = sh.merge_at(sel.active).unwrap_or_else(|| RangeRef::cell(sel.active)); + let active = geo.range_rect(sh, active_range); + let active_here = range_in_pane(active_range, rows, cols); + let cur_here = range_in_pane(sel.current(), rows, cols); + if active_here && !(sel.ranges.len() == 1 && sel.current().is_single()) { p.rect_filled(active.shrink(1.0), 0.0, t.grid_bg.gamma_multiply(0.0)); } let cur = geo.range_rect(sh, sel.current()); @@ -927,6 +973,9 @@ fn paint_selection(p: &Painter, geo: &Geo, sh: &Sheet, sel: &gridcraft_engine::S if sheet.as_deref().is_some_and(|s| !s.eq_ignore_ascii_case(&sh.name)) { continue; } + if !range_in_pane(r, rows, cols) { + continue; + } let rr = geo.range_rect(sh, r); let col = REF_COLORS[idx]; p.rect_filled(rr, 0.0, col.gamma_multiply(0.12)); @@ -936,18 +985,22 @@ fn paint_selection(p: &Painter, geo: &Geo, sh: &Sheet, sel: &gridcraft_engine::S } } } - p.rect_stroke(cur.expand(0.5), 0.0, Stroke::new(2.0, t.sel_border), StrokeKind::Middle); - if sel.ranges.len() > 1 { + if cur_here { + p.rect_stroke(cur.expand(0.5), 0.0, Stroke::new(2.0, t.sel_border), StrokeKind::Middle); + } + if active_here && sel.ranges.len() > 1 { p.rect_stroke(active, 0.0, Stroke::new(1.0, t.sel_border), StrokeKind::Inside); } // Fill handle (bottom-right of the current area). - if app.editor.is_none() { + if app.editor.is_none() && range_in_pane(RangeRef::cell(sel.current().end), rows, cols) { let h = Rect::from_center_size(cur.right_bottom(), vec2(6.0, 6.0)); p.rect_filled(h.expand(1.0), 0.0, Color32::WHITE); p.rect_filled(h, 0.0, t.sel_border); } // Fill-handle drag preview. - if let crate::grid::Drag::Fill { target: Some(tr) } = &app.grid.drag { + if let crate::grid::Drag::Fill { target: Some(tr) } = &app.grid.drag + && range_in_pane(*tr, rows, cols) + { let rr = geo.range_rect(sh, *tr); p.extend(egui::Shape::dashed_line( &[rr.left_top(), rr.right_top(), rr.right_bottom(), rr.left_bottom(), rr.left_top()], @@ -959,12 +1012,15 @@ fn paint_selection(p: &Painter, geo: &Geo, sh: &Sheet, sel: &gridcraft_engine::S if let crate::grid::Drag::Move { to: Some(to), .. } = &app.grid.drag { let r = sel.current(); let moved = RangeRef::new(*to, to.offset_clamped(r.height() as i64 - 1, r.width() as i64 - 1)); - let rr = geo.range_rect(sh, moved); - p.rect_stroke(rr, 0.0, Stroke::new(2.0, Color32::from_gray(110)), StrokeKind::Middle); + if range_in_pane(moved, rows, cols) { + let rr = geo.range_rect(sh, moved); + p.rect_stroke(rr, 0.0, Stroke::new(2.0, Color32::from_gray(110)), StrokeKind::Middle); + } } // 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) + && range_in_pane(clip.range, rows, cols) { let rr = geo.range_rect(sh, clip.range).expand(0.5); let phase = app.grid.marching_phase; @@ -1065,6 +1121,12 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & let sel = app.session.active().map(|d| d.selection.clone()).unwrap_or_default(); let cur_rect = geo.range_rect(sh, sel.current()); let handle = Rect::from_center_size(cur_rect.right_bottom(), vec2(9.0, 9.0)); + // The fill handle and the move border only react in the panes where they are painted, not + // over the frozen rows/columns that a scrolled selection lies under. + let handle_panes = geo.range_panes(RangeRef::cell(sel.current().end)); + let border_panes = geo.range_panes(sel.current()); + let on_handle = |p: Pos2| handle.contains(p) && handle_panes.iter().any(|r| r.contains(p)); + let on_cur_border = |p: Pos2| on_border(cur_rect, p) && border_panes.iter().any(|r| r.contains(p)); // Hover cursors. if resp.hovered() @@ -1076,12 +1138,12 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & ctx.set_cursor_icon(CursorIcon::ResizeColumn); } else if near_row_edge { ctx.set_cursor_icon(CursorIcon::ResizeRow); - } else if handle.contains(p) && app.editor.is_none() { + } else if on_handle(p) && app.editor.is_none() { ctx.set_cursor_icon(CursorIcon::Crosshair); if app.grid.drag == Drag::None && !pointer.any_down() { resp.clone().on_hover_text_at_pointer("Drag to fill cells. Drag from inside a cell to select a range."); } - } else if in_cells && app.editor.is_none() && on_border(cur_rect, p) { + } else if in_cells && app.editor.is_none() && on_cur_border(p) { ctx.set_cursor_icon(CursorIcon::Move); } else if in_cells { ctx.set_cursor_icon(CursorIcon::Cell); @@ -1258,11 +1320,11 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & if app.editor.is_some() && !app.commit_edit(0, 0, false, false) { return; } - if handle.contains(p) { + if on_handle(p) { app.grid.drag = Drag::Fill { target: None }; return; } - if on_border(cur_rect, p) && resp.drag_started() { + if on_cur_border(p) && resp.drag_started() { app.grid.drag = Drag::Move { grab: c, to: None }; return; } @@ -1442,7 +1504,7 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & // Double-click the fill handle. if resp.double_clicked() && let Some(p) = pos - && handle.contains(p) + && on_handle(p) { // Fill down as far as the neighbouring column goes. let src = sel.current(); @@ -1954,7 +2016,7 @@ pub fn point_move(app: &mut SheetApp, ed: &mut EditState, dr: i64, dc: i64, exte /// The dropdown arrow beside a cell with an in-cell list validation. pub fn validation_arrow(sh: &Sheet, geo: &Geo, c: CellRef) -> Option { let dv = sh.validations.iter().find(|d| d.ranges.iter().any(|r| r.contains(c)))?; - if dv.kind != gridcraft_engine::model::ValidationKind::List || !dv.in_cell_dropdown { + if dv.kind != gridcraft_engine::model::ValidationKind::List || !dv.in_cell_dropdown || geo.hidden_under_frozen(sh, c) { return None; } let r = geo.cell_rect(sh, c); @@ -1986,6 +2048,7 @@ fn paint_overlays( && let Some(dv) = sh.validations.iter().find(|d| d.ranges.iter().any(|r| r.contains(sel.active))) && dv.show_input && !(dv.input_title.is_empty() && dv.input_message.is_empty()) + && !geo.hidden_under_frozen(sh, sel.active) { let r = geo.cell_rect(sh, sel.active); let at = pos2(r.left() + 8.0, r.bottom() + 6.0); @@ -2007,7 +2070,7 @@ fn paint_overlays( continue; } let r = geo.cell_rect(sh, *c); - if !p.clip_rect().intersects(r) { + if !p.clip_rect().intersects(r) || geo.hidden_under_frozen(sh, *c) { continue; } let at = pos2(r.right() + 10.0, r.top()); @@ -2047,7 +2110,9 @@ fn paint_overlays( let Some(t) = target else { continue }; let tr = geo.range_rect(sh, t); if !t.is_single() { - p.rect_stroke(tr, 0.0, Stroke::new(1.5, blue), StrokeKind::Inside); + for pane in geo.range_panes(t) { + p.with_clip_rect(pane.intersect(p.clip_rect())).rect_stroke(tr, 0.0, Stroke::new(1.5, blue), StrokeKind::Inside); + } } let (from, to) = if it.get("range").is_some() { (tr.center(), active) } else { (active, tr.center()) }; p.line_segment([from, to], Stroke::new(1.5, blue)); @@ -2223,3 +2288,67 @@ fn paint_pages(app: &mut SheetApp, p: &Painter, geo: &Geo, sh: &Sheet) { } p.rect_stroke(pr, 0.0, Stroke::new(3.0, blue), StrokeKind::Outside); } + +#[cfg(test)] +mod pane_tests { + use super::*; + + fn rng(r0: u32, c0: u32, r1: u32, c1: u32) -> RangeRef { + RangeRef::new(CellRef::new(r0, c0), CellRef::new(r1, c1)) + } + + #[test] + fn selection_in_scrolling_pane_is_not_drawn_in_frozen_pane() { + // Frozen: 2 rows and 2 columns; the scrolling pane shows rows 2..=20 and columns 2..=10. + let frozen_cols = ((2, 20), (0, 1)); + let frozen_rows = ((0, 1), (2, 10)); + let corner = ((0, 1), (0, 1)); + let scrolled = ((2, 20), (2, 10)); + let sel = rng(5, 4, 7, 6); + assert!(range_in_pane(sel, scrolled.0, scrolled.1)); + assert!(!range_in_pane(sel, frozen_cols.0, frozen_cols.1)); + assert!(!range_in_pane(sel, frozen_rows.0, frozen_rows.1)); + assert!(!range_in_pane(sel, corner.0, corner.1)); + } + + #[test] + fn selection_across_the_freeze_line_is_drawn_in_both_panes() { + let sel = rng(5, 0, 7, 6); + assert!(range_in_pane(sel, (2, 20), (0, 1))); + assert!(range_in_pane(sel, (2, 20), (2, 10))); + } + + /// Two frozen rows and columns, scrolled right so that columns C:E lie under the frozen columns. + fn frozen_geo(sh: &mut Sheet) -> Geo { + sh.freeze = Some((2, 2)); + let scroll = vec2(3.0 * sh.default_col_width + 8.0, 0.0); + Geo::new(sh, Rect::from_min_size(pos2(0.0, 0.0), vec2(800.0, 600.0)), scroll) + } + + #[test] + fn overlays_skip_cells_scrolled_under_the_frozen_columns() { + let mut sh = Sheet::new("S"); + let geo = frozen_geo(&mut sh); + assert!(geo.hidden_under_frozen(&sh, CellRef::new(5, 2))); + assert!(geo.hidden_under_frozen(&sh, CellRef::new(5, 4))); + assert!(!geo.hidden_under_frozen(&sh, CellRef::new(5, 5))); + assert!(!geo.hidden_under_frozen(&sh, CellRef::new(5, 0))); + let unscrolled = Geo::new(&sh, geo.rect, vec2(0.0, 0.0)); + assert!(!unscrolled.hidden_under_frozen(&sh, CellRef::new(5, 2))); + } + + #[test] + fn ranges_are_clipped_to_the_panes_that_hold_them() { + let mut sh = Sheet::new("S"); + let geo = frozen_geo(&mut sh); + let split = pos2(geo.cells.left() + geo.frozen_w, geo.cells.top() + geo.frozen_h); + assert_eq!(geo.range_panes(rng(5, 3, 7, 6)), [Rect::from_min_max(split, geo.cells.max)]); + assert_eq!(geo.range_panes(rng(5, 0, 7, 6)).len(), 2); + assert_eq!(geo.range_panes(rng(0, 0, 7, 6)).len(), 4); + assert_eq!(geo.range_panes(rng(0, 0, 1, 1)), [Rect::from_min_max(geo.cells.min, split)]); + // The fill handle of D6:E7 sits over the frozen columns, where nothing of it is shown. + let handle = geo.range_rect(&sh, rng(5, 3, 6, 4)).right_bottom(); + assert!(handle.x < split.x); + assert!(!geo.range_panes(rng(6, 4, 6, 4)).iter().any(|r| r.contains(handle))); + } +} From 36c591883b88d1b8fda497719b6a7f51f81ce4bd Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sat, 10 Oct 2026 23:44:01 +0200 Subject: [PATCH 05/83] Resize charts from the corner handle and the Size fields, keep typed chart and table names (#190) The corner drag now grabs the chart where the press began and the selected object's corner shows a resize cursor. The Format Chart Area Size fields, chart and axis title fields and the Table Name field reloaded their value from the workbook every frame, so typed or dragged edits were thrown away; they now keep a draft until the field loses focus (Escape cancels, like sheet tab rename). --- crates/ui-egui/src/grid.rs | 16 ++- crates/ui-egui/src/panes.rs | 26 ++-- crates/ui-egui/src/ribbon.rs | 7 +- crates/ui-egui/src/widgets.rs | 19 +++ crates/ui-egui/tests/chart_resize.rs | 119 ++++++++++++++++++ crates/ui-egui/tests/committed_text_fields.rs | 81 ++++++++++++ docs/chart-parity.md | 2 +- 7 files changed, 254 insertions(+), 16 deletions(-) create mode 100644 crates/ui-egui/tests/chart_resize.rs create mode 100644 crates/ui-egui/tests/committed_text_fields.rs diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 72d33e2..d890f17 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -1047,6 +1047,9 @@ fn paint_headers(p: &Painter, geo: &Geo, sh: &Sheet, sel: &gridcraft_engine::Sel // ------------------------------------------------------------------ interaction +/// How close (in points) a press must be to an object's bottom-right corner to resize it instead of moving it. +const OBJECT_RESIZE_RADIUS: f32 = 10.0; + fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &Geo, sh: &Sheet, wb: &Workbook) { // Text selection and editing must not also move the object or edit worksheet cells. if app.text_box_editor.is_some() { @@ -1072,7 +1075,12 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & { let near_col_edge = in_col_header && col_edge(geo, sh, p.x).is_some(); let near_row_edge = in_row_header && row_edge(geo, sh, p.y).is_some(); - if near_col_edge { + let selected_object = crate::chartview::hit(app, geo, sh, p).filter(|&(_, id, _)| app.selected_chart == Some(id)); + let near_corner = selected_object.is_some_and(|(_, _, rect)| (p - rect.right_bottom()).length() < OBJECT_RESIZE_RADIUS); + let near_object_corner = app.grid.drag == Drag::None && near_corner; + if near_object_corner || matches!(app.grid.drag, Drag::Object { resize: true, .. }) { + ctx.set_cursor_icon(CursorIcon::ResizeNwSe); + } else if near_col_edge { ctx.set_cursor_icon(CursorIcon::ResizeColumn); } else if near_row_edge { ctx.set_cursor_icon(CursorIcon::ResizeRow); @@ -1135,7 +1143,9 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & } // Objects (charts, pictures, shapes) take clicks first. if resp.drag_started() || primary_clicked { - if let Some(p) = pos + // A drag is recognised only after the pointer has moved, so grab the object where the press began. + let at = if resp.drag_started() { pointer.press_origin().or(pos) } else { pos }; + if let Some(p) = at && let Some((kind, id, rect)) = crate::chartview::hit(app, geo, sh, p) { app.selected_chart = Some(id); @@ -1151,7 +1161,7 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & } if resp.drag_started() { let a = object_anchor(sh, kind, id); - let resize = (p - rect.right_bottom()).length() < 10.0; + let resize = (p - rect.right_bottom()).length() < OBJECT_RESIZE_RADIUS; if let Some(a) = a { app.grid.drag = Drag::Object { kind, id, start: p, orig: (a.cell, a.dx, a.dy, a.width, a.height), resize }; } diff --git a/crates/ui-egui/src/panes.rs b/crates/ui-egui/src/panes.rs index fb8a9d7..47da52d 100644 --- a/crates/ui-egui/src/panes.rs +++ b/crates/ui-egui/src/panes.rs @@ -152,8 +152,8 @@ fn format_chart(app: &mut SheetApp, ui: &mut Ui) { }; let theme = app.session.active().map(|d| d.wb.theme.clone()).unwrap_or_default(); ui.collapsing("Chart Title", |ui| { - let mut title = chart.title.clone().unwrap_or_default(); - if ui.text_edit_singleline(&mut title).lost_focus() { + let field = egui::Id::new(("gridcraft.chart_title", id)); + if let Some(title) = crate::widgets::committed_text(ui, field, chart.title.as_deref().unwrap_or(""), None) { app.run_or_alert("chart.set", json!({"chart": id, "title": title})); } }); @@ -195,17 +195,17 @@ fn format_chart(app: &mut SheetApp, ui: &mut Ui) { if ui.checkbox(&mut gl, "Gridlines").changed() { app.run_or_alert("chart.set", json!({"chart": id, "gridlines": gl})); } - let mut xt = chart.x_title.clone().unwrap_or_default(); ui.horizontal(|ui| { ui.label("Horizontal axis title:"); - if ui.text_edit_singleline(&mut xt).lost_focus() { + let field = egui::Id::new(("gridcraft.chart_x_title", id)); + if let Some(xt) = crate::widgets::committed_text(ui, field, chart.x_title.as_deref().unwrap_or(""), None) { app.run_or_alert("chart.set", json!({"chart": id, "xTitle": if xt.is_empty() { Json::Null } else { json!(xt) }})); } }); - let mut yt = chart.y_title.clone().unwrap_or_default(); ui.horizontal(|ui| { ui.label("Vertical axis title:"); - if ui.text_edit_singleline(&mut yt).lost_focus() { + let field = egui::Id::new(("gridcraft.chart_y_title", id)); + if let Some(yt) = crate::widgets::committed_text(ui, field, chart.y_title.as_deref().unwrap_or(""), None) { app.run_or_alert("chart.set", json!({"chart": id, "yTitle": if yt.is_empty() { Json::Null } else { json!(yt) }})); } }); @@ -250,12 +250,20 @@ fn format_chart(app: &mut SheetApp, ui: &mut Ui) { }); }); ui.collapsing("Size", |ui| { - let mut w = chart.anchor.width; - let mut h = chart.anchor.height; + // Keep the value being typed or dragged between frames; reloading it from the chart every frame threw the edit away. + let draft = egui::Id::new(("gridcraft.chart_size_draft", id)); + let (mut w, mut h) = ui.data_mut(|d| d.get_temp::<(f32, f32)>(draft)).unwrap_or((chart.anchor.width, chart.anchor.height)); let a = ui.add(egui::DragValue::new(&mut w).prefix("Width ").range(40.0..=4000.0)); let b = ui.add(egui::DragValue::new(&mut h).prefix("Height ").range(40.0..=4000.0)); if a.drag_stopped() || a.lost_focus() || b.drag_stopped() || b.lost_focus() { - app.run_or_alert("chart.set", json!({"chart": id, "width": w, "height": h})); + ui.data_mut(|d| d.remove::<(f32, f32)>(draft)); + if !ui.input(|i| i.key_pressed(egui::Key::Escape)) { + app.run_or_alert("chart.set", json!({"chart": id, "width": w, "height": h})); + } + } else if a.dragged() || a.has_focus() || b.dragged() || b.has_focus() { + ui.data_mut(|d| d.insert_temp(draft, (w, h))); + } else { + ui.data_mut(|d| d.remove::<(f32, f32)>(draft)); } }); } diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 264479a..ede1acc 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -1733,9 +1733,10 @@ fn table_design(app: &mut SheetApp, ui: &mut Ui) { }; ui.vertical(|ui| { ui.label(egui::RichText::new("Table Name:").small()); - let mut name = tname.clone(); - let r = ui.add(egui::TextEdit::singleline(&mut name).desired_width(120.0)); - if r.lost_focus() && name != tname { + let field = egui::Id::new(("gridcraft.table_name", &tname)); + if let Some(name) = crate::widgets::committed_text(ui, field, &tname, Some(120.0)) + && name != tname + { act(app, "table.rename", json!({"table": tname, "name": name})); } }); diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 18649e6..7369892 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -125,6 +125,25 @@ pub fn split_button(ui: &mut Ui, icon: Icon, accent: Option, tip: &str) (m.on_hover_text(tr(ui, tip)).clicked(), a) } +/// A one-line text field for a value stored in the workbook, which callers reload every frame. The text being typed is +/// kept in egui memory until the field loses focus, then returned to commit (`None` when Escape cancels the edit). +pub fn committed_text(ui: &mut Ui, id: egui::Id, current: &str, width: Option) -> Option { + let width = width.unwrap_or(ui.spacing().text_edit_width); + let draft = id.with("draft"); + let mut text = ui.data_mut(|d| d.get_temp::(draft)).unwrap_or_else(|| current.to_string()); + let r = ui.add(egui::TextEdit::singleline(&mut text).id(id).desired_width(width)); + if r.lost_focus() { + ui.data_mut(|d| d.remove::(draft)); + return (!ui.input(|i| i.key_pressed(egui::Key::Escape))).then_some(text); + } + if r.has_focus() { + ui.data_mut(|d| d.insert_temp(draft, text)); + } else { + ui.data_mut(|d| d.remove::(draft)); + } + None +} + /// Our colour palette (theme row + tints + standard colours), returns a picked hex colour, /// `Some("none")` for No Fill/Automatic. pub fn color_palette(ui: &mut Ui, theme_colors: &[u32; 12], none_label: &str) -> Option { diff --git a/crates/ui-egui/tests/chart_resize.rs b/crates/ui-egui/tests/chart_resize.rs new file mode 100644 index 0000000..5aa07e0 --- /dev/null +++ b/crates/ui-egui/tests/chart_resize.rs @@ -0,0 +1,119 @@ +//! Resizing a chart by dragging its bottom-right corner or with the Format Chart Area Size fields, through real input. + +use egui::{Event, Key, Modifiers, PointerButton, accesskit::Role, pos2, vec2}; +use egui_kittest::kittest::Queryable as _; +use gridcraft_engine::Session; +use gridcraft_engine::core::CellRef; +use gridcraft_ui_egui::{SheetApp, grid::Geo}; +use serde_json::json; + +type Harness = egui_kittest::Harness<'static, SheetApp>; + +fn fixture() -> (Harness, u32) { + let mut session = Session::new(); + session.new_workbook(); + session.execute("cell.set", json!({"cell": "A1", "input": "1"})).unwrap(); + session.execute("cell.set", json!({"cell": "A2", "input": "2"})).unwrap(); + session.execute("selection.set", json!({"range": "A1:A2"})).unwrap(); + let chart = session.execute("insert.chart", json!({"type": "column", "at": "D3", "width": 300, "height": 200})).unwrap(); + let id = chart["chart"].as_u64().unwrap() as u32; + let mut h = egui_kittest::Harness::builder().with_size(vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SheetApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + SheetApp::new(session, Default::default()), + ); + h.run_steps(4); + (h, id) +} + +fn size(h: &Harness, id: u32) -> (f32, f32) { + let a = h.state().session.doc().unwrap().wb.active().unwrap().charts.iter().find(|c| c.id == id).unwrap().anchor; + (a.width, a.height) +} + +fn corner(h: &Harness, id: u32) -> egui::Pos2 { + let app = h.state(); + let sh = app.session.doc().unwrap().wb.active().unwrap(); + let geo = Geo::new(sh, app.grid.rect.unwrap(), app.view().scroll); + let a = sh.charts.iter().find(|c| c.id == id).unwrap().anchor; + let min = geo.cell_rect(sh, CellRef::parse("D3").unwrap()).min + vec2(a.dx, a.dy) * geo.z; + min + vec2(a.width, a.height) * geo.z +} + +fn button(h: &mut Harness, pos: egui::Pos2, pressed: bool) { + h.input_mut().events.push(Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }); + h.run_steps(2); +} + +#[test] +fn dragging_the_corner_handle_resizes_the_chart() { + let (mut h, id) = fixture(); + let (w0, h0) = size(&h, id); + let start = corner(&h, id); + // Select the chart first, as the user does before grabbing a handle. + let inside = start - vec2(40.0, 40.0); + h.input_mut().events.push(Event::PointerMoved(inside)); + h.run_steps(2); + button(&mut h, inside, true); + button(&mut h, inside, false); + assert_eq!(h.state().selected_chart, Some(id)); + + h.input_mut().events.push(Event::PointerMoved(start)); + h.run_steps(2); + assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::ResizeNwSe, "the corner shows a resize cursor"); + button(&mut h, start, true); + // The pointer moves well past the drag threshold in one step, like a quick drag. + let end = start + vec2(60.0, 30.0); + h.input_mut().events.push(Event::PointerMoved(pos2(end.x, end.y))); + h.run_steps(3); + button(&mut h, end, false); + let (w1, h1) = size(&h, id); + assert!(w1 > w0 + 40.0 && h1 > h0 + 20.0, "chart grew from {w0}x{h0} to {w1}x{h1}"); +} + +/// Opens Format Chart Area for the chart and expands its Size section; returns the Width field's rect. +fn open_size_fields(h: &mut Harness, id: u32) -> egui::Rect { + h.state_mut().selected_chart = Some(id); + h.state_mut().grid.pane = Some("formatChart".into()); + h.run_steps(2); + h.get_by_role_and_label(Role::Button, "Size").click(); + h.run_steps(2); + h.get_all_by_role(Role::SpinButton).next().unwrap().rect() +} + +#[test] +fn typing_a_width_in_the_size_field_resizes_the_chart() { + let (mut h, id) = fixture(); + let (_, h0) = size(&h, id); + let field = open_size_fields(&mut h, id); + h.input_mut().events.push(Event::PointerMoved(field.center())); + button(&mut h, field.center(), true); + button(&mut h, field.center(), false); + // Clicking the field selects its text; each keystroke must survive the next frame. + for digit in ["6", "4", "0"] { + h.input_mut().events.push(Event::Text(digit.into())); + h.run_steps(2); + } + h.key_press(Key::Enter); + h.run_steps(2); + assert_eq!(size(&h, id), (640.0, h0)); +} + +#[test] +fn dragging_the_size_field_resizes_the_chart() { + let (mut h, id) = fixture(); + let (w0, _) = size(&h, id); + let field = open_size_fields(&mut h, id); + h.input_mut().events.push(Event::PointerMoved(field.center())); + button(&mut h, field.center(), true); + for step in 1..=6 { + h.input_mut().events.push(Event::PointerMoved(field.center() + vec2(10.0 * step as f32, 0.0))); + h.run_steps(1); + } + button(&mut h, field.center() + vec2(60.0, 0.0), false); + let (w1, _) = size(&h, id); + assert!(w1 > w0 + 20.0, "width went from {w0} to {w1}"); +} diff --git a/crates/ui-egui/tests/committed_text_fields.rs b/crates/ui-egui/tests/committed_text_fields.rs new file mode 100644 index 0000000..565a3d1 --- /dev/null +++ b/crates/ui-egui/tests/committed_text_fields.rs @@ -0,0 +1,81 @@ +//! Text fields over workbook values (chart titles, table name) keep what the user types until they commit it. + +use egui::{Event, Key, Modifiers, accesskit::Role, vec2}; +use egui_kittest::kittest::Queryable as _; +use gridcraft_engine::Session; +use gridcraft_ui_egui::SheetApp; +use serde_json::json; + +type Harness = egui_kittest::Harness<'static, SheetApp>; + +fn harness(session: Session) -> Harness { + let mut h = egui_kittest::Harness::builder().with_size(vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SheetApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + SheetApp::new(session, Default::default()), + ); + h.run_steps(4); + h +} + +fn session() -> Session { + let mut session = Session::new(); + session.new_workbook(); + session.execute("range.setValues", json!({"range": "A1", "values": [["Name", "Qty"], ["a", 1], ["b", 2]]})).unwrap(); + session +} + +/// Clicks the field, replaces its text with `text` and presses Enter. +fn retype(h: &mut Harness, field: egui::Rect, text: &str) { + h.input_mut().events.push(Event::PointerMoved(field.center())); + h.run_steps(1); + h.get_all_by_role(Role::TextInput).find(|n| n.rect() == field).unwrap().click(); + h.run_steps(2); + h.key_press_modifiers(Modifiers::COMMAND, Key::A); + h.run_steps(1); + // One keystroke per frame, as a person types: each must survive the next frame. + for c in text.chars() { + h.input_mut().events.push(Event::Text(c.to_string())); + h.run_steps(2); + } + h.key_press(Key::Enter); + h.run_steps(2); +} + +#[test] +fn typing_a_chart_title_in_format_chart_area_sets_it() { + let mut session = session(); + session.execute("selection.set", json!({"range": "A1:B3"})).unwrap(); + let chart = session.execute("insert.chart", json!({"type": "column", "at": "D3"})).unwrap(); + let id = chart["chart"].as_u64().unwrap() as u32; + let mut h = harness(session); + h.state_mut().selected_chart = Some(id); + h.state_mut().grid.pane = Some("formatChart".into()); + h.run_steps(2); + h.get_by_role_and_label(Role::Button, "Chart Title").click(); + h.run_steps(2); + // The pane sits on the right; the formula bar and name box are further left. + let field = h.get_all_by_role(Role::TextInput).map(|n| n.rect()).find(|r| r.left() > 850.0).unwrap(); + retype(&mut h, field, "Sales"); + let title = h.state().session.active().unwrap().wb.active().unwrap().charts.iter().find(|c| c.id == id).unwrap().title.clone(); + assert_eq!(title.as_deref(), Some("Sales")); +} + +#[test] +fn typing_a_table_name_on_the_table_design_tab_renames_the_table() { + let mut session = session(); + session.execute("insert.table", json!({"range": "A1:B3", "header": true})).unwrap(); + session.execute("selection.set", json!({"range": "A2"})).unwrap(); + let mut h = harness(session); + h.state_mut().ui.ribbon_tab = "Table Design".into(); + h.run_steps(2); + let label = h.get_by_label("Table Name:").rect(); + let below_label = |r: &egui::Rect| r.top() >= label.bottom() - 2.0 && (r.left() - label.left()).abs() < 20.0; + let field = h.get_all_by_role(Role::TextInput).map(|n| n.rect()).find(below_label).unwrap(); + retype(&mut h, field, "Sales"); + let name = h.state().session.active().unwrap().wb.active().unwrap().tables[0].name.clone(); + assert_eq!(name, "Sales"); +} diff --git a/docs/chart-parity.md b/docs/chart-parity.md index 0577929..edfc95e 100644 --- a/docs/chart-parity.md +++ b/docs/chart-parity.md @@ -35,7 +35,7 @@ open PRs #158, #161, and issue #190. | Element | Excel | GridCraft | |---|---|---| -| Move and resize the chart object | yes | move yes; **resize broken** (#190) | +| Move and resize the chart object | yes | yes (corner handle, Format Chart Area Size fields) | | Chart title, axis titles | text, linked to cells, full formatting | text only | | Legend | position, overlay, formatting, per-entry delete | position only | | Data labels | value, category, series, percentage, from cells, callouts, position | one on/off flag | From ac87c269ffdfe6d6a0f104e0d7468936270842c7 Mon Sep 17 00:00:00 2001 From: Zarvinx Date: Sun, 11 Oct 2026 01:02:18 +0300 Subject: [PATCH 06/83] Fix #213: delete selected objects from keyboard --- ROADMAP.md | 2 ++ crates/ui-egui/src/grid.rs | 18 ++++++++++++++++ crates/ui-egui/tests/interaction.rs | 33 +++++++++++++++++++++++++++++ docs/ui-parity.md | 4 +++- 4 files changed, 56 insertions(+), 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index 1189fd7..1bbae9a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -131,6 +131,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | What landed | |---|---| +| 2026-10-11 | Selected charts, pictures and shapes can be deleted from the keyboard (#213) | | 2026-10-10 | Ribbon translated into Japanese, Chinese, Korean, Brazilian Portuguese and Russian with system-language default (#31, #104, #82); CJK/Thai/Hebrew font fallback (#38); clear messages for encrypted and `.xls` files (#17); smooth line charts (#62). Progress docs restructured to the craftrules standard; full re-measure (this page). Initial ribbon key tips (#43), in-place text box editing (#84), copy as HTML table (#36). ODS and XLSB data import (#100, #101); recalc linear in the number of range formulas (#66); RISC-V Linux build; Flatpak repository on Pages; web cell-commit trap fixed | | 2026-10-09 | Community fixes: spill range operator `A1#`, sheet-scoped names, protection refuses locked-cell edits, Insert/Delete Cells adjust references, `DATE(1900,2,29)`, SUMIF sizing, `ROUND` at 15 digits, `_xlpm.` LAMBDA parameters in XLSX, vertical scrollbar, system appearance, border previews; v0.4.0 | | 2026-10-08 | v0.2.0 and v0.3.0 releases; Flatpak, AppImage zsync, branded DMG; desktop log file; DirectX 12 default on Windows | @@ -142,6 +143,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | Selected objects can be deleted from the keyboard (#213) | | 2026-10-10 | minor | Readiness-by-audience table with hours to ~95% per audience; full number unchanged (additive weighted sum) | | 2026-10-10 | minor | Added Mainstream practitioner (~50%) and Essentials user (~63%); languages updated for the 10-10 merges (ribbon in ja, zh, ko, pt-BR, ru); localization 5%→10%; full number re-checked (51.6, unchanged) | | 2026-10-10 | minor | Checked against the alpha gate (six core workflows pass): stays alpha | diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 72d33e2..6b7530d 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -1608,6 +1608,22 @@ fn object_anchor(sh: &Sheet, kind: &str, id: u32) -> Option bool { + let Some(id) = app.selected_chart else { return false }; + let kind = if sh.charts.iter().any(|object| object.id == id) { + "chart" + } else if sh.images.iter().any(|object| object.id == id) { + "image" + } else if sh.shapes.iter().any(|object| object.id == id) { + "shape" + } else { + return false; + }; + app.run_or_alert("object.delete", json!({"kind": kind, "id": id})); + app.selected_chart = None; + true +} + pub fn follow_link(app: &mut SheetApp, target: &str) { if target.contains("://") || target.starts_with("mailto:") { if let Some(f) = &app.services.open_url { @@ -1708,7 +1724,9 @@ fn keyboard(app: &mut SheetApp, ctx: &egui::Context, resp: &egui::Response, geo: } Key::Tab => app.move_after_enter(0, if shift { -1 } else { 1 }), Key::F2 => app.begin_edit(None, false), + Key::Delete if delete_selected_object(app, sh) => {} Key::Delete => app.run_or_alert("edit.clearContents", json!({})), + Key::Backspace if delete_selected_object(app, sh) => {} Key::Backspace => { // Excel for Mac: Delete clears the cell and starts editing it. app.run_or_alert("edit.clearContents", json!({})); diff --git a/crates/ui-egui/tests/interaction.rs b/crates/ui-egui/tests/interaction.rs index 84f20a6..7558771 100644 --- a/crates/ui-egui/tests/interaction.rs +++ b/crates/ui-egui/tests/interaction.rs @@ -85,6 +85,21 @@ fn click_cell(h: &mut egui_kittest::Harness<'static, SheetApp>, cell: &str) { h.run_steps(2); } +fn click_chart(h: &mut egui_kittest::Harness<'static, SheetApp>, id: u32) { + let app = h.state(); + let sheet = app.session.active().unwrap().wb.active().unwrap(); + let geo = Geo::new(sheet, app.grid.rect.unwrap(), app.view().scroll); + let anchor = sheet.charts.iter().find(|chart| chart.id == id).unwrap().anchor; + let pos = egui::pos2(geo.x(sheet, anchor.cell.col) + anchor.dx * geo.z, geo.y(sheet, anchor.cell.row) + anchor.dy * geo.z) + + egui::vec2(anchor.width, anchor.height) * geo.z / 2.0; + h.input_mut().events.extend([ + Event::PointerMoved(pos), + Event::PointerButton { pos, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }, + Event::PointerButton { pos, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }, + ]); + h.run_steps(2); +} + #[test] fn keyboard_navigation_does_not_replay_the_last_pointer_click() { let mut h = harness(blank()); @@ -170,6 +185,24 @@ fn shortcuts_format_and_extend() { assert_eq!(value(&h, "A1"), Value::Empty); } +#[test] +fn delete_key_removes_the_selected_chart() { + let mut session = blank(); + session.execute("range.setValues", json!({"range": "A1:B2", "values": [[1, 2], [3, 4]]})).unwrap(); + let id = session.execute("insert.chart", json!({"range": "A1:B2", "at": "D2"})).unwrap()["chart"].as_u64().unwrap() as u32; + let mut h = harness(session); + + click_cell(&mut h, "C10"); + click_chart(&mut h, id); + assert_eq!(h.state().selected_chart, Some(id)); + + key(&mut h, Key::Delete, Modifiers::NONE); + + assert!(h.state().session.active().unwrap().wb.active().unwrap().charts.is_empty()); + assert_eq!(h.state().selected_chart, None); + assert_eq!(value(&h, "A1"), Value::Number(1.0)); +} + #[test] fn renders_every_ribbon_tab_without_panicking() { let mut s = Session::new(); diff --git a/docs/ui-parity.md b/docs/ui-parity.md index e85bca1..6c341de 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (created from a source audit and the issue tracker) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (selected objects can be deleted from the keyboard) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Interaction, controls and feel, against Excel. Part of [target-app-parity.md](target-app-parity.md). **UI/UX fidelity: ~55% ready, 50–80 h** (estimated). Ribbon command coverage is measured @@ -56,6 +56,7 @@ Sources: `crates/ui-egui/src` (ribbon, grid, editor, formula bar, dialogs, panes | Ctrl+1, Ctrl+; and Ctrl+Shift+; , Ctrl+D/R, Ctrl+E, Ctrl+T, Ctrl+K, Ctrl+Shift+L | yes | most done; audit pending | | F2, F4, F5, F9 family, Shift+F3, F11 | yes | most; F11 chart sheet missing (no chart sheets) | | Mac-specific (Cmd variants, Fn keys) | yes | Cmd mapping done | +| Delete selected charts, pictures and shapes | yes | done (#213) | | Requests | — | #110 (more shortcuts and hot keys), #33 (sequential Alt; initial support landed in #43) | Work: a shortcut table generated from the command catalog checked against Microsoft's published @@ -91,5 +92,6 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | Delete and Backspace now remove the selected chart, picture or shape (#213) | | 2026-10-10 | minor | Chart resize bug (#190) | | 2026-10-10 | major | Created: chrome, grid, keyboard, dialogs, accessibility; key tips, Page Layout view and accessibility tree flagged | From 706fe7b10c4fa2ab4a168cfd9a11646fb03d1bcf Mon Sep 17 00:00:00 2001 From: Zarvinx Date: Sun, 11 Oct 2026 01:20:03 +0300 Subject: [PATCH 07/83] macOS: use the padded app icon in the Dock --- apps/gridcraft/src/main.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/gridcraft/src/main.rs b/apps/gridcraft/src/main.rs index 3c73a95..dc70a09 100644 --- a/apps/gridcraft/src/main.rs +++ b/apps/gridcraft/src/main.rs @@ -165,6 +165,9 @@ fn services() -> Services { } fn icon() -> Option { + #[cfg(target_os = "macos")] + let bytes = include_bytes!("../../../assets/app-icon/gridcraft-macos-512.png"); + #[cfg(not(target_os = "macos"))] let bytes = include_bytes!("../../../assets/app-icon/hicolor/256x256/apps/ai.storyteller.gridcraft.png"); let img = image::load_from_memory(bytes).ok()?.to_rgba8(); Some(egui::IconData { width: img.width(), height: img.height(), rgba: img.into_raw() }) From a0dc1abce1f5e8fab69430ddbf727ba4d1bd9f84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dejan=20Koci=C4=87?= Date: Sun, 11 Oct 2026 00:35:45 +0200 Subject: [PATCH 08/83] fix: Save toolbar button and saveChanges dialog discard an in-progress cell edit Clicking the Save icon in the toolbar, or the Save button in the "save changes?" dialog, called file.save directly without first committing a cell edit still in progress (e.g. double-click a cell, type a new value, then click Save with the mouse instead of pressing Enter). The pending text in EditState was never flushed into the document, so the saved file still held the pre-edit value, silently. The File > Save menu item already guards against this (app.commit_edit(...) before running the command); this applies the same guard to the toolbar icon and the saveChanges dialog's Save button. --- crates/ui-egui/src/dialogs.rs | 3 ++- crates/ui-egui/src/ribbon.rs | 5 ++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 7dd3acb..8d6c8ad 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -810,7 +810,8 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) { if ui.button("Cancel").clicked() { open = false; } - if ui.button("Save").clicked() { + // File operations must include the cell the user is still editing (as the File > Save menu item already does). + if ui.button("Save").clicked() && app.commit_edit(0, 0, false, false) { app.run_or_alert("file.save", json!({})); if app.session.active().is_some_and(|x| !x.is_dirty()) { app.run_or_alert("file.close", json!({"force": true})); diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 264479a..7838316 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -44,7 +44,10 @@ pub fn title_bar(app: &mut SheetApp, ui: &mut Ui) { if icon_button(ui, Icon::Home, t.text_dim, "Home", vec2(26.0, 26.0)).clicked() { app.open_dialog("start", json!({})); } - if icon_button(ui, Icon::Save, t.text_dim, "Save (⌘S)", vec2(26.0, 26.0)).clicked() { + if icon_button(ui, Icon::Save, t.text_dim, "Save (⌘S)", vec2(26.0, 26.0)).clicked() + // File operations must include the cell the user is still editing (as the File > Save menu item already does). + && app.commit_edit(0, 0, false, false) + { app.run_or_alert("file.save", json!({})); } let can_undo = app.session.active().is_some_and(|d| !d.undo.is_empty()); From 83c7a3c2f5b582388b7436b8c48be76e5790a019 Mon Sep 17 00:00:00 2001 From: webdevtodayjason <115739170+webdevtodayjason@users.noreply.github.com> Date: Sat, 10 Oct 2026 22:56:15 +0000 Subject: [PATCH 09/83] Contributors: add Jason Brashear display name --- contributors/people.toml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/contributors/people.toml b/contributors/people.toml index 78683ae..9b0d8aa 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -28,3 +28,7 @@ sync_github_name = false [people.edurbs] sync_github_name = true + +[people.webdevtodayjason] +display_name = "Jason Brashear" +sync_github_name = false From a788c41faa5b94ab280cfa40c9e1e43d4b731ce5 Mon Sep 17 00:00:00 2001 From: webdevtodayjason <115739170+webdevtodayjason@users.noreply.github.com> Date: Sat, 10 Oct 2026 22:56:15 +0000 Subject: [PATCH 10/83] #200: keep formula helpers inside the worksheet Fixes #200 --- crates/ui-egui/src/formula_bar.rs | 6 +- crates/ui-egui/src/grid.rs | 12 ++- crates/ui-egui/tests/formula_popup.rs | 144 ++++++++++++++++++++++++++ docs/ui-parity.md | 5 +- 4 files changed, 161 insertions(+), 6 deletions(-) create mode 100644 crates/ui-egui/tests/formula_popup.rs diff --git a/crates/ui-egui/src/formula_bar.rs b/crates/ui-egui/src/formula_bar.rs index 6cca110..63abc6f 100644 --- a/crates/ui-egui/src/formula_bar.rs +++ b/crates/ui-egui/src/formula_bar.rs @@ -336,9 +336,11 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font: ui.painter().galley(pos, g, Color32::WHITE); } // Autocomplete list and argument hint under the editor. - if mine && (!ed.autocomplete.is_empty() || ed.current_function().is_some()) { + if mine && ui.is_rect_visible(resp.rect) && (!ed.autocomplete.is_empty() || ed.current_function().is_some()) { let below = resp.rect.left_bottom() + vec2(0.0, 4.0); - egui::Area::new(id.with("ac")).fixed_pos(below).order(egui::Order::Tooltip).show(ui.ctx(), |ui| { + // Areas have their own clip rect: keep cell helpers inside the worksheet. + let bounds = if ed.from_formula_bar { ui.ctx().content_rect() } else { ui.clip_rect() }; + egui::Area::new(id.with("ac")).fixed_pos(below).constrain_to(bounds).order(egui::Order::Tooltip).show(ui.ctx(), |ui| { egui::Frame::popup(ui.style()).inner_margin(4.0).show(ui, |ui| { if !ed.autocomplete.is_empty() { let mut pick = None; diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 72d33e2..8cad6c6 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -1756,10 +1756,18 @@ fn in_cell_editor(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, sh: &Sheet, PatternType::Solid => st.fill.fg.resolve(&wb.theme).map(theme::color32).unwrap_or(Color32::WHITE), _ => Color32::WHITE, }; - ui.painter().rect_filled(erect, 0.0, fill); - ui.painter().rect_stroke(erect.expand(1.0), 0.0, Stroke::new(2.0, Tokens::get(ui.ctx()).sel_border), StrokeKind::Middle); + let split = pos2(geo.cells.left() + geo.frozen_w, geo.cells.top() + geo.frozen_h); + let quadrant = Rect::from_min_max( + pos2(if cell.col >= geo.fc { split.x } else { geo.cells.left() }, if cell.row >= geo.fr { split.y } else { geo.cells.top() }), + pos2(if cell.col >= geo.fc { geo.cells.right() } else { split.x }, if cell.row >= geo.fr { geo.cells.bottom() } else { split.y }), + ); + let clip = ui.clip_rect().intersect(quadrant); + let painter = ui.painter().with_clip_rect(clip); + painter.rect_filled(erect, 0.0, fill); + painter.rect_stroke(erect.expand(1.0), 0.0, Stroke::new(2.0, Tokens::get(ui.ctx()).sel_border), StrokeKind::Middle); let id = egui::Id::new("gridcraft.cell_editor"); let mut child = ui.new_child(egui::UiBuilder::new().max_rect(erect.shrink2(vec2(2.0, 1.0)))); + child.set_clip_rect(clip); crate::formula_bar::editor_widget(app, &mut child, id, font, false, erect.width() - 4.0); } diff --git a/crates/ui-egui/tests/formula_popup.rs b/crates/ui-egui/tests/formula_popup.rs new file mode 100644 index 0000000..6734d30 --- /dev/null +++ b/crates/ui-egui/tests/formula_popup.rs @@ -0,0 +1,144 @@ +//! Formula helpers must stay in the worksheet while its editor scrolls. +use egui::{Context, Id, LayerId, Order, RawInput, Rect, pos2, vec2}; +use gridcraft_engine::Session; +use gridcraft_ui_egui::SheetApp; + +fn frame(ctx: &Context, app: &mut SheetApp) -> egui::FullOutput { + let mut output = + ctx.run_ui(RawInput { screen_rect: Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(1200.0, 800.0))), ..Default::default() }, |ui| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }); + output.textures_delta.clear(); + output +} + +fn frames(ctx: &Context, app: &mut SheetApp) { + for _ in 0..4 { + frame(ctx, app); + } +} + +fn visible(ctx: &Context, editor: &str) -> bool { + ctx.memory(|m| m.areas().is_visible(&LayerId::new(Order::Tooltip, Id::new(editor).with("ac")))) +} + +#[test] +fn cell_formula_helpers_hide_when_scrolled_out_and_return_with_the_editor() { + for text in ["=SU", "=SUM("] { + let ctx = Context::default(); + let mut session = Session::new(); + session.new_workbook(); + let mut app = SheetApp::new(session, Default::default()); + frames(&ctx, &mut app); + app.begin_edit(Some(text.into()), false); + if text == "=SU" { + app.editor.as_mut().unwrap().update_autocomplete(&["SUM".into(), "SUMIF".into()]); + } + frames(&ctx, &mut app); + assert!(visible(&ctx, "gridcraft.cell_editor"), "visible cell shows {text} help"); + app.view_mut().unwrap().scroll.y = 400.0; + frames(&ctx, &mut app); + assert!(!visible(&ctx, "gridcraft.cell_editor"), "scrolled-out cell must hide {text} help"); + assert_eq!(app.editor.as_ref().unwrap().text, text, "scrolling preserves the edit"); + app.view_mut().unwrap().scroll.y = 0.0; + frames(&ctx, &mut app); + assert!(visible(&ctx, "gridcraft.cell_editor"), "scrolling back restores {text} help"); + } +} + +#[test] +fn formula_bar_hint_remains_visible_when_the_worksheet_scrolls() { + let ctx = Context::default(); + let mut session = Session::new(); + session.new_workbook(); + let mut app = SheetApp::new(session, Default::default()); + frames(&ctx, &mut app); + app.begin_edit(Some("=SUM(".into()), true); + frames(&ctx, &mut app); + assert!(visible(&ctx, "gridcraft.formula_bar")); + app.view_mut().unwrap().scroll.y = 400.0; + frames(&ctx, &mut app); + assert!(visible(&ctx, "gridcraft.formula_bar")); +} + +#[test] +fn cell_argument_hint_is_clipped_to_the_worksheet() { + let ctx = Context::default(); + let mut session = Session::new(); + session.new_workbook(); + let mut app = SheetApp::new(session, Default::default()); + frames(&ctx, &mut app); + app.begin_edit(Some("=SUM(".into()), false); + frames(&ctx, &mut app); + app.view_mut().unwrap().scroll.y = 10.0; + frames(&ctx, &mut app); + let output = frame(&ctx, &mut app); + let cells = app.grid.cells_rect.unwrap(); + let hint = output.shapes.iter().find(|s| matches!(&s.shape, egui::Shape::Text(t) if t.galley.text() == "SUM(")).expect("argument hint painted"); + assert!(cells.contains_rect(hint.clip_rect), "helper must not paint over the headers: {:?}", hint.clip_rect); +} + +#[test] +fn scrolled_out_cell_editor_still_handles_escape() { + let ctx = Context::default(); + let mut session = Session::new(); + session.new_workbook(); + let mut app = SheetApp::new(session, Default::default()); + frames(&ctx, &mut app); + app.begin_edit(Some("=SUM(".into()), false); + frames(&ctx, &mut app); + app.view_mut().unwrap().scroll.y = 400.0; + frames(&ctx, &mut app); + let mut output = ctx.run_ui( + RawInput { + screen_rect: Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(1200.0, 800.0))), + events: vec![egui::Event::Key { + key: egui::Key::Escape, + physical_key: None, + pressed: true, + repeat: false, + modifiers: egui::Modifiers::NONE, + }], + ..Default::default() + }, + |ui| app.ui(ui), + ); + output.textures_delta.clear(); + assert!(app.editor.is_none(), "scrolling must not disable editor keys"); +} + +#[test] +fn cell_editor_and_helper_stay_below_the_frozen_top_row() { + let ctx = Context::default(); + let mut session = Session::new(); + session.new_workbook(); + session.run("view.freezeTopRow", serde_json::json!({})).unwrap(); + session.run("selection.set", serde_json::json!({"range": "B5"})).unwrap(); + let mut app = SheetApp::new(session, Default::default()); + frames(&ctx, &mut app); + let sh = app.session.active().unwrap().wb.active().unwrap(); + let frozen_h = sh.row_height(0); + let scroll = sh.row_top(4) as f32; + app.begin_edit(Some("=SUM(".into()), false); + frames(&ctx, &mut app); + assert!(visible(&ctx, "gridcraft.cell_editor")); + app.view_mut().unwrap().scroll.y = scroll - frozen_h / 2.0; + frames(&ctx, &mut app); + let output = frame(&ctx, &mut app); + let frozen_bottom = app.grid.cells_rect.unwrap().top() + frozen_h * gridcraft_ui_egui::grid::DISPLAY_SCALE; + let editor = output + .shapes + .iter() + .find(|s| s.clip_rect.bottom() > frozen_bottom && matches!(&s.shape, egui::Shape::Text(t) if t.galley.text() == "=SUM(")) + .expect("editor painted"); + assert!(editor.clip_rect.top() >= frozen_bottom, "editor must be clipped below the frozen row: {:?}", editor.clip_rect); + app.view_mut().unwrap().scroll.y = scroll; + frames(&ctx, &mut app); + assert!(!visible(&ctx, "gridcraft.cell_editor"), "cell hidden under the frozen row must hide its helper"); + assert_eq!(app.editor.as_ref().unwrap().text, "=SUM("); + app.view_mut().unwrap().scroll.y = 0.0; + frames(&ctx, &mut app); + assert!(visible(&ctx, "gridcraft.cell_editor"), "scrolling back restores the helper"); +} diff --git a/docs/ui-parity.md b/docs/ui-parity.md index e85bca1..689a580 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (created from a source audit and the issue tracker) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (formula helpers stay inside the worksheet, #200) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Interaction, controls and feel, against Excel. Part of [target-app-parity.md](target-app-parity.md). **UI/UX fidelity: ~55% ready, 50–80 h** (estimated). Ribbon command coverage is measured @@ -36,7 +36,7 @@ Sources: `crates/ui-egui/src` (ribbon, grid, editor, formula bar, dialogs, panes | Drag-move and drag-copy cells (border drag, Ctrl/Option, Shift to insert) | done (M3) | | In-cell editing, Enter/Tab movement, Alt/Option+Enter line breaks | done | | Point mode (click/drag/arrow references), reference colouring, F4 | done | -| AutoComplete in columns, function autocomplete and argument tips | done | +| AutoComplete in columns, function autocomplete and argument tips | done; cell helpers clip to the worksheet and hide when the editor scrolls out (#200) | | Header resize and double-click AutoFit | done (#42, #88 for header dividers) | | Freeze panes, split panes | freeze done; split to verify | | Copy as HTML table for other apps | done (#36, 10-10) | @@ -91,5 +91,6 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | Date | Change | Summary | |---|---|---| +| 2026-10-10 | minor | Keep formula helpers inside the worksheet when scrolling (#200) | | 2026-10-10 | minor | Chart resize bug (#190) | | 2026-10-10 | major | Created: chrome, grid, keyboard, dialogs, accessibility; key tips, Page Layout view and accessibility tree flagged | From 2f078962a0aa2d4692d04f6485d4a1aa6327c85e Mon Sep 17 00:00:00 2001 From: pkibiego Date: Sun, 11 Oct 2026 03:39:32 +0300 Subject: [PATCH 11/83] xlsx: bump quick-xml to 0.41 for the namespace-declaration DoS (RUSTSEC-2026-0195) The ODS and XLSB readers parse untrusted package metadata and content through `quick_xml::NsReader`. In 0.38.4 that is RUSTSEC-2026-0195 (CVSS 7.5): the namespace resolver pushes a binding for every `xmlns` / `xmlns:*` declaration on a start tag, with no cap on how many there are, and `NsReader::read_event` reaches that path - not only `read_resolved_event`. None of our caps bound it. MAX_PART (512 MiB), MAX_TOTAL (1 GiB), MAX_ENTRIES, MAX_CELLS, MAX_TEXT and MAX_DEPTH bound part size, entry count, cells, text and nesting, never declarations per tag. Measured with `gridcraft-cli info` on crafted .ods files, before this change: 754 B, 0 declarations -> 8.6 MB, exit 0 24 KB, 1,000,000 -> 52.6 MB, exit 0 187 KB, 8,000,000 -> 367.8 MB, exit 0 770 KB, 33,000,000 -> 1.46 GB, exit 0 About 45 bytes of resident memory per declaration, roughly 1,900x the file size, and no error at all. At MAX_PART a ~1 MB file reaches ~1.9 GB, and every part is held in memory while the package is read. Both readers share the path, so a downloaded .ods or .xlsb was enough. Nothing panicked, so `Session::execute` never saw it. 0.41.0 caps declarations per start tag at 256 (DEFAULT_MAX_DECLARATIONS_PER_ELEMENT, raisable through NamespaceResolver::set_max_declarations_per_element) and returns `NamespaceError::TooManyDeclarations` beyond it. The same fixtures after: 754 B -> 8.6 MB, ok 24 KB -> 19 MB, error 187 KB -> 101.8 MB, error 770 KB -> 397 MB, error with "start tag declares more than 256 namespace bindings". The residual peak is the part itself: MAX_PART still lets one part decompress to 256 MiB, the existing intended bound. API changes for 0.41 (0.x bumps may break; all four are in the xlsx crate): - `resolve_element` and `resolve_attribute` moved off `NsReader` on to the resolver: `reader.resolver_mut().resolve_element(..)`. `attr` and `tag` in ods.rs take `&mut NsReader` for that. - `Attribute::unescape_value` is deprecated in favour of `normalized_value`, which also applies the attribute-value normalization the XML specification requires. - `BytesText::xml_content` now takes the document's XML version. This also clears RUSTSEC-2026-0194, the quadratic duplicate-attribute check. xml.rs already passed `with_checks(false)`, but ods.rs and xlsb.rs used the checked iterator, so they were on the quadratic path as well. Regression test: `ods_tests::ods_namespace_declaration_bomb_is_rejected` asserts that a tag with thousands of declarations is an error and that a document with a handful still reads. It fails on 0.38.4 (the tag is accepted) and passes on 0.41.0. Verified: `cargo xtask ci`; the full xlsx suite (80 tests) plus the malformed-input tests; and the four measurements above, before and after, with no panics either way. --- Cargo.lock | 13 ++----------- Cargo.toml | 2 +- crates/xlsx/src/ods.rs | 23 ++++++++++++----------- crates/xlsx/src/tests/ods_tests.rs | 16 ++++++++++++++++ crates/xlsx/src/xlsb.rs | 7 ++++--- crates/xlsx/src/xml.rs | 5 +++-- 6 files changed, 38 insertions(+), 28 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f772f83..16e2221 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1584,7 +1584,7 @@ dependencies = [ "gridcraft-core", "gridcraft-formula", "gridcraft-model", - "quick-xml 0.38.4", + "quick-xml", "thiserror 2.0.21", "zip", ] @@ -2739,15 +2739,6 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" -[[package]] -name = "quick-xml" -version = "0.38.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" -dependencies = [ - "memchr", -] - [[package]] name = "quick-xml" version = "0.41.0" @@ -3725,7 +3716,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0" dependencies = [ "proc-macro2", - "quick-xml 0.41.0", + "quick-xml", "quote", ] diff --git a/Cargo.toml b/Cargo.toml index d7fad0f..d826390 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,7 +32,7 @@ serde_json = { version = "1", features = ["float_roundtrip"] } log = "0.4" rayon = "1" regex = { version = "1", default-features = false, features = ["std", "unicode", "perf"] } -quick-xml = "0.38" +quick-xml = "0.41" zip = { version = "3", default-features = false, features = ["deflate"] } image = { version = "0.25", default-features = false, features = ["png", "jpeg"] } diff --git a/crates/xlsx/src/ods.rs b/crates/xlsx/src/ods.rs index 4f63fd1..8415ce3 100644 --- a/crates/xlsx/src/ods.rs +++ b/crates/xlsx/src/ods.rs @@ -10,6 +10,7 @@ use gridcraft_core::date::{days_in_month, serial_from_ymd}; use gridcraft_core::{CellRef, DateSystem, MAX_COLS, MAX_ROWS, RangeRef, Value}; use gridcraft_model::{Cell, NumFmt, Sheet, StyleId, Workbook}; use quick_xml::NsReader; +use quick_xml::XmlVersion; use quick_xml::events::{BytesStart, Event}; use quick_xml::name::ResolveResult; @@ -67,13 +68,13 @@ fn is_ns(ns: ResolveResult<'_>, expected: &[u8]) -> bool { matches!(ns, ResolveResult::Bound(n) if n.as_ref() == expected) } -fn attr(reader: &NsReader<&[u8]>, e: &BytesStart<'_>, ns: &[u8], name: &[u8]) -> Result, IoError> { +fn attr(reader: &mut NsReader<&[u8]>, e: &BytesStart<'_>, ns: &[u8], name: &[u8]) -> Result, IoError> { let mut found = None; for a in e.attributes() { let a = a.map_err(|e| IoError::Xml(e.to_string()))?; - let (resolved, local) = reader.resolve_attribute(a.key); + let (resolved, local) = reader.resolver_mut().resolve_attribute(a.key); if is_ns(resolved, ns) && local.as_ref() == name { - let value = a.unescape_value().map_err(|e| IoError::Xml(e.to_string()))?; + let value = a.normalized_value(XmlVersion::Explicit1_0).map_err(|e| IoError::Xml(e.to_string()))?; if value.len() > MAX_CELL_TEXT { return Err(IoError::TooLarge("ODS attribute exceeds 1 MiB".into())); } @@ -106,7 +107,7 @@ fn check_manifest(bytes: &[u8]) -> Result<(), IoError> { let event = reader.read_event().map_err(xml_error)?; match &event { Event::Start(e) | Event::Empty(e) => { - let (ns, name) = reader.resolve_element(e.name()); + let (ns, name) = reader.resolver_mut().resolve_element(e.name()); if is_ns(ns.clone(), MANIFEST) && name.as_ref() == b"encryption-data" { return Err(IoError::Format("encrypted ODS files are not supported".into())); } @@ -153,8 +154,8 @@ enum Tag { Other, } -fn tag(reader: &NsReader<&[u8]>, e: &BytesStart<'_>) -> Tag { - let (ns, name) = reader.resolve_element(e.name()); +fn tag(reader: &mut NsReader<&[u8]>, e: &BytesStart<'_>) -> Tag { + let (ns, name) = reader.resolver_mut().resolve_element(e.name()); let local = name.as_ref(); if is_ns(ns.clone(), OFFICE) { return match local { @@ -253,23 +254,23 @@ impl Import { return Err(xml_error("XML nested too deeply")); } if self.stack.is_empty() { - if root_seen || tag(&reader, &e) != Tag::Document { + if root_seen || tag(&mut reader, &e) != Tag::Document { return Err(xml_error("expected one office:document-content root")); } root_seen = true; } - self.start(&reader, &e)?; + self.start(&mut reader, &e)?; } Event::Empty(e) => { if self.stack.is_empty() { return Err(xml_error("expected office:document-content with a spreadsheet")); } - self.start(&reader, &e)?; + self.start(&mut reader, &e)?; self.end()?; } Event::End(_) => self.end()?, Event::Text(t) => { - let text = t.xml_content().map_err(xml_error)?; + let text = t.xml_content(XmlVersion::Explicit1_0).map_err(xml_error)?; if self.stack.is_empty() && !text.trim().is_empty() { return Err(xml_error("text outside the document")); } @@ -301,7 +302,7 @@ impl Import { Ok(()) } - fn start(&mut self, reader: &NsReader<&[u8]>, e: &BytesStart<'_>) -> Result<(), IoError> { + fn start(&mut self, reader: &mut NsReader<&[u8]>, e: &BytesStart<'_>) -> Result<(), IoError> { let t = tag(reader, e); let depth = self.stack.len(); match t { diff --git a/crates/xlsx/src/tests/ods_tests.rs b/crates/xlsx/src/tests/ods_tests.rs index c0f2e0e..47c2586 100644 --- a/crates/xlsx/src/tests/ods_tests.rs +++ b/crates/xlsx/src/tests/ods_tests.rs @@ -23,6 +23,22 @@ fn text(s: &str) -> Value { Value::Text(s.into()) } +/// A start tag with thousands of `xmlns:` declarations is refused instead of allocated +/// (RUSTSEC-2026-0195: the resolver kept a binding per declaration, with no cap). +#[test] +fn ods_namespace_declaration_bomb_is_rejected() { + let bomb = |n: usize| -> String { (0..n).map(|i| format!(" xmlns:p{i}=\"urn:p{i}\"")).collect() }; + let doc = |decls: &str| { + format!( + r#"1"# + ) + }; + let build = |decls: &str| make_zip(&[("mimetype", MIME), ("META-INF/manifest.xml", MANIFEST), ("content.xml", doc(decls).as_bytes())]); + let ok = read_ods(&build(&bomb(8))); + assert!(ok.is_ok(), "a handful of namespaces still reads: {:?}", ok.err()); + assert!(read_ods(&build(&bomb(10_000))).is_err(), "a tag with thousands of declarations is refused"); +} + #[test] fn ods_typed_values_and_formula_caches_are_constants_and_survive_xlsx() { let bytes = row(r#" diff --git a/crates/xlsx/src/xlsb.rs b/crates/xlsx/src/xlsb.rs index 897a40b..3c84547 100644 --- a/crates/xlsx/src/xlsb.rs +++ b/crates/xlsx/src/xlsb.rs @@ -7,6 +7,7 @@ use std::sync::Arc; use gridcraft_core::{CellError, CellRef, DateSystem, MAX_COLS, MAX_ROWS, RangeRef, Value}; use gridcraft_model::{Cell, Sheet, Visibility, Workbook}; use quick_xml::NsReader; +use quick_xml::XmlVersion; use quick_xml::events::Event; use quick_xml::name::ResolveResult; @@ -160,7 +161,7 @@ fn metadata(bytes: &[u8], ns: &[u8], root_name: &[u8]) -> Result, IoE let event = reader.read_event().map_err(|e| IoError::Xml(e.to_string()))?; match &event { Event::Start(e) | Event::Empty(e) => { - let (namespace, local) = reader.resolve_element(e.name()); + let (namespace, local) = reader.resolver_mut().resolve_element(e.name()); let valid_ns = matches!(namespace, ResolveResult::Bound(n) if n.as_ref() == ns); if depth == 0 { if seen || !valid_ns || local.as_ref() != root_name { @@ -174,7 +175,7 @@ fn metadata(bytes: &[u8], ns: &[u8], root_name: &[u8]) -> Result, IoE if !a.key.as_ref().contains(&b':') { attrs.push(( String::from_utf8_lossy(a.key.as_ref()).into_owned(), - a.unescape_value().map_err(|e| IoError::Xml(e.to_string()))?.into_owned(), + a.normalized_value(XmlVersion::Explicit1_0).map_err(|e| IoError::Xml(e.to_string()))?.into_owned(), )); } } @@ -188,7 +189,7 @@ fn metadata(bytes: &[u8], ns: &[u8], root_name: &[u8]) -> Result, IoE } } Event::End(_) => depth = depth.checked_sub(1).ok_or_else(|| invalid("unexpected metadata end"))?, - Event::Text(t) if depth == 0 && !t.xml_content().map_err(|e| IoError::Xml(e.to_string()))?.trim().is_empty() => { + Event::Text(t) if depth == 0 && !t.xml_content(XmlVersion::Explicit1_0).map_err(|e| IoError::Xml(e.to_string()))?.trim().is_empty() => { return Err(invalid("text outside package metadata")); } Event::DocType(_) => return Err(invalid("DOCTYPE in package metadata is unsupported")), diff --git a/crates/xlsx/src/xml.rs b/crates/xlsx/src/xml.rs index efa5cdc..9e03f5f 100644 --- a/crates/xlsx/src/xml.rs +++ b/crates/xlsx/src/xml.rs @@ -2,6 +2,7 @@ //! shared strings) so large parts never need a full tree, plus escaping helpers for writing. use quick_xml::Reader; +use quick_xml::XmlVersion; use quick_xml::events::{BytesStart, Event}; use crate::IoError; @@ -124,7 +125,7 @@ fn start_el(e: &BytesStart<'_>) -> El { if key == b"xmlns" || key.starts_with(b"xmlns:") { continue; } - let v = match a.unescape_value() { + let v = match a.normalized_value(XmlVersion::Explicit1_0) { Ok(v) => v.into_owned(), Err(_) => String::from_utf8_lossy(&a.value).into_owned(), }; @@ -203,7 +204,7 @@ pub fn parse_streaming(xml: &[u8], split: &[&str], cb: &mut dyn FnMut(El) -> Res } Event::Text(t) => { if let Some(top) = stack.last_mut() { - let s = t.xml_content().map_err(|e| IoError::Xml(e.to_string()))?; + let s = t.xml_content(XmlVersion::Explicit1_0).map_err(|e| IoError::Xml(e.to_string()))?; top.text.push_str(&s); } } From fae90c8e4aece9de93547c22c2a8364a7b6e11eb Mon Sep 17 00:00:00 2001 From: webdevtodayjason <115739170+webdevtodayjason@users.noreply.github.com> Date: Sun, 11 Oct 2026 01:37:50 +0000 Subject: [PATCH 12/83] selection.move: clamp extreme dr/dc to the sheet edge instead of overflowing --- crates/core/src/addr.rs | 23 +++++++++-- crates/engine/src/cmd/edit.rs | 6 +-- .../engine/tests/extreme_selection_moves.rs | 40 +++++++++++++++++++ 3 files changed, 62 insertions(+), 7 deletions(-) create mode 100644 crates/engine/tests/extreme_selection_moves.rs diff --git a/crates/core/src/addr.rs b/crates/core/src/addr.rs index 8a64910..2bdfe79 100644 --- a/crates/core/src/addr.rs +++ b/crates/core/src/addr.rs @@ -30,14 +30,14 @@ impl CellRef { } /// Moved by `(dr, dc)`, `None` when the result leaves the sheet. pub fn offset(&self, dr: i64, dc: i64) -> Option { - let r = self.row as i64 + dr; - let c = self.col as i64 + dc; + let r = (self.row as i64).checked_add(dr)?; + let c = (self.col as i64).checked_add(dc)?; if (0..MAX_ROWS as i64).contains(&r) && (0..MAX_COLS as i64).contains(&c) { Some(CellRef::new(r as u32, c as u32)) } else { None } } /// Moved by `(dr, dc)` and clamped to the sheet. pub fn offset_clamped(&self, dr: i64, dc: i64) -> CellRef { - let r = (self.row as i64 + dr).clamp(0, MAX_ROWS as i64 - 1); - let c = (self.col as i64 + dc).clamp(0, MAX_COLS as i64 - 1); + let r = (self.row as i64).saturating_add(dr).clamp(0, MAX_ROWS as i64 - 1); + let c = (self.col as i64).saturating_add(dc).clamp(0, MAX_COLS as i64 - 1); CellRef::new(r as u32, c as u32) } /// Parses `A1`, `$A$1`, `xfd1048576` (case-insensitive). `None` if malformed or out of range. @@ -272,6 +272,21 @@ mod tests { assert_eq!(a.iter().count(), 9); } + #[test] + fn extreme_offsets() { + let cell = CellRef::new(1, 1); + for delta in [i64::MIN, i64::MAX] { + assert_eq!(cell.offset(delta, 0), None); + assert_eq!(cell.offset(0, delta), None); + assert_eq!(cell.offset(delta, delta), None); + } + assert_eq!(cell.offset_clamped(i64::MAX, i64::MAX), CellRef::new(MAX_ROWS - 1, MAX_COLS - 1)); + assert_eq!(cell.offset_clamped(i64::MIN, i64::MIN), CellRef::new(0, 0)); + assert_eq!(cell.offset_clamped(i64::MAX, i64::MIN), CellRef::new(MAX_ROWS - 1, 0)); + assert_eq!(cell.offset_clamped(i64::MIN, i64::MAX), CellRef::new(0, MAX_COLS - 1)); + assert_eq!(cell.offset(2, -1), Some(CellRef::new(3, 0))); + } + #[test] fn offsets() { assert_eq!(CellRef::new(0, 0).offset(-1, 0), None); diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index bb54fe0..2dba66f 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -1,6 +1,6 @@ //! Cell entry, selection, undo/redo, clipboard, clear, fill, find & replace. -use gridcraft_core::{CellRef, MAX_COLS, MAX_ROWS, RangeRef, Value}; +use gridcraft_core::{CellRef, MAX_ROWS, RangeRef, Value}; use gridcraft_model::{Cell, Formula, StyleId}; use serde_json::{Value as Json, json}; @@ -408,9 +408,9 @@ fn selection_move(s: &mut Session, p: &Json) -> Result { if !extend && let Some(m) = sheet.merge_at(from) { // Leaving a merged cell moves from its edge. if dr > 0 { - t = CellRef::new((m.end.row + dr as u32).min(MAX_ROWS - 1), from.col); + t = CellRef::new(m.end.offset_clamped(dr, 0).row, from.col); } else if dc > 0 { - t = CellRef::new(from.row, (m.end.col + dc as u32).min(MAX_COLS - 1)); + t = CellRef::new(from.row, m.end.offset_clamped(0, dc).col); } } t diff --git a/crates/engine/tests/extreme_selection_moves.rs b/crates/engine/tests/extreme_selection_moves.rs new file mode 100644 index 0000000..f6f5737 --- /dev/null +++ b/crates/engine/tests/extreme_selection_moves.rs @@ -0,0 +1,40 @@ +use gridcraft_core::{CellRef, MAX_COLS, MAX_ROWS}; +use gridcraft_engine::Session; +use serde_json::json; + +#[test] +fn selection_move_clamps_extreme_displacements() { + let mut session = Session::new(); + session.new_workbook(); + for (dr, dc, expected) in [ + (i64::MAX, 0, CellRef::new(MAX_ROWS - 1, 1)), + (0, i64::MAX, CellRef::new(1, MAX_COLS - 1)), + (i64::MAX, i64::MAX, CellRef::new(MAX_ROWS - 1, MAX_COLS - 1)), + (i64::MIN, i64::MIN, CellRef::new(0, 0)), + (i64::MAX, i64::MIN, CellRef::new(MAX_ROWS - 1, 0)), + (i64::MIN, i64::MAX, CellRef::new(0, MAX_COLS - 1)), + (2, -1, CellRef::new(3, 0)), + ] { + session.execute("selection.set", json!({"cell": "B2"})).unwrap(); + session.execute("selection.move", json!({"dr": dr, "dc": dc})).unwrap(); + assert_eq!(session.doc().unwrap().selection.active, expected); + } +} + +#[test] +fn selection_move_from_a_merged_cell_clamps_extreme_displacements() { + let mut session = Session::new(); + session.new_workbook(); + session.execute("selection.set", json!({"range": "B2:C3"})).unwrap(); + session.execute("home.mergeCells", json!({})).unwrap(); + for (dr, dc, expected) in [ + (i64::MAX, 0, CellRef::new(MAX_ROWS - 1, 1)), + (0, i64::MAX, CellRef::new(1, MAX_COLS - 1)), + (4_294_967_297, 0, CellRef::new(MAX_ROWS - 1, 1)), + (1, 0, CellRef::new(3, 1)), + ] { + session.execute("selection.set", json!({"cell": "B2"})).unwrap(); + session.execute("selection.move", json!({"dr": dr, "dc": dc})).unwrap(); + assert_eq!(session.doc().unwrap().selection.active, expected); + } +} From aee898595f49426ffdcae86708171a3dfbc4a7e8 Mon Sep 17 00:00:00 2001 From: Sebastian Whincop Date: Sun, 11 Oct 2026 16:22:36 +1300 Subject: [PATCH 13/83] Accuracy: round an exact tie at the 16th significant digit toward zero MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Excel shows 15 significant digits. When a number is an exact tie at the 16th (its exact decimal expansion is 15 digits, a 5, then only zeros), Excel rounds it toward zero: TEXT(1234567890123455,"0") is 1234567890123450. GridCraft took Rust's `{:.14e}`, which rounds that tie to even, so any tie with an odd 15th digit came out one unit too high (…460). decimal_digits now formats 16 digits (correct unless the 16th is a 5), and only on a 5 checks the exact expansion (a 30-digit screen, then the full one) and truncates a true tie. Non-ties, including values just above or below a tie, round as before. One format serves almost every number. Tests: ties of every 15th-digit parity, negatives, fractional ties, the neighbours just above and below a tie, the carry after fifteen 9s (999999999999999.5 shows 999999999999999), and unchanged ordinary cases. Evidence: Windows Excel 16.0.20430 TEXT() over a 174-row probe and a 24-row carry probe agree on every row except those under a percent format and General over negatives (the two bugs named below). The suite's number-format golden goes from 183,843 to 184,900 of 187,810 passing with no row that passed before failing now. modified: crates/numfmt/src/decimal.rs modified: crates/numfmt/src/tests.rs --- crates/numfmt/src/decimal.rs | 63 +++++++++++++++++++++++++++++--- crates/numfmt/src/tests.rs | 70 ++++++++++++++++++++++++++++++++++++ 2 files changed, 128 insertions(+), 5 deletions(-) diff --git a/crates/numfmt/src/decimal.rs b/crates/numfmt/src/decimal.rs index 15cfcd3..67071d4 100644 --- a/crates/numfmt/src/decimal.rs +++ b/crates/numfmt/src/decimal.rs @@ -9,17 +9,70 @@ pub(crate) const MAX_DECIMALS: usize = 400; /// The 15 significant digits of a positive finite number and the power of ten of the first /// digit: `a ≈ d0.d1d2… × 10^exp`. +/// +/// The value is rounded to the nearest 15-digit decimal. An exact tie at the 16th digit (the exact +/// decimal expansion is 15 digits, a 5, then only zeros, as for 1234567890123455 or +/// 123456789012345.5) goes toward zero, as Excel does: `1234567890123455` shows `1234567890123450`, +/// not `…460`. Excel observed (Windows 16.0.20430, TEXT()). pub(crate) fn decimal_digits(a: f64) -> ([u8; 15], i32) { let mut digits = [0u8; 15]; if !a.is_finite() || a <= 0.0 { return (digits, 0); } - let s = format!("{:.14e}", a); - let (mant, exp) = s.split_once('e').unwrap_or((s.as_str(), "0")); - for (slot, b) in digits.iter_mut().zip(mant.bytes().filter(u8::is_ascii_digit)) { - *slot = b - b'0'; + // 16 digits, rounded. Unless the 16th is a 5 it decides the 15th exactly (4 or less rounds + // down, 6 or more up), so one format serves almost every number. + let (d16, exp) = significant_digits(a, 16); + for (slot, d) in digits.iter_mut().zip(&d16) { + *slot = *d; } - (digits, exp.parse().unwrap_or(0)) + match d16.get(15).copied().unwrap_or(0) { + 0..=4 => (digits, exp), + 5 if is_exact_tie(a) => (digits, exp), + 5 => { + // Not a tie: round the exact value (the 16-digit rounding above may be a double rounding). + let (d15, exp15) = significant_digits(a, 15); + let mut out = [0u8; 15]; + for (slot, d) in out.iter_mut().zip(&d15) { + *slot = *d; + } + (out, exp15) + } + _ => { + let mut exp = exp; + let mut carry = true; + for d in digits.iter_mut().rev() { + if *d == 9 { + *d = 0; + } else { + *d += 1; + carry = false; + break; + } + } + if carry { + digits = [0; 15]; + digits[0] = 1; + exp += 1; + } + (digits, exp) + } + } +} + +/// `n` significant digits of `a` (rounded half to even on the exact binary value) and the power of +/// ten of the first. +fn significant_digits(a: f64, n: usize) -> (Vec, i32) { + let s = format!("{:.*e}", n.saturating_sub(1), a); + let (mant, exp) = s.split_once('e').unwrap_or((s.as_str(), "0")); + (mant.bytes().filter(u8::is_ascii_digit).map(|b| b - b'0').collect(), exp.parse().unwrap_or(0)) +} + +/// True when `a`'s exact decimal expansion has a 5 as its 16th significant digit and only zeros after. +fn is_exact_tie(a: f64) -> bool { + let is_tie = |ds: &[u8]| ds.get(15) == Some(&5) && ds.get(16..).is_some_and(|t| t.iter().all(|&d| d == 0)); + // A cheap screen first (30 digits), then the exact expansion: a double has at most 767 + // significant digits and Rust prints every one of them. + is_tie(&significant_digits(a, 30).0) && is_tie(&significant_digits(a, 800).0) } /// Rounds the number `digits × 10^exp` (see [`decimal_digits`]) to `decimals` places, half away diff --git a/crates/numfmt/src/tests.rs b/crates/numfmt/src/tests.rs index ea40753..0742b26 100644 --- a/crates/numfmt/src/tests.rs +++ b/crates/numfmt/src/tests.rs @@ -421,3 +421,73 @@ fn fuzz_no_panic() { } } } + +// ---- 15 significant digits: an exact tie at the 16th digit goes toward zero ---- +// Windows Excel 16.0.20430 TEXT() over the exact doubles below. An odd 15th digit is where +// round-half-even and toward-zero differ; an even one is where they agree. + +#[test] +fn an_exact_tie_at_the_sixteenth_digit_goes_toward_zero() { + for (v, whole) in [ + (1234567890123405.0, "1234567890123400"), + (1234567890123415.0, "1234567890123410"), + (1234567890123425.0, "1234567890123420"), + (1234567890123435.0, "1234567890123430"), + (1234567890123455.0, "1234567890123450"), + (1234567890123475.0, "1234567890123470"), + (1234567890123495.0, "1234567890123490"), + (6631679258820775.0, "6631679258820770"), + (8052991635850695.0, "8052991635850690"), + ] { + assert_eq!(f("0", v), whole, "0 over {v}"); + assert_eq!(f("0.00", v), format!("{whole}.00"), "0.00 over {v}"); + assert_eq!(f("0", -v), format!("-{whole}"), "0 over -{v}"); + } + assert_eq!(f("#,##0", 1234567890123455.0), "1,234,567,890,123,450"); + assert_eq!(f("#,##0", -3267527853107975.0), "-3,267,527,853,107,970"); + // A fraction is a tie as well, when it is exact in binary. + assert_eq!(f("0", 123456789012345.5), "123456789012345"); + assert_eq!(f("0.00", 123456789012345.5), "123456789012345.00"); + // Excel observed: no carry after fifteen 9s. + assert_eq!(f("0", 999999999999999.5), "999999999999999"); + assert_eq!(f("0", -999999999999999.5), "-999999999999999"); + assert_eq!(f("0", 4999999999999995.0), "4999999999999990"); + assert_eq!(f("#,##0", -4999999999999995.0), "-4,999,999,999,999,990"); +} + +#[test] +fn only_exact_ties_go_toward_zero() { + // Just above and just below a tie round to the nearer 15-digit value (Excel observed). + assert_eq!(f("0", 1234567890123455.0 + 0.25), "1234567890123460"); + assert_eq!(f("0", 1234567890123455.5), "1234567890123460"); + assert_eq!(f("0", 1234567890123455.0 - 0.25), "1234567890123450"); + // 16 digits that are not a tie, and the ordinary decimal cases, are untouched. + assert_eq!(f("0", 1234567890123456.0), "1234567890123460"); + assert_eq!(f("0", 1234567890123454.0), "1234567890123450"); + assert_eq!(f("0.00", 2.675), "2.68"); + assert_eq!(f("0.00", 1.005), "1.01"); + assert_eq!(f("0.0", 0.285), "0.3"); + assert_eq!(f("0.0", 99999999999999.95), "100000000000000.0"); + // Ten digits and a half are not a 16th-digit tie. + assert_eq!(f("0", 0.5), "1"); + assert_eq!(f("0", 2.5), "3"); +} + +#[test] +fn decimal_digits_keeps_fifteen_digits_and_truncates_a_tie() { + use crate::decimal::decimal_digits; + let (d, e) = decimal_digits(1234567890123455.0); + assert_eq!((d, e), ([1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 1, 2, 3, 4, 5], 15)); + let (d, e) = decimal_digits(1234567890123456.0); + assert_eq!((d, e), ([1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 1, 2, 3, 4, 6], 15)); + let (d, e) = decimal_digits(123456789012345.5); + assert_eq!((d, e), ([1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 1, 2, 3, 4, 5], 14)); + // Excel observed: a tie after fifteen 9s does not carry (999999999999999.5 shows 999999999999999). + let (d, e) = decimal_digits(999999999999999.5); + assert_eq!((d, e), ([9; 15], 14)); + let (d, e) = decimal_digits(4999999999999995.0); + assert_eq!((d, e), ([4, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9], 15)); + // A 16th digit above 5 carries as usual. + let (d, e) = decimal_digits(999999999999999.6); + assert_eq!((d, e), ([1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], 15)); +} From cbf56f0707f99ae3928be6abd8ed76c760adb567 Mon Sep 17 00:00:00 2001 From: Diego Ospina Date: Sat, 10 Oct 2026 22:36:49 -0500 Subject: [PATCH 14/83] i18n: Spanish interface translation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Language::Es was registered and switches formulas to Spanish (SUMA, decimal comma, semicolon arguments), but every interface label still fell back to English. This adds es.rs with all 266 labels in Excel's Spanish wording (Combinar y centrar, Inmovilizar paneles, Análisis de hipótesis, Buscar objetivo, Rastrear precedentes…) and covers Spanish in the i18n tests. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012JC2QZ1Qc5KQMBswS38GKy --- crates/ui-egui/src/i18n.rs | 8 +- crates/ui-egui/src/i18n/es.rs | 275 +++++++++++++++++++++++++ crates/ui-egui/tests/formula_locale.rs | 2 +- 3 files changed, 283 insertions(+), 2 deletions(-) create mode 100644 crates/ui-egui/src/i18n/es.rs diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 1e7b212..e44a032 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -9,6 +9,7 @@ use serde::{Deserialize, Serialize}; +mod es; mod ko; mod pt; mod ru; @@ -97,7 +98,8 @@ impl Language { /// "Orientation") is keyed `context|label`: English shows only the part after the `|`. pub fn tr(self, text: &str) -> &str { let translated = match self { - Self::En | Self::Es => None, + Self::En => None, + Self::Es => es::translate(text), Self::Ja => JAPANESE.iter().find(|(english, _)| *english == text).map(|(_, translated)| *translated), Self::Zh => zh::translate(text), Self::Ko => ko::translate(text), @@ -434,12 +436,16 @@ mod tests { assert!(ko::translate(english).is_some_and(|text| !text.is_empty()), "missing Korean: {english}"); assert!(ru::translate(english).is_some_and(|text| !text.is_empty()), "missing Russian: {english}"); assert!(pt::translate(english).is_some_and(|text| !text.is_empty()), "missing Portuguese: {english}"); + assert!(es::translate(english).is_some_and(|text| !text.is_empty()), "missing Spanish: {english}"); } assert_eq!(Language::Ja.tr("Data"), "データ"); assert_eq!(Language::Zh.tr("Data"), "数据"); assert_eq!(Language::Ko.tr("Data"), "데이터"); assert_eq!(Language::Ru.tr("Data"), "Данные"); assert_eq!(Language::PtBr.tr("Data"), "Dados"); + assert_eq!(Language::Es.tr("Data"), "Datos"); + assert_eq!(Language::Es.tr("Freeze Panes"), "Inmovilizar paneles"); + assert_eq!(Language::Es.tr("Sheet1!A1"), "Sheet1!A1"); assert_eq!(Language::PtBr.tr("Date"), "Data"); assert_eq!(Language::PtBr.tr("Sheet1!A1"), "Sheet1!A1"); assert_eq!(Language::Ja.tr("Sheet1!A1"), "Sheet1!A1"); diff --git a/crates/ui-egui/src/i18n/es.rs b/crates/ui-egui/src/i18n/es.rs new file mode 100644 index 0000000..fe201cc --- /dev/null +++ b/crates/ui-egui/src/i18n/es.rs @@ -0,0 +1,275 @@ +//! Spanish translations for the interface strings in i18n.rs. Labels follow Excel's Spanish +//! wording (sentence case, «Inmovilizar paneles», «Combinar y centrar»); anything absent falls back +//! to English. Formula names and separators are localized separately (`formula_locale`). + +pub(super) fn translate(text: &str) -> Option<&'static str> { + Some(match text { + "Paste" => "Pegar", + "Paste Values" => "Pegar valores", + "Paste Formulas" => "Pegar fórmulas", + "Paste Formatting" => "Pegar formato", + "Transpose" => "Transponer", + "Paste Link" => "Pegar vínculo", + "Paste Special…" => "Pegado especial…", + "Cut (⌘X)" => "Cortar (⌘X)", + "Copy (⌘C)" => "Copiar (⌘C)", + "Format Painter (double-click to keep it on)" => "Copiar formato (doble clic para mantenerlo activo)", + "Bold (⌘B)" => "Negrita (⌘B)", + "Italic (⌘I)" => "Cursiva (⌘I)", + "Underline (⌘U)" => "Subrayado (⌘U)", + "Double Underline" => "Subrayado doble", + "Strikethrough" => "Tachado", + "Underline" => "Subrayado", + "Increase Font Size" => "Aumentar tamaño de fuente", + "Decrease Font Size" => "Disminuir tamaño de fuente", + "Wrap Text" => "Ajustar texto", + "Merge & Center" => "Combinar y centrar", + "Merge Cells" => "Combinar celdas", + "Merge Across" => "Combinar horizontalmente", + "Unmerge Cells" => "Separar celdas", + "Fill Color" => "Color de relleno", + "Font Color" => "Color de fuente", + "Borders" => "Bordes", + "Top\nAlign" => "Alinear en la\nparte superior", + "Middle\nAlign" => "Alinear en\nel medio", + "Bottom\nAlign" => "Alinear en la\nparte inferior", + "Left" => "Izquierda", + "Right" => "Derecha", + "Center" => "Centro", + "Horizontal" => "Horizontal", + "Vertical Text" => "Texto vertical", + "Decrease Indent" => "Disminuir sangría", + "Increase Indent" => "Aumentar sangría", + "Orientation" => "Orientación", + "Angle Counterclockwise" => "Ángulo ascendente", + "Angle Clockwise" => "Ángulo descendente", + "Rotate Text Up" => "Girar texto hacia arriba", + "Rotate Text Down" => "Girar texto hacia abajo", + "Accounting Number Format" => "Formato de número de contabilidad", + "Percent Style" => "Estilo porcentual", + "Comma Style" => "Estilo de millares", + "Increase Decimal" => "Aumentar decimales", + "Decrease Decimal" => "Disminuir decimales", + "More Accounting Formats…" => "Más formatos de contabilidad…", + "Conditional\nFormatting" => "Formato\ncondicional", + "Conditional Formatting" => "Formato condicional", + "Format\nas Table" => "Dar formato\ncomo tabla", + "Cell\nStyles" => "Estilos\nde celda", + "Format Cells…" => "Formato de celdas…", + "Format Cell Alignment…" => "Alineación de celdas…", + "Format\nPane" => "Panel\nde formato", + "Format" => "Formato", + "Insert" => "Insertar", + "Insert Cells…" => "Insertar celdas…", + "Delete" => "Eliminar", + "Delete Cells…" => "Eliminar celdas…", + "Insert Sheet Rows" => "Insertar filas de hoja", + "Insert Sheet Columns" => "Insertar columnas de hoja", + "Delete Sheet Rows" => "Eliminar filas de hoja", + "Delete Sheet Columns" => "Eliminar columnas de hoja", + "Row Height…" => "Alto de fila…", + "AutoFit Row Height" => "Autoajustar alto de fila", + "Column Width…" => "Ancho de columna…", + "AutoFit Column Width" => "Autoajustar ancho de columna", + "Default Width…" => "Ancho predeterminado…", + "Hide Rows" => "Ocultar filas", + "Unhide Rows" => "Mostrar filas", + "Hide Columns" => "Ocultar columnas", + "Unhide Columns" => "Mostrar columnas", + "Lock Cell" => "Bloquear celda", + "Clear" => "Borrar", + "Clear All" => "Borrar todo", + "Clear Formats" => "Borrar formatos", + "Clear Contents" => "Borrar contenido", + "Clear Comments and Notes" => "Borrar comentarios y notas", + "Clear Hyperlinks" => "Borrar hipervínculos", + "AutoSum" => "Autosuma", + "AutoSum (⌘⇧T)" => "Autosuma (⌘⇧T)", + "Flash\nFill" => "Relleno\nrápido", + "Flash Fill" => "Relleno rápido", + "Fill" => "Rellenar", + "Clear Print Area" => "Borrar área de impresión", + "Find &\nSelect" => "Buscar y\nseleccionar", + "Find…" => "Buscar…", + "Replace…" => "Reemplazar…", + "Go To…" => "Ir a…", + "Go To Special…" => "Ir a Especial…", + "PivotTable" => "Tabla dinámica", + "Recommended\nCharts" => "Gráficos\nrecomendados", + "Table" => "Tabla", + "Pictures" => "Imágenes", + "Shapes" => "Formas", + "Icons" => "Iconos", + "Text\nBox" => "Cuadro\nde texto", + "Comment" => "Comentario", + "New\nComment" => "Nuevo\ncomentario", + "New Note" => "Nueva nota", + "Show/Hide Note" => "Mostrar u ocultar nota", + "Header &\nFooter" => "Encabezado y\npie de página", + "Text to\nColumns" => "Texto en\ncolumnas", + "Link" => "Vínculo", + "Symbol" => "Símbolo", + "Sparklines" => "Minigráficos", + "Header & Footer" => "Encabezado y pie de página", + "Draw with ink" => "Dibujar con lápiz", + "Eraser" => "Borrador", + "Ink to\nShape" => "Entrada de lápiz\na forma", + "Select\nObjects" => "Seleccionar\nobjetos", + "Themes" => "Temas", + "Margins" => "Márgenes", + "Size" => "Tamaño", + "Print\nArea" => "Área de\nimpresión", + "Set Print Area" => "Establecer área de impresión", + "Breaks" => "Saltos", + "Insert Page Break" => "Insertar salto de página", + "Remove Page Break" => "Quitar salto de página", + "Reset All Page Breaks" => "Restablecer todos los saltos de página", + "Print\nTitles" => "Imprimir\ntítulos", + "Narrow" => "Estrecho", + "Wide" => "Ancho", + "Normal" => "Normal", + "Portrait" => "Vertical", + "Landscape" => "Horizontal", + "Custom Margins…" => "Márgenes personalizados…", + "Insert\nFunction" => "Insertar\nfunción", + "Name\nManager" => "Administrador\nde nombres", + "Define Name" => "Definir nombre", + "Use in Formula" => "Utilizar en la fórmula", + "Create from Selection" => "Crear desde la selección", + "Trace Precedents" => "Rastrear precedentes", + "Trace Dependents" => "Rastrear dependientes", + "Remove Arrows" => "Quitar flechas", + "Show Formulas" => "Mostrar fórmulas", + "Error Checking" => "Comprobación de errores", + "Evaluate Formula" => "Evaluar fórmula", + "Watch Window" => "Ventana Inspección", + "Calculation\nOptions" => "Opciones\nde cálculo", + "Calculate Now" => "Calcular ahora", + "Calculate Sheet" => "Calcular hoja", + "Automatic" => "Automático", + "Automatic Except for Data Tables" => "Automático excepto en las tablas de datos", + "Manual" => "Manual", + "Sort &\nFilter" => "Ordenar\ny filtrar", + "Sort" => "Ordenar", + "Sort A to Z" => "Ordenar de A a Z", + "Sort Z to A" => "Ordenar de Z a A", + "Custom Sort…" => "Orden personalizado…", + "Filter" => "Filtro", + "Reapply" => "Volver a aplicar", + "Get Data\n(Text/CSV)" => "Obtener datos\n(texto/CSV)", + "Remove\nDuplicates" => "Quitar\nduplicados", + "Data\nValidation" => "Validación\nde datos", + "Data Validation" => "Validación de datos", + "Data Validation…" => "Validación de datos…", + "Circle Invalid Data" => "Rodear con un círculo datos no válidos", + "Clear Validation" => "Borrar círculos de validación", + "Group" => "Agrupar", + "Ungroup" => "Desagrupar", + "Subtotal" => "Subtotal", + "What-If\nAnalysis" => "Análisis de\nhipótesis", + "Data Table…" => "Tabla de datos…", + "Scenario Manager…" => "Administrador de escenarios…", + "Goal Seek…" => "Buscar objetivo…", + "Spelling" => "Ortografía", + "Check\nAccessibility" => "Comprobar\naccesibilidad", + "Threaded Comments" => "Comentarios en conversación", + "Show\nComments" => "Mostrar\ncomentarios", + "Protect\nWorkbook" => "Proteger\nlibro", + "Unprotect\nSheet" => "Desproteger\nhoja", + "Protect Sheet…" => "Proteger hoja…", + "Comments" => "Comentarios", + "Notes" => "Notas", + "Page Break\nPreview" => "Vista previa de\nsalto de página", + "Page\nLayout" => "Diseño\nde página", + "Formula Bar" => "Barra de fórmulas", + "Gridlines" => "Líneas de cuadrícula", + "Headings" => "Encabezados", + "Zoom" => "Zoom", + "100%" => "100%", + "Zoom to\nSelection" => "Ampliar\nselección", + "Freeze\nPanes" => "Inmovilizar\npaneles", + "Freeze Panes" => "Inmovilizar paneles", + "Freeze Top Row" => "Inmovilizar fila superior", + "Freeze First Column" => "Inmovilizar primera columna", + "Unfreeze Panes" => "Movilizar paneles", + "Dark Mode" => "Modo oscuro", + "Interface language" => "Idioma de la interfaz", + "Command\nPalette" => "Paleta de\ncomandos", + "Agent\nControl" => "Control\ndel agente", + "Action\nJournal" => "Registro\nde acciones", + "About\nGridCraft" => "Acerca de\nGridCraft", + "Share" => "Compartir", + "Add Chart\nElement" => "Agregar elemento\nde gráfico", + "Change\nChart Type" => "Cambiar tipo\nde gráfico", + "Switch\nRow/Column" => "Cambiar entre\nfilas y columnas", + "Data Labels: None" => "Etiquetas de datos: ninguna", + "Data Labels: Show" => "Etiquetas de datos: mostrar", + "Legend: None" => "Leyenda: ninguna", + "Legend: Bottom" => "Leyenda: abajo", + "Legend: Top" => "Leyenda: arriba", + "Legend: Right" => "Leyenda: derecha", + "Gridlines: None" => "Líneas de cuadrícula: ninguna", + "Gridlines: Show" => "Líneas de cuadrícula: mostrar", + "Chart\nTitle" => "Título\ndel gráfico", + "Delete\nChart" => "Eliminar\ngráfico", + "Convert\nto Range" => "Convertir\nen rango", + "Table\nStyles" => "Estilos\nde tabla", + "Top 10 Items" => "10 superiores", + "Top 10%" => "10% superior", + "Bottom 10 Items" => "10 inferiores", + "Bottom 10%" => "10% inferior", + "Above Average" => "Por encima del promedio", + "Below Average" => "Por debajo del promedio", + "Clear Rules from Entire Sheet" => "Borrar reglas de toda la hoja", + "Clear Rules from Selected Cells" => "Borrar reglas de las celdas seleccionadas", + "Constants" => "Constantes", + "Data Bars" => "Barras de datos", + "Color Scales" => "Escalas de color", + "Icon Sets" => "Conjuntos de iconos", + "Home" => "Inicio", + "Draw" => "Dibujar", + "Page Layout" => "Disposición de página", + "Formulas" => "Fórmulas", + "Data" => "Datos", + "Review" => "Revisar", + "View" => "Vista", + "Automate" => "Automatizar", + "Table Design" => "Diseño de tabla", + "Chart Design" => "Diseño de gráfico", + "Ready" => "Listo", + "Enter" => "Introducir", + "Edit" => "Modificar", + "Point" => "Señalar", + "Calculate" => "Calcular", + "Select destination and press Enter or choose Paste" => "Seleccione el destino y presione Entrar o elija Pegar", + "General" => "General", + "Number" => "Número", + "Currency" => "Moneda", + "Accounting" => "Contabilidad", + "Date" => "Fecha", + "Time" => "Hora", + "Percentage" => "Porcentaje", + "Fraction" => "Fracción", + "Scientific" => "Científica", + "Text" => "Texto", + "Special" => "Especial", + "Custom" => "Personalizada", + "Short Date" => "Fecha corta", + "Long Date" => "Fecha larga", + "More Number Formats…" => "Más formatos de número…", + "Financial" => "Financieras", + "Logical" => "Lógicas", + "Date &\nTime" => "Fecha\ny hora", + "Lookup &\nReference" => "Búsqueda y\nreferencia", + "Math &\nTrig" => "Matemáticas y\ntrigonométricas", + "More\nFunctions" => "Más\nfunciones", + "Page Layout|Orientation" => "Orientación", + "Display theme" => "Tema de la interfaz", + "System" => "Sistema", + "Light" => "Claro", + "Dark" => "Oscuro", + "Sheet Options|View" => "Ver", + "Print" => "Imprimir", + _ => return None, + }) +} diff --git a/crates/ui-egui/tests/formula_locale.rs b/crates/ui-egui/tests/formula_locale.rs index a92ab13..9dcb65d 100644 --- a/crates/ui-egui/tests/formula_locale.rs +++ b/crates/ui-egui/tests/formula_locale.rs @@ -183,5 +183,5 @@ fn portuguese_interface_retains_its_canonical_formula_fallback() { assert!(!enter(&mut app, "=SUMA(1;2)")); let restored: UiState = serde_json::from_str(&serde_json::to_string(&app.ui).unwrap()).unwrap(); assert_eq!(restored.language, Language::PtBr); - assert_eq!(Language::Es.tr("Page Layout|Orientation"), "Orientation"); + assert_eq!(Language::Es.tr("Page Layout|Orientation"), "Orientación"); } From 2b1f851d0ae97d12f355a35732e7f6369c5a537a Mon Sep 17 00:00:00 2001 From: nihilist-narwhal <240798405+nihilist-narwhal@users.noreply.github.com> Date: Sat, 10 Oct 2026 01:07:16 -0500 Subject: [PATCH 15/83] Conditional formatting: apply every matching rule's format MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Only the first matching rule's format was used, so with a light red fill for >300 and a red border for >500, 510 showed only the border. Excel applies every matching rule; where two rules set the same part (fill, font colour, a border edge…), the rule with the higher priority wins, and Stop If True still ends the list. --- crates/engine/src/cf.rs | 70 ++++++++++++++++++++++++++++++++++++++--- 1 file changed, 66 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/cf.rs b/crates/engine/src/cf.rs index a102863..f7d3623 100644 --- a/crates/engine/src/cf.rs +++ b/crates/engine/src/cf.rs @@ -281,10 +281,12 @@ impl CfCache { }; if let Some(st) = matched_style { any = true; - out.style = Some(match out.style.take() { - None => st.clone(), - Some(prev) => prev, - }); + // Every matching rule applies; where two set the same part, the rule with the + // higher priority (seen first) wins. + match &mut out.style { + None => out.style = Some(st.clone()), + Some(prev) => layer_under(prev, st), + } if cf.stop_if_true { break; } @@ -294,6 +296,39 @@ impl CfCache { } } +/// Adds the parts `lower` sets that `style` leaves unset (differential styles: default parts +/// are unset). +fn layer_under(style: &mut Style, lower: &Style) { + if style.fill.pattern == PatternType::None { + style.fill = lower.fill; + } + if style.font.color == Color::Auto { + style.font.color = lower.font.color; + } + style.font.bold |= lower.font.bold; + style.font.italic |= lower.font.italic; + style.font.strike |= lower.font.strike; + if style.font.underline == Underline::None { + style.font.underline = lower.font.underline; + } + let b = &mut style.borders; + for (dst, src) in [ + (&mut b.left, lower.borders.left), + (&mut b.right, lower.borders.right), + (&mut b.top, lower.borders.top), + (&mut b.bottom, lower.borders.bottom), + (&mut b.diag_down, lower.borders.diag_down), + (&mut b.diag_up, lower.borders.diag_up), + ] { + if dst.is_none() { + *dst = src; + } + } + if style.num_fmt.as_str() == "General" { + style.num_fmt = lower.num_fmt.clone(); + } +} + /// Shifts a rule formula written for the top-left cell of the range to `c`. fn relative(f: &str, first: CellRef, c: CellRef) -> String { if first == c { @@ -306,3 +341,30 @@ fn relative(f: &str, first: CellRef, c: CellRef) -> String { Err(_) => f.to_string(), } } + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + + #[test] + fn every_matching_rule_applies() { + let mut s = crate::Session::new(); + s.new_workbook(); + s.execute("range.setValues", json!({"range": "A1", "values": [[200], [400], [510]]})).unwrap(); + let rule = |op: &str, value: &str, preset: &str| json!({"range": "A1:A3", "rule": {"type": "cellIs", "operator": op, "value": value, "preset": preset}}); + s.execute("home.conditionalFormat", rule("greaterThan", "300", "lightRedFill")).unwrap(); + s.execute("home.conditionalFormat", rule("greaterThan", "500", "redBorder")).unwrap(); + let d = s.doc().unwrap(); + let mut cache = CfCache::new(); + let mut look = |a: &str| cache.look(&d.wb, 0, CellRef::parse(a).unwrap()).and_then(|l| l.style); + assert!(look("A1").is_none()); + let only_fill = look("A2").unwrap(); + assert_eq!(only_fill.fill.pattern, PatternType::Solid); + assert!(only_fill.borders.top.is_none()); + let both = look("A3").unwrap(); + assert_eq!(both.fill, only_fill.fill); + assert!(!both.borders.top.is_none()); + } +} From 4e7379bd6801b935448183f052109993bc6cea06 Mon Sep 17 00:00:00 2001 From: nihilist-narwhal <240798405+nihilist-narwhal@users.noreply.github.com> Date: Sat, 10 Oct 2026 03:55:17 -0500 Subject: [PATCH 16/83] Conditional formatting: cell value rules on blank and error cells Excel compares a blank cell as 0 (or empty text) in Cell Value rules, so "less than 5" or "equal to 0" also formats blank cells, and a cell holding an error never matches (the comparison is an error). GridCraft skipped blank cells and matched errors as if they sorted above every number ("greater than 5" formatted #N/A). The No Blanks rule also counted a cell holding only spaces as not blank, while the Blanks rule (like Excel's LEN(TRIM())) counted it as blank, so such a cell matched both. --- crates/engine/src/cf.rs | 29 +++++++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cf.rs b/crates/engine/src/cf.rs index f7d3623..6640c7a 100644 --- a/crates/engine/src/cf.rs +++ b/crates/engine/src/cf.rs @@ -144,7 +144,8 @@ impl CfCache { CfOperator::GreaterOrEqual => compare(&v, &av) != std::cmp::Ordering::Less, CfOperator::LessOrEqual => compare(&v, &av) != std::cmp::Ordering::Greater, }; - (ok && !v.is_empty()).then_some(style.as_ref()) + // As in Excel, a blank cell compares as 0 (or ""), and an error never matches. + (ok && !v.is_error()).then_some(style.as_ref()) } CfRule::Expression { formula, style } => { gridcraft_calc::evaluate(wb, sheet, c, &relative(formula, first, c)).to_bool().unwrap_or(false).then_some(style.as_ref()) @@ -154,7 +155,7 @@ impl CfCache { CfRule::BeginsWith { text: t, style } => text.to_lowercase().starts_with(&t.to_lowercase()).then_some(style.as_ref()), CfRule::EndsWith { text: t, style } => text.to_lowercase().ends_with(&t.to_lowercase()).then_some(style.as_ref()), CfRule::Blanks { style } => (v.is_empty() || text.trim().is_empty()).then_some(style.as_ref()), - CfRule::NoBlanks { style } => (!v.is_empty()).then_some(style.as_ref()), + CfRule::NoBlanks { style } => (!v.is_empty() && !text.trim().is_empty()).then_some(style.as_ref()), CfRule::Errors { style } => v.is_error().then_some(style.as_ref()), CfRule::NoErrors { style } => (!v.is_error()).then_some(style.as_ref()), CfRule::Duplicate { style } => { @@ -367,4 +368,28 @@ mod tests { assert_eq!(both.fill, only_fill.fill); assert!(!both.borders.top.is_none()); } + + #[test] + fn cell_value_rules_on_blanks_and_errors() { + let mut s = crate::Session::new(); + s.new_workbook(); + s.execute("range.setValues", json!({"range": "A1", "values": [[3, "=NA()"], [null, 7], ["=NA()", null], [" ", null], [7, null]]})).unwrap(); + let rule = |range: &str, r: serde_json::Value| json!({"range": range, "rule": r}); + s.execute("home.conditionalFormat", rule("A1:A5", json!({"type": "cellIs", "operator": "less", "value": "5", "preset": "lightRedFill"}))) + .unwrap(); + s.execute("home.conditionalFormat", rule("A1:A5", json!({"type": "noBlanks", "preset": "redBorder"}))).unwrap(); + s.execute("home.conditionalFormat", rule("B1:B2", json!({"type": "cellIs", "operator": "greater", "value": "5", "preset": "lightRedFill"}))) + .unwrap(); + let d = s.doc().unwrap(); + let mut cache = CfCache::new(); + let mut look = |a: &str| cache.look(&d.wb, 0, CellRef::parse(a).unwrap()).and_then(|l| l.style); + let fill = |st: Option + +
Rp10,000.00Name
2.50 & upx
"#; + let rows = table_cells(excel); + assert_eq!(rows.len(), 2); + assert_eq!(rows[0][0], HtmlCell { text: "Rp10,000.00".into(), num: Some(10000.0) }); + assert_eq!(rows[0][1], HtmlCell { text: "Name".into(), num: None }); + assert_eq!(rows[1].len(), 3, "colspan keeps the next cell in its column"); + assert_eq!(rows[1][0].text, "2.50 & up"); + assert_eq!(rows[1][2].text, "x"); + + let calc = r#" +
Rp10,000.00
"#; + assert_eq!(table_cells(calc), vec![vec![HtmlCell { text: "Rp10,000.00".into(), num: Some(10000.0) }]]); + + let quoted_gt = r#"
1500
"#; + assert_eq!(table_cells(quoted_gt), vec![vec![HtmlCell { text: "1500".into(), num: Some(1500.0) }]]); + } + + #[test] + fn hostile_markup_does_not_panic() { + for html in [ + "", + "<", + "", + "
a", + "�&", + "", + " + + + From 46873b2826b502d1619b82be49efdb2c3eeddf81 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 11 Oct 2026 12:47:10 +0200 Subject: [PATCH 61/83] macOS: centre the window buttons in the title bar (#170) AppKit draws the traffic lights centred in its standard 28 pt title-bar band, but GridCraft's title bar was 38 pt tall, so the lights sat above its centre line. On macOS the title bar is now 28 pt with 22 pt controls, and content starts after the lights with the same gap they keep from the top. Other platforms are unchanged. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/ribbon.rs | 56 ++++++++++++++++++++++++++++-------- 1 file changed, 44 insertions(+), 12 deletions(-) diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 264479a..3145448 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -11,12 +11,33 @@ use crate::widgets::{big_button, color_palette, icon_button, small_button, split pub const TABS: &[&str] = &["Home", "Insert", "Draw", "Page Layout", "Formulas", "Data", "Review", "View", "Automate"]; +/// macOS draws the window into the title bar (`with_fullsize_content_view`) and AppKit paints the +/// traffic lights itself, centred in its standard 28 pt title-bar band. Moving them would take +/// AppKit calls (`unsafe`, forbidden here), so on macOS the title bar is that band: the lights sit +/// vertically centred in it, with the controls beside them (#170). +const MAC: bool = cfg!(target_os = "macos"); +pub const MAC_TITLE_BAR: f32 = 28.0; +/// The traffic lights as AppKit lays them out: button diameter, centre-to-centre pitch and the +/// first button's left edge (8–10 pt depending on the macOS version; the larger value keeps the +/// gap after them from shrinking). +const MAC_LIGHT: f32 = 12.0; +const MAC_LIGHT_PITCH: f32 = 20.0; +const MAC_LIGHTS_LEFT: f32 = 10.0; +/// Where title-bar content starts on macOS: the end of the three lights plus the gap the lights +/// keep from the top of the window. +pub const MAC_CONTENT_LEFT: f32 = MAC_LIGHTS_LEFT + 2.0 * MAC_LIGHT_PITCH + MAC_LIGHT + (MAC_TITLE_BAR - MAC_LIGHT) / 2.0; + /// Leave room for the macOS traffic lights when the content extends into the title bar. -pub const TITLE_LEFT_PAD: f32 = if cfg!(target_os = "macos") { 76.0 } else { 8.0 }; +pub const TITLE_LEFT_PAD: f32 = if MAC { MAC_CONTENT_LEFT } else { 8.0 }; +const TITLE_BAR_H: f32 = if MAC { MAC_TITLE_BAR } else { 38.0 }; +/// The tallest title-bar controls, smaller on macOS to keep a margin in the shorter bar. +const TITLE_CONTROL_H: f32 = if MAC { 22.0 } else { 26.0 }; +const _: () = assert!(TITLE_CONTROL_H < TITLE_BAR_H, "title-bar controls keep a margin above and below"); pub fn title_bar(app: &mut SheetApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); - egui::Panel::top("title_bar").exact_size(38.0).frame(egui::Frame::NONE.fill(t.window)).show(ui, |ui| { + let ch = TITLE_CONTROL_H; + egui::Panel::top("title_bar").exact_size(TITLE_BAR_H).frame(egui::Frame::NONE.fill(t.window)).show(ui, |ui| { let rect = ui.max_rect(); // Dragging the empty title area moves the window. let bg = ui.interact(rect, ui.id().with("drag_title"), Sense::click_and_drag()); @@ -41,19 +62,18 @@ pub fn title_bar(app: &mut SheetApp, ui: &mut Ui) { app.grid.toggle_autosave(); } ui.add_space(6.0); - if icon_button(ui, Icon::Home, t.text_dim, "Home", vec2(26.0, 26.0)).clicked() { + if icon_button(ui, Icon::Home, t.text_dim, "Home", vec2(26.0, ch)).clicked() { app.open_dialog("start", json!({})); } - if icon_button(ui, Icon::Save, t.text_dim, "Save (⌘S)", vec2(26.0, 26.0)).clicked() { + if icon_button(ui, Icon::Save, t.text_dim, "Save (⌘S)", vec2(26.0, ch)).clicked() { app.run_or_alert("file.save", json!({})); } let can_undo = app.session.active().is_some_and(|d| !d.undo.is_empty()); let can_redo = app.session.active().is_some_and(|d| !d.redo.is_empty()); - if icon_button(ui, Icon::Undo, if can_undo { t.text_dim } else { t.text_disabled }, "Undo (⌘Z)", vec2(26.0, 26.0)).clicked() && can_undo - { + if icon_button(ui, Icon::Undo, if can_undo { t.text_dim } else { t.text_disabled }, "Undo (⌘Z)", vec2(26.0, ch)).clicked() && can_undo { app.run_or_alert("edit.undo", json!({})); } - let undo_list = icon_button(ui, Icon::Chevron, t.text_dim, "Undo list", vec2(14.0, 26.0)); + let undo_list = icon_button(ui, Icon::Chevron, t.text_dim, "Undo list", vec2(14.0, ch)); egui::Popup::menu(&undo_list).show(|ui| { let labels: Vec = app.session.active().map(|d| d.undo.iter().rev().take(20).map(|e| e.label.clone()).collect()).unwrap_or_default(); @@ -66,11 +86,10 @@ pub fn title_bar(app: &mut SheetApp, ui: &mut Ui) { } } }); - if icon_button(ui, Icon::Redo, if can_redo { t.text_dim } else { t.text_disabled }, "Redo (⌘Y)", vec2(26.0, 26.0)).clicked() && can_redo - { + if icon_button(ui, Icon::Redo, if can_redo { t.text_dim } else { t.text_disabled }, "Redo (⌘Y)", vec2(26.0, ch)).clicked() && can_redo { app.run_or_alert("edit.redo", json!({})); } - let more = icon_button(ui, Icon::More, t.text_dim, "More commands", vec2(26.0, 26.0)); + let more = icon_button(ui, Icon::More, t.text_dim, "More commands", vec2(26.0, ch)); egui::Popup::menu(&more).show(|ui| { for (label, id) in [ ("New Workbook", "file.new"), @@ -97,7 +116,7 @@ pub fn title_bar(app: &mut SheetApp, ui: &mut Ui) { ); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { ui.add_space(8.0); - if icon_button(ui, Icon::Search, t.text_dim, "Search commands (⌘⇧Space)", vec2(28.0, 26.0)).clicked() { + if icon_button(ui, Icon::Search, t.text_dim, "Search commands (⌘⇧Space)", vec2(28.0, ch)).clicked() { app.open_dialog("commandSearch", json!({})); } let share = small_button(ui, Icon::Share, "Share", "Share: export or save a copy", true); @@ -110,7 +129,7 @@ pub fn title_bar(app: &mut SheetApp, ui: &mut Ui) { } } }); - if icon_button(ui, Icon::Comment, t.text_dim, "Comments", vec2(28.0, 26.0)).clicked() { + if icon_button(ui, Icon::Comment, t.text_dim, "Comments", vec2(28.0, ch)).clicked() { app.open_dialog("comments", json!({})); } }); @@ -1910,3 +1929,16 @@ pub fn shortcut(app: &mut SheetApp, key: Key, m: Modifiers) { fn unused(_: &Tokens, _: Icon) { let _ = icons::GREEN; } + +#[cfg(test)] +mod title_bar_tests { + use super::*; + + #[test] + fn mac_title_bar_centres_and_clears_the_traffic_lights() { + // The lights are centred in the bar, and content keeps the same gap after them. + let margin = (MAC_TITLE_BAR - MAC_LIGHT) / 2.0; + let lights_end = MAC_LIGHTS_LEFT + 2.0 * MAC_LIGHT_PITCH + MAC_LIGHT; + assert_eq!(MAC_CONTENT_LEFT - lights_end, margin); + } +} From dc75b2cf5ef72b7dbd409188c6545a73ac41b737 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 11 Oct 2026 12:56:36 +0200 Subject: [PATCH 62/83] Data: Solver with Simplex LP, integer branch and bound and a nonlinear method (#326) data.solver maximises, minimises or targets a value in an objective cell by changing variable cells under <=, >=, =, int and bin constraints. Simplex LP linearises the model through recalculation, checks linearity and solves it with a two-phase simplex; integers use branch and bound. The GRG Nonlinear choice runs an augmented-Lagrangian projected-gradient method. The model is saved as hidden solver_* names; Solver Parameters and Solver Results dialogs. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/mod.rs | 2 + crates/engine/src/cmd/solver.rs | 1487 +++++++++++++++++++++++++++ crates/ui-egui/src/dialogs.rs | 165 ++- crates/ui-egui/src/i18n.rs | 1 + crates/ui-egui/src/i18n/ko.rs | 1 + crates/ui-egui/src/i18n/pt.rs | 1 + crates/ui-egui/src/i18n/ru.rs | 1 + crates/ui-egui/src/i18n/zh.rs | 1 + crates/ui-egui/src/ribbon.rs | 4 + crates/ui-egui/tests/interaction.rs | 32 + docs/gaps.md | 6 +- docs/parity-checklist.md | 8 +- docs/target-app-parity.md | 2 +- docs/ui-parity.md | 2 +- 14 files changed, 1704 insertions(+), 9 deletions(-) create mode 100644 crates/engine/src/cmd/solver.rs diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index c4a290e..60d192a 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -14,6 +14,7 @@ pub mod pivot; pub mod print; pub mod review; pub mod sheet; +pub mod solver; pub mod spelling; pub mod view; pub mod whatif; @@ -118,6 +119,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(extra::specs()); v.extend(draw::specs()); v.extend(spelling::specs()); + v.extend(solver::specs()); v.extend(pivot::specs()); v.extend(whatif::specs()); v.extend(print::specs()); diff --git a/crates/engine/src/cmd/solver.rs b/crates/engine/src/cmd/solver.rs new file mode 100644 index 0000000..5759f54 --- /dev/null +++ b/crates/engine/src/cmd/solver.rs @@ -0,0 +1,1487 @@ +//! Solver (Data › Analysis › Solver): finds values for variable cells that maximise, minimise +//! or hit a value in an objective cell, subject to constraints. +//! +//! The workbook is the model: every evaluation sets the variable cells on a copy of the +//! workbook and recalculates their dependents ([`Trial`]), so any formula works. Two methods: +//! +//! - **Simplex LP**: the model is linearised from `n + 1` evaluations and checked for linearity +//! at one more point; then a dense two-phase simplex (Dantzig's rule, Bland's rule when it +//! stalls) solves it exactly. Integer and binary constraints use depth-first branch and bound. +//! - **GRG Nonlinear** (the name Excel's dialog uses): GridCraft's method is *not* a generalised +//! reduced gradient. It is an augmented-Lagrangian method (Powell–Hestenes–Rockafellar) whose +//! inner problems are solved by projected gradient descent with Barzilai–Borwein steps and an +//! Armijo line search, on central-difference gradients. Variable bounds are kept by +//! projection. It finds a local optimum of a smooth model; integer constraints use a small +//! branch and bound on top. +//! +//! Work is bounded by an evaluation budget (no clock: `Instant` traps on wasm). The model is +//! saved as hidden sheet-scoped names (`solver_opt`, `solver_adj`, `solver_lhs1`…), so it +//! travels with the workbook through XLSX. + +use gridcraft_core::{CellRef, RangeRef, Value}; +use gridcraft_model::{Cell, DefinedName, Workbook}; +use serde_json::{Value as Json, json}; + +use super::whatif::Trial; +use super::*; + +const CMD: &str = "data.solver"; +/// Excel's limit on decision variables. +const MAX_VARS: usize = 200; +/// Most scalar constraints (after expanding ranges). +const MAX_CONSTRAINTS: usize = 1000; +const DEFAULT_EVALS: u64 = 20_000; +const MAX_EVALS: u64 = 200_000; +/// Most simplex pivots × tableau cells per solve. +const LP_WORK: u64 = 2_000_000_000; +const MAX_NODES_LP: usize = 10_000; +const MAX_NODES_NLP: usize = 200; +/// Constraint precision (Excel's default 0.000001). +const PRECISION: f64 = 1e-6; +const EPS: f64 = 1e-9; + +pub fn specs() -> Vec { + vec![cmd!( + "data.solver", + "Solver…", + ["Data", "Analysis"], + None, + "{objective: \"B10\" (formula cell), goal?: max|min|value (default max), value?: number (for goal value), variables: \"B2:B4,D1\" (≤200 constant cells, same sheet), constraints?: [\"B5 <= 10\", \"B2:B4 >= C2:C4\", \"B2 int\", \"B3 bin\", {lhs: \"B6\", op: \"=\", rhs: 3 | \"C6\"}], nonNegative?: bool (default true: variables without a lower bound get 0), method?: grg|simplex (default grg), keep?: bool (default true; false restores the original values), saveModel?: bool (default true: hidden solver_* names), maxEvaluations?: 20000, integerOptimality?: percent (default 0), saved?: bool (solve the sheet's saved model; other params override)} → {status: optimal|converged|iterationLimit|infeasible|unbounded|notLinear, message, objective, values: {cell: value}, evaluations, changed}. No `objective` → {model: the saved model} (the menu opens the dialog).", + has_doc, + run + )] +} + +// ---------------------------------------------------------------- model + +#[derive(Clone, Copy, Debug, PartialEq)] +enum Rel { + Le, + Eq, + Ge, + Int, + Bin, +} + +impl Rel { + fn parse(s: &str) -> Option { + match s.trim().to_ascii_lowercase().as_str() { + "<=" | "≤" | "=<" | "le" => Some(Rel::Le), + ">=" | "≥" | "=>" | "ge" => Some(Rel::Ge), + "=" | "==" | "eq" => Some(Rel::Eq), + "int" | "integer" => Some(Rel::Int), + "bin" | "binary" => Some(Rel::Bin), + _ => None, + } + } + fn text(self) -> &'static str { + match self { + Rel::Le => "<=", + Rel::Eq => "=", + Rel::Ge => ">=", + Rel::Int => "int", + Rel::Bin => "bin", + } + } + /// The relation code of the saved model (`solver_relN`), as in Solver's VBA `SolverAdd`. + fn code(self) -> u8 { + match self { + Rel::Le => 1, + Rel::Eq => 2, + Rel::Ge => 3, + Rel::Int => 4, + Rel::Bin => 5, + } + } + fn from_code(c: &str) -> Option { + match c.trim() { + "1" => Some(Rel::Le), + "2" => Some(Rel::Eq), + "3" => Some(Rel::Ge), + "4" => Some(Rel::Int), + "5" => Some(Rel::Bin), + _ => None, + } + } +} + +#[derive(Clone, Debug)] +enum Rhs { + None, + Const(f64), + Cells(Vec), +} + +#[derive(Clone, Debug)] +struct Constraint { + lhs: Vec, + lhs_text: String, + rel: Rel, + rhs: Rhs, + rhs_text: String, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +enum Goal { + Max, + Min, + Value(f64), +} + +#[derive(Clone, Copy, Debug, PartialEq)] +enum Method { + Simplex, + Grg, +} + +#[derive(Clone, Debug)] +struct Model { + sheet: usize, + objective: CellRef, + goal: Goal, + vars: Vec, + vars_text: String, + cons: Vec, + non_negative: bool, + method: Method, +} + +/// Cells of `"B2:B4,D1"` on one sheet (row-major per area, in order), at most `cap`. +fn cells_of(wb: &Workbook, t: &str, default_sheet: usize, cap: usize) -> Result<(usize, Vec)> { + let mut sheet = None; + let mut out = Vec::new(); + for part in t.split(',').map(str::trim).filter(|x| !x.is_empty()) { + let (sh, body) = split_sheet(part); + let si = match sh { + Some(n) => wb.sheet_index(&n).ok_or_else(|| bad(CMD, format!("no sheet named `{n}`")))?, + None => default_sheet, + }; + if *sheet.get_or_insert(si) != si { + return Err(bad(CMD, "all cells of a reference must be on one sheet")); + } + let r = RangeRef::parse(body.trim()).ok_or_else(|| bad(CMD, format!("not a reference: `{part}`")))?; + if r.count() > cap as u64 || out.len() + r.count() as usize > cap { + return Err(bad(CMD, format!("too many cells (at most {cap})"))); + } + out.extend(r.iter()); + } + if out.is_empty() { + return Err(bad(CMD, format!("not a reference: `{t}`"))); + } + Ok((sheet.unwrap_or(default_sheet), out)) +} + +fn num_text(t: &str) -> Option { + gridcraft_core::parse::parse_number_text(t.trim()).filter(|v| v.is_finite()) +} + +/// Splits `"B5 <= 10"` / `"B2 int"` into (lhs, relation, rhs). +fn split_constraint(t: &str) -> Option<(String, Rel, String)> { + for op in ["<=", ">=", "=<", "=>", "≤", "≥", "=="] { + if let Some((a, b)) = t.split_once(op) { + return Some((a.trim().into(), Rel::parse(op)?, b.trim().into())); + } + } + if let Some((a, b)) = t.split_once('=') { + return Some((a.trim().into(), Rel::Eq, b.trim().into())); + } + let lower = t.trim().to_ascii_lowercase(); + for (suffix, rel) in [("integer", Rel::Int), ("binary", Rel::Bin), ("int", Rel::Int), ("bin", Rel::Bin)] { + if let Some(a) = lower.strip_suffix(suffix) { + let a = a.trim(); + return Some((t.trim().get(..a.len())?.trim().to_string(), rel, String::new())); + } + } + None +} + +fn parse_constraint(wb: &Workbook, sheet: usize, c: &Json) -> Result { + let (lhs_text, rel, rhs_json) = match c { + Json::String(t) => { + let (l, r, rhs) = split_constraint(t).ok_or_else(|| bad(CMD, format!("not a constraint: `{t}`")))?; + (l, r, Json::String(rhs)) + } + Json::Object(_) => { + let lhs = str_param(c, "lhs").or_else(|| str_param(c, "cell")).or_else(|| str_param(c, "range")).unwrap_or("").to_string(); + let op = str_param(c, "op").or_else(|| str_param(c, "relation")).unwrap_or(""); + let rel = Rel::parse(op).ok_or_else(|| bad(CMD, format!("unknown constraint relation `{op}`")))?; + (lhs, rel, c.get("rhs").or_else(|| c.get("value")).cloned().unwrap_or(Json::Null)) + } + _ => return Err(bad(CMD, "a constraint is a string like \"B5 <= 10\" or {lhs, op, rhs}")), + }; + let (lsi, lhs) = cells_of(wb, &lhs_text, sheet, MAX_CONSTRAINTS)?; + if lsi != sheet { + return Err(EngineError::Other("Constraint cells must be on the objective cell's sheet.".into())); + } + let (rhs, rhs_text) = match rel { + Rel::Int | Rel::Bin => (Rhs::None, String::new()), + _ => match &rhs_json { + Json::Number(n) => { + let v = n.as_f64().filter(|v| v.is_finite()).ok_or_else(|| bad(CMD, "the constraint's right side must be a number"))?; + (Rhs::Const(v), v.to_string()) + } + Json::String(t) => { + let t = t.trim().trim_start_matches('=').trim(); + if let Some(v) = num_text(t) { + (Rhs::Const(v), t.to_string()) + } else { + let (rsi, cells) = cells_of(wb, t, sheet, MAX_CONSTRAINTS)?; + if rsi != sheet { + return Err(EngineError::Other("Constraint cells must be on the objective cell's sheet.".into())); + } + if cells.len() != 1 && cells.len() != lhs.len() { + return Err(EngineError::Other("The constraint's two sides must be the same size (or the right side one cell).".into())); + } + (Rhs::Cells(cells), t.to_string()) + } + } + _ => return Err(bad(CMD, "missing the constraint's right side")), + }, + }; + Ok(Constraint { lhs, lhs_text, rel, rhs, rhs_text }) +} + +fn parse_model(s: &Session, p: &Json) -> Result { + 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 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()) { + return Err(EngineError::Other("The objective cell must contain a formula.".into())); + } + let goal = match str_param(p, "goal").or_else(|| str_param(p, "to")).unwrap_or("max").to_ascii_lowercase().as_str() { + "max" | "maximize" | "maximum" => Goal::Max, + "min" | "minimize" | "minimum" => Goal::Min, + "value" | "valueof" | "value of" => { + let v = f64_param(p, "value") + .or_else(|| str_param(p, "value").and_then(num_text)) + .filter(|v| v.is_finite()) + .ok_or_else(|| bad(CMD, "goal `value` needs a number in `value`"))?; + Goal::Value(v) + } + g => return Err(bad(CMD, format!("unknown goal `{g}` (max, min or value)"))), + }; + let vars_text = str_param(p, "variables").or_else(|| str_param(p, "changing")).or_else(|| str_param(p, "byChanging")).unwrap_or("").to_string(); + let (vsi, mut vars) = cells_of(wb, &vars_text, sheet, MAX_VARS).map_err(|e| match e { + EngineError::BadParams { msg, .. } if msg.starts_with("too many") => { + EngineError::Other(format!("Too many variable cells (at most {MAX_VARS}).")) + } + _ => EngineError::Other("The variable cells must be a reference like B2:B4.".into()), + })?; + if vsi != sheet { + return Err(EngineError::Other("The variable cells must be on the objective cell's sheet.".into())); + } + let mut seen = std::collections::HashSet::new(); + vars.retain(|c| seen.insert(*c)); + if vars.iter().any(|c| sh.cell(*c).is_some_and(|c| c.formula.is_some())) { + return Err(EngineError::Other("Variable cells must hold constants, not formulas.".into())); + } + let mut cons = Vec::new(); + let mut scalar = 0usize; + if let Some(list) = p.get("constraints") { + let list = list.as_array().ok_or_else(|| bad(CMD, "`constraints` must be an array"))?; + if list.len() > MAX_CONSTRAINTS { + return Err(EngineError::Other(format!("Too many constraints (at most {MAX_CONSTRAINTS})."))); + } + for c in list { + let c = parse_constraint(wb, sheet, c)?; + scalar = scalar.saturating_add(c.lhs.len()); + if scalar > MAX_CONSTRAINTS { + return Err(EngineError::Other(format!("Too many constraints (at most {MAX_CONSTRAINTS} cells)."))); + } + if matches!(c.rel, Rel::Int | Rel::Bin) && c.lhs.iter().any(|x| !vars.contains(x)) { + return Err(EngineError::Other("Integer and binary constraints must be on variable cells.".into())); + } + cons.push(c); + } + } + let non_negative = bool_param(p, "nonNegative").unwrap_or(true); + let method = match str_param(p, "method").unwrap_or("grg").to_ascii_lowercase().replace([' ', '_', '-'], "").as_str() { + "grg" | "grgnonlinear" | "nonlinear" => Method::Grg, + "simplex" | "simplexlp" | "lp" | "linear" => Method::Simplex, + "evolutionary" => return Err(EngineError::Other("The Evolutionary method isn't available; use GRG Nonlinear or Simplex LP.".into())), + m => return Err(bad(CMD, format!("unknown method `{m}` (grg or simplex)"))), + }; + Ok(Model { sheet, objective, goal, vars, vars_text, cons, non_negative, method }) +} + +// ---------------------------------------------------------------- saved model + +fn ref_text(sheet_name: &str, cells: &str) -> String { + let q = crate::pivot::quote_sheet(sheet_name); + cells + .split(',') + .map(str::trim) + .filter(|x| !x.is_empty()) + .filter_map(|part| { + let r = RangeRef::parse(split_sheet(part).1.trim())?; + let abs = |c: CellRef| format!("${}${}", gridcraft_core::col_to_letters(c.col), c.row as u64 + 1); + Some(if r.is_single() { format!("{q}!{}", abs(r.start)) } else { format!("{q}!{}:{}", abs(r.start), abs(r.end)) }) + }) + .collect::>() + .join(",") +} + +/// The model as hidden names scoped to its sheet (replacing any saved before). +fn save_model(wb: &mut Workbook, m: &Model) { + let Some(sheet_name) = wb.sheet(m.sheet).map(|s| s.name.clone()) else { return }; + wb.names.retain(|n| !(n.scope == Some(m.sheet) && n.name.to_ascii_lowercase().starts_with("solver_"))); + let mut put = |name: String, formula: String| { + wb.names.push(DefinedName { name, scope: Some(m.sheet), formula, comment: String::new(), hidden: true }); + }; + put("solver_opt".into(), ref_text(&sheet_name, &m.objective.a1())); + let (typ, val) = match m.goal { + Goal::Max => (1, 0.0), + Goal::Min => (2, 0.0), + Goal::Value(v) => (3, v), + }; + put("solver_typ".into(), typ.to_string()); + put("solver_val".into(), val.to_string()); + put("solver_adj".into(), ref_text(&sheet_name, &m.vars_text)); + for (i, c) in m.cons.iter().enumerate() { + let k = i + 1; + put(format!("solver_lhs{k}"), ref_text(&sheet_name, &c.lhs_text)); + put(format!("solver_rel{k}"), c.rel.code().to_string()); + let rhs = match &c.rhs { + Rhs::None => { + if c.rel == Rel::Bin { + "\"binary\"".to_string() + } else { + "\"integer\"".to_string() + } + } + Rhs::Const(v) => v.to_string(), + Rhs::Cells(_) => ref_text(&sheet_name, &c.rhs_text), + }; + put(format!("solver_rhs{k}"), rhs); + } + put("solver_num".into(), m.cons.len().to_string()); + put("solver_neg".into(), if m.non_negative { "1" } else { "2" }.into()); + put("solver_eng".into(), if m.method == Method::Simplex { "2" } else { "1" }.into()); + put("solver_ver".into(), "3".into()); +} + +/// The saved model of a sheet as `data.solver` params, if any. +pub fn saved_model(wb: &Workbook, sheet: usize) -> Option { + let get = |n: &str| { + wb.names + .iter() + .find(|d| d.name.eq_ignore_ascii_case(n) && (d.scope == Some(sheet) || d.scope.is_none())) + .map(|d| d.formula.trim().trim_start_matches('=').to_string()) + }; + let local = |t: String| -> String { t.split(',').map(|p| split_sheet(p.trim()).1.replace('$', "")).collect::>().join(",") }; + let objective = local(get("solver_opt")?); + if objective.is_empty() { + return None; + } + let goal = match get("solver_typ").as_deref().map(str::trim) { + Some("2") => "min", + Some("3") => "value", + _ => "max", + }; + let value = get("solver_val").and_then(|v| num_text(&v)).unwrap_or(0.0); + let count = get("solver_num").and_then(|v| num_text(&v)).unwrap_or(0.0).clamp(0.0, MAX_CONSTRAINTS as f64) as usize; + let mut constraints = Vec::new(); + for k in 1..=count { + let (Some(lhs), Some(rel)) = (get(&format!("solver_lhs{k}")), get(&format!("solver_rel{k}")).and_then(|r| Rel::from_code(&r))) else { + continue; + }; + let lhs = local(lhs); + constraints.push(match rel { + Rel::Int | Rel::Bin => json!(format!("{lhs} {}", rel.text())), + _ => { + let rhs = get(&format!("solver_rhs{k}")).unwrap_or_default(); + let rhs = if num_text(&rhs).is_some() { rhs } else { local(rhs) }; + json!(format!("{lhs} {} {rhs}", rel.text())) + } + }); + } + Some(json!({ + "objective": objective, + "goal": goal, + "value": value, + "variables": get("solver_adj").map(local).unwrap_or_default(), + "constraints": constraints, + "nonNegative": get("solver_neg").as_deref().map(str::trim) != Some("2"), + "method": if get("solver_eng").as_deref().map(str::trim) == Some("2") { "simplex" } else { "grg" }, + })) +} + +// ---------------------------------------------------------------- evaluation + +#[derive(Clone, Copy, Debug, PartialEq)] +enum Sense { + Le, + Ge, + Eq, +} + +/// A scalar constraint `lhs (sense) rhs`. +#[derive(Clone, Debug)] +struct Row { + lhs: CellRef, + sense: Sense, + rhs: Option, + rhs_const: f64, +} + +/// The values the solvers see at a point: the objective and `lhs − rhs` per row. +#[derive(Clone, Debug)] +struct Point { + f: f64, + g: Vec, +} + +struct Evaluator { + trial: Trial, + sheet: usize, + vars: Vec, + objective: CellRef, + rows: Vec, + evals: u64, + budget: u64, +} + +fn as_num(v: &Value) -> f64 { + match v { + Value::Number(n) => *n, + Value::Empty => 0.0, + Value::Bool(b) => f64::from(u8::from(*b)), + _ => f64::NAN, + } +} + +impl Evaluator { + fn exhausted(&self) -> bool { + self.evals >= self.budget + } + + /// Evaluates the model at `x`; `None` once the budget is spent. + fn eval(&mut self, x: &[f64]) -> Option { + if self.exhausted() { + return None; + } + self.evals += 1; + let sets: Vec<(usize, CellRef, Value)> = self.vars.iter().zip(x).map(|(c, v)| (self.sheet, *c, Value::number(*v))).collect(); + let mut reads = Vec::with_capacity(1 + 2 * self.rows.len()); + reads.push((self.sheet, self.objective)); + for r in &self.rows { + reads.push((self.sheet, r.lhs)); + if let Some(c) = r.rhs { + reads.push((self.sheet, c)); + } + } + let vals = self.trial.run(&sets, &reads); + let mut it = vals.iter().map(as_num); + let f = it.next().unwrap_or(f64::NAN); + let mut g = Vec::with_capacity(self.rows.len()); + for r in &self.rows { + let l = it.next().unwrap_or(f64::NAN); + let rv = if r.rhs.is_some() { it.next().unwrap_or(f64::NAN) } else { r.rhs_const }; + g.push(l - rv); + } + Some(Point { f, g }) + } +} + +// ---------------------------------------------------------------- problem + +struct Problem { + lo: Vec, + hi: Vec, + integer: Vec, + rows: Vec, +} + +/// Splits the constraints into variable bounds and general rows. +fn build_problem(m: &Model) -> Problem { + let n = m.vars.len(); + let mut lo = vec![f64::NEG_INFINITY; n]; + let mut hi = vec![f64::INFINITY; n]; + let mut has_lower = vec![false; n]; + let mut integer = vec![false; n]; + let mut rows = Vec::new(); + let var_index = |c: &CellRef| m.vars.iter().position(|v| v == c); + for c in &m.cons { + for (k, cell) in c.lhs.iter().enumerate() { + let j = var_index(cell); + match c.rel { + Rel::Int | Rel::Bin => { + if let Some(j) = j { + if let Some(b) = integer.get_mut(j) { + *b = true; + } + if c.rel == Rel::Bin + && let (Some(l), Some(h), Some(hl)) = (lo.get_mut(j), hi.get_mut(j), has_lower.get_mut(j)) + { + *l = l.max(0.0); + *h = h.min(1.0); + *hl = true; + } + } + } + rel => { + let sense = match rel { + Rel::Le => Sense::Le, + Rel::Ge => Sense::Ge, + _ => Sense::Eq, + }; + let (rhs, rhs_const) = match &c.rhs { + Rhs::Const(v) => (None, *v), + Rhs::Cells(cells) => (cells.get(k).or_else(|| cells.first()).copied(), 0.0), + Rhs::None => (None, 0.0), + }; + match (j, rhs) { + // A constant bound on a variable cell is a box bound. + (Some(j), None) => { + if let (Some(l), Some(h), Some(hl)) = (lo.get_mut(j), hi.get_mut(j), has_lower.get_mut(j)) { + if sense != Sense::Ge { + *h = h.min(rhs_const); + } + if sense != Sense::Le { + *l = l.max(rhs_const); + *hl = true; + } + } + } + _ => rows.push(Row { lhs: *cell, sense, rhs, rhs_const }), + } + } + } + } + } + if m.non_negative { + for (l, hl) in lo.iter_mut().zip(&has_lower) { + if !hl { + *l = l.max(0.0); + } + } + } + Problem { lo, hi, integer, rows } +} + +/// What a relaxation (or the whole solve) ended with. +#[derive(Clone, Debug)] +enum Outcome { + /// x, objective to minimise, and whether the solver converged (vs. ran out of budget). + Solved(Vec, f64, bool), + Infeasible, + Unbounded, + Limit, +} + +/// Depth-first branch and bound over a relaxation solver; returns the best integer point. +fn branch_and_bound( + lo: &[f64], + hi: &[f64], + integer: &[bool], + max_nodes: usize, + gap: f64, + relax: &mut dyn FnMut(&[f64], &[f64]) -> Outcome, +) -> (Outcome, usize) { + let mut stack: Vec<(Vec, Vec)> = vec![(lo.to_vec(), hi.to_vec())]; + let mut best: Option<(Vec, f64)> = None; + let mut nodes = 0usize; + let mut limited = false; + let mut root = true; + while let Some((l, h)) = stack.pop() { + if nodes >= max_nodes { + limited = true; + break; + } + nodes += 1; + let out = relax(&l, &h); + let was_root = std::mem::replace(&mut root, false); + let (x, obj, converged) = match out { + Outcome::Solved(x, obj, c) => (x, obj, c), + Outcome::Unbounded if was_root => return (Outcome::Unbounded, nodes), + Outcome::Limit => { + limited = true; + break; + } + _ => continue, + }; + if !converged { + limited = true; + } + if let Some((_, b)) = &best + && obj >= b - gap * b.abs().max(1e-12) - 1e-9 * (1.0 + b.abs()) + { + continue; + } + // The most fractional integer variable. + let frac = x + .iter() + .zip(integer) + .enumerate() + .filter(|(_, (_, int))| **int) + .map(|(j, (v, _))| (j, (v - v.round()).abs())) + .filter(|(_, f)| *f > 1e-6) + .max_by(|a, b| a.1.total_cmp(&b.1)); + match frac { + None => { + let snapped: Vec = x.iter().zip(integer).map(|(v, int)| if *int { v.round() } else { *v }).collect(); + best = Some((snapped, obj)); + } + Some((j, _)) => { + let v = x.get(j).copied().unwrap_or(0.0); + let (mut down_h, mut up_l) = (h.clone(), l.clone()); + if let Some(e) = down_h.get_mut(j) { + *e = e.min(v.floor()); + } + if let Some(e) = up_l.get_mut(j) { + *e = e.max(v.ceil()); + } + // Explore the nearer side first (pushed last). + if v - v.floor() < 0.5 { + stack.push((up_l, h)); + stack.push((l, down_h)); + } else { + stack.push((l, down_h)); + stack.push((up_l, h)); + } + } + } + } + match best { + Some((x, obj)) => (Outcome::Solved(x, obj, !limited), nodes), + None if limited => (Outcome::Limit, nodes), + None => (Outcome::Infeasible, nodes), + } +} + +// ---------------------------------------------------------------- simplex + +/// Pivots the tableau on (r, j), updating the objective row. +fn pivot(t: &mut [Vec], obj: &mut [f64], r: usize, j: usize) { + let Some(prow) = t.get_mut(r) else { return }; + let pv = prow.get(j).copied().unwrap_or(0.0); + if pv == 0.0 { + return; + } + for v in prow.iter_mut() { + *v /= pv; + } + let prow = prow.clone(); + for (i, row) in t.iter_mut().enumerate() { + if i == r { + continue; + } + let f = row.get(j).copied().unwrap_or(0.0); + if f != 0.0 { + for (v, p) in row.iter_mut().zip(&prow) { + *v -= f * p; + } + } + } + let f = obj.get(j).copied().unwrap_or(0.0); + if f != 0.0 { + for (v, p) in obj.iter_mut().zip(&prow) { + *v -= f * p; + } + } +} + +enum Step { + Optimal, + Unbounded, + Limit, +} + +/// Minimises with the reduced costs in `obj` (last entry: −objective). Columns not `allowed` +/// never enter. +fn simplex(t: &mut [Vec], basis: &mut [usize], obj: &mut [f64], allowed: &[bool], budget: &mut u64) -> Step { + let n = obj.len().saturating_sub(1); + let cost = (t.len() as u64 + 1).saturating_mul(n as u64 + 1).max(1); + let mut stalled = 0u32; + loop { + if *budget < cost { + return Step::Limit; + } + *budget -= cost; + let bland = stalled > 50; + let mut enter = None; + let mut best = -EPS; + for (j, d) in obj.iter().take(n).enumerate() { + if !allowed.get(j).copied().unwrap_or(false) || *d >= -EPS { + continue; + } + if bland { + enter = Some(j); + break; + } + if *d < best { + best = *d; + enter = Some(j); + } + } + let Some(j) = enter else { return Step::Optimal }; + let mut leave: Option<(usize, f64)> = None; + for (i, row) in t.iter().enumerate() { + let a = row.get(j).copied().unwrap_or(0.0); + if a <= EPS { + continue; + } + let ratio = row.get(n).copied().unwrap_or(0.0) / a; + let better = match leave { + None => true, + Some((li, lr)) => ratio < lr - 1e-12 || (ratio <= lr + 1e-12 && basis.get(i) < basis.get(li)), + }; + if better { + leave = Some((i, ratio)); + } + } + let Some((r, ratio)) = leave else { return Step::Unbounded }; + stalled = if ratio <= EPS { stalled + 1 } else { 0 }; + pivot(t, obj, r, j); + if let Some(b) = basis.get_mut(r) { + *b = j; + } + } +} + +/// Minimises `c·x` subject to `rows` (coefficients, sense, rhs) and `lo ≤ x ≤ hi`. +fn solve_lp(c: &[f64], rows: &[(Vec, Sense, f64)], lo: &[f64], hi: &[f64], budget: &mut u64) -> Outcome { + let n = c.len(); + if lo.iter().zip(hi).any(|(l, h)| l > h) { + return Outcome::Infeasible; + } + // x_j = offset + Σ sign·y_k with y ≥ 0. + struct Map { + var: usize, + sign: f64, + } + let mut cols: Vec = Vec::new(); + let mut offset = vec![0.0; n]; + let mut extra: Vec<(Vec<(usize, f64)>, Sense, f64)> = Vec::new(); + for j in 0..n { + let (l, h) = (lo.get(j).copied().unwrap_or(f64::NEG_INFINITY), hi.get(j).copied().unwrap_or(f64::INFINITY)); + if l.is_finite() { + if let Some(o) = offset.get_mut(j) { + *o = l; + } + cols.push(Map { var: j, sign: 1.0 }); + if h.is_finite() { + extra.push((vec![(cols.len() - 1, 1.0)], Sense::Le, h - l)); + } + } else if h.is_finite() { + if let Some(o) = offset.get_mut(j) { + *o = h; + } + cols.push(Map { var: j, sign: -1.0 }); + } else { + cols.push(Map { var: j, sign: 1.0 }); + cols.push(Map { var: j, sign: -1.0 }); + } + } + let ny = cols.len(); + // Rows over y: (coefficients, sense, rhs). + let mut yrows: Vec<(Vec, Sense, f64)> = Vec::with_capacity(rows.len() + extra.len()); + for (a, sense, b) in rows { + let mut coef = vec![0.0; ny]; + for (k, m) in cols.iter().enumerate() { + if let Some(v) = coef.get_mut(k) { + *v = a.get(m.var).copied().unwrap_or(0.0) * m.sign; + } + } + let shift: f64 = a.iter().zip(&offset).map(|(x, o)| x * o).sum(); + yrows.push((coef, *sense, b - shift)); + } + for (entries, sense, b) in extra { + let mut coef = vec![0.0; ny]; + for (k, v) in entries { + if let Some(e) = coef.get_mut(k) { + *e = v; + } + } + yrows.push((coef, sense, b)); + } + let m = yrows.len(); + let n_slack = yrows.iter().filter(|r| r.1 != Sense::Eq).count(); + // Rows with b < 0 are negated so every right side is ≥ 0. + let normalized: Vec<(Vec, Sense, f64)> = yrows + .into_iter() + .map(|(a, s, b)| { + if b < 0.0 { + let s = match s { + Sense::Le => Sense::Ge, + Sense::Ge => Sense::Le, + Sense::Eq => Sense::Eq, + }; + (a.iter().map(|v| -v).collect(), s, -b) + } else { + (a, s, b) + } + }) + .collect(); + let n_art = normalized.iter().filter(|r| r.1 != Sense::Le).count(); + let width = ny + n_slack + n_art; + let mut t: Vec> = Vec::with_capacity(m); + let mut basis = vec![0usize; m]; + let (mut si, mut ai) = (ny, ny + n_slack); + for (i, (a, s, b)) in normalized.iter().enumerate() { + let mut row = vec![0.0; width + 1]; + for (v, x) in row.iter_mut().zip(a) { + *v = *x; + } + match s { + Sense::Le => { + if let Some(v) = row.get_mut(si) { + *v = 1.0; + } + if let Some(bi) = basis.get_mut(i) { + *bi = si; + } + si += 1; + } + Sense::Ge => { + if let Some(v) = row.get_mut(si) { + *v = -1.0; + } + si += 1; + if let Some(v) = row.get_mut(ai) { + *v = 1.0; + } + if let Some(bi) = basis.get_mut(i) { + *bi = ai; + } + ai += 1; + } + Sense::Eq => { + if let Some(v) = row.get_mut(ai) { + *v = 1.0; + } + if let Some(bi) = basis.get_mut(i) { + *bi = ai; + } + ai += 1; + } + } + if let Some(v) = row.get_mut(width) { + *v = *b; + } + t.push(row); + } + let is_art = |j: usize| j >= ny + n_slack && j < width; + // Phase 1: minimise the sum of the artificials. + let mut obj = vec![0.0; width + 1]; + for j in (ny + n_slack)..width { + if let Some(v) = obj.get_mut(j) { + *v = 1.0; + } + } + for (i, row) in t.iter().enumerate() { + if basis.get(i).is_some_and(|b| is_art(*b)) { + for (v, x) in obj.iter_mut().zip(row) { + *v -= x; + } + } + } + let all: Vec = vec![true; width]; + if n_art > 0 { + match simplex(&mut t, &mut basis, &mut obj, &all, budget) { + Step::Limit => return Outcome::Limit, + Step::Unbounded | Step::Optimal => {} + } + let scale = normalized.iter().map(|r| r.2.abs()).fold(1.0f64, f64::max); + if -obj.get(width).copied().unwrap_or(0.0) > 1e-7 * scale { + return Outcome::Infeasible; + } + // Drive zero-valued artificials out of the basis where possible. + for i in 0..m { + if !basis.get(i).is_some_and(|b| is_art(*b)) { + continue; + } + let j = t.get(i).and_then(|row| (0..ny + n_slack).find(|&j| row.get(j).is_some_and(|v| v.abs() > EPS))); + if let Some(j) = j { + pivot(&mut t, &mut obj, i, j); + if let Some(b) = basis.get_mut(i) { + *b = j; + } + } + } + } + // Phase 2: the real costs; artificials may not re-enter. + let mut cost = vec![0.0; width + 1]; + for (k, mp) in cols.iter().enumerate() { + if let Some(v) = cost.get_mut(k) { + *v = c.get(mp.var).copied().unwrap_or(0.0) * mp.sign; + } + } + let mut obj = cost.clone(); + for (i, row) in t.iter().enumerate() { + let cb = basis.get(i).and_then(|b| cost.get(*b)).copied().unwrap_or(0.0); + if cb != 0.0 { + for (v, x) in obj.iter_mut().zip(row) { + *v -= cb * x; + } + } + } + let allowed: Vec = (0..width).map(|j| !is_art(j)).collect(); + match simplex(&mut t, &mut basis, &mut obj, &allowed, budget) { + Step::Limit => return Outcome::Limit, + Step::Unbounded => return Outcome::Unbounded, + Step::Optimal => {} + } + let mut y = vec![0.0; width]; + for (i, row) in t.iter().enumerate() { + if let (Some(b), Some(v)) = (basis.get(i), row.get(width)) + && let Some(e) = y.get_mut(*b) + { + *e = *v; + } + } + let mut x = offset; + for (k, mp) in cols.iter().enumerate() { + if let Some(e) = x.get_mut(mp.var) { + *e += mp.sign * y.get(k).copied().unwrap_or(0.0); + } + } + let val: f64 = c.iter().zip(&x).map(|(a, b)| a * b).sum(); + Outcome::Solved(x, val, true) +} + +/// Linearises the model at `x0` (n + 1 evaluations) and checks it at one more point. Returns the +/// objective gradient and constant, and per row the gradient and constant of `lhs − rhs`. +#[allow(clippy::type_complexity)] +fn linearize(ev: &mut Evaluator, x0: &[f64]) -> std::result::Result<((Vec, f64), Vec<(Vec, f64)>), &'static str> { + let n = x0.len(); + let p0 = ev.eval(x0).ok_or("budget")?; + if !p0.f.is_finite() || p0.g.iter().any(|v| !v.is_finite()) { + return Err("error"); + } + let mut cf = vec![0.0; n]; + let mut ga = vec![vec![0.0; n]; p0.g.len()]; + for j in 0..n { + let mut x = x0.to_vec(); + if let Some(v) = x.get_mut(j) { + *v += 1.0; + } + let p = ev.eval(&x).ok_or("budget")?; + if let Some(v) = cf.get_mut(j) { + *v = p.f - p0.f; + } + for (row, (gi, g0)) in ga.iter_mut().zip(p.g.iter().zip(&p0.g)) { + if let Some(v) = row.get_mut(j) { + *v = gi - g0; + } + } + } + let dot = |a: &[f64], x: &[f64]| a.iter().zip(x).map(|(p, q)| p * q).sum::(); + let f_const = p0.f - dot(&cf, x0); + let rows: Vec<(Vec, f64)> = ga.into_iter().zip(&p0.g).map(|(a, g0)| (a.clone(), g0 - dot(&a, x0))).collect(); + // Linearity check at a spread-out point. + let xt: Vec = + x0.iter().enumerate().map(|(j, v)| v + if j % 2 == 0 { 1.7 + 0.3 * (j % 5) as f64 } else { -0.6 - 0.2 * (j % 3) as f64 }).collect(); + let pt = ev.eval(&xt).ok_or("budget")?; + let close = |actual: f64, pred: f64, terms: f64| actual.is_finite() && (actual - pred).abs() <= 1e-6 * (1.0 + actual.abs() + terms); + let terms = |a: &[f64]| a.iter().zip(&xt).map(|(p, q)| (p * q).abs()).sum::(); + if !close(pt.f, f_const + dot(&cf, &xt), terms(&cf)) { + return Err("nonlinear"); + } + for ((a, k), g) in rows.iter().zip(&pt.g) { + if !close(*g, k + dot(a, &xt), terms(a)) { + return Err("nonlinear"); + } + } + Ok(((cf, f_const), rows)) +} + +// ---------------------------------------------------------------- nonlinear + +/// Augmented-Lagrangian value of a point: objective `phi` plus the constraint terms. +fn aug(phi: f64, cons: &[f64], senses: &[Sense], lam: &[f64], mu: f64) -> f64 { + let mut v = phi; + for ((c, s), l) in cons.iter().zip(senses).zip(lam) { + v += match s { + Sense::Eq => l * c + 0.5 * mu * c * c, + _ => { + let t = (l + mu * c).max(0.0); + (t * t - l * l) / (2.0 * mu) + } + }; + } + v +} + +/// Constraint values in the `≤ 0` / `= 0` form, and the objective to minimise. +fn shape(p: &Point, rows: &[Row], goal: Goal) -> (f64, Vec) { + let phi = match goal { + Goal::Max => -p.f, + Goal::Min => p.f, + Goal::Value(v) => (p.f - v) * (p.f - v), + }; + let cons = p.g.iter().zip(rows).map(|(g, r)| if r.sense == Sense::Ge { -g } else { *g }).collect(); + (phi, cons) +} + +fn violation(cons: &[f64], senses: &[Sense]) -> f64 { + cons.iter().zip(senses).map(|(c, s)| if *s == Sense::Eq { c.abs() } else { c.max(0.0) }).fold(0.0, f64::max) +} + +fn project(x: &mut [f64], lo: &[f64], hi: &[f64]) { + for ((v, l), h) in x.iter_mut().zip(lo).zip(hi) { + if *v < *l { + *v = *l; + } + if *v > *h { + *v = *h; + } + } +} + +/// Local minimisation of the model within bounds `lo..hi` from `x0`. +fn nlp(ev: &mut Evaluator, x0: &[f64], lo: &[f64], hi: &[f64], goal: Goal) -> Outcome { + let n = x0.len(); + let rows = ev.rows.clone(); + let senses: Vec = rows.iter().map(|r| r.sense).collect(); + let mut x = x0.to_vec(); + project(&mut x, lo, hi); + let mut lam = vec![0.0; rows.len()]; + let mut mu = 10.0; + let Some(p) = ev.eval(&x) else { return Outcome::Limit }; + let (mut phi, mut cons) = shape(&p, &rows, goal); + if !phi.is_finite() { + return Outcome::Infeasible; + } + let phi0 = phi; + let mut prev_viol = violation(&cons, &senses); + let mut converged = false; + let mut out_of_budget = false; + let mut carried: Option = None; + for _outer in 0..40 { + // Inner: projected gradient on the augmented Lagrangian. + let lval = |phi: f64, cons: &[f64], lam: &[f64], mu: f64| aug(phi, cons, &senses, lam, mu); + let grad = |ev: &mut Evaluator, x: &[f64], lam: &[f64], mu: f64| -> Option> { + let mut gr = vec![0.0; n]; + for j in 0..n { + let xj = x.get(j).copied().unwrap_or(0.0); + let h = 1e-6 * xj.abs().max(1.0); + let (l, u) = (lo.get(j).copied().unwrap_or(f64::NEG_INFINITY), hi.get(j).copied().unwrap_or(f64::INFINITY)); + let (a, b) = (if xj - h >= l { xj - h } else { xj }, if xj + h <= u { xj + h } else { xj }); + if b <= a { + continue; + } + let mut xa = x.to_vec(); + let mut xb = x.to_vec(); + if let Some(v) = xa.get_mut(j) { + *v = a; + } + if let Some(v) = xb.get_mut(j) { + *v = b; + } + let (pa, ca) = shape(&ev.eval(&xa)?, &rows, goal); + let (pb, cb) = shape(&ev.eval(&xb)?, &rows, goal); + let d = (lval(pb, &cb, lam, mu) - lval(pa, &ca, lam, mu)) / (b - a); + if let Some(v) = gr.get_mut(j) { + *v = if d.is_finite() { d } else { 0.0 }; + } + } + Some(gr) + }; + let mut l_cur = lval(phi, &cons, &lam, mu); + let Some(mut g) = grad(ev, &x, &lam, mu) else { + out_of_budget = true; + break; + }; + // The step length carries over between outer iterations once known. + let gnorm = g.iter().map(|v| v.abs()).fold(0.0, f64::max); + let mut alpha = carried.unwrap_or(if gnorm > 0.0 { 1.0 / gnorm } else { 1.0 }); + let mut inner_done = false; + let mut quiet = 0; + for _inner in 0..500 { + let mut accepted = None; + let mut a = alpha; + for _ in 0..40 { + let mut xn: Vec = x.iter().zip(&g).map(|(v, d)| v - a * d).collect(); + project(&mut xn, lo, hi); + let Some(pn) = ev.eval(&xn) else { break }; + let (phn, cn) = shape(&pn, &rows, goal); + let ln = lval(phn, &cn, &lam, mu); + let decrease: f64 = g.iter().zip(x.iter().zip(&xn)).map(|(d, (u, v))| d * (u - v)).sum(); + if ln.is_finite() && ln <= l_cur - 1e-4 * decrease { + accepted = Some((xn, phn, cn, ln)); + break; + } + a *= 0.25; + } + if ev.exhausted() { + out_of_budget = true; + } + let Some((xn, phn, cn, ln)) = accepted else { + inner_done = true; + break; + }; + // Variables or objective running off towards infinity: unbounded. + if phn.abs() > 1e15 * (1.0 + phi0.abs()) || xn.iter().zip(x0).any(|(v, s)| v.abs() > 1e15 * (1.0 + s.abs())) { + return Outcome::Unbounded; + } + let s: Vec = xn.iter().zip(&x).map(|(a, b)| a - b).collect(); + let step = s.iter().map(|v| v.abs()).fold(0.0, f64::max); + let xmax = xn.iter().map(|v| v.abs()).fold(1.0, f64::max); + let small = step <= 1e-10 * xmax || (l_cur - ln).abs() <= 1e-13 * (1.0 + l_cur.abs()); + quiet = if small { quiet + 1 } else { 0 }; + x = xn; + phi = phn; + cons = cn; + l_cur = ln; + if quiet >= 3 { + inner_done = true; + break; + } + let Some(gn) = grad(ev, &x, &lam, mu) else { + out_of_budget = true; + break; + }; + // Barzilai–Borwein step. + let yv: Vec = gn.iter().zip(&g).map(|(a, b)| a - b).collect(); + let sy: f64 = s.iter().zip(&yv).map(|(a, b)| a * b).sum(); + let ss: f64 = s.iter().map(|v| v * v).sum(); + alpha = if sy > 0.0 { (ss / sy).clamp(1e-12, 1e12) } else { (a * 4.0).min(1e12) }; + g = gn; + if g.iter().all(|v| *v == 0.0) { + inner_done = true; + break; + } + } + carried = Some(alpha); + if out_of_budget { + break; + } + let viol = violation(&cons, &senses); + let scale = rows.iter().map(|r| r.rhs_const.abs()).fold(1.0, f64::max); + if viol <= PRECISION * scale && inner_done { + converged = true; + if rows.is_empty() { + break; + } + } + // Multiplier and penalty updates. + for ((l, c), s) in lam.iter_mut().zip(&cons).zip(&senses) { + *l = match s { + Sense::Eq => *l + mu * c, + _ => (*l + mu * c).max(0.0), + }; + } + if viol > 0.25 * prev_viol { + mu = (mu * 10.0).min(1e10); + } + if converged && viol <= PRECISION * scale * 1e-2 { + break; + } + prev_viol = viol; + converged = false; + } + let viol = violation(&cons, &senses); + let scale = rows.iter().map(|r| r.rhs_const.abs()).fold(1.0, f64::max); + if viol > PRECISION * scale * 10.0 { + return if out_of_budget { Outcome::Limit } else { Outcome::Infeasible }; + } + Outcome::Solved(x, phi, (converged || viol <= PRECISION * scale) && !out_of_budget) +} + +// ---------------------------------------------------------------- command + +fn run(s: &mut Session, p: &Json) -> Result { + let d = s.doc()?; + 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()))?; + if let (Some(base), Some(over)) = (saved.as_object(), p.as_object()) { + let mut merged = base.clone(); + for (k, v) in over { + merged.insert(k.clone(), v.clone()); + } + params = Json::Object(merged); + } + } + let has_objective = ["objective", "setObjective", "set"].iter().any(|k| str_param(¶ms, k).is_some_and(|t| !t.trim().is_empty())); + if !has_objective { + let model = saved_model(&d.wb, active); + s.ui_requests.push(crate::UiRequest::Dialog("solver".into(), json!({}))); + return Ok(json!({"model": model})); + } + let model = parse_model(s, ¶ms)?; + let budget = params + .get("maxEvaluations") + .and_then(Json::as_f64) + .filter(|v| v.is_finite() && *v >= 1.0) + .map_or(DEFAULT_EVALS, |v| (v as u64).min(MAX_EVALS)); + let gap = params.get("integerOptimality").and_then(Json::as_f64).filter(|v| v.is_finite()).map_or(0.0, |v| v.clamp(0.0, 100.0) / 100.0); + let keep = bool_param(¶ms, "keep").unwrap_or(true); + let save = bool_param(¶ms, "saveModel").unwrap_or(true); + let prob = build_problem(&model); + let d = s.doc()?; + let sh = d.wb.sheet(model.sheet).ok_or(EngineError::NoDocument)?; + let x0: Vec = model.vars.iter().map(|c| as_num(&sh.value(*c))).map(|v| if v.is_finite() { v } else { 0.0 }).collect(); + let mut ev = Evaluator { + trial: Trial::new(d), + sheet: model.sheet, + vars: model.vars.clone(), + objective: model.objective, + rows: prob.rows.clone(), + evals: 0, + budget, + }; + let any_int = prob.integer.iter().any(|b| *b); + let mut not_linear = false; + let outcome = if prob.lo.iter().zip(&prob.hi).any(|(l, h)| l > h) { + Outcome::Infeasible + } else { + match model.method { + Method::Simplex => match linearize(&mut ev, &x0) { + Err("nonlinear") | Err("error") => { + not_linear = true; + Outcome::Infeasible + } + Err(_) => Outcome::Limit, + Ok(((cf, f_const), lin)) => { + let mut rows: Vec<(Vec, Sense, f64)> = lin.iter().zip(&prob.rows).map(|((a, k), r)| (a.clone(), r.sense, -k)).collect(); + let c: Vec = match model.goal { + Goal::Max => cf.iter().map(|v| -v).collect(), + Goal::Min => cf.clone(), + Goal::Value(v) => { + rows.push((cf.clone(), Sense::Eq, v - f_const)); + vec![0.0; cf.len()] + } + }; + let size = (rows.len() as u64 + 2 * model.vars.len() as u64 + 1) * (4 * model.vars.len() as u64 + 2 * rows.len() as u64 + 1); + let mut work = LP_WORK.max(size); + let mut relax = |l: &[f64], h: &[f64]| solve_lp(&c, &rows, l, h, &mut work); + if any_int { + branch_and_bound(&prob.lo, &prob.hi, &prob.integer, MAX_NODES_LP, gap, &mut relax).0 + } else { + relax(&prob.lo, &prob.hi) + } + } + }, + Method::Grg => { + let goal = model.goal; + if any_int { + let mut relax = |l: &[f64], h: &[f64]| { + let mut start = x0.clone(); + project(&mut start, l, h); + nlp(&mut ev, &start, l, h, goal) + }; + branch_and_bound(&prob.lo, &prob.hi, &prob.integer, MAX_NODES_NLP, gap, &mut relax).0 + } else { + nlp(&mut ev, &x0, &prob.lo, &prob.hi, goal) + } + } + } + }; + let (status, message, solution) = match outcome { + _ if not_linear => ("notLinear", "The model isn't linear in the variable cells, so Simplex LP can't solve it. Try GRG Nonlinear.", None), + Outcome::Solved(x, _, true) => ( + if model.method == Method::Simplex { "optimal" } else { "converged" }, + if model.method == Method::Simplex { + "Solver found an optimal solution. All constraints are satisfied." + } else { + "Solver converged to a locally optimal solution. All constraints are satisfied." + }, + Some(x), + ), + Outcome::Solved(x, _, false) => { + ("iterationLimit", "Solver stopped at its evaluation limit; the best solution found satisfies the constraints.", Some(x)) + } + Outcome::Limit => ("iterationLimit", "Solver stopped at its evaluation limit without finding a feasible solution.", None), + Outcome::Infeasible => ("infeasible", "Solver could not find a solution that satisfies all constraints.", None), + Outcome::Unbounded => ("unbounded", "The objective can grow without limit: the problem is unbounded.", None), + }; + let evaluations = ev.evals; + // The objective's value at the solution. + let objective = solution.as_ref().and_then(|x| { + ev.budget = ev.budget.saturating_add(1); + ev.eval(x).map(|p| p.f) + }); + let mut values = serde_json::Map::new(); + if let Some(x) = &solution { + for (c, v) in model.vars.iter().zip(x) { + values.insert(c.a1(), json!(v)); + } + } + let changed = keep && solution.is_some(); + if changed || save { + let sheet = model.sheet; + let vars = model.vars.clone(); + let m = model.clone(); + edit(s, |cx| { + if changed && let Some(x) = &solution { + let sh = cx.sheet_mut(sheet)?; + for (c, v) in vars.iter().zip(x) { + let style = sh.style_id(*c); + sh.set_cell(*c, Cell { value: Value::number(*v), formula: None, style }); + } + for c in &vars { + cx.touch(sheet, *c); + } + } + if save { + save_model(&mut cx.wb, &m); + } + Ok(()) + })?; + } + Ok(json!({ + "status": status, + "message": message, + "objective": objective, + "values": values, + "evaluations": evaluations, + "changed": changed, + })) +} + +#[cfg(test)] +mod tests { + use gridcraft_core::CellRef; + use serde_json::json; + + use crate::Session; + + fn s() -> Session { + let mut s = Session::new(); + s.new_workbook(); + s + } + fn n(s: &Session, a: &str) -> f64 { + let d = s.doc().unwrap(); + d.wb.active().unwrap().value(CellRef::parse(a).unwrap()).as_f64().unwrap_or(f64::NAN) + } + fn set(s: &mut Session, cells: &[(&str, &str)]) { + for (c, v) in cells { + s.execute("cell.set", json!({"cell": c, "input": v})).unwrap(); + } + } + + #[test] + fn simplex_solves_the_wyndor_glass_lp() { + // Hillier & Lieberman's Wyndor Glass problem: max 3x + 5y, x ≤ 4, 2y ≤ 12, 3x + 2y ≤ 18 + // → x = 2, y = 6, Z = 36. + let mut s = s(); + set(&mut s, &[("A1", "0"), ("A2", "0"), ("B1", "=3*A1+5*A2"), ("B2", "=2*A2"), ("B3", "=3*A1+2*A2")]); + let r = s + .execute( + "data.solver", + json!({"objective": "B1", "goal": "max", "variables": "A1:A2", "constraints": ["A1 <= 4", "B2 <= 12", {"lhs": "B3", "op": "<=", "rhs": 18}], "method": "simplex"}), + ) + .unwrap(); + assert_eq!(r["status"], "optimal", "{r}"); + assert!((n(&s, "A1") - 2.0).abs() < 1e-9 && (n(&s, "A2") - 6.0).abs() < 1e-9); + assert!((n(&s, "B1") - 36.0).abs() < 1e-9); + // The model is saved as hidden names, survives XLSX and solves again from them. + let names: Vec = s.doc().unwrap().wb.names.iter().map(|n| n.name.clone()).collect(); + assert!(names.iter().any(|n| n == "solver_opt") && names.iter().any(|n| n == "solver_lhs3")); + let b64 = s.execute("file.saveBytes", json!({"format": "xlsx"})).unwrap()["base64"].as_str().unwrap().to_string(); + s.execute("file.open", json!({"name": "m.xlsx", "base64": b64})).unwrap(); + set(&mut s, &[("A1", "0"), ("A2", "0")]); + let m = s.execute("data.solver", json!({})).unwrap(); + assert_eq!(m["model"]["objective"], "B1"); + assert_eq!(m["model"]["method"], "simplex"); + let r = s.execute("data.solver", json!({"saved": true})).unwrap(); + assert_eq!(r["status"], "optimal"); + assert!((n(&s, "B1") - 36.0).abs() < 1e-9); + // Minimise with keep: false leaves the cells alone. + set(&mut s, &[("A1", "1"), ("A2", "1")]); + let r = s.execute("data.solver", json!({"saved": true, "goal": "min", "keep": false})).unwrap(); + assert_eq!(r["objective"], 0.0); + assert_eq!((n(&s, "A1"), n(&s, "A2")), (1.0, 1.0)); + } + + #[test] + fn integer_and_binary_constraints_use_branch_and_bound() { + // max 5x + 4y, 6x + 4y ≤ 24, x + 2y ≤ 6: the LP optimum (3, 1.5) = 21; integers give + // (4, 0) = 20. + let mut s = s(); + set(&mut s, &[("B1", "=5*A1+4*A2"), ("B2", "=6*A1+4*A2"), ("B3", "=A1+2*A2")]); + let base = json!({"objective": "B1", "variables": "A1:A2", "constraints": ["B2 <= 24", "B3 <= 6"], "method": "simplex"}); + let r = s.execute("data.solver", base.clone()).unwrap(); + assert!((r["objective"].as_f64().unwrap() - 21.0).abs() < 1e-9); + let mut p = base.clone(); + p["constraints"].as_array_mut().unwrap().push(json!("A1:A2 int")); + let r = s.execute("data.solver", p).unwrap(); + assert_eq!(r["status"], "optimal"); + assert_eq!((n(&s, "A1"), n(&s, "A2"), n(&s, "B1")), (4.0, 0.0, 20.0)); + // 0/1 knapsack: values 10, 13, 7, weights 5, 6, 4, capacity 10 → items 2 and 3 (20). + set(&mut s, &[("D1", "=10*C1+13*C2+7*C3"), ("D2", "=5*C1+6*C2+4*C3")]); + let r = s + .execute("data.solver", json!({"objective": "D1", "variables": "C1:C3", "constraints": ["D2 <= 10", "C1:C3 bin"], "method": "simplex"})) + .unwrap(); + assert_eq!(r["status"], "optimal"); + assert_eq!((n(&s, "C1"), n(&s, "C2"), n(&s, "C3"), n(&s, "D1")), (0.0, 1.0, 1.0, 20.0)); + } + + #[test] + fn grg_finds_smooth_optima() { + // Largest rectangle with perimeter at most 20: a 5 × 5 square, area 25. + let mut s = s(); + set(&mut s, &[("A1", "1"), ("A2", "1"), ("B1", "=A1*A2"), ("B2", "=2*A1+2*A2")]); + let r = s.execute("data.solver", json!({"objective": "B1", "variables": "A1:A2", "constraints": ["B2 <= 20"]})).unwrap(); + assert_eq!(r["status"], "converged", "{r}"); + assert!((n(&s, "A1") - 5.0).abs() < 1e-3 && (n(&s, "A2") - 5.0).abs() < 1e-3, "{r}"); + // Unconstrained minimum of (x − 3)² + (y + 1)² at (3, −1) once negatives are allowed. + set(&mut s, &[("C1", "0"), ("C2", "0"), ("D1", "=(C1-3)^2+(C2+1)^2")]); + s.execute("data.solver", json!({"objective": "D1", "goal": "min", "variables": "C1:C2", "nonNegative": false})).unwrap(); + assert!((n(&s, "C1") - 3.0).abs() < 1e-4 && (n(&s, "C2") + 1.0).abs() < 1e-4); + // Value Of: x³ = 8 → x = 2. + set(&mut s, &[("E1", "1"), ("F1", "=E1^3")]); + s.execute("data.solver", json!({"objective": "F1", "goal": "value", "value": 8, "variables": "E1"})).unwrap(); + assert!((n(&s, "E1") - 2.0).abs() < 1e-4); + } + + #[test] + fn infeasible_unbounded_and_nonlinear_models_are_reported() { + let mut s = s(); + set(&mut s, &[("A1", "1"), ("B1", "=2*A1")]); + for method in ["simplex", "grg"] { + let r = s + .execute("data.solver", json!({"objective": "B1", "variables": "A1", "constraints": ["A1 >= 5", "B1 <= 6"], "method": method})) + .unwrap(); + assert_eq!(r["status"], "infeasible", "{method}: {r}"); + assert_eq!(r["changed"], false); + assert_eq!(n(&s, "A1"), 1.0, "an infeasible model leaves the cells alone"); + } + let r = s.execute("data.solver", json!({"objective": "B1", "variables": "A1", "method": "simplex"})).unwrap(); + assert_eq!(r["status"], "unbounded"); + let r = s.execute("data.solver", json!({"objective": "B1", "variables": "A1", "method": "grg", "maxEvaluations": 5000})).unwrap(); + assert!(r["status"] == "unbounded" || r["status"] == "iterationLimit", "{r}"); + set(&mut s, &[("A1", "1"), ("C1", "=A1^2")]); + let r = s.execute("data.solver", json!({"objective": "C1", "goal": "min", "variables": "A1", "method": "simplex"})).unwrap(); + assert_eq!(r["status"], "notLinear"); + } + + #[test] + fn hostile_models_are_refused_without_panicking() { + let mut s = s(); + let r = s.execute("data.solver", json!({})).unwrap(); + assert!(r["model"].is_null()); + assert!(s.take_ui_requests().iter().any(|q| matches!(q, crate::UiRequest::Dialog(n, _) if n == "solver"))); + set(&mut s, &[("B1", "=SUM(A1:A1000)"), ("C1", "5")]); + for p in [ + json!({"objective": "B1", "variables": "A1:A1000"}), + json!({"objective": "B1", "variables": "A1:XFD1048576"}), + json!({"objective": "C1", "variables": "A1"}), + json!({"objective": "B1:B2", "variables": "A1"}), + json!({"objective": "B1", "variables": "B1"}), + json!({"objective": "B1", "variables": "A1", "goal": "sideways"}), + json!({"objective": "B1", "variables": "A1", "goal": "value"}), + json!({"objective": "B1", "variables": "A1", "method": "evolutionary"}), + json!({"objective": "B1", "variables": "A1", "constraints": ["A1 ?? 3"]}), + json!({"objective": "B1", "variables": "A1", "constraints": "A1 <= 3"}), + json!({"objective": "B1", "variables": "A1", "constraints": ["A1:A3 <= C1:C2"]}), + json!({"objective": "B1", "variables": "A1", "constraints": ["C1 int"]}), + json!({"objective": "B1", "variables": "A1", "constraints": ["A1:A1048576 <= 1"]}), + json!({"objective": "B1", "variables": "A1", "constraints": vec![json!("A1 <= 1"); 1001]}), + json!({"objective": "Nope!B1", "variables": "A1"}), + ] { + assert!(s.execute("data.solver", p.clone()).is_err(), "{p}"); + } + // Huge evaluation requests are capped, and a model erroring everywhere doesn't panic. + set(&mut s, &[("D1", "=1/0+A1")]); + let r = s.execute("data.solver", json!({"objective": "D1", "variables": "A1", "maxEvaluations": 1e300})).unwrap(); + assert_ne!(r["status"], "converged"); + } +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 7dd3acb..d67bc1a 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -317,6 +317,23 @@ impl Dialog { json!({"header": true}), ), "goalSeek" => Dialog::custom("goalSeek", "Goal Seek", json!({"set": active, "to": "0", "changing": ""})), + "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 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 { + v.insert(k.clone(), if k == "value" { json!(x.to_string()) } else { x.clone() }); + } + } + if let Some(v) = values.as_object_mut() { + v.insert("newOp".into(), json!("<=")); + } + let mut d = Dialog::custom("solver", "Solver Parameters", values); + d.command = Some("data.solver"); + d + } + "solverResults" => Dialog::custom("solverResults", "Solver Results", params.clone()), "subtotal" => Dialog::form( "subtotal", "Subtotal", @@ -659,6 +676,7 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) { "formatCells" => 560.0, "about" => 660.0, "insertFunction" | "commandSearch" | "nameManager" | "manageRules" | "journal" | "agents" => 520.0, + "solver" => 460.0, _ => 380.0, }; let window = egui::Window::new(d.title.clone()) @@ -681,6 +699,8 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) { "manageRules" => manage_rules(app, ui), "commandSearch" => command_search(app, ui, &mut d, &mut confirm), "goalSeek" => goal_seek(app, ui, &mut d, &mut confirm), + "solver" => solver(ui, &mut d, &mut confirm, &mut open), + "solverResults" => solver_results(app, ui, &mut d, &mut open), "spelling" => spelling(app, ui, &mut d, &mut open), "agents" => { ui.label("GridCraft is fully drivable by agents. Every menu item, button and gesture is a command:"); @@ -877,7 +897,12 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) { if confirm && let Some(cmd) = d.command { let params = build_params(&d); match app.run(cmd, params) { - Ok(_) => open = false, + Ok(r) => { + open = false; + if d.name == "solver" { + app.open_dialog("solverResults", r); + } + } Err(e) => app.message = Some((d.title.clone(), crate::clean_error(&e))), } } else if confirm { @@ -1607,6 +1632,144 @@ fn goal_seek(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mu } } +/// Data › Solver: the Solver Parameters form. Solve runs `data.solver` through the dialog's +/// command; the result opens Solver Results. +fn solver(ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) { + let text = |d: &Dialog, k: &str| d.values.get(k).map(|v| v.as_str().map(str::to_string).unwrap_or_else(|| v.to_string())).unwrap_or_default(); + let edit = |ui: &mut egui::Ui, d: &mut Dialog, k: &str, width: f32| { + let mut t = text(d, k); + if ui.add(egui::TextEdit::singleline(&mut t).desired_width(width)).changed() { + d.values.insert(k.into(), json!(t)); + } + }; + ui.horizontal(|ui| { + ui.label("Set Objective:"); + edit(ui, d, "objective", 200.0); + }); + let goal = text(d, "goal"); + ui.horizontal(|ui| { + ui.label("To:"); + for (g, label) in [("max", "Max"), ("min", "Min"), ("value", "Value Of:")] { + if ui.radio(goal == g, label).clicked() { + d.values.insert("goal".into(), json!(g)); + } + } + edit(ui, d, "value", 80.0); + }); + ui.label("By Changing Variable Cells:"); + edit(ui, d, "variables", 420.0); + ui.label("Subject to the Constraints:"); + let list: Vec = d + .values + .get("constraints") + .and_then(Json::as_array) + .map(|a| a.iter().map(|c| c.as_str().map(str::to_string).unwrap_or_else(|| c.to_string())).collect()) + .unwrap_or_default(); + egui::Frame::group(ui.style()).show(ui, |ui| { + ui.set_min_size(vec2(420.0, 90.0)); + egui::ScrollArea::vertical().max_height(110.0).show(ui, |ui| { + for (i, c) in list.iter().enumerate() { + if ui.selectable_label(d.list_index == i, c).clicked() { + d.list_index = i; + } + } + }); + }); + ui.horizontal(|ui| { + edit(ui, d, "newLhs", 110.0); + let op = text(d, "newOp"); + egui::ComboBox::from_id_salt("solverOp").selected_text(op.clone()).width(60.0).show_ui(ui, |ui| { + for o in ["<=", "=", ">=", "int", "bin"] { + if ui.selectable_label(op == o, o).clicked() { + d.values.insert("newOp".into(), json!(o)); + } + } + }); + if !matches!(op.as_str(), "int" | "bin") { + edit(ui, d, "newRhs", 110.0); + } + if ui.button("Add").clicked() { + let lhs = text(d, "newLhs"); + if !lhs.trim().is_empty() { + let rhs = if matches!(op.as_str(), "int" | "bin") { String::new() } else { format!(" {}", text(d, "newRhs").trim()) }; + let mut list = list.clone(); + list.push(format!("{} {op}{rhs}", lhs.trim())); + d.values.insert("constraints".into(), json!(list)); + d.values.insert("newLhs".into(), json!("")); + d.values.insert("newRhs".into(), json!("")); + } + } + if ui.add_enabled(d.list_index < list.len(), egui::Button::new("Delete")).clicked() { + let mut list = list.clone(); + if d.list_index < list.len() { + list.remove(d.list_index); + } + d.values.insert("constraints".into(), json!(list)); + } + }); + let mut nonneg = d.values.get("nonNegative").and_then(Json::as_bool).unwrap_or(true); + if ui.checkbox(&mut nonneg, "Make Unconstrained Variables Non-Negative").changed() { + d.values.insert("nonNegative".into(), json!(nonneg)); + } + let method = text(d, "method"); + ui.horizontal(|ui| { + ui.label("Select a Solving Method:"); + let label = |m: &str| if m == "simplex" { "Simplex LP" } else { "GRG Nonlinear" }; + egui::ComboBox::from_id_salt("solverMethod").selected_text(label(&method)).show_ui(ui, |ui| { + for m in ["grg", "simplex"] { + if ui.selectable_label(method == m, label(m)).clicked() { + d.values.insert("method".into(), json!(m)); + } + } + }); + }); + ui.label( + egui::RichText::new(if method == "simplex" { + "Simplex LP: for linear models; integer and binary constraints by branch and bound." + } else { + "GRG Nonlinear: a local optimum of a smooth model, from the current values." + }) + .small(), + ); + ui.add_space(8.0); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let solve = + ui.add(egui::Button::new(egui::RichText::new(" Solve ").color(Color32::WHITE)).fill(crate::theme::Tokens::get(ui.ctx()).accent)); + if solve.clicked() && !text(d, "objective").trim().is_empty() { + *confirm = true; + } + if ui.button(" Close ").clicked() { + *open = false; + } + }); +} + +/// Solver Results: the outcome, and keeping or restoring the variable cells. +fn solver_results(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, open: &mut bool) { + let message = d.values.get("message").and_then(Json::as_str).unwrap_or("").to_string(); + ui.label(message); + let changed = d.values.get("changed").and_then(Json::as_bool).unwrap_or(false); + let restore = d.values.get("restore").and_then(Json::as_bool).unwrap_or(false); + if changed { + ui.add_space(4.0); + if ui.radio(!restore, "Keep Solver Solution").clicked() { + d.values.insert("restore".into(), json!(false)); + } + if ui.radio(restore, "Restore Original Values").clicked() { + d.values.insert("restore".into(), json!(true)); + } + } + let mut confirm = false; + ok_cancel(ui, &mut confirm, open); + if confirm { + // The solution (and the saved model) is one undo step. + if changed && restore { + app.run_or_alert("edit.undo", json!({})); + } + *open = false; + } +} + fn spelling(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, open: &mut bool) { let r = d.result.clone().unwrap_or(Json::Null); if let Some(e) = r.get("error").and_then(Json::as_str) { diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 1e7b212..36a20e2 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -313,6 +313,7 @@ const JAPANESE: &[(&str, &str)] = &[ ("Group", "グループ化"), ("Ungroup", "グループ解除"), ("Subtotal", "小計"), + ("Solver", "ソルバー"), ("What-If\nAnalysis", "What-If 分析"), ("Data Table…", "データ テーブル…"), ("Scenario Manager…", "シナリオ マネージャー…"), diff --git a/crates/ui-egui/src/i18n/ko.rs b/crates/ui-egui/src/i18n/ko.rs index c37ae3c..13fdc2f 100644 --- a/crates/ui-egui/src/i18n/ko.rs +++ b/crates/ui-egui/src/i18n/ko.rs @@ -164,6 +164,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Group" => "그룹", "Ungroup" => "그룹 해제", "Subtotal" => "부분합", + "Solver" => "해 찾기", "What-If\nAnalysis" => "가상 분석", "Data Table…" => "데이터 표…", "Scenario Manager…" => "시나리오 관리자…", diff --git a/crates/ui-egui/src/i18n/pt.rs b/crates/ui-egui/src/i18n/pt.rs index 1bdc2c3..b0b30e0 100644 --- a/crates/ui-egui/src/i18n/pt.rs +++ b/crates/ui-egui/src/i18n/pt.rs @@ -165,6 +165,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Group" => "Agrupar", "Ungroup" => "Desagrupar", "Subtotal" => "Subtotal", + "Solver" => "Solver", "What-If\nAnalysis" => "Análise de\nHipóteses", "Data Table…" => "Tabela de Dados…", "Scenario Manager…" => "Gerenciador de Cenários…", diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index 8d54856..aaaadbb 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -164,6 +164,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Group" => "Группировать", "Ungroup" => "Разгруппировать", "Subtotal" => "Промежуточный итог", + "Solver" => "Поиск решения", "What-If\nAnalysis" => "Анализ «что\nесли»", "Data Table…" => "Таблица данных…", "Scenario Manager…" => "Диспетчер сценариев…", diff --git a/crates/ui-egui/src/i18n/zh.rs b/crates/ui-egui/src/i18n/zh.rs index 9f04ada..462462c 100644 --- a/crates/ui-egui/src/i18n/zh.rs +++ b/crates/ui-egui/src/i18n/zh.rs @@ -164,6 +164,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Group" => "组合", "Ungroup" => "取消组合", "Subtotal" => "分类汇总", + "Solver" => "规划求解", "What-If\nAnalysis" => "假设分析", "Data Table…" => "数据表…", "Scenario Manager…" => "方案管理器…", diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 264479a..70b24a3 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -1580,6 +1580,10 @@ fn data(app: &mut SheetApp, ui: &mut Ui) { app.open_dialog("subtotal", json!({})); } }); + sep(ui); + if big_button(ui, Icon::Function, "Solver", "Solver", false).clicked() { + app.open_dialog("solver", json!({})); + } } fn review(app: &mut SheetApp, ui: &mut Ui) { diff --git a/crates/ui-egui/tests/interaction.rs b/crates/ui-egui/tests/interaction.rs index 84f20a6..21f4f04 100644 --- a/crates/ui-egui/tests/interaction.rs +++ b/crates/ui-egui/tests/interaction.rs @@ -228,6 +228,7 @@ fn dialogs_open_and_close() { "spelling", "goTo", "about", + "solver", ] { h.state_mut().open_dialog(name, json!({})); h.run_steps(2); @@ -237,6 +238,37 @@ fn dialogs_open_and_close() { } } +#[test] +fn solver_dialog_loads_the_saved_model_and_results_can_restore() { + let mut s = blank(); + for (c, v) in [("A1", "0"), ("A2", "0"), ("B1", "=3*A1+5*A2"), ("B2", "=2*A2"), ("B3", "=3*A1+2*A2")] { + s.execute("cell.set", json!({"cell": c, "input": v})).unwrap(); + } + let r = s + .execute( + "data.solver", + json!({"objective": "B1", "variables": "A1:A2", "constraints": ["A1 <= 4", "B2 <= 12", "B3 <= 18"], "method": "simplex"}), + ) + .unwrap(); + assert_eq!(s.active().unwrap().wb.active().unwrap().value(CellRef::parse("B1").unwrap()), Value::Number(36.0)); + let mut h = harness(s); + h.state_mut().open_dialog("solver", json!({})); + h.run_steps(2); + let d = h.state().dialog.as_ref().unwrap(); + assert_eq!(d.values.get("objective"), Some(&json!("B1"))); + assert_eq!(d.values.get("method"), Some(&json!("simplex"))); + assert_eq!(d.values.get("constraints").and_then(|c| c.as_array()).map(Vec::len), Some(3)); + h.state_mut().dialog = None; + // Solver Results › Restore Original Values undoes the solution. + h.state_mut().open_dialog("solverResults", r); + h.state_mut().dialog.as_mut().unwrap().values.insert("restore".into(), json!(true)); + h.run_steps(2); + key(&mut h, Key::Enter, Modifiers::NONE); + assert!(h.state().dialog.is_none()); + assert_eq!(value(&h, "A1"), Value::Number(0.0)); + assert_eq!(value(&h, "B1"), Value::Number(0.0)); +} + #[test] fn about_renders_every_tab() { let mut h = harness(blank()); diff --git a/docs/gaps.md b/docs/gaps.md index aa752b8..ef1e08d 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -98,8 +98,10 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha- data model (`data.fromWeb`, `data.relationships` missing). **Estimate:** 60–100 h. ### 13. Analysis tools: Solver, Analysis ToolPak, Forecast Sheet (F) -- `data.solver`, `data.analysisToolPak`, `data.forecastSheet` missing; FORECAST.ETS family missing. - **Estimate:** 22–35 h. +- `data.analysisToolPak`, `data.forecastSheet` missing; FORECAST.ETS family missing. Solver + (`data.solver`) has Simplex LP with integer/binary branch and bound and a local nonlinear method + (augmented Lagrangian, not GRG); Evolutionary, AllDifferent constraints, Solver options and + reports remain. **Estimate:** 16–28 h. ### 14. Page Layout view and print polish (U, F) - No paper view with rulers and in-place header/footer editing; web print exports only (#141, PR diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index e98835d..623b2b6 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -4,7 +4,7 @@ Each entry is an Excel ribbon or menu command (feature names only). It counts as implemented when GridCraft registers a command with the same id. This measures breadth only; the overall assessment (depth, fidelity, effort) is in [target-app-parity.md](target-app-parity.md). -**Overall: 258 / 290 commands implemented (89.0%).** +**Overall: 259 / 290 commands implemented (89.3%).** | Tab | Implemented | Total | Parity | |---|---|---|---| @@ -14,7 +14,7 @@ Each entry is an Excel ribbon or menu command (feature names only). It counts as | Draw | 4 | 4 | 100% | | Page Layout | 23 | 23 | 100% | | Formulas | 23 | 23 | 100% | -| Data | 20 | 28 | 71% | +| Data | 21 | 28 | 75% | | Review | 9 | 18 | 50% | | View | 18 | 20 | 90% | | Automate | 3 | 3 | 100% | @@ -240,7 +240,7 @@ Implemented: - `formulas.calculateNow` Calculate Now (Calculation) - `formulas.calculateSheet` Calculate Sheet (Calculation) -## Data (20 / 28, 71%) +## Data (21 / 28, 75%) Implemented: @@ -264,6 +264,7 @@ Implemented: - `data.subtotal` Subtotal (Outline) - `data.showDetail` Show Detail (Outline) - `data.hideDetail` Hide Detail (Outline) +- `data.solver` Solver (Analysis) Missing: @@ -273,7 +274,6 @@ Missing: - `data.relationships` Relationships (Data Tools) - `data.forecastSheet` Forecast Sheet (Forecast) - `data.analyzeData` Analyze Data (Analysis) -- `data.solver` Solver (Analysis) - `data.analysisToolPak` Data Analysis (Analysis) ## Review (9 / 18, 50%) diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 2e5ed01..b3ec3a6 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -87,7 +87,7 @@ performance 8, stability 8, localization 4, hardware 2, platforms 2, ecosystem 2 | PivotTables | 7 | ~65% | ~45% | 25–40 | Insert/Recommended, field list, three layouts, date grouping, show-values-as, refresh, XLSX round-trip. Missing: slicers, timelines, calculated fields/items, PivotCharts, value/label filters, number grouping, GETPIVOTDATA, data-model pivots. Round-trip never checked in Excel | | Charts and sparklines | 8 | ~50% | ~30% | 40–60 | 25 chart kinds, all 2-D. The chart model holds title, legend position, a data-labels flag, gridlines flag and axis titles; no axis scaling, trendlines, error bars, per-point formatting, chart sheets, 3-D, surface, map, Pareto. Several kinds don't survive XLSX round trip (open PRs #158, #161). [chart-parity.md](chart-parity.md) | | Page layout, print, PDF | 5 | ~80% | ~55% | 12–20 | Page setup, print titles, breaks, Page Break Preview, headers/footers, PDF export and printing. No true Page Layout view; the PDF writer uses only the 14 standard fonts without embedding (no CJK or other non-Latin text in PDF); web print only exports (#141, #143) | -| What-if and analysis | 4 | ~50% | ~40% | 22–35 | Goal Seek, Scenario Manager, data tables. Missing: Solver, Analysis ToolPak, Forecast Sheet, Analyze Data | +| What-if and analysis | 4 | ~50% | ~40% | 22–35 | Goal Seek, Scenario Manager, data tables, Solver (Simplex LP and a local nonlinear method; no Evolutionary or reports). Missing: Analysis ToolPak, Forecast Sheet, Analyze Data | | Get & Transform, external data, data model | 5 | ~15% | ~10% | 60–100 | Import of CSV/TSV, JSON, HTML. No Power Query editor, no From Web, database or folder sources, no connections or refresh, no relationships or data model, no external workbook links (dropped with a warning) | | Insert objects | 3 | ~55% | ~45% | 25–40 | Pictures, 8 shape kinds (Excel has ~170), text boxes, own icon set, ink with Ink to Shape, hyperlinks, checkboxes. Missing: SmartArt, WordArt, equations, signature line, objects, 3-D models, screenshot, pictures in cells (PR #105) | | Review, comments, protection, collaboration | 4 | ~55% | ~40% | 35–55 | Threaded comments, notes, spelling (system dictionary), thesaurus, sheet and workbook protection. Missing: comment navigation, Show Changes, Allow Edit Ranges, notes-to-comments conversion, accessibility checker, co-authoring, opening password-encrypted files (refused with a message since #17) | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index e85bca1..71b6602 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -65,7 +65,7 @@ shortcut lists for Windows and Mac, then fill the gaps: 6–10 h. Format Cells, Insert Function/Formula Builder, Find and Replace, Go To and Go To Special, Sort, Name Manager, Data Validation, Paste Special, Conditional Formatting manager, Page Setup, Goal -Seek, Scenario Manager and others exist. Missing or partial: Options, Solver, Data Analysis, +Seek, Scenario Manager, Solver and others exist. Missing or partial: Options, Data Analysis, Forecast Sheet, Power Query editor, Insert Object, Equation editor, Protect/Allow Edit Ranges, Workbook Links, Accessibility checker. From b4d823902aa3630d64b590aecb23dc1a9f75ab49 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 11 Oct 2026 13:03:11 +0200 Subject: [PATCH 63/83] UI: File tab Backstage view (#174) Clicking the File tab now opens a full-window Backstage view in place of the ribbon, formula bar, grid and sheet tabs, like Excel's. Esc or the back arrow return to the sheet. Pages: Home (new + recent), New (blank + samples), Open (recent + Browse), Info (location, properties, Protect Workbook), Save, Save As, Print (page preview, scope, fit to page, Page Setup), Export (PDF, CSV, web page), Close and Options (theme, language, formula bar, AutoSave, calculation). Every action runs an existing command or dialog. Adds `file.options` (reports the workbook's calculation settings and asks the interface for the Options page) and the UI command `ui.backstage {page}`. Keyboard shortcuts and the macOS native menu are unchanged. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/engine/src/cmd/file.rs | 27 +- crates/engine/src/tests.rs | 16 + crates/ui-egui/src/backstage.rs | 828 ++++++++++++++++++++++++++++++ crates/ui-egui/src/control.rs | 2 + crates/ui-egui/src/i18n.rs | 61 +++ crates/ui-egui/src/i18n/ko.rs | 59 +++ crates/ui-egui/src/i18n/pt.rs | 59 +++ crates/ui-egui/src/i18n/ru.rs | 59 +++ crates/ui-egui/src/i18n/zh.rs | 55 ++ crates/ui-egui/src/icons.rs | 29 ++ crates/ui-egui/src/lib.rs | 81 ++- crates/ui-egui/src/ribbon.rs | 29 +- crates/ui-egui/tests/file_menu.rs | 87 +++- docs/control-protocol.md | 3 +- docs/parity-checklist.md | 9 +- docs/ui-parity.md | 9 +- 17 files changed, 1348 insertions(+), 66 deletions(-) create mode 100644 crates/ui-egui/src/backstage.rs diff --git a/ROADMAP.md b/ROADMAP.md index 1189fd7..9c53c82 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -131,6 +131,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | What landed | |---|---| +| 2026-10-11 | File tab Backstage view (Home, New, Open, Info, Save As, Print preview, Export, Options) and `file.options` (#174) | | 2026-10-10 | Ribbon translated into Japanese, Chinese, Korean, Brazilian Portuguese and Russian with system-language default (#31, #104, #82); CJK/Thai/Hebrew font fallback (#38); clear messages for encrypted and `.xls` files (#17); smooth line charts (#62). Progress docs restructured to the craftrules standard; full re-measure (this page). Initial ribbon key tips (#43), in-place text box editing (#84), copy as HTML table (#36). ODS and XLSB data import (#100, #101); recalc linear in the number of range formulas (#66); RISC-V Linux build; Flatpak repository on Pages; web cell-commit trap fixed | | 2026-10-09 | Community fixes: spill range operator `A1#`, sheet-scoped names, protection refuses locked-cell edits, Insert/Delete Cells adjust references, `DATE(1900,2,29)`, SUMIF sizing, `ROUND` at 15 digits, `_xlpm.` LAMBDA parameters in XLSX, vertical scrollbar, system appearance, border previews; v0.4.0 | | 2026-10-08 | v0.2.0 and v0.3.0 releases; Flatpak, AppImage zsync, branded DMG; desktop log file; DirectX 12 default on Windows | diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index d52418b..16e714a 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -1,4 +1,4 @@ -//! File menu: new, open, save, close, export, properties. +//! File menu: new, open, save, close, export, properties, options. use serde_json::{Value as Json, json}; @@ -16,6 +16,15 @@ pub fn specs() -> Vec { cmd!(noundo "file.exportCsv", "Export as CSV", ["File", "Export"], None, "{path, sheet?}", has_doc, |s, p| export(s, p, "csv")), cmd!(noundo "file.exportHtml", "Save as Web Page", ["File", "Export"], None, "{path}", has_doc, |s, p| export(s, p, "html")), cmd!("file.properties", "Properties", ["File"], None, "{title?, subject?, author?, company?, keywords?, description?}", has_doc, properties), + cmd!( + query "file.options", + "Options", + ["File"], + None, + "{} → {calculation: {mode, iterative, maxIterations, maxChange}} for the active workbook (the interface opens File › Options; change settings with their own commands, e.g. formulas.calculationOptions, view.theme, app.language.set)", + always, + options + ), cmd!(noundo "window.activate", "Switch Window", ["Window"], None, "{index}", has_doc, activate_window), ] } @@ -156,6 +165,22 @@ fn properties(s: &mut Session, p: &Json) -> Result { }) } +/// File › Options: reports the workbook-level settings and asks the interface to show its Options +/// page. Nothing changes here; every setting has its own command. +fn options(s: &mut Session, _: &Json) -> Result { + s.ui_requests.push(crate::UiRequest::Dialog("options".into(), json!({}))); + let calculation = s.active().map(|d| { + let c = &d.wb.calc; + let mode = match c.mode { + gridcraft_model::CalcMode::Automatic => "automatic", + gridcraft_model::CalcMode::AutomaticExceptTables => "automaticExceptTables", + gridcraft_model::CalcMode::Manual => "manual", + }; + json!({"mode": mode, "iterative": c.iterative, "maxIterations": c.max_iterations, "maxChange": c.max_change}) + }); + Ok(json!({"calculation": calculation})) +} + fn activate_window(s: &mut Session, p: &Json) -> Result { let i = u32_param(p, "index").ok_or_else(|| bad("window.activate", "missing `index`"))? as usize; if i >= s.documents().len() { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 6bd03d5..4bc7262 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1036,6 +1036,22 @@ fn every_command_survives_empty_params() { } } +#[test] +fn file_options_reports_settings_and_asks_for_the_options_page() { + let mut s = Session::new(); + // No workbook: nothing to report, but still no error. + assert_eq!(s.execute("file.options", json!({})).unwrap(), json!({"calculation": null})); + s.take_ui_requests(); + s.new_workbook(); + s.execute("formulas.calculationOptions", json!({"mode": "manual", "iterative": true})).unwrap(); + let before = s.active().unwrap().wb.clone(); + let r = s.execute("file.options", json!({})).unwrap(); + assert_eq!(r["calculation"]["mode"], "manual"); + assert_eq!(r["calculation"]["iterative"], true); + assert!(matches!(s.take_ui_requests().as_slice(), [crate::UiRequest::Dialog(name, _)] if name == "options")); + assert_eq!(s.active().unwrap().wb, before, "Options never changes the workbook"); +} + fn reopen(s: &mut Session) { let r = s.execute("file.saveBytes", json!({"format": "xlsx"})).unwrap(); let b64 = r["base64"].as_str().unwrap().to_string(); diff --git a/crates/ui-egui/src/backstage.rs b/crates/ui-egui/src/backstage.rs new file mode 100644 index 0000000..c291740 --- /dev/null +++ b/crates/ui-egui/src/backstage.rs @@ -0,0 +1,828 @@ +//! File tab Backstage view (#174): a full-window page that replaces the ribbon, formula bar, grid +//! and sheet tabs until Esc or the back arrow returns to the sheet. +//! +//! A navigation column on the left picks a page (Home, New, Open, Info, Save As, Print, Export, +//! Options); Save and Close act at once. Every action runs an existing command through +//! [`SheetApp::run`] or an existing dialog through [`SheetApp::open_dialog`]; the state here is +//! UI-only and never saved. Recent workbooks are listed by path only: nothing on disk is touched +//! until the user picks one. + +use egui::{Align2, Color32, Key, Modifiers, Rect, RichText, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; +use serde_json::{Value as Json, json}; + +use crate::SheetApp; +use crate::i18n::{self, Language}; +use crate::icons::{self, Icon}; +use crate::theme::{self, Tokens}; + +/// Width of the navigation column. +const NAV_W: f32 = 196.0; +/// Width of the content column (pages are left-aligned like a document, not stretched). +const CONTENT_W: f32 = 760.0; + +/// A Backstage page. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Page { + Home, + New, + Open, + Info, + SaveAs, + Print, + Export, + Options, +} + +impl Page { + pub const ALL: [Page; 8] = [Page::Home, Page::New, Page::Open, Page::Info, Page::SaveAs, Page::Print, Page::Export, Page::Options]; + + /// The id `ui.backstage` and `ui.dialog {"name": "backstage", "page": …}` take. + pub fn id(self) -> &'static str { + match self { + Page::Home => "home", + Page::New => "new", + Page::Open => "open", + Page::Info => "info", + Page::SaveAs => "saveAs", + Page::Print => "print", + Page::Export => "export", + Page::Options => "options", + } + } + + pub fn parse(id: &str) -> Option { + let id = id.trim(); + Page::ALL.into_iter().find(|p| p.id().eq_ignore_ascii_case(id)) + } + + /// English label (navigation entry and page heading); translated when drawn. + pub fn label(self) -> &'static str { + match self { + Page::Home => "Home", + Page::New => "New", + Page::Open => "Open", + Page::Info => "Info", + Page::SaveAs => "Save As", + Page::Print => "Print", + Page::Export => "Export", + Page::Options => "Options", + } + } + + fn icon(self) -> Icon { + match self { + Page::Home => Icon::Home, + Page::New => Icon::NewFile, + Page::Open => Icon::OpenFolder, + Page::Info => Icon::Info, + Page::SaveAs => Icon::Save, + Page::Print => Icon::Print, + Page::Export => Icon::Share, + Page::Options => Icon::Settings, + } + } +} + +/// Document properties shown on the Info page: (`file.properties` key, label). +const PROPERTIES: [(&str, &str); 6] = [ + ("title", "Title"), + ("subject", "Subject"), + ("author", "Author"), + ("company", "Company"), + ("keywords", "Keywords"), + ("description", "Description"), +]; + +/// The open Backstage view. +#[derive(Clone, Debug)] +pub struct Backstage { + pub page: Page, + /// Info: property drafts while a field is being edited (committed when it loses focus). + props: [String; 6], + /// Print: the whole workbook rather than the active sheet. + pub print_all: bool, + /// Print: fit each sheet on one page. + pub fit_to_page: bool, + /// Print: the previewed page (0-based). + preview_page: usize, + /// Print: the preview for (document, revision, whole workbook, fit to page). + preview: Option<((u64, u64, bool, bool), Json)>, +} + +impl Backstage { + pub fn new(page: Page) -> Backstage { + Backstage { page, props: Default::default(), print_all: false, fit_to_page: false, preview_page: 0, preview: None } + } +} + +/// What a click asked for, applied after drawing. +#[derive(Default)] +struct Outcome { + close: bool, + page: Option, +} + +/// Draws the Backstage view in place of the ribbon, formula bar, grid and sheet tabs. +pub fn show(app: &mut SheetApp, ui: &mut Ui) { + let Some(mut bs) = app.backstage.take() else { return }; + let ctx = ui.ctx().clone(); + // Esc inside a text field leaves the field; it closes the view only from outside one. + let typing = ctx.text_edit_focused(); + let t = Tokens::get(&ctx); + let lang = app.ui.language; + let mut out = Outcome::default(); + let (nav_bg, nav_text, nav_sel) = + if t.dark { (t.accent_soft, t.text, Color32::from_white_alpha(30)) } else { (t.accent, Color32::WHITE, Color32::from_black_alpha(46)) }; + egui::Panel::left("backstage_nav") + .exact_size(NAV_W) + .resizable(false) + .frame(egui::Frame::NONE.fill(nav_bg).inner_margin(egui::Margin { left: 0, right: 0, top: 10, bottom: 10 })) + .show(ui, |ui| { + ui.spacing_mut().item_spacing.y = 0.0; + if nav_item(ui, Icon::Back, lang.tr("Back"), false, nav_text, nav_sel).clicked() { + out.close = true; + } + ui.add_space(10.0); + for page in [Page::Home, Page::New, Page::Open] { + if nav_item(ui, page.icon(), lang.tr(page.label()), bs.page == page, nav_text, nav_sel).clicked() { + out.page = Some(page); + } + } + nav_separator(ui, nav_text); + if nav_item(ui, Page::Info.icon(), lang.tr("Info"), bs.page == Page::Info, nav_text, nav_sel).clicked() { + out.page = Some(Page::Info); + } + if nav_item(ui, Icon::Save, lang.tr("Save"), false, nav_text, nav_sel).clicked() { + save(app, &mut out); + } + for page in [Page::SaveAs, Page::Print, Page::Export] { + if nav_item(ui, page.icon(), lang.tr(page.label()), bs.page == page, nav_text, nav_sel).clicked() { + out.page = Some(page); + } + } + if nav_item(ui, Icon::Close, lang.tr("Close"), false, nav_text, nav_sel).clicked() { + // A cell still being edited is part of the workbook; Close keeps the unsaved prompt. + if app.commit_edit(0, 0, false, false) { + app.run_or_alert("file.close", json!({})); + out.close = true; + } + } + ui.with_layout(egui::Layout::bottom_up(egui::Align::Min), |ui| { + if nav_item(ui, Page::Options.icon(), lang.tr("Options"), bs.page == Page::Options, nav_text, nav_sel).clicked() { + out.page = Some(Page::Options); + } + }); + }); + egui::CentralPanel::default().frame(egui::Frame::NONE.fill(t.ribbon)).show(ui, |ui| { + egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| { + egui::Frame::NONE.inner_margin(egui::Margin { left: 44, right: 32, top: 26, bottom: 32 }).show(ui, |ui| { + ui.set_max_width(CONTENT_W); + ui.label(RichText::new(lang.tr(bs.page.label())).font(theme::ui_font(28.0)).color(t.text)); + ui.add_space(18.0); + match bs.page { + Page::Home => home(app, ui, &mut out), + Page::New => new_page(app, ui, &mut out), + Page::Open => open_page(app, ui, &mut out), + Page::Info => info(app, ui, &mut bs), + Page::SaveAs => save_as(app, ui, &mut out), + Page::Print => print(app, ui, &mut bs, &mut out), + Page::Export => export(app, ui, &mut out), + Page::Options => options(app, ui), + } + }); + }); + }); + if !typing + && app.dialog.is_none() + && app.message.is_none() + && !egui::Popup::is_any_open(&ctx) + && ctx.input_mut(|i| i.consume_key(Modifiers::NONE, Key::Escape)) + { + out.close = true; + } + if let Some(page) = out.page { + bs.page = page; + } + // A command run from a page may itself have opened (or replaced) the view; keep that. + if out.close { + app.backstage = None; + } else if app.backstage.is_none() { + app.backstage = Some(bs); + } +} + +/// Save: saves a workbook that has a file and returns to the sheet; a new workbook goes to the +/// Save As page first, like Excel's. +fn save(app: &mut SheetApp, out: &mut Outcome) { + if !app.commit_edit(0, 0, false, false) { + return; + } + if app.session.active().is_some_and(|d| d.path.is_some()) { + app.run_or_alert("file.save", json!({})); + out.close = true; + } else { + out.page = Some(Page::SaveAs); + } +} + +// ---------------------------------------------------------------- navigation + +fn nav_item(ui: &mut Ui, icon: Icon, label: &str, selected: bool, text: Color32, sel: Color32) -> egui::Response { + let (rect, resp) = ui.allocate_exact_size(vec2(NAV_W, 38.0), Sense::click()); + resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, true, selected, label)); + let p = ui.painter(); + if selected { + p.rect_filled(rect, 0.0, sel); + p.rect_filled(Rect::from_min_size(rect.min, vec2(4.0, rect.height())), 0.0, text); + } else if resp.hovered() { + p.rect_filled(rect, 0.0, sel.gamma_multiply(0.55)); + } + icons::paint(p, Rect::from_center_size(pos2(rect.left() + 30.0, rect.center().y), vec2(17.0, 17.0)), icon, text); + p.text(pos2(rect.left() + 50.0, rect.center().y), Align2::LEFT_CENTER, label, theme::ui_font(14.0), text); + resp +} + +fn nav_separator(ui: &mut Ui, text: Color32) { + let (rect, _) = ui.allocate_exact_size(vec2(NAV_W, 17.0), Sense::hover()); + let y = rect.center().y; + ui.painter().line_segment([pos2(rect.left() + 20.0, y), pos2(rect.right() - 20.0, y)], Stroke::new(1.0, text.gamma_multiply(0.35))); +} + +// ---------------------------------------------------------------- shared pieces + +fn section(ui: &mut Ui, lang: Language, title: &str) { + let t = Tokens::get(ui.ctx()); + ui.add_space(6.0); + ui.label(RichText::new(lang.tr(title)).font(theme::ui_bold(15.0)).color(t.text)); + ui.add_space(8.0); +} + +fn dim(ui: &mut Ui, text: &str) { + let t = Tokens::get(ui.ctx()); + ui.label(RichText::new(text).font(theme::ui_font(12.5)).color(t.text_dim)); +} + +/// A new-workbook tile: a small sheet drawn in code over its name. +fn tile(ui: &mut Ui, label: &str, sample: Option) -> egui::Response { + let t = Tokens::get(ui.ctx()); + let (rect, resp) = ui.allocate_exact_size(vec2(150.0, 140.0), Sense::click()); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, label)); + let p = ui.painter(); + let thumb = Rect::from_min_size(rect.min, vec2(rect.width(), 104.0)); + p.rect_filled(thumb, 4.0, t.grid_bg); + let edge = if resp.hovered() { Stroke::new(2.0, t.accent) } else { Stroke::new(1.0, t.ribbon_border) }; + let inner = thumb.shrink(10.0); + let (rows, cols) = (7, 4); + let (rh, cw) = (inner.height() / rows as f32, inner.width() / cols as f32); + if let Some(i) = sample { + // A header band and a few "values" so each sample reads differently from a blank book. + p.rect_filled(Rect::from_min_size(inner.min, vec2(inner.width(), rh)), 0.0, t.accent_soft); + for r in 1..rows { + let len = ((r * (i + 2) * 7 + i * 3) % 10) as f32 / 10.0; + let bar = Rect::from_min_size( + pos2(inner.left() + cw + 3.0, inner.top() + r as f32 * rh + 3.0), + vec2((cw * 3.0 - 6.0) * (0.25 + 0.75 * len), rh - 6.0), + ); + p.rect_filled(bar, 1.0, t.accent.gamma_multiply(0.75)); + } + } + for r in 0..=rows { + let y = inner.top() + r as f32 * rh; + p.line_segment([pos2(inner.left(), y), pos2(inner.right(), y)], Stroke::new(1.0, t.gridline)); + } + for c in 0..=cols { + let x = inner.left() + c as f32 * cw; + p.line_segment([pos2(x, inner.top()), pos2(x, inner.bottom())], Stroke::new(1.0, t.gridline)); + } + p.rect_stroke(thumb, 4.0, edge, StrokeKind::Inside); + let galley = p.layout(label.to_string(), theme::ui_font(12.5), t.text, rect.width()); + p.galley(pos2(rect.left(), thumb.bottom() + 6.0), galley, t.text); + resp +} + +/// Blank workbook and the built-in samples. +fn new_tiles(app: &mut SheetApp, ui: &mut Ui, out: &mut Outcome) { + let lang = app.ui.language; + ui.horizontal_wrapped(|ui| { + ui.spacing_mut().item_spacing = vec2(18.0, 14.0); + if tile(ui, lang.tr("Blank workbook"), None).clicked() { + app.run_or_alert("file.new", json!({})); + out.close = true; + } + for (i, (name, title)) in gridcraft_engine::sample::SAMPLES.iter().enumerate() { + if tile(ui, title, Some(i)).clicked() { + app.run_or_alert("file.new", json!({"sample": name})); + out.close = true; + } + } + }); +} + +/// File name and folder of a recent path, split as text (nothing on disk is read). +fn split_path(path: &str) -> (String, String) { + let p = std::path::Path::new(path); + let name = p.file_name().and_then(|n| n.to_str()).unwrap_or(path).to_string(); + let folder = p.parent().and_then(|f| f.to_str()).unwrap_or("").to_string(); + (name, folder) +} + +/// Recent workbooks, newest first. Opening one is the user's explicit choice. +fn recent_list(app: &mut SheetApp, ui: &mut Ui, out: &mut Outcome, limit: usize) { + let t = Tokens::get(ui.ctx()); + let lang = app.ui.language; + if app.ui.recent.is_empty() { + dim(ui, lang.tr("No recent workbooks yet.")); + return; + } + let width = ui.available_width().min(CONTENT_W); + for path in app.ui.recent.iter().take(limit).cloned().collect::>() { + let (name, folder) = split_path(&path); + let (rect, resp) = ui.allocate_exact_size(vec2(width, 44.0), Sense::click()); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &name)); + let p = ui.painter(); + if resp.hovered() { + p.rect_filled(rect, 4.0, t.hover); + } + icons::paint(p, Rect::from_center_size(pos2(rect.left() + 20.0, rect.center().y), vec2(20.0, 20.0)), Icon::Book, t.text); + p.text(pos2(rect.left() + 42.0, rect.center().y - 8.0), Align2::LEFT_CENTER, &name, theme::ui_font(13.5), t.text); + p.text(pos2(rect.left() + 42.0, rect.center().y + 9.0), Align2::LEFT_CENTER, &folder, theme::ui_font(11.5), t.text_dim); + let resp = resp.on_hover_text(&path); + if resp.clicked() { + app.open_path(&path); + if app.session.active().and_then(|d| d.path.as_deref()) == Some(path.as_str()) { + out.close = true; + } + } + } +} + +/// A large clickable row (icon, title, one-line description). +fn action_row(ui: &mut Ui, icon: Icon, title: &str, detail: &str) -> egui::Response { + let t = Tokens::get(ui.ctx()); + let width = ui.available_width().min(CONTENT_W); + let (rect, resp) = ui.allocate_exact_size(vec2(width, 56.0), Sense::click()); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, title)); + let p = ui.painter(); + if resp.hovered() { + p.rect_filled(rect, 4.0, t.hover); + } + icons::paint(p, Rect::from_center_size(pos2(rect.left() + 24.0, rect.center().y), vec2(24.0, 24.0)), icon, t.text); + p.text(pos2(rect.left() + 52.0, rect.center().y - 9.0), Align2::LEFT_CENTER, title, theme::ui_bold(13.5), t.text); + p.text(pos2(rect.left() + 52.0, rect.center().y + 10.0), Align2::LEFT_CENTER, detail, theme::ui_font(12.0), t.text_dim); + resp +} + +/// A bordered square command button (Browse…, Print, Protect Workbook). +fn square_button(ui: &mut Ui, icon: Icon, label: &str) -> egui::Response { + let t = Tokens::get(ui.ctx()); + let (rect, resp) = ui.allocate_exact_size(vec2(96.0, 88.0), Sense::click()); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, label)); + let p = ui.painter(); + p.rect_filled(rect, 6.0, if resp.hovered() { t.hover } else { t.ribbon }); + p.rect_stroke(rect, 6.0, Stroke::new(1.0, if resp.hovered() { t.accent } else { t.ribbon_border }), StrokeKind::Inside); + icons::paint(p, Rect::from_center_size(pos2(rect.center().x, rect.top() + 32.0), vec2(30.0, 30.0)), icon, t.text); + let galley = p.layout(label.to_string(), theme::ui_font(12.0), t.text, rect.width() - 8.0); + p.galley(pos2(rect.center().x - galley.size().x / 2.0, rect.top() + 56.0), galley, t.text); + resp +} + +// ---------------------------------------------------------------- pages + +fn home(app: &mut SheetApp, ui: &mut Ui, out: &mut Outcome) { + let lang = app.ui.language; + section(ui, lang, "New"); + new_tiles(app, ui, out); + ui.add_space(22.0); + section(ui, lang, "Recent"); + recent_list(app, ui, out, 8); + if app.ui.recent.len() > 8 { + ui.add_space(6.0); + if ui.link(lang.tr("More workbooks")).clicked() { + out.page = Some(Page::Open); + } + } +} + +fn new_page(app: &mut SheetApp, ui: &mut Ui, out: &mut Outcome) { + new_tiles(app, ui, out); +} + +fn open_page(app: &mut SheetApp, ui: &mut Ui, out: &mut Outcome) { + let lang = app.ui.language; + if square_button(ui, Icon::OpenFolder, lang.tr("Browse…")).clicked() { + let before = app.session.active().map(|d| d.uid); + app.open_dialog("open", json!({})); + // Desktop pickers return at once; the web picker delivers later (see `SheetApp::logic`). + if app.session.active().map(|d| d.uid) != before { + out.close = true; + } + } + ui.add_space(22.0); + section(ui, lang, "Recent"); + recent_list(app, ui, out, usize::MAX); +} + +fn info(app: &mut SheetApp, ui: &mut Ui, bs: &mut Backstage) { + let t = Tokens::get(ui.ctx()); + let lang = app.ui.language; + let Some(d) = app.session.active() else { + dim(ui, lang.tr("No workbook is open.")); + return; + }; + let title = d.display_title(); + let path = d.path.clone(); + let sheets = d.wb.sheets.len(); + let protected = d.wb.protected_structure; + let current: Vec = { + let pr = &d.wb.props; + vec![pr.title.clone(), pr.subject.clone(), pr.author.clone(), pr.company.clone(), pr.keywords.clone(), pr.description.clone()] + }; + ui.horizontal(|ui| { + icons::paint(ui.painter(), Rect::from_min_size(ui.cursor().min, vec2(28.0, 28.0)), Icon::Book, t.text); + ui.add_space(38.0); + ui.vertical(|ui| { + ui.label(RichText::new(&title).font(theme::ui_bold(16.0)).color(t.text)); + match &path { + Some(p) => dim(ui, p), + None => dim(ui, lang.tr("Not saved yet")), + } + }); + }); + ui.add_space(18.0); + ui.horizontal(|ui| { + if square_button(ui, Icon::Lock, lang.tr("Protect Workbook")).clicked() { + app.run_or_alert("review.protectWorkbook", json!({"on": !protected})); + } + ui.add_space(12.0); + ui.vertical(|ui| { + ui.add_space(14.0); + ui.label(RichText::new(lang.tr("Protect Workbook")).font(theme::ui_bold(13.5)).color(t.text)); + let text = if protected { + "Sheet structure is locked: sheets can't be added, moved, renamed or deleted." + } else { + "Lock the sheet structure so sheets can't be added, moved, renamed or deleted." + }; + dim(ui, lang.tr(text)); + }); + }); + ui.add_space(22.0); + section(ui, lang, "Properties"); + egui::Grid::new("backstage_props").num_columns(2).spacing(vec2(18.0, 8.0)).show(ui, |ui| { + ui.label(RichText::new(lang.tr("Location")).color(t.text_dim)); + ui.label(path.as_deref().unwrap_or(lang.tr("Not saved yet"))); + ui.end_row(); + ui.label(RichText::new(lang.tr("Sheets")).color(t.text_dim)); + ui.label(sheets.to_string()); + ui.end_row(); + for (i, (key, label)) in PROPERTIES.iter().enumerate() { + let (Some(draft), Some(saved)) = (bs.props.get_mut(i), current.get(i)) else { continue }; + ui.label(RichText::new(lang.tr(label)).color(t.text_dim)); + let id = ui.make_persistent_id(("backstage_prop", *key)); + // Show the workbook's value unless the field is being edited (undo, another window). + if !ui.ctx().memory(|m| m.has_focus(id)) { + draft.clone_from(saved); + } + let resp = ui.add(egui::TextEdit::singleline(draft).id(id).desired_width(360.0)); + if resp.lost_focus() && draft != saved { + let mut params = serde_json::Map::new(); + params.insert((*key).to_string(), json!(draft.clone())); + app.run_or_alert("file.properties", Json::Object(params)); + } + ui.end_row(); + } + }); +} + +fn save_as(app: &mut SheetApp, ui: &mut Ui, out: &mut Outcome) { + let lang = app.ui.language; + dim(ui, lang.tr("Save the workbook under another name or in another folder.")); + ui.add_space(14.0); + // Desktop: the system Save dialog. Web: the workbook downloads as .xlsx. + let label = if app.services.pick_save.is_some() || app.services.download.is_none() { "Browse…" } else { "Download (.xlsx)" }; + if square_button(ui, Icon::OpenFolder, lang.tr(label)).clicked() && app.commit_edit(0, 0, false, false) { + let before = app.session.active().map(|d| (d.path.clone(), d.is_dirty())); + app.open_dialog("saveAs", json!({})); + let after = app.session.active().map(|d| (d.path.clone(), d.is_dirty())); + if after != before || app.services.pick_save.is_none() { + out.close = true; + } + } + ui.add_space(18.0); + dim(ui, lang.tr("PDF, CSV and web pages are on the Export page.")); +} + +fn print(app: &mut SheetApp, ui: &mut Ui, bs: &mut Backstage, out: &mut Outcome) { + let t = Tokens::get(ui.ctx()); + let lang = app.ui.language; + let Some((uid, revision)) = app.session.active().map(|d| (d.uid, d.revision)) else { + dim(ui, lang.tr("No workbook is open.")); + return; + }; + let mut params = json!({"sheets": if bs.print_all { "all" } else { "active" }}); + if bs.fit_to_page { + params["fitToPage"] = json!(true); + } + let key = (uid, revision, bs.print_all, bs.fit_to_page); + if bs.preview.as_ref().is_none_or(|(k, _)| *k != key) { + let preview = app.session.run("file.printPreview", params.clone()).unwrap_or(Json::Null); + bs.preview = Some((key, preview)); + } + let pages: Vec = bs.preview.as_ref().and_then(|(_, p)| p.get("pageList")).and_then(Json::as_array).cloned().unwrap_or_default(); + ui.horizontal_top(|ui| { + ui.vertical(|ui| { + ui.set_width(250.0); + if square_button(ui, Icon::Print, lang.tr("Print")).clicked() && app.commit_edit(0, 0, false, false) { + app.run_or_alert("file.print", params.clone()); + out.close = true; + } + ui.add_space(16.0); + let scope = if bs.print_all { "Print Entire Workbook" } else { "Print Active Sheets" }; + egui::ComboBox::from_id_salt("backstage_print_scope").width(230.0).selected_text(lang.tr(scope)).show_ui(ui, |ui| { + for (all, label) in [(false, "Print Active Sheets"), (true, "Print Entire Workbook")] { + if ui.selectable_label(bs.print_all == all, lang.tr(label)).clicked() { + bs.print_all = all; + bs.preview_page = 0; + } + } + }); + ui.add_space(8.0); + if ui.checkbox(&mut bs.fit_to_page, lang.tr("Fit Sheet on One Page")).changed() { + bs.preview_page = 0; + } + ui.add_space(8.0); + if ui.link(lang.tr("Page Setup…")).clicked() { + app.open_dialog("pageSetup", json!({})); + } + }); + ui.add_space(24.0); + ui.vertical(|ui| { + if pages.is_empty() { + dim(ui, lang.tr("Nothing to print.")); + return; + } + let last = pages.len().saturating_sub(1); + bs.preview_page = bs.preview_page.min(last); + let Some(page) = pages.get(bs.preview_page) else { return }; + // Paper proportions from the preview (points); hostile sizes fall back to Letter. + let w = page.get("width").and_then(Json::as_f64).filter(|w| w.is_finite() && *w > 1.0).unwrap_or(612.0) as f32; + let h = page.get("height").and_then(Json::as_f64).filter(|h| h.is_finite() && *h > 1.0).unwrap_or(792.0) as f32; + let scale = (520.0 / h).min(440.0 / w); + let (rect, _) = ui.allocate_exact_size(vec2(w * scale, h * scale), Sense::hover()); + let p = ui.painter(); + p.rect_filled(rect.translate(vec2(2.0, 3.0)), 2.0, t.shadow); + p.rect_filled(rect, 2.0, t.grid_bg); + p.rect_stroke(rect, 2.0, Stroke::new(1.0, t.ribbon_border), StrokeKind::Inside); + if let Some(d) = app.session.active() { + paint_page(p, rect, w, page, &d.wb, &t); + } + let sheet = page.get("sheet").and_then(Json::as_str).unwrap_or(""); + let range = page.get("range").and_then(Json::as_str).unwrap_or(""); + ui.add_space(4.0); + dim(ui, &format!("{sheet} · {range}")); + ui.add_space(8.0); + ui.horizontal(|ui| { + if ui.add_enabled(bs.preview_page > 0, egui::Button::new("◀")).on_hover_text(lang.tr("Previous page")).clicked() { + bs.preview_page = bs.preview_page.saturating_sub(1); + } + let text = lang + .tr("Page {page} of {pages}") + .replace("{page}", &(bs.preview_page + 1).to_string()) + .replace("{pages}", &pages.len().to_string()); + ui.label(text); + if ui.add_enabled(bs.preview_page < last, egui::Button::new("▶")).on_hover_text(lang.tr("Next page")).clicked() { + bs.preview_page = (bs.preview_page + 1).min(last); + } + }); + }); + }); +} + +/// Most rows and columns one preview page draws (a page holds far fewer at any readable scale). +const PREVIEW_ROWS: u32 = 200; +const PREVIEW_COLS: u32 = 60; + +/// Draws one printed page in miniature: the cells' displayed text at the page's print scale, +/// inside the sheet's margins. `paper_w` is the page width in points. +fn paint_page(p: &egui::Painter, rect: Rect, paper_w: f32, page: &Json, wb: &gridcraft_engine::model::Workbook, t: &Tokens) { + let Some(range) = page.get("range").and_then(Json::as_str).and_then(gridcraft_engine::core::RangeRef::parse) else { return }; + let Some(sh) = page.get("sheet").and_then(Json::as_str).and_then(|n| wb.sheet_index(n)).and_then(|i| wb.sheets.get(i)) else { return }; + // Preview pixels per point, and per sheet pixel (sheet geometry is 96 dpi: 0.75 pt per pixel). + let per_pt = rect.width() / paper_w; + let print_scale = + page.get("scale").and_then(Json::as_f64).filter(|s| s.is_finite() && *s > 0.0).map_or(1.0, |s| (s / 100.0) as f32).clamp(0.1, 4.0); + let per_px = per_pt * 0.75 * print_scale; + let margin = + |i: usize, default: f32| sh.print.margins.get(i).copied().filter(|m| m.is_finite()).unwrap_or(default).clamp(0.0, 3.0) * 72.0 * per_pt; + let body = Rect::from_min_max( + pos2(rect.left() + margin(0, 0.7), rect.top() + margin(2, 0.75)), + pos2(rect.right() - margin(1, 0.7), rect.bottom() - margin(3, 0.75)), + ); + if !body.is_positive() { + return; + } + let font = theme::ui_font((11.0 * per_pt * print_scale).clamp(3.0, 14.0)); + let last_row = range.end.row.min(range.start.row.saturating_add(PREVIEW_ROWS)); + let last_col = range.end.col.min(range.start.col.saturating_add(PREVIEW_COLS)); + let mut y = body.top(); + for row in range.start.row..=last_row { + let h = sh.row_height(row) * per_px; + if h <= 0.0 { + continue; + } + if y >= body.bottom() { + break; + } + let mut x = body.left(); + for col in range.start.col..=last_col { + let w = sh.col_width(col) * per_px; + if w <= 0.0 { + continue; + } + if x >= body.right() { + break; + } + let cell = gridcraft_engine::core::CellRef::new(row, col); + let text = gridcraft_engine::display::cell_text(wb, sh, cell); + if !text.is_empty() { + let numeric = sh.value(cell).as_f64().is_some(); + // Text runs on over empty neighbours, as on the sheet; numbers stay in their cell. + let mut span = w; + if !numeric { + let mut next = col.saturating_add(1); + while next <= last_col && x + span < body.right() && sh.value(gridcraft_engine::core::CellRef::new(row, next)).is_empty() { + span += sh.col_width(next) * per_px; + next = next.saturating_add(1); + } + } + let clip = Rect::from_min_size(pos2(x, y), vec2(span, h)).intersect(body); + let (anchor, at) = + if numeric { (Align2::RIGHT_CENTER, pos2(x + w - 1.0, y + h / 2.0)) } else { (Align2::LEFT_CENTER, pos2(x + 1.0, y + h / 2.0)) }; + p.with_clip_rect(clip).text(at, anchor, text, font.clone(), t.cell_text); + } + x += w; + } + y += h; + } +} + +fn export(app: &mut SheetApp, ui: &mut Ui, out: &mut Outcome) { + let lang = app.ui.language; + if action_row(ui, Icon::Paper, lang.tr("Create PDF"), lang.tr("Save the sheet as a PDF document that keeps its page layout.")).clicked() + && export_as(app, "pdf") + { + out.close = true; + } + ui.add_space(14.0); + section(ui, lang, "Change File Type"); + for (fmt, icon, title, detail) in [ + ("csv", Icon::TextColumns, "CSV (Comma delimited)", "Save the active sheet as comma-separated text."), + ("html", Icon::Link, "Web Page (.html)", "Save the workbook as a web page."), + ] { + if action_row(ui, icon, lang.tr(title), lang.tr(detail)).clicked() && export_as(app, fmt) { + out.close = true; + } + } +} + +/// Exports the active workbook as `pdf`, `csv` or `html`: through the save picker on the desktop, +/// as a browser download on the web. Returns whether a file was written or handed over. +pub fn export_as(app: &mut SheetApp, fmt: &str) -> bool { + if !app.commit_edit(0, 0, false, false) { + return false; + } + let id = match fmt { + "pdf" => "file.exportPdf", + "csv" => "file.exportCsv", + "html" => "file.exportHtml", + _ => return false, + }; + let title = app.session.active().map(|d| d.display_title()).unwrap_or_else(|| "Book1".into()); + let stem = std::path::Path::new(&title).file_stem().and_then(|s| s.to_str()).unwrap_or("Book1").to_string(); + if let Some(pick) = &app.services.pick_save { + let Some(path) = pick(&format!("{stem}.{fmt}")) else { return false }; + // The picker may suggest a workbook extension; the export writes the chosen format. + let path = std::path::Path::new(&path).with_extension(fmt).to_string_lossy().into_owned(); + return match app.run(id, json!({"path": path})) { + Ok(_) => true, + Err(e) => { + app.message = Some(("GridCraft".into(), crate::clean_error(&e))); + false + } + }; + } + if app.services.download.is_none() { + app.message = Some(("GridCraft".into(), "Exporting needs a file picker, which this window doesn't have.".into())); + return false; + } + if fmt == "pdf" { + // Without a path the PDF comes back as bytes, which `SheetApp::run` hands to the browser. + return match app.run(id, json!({})) { + Ok(_) => true, + Err(e) => { + app.message = Some(("GridCraft".into(), crate::clean_error(&e))); + false + } + }; + } + match app.session.run("file.saveBytes", json!({"format": fmt})) { + Ok(r) => { + let bytes = r.get("base64").and_then(Json::as_str).and_then(gridcraft_engine::io::base64_decode); + let name = r.get("name").and_then(Json::as_str).map_or_else(|| format!("{stem}.{fmt}"), str::to_string); + match (bytes, &app.services.download) { + (Some(b), Some(dl)) => { + dl(&name, &b); + true + } + _ => false, + } + } + Err(e) => { + app.message = Some(("GridCraft".into(), crate::clean_error(&e))); + false + } + } +} + +fn options(app: &mut SheetApp, ui: &mut Ui) { + let lang = app.ui.language; + section(ui, lang, "Options|General"); + egui::Grid::new("backstage_options_general").num_columns(2).spacing(vec2(18.0, 10.0)).show(ui, |ui| { + ui.label(lang.tr("Display theme")); + let mode = app.ui.theme_mode(); + let current = match mode { + "system" => "System", + "dark" => "Dark", + _ => "Light", + }; + egui::ComboBox::from_id_salt("backstage_theme").width(160.0).selected_text(lang.tr(current)).show_ui(ui, |ui| { + for (value, label) in [("system", "System"), ("light", "Light"), ("dark", "Dark")] { + if ui.selectable_label(mode == value, lang.tr(label)).clicked() { + app.run_or_alert("view.theme", json!({"mode": value})); + } + } + }); + ui.end_row(); + ui.label(lang.tr("Interface language")); + egui::ComboBox::from_id_salt("backstage_language").width(160.0).selected_text(lang.name()).show_ui(ui, |ui| { + for l in i18n::Language::ALL { + if ui.selectable_label(l == lang, l.name()).clicked() { + app.run_or_alert("app.language.set", json!({"language": l.code()})); + } + } + }); + ui.end_row(); + }); + ui.add_space(6.0); + let mut formula_bar = app.ui.formula_bar; + if ui.checkbox(&mut formula_bar, lang.tr("Show formula bar")).changed() { + app.run_or_alert("view.formulaBar", json!({"on": formula_bar})); + } + ui.add_space(18.0); + section(ui, lang, "Options|Save"); + let mut autosave = app.grid.autosave(); + if ui.checkbox(&mut autosave, lang.tr("Save .xlsx workbooks automatically after every change")).changed() { + app.grid.toggle_autosave(); + } + let Some(calc) = app.session.active().map(|d| d.wb.calc.clone()) else { return }; + ui.add_space(18.0); + section(ui, lang, "Formulas"); + ui.label(lang.tr("Workbook calculation")); + use gridcraft_engine::model::CalcMode; + for (mode, id, label) in [ + (CalcMode::Automatic, "automatic", "Automatic"), + (CalcMode::AutomaticExceptTables, "automaticExceptTables", "Automatic Except for Data Tables"), + (CalcMode::Manual, "manual", "Manual"), + ] { + if ui.radio(calc.mode == mode, lang.tr(label)).clicked() && calc.mode != mode { + app.run_or_alert("formulas.calculationOptions", json!({"mode": id})); + } + } + ui.add_space(4.0); + let mut iterative = calc.iterative; + if ui.checkbox(&mut iterative, lang.tr("Enable iterative calculation")).changed() { + app.run_or_alert("formulas.calculationOptions", json!({"iterative": iterative})); + } + ui.add_space(6.0); + dim(ui, lang.tr("Calculation settings are saved with the workbook.")); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn page_ids_round_trip_and_unknown_ids_are_refused() { + for page in Page::ALL { + assert_eq!(Page::parse(page.id()), Some(page)); + } + assert_eq!(Page::parse(" SAVEAS "), Some(Page::SaveAs)); + assert_eq!(Page::parse("account"), None); + assert_eq!(Page::parse(""), None); + } + + #[test] + fn recent_paths_split_without_touching_the_disk() { + assert_eq!(split_path("/home/me/Budget.xlsx"), ("Budget.xlsx".into(), "/home/me".into())); + assert_eq!(split_path("Budget.xlsx"), ("Budget.xlsx".into(), "".into())); + } +} diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index e6da0f0..38bfcce 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -73,6 +73,7 @@ pub fn inspect(app: &SheetApp, ctx: &egui::Context) -> Json { "scroll": [v.scroll.x, v.scroll.y], "editor": app.editor.as_ref().map(|e| json!({"cell": e.cell.a1(), "text": e.text, "formulaBar": e.from_formula_bar, "point": e.can_point()})), "dialog": app.dialog.as_ref().map(|d| json!({"name": d.name, "title": d.title, "values": d.values, "tab": d.tab})), + "backstage": app.backstage.as_ref().map(|b| b.page.id()), "message": app.message.as_ref().map(|(t, m)| json!({"title": t, "text": m})), "selectedChart": app.selected_chart, "mode": format!("{:?}", app.session.mode), @@ -174,6 +175,7 @@ pub fn handle(app: &mut SheetApp, ctx: &egui::Context, method: &str, p: &Json) - ("view.theme", "Display Theme", ""), ("view.zoom100", "100%", ""), ("ui.dialog", "Open Dialog", ""), + ("ui.backstage", "File (Backstage view)", "{page?: home|new|open|info|saveAs|print|export|options|close}"), ("app.language.set", "Interface Language", "{language: \"en\"|\"zh\"|\"ja\"|\"ko\"|\"ru\"|\"pt\"} (alias: code)"), ("app.language.english", "Interface Language: English", ""), ("app.language.japanese", "Interface Language: Japanese", ""), diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 1e7b212..420911a 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -416,6 +416,67 @@ const JAPANESE: &[(&str, &str)] = &[ ("More\nFunctions", "その他の関数"), ("Sheet Options|View", "画面"), ("Print", "印刷"), + // File › Backstage view. + ("File", "ファイル"), + ("Back", "戻る"), + ("New", "新規"), + ("Open", "開く"), + ("Info", "情報"), + ("Save", "上書き保存"), + ("Save As", "名前を付けて保存"), + ("Export", "エクスポート"), + ("Close", "閉じる"), + ("Options", "オプション"), + ("Blank workbook", "空白のブック"), + ("Recent", "最近使ったアイテム"), + ("No recent workbooks yet.", "最近使ったブックはまだありません。"), + ("More workbooks", "その他のブック"), + ("Browse…", "参照…"), + ("Not saved yet", "まだ保存されていません"), + ("Location", "場所"), + ("Sheets", "シート"), + ("Protect Workbook", "ブックの保護"), + ( + "Sheet structure is locked: sheets can't be added, moved, renamed or deleted.", + "シートの構成はロックされています。シートの追加、移動、名前の変更、削除はできません。", + ), + ( + "Lock the sheet structure so sheets can't be added, moved, renamed or deleted.", + "シートの構成をロックして、シートの追加、移動、名前の変更、削除をできないようにします。", + ), + ("Properties", "プロパティ"), + ("Title", "タイトル"), + ("Subject", "件名"), + ("Author", "作成者"), + ("Company", "会社名"), + ("Keywords", "キーワード"), + ("Description", "説明"), + ("No workbook is open.", "ブックが開かれていません。"), + ("Save the workbook under another name or in another folder.", "ブックを別の名前または別のフォルダーに保存します。"), + ("Download (.xlsx)", "ダウンロード (.xlsx)"), + ("PDF, CSV and web pages are on the Export page.", "PDF、CSV、Web ページは [エクスポート] ページにあります。"), + ("Print Active Sheets", "作業中のシートを印刷"), + ("Print Entire Workbook", "ブック全体を印刷"), + ("Fit Sheet on One Page", "シートを 1 ページに印刷"), + ("Page Setup…", "ページ設定…"), + ("Page {page} of {pages}", "{page} / {pages} ページ"), + ("Nothing to print.", "印刷する内容がありません。"), + ("Previous page", "前のページ"), + ("Next page", "次のページ"), + ("Create PDF", "PDF の作成"), + ("Save the sheet as a PDF document that keeps its page layout.", "ページ レイアウトを保ったまま、シートを PDF ドキュメントとして保存します。"), + ("Change File Type", "ファイルの種類の変更"), + ("CSV (Comma delimited)", "CSV (コンマ区切り)"), + ("Save the active sheet as comma-separated text.", "作業中のシートをコンマ区切りのテキストとして保存します。"), + ("Web Page (.html)", "Web ページ (.html)"), + ("Save the workbook as a web page.", "ブックを Web ページとして保存します。"), + ("Options|General", "全般"), + ("Options|Save", "保存"), + ("Show formula bar", "数式バーを表示する"), + ("Save .xlsx workbooks automatically after every change", "変更のたびに .xlsx ブックを自動的に保存する"), + ("Workbook calculation", "ブックの計算"), + ("Enable iterative calculation", "反復計算を行う"), + ("Calculation settings are saved with the workbook.", "計算の設定はブックと共に保存されます。"), ]; #[cfg(test)] diff --git a/crates/ui-egui/src/i18n/ko.rs b/crates/ui-egui/src/i18n/ko.rs index c37ae3c..c0d7aa7 100644 --- a/crates/ui-egui/src/i18n/ko.rs +++ b/crates/ui-egui/src/i18n/ko.rs @@ -268,6 +268,65 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Dark" => "어둡게", "Sheet Options|View" => "화면", "Print" => "인쇄", + // File › Backstage view. + "File" => "파일", + "Back" => "뒤로", + "New" => "새로 만들기", + "Open" => "열기", + "Info" => "정보", + "Save" => "저장", + "Save As" => "다른 이름으로 저장", + "Export" => "내보내기", + "Close" => "닫기", + "Options" => "옵션", + "Blank workbook" => "새 통합 문서", + "Recent" => "최근 항목", + "No recent workbooks yet." => "최근 통합 문서가 아직 없습니다.", + "More workbooks" => "더 많은 통합 문서", + "Browse…" => "찾아보기…", + "Not saved yet" => "아직 저장되지 않음", + "Location" => "위치", + "Sheets" => "시트", + "Protect Workbook" => "통합 문서 보호", + "Sheet structure is locked: sheets can't be added, moved, renamed or deleted." => { + "시트 구조가 잠겨 있습니다. 시트를 추가, 이동, 이름 바꾸기 또는 삭제할 수 없습니다." + } + "Lock the sheet structure so sheets can't be added, moved, renamed or deleted." => { + "시트 구조를 잠가 시트를 추가, 이동, 이름 바꾸기 또는 삭제할 수 없게 합니다." + } + "Properties" => "속성", + "Title" => "제목", + "Subject" => "주제", + "Author" => "만든 이", + "Company" => "회사", + "Keywords" => "키워드", + "Description" => "설명", + "No workbook is open." => "열려 있는 통합 문서가 없습니다.", + "Save the workbook under another name or in another folder." => "통합 문서를 다른 이름 또는 다른 폴더에 저장합니다.", + "Download (.xlsx)" => "다운로드(.xlsx)", + "PDF, CSV and web pages are on the Export page." => "PDF, CSV 및 웹 페이지는 내보내기 페이지에 있습니다.", + "Print Active Sheets" => "활성 시트 인쇄", + "Print Entire Workbook" => "전체 통합 문서 인쇄", + "Fit Sheet on One Page" => "한 페이지에 시트 맞추기", + "Page Setup…" => "페이지 설정…", + "Page {page} of {pages}" => "{page}/{pages} 페이지", + "Nothing to print." => "인쇄할 내용이 없습니다.", + "Previous page" => "이전 페이지", + "Next page" => "다음 페이지", + "Create PDF" => "PDF 만들기", + "Save the sheet as a PDF document that keeps its page layout." => "페이지 레이아웃을 유지한 채 시트를 PDF 문서로 저장합니다.", + "Change File Type" => "파일 형식 변경", + "CSV (Comma delimited)" => "CSV(쉼표로 분리)", + "Save the active sheet as comma-separated text." => "활성 시트를 쉼표로 구분된 텍스트로 저장합니다.", + "Web Page (.html)" => "웹 페이지(.html)", + "Save the workbook as a web page." => "통합 문서를 웹 페이지로 저장합니다.", + "Options|General" => "일반", + "Options|Save" => "저장", + "Show formula bar" => "수식 입력줄 표시", + "Save .xlsx workbooks automatically after every change" => "변경할 때마다 .xlsx 통합 문서를 자동으로 저장", + "Workbook calculation" => "통합 문서 계산", + "Enable iterative calculation" => "반복 계산 사용", + "Calculation settings are saved with the workbook." => "계산 설정은 통합 문서와 함께 저장됩니다.", _ => return None, }) } diff --git a/crates/ui-egui/src/i18n/pt.rs b/crates/ui-egui/src/i18n/pt.rs index 1bdc2c3..a5d00a0 100644 --- a/crates/ui-egui/src/i18n/pt.rs +++ b/crates/ui-egui/src/i18n/pt.rs @@ -269,6 +269,65 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Dark" => "Escuro", "Sheet Options|View" => "Exibir", "Print" => "Imprimir", + // File › Backstage view. + "File" => "Arquivo", + "Back" => "Voltar", + "New" => "Novo", + "Open" => "Abrir", + "Info" => "Informações", + "Save" => "Salvar", + "Save As" => "Salvar como", + "Export" => "Exportar", + "Close" => "Fechar", + "Options" => "Opções", + "Blank workbook" => "Pasta de trabalho em branco", + "Recent" => "Recentes", + "No recent workbooks yet." => "Ainda não há pastas de trabalho recentes.", + "More workbooks" => "Mais pastas de trabalho", + "Browse…" => "Procurar…", + "Not saved yet" => "Ainda não salvo", + "Location" => "Local", + "Sheets" => "Planilhas", + "Protect Workbook" => "Proteger pasta de trabalho", + "Sheet structure is locked: sheets can't be added, moved, renamed or deleted." => { + "A estrutura das planilhas está bloqueada: não é possível adicionar, mover, renomear nem excluir planilhas." + } + "Lock the sheet structure so sheets can't be added, moved, renamed or deleted." => { + "Bloqueie a estrutura das planilhas para que não possam ser adicionadas, movidas, renomeadas nem excluídas." + } + "Properties" => "Propriedades", + "Title" => "Título", + "Subject" => "Assunto", + "Author" => "Autor", + "Company" => "Empresa", + "Keywords" => "Palavras-chave", + "Description" => "Descrição", + "No workbook is open." => "Nenhuma pasta de trabalho está aberta.", + "Save the workbook under another name or in another folder." => "Salve a pasta de trabalho com outro nome ou em outra pasta.", + "Download (.xlsx)" => "Baixar (.xlsx)", + "PDF, CSV and web pages are on the Export page." => "PDF, CSV e páginas da Web estão na página Exportar.", + "Print Active Sheets" => "Imprimir planilhas ativas", + "Print Entire Workbook" => "Imprimir toda a pasta de trabalho", + "Fit Sheet on One Page" => "Ajustar planilha em uma página", + "Page Setup…" => "Configurar página…", + "Page {page} of {pages}" => "Página {page} de {pages}", + "Nothing to print." => "Nada para imprimir.", + "Previous page" => "Página anterior", + "Next page" => "Próxima página", + "Create PDF" => "Criar PDF", + "Save the sheet as a PDF document that keeps its page layout." => "Salve a planilha como um documento PDF que mantém o layout da página.", + "Change File Type" => "Alterar tipo de arquivo", + "CSV (Comma delimited)" => "CSV (separado por vírgulas)", + "Save the active sheet as comma-separated text." => "Salve a planilha ativa como texto separado por vírgulas.", + "Web Page (.html)" => "Página da Web (.html)", + "Save the workbook as a web page." => "Salve a pasta de trabalho como uma página da Web.", + "Options|General" => "Geral", + "Options|Save" => "Salvar", + "Show formula bar" => "Mostrar barra de fórmulas", + "Save .xlsx workbooks automatically after every change" => "Salvar pastas de trabalho .xlsx automaticamente após cada alteração", + "Workbook calculation" => "Cálculo da pasta de trabalho", + "Enable iterative calculation" => "Habilitar cálculo iterativo", + "Calculation settings are saved with the workbook." => "As configurações de cálculo são salvas com a pasta de trabalho.", _ => return None, }) } diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index 8d54856..cbe2aa3 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -268,6 +268,65 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Dark" => "Тёмная", "Sheet Options|View" => "На экране", "Print" => "Печать", + // File › Backstage view. + "File" => "Файл", + "Back" => "Назад", + "New" => "Создать", + "Open" => "Открыть", + "Info" => "Сведения", + "Save" => "Сохранить", + "Save As" => "Сохранить как", + "Export" => "Экспорт", + "Close" => "Закрыть", + "Options" => "Параметры", + "Blank workbook" => "Пустая книга", + "Recent" => "Последние", + "No recent workbooks yet." => "Недавних книг пока нет.", + "More workbooks" => "Другие книги", + "Browse…" => "Обзор…", + "Not saved yet" => "Ещё не сохранено", + "Location" => "Расположение", + "Sheets" => "Листы", + "Protect Workbook" => "Защитить книгу", + "Sheet structure is locked: sheets can't be added, moved, renamed or deleted." => { + "Структура листов заблокирована: листы нельзя добавлять, перемещать, переименовывать или удалять." + } + "Lock the sheet structure so sheets can't be added, moved, renamed or deleted." => { + "Заблокировать структуру листов, чтобы их нельзя было добавлять, перемещать, переименовывать или удалять." + } + "Properties" => "Свойства", + "Title" => "Название", + "Subject" => "Тема", + "Author" => "Автор", + "Company" => "Организация", + "Keywords" => "Ключевые слова", + "Description" => "Описание", + "No workbook is open." => "Нет открытых книг.", + "Save the workbook under another name or in another folder." => "Сохранить книгу под другим именем или в другой папке.", + "Download (.xlsx)" => "Скачать (.xlsx)", + "PDF, CSV and web pages are on the Export page." => "PDF, CSV и веб-страницы — на странице «Экспорт».", + "Print Active Sheets" => "Напечатать активные листы", + "Print Entire Workbook" => "Напечатать всю книгу", + "Fit Sheet on One Page" => "Вписать лист на одну страницу", + "Page Setup…" => "Параметры страницы…", + "Page {page} of {pages}" => "Страница {page} из {pages}", + "Nothing to print." => "Нечего печатать.", + "Previous page" => "Предыдущая страница", + "Next page" => "Следующая страница", + "Create PDF" => "Создать PDF", + "Save the sheet as a PDF document that keeps its page layout." => "Сохранить лист как PDF-документ с сохранением разметки страниц.", + "Change File Type" => "Изменить тип файла", + "CSV (Comma delimited)" => "CSV (разделители — запятые)", + "Save the active sheet as comma-separated text." => "Сохранить активный лист как текст с разделителями-запятыми.", + "Web Page (.html)" => "Веб-страница (.html)", + "Save the workbook as a web page." => "Сохранить книгу как веб-страницу.", + "Options|General" => "Общие", + "Options|Save" => "Сохранение", + "Show formula bar" => "Показывать строку формул", + "Save .xlsx workbooks automatically after every change" => "Автоматически сохранять книги .xlsx после каждого изменения", + "Workbook calculation" => "Вычисления в книге", + "Enable iterative calculation" => "Включить итеративные вычисления", + "Calculation settings are saved with the workbook." => "Параметры вычислений сохраняются вместе с книгой.", _ => return None, }) } diff --git a/crates/ui-egui/src/i18n/zh.rs b/crates/ui-egui/src/i18n/zh.rs index 9f04ada..b1bebfd 100644 --- a/crates/ui-egui/src/i18n/zh.rs +++ b/crates/ui-egui/src/i18n/zh.rs @@ -268,6 +268,61 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Dark" => "深色", "Sheet Options|View" => "屏幕", "Print" => "打印", + // File › Backstage view. + "File" => "文件", + "Back" => "返回", + "New" => "新建", + "Open" => "打开", + "Info" => "信息", + "Save" => "保存", + "Save As" => "另存为", + "Export" => "导出", + "Close" => "关闭", + "Options" => "选项", + "Blank workbook" => "空白工作簿", + "Recent" => "最近", + "No recent workbooks yet." => "还没有最近使用的工作簿。", + "More workbooks" => "更多工作簿", + "Browse…" => "浏览…", + "Not saved yet" => "尚未保存", + "Location" => "位置", + "Sheets" => "工作表", + "Protect Workbook" => "保护工作簿", + "Sheet structure is locked: sheets can't be added, moved, renamed or deleted." => "工作表结构已锁定:无法添加、移动、重命名或删除工作表。", + "Lock the sheet structure so sheets can't be added, moved, renamed or deleted." => "锁定工作表结构,使工作表无法被添加、移动、重命名或删除。", + "Properties" => "属性", + "Title" => "标题", + "Subject" => "主题", + "Author" => "作者", + "Company" => "公司", + "Keywords" => "关键词", + "Description" => "说明", + "No workbook is open." => "没有打开的工作簿。", + "Save the workbook under another name or in another folder." => "以其他名称或在其他文件夹中保存工作簿。", + "Download (.xlsx)" => "下载 (.xlsx)", + "PDF, CSV and web pages are on the Export page." => "PDF、CSV 和网页位于“导出”页。", + "Print Active Sheets" => "打印活动工作表", + "Print Entire Workbook" => "打印整个工作簿", + "Fit Sheet on One Page" => "将工作表调整为一页", + "Page Setup…" => "页面设置…", + "Page {page} of {pages}" => "第 {page} 页,共 {pages} 页", + "Nothing to print." => "没有可打印的内容。", + "Previous page" => "上一页", + "Next page" => "下一页", + "Create PDF" => "创建 PDF", + "Save the sheet as a PDF document that keeps its page layout." => "将工作表另存为保留页面布局的 PDF 文档。", + "Change File Type" => "更改文件类型", + "CSV (Comma delimited)" => "CSV (逗号分隔)", + "Save the active sheet as comma-separated text." => "将活动工作表另存为逗号分隔的文本。", + "Web Page (.html)" => "网页 (.html)", + "Save the workbook as a web page." => "将工作簿另存为网页。", + "Options|General" => "常规", + "Options|Save" => "保存", + "Show formula bar" => "显示编辑栏", + "Save .xlsx workbooks automatically after every change" => "每次更改后自动保存 .xlsx 工作簿", + "Workbook calculation" => "工作簿计算", + "Enable iterative calculation" => "启用迭代计算", + "Calculation settings are saved with the workbook." => "计算设置随工作簿一起保存。", _ => return None, }) } diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index b2079c8..fcd2d87 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -111,6 +111,12 @@ pub enum Icon { Sheet, Book, Folder, + /// File › Backstage navigation (line art, drawn in the navigation colour). + Back, + NewFile, + OpenFolder, + Info, + Print, } struct G<'a> { @@ -605,6 +611,29 @@ pub fn paint(p: &Painter, rect: Rect, icon: Icon, c: Color32) { Icon::Folder => { g.poly(&[(1.5, 4.0), (6.0, 4.0), (7.5, 5.5), (14.5, 5.5), (14.5, 13.0), (1.5, 13.0)], YELLOW); } + Icon::Back => { + g.circle(8.0, 8.0, 6.5, None, Some(c)); + g.line(&[(11.5, 8.0), (4.8, 8.0)], c, 1.2); + g.line(&[(7.5, 5.3), (4.8, 8.0), (7.5, 10.7)], c, 1.2); + } + Icon::NewFile => { + g.line(&[(9.5, 1.5), (3.5, 1.5), (3.5, 14.5), (12.5, 14.5), (12.5, 4.5), (9.5, 1.5), (9.5, 4.5), (12.5, 4.5)], c, 1.1); + } + Icon::OpenFolder => { + g.line(&[(13.0, 6.5), (13.0, 4.5), (7.5, 4.5), (6.0, 3.0), (1.5, 3.0), (1.5, 13.0)], c, 1.1); + g.line(&[(1.5, 13.0), (4.0, 7.0), (15.0, 7.0), (12.5, 13.0), (1.5, 13.0)], c, 1.1); + } + Icon::Info => { + g.circle(8.0, 8.0, 6.5, None, Some(c)); + g.line(&[(8.0, 7.0), (8.0, 11.8)], c, 1.3); + g.circle(8.0, 4.8, 0.8, Some(c), None); + } + Icon::Print => { + g.line(&[(4.5, 5.5), (4.5, 1.5), (11.5, 1.5), (11.5, 5.5)], c, 1.1); + g.line(&[(4.0, 11.5), (1.5, 11.5), (1.5, 5.5), (14.5, 5.5), (14.5, 11.5), (12.0, 11.5)], c, 1.1); + g.line(&[(4.5, 9.0), (4.5, 14.5), (11.5, 14.5), (11.5, 9.0), (4.5, 9.0)], c, 1.1); + g.circle(12.3, 7.6, 0.6, Some(c), None); + } } } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 1225eec..32b63fa 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -6,6 +6,7 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] #![forbid(unsafe_code)] +pub mod backstage; mod border_preview; mod cell_pictures; pub mod chartview; @@ -141,6 +142,8 @@ pub struct SheetApp { pub editor: Option, pub text_box_editor: Option, pub dialog: Option, + /// The File tab's Backstage view, when open (UI-only; replaces the ribbon, grid and tabs). + pub backstage: Option, pub message: Option<(String, String)>, pub toast: Option<(String, f64)>, pub services: Services, @@ -173,6 +176,7 @@ impl SheetApp { editor: None, text_box_editor: None, dialog: None, + backstage: None, message: None, toast: None, services, @@ -339,6 +343,22 @@ impl SheetApp { self.ui.language = i18n::Language::Ja; Ok(json!({"language": i18n::Language::Ja})) } + "ui.backstage" => { + // `{"page": "info"}` opens File › Info; `{"page": "close"}` returns to the sheet. + match p.get("page").and_then(Json::as_str).map(str::trim) { + Some("close" | "none" | "") => self.backstage = None, + Some(id) => match backstage::Page::parse(id) { + Some(page) => self.open_backstage(page), + None => { + return Some(Err(format!( + "unknown File page {id:?} (use home, new, open, info, saveAs, print, export, options or close)" + ))); + } + }, + None => self.open_backstage(backstage::Page::Home), + } + Ok(json!({"page": self.backstage.as_ref().map(|b| b.page.id())})) + } "ui.dialog" => { let name = p.get("name").and_then(Json::as_str).unwrap_or(""); self.open_dialog(name, p.clone()); @@ -407,10 +427,28 @@ impl SheetApp { dl(&name, &b); } } + // File › Options (`file.options`) and the File tab itself open the Backstage view. + "options" => self.open_backstage(backstage::Page::Options), + "backstage" => { + let page = params.get("page").and_then(Json::as_str).and_then(backstage::Page::parse).unwrap_or(backstage::Page::Home); + self.open_backstage(page); + } other => self.dialog = dialogs::Dialog::open(self, other, params), } } + /// Opens the File tab's Backstage view on `page`, committing a cell still being edited first + /// (File actions work on the whole workbook). A draft that can't be committed keeps the sheet. + pub fn open_backstage(&mut self, page: backstage::Page) { + if !self.commit_edit(0, 0, false, false) { + return; + } + match &mut self.backstage { + Some(b) => b.page = page, + None => self.backstage = Some(backstage::Backstage::new(page)), + } + } + pub fn open_path(&mut self, path: &str) { match self.run("file.open", json!({"path": path})) { Ok(_) => { @@ -577,8 +615,10 @@ impl SheetApp { .unwrap_or_default(); for (name, bytes) in arrived { let b64 = gridcraft_engine::io::base64_encode(&bytes); - if let Err(e) = self.run("file.open", json!({"name": name, "base64": b64})) { - self.message = Some(("GridCraft".into(), clean_error(&e))); + match self.run("file.open", json!({"name": name, "base64": b64})) { + // A file picked from File › Open (web) arrives here: show it, not the Backstage. + Ok(_) => self.backstage = None, + Err(e) => self.message = Some(("GridCraft".into(), clean_error(&e))), } self.after_engine(); } @@ -605,23 +645,30 @@ impl SheetApp { // The language the widgets translate with this frame (see `i18n::current`). i18n::set_current(&ctx, self.ui.language); let t = theme::Tokens::get(&ctx); - self.ribbon_keys(&ctx); + if self.backstage.is_none() { + self.ribbon_keys(&ctx); + } ribbon::title_bar(self, ui); - ribbon::show(self, ui); - if self.ui.formula_bar { - formula_bar::show(self, ui); + if self.backstage.is_some() { + // File tab: the Backstage view replaces everything below the title bar. + backstage::show(self, ui); + } else { + ribbon::show(self, ui); + if self.ui.formula_bar { + formula_bar::show(self, ui); + } + if self.ui.status_bar { + tabs::status_bar(self, ui); + } + tabs::sheet_tabs(self, ui); + pivot_pane::show(self, ui); + panes::show(self, ui); + egui::CentralPanel::default().frame(egui::Frame::NONE.fill(t.grid_bg)).show(ui, |ui| { + let g0 = now_ms(); + grid::show(self, ui); + self.perf.grid_ms = now_ms() - g0; + }); } - if self.ui.status_bar { - tabs::status_bar(self, ui); - } - tabs::sheet_tabs(self, ui); - pivot_pane::show(self, ui); - panes::show(self, ui); - egui::CentralPanel::default().frame(egui::Frame::NONE.fill(t.grid_bg)).show(ui, |ui| { - let g0 = now_ms(); - grid::show(self, ui); - self.perf.grid_ms = now_ms() - g0; - }); dialogs::show(self, &ctx); widgets::message_box(self, &ctx); widgets::toast(self, &ctx); diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 264479a..06dee5b 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -207,30 +207,15 @@ pub fn show(app: &mut SheetApp, ui: &mut Ui) { }); } +/// The File tab opens the Backstage view (New, Open, Info, Save As, Print, Export, Options…), like +/// Excel's; the ribbon's tab and collapsed state are left as they were for when it closes. fn file_menu(app: &mut SheetApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); - let label = egui::RichText::new("File").font(theme::ui_font(13.5)).color(t.accent); - let file = ui.add_sized(vec2(46.0, 30.0), egui::Button::new(label).frame(false)); - egui::Popup::menu(&file).show(|ui| { - for (label, id, needs_document) in [ - ("New Workbook", "file.new", false), - ("Open…", "file.open", false), - ("Save", "file.save", true), - ("Save As…", "file.saveAs", true), - ("Close", "file.close", true), - ] { - if id == "file.save" || id == "file.close" { - ui.separator(); - } - if ui.add_enabled(!needs_document || app.session.active().is_some(), egui::Button::new(label)).clicked() { - // File operations must include the cell the user is still editing. - if app.commit_edit(0, 0, false, false) { - app.run_or_alert(id, json!({})); - } - ui.close(); - } - } - }); + let label = egui::RichText::new(app.ui.language.tr("File")).font(theme::ui_font(13.5)).color(t.accent); + let file = ui.add(egui::Button::new(label).frame(false).min_size(vec2(46.0, 30.0))); + if file.clicked() { + app.open_backstage(crate::backstage::Page::Home); + } } fn contextual_tabs(app: &SheetApp) -> Vec<&'static str> { diff --git a/crates/ui-egui/tests/file_menu.rs b/crates/ui-egui/tests/file_menu.rs index f3b07f4..d47bf5a 100644 --- a/crates/ui-egui/tests/file_menu.rs +++ b/crates/ui-egui/tests/file_menu.rs @@ -1,4 +1,4 @@ -//! The shared ribbon exposes basic file operations without requiring a native menu. +//! The File tab opens the Backstage view, which exposes the file operations without a native menu. use std::sync::{Arc, Mutex}; @@ -21,15 +21,26 @@ fn harness(services: Services) -> egui_kittest::Harness<'static, SheetApp> { h } +/// Opens File (the Backstage view) and clicks a navigation entry or page button. fn choose(h: &mut egui_kittest::Harness<'static, SheetApp>, label: &str) { - h.get_by_label("File").click(); - h.run_steps(3); - h.get_by_label(label).click(); + if h.state().backstage.is_none() { + h.get_by_label("File").click(); + h.run_steps(3); + } + click(h, label); +} + +fn click(h: &mut egui_kittest::Harness<'static, SheetApp>, label: &str) { + h.get_by_role_and_label(egui::accesskit::Role::Button, label).click(); h.run_steps(3); } +fn page(h: &egui_kittest::Harness<'static, SheetApp>) -> Option<&'static str> { + h.state().backstage.as_ref().map(|b| b.page.id()) +} + #[test] -fn new_and_open_are_visible_even_when_the_ribbon_is_collapsed() { +fn new_and_open_from_the_file_tab_keep_the_ribbon_as_it_was() { let opened = Arc::new(Mutex::new(0)); let capture = opened.clone(); let mut h = harness(Services { @@ -42,39 +53,85 @@ fn new_and_open_are_visible_even_when_the_ribbon_is_collapsed() { h.state_mut().ui.ribbon_tab = "Data".into(); h.state_mut().ui.ribbon_collapsed = true; h.run_steps(2); - choose(&mut h, "New Workbook"); + h.get_by_label("File").click(); + h.run_steps(3); + assert_eq!(page(&h), Some("home"), "the File tab opens the Backstage view on Home"); + choose(&mut h, "New"); + assert_eq!(page(&h), Some("new")); + click(&mut h, "Blank workbook"); assert_eq!(h.state().session.documents().len(), 2); - choose(&mut h, "Open…"); - assert_eq!(*opened.lock().unwrap(), 1, "Open invokes the platform file picker"); + assert!(h.state().backstage.is_none(), "creating a workbook returns to the sheet"); + choose(&mut h, "Open"); + click(&mut h, "Browse…"); + assert_eq!(*opened.lock().unwrap(), 1, "Browse invokes the platform file picker"); + assert_eq!(page(&h), Some("open"), "a cancelled pick stays on File › Open"); + h.key_press(egui::Key::Escape); + h.run_steps(3); + assert!(h.state().backstage.is_none(), "Esc returns to the sheet"); assert_eq!(h.state().ui.ribbon_tab, "Data"); assert!(h.state().ui.ribbon_collapsed); - assert!(h.query_by_label("Open…").is_none(), "choosing an action dismisses File"); } #[test] fn save_actions_include_the_cell_draft_and_close_keeps_the_unsaved_prompt() { - let downloads = Arc::new(Mutex::new(Vec::>::new())); + let downloads = Arc::new(Mutex::new(Vec::<(String, Vec)>::new())); let capture = downloads.clone(); - let mut h = harness(Services { download: Some(Box::new(move |_, bytes| capture.lock().unwrap().push(bytes.to_vec()))), ..Default::default() }); + let mut h = harness(Services { + download: Some(Box::new(move |name, bytes| capture.lock().unwrap().push((name.to_string(), bytes.to_vec())))), + ..Default::default() + }); h.input_mut().events.push(egui::Event::Text("123".into())); h.run_steps(2); assert!(h.state().editor.is_some()); choose(&mut h, "Save"); - choose(&mut h, "Save As…"); + assert!(h.state().editor.is_none(), "opening File commits the cell draft"); + assert_eq!(page(&h), Some("saveAs"), "a workbook without a file saves through Save As"); + click(&mut h, "Download (.xlsx)"); + assert!(h.state().backstage.is_none()); + choose(&mut h, "Save As"); + click(&mut h, "Download (.xlsx)"); + choose(&mut h, "Export"); + click(&mut h, "CSV (Comma delimited)"); let saved = downloads.lock().unwrap(); - assert_eq!(saved.len(), 2); - for bytes in saved.iter() { + assert_eq!(saved.len(), 3); + for (_, bytes) in saved.iter().take(2) { let (wb, _) = gridcraft_engine::io::open_bytes("saved.xlsx", bytes).unwrap(); assert_eq!(wb.active().unwrap().value(CellRef::new(0, 0)), Value::Number(123.0)); } + assert!(saved[2].0.ends_with(".csv")); + assert!(String::from_utf8_lossy(&saved[2].1).contains("123")); drop(saved); - assert!(h.state().editor.is_none()); choose(&mut h, "Close"); + assert!(h.state().backstage.is_none()); assert_eq!(h.state().session.documents().len(), 1); assert_eq!(h.state().dialog.as_ref().map(|d| d.name()), Some("saveChanges")); assert_eq!(h.state().session.active().unwrap().wb.active().unwrap().value(CellRef::new(0, 0)), Value::Number(123.0)); } +#[test] +fn file_options_opens_the_options_page_and_pages_are_reachable_by_command() { + let mut h = harness(Services::default()); + let r = h.state_mut().run("file.options", serde_json::json!({})).unwrap(); + assert_eq!(r["calculation"]["mode"], "automatic"); + h.run_steps(3); + assert_eq!(page(&h), Some("options")); + click(&mut h, "Info"); + assert_eq!(page(&h), Some("info")); + for id in ["print", "export", "saveAs", "new", "open", "home"] { + h.state_mut().run("ui.backstage", serde_json::json!({"page": id})).unwrap(); + h.run_steps(2); + assert_eq!(page(&h), Some(id)); + } + assert!(h.state_mut().run("ui.backstage", serde_json::json!({"page": "account"})).is_err()); + h.state_mut().run("ui.backstage", serde_json::json!({"page": "close"})).unwrap(); + assert!(h.state().backstage.is_none()); + // The back arrow returns to the sheet too. + h.state_mut().run("ui.backstage", serde_json::json!({})).unwrap(); + h.run_steps(2); + click(&mut h, "Back"); + assert!(h.state().backstage.is_none()); +} + #[test] fn export_as_pdf_is_delivered_as_a_download() { let files = Arc::new(Mutex::new(Vec::<(String, Vec)>::new())); diff --git a/docs/control-protocol.md b/docs/control-protocol.md index aeb540d..09cb590 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -25,13 +25,14 @@ Errors come back as `{"id": …, "ok": false, "error": "…"}`. | `document.inspect` | | Workbook summary: sheets, used ranges, tables, charts, names, selection, undo labels | | `sheet.read` | `{range?, sheet?, formulas?, formatted?}` | Values (or formulas / displayed text) of a range | | `cell.get` | `{cell?}` | One cell: value, formula, displayed text, style, merge, comment, link | -| `ui.inspect` | | UI state: ribbon tab, editor, open dialog, message, grid rect, scroll, perf | +| `ui.inspect` | | UI state: ribbon tab, editor, open dialog, Backstage page, message, grid rect, scroll, perf | | `ui.click` / `ui.drag` / `ui.move` | `{x, y, toX?, toY?, button?, count?, shift?, cmd?, alt?}` | Real pointer input in window points | | `ui.cell.click` / `ui.cell.drag` | `{cell}` / `{from, to}` | Pointer input at a cell's centre (works in point mode while editing) | | `ui.key` / `ui.text` | `{key, shift?, cmd?, alt?, ctrl?}` / `{text}` | Keyboard input (typing in the grid starts editing) | | `ui.edit.begin` / `ui.edit.text` / `ui.edit.commit` / `ui.edit.cancel` | `{text?}` / `{move?: down\|right\|up\|left\|none}` | Drive the cell editor | | `ui.ribbon` | `{tab}` | Switch ribbon tab | | `ui.dialog` / `ui.dialog.set` / `ui.dialog.confirm` / `ui.dialog.cancel` | `{name}` / `{field, value}` | Open, fill in and confirm dialogs | +| `ui.backstage` (via `engine.execute`) | `{page?: home\|new\|open\|info\|saveAs\|print\|export\|options\|close}` | Open the File tab's Backstage view on a page, or close it (`ui.inspect` reports the open page as `backstage`) | | `ui.screenshot` | `{path?}` | PNG of the window (base64 when no path) | | `ui.set` | `{dark?, formulaBar?, ribbonCollapsed?}` | UI preferences | | `ui.resize` / `ui.focus` | `{width, height}` | Window control | diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index e98835d..d039e3d 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -4,11 +4,11 @@ Each entry is an Excel ribbon or menu command (feature names only). It counts as implemented when GridCraft registers a command with the same id. This measures breadth only; the overall assessment (depth, fidelity, effort) is in [target-app-parity.md](target-app-parity.md). -**Overall: 258 / 290 commands implemented (89.0%).** +**Overall: 259 / 290 commands implemented (89.3%).** | Tab | Implemented | Total | Parity | |---|---|---|---| -| File | 10 | 11 | 91% | +| File | 11 | 11 | 100% | | Home | 94 | 94 | 100% | | Insert | 15 | 25 | 60% | | Draw | 4 | 4 | 100% | @@ -24,7 +24,7 @@ Each entry is an Excel ribbon or menu command (feature names only). It counts as | PivotTable Analyze | 12 | 13 | 92% | | PivotTable Design | 4 | 4 | 100% | -## File (10 / 11, 91%) +## File (11 / 11, 100%) Implemented: @@ -38,9 +38,6 @@ Implemented: - `file.exportCsv` CSV (Export) - `file.exportHtml` Web Page (Export) - `file.properties` Properties (Info) - -Missing: - - `file.options` Options (Options) ## Home (94 / 94, 100%) diff --git a/docs/ui-parity.md b/docs/ui-parity.md index e85bca1..9dd4a1d 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,10 +1,10 @@ # UI parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (created from a source audit and the issue tracker) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (File tab Backstage view, #174) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Interaction, controls and feel, against Excel. Part of [target-app-parity.md](target-app-parity.md). **UI/UX fidelity: ~55% ready, 50–80 h** (estimated). Ribbon command coverage is measured -separately in [parity-checklist.md](parity-checklist.md) (258 / 290). +separately in [parity-checklist.md](parity-checklist.md) (259 / 290). Sources: `crates/ui-egui/src` (ribbon, grid, editor, formula bar, dialogs, panes), `crates/engine/src/cmd` (shortcuts in command specs), the owner's black-box notes @@ -16,13 +16,13 @@ Sources: `crates/ui-egui/src` (ribbon, grid, editor, formula bar, dialogs, panes |---|---|---|---| | Title bar, Quick Access Toolbar, AutoSave toggle | yes | yes | done; macOS window controls misaligned (#170), Dock icon size (#169) | | Ribbon: tabs, groups, split buttons, galleries, contextual tabs | yes | yes (Table Design, Chart Design, Format, PivotTable tabs) | done; missing contextual tabs for pictures, shapes, sparklines, header/footer, slicers | -| Ribbon collapse, simplified ribbon, customize ribbon/QAT | yes | partial | customization missing (File ▸ Options is the one missing File command) | +| Ribbon collapse, simplified ribbon, customize ribbon/QAT | yes | partial | customization missing | | Key tips (Alt / F10 then letters) | yes, every ribbon control | **initial** (#43, 10-10): Alt or F10 shows key tips on Home for alignment, Paste Special and AutoFit plus legacy Alt sequences; other tabs and controls not yet (#33, #49) | 3–5 h | | Formula bar with Name Box, expand, function hints | yes | yes | done | | Sheet tabs: rename, reorder, colour, hide, scroll | yes | yes | rename focus bug on macOS (#145) | | Status bar: Sum/Count/Average and more, view buttons, zoom | yes | yes | customizable statistics partial | | Task panes (Format Chart, Comments, Watch, Selection, PivotTable fields) | yes | yes | element-level Format pane for shapes and pictures missing | -| Backstage (File) view: Info, New with templates, Open recent, Export, Options | yes | partial | Options dialog and template gallery missing | +| 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), 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) | 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 | **no** (Page Break Preview only) | 8–12 h | | Multiple windows per workbook | yes | no (#162) | see [hardware-parity.md](hardware-parity.md) | @@ -91,5 +91,6 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | File tab Backstage view and File › Options (#174) | | 2026-10-10 | minor | Chart resize bug (#190) | | 2026-10-10 | major | Created: chrome, grid, keyboard, dialogs, accessibility; key tips, Page Layout view and accessibility tree flagged | From c8ce333a74c14af603ce4f001f562243d19c1ba0 Mon Sep 17 00:00:00 2001 From: Losminek <301412321+Losminek@users.noreply.github.com> Date: Sun, 11 Oct 2026 13:35:04 +0200 Subject: [PATCH 64/83] i18n: Czech interface language (cs) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a Czech table with all 266 interface labels (the same key set as Korean, Russian, Simplified Chinese and Brazilian Portuguese) and register `cs` in Language: name "Čeština", code `cs`, system tags `cs-*` / `cs_*`, appended to the end of the switcher. The language command help, its error text and the snapshot example usage list `cs`. Like the other ribbon tables, it covers the ribbon, sheet tabs and widgets; dialogs, menus and messages stay English. Formulas stay canonical English: Czech uses the English formula locale like every non-Spanish language. Translated from the meaning of each English label (clean-room, not from another product's localization), with AI assistance. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/examples/snapshot.rs | 2 +- crates/ui-egui/src/control.rs | 2 +- crates/ui-egui/src/i18n.rs | 8 +- crates/ui-egui/src/i18n/cs.rs | 273 ++++++++++++++++++++++++++++ crates/ui-egui/src/lib.rs | 2 +- 5 files changed, 283 insertions(+), 4 deletions(-) create mode 100644 crates/ui-egui/src/i18n/cs.rs diff --git a/crates/ui-egui/examples/snapshot.rs b/crates/ui-egui/examples/snapshot.rs index 93359f0..0a481b2 100644 --- a/crates/ui-egui/examples/snapshot.rs +++ b/crates/ui-egui/examples/snapshot.rs @@ -1,7 +1,7 @@ //! Renders the whole GridCraft window offscreen (wgpu, no window) to a PNG. //! //! `cargo run --release -p gridcraft-ui-egui --example snapshot -- [--sample sales] [--in file.xlsx] -//! [--size 1440x900] [--scale 2] [--cmd 'id={json}']... [--tab Insert] [--dark] [--language en|zh|ja|ko|ru|pt] +//! [--size 1440x900] [--scale 2] [--cmd 'id={json}']... [--tab Insert] [--dark] [--language en|zh|ja|ko|ru|pt|cs] //! [--dialog 'name={json}'] out.png` use gridcraft_engine::Session; diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index e6da0f0..f2a1e05 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -174,7 +174,7 @@ pub fn handle(app: &mut SheetApp, ctx: &egui::Context, method: &str, p: &Json) - ("view.theme", "Display Theme", ""), ("view.zoom100", "100%", ""), ("ui.dialog", "Open Dialog", ""), - ("app.language.set", "Interface Language", "{language: \"en\"|\"zh\"|\"ja\"|\"ko\"|\"ru\"|\"pt\"} (alias: code)"), + ("app.language.set", "Interface Language", "{language: \"en\"|\"zh\"|\"ja\"|\"ko\"|\"ru\"|\"pt\"|\"cs\"} (alias: code)"), ("app.language.english", "Interface Language: English", ""), ("app.language.japanese", "Interface Language: Japanese", ""), ] { diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 1e7b212..2ff8399 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -9,6 +9,7 @@ use serde::{Deserialize, Serialize}; +mod cs; mod ko; mod pt; mod ru; @@ -26,10 +27,11 @@ pub enum Language { Ru, #[serde(rename = "pt")] PtBr, + Cs, } impl Language { - pub const ALL: [Self; 7] = [Self::En, Self::Es, Self::Zh, Self::Ja, Self::Ko, Self::Ru, Self::PtBr]; + pub const ALL: [Self; 8] = [Self::En, Self::Es, Self::Zh, Self::Ja, Self::Ko, Self::Ru, Self::PtBr, Self::Cs]; /// The language's own name, shown in the switcher. pub fn name(self) -> &'static str { @@ -41,6 +43,7 @@ impl Language { Self::Ko => "한국어", Self::Ru => "Русский", Self::PtBr => "Português (Brasil)", + Self::Cs => "Čeština", } } @@ -54,6 +57,7 @@ impl Language { Self::Ko => "ko", Self::Ru => "ru", Self::PtBr => "pt", + Self::Cs => "cs", } } @@ -70,6 +74,7 @@ impl Language { "ko" => Some(Self::Ko), "ru" => Some(Self::Ru), "pt" => Some(Self::PtBr), + "cs" => Some(Self::Cs), _ => None, } } @@ -103,6 +108,7 @@ impl Language { Self::Ko => ko::translate(text), Self::Ru => ru::translate(text), Self::PtBr => pt::translate(text), + Self::Cs => cs::translate(text), }; translated.unwrap_or_else(|| text.rsplit_once('|').map_or(text, |(_, label)| label)) } diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs new file mode 100644 index 0000000..1f54793 --- /dev/null +++ b/crates/ui-egui/src/i18n/cs.rs @@ -0,0 +1,273 @@ +//! Czech translations for the interface strings in i18n.rs. + +pub(super) fn translate(text: &str) -> Option<&'static str> { + Some(match text { + "Paste" => "Vložit", + "Paste Values" => "Vložit hodnoty", + "Paste Formulas" => "Vložit vzorce", + "Paste Formatting" => "Vložit formátování", + "Transpose" => "Transponovat", + "Paste Link" => "Vložit propojení", + "Paste Special…" => "Vložit jinak…", + "Cut (⌘X)" => "Vyjmout (⌘X)", + "Copy (⌘C)" => "Kopírovat (⌘C)", + "Format Painter (double-click to keep it on)" => "Štětec formátu (dvojklikem zůstane zapnutý)", + "Bold (⌘B)" => "Tučné (⌘B)", + "Italic (⌘I)" => "Kurzíva (⌘I)", + "Underline (⌘U)" => "Podtržení (⌘U)", + "Double Underline" => "Dvojité podtržení", + "Strikethrough" => "Přeškrtnutí", + "Underline" => "Podtržení", + "Increase Font Size" => "Zvětšit písmo", + "Decrease Font Size" => "Zmenšit písmo", + "Wrap Text" => "Zalamovat text", + "Merge & Center" => "Sloučit a vycentrovat", + "Merge Cells" => "Sloučit buňky", + "Merge Across" => "Sloučit po řádcích", + "Unmerge Cells" => "Zrušit sloučení buněk", + "Fill Color" => "Barva výplně", + "Font Color" => "Barva písma", + "Borders" => "Ohraničení", + "Top\nAlign" => "Zarovnat\nnahoru", + "Middle\nAlign" => "Zarovnat\ndoprostřed", + "Bottom\nAlign" => "Zarovnat\ndolů", + "Left" => "Doleva", + "Right" => "Doprava", + "Center" => "Zarovnat na střed", + "Horizontal" => "Vodorovně", + "Vertical Text" => "Svislý text", + "Decrease Indent" => "Zmenšit odsazení", + "Increase Indent" => "Zvětšit odsazení", + "Orientation" => "Orientace textu", + "Page Layout|Orientation" => "Orientace", + "Angle Counterclockwise" => "Natočit proti směru hodin", + "Angle Clockwise" => "Natočit po směru hodin", + "Rotate Text Up" => "Otočit text nahoru", + "Rotate Text Down" => "Otočit text dolů", + "Accounting Number Format" => "Účetnický formát čísla", + "Percent Style" => "Styl procent", + "Comma Style" => "Styl s oddělovačem tisíců", + "Increase Decimal" => "Přidat desetinné místo", + "Decrease Decimal" => "Odebrat desetinné místo", + "More Accounting Formats…" => "Další účetnické formáty…", + "Conditional\nFormatting" => "Podmíněné\nformátování", + "Conditional Formatting" => "Podmíněné formátování", + "Format\nas Table" => "Formátovat\njako tabulku", + "Cell\nStyles" => "Styly\nbuněk", + "Format Cells…" => "Formát buněk…", + "Format Cell Alignment…" => "Zarovnání buněk…", + "Format\nPane" => "Podokno\nformátu", + "Format" => "Formát", + "Insert" => "Vložit", + "Insert Cells…" => "Vložit buňky…", + "Delete" => "Odstranit", + "Delete Cells…" => "Odstranit buňky…", + "Insert Sheet Rows" => "Vložit řádky listu", + "Insert Sheet Columns" => "Vložit sloupce listu", + "Delete Sheet Rows" => "Odstranit řádky listu", + "Delete Sheet Columns" => "Odstranit sloupce listu", + "Row Height…" => "Výška řádku…", + "AutoFit Row Height" => "Přizpůsobit výšku řádku", + "Column Width…" => "Šířka sloupce…", + "AutoFit Column Width" => "Přizpůsobit šířku sloupce", + "Default Width…" => "Výchozí šířka…", + "Hide Rows" => "Skrýt řádky", + "Unhide Rows" => "Zobrazit řádky", + "Hide Columns" => "Skrýt sloupce", + "Unhide Columns" => "Zobrazit sloupce", + "Lock Cell" => "Zamknout buňku", + "Clear" => "Vymazat", + "Clear All" => "Vymazat vše", + "Clear Formats" => "Vymazat formáty", + "Clear Contents" => "Vymazat obsah", + "Clear Comments and Notes" => "Vymazat komentáře a poznámky", + "Clear Hyperlinks" => "Vymazat hypertextové odkazy", + "AutoSum" => "Automatický\nsoučet", + "AutoSum (⌘⇧T)" => "Automatický součet (⌘⇧T)", + "Flash\nFill" => "Dynamické\ndoplňování", + "Flash Fill" => "Dynamické doplňování", + "Fill" => "Vyplnit", + "Clear Print Area" => "Vymazat oblast tisku", + "Find &\nSelect" => "Najít a\nvybrat", + "Find…" => "Najít…", + "Replace…" => "Nahradit…", + "Go To…" => "Přejít na…", + "Go To Special…" => "Přejít na jinak…", + "PivotTable" => "Kontingenční\ntabulka", + "Recommended\nCharts" => "Doporučené\ngrafy", + "Table" => "Tabulka", + "Pictures" => "Obrázky", + "Shapes" => "Obrazce", + "Icons" => "Ikony", + "Text\nBox" => "Textové\npole", + "Comment" => "Komentář", + "New\nComment" => "Nový\nkomentář", + "New Note" => "Nová poznámka", + "Show/Hide Note" => "Zobrazit/skrýt poznámku", + "Header &\nFooter" => "Záhlaví a\nzápatí", + "Text to\nColumns" => "Text do\nsloupců", + "Link" => "Odkaz", + "Symbol" => "Symbol", + "Sparklines" => "Minigrafy", + "Header & Footer" => "Záhlaví a zápatí", + "Draw with ink" => "Kreslit od ruky", + "Eraser" => "Guma", + "Ink to\nShape" => "Rukopis\nna obrazec", + "Select\nObjects" => "Vybrat\nobjekty", + "Themes" => "Motivy", + "Margins" => "Okraje", + "Size" => "Velikost", + "Print\nArea" => "Oblast\ntisku", + "Set Print Area" => "Nastavit oblast tisku", + "Breaks" => "Konce", + "Insert Page Break" => "Vložit konec stránky", + "Remove Page Break" => "Odebrat konec stránky", + "Reset All Page Breaks" => "Obnovit všechny konce stránek", + "Print\nTitles" => "Tisk\nnázvů", + "Narrow" => "Úzké", + "Wide" => "Široké", + "Normal" => "Normální", + "Portrait" => "Na výšku", + "Landscape" => "Na šířku", + "Custom Margins…" => "Vlastní okraje…", + "Insert\nFunction" => "Vložit\nfunkci", + "Name\nManager" => "Správce\nnázvů", + "Define Name" => "Definovat název", + "Use in Formula" => "Použít ve vzorci", + "Create from Selection" => "Vytvořit z výběru", + "Trace Precedents" => "Sledovat předchůdce", + "Trace Dependents" => "Sledovat následníky", + "Remove Arrows" => "Odebrat šipky", + "Show Formulas" => "Zobrazit vzorce", + "Error Checking" => "Kontrola chyb", + "Evaluate Formula" => "Vyhodnotit vzorec", + "Watch Window" => "Okno sledování", + "Calculation\nOptions" => "Možnosti\nvýpočtu", + "Calculate Now" => "Přepočítat", + "Calculate Sheet" => "Přepočítat list", + "Automatic" => "Automaticky", + "Automatic Except for Data Tables" => "Automaticky kromě tabulek dat", + "Manual" => "Ručně", + "Sort &\nFilter" => "Seřadit a\nfiltrovat", + "Sort" => "Seřadit", + "Sort A to Z" => "Seřadit od A do Z", + "Sort Z to A" => "Seřadit od Z do A", + "Custom Sort…" => "Vlastní řazení…", + "Filter" => "Filtr", + "Reapply" => "Použít znovu", + "Get Data\n(Text/CSV)" => "Načíst data\n(text/CSV)", + "Remove\nDuplicates" => "Odebrat\nduplicity", + "Data\nValidation" => "Ověření\ndat", + "Data Validation" => "Ověření dat", + "Data Validation…" => "Ověření dat…", + "Circle Invalid Data" => "Zakroužkovat neplatná data", + "Clear Validation" => "Vymazat ověření", + "Group" => "Seskupit", + "Ungroup" => "Zrušit seskupení", + "Subtotal" => "Mezisoučet", + "What-If\nAnalysis" => "Citlivostní\nanalýza", + "Data Table…" => "Tabulka dat…", + "Scenario Manager…" => "Správce scénářů…", + "Goal Seek…" => "Hledat řešení…", + "Spelling" => "Pravopis", + "Check\nAccessibility" => "Zkontrolovat\npřístupnost", + "Threaded Comments" => "Komentáře ve vláknech", + "Show\nComments" => "Zobrazit\nkomentáře", + "Protect\nWorkbook" => "Zamknout\nsešit", + "Unprotect\nSheet" => "Odemknout\nlist", + "Protect Sheet…" => "Zamknout list…", + "Comments" => "Komentáře", + "Notes" => "Poznámky", + "Page Break\nPreview" => "Náhled\nkonců stránek", + "Page\nLayout" => "Rozložení\nstránky", + "Formula Bar" => "Řádek vzorců", + "Gridlines" => "Mřížka", + "Headings" => "Záhlaví", + "Zoom" => "Lupa", + "100%" => "100 %", + "Zoom to\nSelection" => "Přiblížit\nna výběr", + "Freeze\nPanes" => "Ukotvit\npříčky", + "Freeze Panes" => "Ukotvit příčky", + "Freeze Top Row" => "Ukotvit horní řádek", + "Freeze First Column" => "Ukotvit první sloupec", + "Unfreeze Panes" => "Uvolnit příčky", + "Dark Mode" => "Tmavý režim", + "Display theme" => "Vzhled", + "System" => "Podle systému", + "Light" => "Světlý", + "Dark" => "Tmavý", + "Interface language" => "Jazyk rozhraní", + "Command\nPalette" => "Paleta\npříkazů", + "Agent\nControl" => "Řízení\nagenty", + "Action\nJournal" => "Deník\nakcí", + "About\nGridCraft" => "O aplikaci\nGridCraft", + "Share" => "Sdílet", + "Add Chart\nElement" => "Přidat prvek\ngrafu", + "Change\nChart Type" => "Změnit\ntyp grafu", + "Switch\nRow/Column" => "Prohodit\nřádky/sloupce", + "Data Labels: None" => "Popisky dat: žádné", + "Data Labels: Show" => "Popisky dat: zobrazit", + "Legend: None" => "Legenda: žádná", + "Legend: Bottom" => "Legenda: dole", + "Legend: Top" => "Legenda: nahoře", + "Legend: Right" => "Legenda: vpravo", + "Gridlines: None" => "Mřížka: žádná", + "Gridlines: Show" => "Mřížka: zobrazit", + "Chart\nTitle" => "Název\ngrafu", + "Delete\nChart" => "Odstranit\ngraf", + "Convert\nto Range" => "Převést\nna rozsah", + "Table\nStyles" => "Styly\ntabulky", + "Top 10 Items" => "Prvních 10 položek", + "Top 10%" => "Prvních 10 %", + "Bottom 10 Items" => "Posledních 10 položek", + "Bottom 10%" => "Posledních 10 %", + "Above Average" => "Nad průměrem", + "Below Average" => "Pod průměrem", + "Clear Rules from Entire Sheet" => "Vymazat pravidla z celého listu", + "Clear Rules from Selected Cells" => "Vymazat pravidla z vybraných buněk", + "Constants" => "Konstanty", + "Data Bars" => "Datové pruhy", + "Color Scales" => "Barevné škály", + "Icon Sets" => "Sady ikon", + "Home" => "Domů", + "Draw" => "Kreslení", + "Page Layout" => "Rozložení stránky", + "Formulas" => "Vzorce", + "Data" => "Data", + "Review" => "Revize", + "View" => "Zobrazení", + "Automate" => "Automatizace", + "Table Design" => "Návrh tabulky", + "Chart Design" => "Návrh grafu", + "Ready" => "Připraveno", + "Enter" => "Zadávání", + "Edit" => "Úpravy", + "Point" => "Výběr odkazu", + "Calculate" => "Přepočítat", + "Select destination and press Enter or choose Paste" => "Vyberte cíl a stiskněte Enter nebo zvolte Vložit", + "General" => "Obecný", + "Number" => "Číslo", + "Currency" => "Měna", + "Accounting" => "Účetnický", + "Date" => "Datum", + "Time" => "Čas", + "Percentage" => "Procenta", + "Fraction" => "Zlomek", + "Scientific" => "Vědecký", + "Text" => "Text", + "Special" => "Speciální", + "Custom" => "Vlastní", + "Short Date" => "Krátké datum", + "Long Date" => "Dlouhé datum", + "More Number Formats…" => "Další formáty čísel…", + "Financial" => "Finanční", + "Logical" => "Logické", + "Date &\nTime" => "Datum a\nčas", + "Lookup &\nReference" => "Vyhledávání\na odkazy", + "Math &\nTrig" => "Matematika a\ntrigonometrie", + "More\nFunctions" => "Další\nfunkce", + "Sheet Options|View" => "Zobrazit", + "Print" => "Tisknout", + _ => return None, + }) +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 1225eec..99a9960 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -328,7 +328,7 @@ impl SheetApp { self.ui.language = l; Ok(json!({"language": l})) } - None => Err(format!("unknown language {code:?} (use \"en\", \"es\", \"zh\", \"ja\", \"ko\", \"ru\" or \"pt\")")), + None => Err(format!("unknown language {code:?} (use \"en\", \"es\", \"zh\", \"ja\", \"ko\", \"ru\", \"pt\" or \"cs\")")), } } "app.language.english" => { From f44b31e15443507e2a03d54cf741428d5f0d900c Mon Sep 17 00:00:00 2001 From: Losminek <301412321+Losminek@users.noreply.github.com> Date: Sun, 11 Oct 2026 13:36:33 +0200 Subject: [PATCH 65/83] i18n: cover Czech in the interface-language tests - The catalog-completeness test requires a non-empty Czech label for every key of the reference (Japanese) table. - Czech context keys: `Page Layout|Orientation` and `Orientation` get different labels; unknown text such as `Sheet1!A1` passes through. - `cs-CZ` / `cs_CZ.UTF-8` system tags and `parse("cs")` resolve to Czech. - The ribbon renders after switching to Czech, and the choice persists in UiState. - Czech keeps canonical formula entry (`=SUM(1.5,2.25)`), and `=SUMA(1;2)` is not accepted. - The UI font draws every Czech accented letter, upper and lower case, without tofu; Czech shares the default Han font order. Czech spells "Data" as English does, so the Czech checks probe "Formulas" -> "Vzorce", where an English fallback would be caught. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/i18n.rs | 10 ++++++++++ crates/ui-egui/src/theme.rs | 6 ++++-- crates/ui-egui/tests/formula_locale.rs | 16 ++++++++++++++++ crates/ui-egui/tests/interaction.rs | 7 +++++++ 4 files changed, 37 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 2ff8399..c55d9c8 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -440,6 +440,7 @@ mod tests { assert!(ko::translate(english).is_some_and(|text| !text.is_empty()), "missing Korean: {english}"); assert!(ru::translate(english).is_some_and(|text| !text.is_empty()), "missing Russian: {english}"); assert!(pt::translate(english).is_some_and(|text| !text.is_empty()), "missing Portuguese: {english}"); + assert!(cs::translate(english).is_some_and(|text| !text.is_empty()), "missing Czech: {english}"); } assert_eq!(Language::Ja.tr("Data"), "データ"); assert_eq!(Language::Zh.tr("Data"), "数据"); @@ -452,10 +453,16 @@ mod tests { assert_eq!(Language::Zh.tr("Sheet1!A1"), "Sheet1!A1"); assert_eq!(Language::Ko.tr("Sheet1!A1"), "Sheet1!A1"); assert_eq!(Language::Ru.tr("Sheet1!A1"), "Sheet1!A1"); + // Czech "Data" is spelled as in English, so probe it with a label that differs. + assert_eq!(Language::Cs.tr("Data"), "Data"); + assert_eq!(Language::Cs.tr("Formulas"), "Vzorce"); + assert_eq!(Language::Cs.tr("Sheet1!A1"), "Sheet1!A1"); // A `context|label` key shows only its label in English and splits the translation. assert_eq!(Language::En.tr("Page Layout|Orientation"), "Orientation"); assert_eq!(Language::Ja.tr("Page Layout|Orientation"), "印刷の向き"); assert_eq!(Language::Ja.tr("Orientation"), "方向"); + assert_eq!(Language::Cs.tr("Page Layout|Orientation"), "Orientace"); + assert_eq!(Language::Cs.tr("Orientation"), "Orientace textu"); assert_eq!(Language::parse("xx"), None); } @@ -486,6 +493,9 @@ mod tests { assert_eq!(Language::from_tag("pt-BR"), Some(Language::PtBr)); assert_eq!(Language::parse("pt"), Some(Language::PtBr)); assert_eq!(Language::parse("pt-br"), Some(Language::PtBr)); + assert_eq!(Language::from_tag("cs-CZ"), Some(Language::Cs)); + assert_eq!(Language::from_tag("cs_CZ.UTF-8"), Some(Language::Cs)); + assert_eq!(Language::parse("cs"), Some(Language::Cs)); } #[test] diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 219e2d2..25b9ca8 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -587,7 +587,7 @@ mod tests { assert_eq!(keys(HanOrder::Japanese), ["sys-cjk-han-ja", "sys-cjk-han-zh", "sys-cjk-hangul", "sys-thai", "sys-hebrew"]); assert_eq!(keys(HanOrder::Chinese), ["sys-cjk-han-zh", "sys-cjk-han-ja", "sys-cjk-hangul", "sys-thai", "sys-hebrew"]); assert_eq!(HanOrder::of(Language::Ja), HanOrder::Japanese); - for l in [Language::En, Language::Zh, Language::Ko, Language::Ru, Language::PtBr] { + for l in [Language::En, Language::Zh, Language::Ko, Language::Ru, Language::PtBr, Language::Cs] { assert_eq!(HanOrder::of(l), HanOrder::Chinese, "{l:?} shares the default order (no font rebuild)"); } } @@ -613,13 +613,15 @@ mod tests { // Real render check, per script: the proportional family (what cells and the UI use) must // resolve a sample string to a real glyph, not the tofu replacement char. Skips a script // whose face is absent (a minimal machine) and fails if a face is present but not wired in. - let cases: [(&str, Vec<(String, u32)>, &str); 6] = [ + let cases: [(&str, Vec<(String, u32)>, &str); 7] = [ ("sys-cjk-han-ja", cjk_han_japanese(), "カテゴリ"), ("sys-cjk-han-zh", cjk_han_simplified(), "页面布局 客户端配置"), ("sys-cjk-hangul", cjk_hangul(), "한글"), ("sys-thai", thai(), "ทดสอบ"), ("sys-hebrew", hebrew(), "עברית"), ("ui", candidates(UI), "Русский"), + // Czech interface labels: every Czech accented letter, in both cases. + ("ui", candidates(UI), "Příliš žluťoučký kůň úpěl ďábelské ódy ŘŠČŽÝÁÍÉÚŮĚŤĎŇÓ"), ]; for language in [Language::En, Language::Ja] { let ctx = egui::Context::default(); diff --git a/crates/ui-egui/tests/formula_locale.rs b/crates/ui-egui/tests/formula_locale.rs index a92ab13..6ce2208 100644 --- a/crates/ui-egui/tests/formula_locale.rs +++ b/crates/ui-egui/tests/formula_locale.rs @@ -185,3 +185,19 @@ fn portuguese_interface_retains_its_canonical_formula_fallback() { assert_eq!(restored.language, Language::PtBr); assert_eq!(Language::Es.tr("Page Layout|Orientation"), "Orientation"); } + +#[test] +fn czech_interface_retains_its_canonical_formula_fallback() { + // The Czech table translates interface labels only: formulas keep canonical English names, + // `,` separators and `.` decimals until a Czech formula locale exists. + let mut app = app(); + app.run("app.language.set", json!({"code":"cs-CZ"})).unwrap(); + assert_eq!(app.ui.language, Language::Cs); + assert_eq!(app.ui.language.tr("Formulas"), "Vzorce"); + assert!(enter(&mut app, "=SUM(1.5,2.25)")); + app.begin_edit(None, true); + assert_eq!(app.editor.as_ref().unwrap().text, "=SUM(1.5,2.25)"); + assert!(!enter(&mut app, "=SUMA(1;2)")); + let restored: UiState = serde_json::from_str(&serde_json::to_string(&app.ui).unwrap()).unwrap(); + assert_eq!(restored.language, Language::Cs); +} diff --git a/crates/ui-egui/tests/interaction.rs b/crates/ui-egui/tests/interaction.rs index 84f20a6..1b71c82 100644 --- a/crates/ui-egui/tests/interaction.rs +++ b/crates/ui-egui/tests/interaction.rs @@ -209,6 +209,13 @@ fn language_switch_translates_the_ribbon_and_persists() { let restored: gridcraft_ui_egui::UiState = serde_json::from_str(&serde_json::to_string(&h.state().ui).unwrap()).unwrap(); assert_eq!(restored.language, language); } + // Czech spells "Data" as English does, so the Czech table is checked on a label that differs. + h.state_mut().run("app.language.set", json!({"language": "cs"})).unwrap(); + h.run_steps(2); + assert_eq!(h.state().ui.language, Language::Cs); + assert_eq!(Language::Cs.tr("Formulas"), "Vzorce"); + let restored: gridcraft_ui_egui::UiState = serde_json::from_str(&serde_json::to_string(&h.state().ui).unwrap()).unwrap(); + assert_eq!(restored.language, Language::Cs); h.state_mut().run("app.language.set", json!({"language": "en"})).unwrap(); assert_eq!(h.state().ui.language, Language::En); } From 509afaf4cfef5e2b903e772ca12aaabe2e329855 Mon Sep 17 00:00:00 2001 From: Losminek <301412321+Losminek@users.noreply.github.com> Date: Sun, 11 Oct 2026 13:36:55 +0200 Subject: [PATCH 66/83] docs: list Czech (cs) in the localization status Czech joins Russian under "other shipped languages" in ROADMAP.md and docs/localization-parity.md: 266 ribbon labels (~12%), menus only, formulas still en-US. Timestamp lines and revision history updated. Kept separate from the catalog so it can be dropped if the docs are refreshed upstream. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 5 +++-- docs/localization-parity.md | 8 +++++--- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 1189fd7..8817691 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~25 points of "ready for real work" and ~160–240 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (full re-measure against Excel for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (Czech ribbon table listed under other shipped languages) · **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. @@ -110,7 +110,7 @@ is in PR #163. Detail: [localization-parity.md](docs/localization-parity.md). | Korean | ko | menus only | ~12% | | Vietnamese | vi | none | 0% | -Other shipped languages: 1 (Russian, menus only, ~12%). +Other shipped languages: 2 (Russian and Czech, menus only, ~12% each). ## Upcoming @@ -142,6 +142,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | Czech (cs) listed under other shipped languages: ribbon only, ~12% | | 2026-10-10 | minor | Readiness-by-audience table with hours to ~95% per audience; full number unchanged (additive weighted sum) | | 2026-10-10 | minor | Added Mainstream practitioner (~50%) and Essentials user (~63%); languages updated for the 10-10 merges (ribbon in ja, zh, ko, pt-BR, ru); localization 5%→10%; full number re-checked (51.6, unchanged) | | 2026-10-10 | minor | Checked against the alpha gate (six core workflows pass): stays alpha | diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 3a8a991..17a5efc 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -1,6 +1,6 @@ # Localization parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (re-measured after the 10-10 language and font-fallback merges) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (Czech ribbon table added) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Per-language status against Excel, which ships its UI in dozens of languages (Windows language packs cover 100+), with localized function names, argument separators, number and date formats, @@ -12,7 +12,7 @@ right-to-left sheets and full complex-script support. Part of - **A first catalog exists, for the ribbon only.** `crates/ui-egui/src/i18n.rs` (merged 10-10 in #31, #104, #82) adds an interface-language preference that defaults to the system language (closes #20) and a View-tab switcher, with English-keyed lookup tables: Japanese 268 labels, - Simplified Chinese, Korean, Russian and Brazilian Portuguese 266 each. The tables are used by + Simplified Chinese, Korean, Russian, Brazilian Portuguese and Czech 266 each. The tables are used by the ribbon, sheet tabs and widgets (`ribbon.rs`, `tabs.rs`, `widgets.rs`); dialogs, menus, context menus, task panes, status messages, command labels and function descriptions are still English literals. Untranslated labels fall back to English. @@ -51,7 +51,8 @@ separators, number/date formats, machine translation with agent review, and scri | Korean | ko | 266 (~12%) | no | Hangul via the CJK fallback; IME unverified | no | menus only | 6–10 h | | Vietnamese | vi | 0 (0%) | no | Latin with stacked diacritics: unverified | no | none | 6–10 h | -Other shipped languages: Russian (ru), 266 labels (~12%), menus only. +Other shipped languages: Russian (ru) and Czech (cs), 266 labels each (~12%), menus only. Czech +formulas stay en-US (no Czech function names, `;` separator or decimal comma yet). **Total:** catalog work 10–20 h + languages 92–144 h ≈ **100–165 h**. Parallelizes almost fully once the catalog covers dialogs and commands. **Needs a human:** native-speaker review of every @@ -73,5 +74,6 @@ language (none so far) and of localized function names. | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | Czech (cs) ribbon table: 266 labels (~12%), menus only | | 2026-10-10 | minor | Re-measured after #31, #104, #82, #38 merged: ribbon translated into ja, zh, ko, pt-BR and ru (266–268 labels, ~12%), system-language default, CJK/Thai/Hebrew font fallback; ~10% ready, 100–165 h | | 2026-10-10 | major | Created: English only, no catalog, ~2,200 strings, script-support gaps, open language PRs | From 5f346dc3ccdc5682627a8b7609628bd8e25e3cca Mon Sep 17 00:00:00 2001 From: Losminek <301412321+Losminek@users.noreply.github.com> Date: Sun, 11 Oct 2026 13:47:02 +0200 Subject: [PATCH 67/83] i18n (cs): terminology review fixes An independent review of the table found two meaning errors: "Enter" is both the status-bar mode and the tooltip of the confirm button next to the formula bar, so "Zadat" fits both places better than "Zadavani"; "Convert to Range" uses "oblast" (as "Oblast tisku" does), not "rozsah". Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/i18n/cs.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index 1f54793..5b56b20 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -215,7 +215,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Gridlines: Show" => "Mřížka: zobrazit", "Chart\nTitle" => "Název\ngrafu", "Delete\nChart" => "Odstranit\ngraf", - "Convert\nto Range" => "Převést\nna rozsah", + "Convert\nto Range" => "Převést\nna oblast", "Table\nStyles" => "Styly\ntabulky", "Top 10 Items" => "Prvních 10 položek", "Top 10%" => "Prvních 10 %", @@ -240,7 +240,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Table Design" => "Návrh tabulky", "Chart Design" => "Návrh grafu", "Ready" => "Připraveno", - "Enter" => "Zadávání", + "Enter" => "Zadat", "Edit" => "Úpravy", "Point" => "Výběr odkazu", "Calculate" => "Přepočítat", From a9e743ee6d667be60a326a7b099ad826eda5b7af Mon Sep 17 00:00:00 2001 From: Losminek <301412321+Losminek@users.noreply.github.com> Date: Sun, 11 Oct 2026 13:47:13 +0200 Subject: [PATCH 68/83] contributors: add my own people.toml entry Own entry only, as AGENTS.md describes: credited by the name on my public GitHub profile (sync_github_name), otherwise by @username. Co-Authored-By: Claude Opus 5.5 --- contributors/people.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/contributors/people.toml b/contributors/people.toml index 78683ae..8086cf1 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -28,3 +28,6 @@ sync_github_name = false [people.edurbs] sync_github_name = true + +[people.Losminek] +sync_github_name = true From 206d83763916f444971c2ddca7a5a19e9e5e5946 Mon Sep 17 00:00:00 2001 From: lloyd Date: Sun, 11 Oct 2026 16:48:43 +0200 Subject: [PATCH 69/83] Grid: header drags resize every Ctrl-selected row or column (#341) Dragging a header boundary inside a selection of whole columns (rows) sized only the current area, so with B, D and F Ctrl-selected only the last-selected one changed. The drag now targets every whole-column (whole-row) area of the selection, as Excel does; lines outside the selection, and partial-cell areas, keep their size. home.columnWidth / home.rowHeight accept a comma-separated list of areas ("B:B,D:F") so the drag stays one command and one undo step. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/format.rs | 14 +++++++--- crates/engine/src/tests.rs | 24 ++++++++++++++++ crates/ui-egui/src/grid.rs | 11 ++++++-- crates/ui-egui/tests/header_autofit.rs | 38 ++++++++++++++++++++++++++ 4 files changed, 81 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/cmd/format.rs b/crates/engine/src/cmd/format.rs index 4f13bf9..2b6e50b 100644 --- a/crates/engine/src/cmd/format.rs +++ b/crates/engine/src/cmd/format.rs @@ -156,7 +156,7 @@ pub fn specs() -> Vec { "Row Height…", ["Home", "Cells", "Format"], None, - "{rows?: \"2:5\", height: 20 (points on screen)}", + "{rows?: \"2:5\" | \"2:2,4:5\", height: 20 (points on screen)}", has_doc, row_height ), @@ -165,7 +165,7 @@ pub fn specs() -> Vec { "Column Width…", ["Home", "Cells", "Format"], None, - "{cols?: \"B:D\", width: 64 (points) | chars: 8.43}", + "{cols?: \"B:D\" | \"B:B,D:F\", width: 64 (points) | chars: 8.43}", has_doc, col_width ), @@ -910,8 +910,14 @@ fn cell_style(s: &mut Session, p: &Json) -> Result { fn line_targets(s: &Session, p: &Json, rows: bool) -> Result> { let key = if rows { "rows" } else { "cols" }; if let Some(t) = str_param(p, key) { - let r = RangeRef::parse(t).ok_or_else(|| bad(key, format!("not a range: {t}")))?; - return Ok(vec![if rows { (r.start.row, r.end.row) } else { (r.start.col, r.end.col) }]); + // A comma-separated list ("B:B,D:F") targets several areas, like a multi-area selection. + return t + .split(',') + .map(|part| { + let r = RangeRef::parse(part).ok_or_else(|| bad(key, format!("not a range: {part}")))?; + Ok(if rows { (r.start.row, r.end.row) } else { (r.start.col, r.end.col) }) + }) + .collect(); } Ok(s.doc()?.selection.ranges.iter().map(|r| if rows { (r.start.row, r.end.row) } else { (r.start.col, r.end.col) }).collect()) } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 6bd03d5..3c08ee0 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1633,3 +1633,27 @@ fn notes_comments_and_links_survive_a_json_save() { assert_eq!(after.hyperlinks, before.hyperlinks); assert_eq!(after.cells, before.cells); } + +#[test] +fn row_height_and_column_width_take_a_list_of_areas() { + // Issue #341: header drags on a multi-area selection size every selected line in one step. + let mut s = s(); + s.execute("home.columnWidth", json!({"cols": "B:B,D:E", "width": 120})).unwrap(); + s.execute("home.rowHeight", json!({"rows": "2:2,4:5", "height": 40})).unwrap(); + let widths: Vec = (0..6).map(|c| s.doc().unwrap().wb.active().unwrap().col_width(c)).collect(); + let heights: Vec = (0..6).map(|r| s.doc().unwrap().wb.active().unwrap().row_height(r)).collect(); + for c in [1, 3, 4] { + assert_eq!(widths[c], 120.0, "column {c}"); + } + for c in [0, 2, 5] { + assert_ne!(widths[c], 120.0, "column {c}"); + } + for r in [1, 3, 4] { + assert_eq!(heights[r], 40.0, "row {r}"); + } + for r in [0, 2, 5] { + assert_ne!(heights[r], 40.0, "row {r}"); + } + assert!(s.execute("home.columnWidth", json!({"cols": "B:B,nope", "width": 50})).is_err()); + assert_eq!(s.doc().unwrap().wb.active().unwrap().col_width(1), 120.0, "a bad list changes nothing"); +} diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 72d33e2..d228e51 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -1359,8 +1359,10 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & let w = (orig + (p.x - start) / geo.z).max(0.0); app.toast = Some((format!("Width: {:.2} ({} pixels)", gridcraft_engine::cmd::format::points_to_chars(w as f64), w.round()), crate::now_ms())); + // Inside a selection of whole columns, every selected column takes the new width + // (Ctrl-selected areas included); otherwise only the dragged one. let cols = if sel.ranges.iter().any(|r| r.is_full_cols() && col >= r.start.col && col <= r.end.col) { - sel.current().a1() + sel.ranges.iter().filter(|r| r.is_full_cols()).map(|r| r.a1()).collect::>().join(",") } else { RangeRef::cols(col, col).a1() }; @@ -1371,7 +1373,12 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & let h = (orig + (p.y - start) / geo.z).max(0.0); app.toast = Some((format!("Height: {:.2} ({} pixels)", h * 0.75, h.round()), crate::now_ms())); let rows = if sel.ranges.iter().any(|r| r.is_full_rows() && row >= r.start.row && row <= r.end.row) { - format!("{}:{}", sel.current().start.row + 1, sel.current().end.row + 1) + sel.ranges + .iter() + .filter(|r| r.is_full_rows()) + .map(|r| format!("{}:{}", r.start.row + 1, r.end.row + 1)) + .collect::>() + .join(",") } else { format!("{}:{}", row + 1, row + 1) }; diff --git a/crates/ui-egui/tests/header_autofit.rs b/crates/ui-egui/tests/header_autofit.rs index 8a66738..7d1bbbc 100644 --- a/crates/ui-egui/tests/header_autofit.rs +++ b/crates/ui-egui/tests/header_autofit.rs @@ -114,3 +114,41 @@ fn dragging_a_header_boundary_still_resizes_normally() { assert!(!h.state().session.journal.iter().any(|(id, _)| id.starts_with("home.autofit"))); } } + +#[test] +fn dragging_a_boundary_inside_a_multi_area_selection_resizes_every_selected_line() { + // Issue #341: Ctrl-selected rows/columns resize together; unselected lines keep their size. + for (rows, selection, dragged, changed) in [ + (false, "A:A,C:C", 2, [true, false, true, false]), + (false, "A:A,C:C", 0, [true, false, true, false]), + (false, "A:A,C:D", 3, [true, false, true, true]), + (false, "A:A,C:C", 1, [false, true, false, false]), + (false, "A:A,C3", 0, [true, false, false, false]), + (true, "1:1,3:3", 2, [true, false, true, false]), + (true, "1:1,3:3", 0, [true, false, true, false]), + (true, "1:1,3:4", 3, [true, false, true, true]), + (true, "1:1,3:3", 1, [false, true, false, false]), + (true, "1:1,C3", 0, [true, false, false, false]), + ] { + let mut h = harness(selection); + let before = sizes(&h, rows); + let (start, zoom) = edge(&h, rows, dragged); + let delta = if rows { vec2(0.0, 30.0) } else { vec2(30.0, 0.0) }; + button(&mut h, start, true); + h.input_mut().events.push(Event::PointerMoved(start + delta / 2.0)); + h.step(); + h.input_mut().events.push(Event::PointerMoved(start + delta)); + h.step(); + button(&mut h, start + delta, false); + h.run_steps(2); + let after = sizes(&h, rows); + let expected = (before[dragged as usize] + 30.0 / zoom).round(); + for (index, changed) in changed.iter().enumerate() { + let want = if *changed { expected } else { before[index] }; + assert_eq!(after[index], want, "selection {selection}, dragged {dragged}, line {index}"); + } + assert_eq!(h.state().session.active().unwrap().undo.len(), 1, "selection {selection}: one undo step"); + h.state_mut().session.execute("edit.undo", json!({})).unwrap(); + assert_eq!(sizes(&h, rows), before, "selection {selection}: undo restores every line"); + } +} From f7f7769460e6589a062aa4c930fa4b8db4534a2b Mon Sep 17 00:00:00 2001 From: Sasha Syrotenko Date: Sun, 11 Oct 2026 17:50:23 +0300 Subject: [PATCH 70/83] Fold 'SUM', 'COUNT' and 'AVERAGE' over ranges in place --- crates/calc/src/eval.rs | 123 ++++++++++++++++++++++++++ crates/calc/src/recalc.rs | 65 +++++++++++++- crates/calc/src/tests.rs | 182 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 368 insertions(+), 2 deletions(-) diff --git a/crates/calc/src/eval.rs b/crates/calc/src/eval.rs index eff659d..bf5c971 100644 --- a/crates/calc/src/eval.rs +++ b/crates/calc/src/eval.rs @@ -71,6 +71,11 @@ pub trait Host { fn used_range(&mut self, sheet: usize) -> Option { self.workbook().sheet(sheet).and_then(|s| s.used_range()) } + /// Calls `f` on each stored value of a block, row-major (formula cells evaluated first), + /// until `f` returns false. `false` when this host can't (callers use `range_values`). + fn visit_range(&mut self, _sheet: usize, _range: RangeRef, _f: &mut dyn FnMut(&Value) -> bool) -> bool { + false + } } pub struct Evaluator<'h> { @@ -726,6 +731,9 @@ impl<'h> Evaluator<'h> { if args.len() < spec.min_args || args.len() > spec.max_args { return err(CellError::Value); } + if let Some(kind) = Agg::of(spec.name) { + return self.aggregate(kind, args); + } let mut evaluated = Vec::with_capacity(args.len()); // SUMIF/AVERAGEIF: the size of the criteria range (argument 1), which the sum range takes. let mut criteria_shape = None; @@ -762,6 +770,70 @@ impl<'h> Evaluator<'h> { gridcraft_functions::call(spec, &evaluated, &mut ctx).pipe(Ev::V) } + /// Performs aggregation operations without copying its ranges: blocks are folded over the stored + /// cells, so no array is built and there is no `MAX_CELLS` limit. Arguments are evaluated and read one + /// after another, as the array path does, so volatile formulas (like `RAND`) see the same order. + /// Only `SUM`, `COUNT` and `AVG` are supported currently. + fn aggregate(&mut self, kind: Agg, args: &[Expr]) -> Ev { + let mut acc = Acc::default(); + let mut first_error = None; + for a in args { + let ev = self.eval(a); + let steps = match ev { + Ev::R(areas) => areas.iter().map(|area| self.aggregate_area(kind, area, &mut acc)).collect(), + ev => { + let value = self.deref(ev); + vec![self.aggregate_value(kind, value, false, &mut acc)] + } + }; + if first_error.is_none() { + first_error = steps.into_iter().find_map(Result::err); + } + } + first_error.unwrap_or_else(|| Ev::V(acc.finish(kind))) + } + + /// Adds one area to `acc`; `Err` is the result the function ends with. + fn aggregate_area(&mut self, kind: Agg, a: &Area, acc: &mut Acc) -> Result<(), Ev> { + if !a.range.is_single() { + let mut error = None; + let walked = self.host.visit_range(a.sheet, a.range, &mut |v| match acc.add_cell(kind, v) { + Ok(()) => true, + Err(e) => { + error = Some(e); + false + } + }); + if walked { + return error.map_or(Ok(()), |e| Err(err(e))); + } + } + let value = self.area_value(a); + self.aggregate_value(kind, value, true, acc) + } + + /// Adds one argument's value by calling `SUM` and `COUNT` on it, so coercion and error rules + /// stay in one place (`AVERAGE` of an argument is its `SUM` over its `COUNT`). + fn aggregate_value(&mut self, kind: Agg, value: Value, from_ref: bool, acc: &mut Acc) -> Result<(), Ev> { + let (Some(sum), Some(count)) = (gridcraft_functions::lookup("SUM"), gridcraft_functions::lookup("COUNT")) else { + return Err(err(CellError::Name)); + }; + let args = [Arg { value, from_ref }]; + if kind != Agg::Count { + match gridcraft_functions::call(sum, &args, &mut FnCtx { ev: self }) { + Value::Number(n) => acc.sum += n, + other => return Err(Ev::V(other)), + } + } + if kind != Agg::Sum { + match gridcraft_functions::call(count, &args, &mut FnCtx { ev: self }) { + Value::Number(n) => acc.count = acc.count.saturating_add(n as u64), + other => return Err(Ev::V(other)), + } + } + Ok(()) + } + pub fn apply_lambda(&mut self, l: &Lambda, args: &[Expr]) -> Ev { if args.len() > l.params.len() { return err(CellError::Value); @@ -1239,6 +1311,57 @@ pub fn r1c1_to_a1(s: &str, at: CellRef) -> Option { Some(format!("{sheet}{out}")) } +/// The aggregates that can fold a reference without building an array. +#[derive(Clone, Copy, PartialEq, Eq)] +enum Agg { + Sum, + Count, + Average, +} + +impl Agg { + fn of(name: &str) -> Option { + match name { + "SUM" => Some(Agg::Sum), + "COUNT" => Some(Agg::Count), + "AVERAGE" => Some(Agg::Average), + _ => None, + } + } +} + +/// What a folded aggregate carries from one value to the next. +#[derive(Default)] +struct Acc { + sum: f64, + count: u64, +} + +impl Acc { + /// Takes a value found inside a reference: only numbers count. `Err` is an error value that + /// ends `SUM` and `AVERAGE`; `COUNT` skips errors. + fn add_cell(&mut self, kind: Agg, v: &Value) -> Result<(), CellError> { + match v { + Value::Number(n) => { + self.sum += n; + self.count = self.count.saturating_add(1); + Ok(()) + } + Value::Error(e) if kind != Agg::Count => Err(*e), + _ => Ok(()), + } + } + + fn finish(&self, kind: Agg) -> Value { + match kind { + Agg::Sum => Value::number(self.sum), + Agg::Count => Value::number(self.count as f64), + Agg::Average if self.count == 0 => Value::Error(CellError::Div0), + Agg::Average => Value::number(self.sum / self.count as f64), + } + } +} + // ---------------------------------------------------------------- operators fn map_unary(v: &Value, f: &dyn Fn(f64) -> Value, plus: bool) -> Value { diff --git a/crates/calc/src/recalc.rs b/crates/calc/src/recalc.rs index 4cf0c6a..74c406b 100644 --- a/crates/calc/src/recalc.rs +++ b/crates/calc/src/recalc.rs @@ -598,6 +598,30 @@ impl Host for PassHost<'_> { } out } + fn visit_range(&mut self, sheet: usize, range: RangeRef, f: &mut dyn FnMut(&Value) -> bool) -> bool { + let wb = self.wb; + let Some(sh) = wb.sheet(sheet) else { return false }; + // Formula cells first, in row-major order like `range_values`: reading one calculates it + // and the pass remembers the value, so the walk below gets the same value again without + // this method holding any. + for (c, cell) in sh.cells.iter_range(range) { + if cell.formula.is_some() { + let _ = self.cell_value(sheet, c); + } + } + // Spilled values sit between the stored cells (and the formulas just calculated may have + // produced some): leave such blocks to `range_values`. + if self.spills.keys().any(|a| a.0 == sheet) || sh.spill.range(range.start..=range.end).any(|(c, _)| range.contains(*c)) { + return false; + } + for (c, cell) in sh.cells.iter_range(range) { + let go = if cell.formula.is_some() { f(&self.cell_value(sheet, c)) } else { f(&cell.value) }; + if !go { + break; + } + } + true + } fn used_range(&mut self, sheet: usize) -> Option { let wb = self.wb; *self.used.entry(sheet).or_insert_with(|| wb.sheet(sheet).and_then(|s| s.used_range())) @@ -974,13 +998,13 @@ fn prof_now() -> f64 { } #[cfg(test)] -mod tests { +pub(crate) mod tests { use std::time::{Duration, Instant}; use gridcraft_core::{CellRef, RangeRef, Value}; use gridcraft_model::{Cell, Formula, Workbook}; - use super::{Calc, Intervals, Key}; + use super::{Calc, HashMap, HashSet, Host, Intervals, Key, PassHost}; /// xorshift64 for repeatable pseudo-random cases. fn rng(state: &mut u64) -> u64 { @@ -1181,4 +1205,41 @@ mod tests { calc.graph.dependents(1, CellRef::parse("A2").unwrap(), &mut deps); assert_eq!(deps, vec![at("DA9"), at("DA9")]); } + + /// For tests of `PassHost::visit_range`, which can't be reached from outside this module: the + /// values it hands over for `range` while the formulas in `pending` (cell, formula) have not been + /// calculated yet, or `None` when it declines the block. + pub(crate) fn probe_visit_range(wb: &Workbook, pending: &[(&str, &str)], range: RangeRef) -> Option> { + let mut exprs = HashMap::default(); + for (at, formula) in pending { + let expr = gridcraft_formula::parse(formula.strip_prefix('=').unwrap_or(formula)).ok()?; + exprs.insert((0, CellRef::parse(at)?), std::sync::Arc::new(expr)); + } + + // A cell in `pending` is calculated the first time something reads it, which is the state + // of a real pass partway through; `visit_range` has to cope with the formulas it reads + // being calculated under it. + let mut rng = 1u64; + let mut host = PassHost { + wb, + pending: exprs.keys().copied().collect(), + exprs: &exprs, + results: HashMap::default(), + in_progress: HashSet::default(), + spills: HashMap::default(), + used: HashMap::default(), + rng: &mut rng, + now: 0.0, + cycle: false, + cycles: vec![], + depth: 0, + }; + + let mut seen = Vec::new(); + host.visit_range(0, range, &mut |v| { + seen.push(v.clone()); + true + }) + .then_some(seen) + } } diff --git a/crates/calc/src/tests.rs b/crates/calc/src/tests.rs index d1e8576..5fb6410 100644 --- a/crates/calc/src/tests.rs +++ b/crates/calc/src/tests.rs @@ -467,3 +467,185 @@ fn sumif_resized_sum_range_on_another_sheet_and_through_a_name() { assert_eq!(t.num("C1"), 10.0); assert_eq!(t.num("C2"), 5.0); } + +/// `formula` in AB1 over a small mixed sheet. The ranges in the cases below are cut from it: +/// +/// ```text +/// A B C E +/// 1 1.5 10 =A1*4 (C1 is a formula cell inside the ranges) +/// 2 2 TRUE #DIV/0! (E2 only when `error` is set) +/// 3 text 3 +/// 4 -0.25 +/// ``` +fn aggregate_of(error: bool, formula: &str) -> Value { + let mut t = T::new(); + for (at, v) in [("A1", "1.5"), ("A2", "2"), ("A3", "skipped"), ("A4", "-0.25"), ("B1", "10"), ("B2", "TRUE"), ("B3", "3"), ("C1", "=A1*4")] { + t.set(at, v); + } + if error { + t.set("E2", "#DIV/0!"); + } + + // AB1 is outside every range used, so the formula can't read itself. + t.set("AB1", formula); + t.get("AB1") +} + +#[test] +fn sum_count_average_over_ranges() { + // Text, booleans, blanks and a formula cell inside ranges; direct arguments; unions; whole + // columns; blocks holding an error or nothing numeric. The expected values are what the + // array path (the ranges copied into an array) gave before the ranges were folded in place, + // so any difference is a change of behavior. + let n = Value::Number; + let e = Value::Error; + + // (formula written with SUM, error in E2?, [SUM, COUNT, AVERAGE] of that formula) + #[rustfmt::skip] + let cases: &[(&str, bool, [Value; 3])] = &[ + ("=SUM(A1:A1,B3)", false, [n(4.5), n(2.0), n(2.25)]), + ("=SUM(A1:A1,B3)", true, [n(4.5), n(2.0), n(2.25)]), + ("=SUM((A1:A2,B1:B3))", false, [n(16.5), n(4.0), n(4.125)]), + ("=SUM((A1:A2,B1:B3))", true, [n(16.5), n(4.0), n(4.125)]), + ("=SUM(A1:A4,B1:B3)", false, [n(16.25), n(5.0), n(3.25)]), + ("=SUM(A1:A4,B1:B3)", true, [n(16.25), n(5.0), n(3.25)]), + ("=SUM(A1:A4,\"x\")", false, [e(CellError::Value), n(3.0), e(CellError::Value)]), + ("=SUM(A1:A4,\"x\")", true, [e(CellError::Value), n(3.0), e(CellError::Value)]), + ("=SUM(A1:B4,5,\"7\",TRUE)", false, [n(29.25), n(8.0), n(3.65625)]), + ("=SUM(A1:B4,5,\"7\",TRUE)", true, [n(29.25), n(8.0), n(3.65625)]), + ("=SUM(A1:C4)+SUM(B1:B3)", false, [n(35.25), n(8.0), n(10.208333333333334)]), + ("=SUM(A1:C4)+SUM(B1:B3)", true, [n(35.25), n(8.0), n(10.208333333333334)]), + ("=SUM(A1:C4)", false, [n(22.25), n(6.0), n(3.7083333333333335)]), + ("=SUM(A1:C4)", true, [n(22.25), n(6.0), n(3.7083333333333335)]), + ("=SUM(A1:E4)", false, [n(22.25), n(6.0), n(3.7083333333333335)]), + ("=SUM(A1:E4)", true, [e(CellError::Div0), n(6.0), e(CellError::Div0)]), + ("=SUM(A1)", false, [n(1.5), n(1.0), n(1.5)]), + ("=SUM(A1)", true, [n(1.5), n(1.0), n(1.5)]), + ("=SUM(A3,B2)", false, [n(0.0), n(0.0), e(CellError::Div0)]), + ("=SUM(A3,B2)", true, [n(0.0), n(0.0), e(CellError::Div0)]), + ("=SUM(A:C)", false, [n(22.25), n(6.0), n(3.7083333333333335)]), + ("=SUM(A:C)", true, [n(22.25), n(6.0), n(3.7083333333333335)]), + ("=SUM(E1:E3)", false, [n(0.0), n(0.0), e(CellError::Div0)]), + ("=SUM(E1:E3)", true, [e(CellError::Div0), n(0.0), e(CellError::Div0)]), + ("=SUM(Z1:Z9)", false, [n(0.0), n(0.0), e(CellError::Div0)]), + ("=SUM(Z1:Z9)", true, [n(0.0), n(0.0), e(CellError::Div0)]), + ]; + + for (formula, error, want) in cases { + for (func, want) in ["SUM", "COUNT", "AVERAGE"].into_iter().zip(want) { + // The same formula for each function: `SUM(` becomes `COUNT(` or `AVERAGE(`. + let f = formula.replace("SUM", func); + + assert_eq!(&aggregate_of(*error, &f), want, "{f} (error in E2: {error})"); + } + } +} + +#[test] +fn sum_count_average_have_no_size_limit() { + // One stored cell far down stretches the used range to 27 million cells (27 columns by a + // million rows), past the 16M-cell limit of ranges copied into an array. Only two cells are + // stored, so the sheet itself is tiny. + for (func, want) in [("SUM", Value::Number(2.5)), ("COUNT", Value::Number(2.0)), ("AVERAGE", Value::Number(1.25))] { + for range in ["A1:AA1000000", "A:AA"] { + let mut t = T::new(); + t.set("A1", "1.5"); + t.set("Z1000000", "1"); + + t.set("AB1", &format!("={func}({range})")); + + assert_eq!(t.get("AB1"), want, "{func}({range})"); + } + } +} + +#[test] +fn sum_count_average_over_spilled_values() { + // A1 spills over A1:A3, so the block holds values that exist only as a spill (they have no + // stored cell of their own). The host must not fold such a block in place: it falls back + // to the array path, which sees the spill. Expected: 1 + 2 + 3 + 10. + let mut t = T::new(); + t.set("A1", "=SEQUENCE(3)"); + t.set("A4", "10"); + + for (f, want) in [("SUM", 16.0), ("COUNT", 4.0), ("AVERAGE", 4.0)] { + t.set("C1", &format!("={f}(A1:A5)")); + + assert_eq!(t.num("C1"), want, "{f}"); + } +} + +#[test] +fn aggregate_arguments_are_read_in_order() { + // Volatile cells draw a random number each time they are calculated, so the order in which + // the arguments are read decides which draw each one gets. `B1:B2` comes first, so B2 must + // draw before the `RAND()` argument does. (Reading the plain `RAND()` first once gave + // 7.9043 here.) The expected values are what the array path gave; they depend on the random + // generator and its seed, and must be updated if either changes. + let mut t = T::new(); + t.set("B1", "1"); + t.set("B2", "=RAND()*10"); + + t.set("A1", "=SUM(B1:B2,RAND())"); + + assert_eq!((t.num("A1"), t.num("B2")), (4.968520165759784, 3.3112028100185356)); +} + +#[test] +fn sum_count_average_follow_edits_inside_the_range() { + // A range read in place must still count as a precedent of the formula: if it didn't, an + // edit inside it would leave the formula showing a stale value. Each step below changes one + // cell inside the range and checks all three functions; the last one changes a cell outside. + for range in ["A1:A5", "A:A"] { + let mut t = T::new(); + t.set("A1", "1"); + t.set("A3", "3"); + t.set("C1", &format!("=SUM({range})")); + t.set("C2", &format!("=COUNT({range})")); + t.set("C3", &format!("=AVERAGE({range})")); + let all = |t: &T| (t.num("C1"), t.num("C2"), t.num("C3")); + assert_eq!(all(&t), (4.0, 2.0, 2.0), "{range}"); + + t.set("A1", "5"); + assert_eq!(all(&t), (8.0, 2.0, 4.0), "{range}: edit"); + + t.set("A2", "10"); + assert_eq!(all(&t), (18.0, 3.0, 6.0), "{range}: fill a blank"); + + t.set("A3", "text"); + assert_eq!(all(&t), (15.0, 2.0, 7.5), "{range}: number to text"); + + t.set("A2", ""); + assert_eq!(all(&t), (5.0, 1.0, 5.0), "{range}: clear"); + + t.set("A5", "=A1*2"); + assert_eq!(all(&t), (15.0, 2.0, 7.5), "{range}: formula in the range"); + + t.set("A1", "1"); + assert_eq!(all(&t), (3.0, 2.0, 1.5), "{range}: its precedent changes"); + + // A7 is inside the whole column A:A but outside A1:A5. + t.set("A7", "100"); + let outside = if range == "A:A" { (103.0, 3.0, 103.0 / 3.0) } else { (3.0, 2.0, 1.5) }; + assert_eq!(all(&t), outside, "{range}: a cell outside the range"); + } +} + +#[test] +fn visit_range_declines_when_a_formula_in_the_block_spills_into_it() { + // The sheet: D1 = SEQUENCE(3) and D4 = 10, and D1 has not been calculated yet when the + // block D1:D5 is read. + let mut wb = Workbook::new(); + let sh = wb.sheet_mut(0).unwrap(); + sh.cells.set(c("D1"), Cell::formula(Formula::new("=SEQUENCE(3)"))); + sh.cells.set(c("D4"), Cell::value(Value::Number(10.0))); + let block = gridcraft_core::RangeRef::parse("D1:D5").unwrap(); + + // Reading the block calculates D1, which spills over D2:D3. A walk over the stored cells + // would never see 2 and 3, so the host must decline and leave the block to `range_values`. + // (`Calc` recalculates a pass that produced new spills, so final results are right either + // way; only this direct call shows the host's contract.) + let walked = crate::recalc::tests::probe_visit_range(&wb, &[("D1", "=SEQUENCE(3)")], block); + + assert_eq!(walked, None, "walked a block holding a spill it can't see"); +} From a8eb09604d44ef516f40293f0be19f2a029257cf Mon Sep 17 00:00:00 2001 From: lloyd Date: Sun, 11 Oct 2026 16:55:13 +0200 Subject: [PATCH 71/83] Review: mask the Protect / Unprotect Sheet password (#347) The password fields in Protect Sheet and Unprotect Sheet were plain text fields, so the password was readable on screen and in the accessibility tree. A new Password form field masks it as typed and adds a "Show password" check box that reveals it on purpose (off each time the dialog opens). These are the only password fields in the UI: Protect Workbook runs without a dialog and encrypted files are refused on open. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs.rs | 20 ++++++- crates/ui-egui/tests/protect_password.rs | 70 ++++++++++++++++++++++++ 2 files changed, 88 insertions(+), 2 deletions(-) create mode 100644 crates/ui-egui/tests/protect_password.rs diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 7dd3acb..3c75d1a 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -13,6 +13,7 @@ use crate::theme; #[derive(Clone, Debug, PartialEq)] pub enum Field { Text { key: &'static str, label: &'static str }, + Password { key: &'static str, label: &'static str }, Number { key: &'static str, label: &'static str }, Choice { key: &'static str, label: &'static str, options: Vec<(&'static str, &'static str)> }, Check { key: &'static str, label: &'static str }, @@ -31,6 +32,8 @@ pub struct Dialog { pub search: String, pub list_index: usize, pub result: Option, + /// "Show password" is ticked: password fields show their text. + pub show_password: bool, picture_inbox: Option, picture_target: Option<(u64, usize)>, } @@ -51,6 +54,7 @@ impl Dialog { search: String::new(), list_index: 0, result: None, + show_password: false, picture_inbox: None, picture_target: None, } @@ -67,6 +71,7 @@ impl Dialog { search: String::new(), list_index: 0, result: None, + show_password: false, picture_inbox: None, picture_target: None, } @@ -151,7 +156,7 @@ impl Dialog { "Protect Sheet", "review.protectSheet", vec![ - Text { key: "password", label: "Password (optional):" }, + Password { key: "password", label: "Password (optional):" }, Note("Allow all users of this sheet to:".into()), Check { key: "formatCells", label: "Format cells" }, Check { key: "formatColumns", label: "Format columns" }, @@ -167,7 +172,7 @@ impl Dialog { "unprotectSheet", "Unprotect Sheet", "review.unprotectSheet", - vec![Text { key: "password", label: "Password:" }], + vec![Password { key: "password", label: "Password:" }], json!({}), ), "unhideSheet" => { @@ -989,6 +994,17 @@ fn form(ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) } }); } + Field::Password { key, label } => { + // Masked as typed (Excel shows dots); "Show password" reveals it on purpose. + ui.vertical(|ui| { + ui.label(label); + let mut s = d.values.get(key).and_then(Json::as_str).unwrap_or_default().to_string(); + if ui.add(egui::TextEdit::singleline(&mut s).password(!d.show_password).desired_width(320.0)).changed() { + d.values.insert(key.into(), json!(s)); + } + ui.checkbox(&mut d.show_password, "Show password"); + }); + } Field::Number { key, label } => { ui.horizontal(|ui| { ui.label(label); diff --git a/crates/ui-egui/tests/protect_password.rs b/crates/ui-egui/tests/protect_password.rs new file mode 100644 index 0000000..dc2b1c5 --- /dev/null +++ b/crates/ui-egui/tests/protect_password.rs @@ -0,0 +1,70 @@ +//! Protect Sheet / Unprotect Sheet mask the password as it's typed (issue #347). + +use egui::accesskit::Role; +use egui_kittest::kittest::Queryable as _; +use gridcraft_engine::Session; +use gridcraft_ui_egui::SheetApp; +use serde_json::json; + +const SECRET: &str = "s3cret"; + +fn harness() -> egui_kittest::Harness<'static, SheetApp> { + let mut session = Session::new(); + session.new_workbook(); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SheetApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + SheetApp::new(session, Default::default()), + ); + h.run_steps(4); + h +} + +fn type_password(h: &mut egui_kittest::Harness<'static, SheetApp>) { + h.get_by_role(Role::PasswordInput).click(); + h.run_steps(2); + h.input_mut().events.push(egui::Event::Text(SECRET.into())); + h.run_steps(2); +} + +fn secret_on_screen(h: &egui_kittest::Harness<'static, SheetApp>) -> bool { + h.query_all_by(|n| n.value().is_some_and(|v| v.contains(SECRET)) || n.label().is_some_and(|l| l.contains(SECRET))).next().is_some() +} + +#[test] +fn protect_and_unprotect_sheet_mask_the_password() { + let mut h = harness(); + h.state_mut().open_dialog("protectSheet", json!({})); + h.run_steps(3); + type_password(&mut h); + assert_eq!(h.state().dialog.as_ref().and_then(|d| d.values.get("password")), Some(&json!(SECRET))); + assert!(!secret_on_screen(&h), "the typed password must not be readable"); + assert!(h.get_by_role(Role::PasswordInput).value().is_some_and(|v| !v.is_empty() && !v.contains(SECRET))); + + // Show password reveals it on purpose; unticking masks it again. + h.get_by_label("Show password").click(); + h.run_steps(3); + assert!(h.query_by_role(Role::PasswordInput).is_none()); + assert!(secret_on_screen(&h)); + h.get_by_label("Show password").click(); + h.run_steps(3); + assert!(h.query_by_role(Role::PasswordInput).is_some()); + assert!(!secret_on_screen(&h)); + + h.get_by_label(" OK ").click(); + h.run_steps(3); + assert!(h.state().dialog.is_none()); + assert!(h.state().session.active().unwrap().wb.active().unwrap().protection.as_ref().is_some_and(|p| p.password_hash.is_some())); + + // Unprotect asks for the password with a masked field too, and it starts hidden again. + h.state_mut().open_dialog("unprotectSheet", json!({})); + h.run_steps(3); + type_password(&mut h); + assert!(!secret_on_screen(&h), "the typed password must not be readable"); + h.get_by_label(" OK ").click(); + h.run_steps(3); + assert!(h.state().session.active().unwrap().wb.active().unwrap().protection.is_none()); +} From d665499055deb924903e625748f07e9b4042fbd6 Mon Sep 17 00:00:00 2001 From: lloyd Date: Sun, 11 Oct 2026 17:02:39 +0200 Subject: [PATCH 72/83] Clipboard: copy lined-up multiple selections; say why others can't be copied (#348) Copying a multi-area selection always failed in the engine, and the UI dropped the error, so Ctrl+C did nothing and Ctrl+V had nothing to paste. Now, as in Excel: - Areas that cover the same rows (or the same columns) and don't overlap are copied as one block, slid together left to right (top to bottom). The clipboard keeps a snapshot with the block packed at the first area's place; formulas travel as their values, and column widths or row heights, comments, pictures and merges come along. - Other layouts, Cut of several areas and Paste Link of a multi-area copy are refused with "This action won't work on multiple selections."; Copy and Cut from the keyboard, ribbon and context menus now show that message instead of failing silently. - The marching ants go around each copied area. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/edit.rs | 111 +++++++++++++++++++++++++++--- crates/engine/src/lib.rs | 3 + crates/engine/src/tests.rs | 61 ++++++++++++++++ crates/ui-egui/src/grid.rs | 12 ++-- crates/ui-egui/src/lib.rs | 20 ++++-- crates/ui-egui/tests/clipboard.rs | 19 +++++ 6 files changed, 205 insertions(+), 21 deletions(-) diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index bb54fe0..d1d6837 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -604,25 +604,34 @@ fn redo(s: &mut Session, p: &Json) -> Result { // ---------------------------------------------------------------- clipboard fn copy_impl(s: &mut Session, p: &Json, cut: bool) -> Result { + let multiple = || EngineError::Other("This action won't work on multiple selections.".into()); let sheet = target_sheet(s, p)?; let ranges = target_ranges(s, p)?; - if ranges.len() > 1 { - return Err(EngineError::Other("This action won't work on multiple selections.".into())); + if ranges.len() > 1 && cut { + return Err(multiple()); } - let range = ranges.first().copied().unwrap_or_default(); let d = s.doc()?; let Some(sh) = d.wb.sheet(sheet) else { return Err(EngineError::NoDocument) }; - let range = if range.is_full_cols() || range.is_full_rows() { - let used = sh.used_range().unwrap_or(RangeRef::cell(range.start)); - range.intersection(&used.union(&RangeRef::cell(range.start))).unwrap_or(range) - } else { - range + let trim = |range: RangeRef| { + if range.is_full_cols() || range.is_full_rows() { + let used = sh.used_range().unwrap_or(RangeRef::cell(range.start)); + range.intersection(&used.union(&RangeRef::cell(range.start))).unwrap_or(range) + } else { + range + } }; - let text = crate::display::range_text(&d.wb, sheet, range); + let (wb, range, areas) = if ranges.len() > 1 { + let areas: Vec = ranges.iter().map(|r| trim(*r)).collect(); + let (wb, block) = slide_together(&d.wb, sheet, &areas).ok_or_else(multiple)?; + (std::sync::Arc::new(wb), block, areas) + } else { + (d.wb.clone(), trim(ranges.first().copied().unwrap_or_default()), Vec::new()) + }; + let text = crate::display::range_text(&wb, sheet, range); // The HTML rendering is for the host clipboard (UI); it can reach 4 MB, so agents and // scripts get it only when they ask for it. - let html = if p.get("html").and_then(Json::as_bool) == Some(true) { crate::io::range_html(&d.wb, sheet, range, &text) } else { None }; - let clip = Clipboard { wb: d.wb.clone(), sheet, range, cut, doc_uid: d.uid, text: text.clone() }; + let html = if p.get("html").and_then(Json::as_bool) == Some(true) { crate::io::range_html(&wb, sheet, range, &text) } else { None }; + let clip = Clipboard { wb, sheet, range, cut, doc_uid: d.uid, text: text.clone(), areas }; s.clipboard = Some(clip); let mut result = json!({"range": range.a1(), "text": text}); if let Some(html) = html { @@ -631,6 +640,83 @@ fn copy_impl(s: &mut Session, p: &Json, cut: bool) -> Result { Ok(result) } +/// Excel copies a multi-area selection only when the areas line up: all cover the same rows (they +/// slide together side by side, left to right) or all cover the same columns (stacked, top to +/// bottom), without overlapping. Returns a copy of the workbook whose sheet holds the areas packed +/// into one block at the first area's place, and that block. Formulas travel as their values, as +/// in Excel. `None` when the areas don't line up. +fn slide_together(wb: &gridcraft_model::Workbook, sheet: usize, areas: &[RangeRef]) -> Option<(gridcraft_model::Workbook, RangeRef)> { + let first = *areas.first()?; + let across = areas.iter().all(|a| a.start.row == first.start.row && a.end.row == first.end.row); + let down = areas.iter().all(|a| a.start.col == first.start.col && a.end.col == first.end.col); + let mut sorted = areas.to_vec(); + if across { + sorted.sort_by_key(|a| a.start.col); + } else if down { + sorted.sort_by_key(|a| a.start.row); + } else { + return None; + } + if sorted.windows(2).any(|w| matches!(w, [a, b] if a.intersects(b))) { + return None; + } + let anchor = sorted.first()?.start; + let (h, w) = if across { + (first.height(), sorted.iter().map(RangeRef::width).fold(0u32, u32::saturating_add)) + } else { + (sorted.iter().map(RangeRef::height).fold(0u32, u32::saturating_add), first.width()) + }; + let block = RangeRef::new(anchor, anchor.offset(i64::from(h) - 1, i64::from(w) - 1)?); + let src = wb.sheet(sheet)?; + let mut out = wb.clone(); + let dst = out.sheet_mut(sheet)?; + dst.take_cells(block); + dst.spill.retain(|c, _| !block.contains(*c)); + dst.spill_ranges.retain(|c, _| !block.contains(*c)); + dst.comments.retain(|c, _| !block.contains(*c)); + dst.merges.retain(|m| !m.intersects(&block)); + if across { + dst.cols.retain(|c, _| !(block.start.col..=block.end.col).contains(c)); + } else { + dst.rows.retain(|r, _| !(block.start.row..=block.end.row).contains(r)); + } + let mut next = if across { anchor.col } else { anchor.row }; + for a in &sorted { + let (dr, dc) = if across { (0, i64::from(next) - i64::from(a.start.col)) } else { (i64::from(next) - i64::from(a.start.row), 0) }; + let cells = src.cells.iter_range(*a).map(|(c, _)| c).chain(src.spill.keys().copied().filter(|c| a.contains(*c))); + for c in cells { + let Some(to) = c.offset(dr, dc) else { continue }; + let style = src.cell(c).map(|x| x.style).unwrap_or_default(); + dst.set_cell(to, Cell { value: src.value(c), formula: None, style }); + if let Some(picture) = src.cell_pictures.get(&c) { + dst.cell_pictures.insert(to, picture.clone()); + } + } + for (c, comment) in src.comments.iter().filter(|(c, _)| a.contains(**c)) { + if let Some(to) = c.offset(dr, dc) { + dst.comments.insert(to, comment.clone()); + } + } + for m in src.merges.iter().filter(|m| a.contains_range(m)) { + if let (Some(s), Some(e)) = (m.start.offset(dr, dc), m.end.offset(dr, dc)) { + dst.merges.push(RangeRef::new(s, e)); + } + } + // Column widths (side by side) or row heights and hidden rows (stacked) follow their cells. + if across { + for (c, info) in src.cols.range(a.start.col..=a.end.col) { + dst.cols.insert(next.saturating_add(c.saturating_sub(a.start.col)), *info); + } + } else { + for (r, info) in src.rows.range(a.start.row..=a.end.row) { + dst.rows.insert(next.saturating_add(r.saturating_sub(a.start.row)), *info); + } + } + next = next.saturating_add(if across { a.width() } else { a.height() }); + } + Some((out, block)) +} + fn copy(s: &mut Session, p: &Json) -> Result { copy_impl(s, p, false) } @@ -769,6 +855,9 @@ fn paste_special(s: &mut Session, p: &Json) -> Result { let skip_blanks = bool_param(p, "skipBlanks").unwrap_or(false); let transpose = bool_param(p, "transpose").unwrap_or(false); let link = bool_param(p, "link").unwrap_or(false); + if link && !clip.areas.is_empty() { + return Err(EngineError::Other("This action won't work on multiple selections.".into())); + } let dest_sheet = target_sheet(s, p)?; let sel = s.doc()?.selection.current(); let at = cell_param(p, "at").unwrap_or(sel.start); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index de786a1..267ef83 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -70,6 +70,9 @@ pub struct Clipboard { pub doc_uid: u64, /// Plain text (tab-separated) for the system clipboard. pub text: String, + /// A multi-area copy: the areas as selected (for the marquee). `wb` then holds them slid + /// together into one block at `range`. Empty for a single-range copy. + pub areas: Vec, } /// In-cell edit mode as shown in the status bar. diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 6bd03d5..228612a 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1633,3 +1633,64 @@ fn notes_comments_and_links_survive_a_json_save() { assert_eq!(after.hyperlinks, before.hyperlinks); assert_eq!(after.cells, before.cells); } + +#[test] +fn copying_areas_that_share_rows_or_columns_pastes_them_as_one_block() { + // Issue #348: Excel slides lined-up areas together; formulas travel as their values. + let mut s = s(); + s.execute( + "range.setValues", + json!({"range": "A1", "values": [["a1", "b1", "c1", "d1"], [1, 2, 3, 4], ["a3", "b3", "c3", "d3"], ["a4", "b4", "c4", "d4"]]}), + ) + .unwrap(); + s.execute("cell.set", json!({"cell": "C2", "input": "=A2+B2"})).unwrap(); + + // Same rows, selected right to left: pasted left to right, B left out. + s.execute("selection.set", json!({"range": "C1:C2,A1:A2"})).unwrap(); + let copied = s.execute("edit.copy", json!({})).unwrap(); + assert_eq!(copied["text"], "a1\tc1\n1\t3\n"); + s.execute("edit.paste", json!({"at": "F1"})).unwrap(); + assert_eq!(v(&s, "F1"), Value::text("a1")); + assert_eq!(v(&s, "G1"), Value::text("c1")); + assert_eq!(v(&s, "F2"), Value::Number(1.0)); + assert_eq!(v(&s, "G2"), Value::Number(3.0)); + assert!(s.doc().unwrap().wb.active().unwrap().cell(CellRef::parse("G2").unwrap()).unwrap().formula.is_none()); + assert!(v(&s, "H1").is_empty()); + assert_eq!(s.doc().unwrap().selection.current().a1(), "F1:G2"); + // The source is untouched. + assert_eq!(v(&s, "B1"), Value::text("b1")); + assert!(s.doc().unwrap().wb.active().unwrap().cell(CellRef::parse("C2").unwrap()).unwrap().formula.is_some()); + + // Same columns: stacked, row 2 left out. + s.execute("selection.set", json!({"range": "A1:B1,A3:B4"})).unwrap(); + assert_eq!(s.execute("edit.copy", json!({})).unwrap()["text"], "a1\tb1\na3\tb3\na4\tb4\n"); + s.execute("edit.paste", json!({"at": "F5"})).unwrap(); + for (cell, want) in [("F5", "a1"), ("G5", "b1"), ("F6", "a3"), ("G7", "b4")] { + assert_eq!(v(&s, cell), Value::text(want), "{cell}"); + } + assert!(v(&s, "F8").is_empty()); + + // Whole columns are trimmed to the used rows like a single-area copy. + s.execute("edit.undo", json!({"steps": 2})).unwrap(); + s.execute("selection.set", json!({"range": "B:B,D:D"})).unwrap(); + assert_eq!(s.execute("edit.copy", json!({})).unwrap()["text"], "b1\td1\n2\t4\nb3\td3\nb4\td4\n"); +} + +#[test] +fn copying_areas_that_do_not_line_up_is_refused_with_a_message() { + let mut s = s(); + s.execute("range.setValues", json!({"range": "A1", "values": [[1, 2, 3], [4, 5, 6], [7, 8, 9]]})).unwrap(); + s.execute("selection.set", json!({"range": "A1"})).unwrap(); + s.execute("edit.copy", json!({})).unwrap(); + for (range, cut) in [("A1:A2,C2:C3", false), ("A1:A2,C1:D3", false), ("A1:A2,A2:A3", false), ("A1:A2,A1:A2", false), ("A1:A2,C1:C2", true)] { + s.execute("selection.set", json!({"range": range})).unwrap(); + let e = s.execute(if cut { "edit.cut" } else { "edit.copy" }, json!({})).unwrap_err().to_string(); + assert!(e.contains("This action won't work on multiple selections."), "{range}: {e}"); + assert_eq!(s.clipboard.as_ref().map(|c| c.range.a1()), Some("A1".into()), "{range}: the earlier copy stays"); + } + // Paste Link can't point at a slid-together block. + s.execute("selection.set", json!({"range": "A1:A2,C1:C2"})).unwrap(); + s.execute("edit.copy", json!({})).unwrap(); + assert!(s.execute("edit.pasteSpecial", json!({"what": "all", "link": true, "at": "E1"})).is_err()); + assert!(v(&s, "E1").is_empty()); +} diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 72d33e2..122f863 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -966,10 +966,14 @@ fn paint_selection(p: &Painter, geo: &Geo, sh: &Sheet, sel: &gridcraft_engine::S 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) { - let rr = geo.range_rect(sh, clip.range).expand(0.5); - let phase = app.grid.marching_phase; - let pts = [rr.left_top(), rr.right_top(), rr.right_bottom(), rr.left_bottom(), rr.left_top()]; - p.extend(egui::Shape::dashed_line_with_offset(&pts, Stroke::new(2.0, t.sel_border), &[4.0], &[4.0], phase)); + // A multi-area copy marks each area as selected. + let areas = if clip.areas.is_empty() { std::slice::from_ref(&clip.range) } else { clip.areas.as_slice() }; + for area in areas { + let rr = geo.range_rect(sh, *area).expand(0.5); + let phase = app.grid.marching_phase; + let pts = [rr.left_top(), rr.right_top(), rr.right_bottom(), rr.left_bottom(), rr.left_top()]; + p.extend(egui::Shape::dashed_line_with_offset(&pts, Stroke::new(2.0, t.sel_border), &[4.0], &[4.0], phase)); + } } } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 1225eec..650483f 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -250,7 +250,10 @@ impl SheetApp { /// their existing internal clipboard behavior and do not write to the host clipboard. pub fn copy_to_clipboard(&mut self, ctx: &egui::Context, command: &str) { // Only ask the engine for HTML when the host can publish it. - let Ok(result) = self.run(command, json!({"html": self.services.copy_html.is_some()})) else { return }; + let result = match self.run(command, json!({"html": self.services.copy_html.is_some()})) { + Ok(result) => result, + Err(e) => return self.alert(e), + }; let Some(text) = result.get("text").and_then(Json::as_str) else { return }; if let Some(html) = result.get("html").and_then(Json::as_str) && let Some(copy_html) = &mut self.services.copy_html @@ -279,11 +282,16 @@ impl SheetApp { /// Runs a command and shows its error in a message box (for menu/ribbon clicks). pub fn run_or_alert(&mut self, id: &str, params: Json) { if let Err(e) = self.run(id, params) { - if e.contains("is not available right now") { - self.toast = Some((e, now_ms())); - } else { - self.message = Some(("GridCraft".into(), clean_error(&e))); - } + self.alert(e); + } + } + + /// Shows a command error: a toast for an unavailable command, else a message box. + fn alert(&mut self, e: String) { + if e.contains("is not available right now") { + self.toast = Some((e, now_ms())); + } else { + self.message = Some(("GridCraft".into(), clean_error(&e))); } } diff --git a/crates/ui-egui/tests/clipboard.rs b/crates/ui-egui/tests/clipboard.rs index 188dbdc..3922708 100644 --- a/crates/ui-egui/tests/clipboard.rs +++ b/crates/ui-egui/tests/clipboard.rs @@ -83,3 +83,22 @@ fn programmatic_copy_does_not_write_to_the_system_clipboard() { assert_eq!(*calls.borrow(), 0); assert!(!h.output().platform_output.commands.iter().any(|c| matches!(c, OutputCommand::CopyText(_)))); } + +#[test] +fn copying_multiple_areas_copies_lined_up_areas_and_explains_the_rest() { + // Issue #348: lined-up areas copy as one block; others get Excel's message instead of nothing. + let mut h = harness(Services::default()); + h.state_mut().session.execute("selection.set", json!({"range": "B1:B2,A1:A2"})).unwrap(); + h.input_mut().events.push(Event::Copy); + h.step(); + assert!(h.output().platform_output.commands.iter().any(|c| matches!(c, OutputCommand::CopyText(text) if text == "Name\tScore\nAda\t42\n"))); + assert!(h.state().message.is_none()); + assert_eq!(h.state().session.clipboard.as_ref().map(|c| c.areas.len()), Some(2)); + + h.state_mut().session.execute("selection.set", json!({"range": "A1,B2"})).unwrap(); + h.input_mut().events.push(Event::Copy); + h.step(); + assert!(!h.output().platform_output.commands.iter().any(|c| matches!(c, OutputCommand::CopyText(_)))); + assert_eq!(h.state().message.as_ref().map(|(_, text)| text.as_str()), Some("This action won't work on multiple selections.")); + assert_eq!(h.state().session.clipboard.as_ref().map(|c| c.areas.len()), Some(2), "the earlier copy stays"); +} From c1a500c5b9aee87312fc7b054a7b41ca40c74b94 Mon Sep 17 00:00:00 2001 From: lloyd Date: Sun, 11 Oct 2026 17:17:30 +0200 Subject: [PATCH 73/83] Journal: leave sheet protection passwords out Protect Sheet and Unprotect Sheet were journaled with their `password` param, so the password showed in plain text in the journal dialog and `engine.journal`, and a recorded script (Automate > Record Actions) saved it in the workbook's hidden `_sc_scripts` name. Session::execute now journals the params without secret ones (`password`, or a name ending in `Password`). A replayed entry runs without that param: Protect Sheet protects with the same options but no password, and the matching Unprotect Sheet still succeeds. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/mod.rs | 15 +++++++++++++++ crates/engine/src/lib.rs | 3 ++- crates/engine/src/tests.rs | 37 ++++++++++++++++++++++++++++++++++++ docs/control-protocol.md | 2 +- 4 files changed, 55 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index c4a290e..a37c1a8 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -69,6 +69,21 @@ impl CommandSpec { } } +/// Params that hold a secret: `password`, or a name ending in `Password`. +pub fn is_secret_param(key: &str) -> bool { + key == "password" || key.ends_with("Password") +} + +/// The params as the journal records them: secret params are left out, so a password never +/// reaches the journal dialog, `engine.journal` or a recorded script (saved in the workbook). +/// Replaying such an entry runs the command without that param. +pub fn journal_params(params: &Json) -> Json { + match params { + Json::Object(o) => Json::Object(o.iter().filter(|(k, _)| !is_secret_param(k)).map(|(k, v)| (k.clone(), v.clone())).collect()), + other => other.clone(), + } +} + pub fn always(_: &Session) -> std::result::Result<(), String> { Ok(()) } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index de786a1..a614e16 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -184,6 +184,7 @@ pub struct Session { pub clipboard: Option, pub prefs: Prefs, pub mode: Mode, + /// Commands run so far, without their passwords ([`cmd::journal_params`]). pub journal: Vec<(String, Json)>, pub ui_requests: Vec, /// Format painter source style (sticky when double-clicked). @@ -284,7 +285,7 @@ impl Session { } }; if spec.journal && result.is_ok() { - self.journal.push((id.to_string(), params.clone())); + self.journal.push((id.to_string(), cmd::journal_params(¶ms))); if self.journal.len() > 10_000 { self.journal.drain(..5_000); } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 6bd03d5..671a46e 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -512,6 +512,43 @@ fn sort_and_filter() { assert_eq!(r["hiddenRows"], 1); } +#[test] +fn protection_passwords_stay_out_of_the_journal_and_recorded_scripts() { + const PW: &str = "synthetic-pw-7319"; + let mut s = s(); + s.execute("automate.recordActions", json!({})).unwrap(); + s.execute("review.protectSheet", json!({"password": PW, "formatCells": true})).unwrap(); + // The password still protects the sheet; the refused attempt isn't journaled. + assert!(s.execute("review.unprotectSheet", json!({})).is_err()); + s.execute("review.unprotectSheet", json!({"password": PW})).unwrap(); + s.execute("automate.recordActions", json!({"stop": true, "name": "Lock"})).unwrap(); + + let journal = serde_json::to_string(&s.journal).unwrap(); + assert!(!journal.contains(PW), "{journal}"); + let params = |s: &Session, id: &str| s.journal.iter().find(|(c, _)| c == id).map(|(_, p)| p.clone()); + assert_eq!(params(&s, "review.protectSheet"), Some(json!({"formatCells": true}))); + assert_eq!(params(&s, "review.unprotectSheet"), Some(json!({}))); + + // The recorded script is stored in the workbook, so it is saved with the file. + let bytes = crate::io::save_bytes(&s.doc().unwrap().wb, "book.xlsx").unwrap(); + let (reopened, _) = crate::io::open_bytes("book.xlsx", &bytes).unwrap(); + let script = reopened.names.iter().find(|n| n.name == "_sc_scripts").map(|n| n.formula.clone()).unwrap(); + assert!(script.contains("review.protectSheet"), "{script}"); + assert!(!script.contains(PW), "{script}"); + + // The journal still replays: protection without a password, then unprotection. + let mut r = self::s(); + for (id, p) in s.journal.iter().filter(|(id, _)| !id.starts_with("automate.")) { + r.execute(id, p.clone()).unwrap_or_else(|e| panic!("{id}: {e}")); + let prot = r.doc().unwrap().wb.active().unwrap().protection.clone(); + if id == "review.protectSheet" { + assert!(prot.as_ref().is_some_and(|p| p.format_cells && p.password_hash.is_none()), "{prot:?}"); + } + } + assert!(r.doc().unwrap().wb.active().unwrap().protection.is_none()); + s.execute("automate.runScript", json!({"name": "Lock"})).unwrap(); +} + /// A sheet with locked data in A1:B3, unlocked cells in D1:D3 and row 10, then protected. fn protected(extra: serde_json::Value) -> Session { let mut s = s(); diff --git a/docs/control-protocol.md b/docs/control-protocol.md index aeb540d..e515a6d 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -21,7 +21,7 @@ Errors come back as `{"id": …, "ok": false, "error": "…"}`. |---|---|---| | `engine.execute` | `{command, params}` | Runs any engine command (see `engine.commands` or `gridcraft-cli commands`) | | `engine.commands` | `{search?}` | Every command: id, label, ribbon path, shortcut, params doc, enabled; `search` keeps those whose id, label or ribbon path contains it (case-insensitive) | -| `engine.journal` | | Commands run so far (replayable) | +| `engine.journal` | | Commands run so far (replayable). Passwords (`password`, `…Password` params) are left out, so a replayed Protect Sheet has no password | | `document.inspect` | | Workbook summary: sheets, used ranges, tables, charts, names, selection, undo labels | | `sheet.read` | `{range?, sheet?, formulas?, formatted?}` | Values (or formulas / displayed text) of a range | | `cell.get` | `{cell?}` | One cell: value, formula, displayed text, style, merge, comment, link | From dfa15c3f25e57a13f2feaf8b4057bbd1902fedc0 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sun, 11 Oct 2026 11:54:22 -0400 Subject: [PATCH 74/83] ci: refactor workflows to use storytold/craft-common-actions (#336) * ci: refactor workflows to use reusable artcraft-common-actions * ci: point reusable workflows to storytold/craft-common-actions * ci: pin storytold/craft-common-actions to commit 27a978f and pass secrets explicitly * ci: use @main (unpinned) for craft-common-actions with explanation comment --- .github/workflows/packaging-lint.yml | 77 +---- .github/workflows/release.yml | 448 +-------------------------- .github/workflows/windows-arm64.yml | 73 +---- 3 files changed, 25 insertions(+), 573 deletions(-) diff --git a/.github/workflows/packaging-lint.yml b/.github/workflows/packaging-lint.yml index 056cb05..bb3989d 100644 --- a/.github/workflows/packaging-lint.yml +++ b/.github/workflows/packaging-lint.yml @@ -19,78 +19,5 @@ concurrency: jobs: lint: - runs-on: ubuntu-latest - env: - ACTIONLINT_VERSION: 1.7.12 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - name: Install tools - run: | - sudo apt-get update - sudo apt-get install -y --no-install-recommends shellcheck desktop-file-utils appstream libxml2-utils python3-yaml - curl -fsSL "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" \ - | tar -xz -C "$RUNNER_TEMP" actionlint - - name: actionlint - run: | - "$RUNNER_TEMP/actionlint" -color - - name: shellcheck - run: shellcheck packaging/*.sh packaging/*/*.sh - - name: PowerShell - shell: pwsh - run: | - foreach ($f in Get-ChildItem packaging/windows/*.ps1) { - $errors = $null - [System.Management.Automation.Language.Parser]::ParseFile($f.FullName, [ref]$null, [ref]$errors) | Out-Null - if ($errors) { $errors | ForEach-Object { Write-Output "::error file=$($f.Name)::$_" }; exit 1 } - Write-Output "ok $($f.Name)" - } - - name: XML, desktop entry, AppStream, Flatpak manifest - run: | - xmllint --noout packaging/windows/gridcraft.wxs packaging/macos/*.plist* packaging/linux/*.xml* - # Windows Installer needs Icon ids ending in .ico or .exe, or shortcuts show a blank icon. - if grep -oE '"$RUNNER_TEMP/ai.storyteller.gridcraft.metainfo.xml" - appstreamcli validate --no-net --explain "$RUNNER_TEMP/ai.storyteller.gridcraft.metainfo.xml" - python3 - <<'EOF' - import yaml - src = yaml.safe_load(open("packaging/linux/flatpak/ai.storyteller.gridcraft.yml")) - bundle = yaml.safe_load(open("packaging/linux/flatpak/ai.storyteller.gridcraft.bundle.yml")) - for m in (src, bundle): - assert m["id"] == "ai.storyteller.gridcraft" and m["command"] == "gridcraft", m["id"] - for key in ("runtime", "runtime-version", "sdk", "finish-args"): - assert src[key] == bundle[key], f"Flatpak manifests disagree on {key}: {src[key]} vs {bundle[key]}" - print("Flatpak manifests ok") - EOF - - desktop-shortcut: - name: Windows desktop shortcut smoke - runs-on: windows-latest - timeout-minutes: 10 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - name: Build fixture MSIs and test shortcut lifecycle - shell: pwsh - run: | - dotnet tool install --tool-path "$env:RUNNER_TEMP\wix" wix --version 5.0.2 - if ($LASTEXITCODE -ne 0) { throw 'Could not install WiX' } - $env:PATH = "$env:RUNNER_TEMP\wix;$env:PATH" - ./packaging/windows/test-desktop-shortcut.ps1 - - name: Capture native MSI dialog previews - shell: pwsh - run: | - pwsh -NoProfile -STA -File ./packaging/windows/preview-installer-dialogs.ps1 ` - -Msi "$env:RUNNER_TEMP\gridcraft-shortcut-smoke\gridcraft-90.0.0.msi" ` - -OutputDir "$env:RUNNER_TEMP\gridcraft-shortcut-smoke\dialogs" - if ($LASTEXITCODE -ne 0) { throw 'Native MSI dialog preview failed' } - - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 - if: always() - with: - name: desktop-shortcut-msi-evidence - path: | - ${{ runner.temp }}/gridcraft-shortcut-smoke/*.log - ${{ runner.temp }}/gridcraft-shortcut-smoke/dialogs/* - if-no-files-found: ignore - retention-days: 7 + # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. + uses: storytold/craft-common-actions/.github/workflows/packaging-lint.yml@main diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6c9050a..87d8954 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,13 +1,3 @@ -# Release pipeline: every push to `release` builds signed installers for every platform and -# creates or updates the draft GitHub Release "GridCraft v". See docs/releasing.md. -# Copied from DesignCraft (craftrules release/playbook.md); keep the two in step. -# -# The signing jobs (macOS, Windows) and the draft-release job run in the `release` environment -# (restricted to the `release` branch), which holds the signing secrets. Every secret is -# optional: missing ones produce unsigned artifacts and a warning, never a failed build. The -# other jobs sign nothing and get no secrets, so a manual run from another branch is a dry run: -# they build and upload their artifacts, the environment refuses the signing jobs, and the -# draft-release job (which needs them) is skipped. name: Release on: @@ -21,431 +11,27 @@ on: default: "" permissions: - contents: read + contents: write concurrency: group: release-${{ github.ref }} - # Never cancel a run mid-notarization or mid-upload; a newer push waits its turn. cancel-in-progress: false -env: - CARGO_TERM_COLOR: always - CARGO_INCREMENTAL: 0 - RUST_BACKTRACE: 1 - GRIDCRAFT_BUILD_SHA: ${{ github.sha }} - INPUT_VERSION: ${{ inputs.version }} - jobs: - version: - name: version - runs-on: ubuntu-latest - # No `environment: release`: this job needs no secrets (see the header about dry runs). - outputs: - version: ${{ steps.v.outputs.version }} - date: ${{ steps.v.outputs.date }} - prerelease: ${{ steps.v.outputs.prerelease }} - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - id: v - name: Resolve version - run: | - . packaging/env.sh - v="${INPUT_VERSION#v}" - v="${v:-$VERSION}" - if ! printf '%s' "$v" | grep -Eq '^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$'; then - echo "::error::'$v' is not a version like 1.2.3 or 1.2.3-rc.1" - exit 1 - fi - case "$v" in *-*) pre=true ;; *) pre=false ;; esac - { - echo "version=$v" - echo "date=$(date -u +%Y-%m-%d)" - echo "prerelease=$pre" - } >>"$GITHUB_OUTPUT" - echo "### GridCraft v$v (\`${GITHUB_SHA::9}\`)" >>"$GITHUB_STEP_SUMMARY" - - macos: - name: macOS universal - needs: version - runs-on: macos-15-xlarge # M2 Pro larger runner; use only in release.yml (actions_macos_xl budget caps spend) - environment: release - env: - GRIDCRAFT_VERSION: ${{ needs.version.outputs.version }} - GRIDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - name: Check out craft-fonts - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - repository: storytold/craft-fonts - ref: abb83316d96aa59c1cf64784289e378fe9fa5695 - path: craft-fonts - - uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable - with: - targets: aarch64-apple-darwin, x86_64-apple-darwin - - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 - with: - key: release-macos-universal - - name: Apply version override - if: inputs.version != '' - run: cargo run -q -p xtask --no-default-features -- version set "$GRIDCRAFT_VERSION" - - name: Import signing certificate - env: - APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} - APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} - KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} - run: packaging/macos/import-cert.sh - - name: Build, sign, notarize - env: - CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts - CRAFT_FONTS_REQUIRED: "1" - APPLE_ID: ${{ secrets.APPLE_ID }} - APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} - APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} - run: packaging/macos/package.sh --arch universal - - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 - with: - name: release-macos - path: dist/release/* - if-no-files-found: error - - name: Remove temporary keychain - if: always() - run: | - if [ -n "${MACOS_KEYCHAIN:-}" ]; then security delete-keychain "$MACOS_KEYCHAIN" || true; fi - - windows: - name: Windows ${{ matrix.arch }} - needs: version - runs-on: windows-latest - environment: release - strategy: - fail-fast: false - matrix: - include: - - { arch: x64, target: x86_64-pc-windows-msvc } - - { arch: x86, target: i686-pc-windows-msvc } - # Windows on ARM: cross-compiled on the x64 runner, so signing and WiX run as above. - # .github/workflows/windows-arm64.yml installs and runs it on ARM64 hardware. - - { arch: arm64, target: aarch64-pc-windows-msvc } - env: - GRIDCRAFT_VERSION: ${{ needs.version.outputs.version }} - GRIDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - name: Check out craft-fonts - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - repository: storytold/craft-fonts - ref: abb83316d96aa59c1cf64784289e378fe9fa5695 - path: craft-fonts - - uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable - with: - targets: ${{ matrix.target }} - - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 - with: - key: release-windows-${{ matrix.arch }} - - name: Apply version override - if: inputs.version != '' - shell: bash - run: cargo run -q -p xtask --no-default-features -- version set "$GRIDCRAFT_VERSION" - - name: Install WiX - shell: pwsh - run: dotnet tool install --global wix --version 5.0.2 - - name: Build, sign, package - shell: pwsh - env: - CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts - CRAFT_FONTS_REQUIRED: "1" - WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }} - WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }} - AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} - AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} - AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} - AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }} - AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }} - AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }} - run: ./packaging/windows/package.ps1 -Arch ${{ matrix.arch }} - - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 - with: - name: release-windows-${{ matrix.arch }} - path: dist/release/* - if-no-files-found: error - - linux: - name: Linux ${{ matrix.arch }} - needs: version - # The oldest GitHub-hosted image, so the binaries only need glibc >= 2.35 (Ubuntu 22.04+, - # Debian 12+, Fedora 36+, RHEL 10). See docs/releasing.md. - runs-on: ${{ matrix.runner }} - # No `environment: release`: this job signs nothing, so it gets no release secrets. - strategy: - fail-fast: false - matrix: - include: - - { arch: x86_64, runner: ubuntu-22.04, deb: amd64 } - - { arch: aarch64, runner: ubuntu-22.04-arm, deb: arm64 } - env: - GRIDCRAFT_VERSION: ${{ needs.version.outputs.version }} - GRIDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} - NFPM_VERSION: 2.47.0 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - name: Check out craft-fonts - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - repository: storytold/craft-fonts - ref: abb83316d96aa59c1cf64784289e378fe9fa5695 - path: craft-fonts - - name: Install build and packaging tools - run: | - sudo apt-get update - sudo apt-get install -y --no-install-recommends \ - libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev libgl1-mesa-dev \ - desktop-file-utils appstream python3-yaml zsync - curl -fsSL -o "$RUNNER_TEMP/nfpm.deb" \ - "https://github.com/goreleaser/nfpm/releases/download/v${NFPM_VERSION}/nfpm_${NFPM_VERSION}_${{ matrix.deb }}.deb" - sudo dpkg -i "$RUNNER_TEMP/nfpm.deb" - - uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable - - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 - with: - key: release-linux-${{ matrix.arch }} - - name: Apply version override - if: inputs.version != '' - run: cargo run -q -p xtask --no-default-features -- version set "$GRIDCRAFT_VERSION" - - name: Build and package (AppImage, deb, rpm, tar.gz) - env: - CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts - CRAFT_FONTS_REQUIRED: "1" - run: packaging/linux/package.sh - - name: Check the packages - run: | - dpkg-deb --info dist/release/*.deb - dpkg-deb --contents dist/release/*.deb | grep -E 'usr/bin/gridcraft$|metainfo|applications' - ldd target/release/gridcraft - python3 -c "import yaml; m = yaml.safe_load(open('packaging/linux/flatpak/ai.storyteller.gridcraft.yml')); assert m['id'] == 'ai.storyteller.gridcraft'" - for appimage in dist/release/*.AppImage; do - chmod +x "$appimage" - APPIMAGE_EXTRACT_AND_RUN=1 "$appimage" --version - # Update information is embedded, and the .zsync for delta updates sits beside it. - "$appimage" --appimage-updateinformation | grep -F 'gh-releases-zsync|' - test -s "$appimage.zsync" - done - - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 - with: - name: release-linux-${{ matrix.arch }} - path: dist/release/* - if-no-files-found: error - - flatpak: - name: Flatpak ${{ matrix.arch }} - needs: [version, linux] - # Repackages the Linux job's tarball (the same binaries as the AppImage/deb/rpm); no Rust - # build here. 24.04 for a newer flatpak-builder (1.4) than 22.04 ships. - runs-on: ${{ matrix.runner }} - # No `environment: release`: this job signs nothing, so it gets no release secrets. - strategy: - fail-fast: false - matrix: - include: - - { arch: x86_64, runner: ubuntu-24.04 } - - { arch: aarch64, runner: ubuntu-24.04-arm } - env: - GRIDCRAFT_VERSION: ${{ needs.version.outputs.version }} - GRIDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 - with: - name: release-linux-${{ matrix.arch }} - path: dist/linux - - name: Install flatpak-builder - run: | - sudo apt-get update - # librsvg2-common: the SVG pixbuf loader. flatpak-builder runs the host's - # `appstreamcli compose`, which renders the scalable icon and fails without it. - sudo apt-get install -y --no-install-recommends flatpak flatpak-builder librsvg2-common - # Ubuntu 24.04's AppArmor blocks the unprivileged user namespaces bubblewrap needs. - sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 - - name: Build the Flatpak bundle - run: packaging/linux/flatpak-bundle.sh "dist/linux/gridcraft-$GRIDCRAFT_VERSION-linux-${{ matrix.arch }}.tar.gz" - - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 - with: - name: release-flatpak-${{ matrix.arch }} - path: dist/release/*.flatpak - if-no-files-found: error - - web: - name: Web (wasm) - needs: version - runs-on: ubuntu-latest - # No `environment: release`: this job signs nothing, so it gets no release secrets. - env: - GRIDCRAFT_VERSION: ${{ needs.version.outputs.version }} - GRIDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} - TRUNK_VERSION: 0.21.14 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - name: Check out craft-fonts - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - repository: storytold/craft-fonts - ref: abb83316d96aa59c1cf64784289e378fe9fa5695 - path: craft-fonts - - uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable - with: - targets: wasm32-unknown-unknown - - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 - with: - key: release-web - - name: Install trunk - run: | - curl -fsSL "https://github.com/trunk-rs/trunk/releases/download/v${TRUNK_VERSION}/trunk-x86_64-unknown-linux-gnu.tar.gz" \ - | tar -xz -C "$HOME/.cargo/bin" trunk - trunk --version - - name: Apply version override - if: inputs.version != '' - run: cargo run -q -p xtask --no-default-features -- version set "$GRIDCRAFT_VERSION" - - name: Build and zip - env: - CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts - CRAFT_FONTS_REQUIRED: "1" - run: packaging/web/package.sh - - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 - with: - name: release-web - path: dist/release/* - if-no-files-found: error - - linux-riscv64: - name: Linux riscv64 - needs: version - # Cross-compiled on x86_64 (no hosted RISC-V runners) against the runner's own Ubuntu release - # from ports.ubuntu.com, so the binaries need that release's glibc (24.04: 2.39). 22.04's - # riscv64 ports lag its amd64 updates, which breaks the multiarch install. tar.gz only; the - # CLI is smoke-tested under QEMU. - runs-on: ubuntu-24.04 - # No `environment: release`: this job signs nothing, so it gets no release secrets. - env: - GRIDCRAFT_VERSION: ${{ needs.version.outputs.version }} - GRIDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - name: Check out craft-fonts - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - repository: storytold/craft-fonts - ref: abb83316d96aa59c1cf64784289e378fe9fa5695 - path: craft-fonts - - uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable - with: - targets: riscv64gc-unknown-linux-gnu - - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 - with: - key: release-linux-riscv64 - - name: Apply version override - if: inputs.version != '' - run: cargo run -q -p xtask --no-default-features -- version set "$GRIDCRAFT_VERSION" - - name: Install cross-compilation dependencies - run: | - . /etc/os-release - sudo dpkg --add-architecture riscv64 - # Pin the existing (archive.ubuntu.com) sources to amd64; riscv64 comes from ports. - if [ -f /etc/apt/sources.list.d/ubuntu.sources ]; then - sudo sed -i '/^Types: deb/a Architectures: amd64' /etc/apt/sources.list.d/ubuntu.sources - fi - if [ -f /etc/apt/sources.list ]; then - sudo sed -i 's/^deb /deb [arch=amd64] /g' /etc/apt/sources.list - sudo sed -i 's/^deb-src /deb-src [arch=amd64] /g' /etc/apt/sources.list - fi - for suite in "$VERSION_CODENAME" "$VERSION_CODENAME-updates" "$VERSION_CODENAME-security"; do - echo "deb [arch=riscv64] http://ports.ubuntu.com/ubuntu-ports $suite main universe restricted multiverse" - done | sudo tee /etc/apt/sources.list.d/riscv64.list - sudo apt-get update - sudo apt-get install -y --no-install-recommends \ - gcc-riscv64-linux-gnu binutils-riscv64-linux-gnu libc6-dev-riscv64-cross libc6-dev:riscv64 qemu-user \ - libxkbcommon-dev:riscv64 libwayland-dev:riscv64 libx11-dev:riscv64 libxrandr-dev:riscv64 \ - libxi-dev:riscv64 libgl1-mesa-dev:riscv64 - - name: Build and package (tar.gz) - env: - CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts - CRAFT_FONTS_REQUIRED: "1" - CARGO_TARGET_RISCV64GC_UNKNOWN_LINUX_GNU_LINKER: riscv64-linux-gnu-gcc - PKG_CONFIG_SYSROOT_DIR: / - PKG_CONFIG_PATH: /usr/lib/riscv64-linux-gnu/pkgconfig:/usr/share/pkgconfig - PKG_CONFIG_ALLOW_CROSS: "1" - CROSS_ARCH: riscv64 - CROSS_TARGET: riscv64gc-unknown-linux-gnu - CROSS_COMPILE: riscv64-linux-gnu- - EMULATOR: qemu-riscv64 - QEMU_LD_PREFIX: /usr/riscv64-linux-gnu - run: packaging/linux/package.sh --formats tar - - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 - with: - name: release-linux-riscv64 - path: dist/release/*.tar.gz - if-no-files-found: error - - # FreeBSD has no hosted runners: freebsd.yml builds in a FreeBSD VM and, with package: true, - # uploads the release-freebsd artifact (gridcraft--freebsd-x86_64.tar.gz). It needs no - # secrets, so the called workflow runs without the release environment (and in dry runs). - freebsd: - name: FreeBSD - needs: version - uses: ./.github/workflows/freebsd.yml - with: - version: ${{ needs.version.outputs.version }} - version_override: ${{ inputs.version != '' }} - package: true - release: - name: Draft GitHub Release - needs: [version, macos, windows, linux, linux-riscv64, flatpak, web, freebsd] - # Belt and braces for branch dry runs: only the release branch drafts a release (the - # environment's branch rule refuses this job and the signing jobs anywhere else, too). - if: github.ref == 'refs/heads/release' - runs-on: ubuntu-latest - environment: release - permissions: - contents: write - env: - VERSION: ${{ needs.version.outputs.version }} - PRERELEASE: ${{ needs.version.outputs.prerelease }} - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - GH_REPO: ${{ github.repository }} - steps: - - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 - with: - pattern: release-* - path: artifacts - merge-multiple: true - - name: Checksums - working-directory: artifacts - run: | - sha256sum -- * | tee SHA256SUMS.txt - - name: Create or update the draft release - working-directory: artifacts - run: | - tag="v$VERSION" - title="GridCraft v$VERSION" - pre=() - if [ "$PRERELEASE" = true ]; then pre=(--prerelease); fi - if draft="$(gh release view "$tag" --json isDraft --jq .isDraft 2>/dev/null)"; then - if [ "$draft" != "true" ]; then - echo "::error::Release $tag is already published. Bump the version (cargo xtask version set) before pushing to release." - exit 1 - fi - echo "Updating draft $tag: replacing its assets" - gh release view "$tag" --json assets --jq '.assets[].name' | while read -r asset; do - gh release delete-asset "$tag" "$asset" --yes - done - gh release edit "$tag" --draft --title "$title" --target "$GITHUB_SHA" --prerelease="$PRERELEASE" - gh release upload "$tag" --clobber -- * - else - echo "Creating draft $tag" - gh release create "$tag" --draft --title "$title" --target "$GITHUB_SHA" --generate-notes "${pre[@]}" -- * - fi - { - echo "Draft release **$title** at \`${GITHUB_SHA::9}\`:" - echo - sed 's/^/ /' SHA256SUMS.txt - } >>"$GITHUB_STEP_SUMMARY" + # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. + uses: storytold/craft-common-actions/.github/workflows/release.yml@main + with: + app-name: gridcraft + app-title: GridCraft + craft-fonts-ref: abb83316d96aa59c1cf64784289e378fe9fa5695 + version: ${{ inputs.version }} + secrets: + APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }} + WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }} diff --git a/.github/workflows/windows-arm64.yml b/.github/workflows/windows-arm64.yml index 92239fa..2f9b141 100644 --- a/.github/workflows/windows-arm64.yml +++ b/.github/workflows/windows-arm64.yml @@ -17,71 +17,10 @@ concurrency: permissions: contents: read -env: - CARGO_TERM_COLOR: always - CARGO_INCREMENTAL: 0 - jobs: - package: - name: package (cross-compiled on x64) - if: github.ref == 'refs/heads/release' - runs-on: windows-latest - timeout-minutes: 90 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable - with: - targets: aarch64-pc-windows-msvc - - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 - with: - key: windows-arm64-package - - name: Install WiX - shell: pwsh - run: dotnet tool install --global wix --version 5.0.2 - - name: Build and package (unsigned) - shell: pwsh - env: - DIST: ${{ github.workspace }}\dist\arm64 - run: ./packaging/windows/package.ps1 -Arch arm64 - - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 - with: - name: gridcraft-windows-arm64 - path: dist/arm64/* - if-no-files-found: error - retention-days: 7 - - install: - name: install and run on ARM64 - needs: package - runs-on: windows-11-arm - timeout-minutes: 30 - steps: - - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 - with: - name: gridcraft-windows-arm64 - path: dist - - name: Install the MSI, check it, run it, uninstall it - shell: pwsh - run: | - $ErrorActionPreference = 'Stop' - $msi = (Get-ChildItem dist/*.msi | Select-Object -First 1).FullName - $p = Start-Process msiexec.exe -ArgumentList '/i', "`"$msi`"", '/qn', '/l*v', 'install.log' -Wait -PassThru - if ($p.ExitCode -ne 0) { Get-Content install.log -Tail 60; throw "msiexec /i exited $($p.ExitCode)" } - $dir = Join-Path $env:ProgramFiles 'GridCraft' - foreach ($exe in 'gridcraft.exe', 'gridcraft-cli.exe') { - $path = Join-Path $dir $exe - if (-not (Test-Path $path)) { throw "$exe not installed in $dir" } - $bytes = [System.IO.File]::ReadAllBytes($path) - $machine = [BitConverter]::ToUInt16($bytes, [BitConverter]::ToInt32($bytes, 0x3C) + 4) - if ($machine -ne 0xAA64) { throw "$exe is for machine 0x$('{0:X}' -f $machine), not ARM64" } - Write-Output "ok $path (ARM64)" - } - # The installed CLI runs natively on ARM64. - $cli = Join-Path $dir 'gridcraft-cli.exe' - $out = & $cli --version 2>&1 | Out-String - if ($LASTEXITCODE -ne 0) { Write-Output $out; throw "gridcraft-cli --version exited $LASTEXITCODE" } - Write-Output "ok gridcraft-cli --version: $($out.Trim())" - $p = Start-Process msiexec.exe -ArgumentList '/x', "`"$msi`"", '/qn' -Wait -PassThru - if ($p.ExitCode -ne 0) { throw "msiexec /x exited $($p.ExitCode)" } - if (Test-Path (Join-Path $dir 'gridcraft.exe')) { throw 'uninstall left gridcraft.exe behind' } - Write-Output 'ok uninstalled' + windows-arm64: + # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. + uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@main + with: + app-name: gridcraft + app-dir: GridCraft From 2f6c2cd486d6fe0068e9152678ae32eac1f127f2 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sun, 11 Oct 2026 11:59:24 -0400 Subject: [PATCH 75/83] Fix-up: Spanish and German strings for Interface size (#294 met #242/#271); rustfmt after merges Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/i18n.rs | 2 +- crates/ui-egui/src/i18n/de.rs | 2 ++ crates/ui-egui/src/i18n/es.rs | 2 ++ crates/ui-egui/tests/interaction.rs | 11 ++++++++--- 4 files changed, 13 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index ef2f65e..3101e00 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -9,8 +9,8 @@ use serde::{Deserialize, Serialize}; -mod es; mod de; +mod es; mod ko; mod pt; mod ru; diff --git a/crates/ui-egui/src/i18n/de.rs b/crates/ui-egui/src/i18n/de.rs index d797baa..5640187 100644 --- a/crates/ui-egui/src/i18n/de.rs +++ b/crates/ui-egui/src/i18n/de.rs @@ -184,6 +184,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Gridlines" => "Gitternetzlinien", "Headings" => "Überschriften", "Zoom" => "Zoom", + "Interface size" => "Größe der Benutzeroberfläche", + "Auto" => "Automatisch", "100%" => "100%", "Zoom to\nSelection" => "Zoom auf die\nAuswahl", "Freeze\nPanes" => "Fenster\nfixieren", diff --git a/crates/ui-egui/src/i18n/es.rs b/crates/ui-egui/src/i18n/es.rs index fe201cc..18a30ab 100644 --- a/crates/ui-egui/src/i18n/es.rs +++ b/crates/ui-egui/src/i18n/es.rs @@ -185,6 +185,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Gridlines" => "Líneas de cuadrícula", "Headings" => "Encabezados", "Zoom" => "Zoom", + "Interface size" => "Tamaño de la interfaz", + "Auto" => "Automático", "100%" => "100%", "Zoom to\nSelection" => "Ampliar\nselección", "Freeze\nPanes" => "Inmovilizar\npaneles", diff --git a/crates/ui-egui/tests/interaction.rs b/crates/ui-egui/tests/interaction.rs index affb8bb..8ab774b 100644 --- a/crates/ui-egui/tests/interaction.rs +++ b/crates/ui-egui/tests/interaction.rs @@ -253,9 +253,14 @@ fn language_switch_translates_the_ribbon_and_persists() { for id in ["app.language.set", "app.language.english", "app.language.japanese"] { assert!(ids.contains(&id), "engine.commands lists {id}"); } - for (language, expected) in - [(Language::Zh, "数据"), (Language::Ja, "データ"), (Language::Ko, "데이터"), (Language::Ru, "Данные"), (Language::PtBr, "Dados"), (Language::De, "Daten")] - { + for (language, expected) in [ + (Language::Zh, "数据"), + (Language::Ja, "データ"), + (Language::Ko, "데이터"), + (Language::Ru, "Данные"), + (Language::PtBr, "Dados"), + (Language::De, "Daten"), + ] { h.state_mut().run("app.language.set", json!({"language": language.code()})).unwrap(); h.run_steps(2); // every locale renders the ribbon without panicking assert_eq!(h.state().ui.language, language); From 5f3e60b48a63f13e3b6549e2ffe691be5d17181f Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sun, 11 Oct 2026 12:02:26 -0400 Subject: [PATCH 76/83] Docs: headline counts after the community merges (509 / 523 functions, 500 working; catalog 260 / 290) Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- ROADMAP.md | 4 ++-- docs/function-parity.md | 2 +- docs/target-app-parity.md | 10 +++++----- docs/ui-parity.md | 2 +- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 8e65dbb..2331a73 100644 --- a/README.md +++ b/README.md @@ -197,7 +197,7 @@ egui frontend is a separate crate, so the UI can be swapped without touching the GridCraft is in **alpha**: the core workflow works end to end, but depth, fidelity and file compatibility are still rough. It covers 89% of the Excel ribbon commands in our catalog -([`docs/parity-checklist.md`](docs/parity-checklist.md)) and 505 of Excel's 523 worksheet functions +([`docs/parity-checklist.md`](docs/parity-checklist.md)) and 509 of Excel's 523 worksheet functions ([`docs/function-parity.md`](docs/function-parity.md)); weighted by what Excel users rely on, we estimate it is about 50% ready for real work. Next up: XLSX fidelity on real-world files, very large workbooks and multi-threaded recalc, chart depth, text in every script, and `.xls` import. Stage, diff --git a/ROADMAP.md b/ROADMAP.md index 5964519..450afc1 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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-11 · **Change:** minor (worksheet functions 505 / 523) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (community merges: worksheet functions 509 / 523, catalog 260 / 290) · **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. @@ -21,7 +21,7 @@ This page is the summary. The detail is in: | Number | Value | Kind | |---|---|---| -| **Feature breadth** (weighted) | **~75%** | estimated, anchored on two measurements: ribbon catalog 258 / 290 (89.0%), worksheet functions 505 / 523 (96.6%; 496 working, 94.8%) | +| **Feature breadth** (weighted) | **~75%** | estimated, anchored on two measurements: ribbon catalog 260 / 290 (89.7%), worksheet functions 509 / 523 (97.3%; 500 working, 95.6%) | | **Ready for real work** | **~50%** | estimated from written weights (51.6); [target-app-parity.md](docs/target-app-parity.md) | | **Mainstream practitioner** | **~50%** | estimated: typical analyst/finance user, weekly features, × 0.90 interaction × 0.92 stability × 0.88 file exchange; [method](docs/target-app-parity.md#mainstream-practitioner) | | **Essentials user** | **~63%** | estimated: core features only, × 0.92 launch/stability × 0.95 clarity × 0.90 opening sent files; [method](docs/target-app-parity.md#essentials-user) | diff --git a/docs/function-parity.md b/docs/function-parity.md index 75eddfc..4d1dfac 100644 --- a/docs/function-parity.md +++ b/docs/function-parity.md @@ -26,7 +26,7 @@ Excel's worksheet functions, category by category, against GridCraft's function ## Coverage -**Measured: 505 / 523 by name (96.6%); 496 / 523 working (94.8%).** +**Measured: 509 / 523 by name (97.3%); 500 / 523 working (95.6%).** | Category | Excel | Ours | Working | % working | Missing | |---|---|---|---|---|---| diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 3b8a914..08dabb6 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Target-app parity: GridCraft vs Microsoft Excel -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (worksheet functions 505 / 523: the four ODD* bond functions) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (community merges: worksheet functions 509 / 523, catalog 260 / 290) · **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: @@ -13,9 +13,9 @@ generated ribbon checklist is [`parity-checklist.md`](parity-checklist.md). | Number | Value | Kind | |---|---|---| | Feature breadth (weighted by use) | **~75%** | estimated from the area table below; anchored on two measurements | -| Ribbon and menu catalog | **258 / 290 (89.0%)** | measured: `cargo xtask parity` (`crates/engine/src/catalog.rs` ∩ registered command ids) | -| Worksheet functions, by name | **505 / 523 (96.6%)** | measured: registered functions vs Microsoft's published function list (method below) | -| Worksheet functions that do real work | **492 / 523 (94.1%)** | measured: excludes 9 stubs (7 CUBE functions, WEBSERVICE, FILTERXML) | +| Ribbon and menu catalog | **260 / 290 (89.7%)** | measured: `cargo xtask parity` (`crates/engine/src/catalog.rs` ∩ registered command ids) | +| Worksheet functions, by name | **509 / 523 (97.3%)** | measured: registered functions vs Microsoft's published function list (method below) | +| Worksheet functions that do real work | **500 / 523 (95.6%)** | measured: excludes 9 stubs (7 CUBE functions, WEBSERVICE, FILTERXML) | | **Ready for real work** | **~50%** | estimated; written weights below (51.6 computed) | | **Mainstream practitioner** | **~50%** | estimated; [method](#mainstream-practitioner) (69.5 depth × 0.90 × 0.92 × 0.88 = 50.6) | | **Essentials user** | **~63%** | estimated; [method](#essentials-user) (80.2 depth × 0.92 × 0.95 × 0.90 = 63.1) | @@ -80,7 +80,7 @@ performance 8, stability 8, localization 4, hardware 2, platforms 2, ecosystem 2 | Area | Weight | Breadth | Ready | Hours | Evidence | |---|---|---|---|---|---| | Formula language and calculation | 18 | ~92% | ~72% | 20–30 | A1/R1C1 parser, reference adjustment, dependency-graph recalc, dynamic arrays and `#SPILL!`, `A1#`, LET/LAMBDA and helpers, structured references, names, 3-D references. Open correctness bugs: 15-digit equality (#57), stale spills after structural edits and cleared blockers (#133, #139), 3-D span endpoint deletion (#140), sheet rename and qualified names (#134). Single-threaded | -| Worksheet functions | 12 | 96.6% (measured) | ~82% | 16–26 | 505 / 523 names, 496 working. Missing: FORECAST.ETS family, GETPIVOTDATA, TRIMRANGE, PERCENTOF, BAHTTEXT, IMAGE plus cloud functions. No conformance suite against Excel-computed oracles; edge-case bugs keep surfacing (#137, #138). [function-parity.md](function-parity.md) | +| Worksheet functions | 12 | 97.3% (measured) | ~82% | 16–26 | 509 / 523 names, 500 working. Missing: FORECAST.ETS family, GETPIVOTDATA, IMAGE plus cloud functions. No conformance suite against Excel-computed oracles; edge-case bugs keep surfacing (#137, #138). [function-parity.md](function-parity.md) | | Editing, navigation, clipboard, fill | 12 | ~90% | ~70% | 15–25 | Point mode, F4, AutoComplete, Paste Special, AutoFill, Flash Fill, Find/Replace, Go To Special. Bugs: rectangular drag-select (#28), monthly fill (#132), cut across sheets (#128), Insert Copied Cells missing (#21), key tips only on part of the Home tab (#43; #33, #49). [ui-parity.md](ui-parity.md) | | Cell formatting, styles, conditional formatting | 10 | ~95% | ~80% | 8–14 | Full number-format language, borders, 47 cell styles, 60 table styles, all CF rule kinds including data bars, colour scales and icon sets. Gaps: font weights beyond regular/bold (#168), system fonts list (#4), themes | | Sort, filter, tables, validation, outline | 8 | ~82% | ~70% | 10–16 | Multi-level sort, AutoFilter, Advanced Filter, tables with totals, validation, outline, subtotals, consolidate. Bugs: subtotal header (#131), validation edits (#129), table style panic caught (#130); no slicers on tables | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 410b8db..ed4cd1f 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -4,7 +4,7 @@ Interaction, controls and feel, against Excel. Part of [target-app-parity.md](target-app-parity.md). **UI/UX fidelity: ~55% ready, 50–80 h** (estimated). Ribbon command coverage is measured -separately in [parity-checklist.md](parity-checklist.md) (258 / 290). +separately in [parity-checklist.md](parity-checklist.md) (260 / 290). Sources: `crates/ui-egui/src` (ribbon, grid, editor, formula bar, dialogs, panes), `crates/engine/src/cmd` (shortcuts in command specs), the owner's black-box notes From 267a2183ac74feec6ab7a8750b397f204998a6ac Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sun, 11 Oct 2026 12:13:09 -0400 Subject: [PATCH 77/83] Fix-up: Backstage options read the formula bar from engine prefs (#331 after #295) Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/backstage.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/src/backstage.rs b/crates/ui-egui/src/backstage.rs index c291740..32000f7 100644 --- a/crates/ui-egui/src/backstage.rs +++ b/crates/ui-egui/src/backstage.rs @@ -773,7 +773,7 @@ fn options(app: &mut SheetApp, ui: &mut Ui) { ui.end_row(); }); ui.add_space(6.0); - let mut formula_bar = app.ui.formula_bar; + let mut formula_bar = app.session.prefs.formula_bar; if ui.checkbox(&mut formula_bar, lang.tr("Show formula bar")).changed() { app.run_or_alert("view.formulaBar", json!({"on": formula_bar})); } From b3b2cd322174ce3d3e8b83ef3e2fe6885ef100bc Mon Sep 17 00:00:00 2001 From: Losminek <301412321+Losminek@users.noreply.github.com> Date: Sun, 11 Oct 2026 18:16:03 +0200 Subject: [PATCH 78/83] i18n (cs): translate "Interface size" and "Auto" (#294) Two new upstream catalog strings (interface scale control in the View tab). Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/i18n/cs.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index 5b56b20..981e64b 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -184,6 +184,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Gridlines" => "Mřížka", "Headings" => "Záhlaví", "Zoom" => "Lupa", + "Interface size" => "Velikost rozhraní", + "Auto" => "Automaticky", "100%" => "100 %", "Zoom to\nSelection" => "Přiblížit\nna výběr", "Freeze\nPanes" => "Ukotvit\npříčky", From 4d2335c8f59f05005a93d0517d824ce792536faa Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sun, 11 Oct 2026 12:21:15 -0400 Subject: [PATCH 79/83] Fix-up: Spanish, German and Czech strings for the features merged alongside them; theme test goes through Backstage Options (#318 after #331) Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/i18n.rs | 2 +- crates/ui-egui/src/i18n/cs.rs | 72 ++++++++++++++++++++++++++++ crates/ui-egui/src/i18n/de.rs | 72 ++++++++++++++++++++++++++++ crates/ui-egui/src/i18n/es.rs | 70 +++++++++++++++++++++++++++ crates/ui-egui/tests/system_theme.rs | 3 +- 5 files changed, 217 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index bbaf71e..eae37a4 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -9,9 +9,9 @@ use serde::{Deserialize, Serialize}; +mod cs; mod de; mod es; -mod cs; mod ko; mod pt; mod ru; diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index 5b56b20..f8605b5 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -184,6 +184,78 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Gridlines" => "Mřížka", "Headings" => "Záhlaví", "Zoom" => "Lupa", + "Checkbox" => "Zaškrtávací políčko", + "Insert checkboxes in the selected cells" => "Vložit zaškrtávací políčka do vybraných buněk", + "Sheet\nRight-to-Left" => "List zprava\ndoleva", + "Sheet Right-to-Left" => "List zprava doleva", + "Data\nAnalysis" => "Analýza\ndat", + "Data Analysis" => "Analýza dat", + "Solver" => "Řešitel", + "Allow Edit\nRanges" => "Povolit úpravy\noblastí", + "Allow Edit Ranges" => "Povolit úpravy oblastí", + "Interface size" => "Velikost rozhraní", + "Auto" => "Automaticky", + "Drag to freeze columns" => "Přetažením ukotvíte sloupce", + "Drag to freeze rows" => "Přetažením ukotvíte řádky", + "Select\nData" => "Vybrat\ndata", + "File" => "Soubor", + "Back" => "Zpět", + "New" => "Nový", + "Open" => "Otevřít", + "Info" => "Informace", + "Save" => "Uložit", + "Save As" => "Uložit jako", + "Export" => "Exportovat", + "Close" => "Zavřít", + "Options" => "Možnosti", + "Blank workbook" => "Prázdný sešit", + "Recent" => "Naposledy použité", + "No recent workbooks yet." => "Zatím žádné naposledy použité sešity.", + "More workbooks" => "Další sešity", + "Browse…" => "Procházet…", + "Not saved yet" => "Zatím neuloženo", + "Location" => "Umístění", + "Sheets" => "Listy", + "Protect Workbook" => "Zamknout sešit", + "Sheet structure is locked: sheets can't be added, moved, renamed or deleted." => { + "Struktura listů je zamčená: listy nelze přidávat, přesouvat, přejmenovávat ani odstraňovat." + } + "Lock the sheet structure so sheets can't be added, moved, renamed or deleted." => { + "Zamkne strukturu listů, aby listy nebylo možné přidávat, přesouvat, přejmenovávat ani odstraňovat." + } + "Properties" => "Vlastnosti", + "Title" => "Název", + "Subject" => "Předmět", + "Author" => "Autor", + "Company" => "Společnost", + "Keywords" => "Klíčová slova", + "Description" => "Popis", + "No workbook is open." => "Není otevřený žádný sešit.", + "Save the workbook under another name or in another folder." => "Uloží sešit pod jiným názvem nebo do jiné složky.", + "Download (.xlsx)" => "Stáhnout (.xlsx)", + "PDF, CSV and web pages are on the Export page." => "PDF, CSV a webové stránky najdete na stránce Exportovat.", + "Print Active Sheets" => "Vytisknout aktivní listy", + "Print Entire Workbook" => "Vytisknout celý sešit", + "Fit Sheet on One Page" => "Přizpůsobit list na jednu stránku", + "Page Setup…" => "Vzhled stránky…", + "Page {page} of {pages}" => "Stránka {page} z {pages}", + "Nothing to print." => "Není co tisknout.", + "Previous page" => "Předchozí stránka", + "Next page" => "Další stránka", + "Create PDF" => "Vytvořit PDF", + "Save the sheet as a PDF document that keeps its page layout." => "Uloží list jako dokument PDF se zachováním rozložení stránky.", + "Change File Type" => "Změnit typ souboru", + "CSV (Comma delimited)" => "CSV (oddělený čárkami)", + "Save the active sheet as comma-separated text." => "Uloží aktivní list jako text oddělený čárkami.", + "Web Page (.html)" => "Webová stránka (.html)", + "Save the workbook as a web page." => "Uloží sešit jako webovou stránku.", + "Options|General" => "Obecné", + "Options|Save" => "Uložit", + "Show formula bar" => "Zobrazit řádek vzorců", + "Save .xlsx workbooks automatically after every change" => "Automaticky ukládat sešity .xlsx po každé změně", + "Workbook calculation" => "Výpočet sešitu", + "Enable iterative calculation" => "Povolit iterativní výpočet", + "Calculation settings are saved with the workbook." => "Nastavení výpočtů se ukládá se sešitem.", "100%" => "100 %", "Zoom to\nSelection" => "Přiblížit\nna výběr", "Freeze\nPanes" => "Ukotvit\npříčky", diff --git a/crates/ui-egui/src/i18n/de.rs b/crates/ui-egui/src/i18n/de.rs index 5640187..e0f3508 100644 --- a/crates/ui-egui/src/i18n/de.rs +++ b/crates/ui-egui/src/i18n/de.rs @@ -184,6 +184,78 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Gridlines" => "Gitternetzlinien", "Headings" => "Überschriften", "Zoom" => "Zoom", + "Checkbox" => "Kontrollkästchen", + "Insert checkboxes in the selected cells" => "Kontrollkästchen in die ausgewählten Zellen einfügen", + "Sheet\nRight-to-Left" => "Blatt von rechts\nnach links", + "Sheet Right-to-Left" => "Blatt von rechts nach links", + "Data\nAnalysis" => "Daten-\nanalyse", + "Data Analysis" => "Datenanalyse", + "Solver" => "Solver", + "Allow Edit\nRanges" => "Bearbeiten von\nBereichen zulassen", + "Allow Edit Ranges" => "Bearbeiten von Bereichen zulassen", + "Drag to freeze columns" => "Ziehen, um Spalten zu fixieren", + "Drag to freeze rows" => "Ziehen, um Zeilen zu fixieren", + "Select\nData" => "Daten\nauswählen", + "File" => "Datei", + "Back" => "Zurück", + "New" => "Neu", + "Open" => "Öffnen", + "Info" => "Informationen", + "Save" => "Speichern", + "Save As" => "Speichern unter", + "Export" => "Exportieren", + "Close" => "Schließen", + "Options" => "Optionen", + "Blank workbook" => "Leere Arbeitsmappe", + "Recent" => "Zuletzt verwendet", + "No recent workbooks yet." => "Noch keine zuletzt verwendeten Arbeitsmappen.", + "More workbooks" => "Weitere Arbeitsmappen", + "Browse…" => "Durchsuchen…", + "Not saved yet" => "Noch nicht gespeichert", + "Location" => "Speicherort", + "Sheets" => "Blätter", + "Protect Workbook" => "Arbeitsmappe schützen", + "Sheet structure is locked: sheets can't be added, moved, renamed or deleted." => { + "Die Blattstruktur ist gesperrt: Blätter können nicht hinzugefügt, verschoben, umbenannt oder gelöscht werden." + } + "Lock the sheet structure so sheets can't be added, moved, renamed or deleted." => { + "Sperrt die Blattstruktur, sodass Blätter nicht hinzugefügt, verschoben, umbenannt oder gelöscht werden können." + } + "Properties" => "Eigenschaften", + "Title" => "Titel", + "Subject" => "Thema", + "Author" => "Autor", + "Company" => "Firma", + "Keywords" => "Stichwörter", + "Description" => "Beschreibung", + "No workbook is open." => "Es ist keine Arbeitsmappe geöffnet.", + "Save the workbook under another name or in another folder." => { + "Speichert die Arbeitsmappe unter einem anderen Namen oder in einem anderen Ordner." + } + "Download (.xlsx)" => "Herunterladen (.xlsx)", + "PDF, CSV and web pages are on the Export page." => "PDF, CSV und Webseiten finden Sie auf der Seite „Exportieren“.", + "Print Active Sheets" => "Aktive Blätter drucken", + "Print Entire Workbook" => "Gesamte Arbeitsmappe drucken", + "Fit Sheet on One Page" => "Blatt auf einer Seite darstellen", + "Page Setup…" => "Seite einrichten…", + "Page {page} of {pages}" => "Seite {page} von {pages}", + "Nothing to print." => "Nichts zu drucken.", + "Previous page" => "Vorherige Seite", + "Next page" => "Nächste Seite", + "Create PDF" => "PDF erstellen", + "Save the sheet as a PDF document that keeps its page layout." => "Speichert das Blatt als PDF-Dokument mit unverändertem Seitenlayout.", + "Change File Type" => "Dateityp ändern", + "CSV (Comma delimited)" => "CSV (Trennzeichen-getrennt)", + "Save the active sheet as comma-separated text." => "Speichert das aktive Blatt als durch Kommas getrennten Text.", + "Web Page (.html)" => "Webseite (.html)", + "Save the workbook as a web page." => "Speichert die Arbeitsmappe als Webseite.", + "Options|General" => "Allgemein", + "Options|Save" => "Speichern", + "Show formula bar" => "Bearbeitungsleiste anzeigen", + "Save .xlsx workbooks automatically after every change" => ".xlsx-Arbeitsmappen nach jeder Änderung automatisch speichern", + "Workbook calculation" => "Arbeitsmappenberechnung", + "Enable iterative calculation" => "Iterative Berechnung aktivieren", + "Calculation settings are saved with the workbook." => "Berechnungseinstellungen werden mit der Arbeitsmappe gespeichert.", "Interface size" => "Größe der Benutzeroberfläche", "Auto" => "Automatisch", "100%" => "100%", diff --git a/crates/ui-egui/src/i18n/es.rs b/crates/ui-egui/src/i18n/es.rs index 18a30ab..fe6f368 100644 --- a/crates/ui-egui/src/i18n/es.rs +++ b/crates/ui-egui/src/i18n/es.rs @@ -185,6 +185,76 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Gridlines" => "Líneas de cuadrícula", "Headings" => "Encabezados", "Zoom" => "Zoom", + "Checkbox" => "Casilla", + "Insert checkboxes in the selected cells" => "Insertar casillas en las celdas seleccionadas", + "Sheet\nRight-to-Left" => "Hoja de derecha\na izquierda", + "Sheet Right-to-Left" => "Hoja de derecha a izquierda", + "Data\nAnalysis" => "Análisis\nde datos", + "Data Analysis" => "Análisis de datos", + "Solver" => "Solver", + "Allow Edit\nRanges" => "Permitir editar\nrangos", + "Allow Edit Ranges" => "Permitir editar rangos", + "Drag to freeze columns" => "Arrastrar para inmovilizar columnas", + "Drag to freeze rows" => "Arrastrar para inmovilizar filas", + "Select\nData" => "Seleccionar\ndatos", + "File" => "Archivo", + "Back" => "Atrás", + "New" => "Nuevo", + "Open" => "Abrir", + "Info" => "Información", + "Save" => "Guardar", + "Save As" => "Guardar como", + "Export" => "Exportar", + "Close" => "Cerrar", + "Options" => "Opciones", + "Blank workbook" => "Libro en blanco", + "Recent" => "Recientes", + "No recent workbooks yet." => "Todavía no hay libros recientes.", + "More workbooks" => "Más libros", + "Browse…" => "Examinar…", + "Not saved yet" => "Aún no se ha guardado", + "Location" => "Ubicación", + "Sheets" => "Hojas", + "Protect Workbook" => "Proteger libro", + "Sheet structure is locked: sheets can't be added, moved, renamed or deleted." => { + "La estructura de hojas está bloqueada: no se pueden agregar, mover, cambiar de nombre ni eliminar hojas." + } + "Lock the sheet structure so sheets can't be added, moved, renamed or deleted." => { + "Bloquea la estructura de hojas para que no se puedan agregar, mover, cambiar de nombre ni eliminar hojas." + } + "Properties" => "Propiedades", + "Title" => "Título", + "Subject" => "Asunto", + "Author" => "Autor", + "Company" => "Organización", + "Keywords" => "Palabras clave", + "Description" => "Descripción", + "No workbook is open." => "No hay ningún libro abierto.", + "Save the workbook under another name or in another folder." => "Guarda el libro con otro nombre o en otra carpeta.", + "Download (.xlsx)" => "Descargar (.xlsx)", + "PDF, CSV and web pages are on the Export page." => "PDF, CSV y páginas web están en la página Exportar.", + "Print Active Sheets" => "Imprimir hojas activas", + "Print Entire Workbook" => "Imprimir todo el libro", + "Fit Sheet on One Page" => "Ajustar hoja en una página", + "Page Setup…" => "Configurar página…", + "Page {page} of {pages}" => "Página {page} de {pages}", + "Nothing to print." => "No hay nada que imprimir.", + "Previous page" => "Página anterior", + "Next page" => "Página siguiente", + "Create PDF" => "Crear PDF", + "Save the sheet as a PDF document that keeps its page layout." => "Guarda la hoja como documento PDF conservando el diseño de página.", + "Change File Type" => "Cambiar el tipo de archivo", + "CSV (Comma delimited)" => "CSV (delimitado por comas)", + "Save the active sheet as comma-separated text." => "Guarda la hoja activa como texto separado por comas.", + "Web Page (.html)" => "Página web (.html)", + "Save the workbook as a web page." => "Guarda el libro como página web.", + "Options|General" => "General", + "Options|Save" => "Guardar", + "Show formula bar" => "Mostrar barra de fórmulas", + "Save .xlsx workbooks automatically after every change" => "Guardar automáticamente los libros .xlsx tras cada cambio", + "Workbook calculation" => "Cálculo del libro", + "Enable iterative calculation" => "Habilitar cálculo iterativo", + "Calculation settings are saved with the workbook." => "La configuración de cálculo se guarda con el libro.", "Interface size" => "Tamaño de la interfaz", "Auto" => "Automático", "100%" => "100%", diff --git a/crates/ui-egui/tests/system_theme.rs b/crates/ui-egui/tests/system_theme.rs index a0a6b00..73a4f19 100644 --- a/crates/ui-egui/tests/system_theme.rs +++ b/crates/ui-egui/tests/system_theme.rs @@ -178,9 +178,10 @@ fn title_bar_switch_and_file_menu_choose_the_theme() { h.run_steps(2); assert_appearance(&h, false); + // File opens Backstage; its Options page holds the Office Theme choice (#331). h.get_by_label("File").click(); h.run_steps(3); - h.get_by_label("Display theme ⏵").click(); + h.get_by_label("Options").click(); h.run_steps(3); h.get_by_label("System").click(); h.run_steps(3); From abbd8181b9ac5a4ac476ee8197eccfa498b484e0 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sun, 11 Oct 2026 12:31:17 -0400 Subject: [PATCH 80/83] Fix-up: theme test picks System from Backstage Options' Display theme combo (#318 after #331) Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/tests/system_theme.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/tests/system_theme.rs b/crates/ui-egui/tests/system_theme.rs index 73a4f19..e6b0542 100644 --- a/crates/ui-egui/tests/system_theme.rs +++ b/crates/ui-egui/tests/system_theme.rs @@ -181,7 +181,10 @@ fn title_bar_switch_and_file_menu_choose_the_theme() { // File opens Backstage; its Options page holds the Office Theme choice (#331). h.get_by_label("File").click(); h.run_steps(3); - h.get_by_label("Options").click(); + h.get_by_role_and_label(egui::accesskit::Role::Button, "Options").click(); + h.run_steps(3); + // The first combo box on the page is Display theme. + h.get_all_by_role(egui::accesskit::Role::ComboBox).next().expect("theme combo").click(); h.run_steps(3); h.get_by_label("System").click(); h.run_steps(3); From cd9662f1d4ec276b9261d73365e921837c0ba2bc Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sun, 11 Oct 2026 12:31:17 -0400 Subject: [PATCH 81/83] Docs: headline counts after wave 3 (513 / 523 functions, 504 working; catalog 266 / 290) Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- ROADMAP.md | 4 ++-- docs/function-parity.md | 2 +- docs/parity-checklist.md | 12 +++++------- docs/target-app-parity.md | 10 +++++----- docs/ui-parity.md | 2 +- 6 files changed, 15 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index fbae96b..72a1734 100644 --- a/README.md +++ b/README.md @@ -197,7 +197,7 @@ egui frontend is a separate crate, so the UI can be swapped without touching the GridCraft is in **alpha**: the core workflow works end to end, but depth, fidelity and file compatibility are still rough. It covers 89% of the Excel ribbon commands in our catalog -([`docs/parity-checklist.md`](docs/parity-checklist.md)) and 509 of Excel's 523 worksheet functions +([`docs/parity-checklist.md`](docs/parity-checklist.md)) and 513 of Excel's 523 worksheet functions ([`docs/function-parity.md`](docs/function-parity.md)); weighted by what Excel users rely on, we estimate it is about 50% ready for real work. Next up: XLSX fidelity on real-world files, very large workbooks and multi-threaded recalc, chart depth, text in every script, and `.xls` import. Stage, diff --git a/ROADMAP.md b/ROADMAP.md index 9561dbe..d44c743 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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-11 · **Change:** minor (community merges: worksheet functions 509 / 523, catalog 260 / 290) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (community merges: worksheet functions 513 / 523, catalog 266 / 290) · **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. @@ -21,7 +21,7 @@ This page is the summary. The detail is in: | Number | Value | Kind | |---|---|---| -| **Feature breadth** (weighted) | **~75%** | estimated, anchored on two measurements: ribbon catalog 260 / 290 (89.7%), worksheet functions 509 / 523 (97.3%; 500 working, 95.6%) | +| **Feature breadth** (weighted) | **~75%** | estimated, anchored on two measurements: ribbon catalog 266 / 290 (91.7%), worksheet functions 513 / 523 (98.1%; 504 working, 96.4%) | | **Ready for real work** | **~50%** | estimated from written weights (51.6); [target-app-parity.md](docs/target-app-parity.md) | | **Mainstream practitioner** | **~50%** | estimated: typical analyst/finance user, weekly features, × 0.90 interaction × 0.92 stability × 0.88 file exchange; [method](docs/target-app-parity.md#mainstream-practitioner) | | **Essentials user** | **~63%** | estimated: core features only, × 0.92 launch/stability × 0.95 clarity × 0.90 opening sent files; [method](docs/target-app-parity.md#essentials-user) | diff --git a/docs/function-parity.md b/docs/function-parity.md index 6afc4c3..901507e 100644 --- a/docs/function-parity.md +++ b/docs/function-parity.md @@ -27,7 +27,7 @@ Excel's worksheet functions, category by category, against GridCraft's function ## Coverage -**Measured: 509 / 523 by name (97.3%); 500 / 523 working (95.6%).** +**Measured: 513 / 523 by name (98.1%); 504 / 523 working (96.4%).** | Category | Excel | Ours | Working | % working | Missing | |---|---|---|---|---|---| diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 7cda7c6..3002b89 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -4,7 +4,7 @@ Each entry is an Excel ribbon or menu command (feature names only). It counts as implemented when GridCraft registers a command with the same id. This measures breadth only; the overall assessment (depth, fidelity, effort) is in [target-app-parity.md](target-app-parity.md). -**Overall: 260 / 290 commands implemented (89.7%).** +**Overall: 266 / 290 commands implemented (91.7%).** | Tab | Implemented | Total | Parity | |---|---|---|---| @@ -14,8 +14,8 @@ Each entry is an Excel ribbon or menu command (feature names only). It counts as | Draw | 4 | 4 | 100% | | Page Layout | 23 | 23 | 100% | | Formulas | 23 | 23 | 100% | -| Data | 21 | 28 | 75% | -| Review | 9 | 18 | 50% | +| Data | 22 | 28 | 79% | +| Review | 10 | 18 | 56% | | View | 20 | 20 | 100% | | Automate | 3 | 3 | 100% | | Table Design | 12 | 13 | 92% | @@ -237,7 +237,7 @@ Implemented: - `formulas.calculateNow` Calculate Now (Calculation) - `formulas.calculateSheet` Calculate Sheet (Calculation) -## Data (21 / 28, 75%) +## Data (22 / 28, 79%) Implemented: @@ -261,8 +261,8 @@ Implemented: - `data.subtotal` Subtotal (Outline) - `data.showDetail` Show Detail (Outline) - `data.hideDetail` Hide Detail (Outline) -- `data.analysisToolPak` Data Analysis (Analysis) - `data.solver` Solver (Analysis) +- `data.analysisToolPak` Data Analysis (Analysis) Missing: @@ -272,8 +272,6 @@ Missing: - `data.relationships` Relationships (Data Tools) - `data.forecastSheet` Forecast Sheet (Forecast) - `data.analyzeData` Analyze Data (Analysis) -- `data.solver` Solver (Analysis) -- `data.analysisToolPak` Data Analysis (Analysis) ## Review (10 / 18, 56%) diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 5966723..05ce818 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Target-app parity: GridCraft vs Microsoft Excel -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (community merges: worksheet functions 509 / 523, catalog 260 / 290) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (community merges: worksheet functions 513 / 523, catalog 266 / 290) · **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: @@ -13,9 +13,9 @@ generated ribbon checklist is [`parity-checklist.md`](parity-checklist.md). | Number | Value | Kind | |---|---|---| | Feature breadth (weighted by use) | **~75%** | estimated from the area table below; anchored on two measurements | -| Ribbon and menu catalog | **260 / 290 (89.7%)** | measured: `cargo xtask parity` (`crates/engine/src/catalog.rs` ∩ registered command ids) | -| Worksheet functions, by name | **509 / 523 (97.3%)** | measured: registered functions vs Microsoft's published function list (method below) | -| Worksheet functions that do real work | **500 / 523 (95.6%)** | measured: excludes 9 stubs (7 CUBE functions, WEBSERVICE, FILTERXML) | +| Ribbon and menu catalog | **266 / 290 (91.7%)** | measured: `cargo xtask parity` (`crates/engine/src/catalog.rs` ∩ registered command ids) | +| Worksheet functions, by name | **513 / 523 (98.1%)** | measured: registered functions vs Microsoft's published function list (method below) | +| Worksheet functions that do real work | **504 / 523 (96.4%)** | measured: excludes 9 stubs (7 CUBE functions, WEBSERVICE, FILTERXML) | | **Ready for real work** | **~50%** | estimated; written weights below (51.6 computed) | | **Mainstream practitioner** | **~50%** | estimated; [method](#mainstream-practitioner) (69.5 depth × 0.90 × 0.92 × 0.88 = 50.6) | | **Essentials user** | **~63%** | estimated; [method](#essentials-user) (80.2 depth × 0.92 × 0.95 × 0.90 = 63.1) | @@ -80,7 +80,7 @@ performance 8, stability 8, localization 4, hardware 2, platforms 2, ecosystem 2 | Area | Weight | Breadth | Ready | Hours | Evidence | |---|---|---|---|---|---| | Formula language and calculation | 18 | ~92% | ~72% | 20–30 | A1/R1C1 parser, reference adjustment, dependency-graph recalc, dynamic arrays and `#SPILL!`, `A1#`, LET/LAMBDA and helpers, structured references, names, 3-D references. Open correctness bugs: 15-digit equality (#57), stale spills after structural edits and cleared blockers (#133, #139), 3-D span endpoint deletion (#140), sheet rename and qualified names (#134). Single-threaded | -| Worksheet functions | 12 | 97.3% (measured) | ~82% | 16–26 | 509 / 523 names, 500 working. Missing: FORECAST.ETS family, GETPIVOTDATA, IMAGE plus cloud functions. No conformance suite against Excel-computed oracles; edge-case bugs keep surfacing (#137, #138). [function-parity.md](function-parity.md) | +| Worksheet functions | 12 | 98.1% (measured) | ~82% | 16–26 | 513 / 523 names, 504 working. Missing: GETPIVOTDATA, IMAGE plus cloud functions. No conformance suite against Excel-computed oracles; edge-case bugs keep surfacing (#137, #138). [function-parity.md](function-parity.md) | | Editing, navigation, clipboard, fill | 12 | ~90% | ~70% | 15–25 | Point mode, F4, AutoComplete, Paste Special, AutoFill, Flash Fill, Find/Replace, Go To Special. Bugs: rectangular drag-select (#28), monthly fill (#132), cut across sheets (#128), Insert Copied Cells missing (#21), key tips only on part of the Home tab (#43; #33, #49). [ui-parity.md](ui-parity.md) | | Cell formatting, styles, conditional formatting | 10 | ~95% | ~80% | 8–14 | Full number-format language, borders, 47 cell styles, 60 table styles, all CF rule kinds including data bars, colour scales and icon sets. Gaps: font weights beyond regular/bold (#168), system fonts list (#4), themes | | Sort, filter, tables, validation, outline | 8 | ~82% | ~70% | 10–16 | Multi-level sort, AutoFilter, Advanced Filter, tables with totals, validation, outline, subtotals, consolidate. Bugs: subtotal header (#131), validation edits (#129), table style panic caught (#130); no slicers on tables | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 7a810c0..a4f3b44 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -4,7 +4,7 @@ Interaction, controls and feel, against Excel. Part of [target-app-parity.md](target-app-parity.md). **UI/UX fidelity: ~55% ready, 50–80 h** (estimated). Ribbon command coverage is measured -separately in [parity-checklist.md](parity-checklist.md) (260 / 290). +separately in [parity-checklist.md](parity-checklist.md) (266 / 290). Sources: `crates/ui-egui/src` (ribbon, grid, editor, formula bar, dialogs, panes), `crates/engine/src/cmd` (shortcuts in command specs), the owner's black-box notes From 4cb4f6586eff5d8296afee20aaf04ae12fa9010e Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sun, 11 Oct 2026 12:42:02 -0400 Subject: [PATCH 82/83] rustfmt the sheet-tab scroll area (review fix-up for #268) Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/tabs.rs | 269 ++++++++++++++++++------------------- 1 file changed, 132 insertions(+), 137 deletions(-) diff --git a/crates/ui-egui/src/tabs.rs b/crates/ui-egui/src/tabs.rs index a56be84..a652a4f 100644 --- a/crates/ui-egui/src/tabs.rs +++ b/crates/ui-egui/src/tabs.rs @@ -40,146 +40,141 @@ pub fn sheet_tabs(app: &mut SheetApp, ui: &mut Ui) { // Keep worksheet tabs in their own horizontally scrollable // viewport, leaving room for the New Sheet button and grid scrollbar. let tabs_width = (ui.available_width() - 180.0).max(100.0); - ui.allocate_ui_with_layout( - vec2(tabs_width, 30.0), - egui::Layout::left_to_right(egui::Align::Center), - |ui| { - // Translate vertical wheel movement into horizontal - // worksheet-tab scrolling. - let wheel_scroll = if ui.rect_contains_pointer(ui.max_rect()) { - ui.input(|i| { - let d = i.smooth_scroll_delta; - if d.x.abs() > d.y.abs() { d.x } else { d.y } - }) - } else { - 0.0 - }; - - egui::ScrollArea::horizontal() - .id_salt("worksheet_tabs_horizontal") - .auto_shrink([false, false]) - .scroll_bar_visibility( - egui::scroll_area::ScrollBarVisibility::AlwaysHidden - ) - .show(ui, |ui| { - if wheel_scroll.abs() > 0.0 { - ui.scroll_with_delta(vec2(wheel_scroll, 0.0)); - } - ui.horizontal(|ui| { - ui.spacing_mut().item_spacing.x = 0.0; - for (i, name, color, active) in sheets { - let renaming = app.grid.renaming_tab == Some(i); - let font = if active { theme::ui_bold(13.0) } else { theme::ui_font(13.0) }; - let tw = ui.painter().layout_no_wrap(name.clone(), font.clone(), t.text).size().x; - let w = (tw + 32.0).max(72.0); - let (r, resp) = ui.allocate_exact_size(vec2(w, 30.0), Sense::click_and_drag()); - let r = r.shrink2(vec2(0.0, 1.0)); - if active { - ui.painter().rect_filled(Rect::from_min_max(pos2(r.left(), r.top() - 2.0), r.max), 4.0, t.tab_active); - ui.painter().rect_stroke( - Rect::from_min_max(pos2(r.left(), r.top() - 2.0), r.max), - 4.0, - Stroke::new(1.0, t.ribbon_border), - StrokeKind::Inside, - ); - } else if resp.hovered() { - ui.painter().rect_filled(r, 4.0, t.hover); - } - if let Some(c) = color { - let band = Rect::from_min_size(pos2(r.left() + 6.0, r.bottom() - 4.0), vec2(r.width() - 12.0, 3.0)); - ui.painter().rect_filled(band, 1.0, theme::color32(c)); - } else if active { - let band = Rect::from_center_size(pos2(r.center().x, r.bottom() - 3.0), vec2(tw.min(48.0), 2.5)); - ui.painter().rect_filled(band, 1.0, t.accent); - } - if renaming { - let mut text = app.grid.rename_text.clone(); - let mut child = ui.new_child(egui::UiBuilder::new().max_rect(r.shrink2(vec2(6.0, 5.0)))); - let ed = child.add(egui::TextEdit::singleline(&mut text).font(theme::ui_font(13.0)).desired_width(r.width() - 12.0)); - app.grid.rename_text = text.clone(); - if rename_needs_focus(ed.has_focus(), ed.lost_focus()) { - ed.request_focus(); - } - if ed.lost_focus() { - if !ui.input(|i| i.key_pressed(egui::Key::Escape)) && text != name { - app.run_or_alert("sheet.rename", json!({"sheet": i, "name": text})); - } - app.grid.renaming_tab = None; - } + ui.allocate_ui_with_layout(vec2(tabs_width, 30.0), egui::Layout::left_to_right(egui::Align::Center), |ui| { + // Translate vertical wheel movement into horizontal + // worksheet-tab scrolling. + let wheel_scroll = if ui.rect_contains_pointer(ui.max_rect()) { + ui.input(|i| { + let d = i.smooth_scroll_delta; + if d.x.abs() > d.y.abs() { d.x } else { d.y } + }) } else { - ui.painter().text(r.center(), Align2::CENTER_CENTER, &name, font, if active { t.accent } else { t.text }); - } - if resp.clicked() { - if app.editor.as_ref().is_some_and(|e| e.can_point()) { - // Point mode across sheets: switch without committing. - let _ = app.session.run("sheet.activate", json!({"sheet": i})); - } else { - if app.editor.is_some() { - app.commit_edit(0, 0, false, false); + 0.0 + }; + + egui::ScrollArea::horizontal() + .id_salt("worksheet_tabs_horizontal") + .auto_shrink([false, false]) + .scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysHidden) + .show(ui, |ui| { + if wheel_scroll.abs() > 0.0 { + ui.scroll_with_delta(vec2(wheel_scroll, 0.0)); } - app.run_or_alert("sheet.activate", json!({"sheet": i})); - } - } - if resp.double_clicked() { - app.grid.renaming_tab = Some(i); - app.grid.rename_text = name.clone(); - } - // Drag to reorder. - if resp.drag_stopped() - && let Some(p) = ui.input(|inp| inp.pointer.interact_pos()) - { - let dx = p.x - r.center().x; - let steps = (dx / w).round() as i64; - if steps != 0 { - let to = (i as i64 + steps).max(0) as usize; - app.run_or_alert("sheet.move", json!({"sheet": i, "to": to})); - } - } - resp.context_menu(|ui| { - ui.set_min_width(180.0); - if ui.button("Insert Sheet").clicked() { - app.run_or_alert("home.insertSheet", json!({"before": i})); - } - if ui.button("Delete").clicked() { - app.run_or_alert("home.deleteSheet", json!({"sheet": i})); - } - if ui.button("Rename").clicked() { - app.grid.renaming_tab = Some(i); - app.grid.rename_text = name.clone(); - } - if ui.button("Move or Copy…").clicked() { - app.open_dialog("moveSheet", json!({"sheet": i})); - } - if ui.button("Duplicate").clicked() { - app.run_or_alert("sheet.move", json!({"sheet": i, "to": i + 1, "copy": true})); - } - ui.menu_button("Tab Color", |ui| { - let colors = app.session.active().map(|d| d.wb.theme.colors).unwrap_or_default(); - if let Some(c) = crate::widgets::color_palette(ui, &colors, "No Color") { - app.run_or_alert("sheet.tabColor", json!({"sheet": i, "color": c})); - ui.close(); - } - }); - ui.separator(); - if ui.button("Hide").clicked() { - app.run_or_alert("sheet.hide", json!({"sheet": i})); - } - if ui.button("Unhide…").clicked() { - app.run_or_alert("sheet.unhide", json!({})); - } - ui.separator(); - if ui.button("Protect Sheet…").clicked() { - app.open_dialog("protectSheet", json!({})); - } - if ui.button("Select All Sheets").clicked() { - ui.close(); - } - }); - } - }); + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 0.0; + for (i, name, color, active) in sheets { + let renaming = app.grid.renaming_tab == Some(i); + let font = if active { theme::ui_bold(13.0) } else { theme::ui_font(13.0) }; + let tw = ui.painter().layout_no_wrap(name.clone(), font.clone(), t.text).size().x; + let w = (tw + 32.0).max(72.0); + let (r, resp) = ui.allocate_exact_size(vec2(w, 30.0), Sense::click_and_drag()); + let r = r.shrink2(vec2(0.0, 1.0)); + if active { + ui.painter().rect_filled(Rect::from_min_max(pos2(r.left(), r.top() - 2.0), r.max), 4.0, t.tab_active); + ui.painter().rect_stroke( + Rect::from_min_max(pos2(r.left(), r.top() - 2.0), r.max), + 4.0, + Stroke::new(1.0, t.ribbon_border), + StrokeKind::Inside, + ); + } else if resp.hovered() { + ui.painter().rect_filled(r, 4.0, t.hover); + } + if let Some(c) = color { + let band = Rect::from_min_size(pos2(r.left() + 6.0, r.bottom() - 4.0), vec2(r.width() - 12.0, 3.0)); + ui.painter().rect_filled(band, 1.0, theme::color32(c)); + } else if active { + let band = Rect::from_center_size(pos2(r.center().x, r.bottom() - 3.0), vec2(tw.min(48.0), 2.5)); + ui.painter().rect_filled(band, 1.0, t.accent); + } + if renaming { + let mut text = app.grid.rename_text.clone(); + let mut child = ui.new_child(egui::UiBuilder::new().max_rect(r.shrink2(vec2(6.0, 5.0)))); + let ed = + child.add(egui::TextEdit::singleline(&mut text).font(theme::ui_font(13.0)).desired_width(r.width() - 12.0)); + app.grid.rename_text = text.clone(); + if rename_needs_focus(ed.has_focus(), ed.lost_focus()) { + ed.request_focus(); + } + if ed.lost_focus() { + if !ui.input(|i| i.key_pressed(egui::Key::Escape)) && text != name { + app.run_or_alert("sheet.rename", json!({"sheet": i, "name": text})); + } + app.grid.renaming_tab = None; + } + } else { + ui.painter().text(r.center(), Align2::CENTER_CENTER, &name, font, if active { t.accent } else { t.text }); + } + if resp.clicked() { + if app.editor.as_ref().is_some_and(|e| e.can_point()) { + // Point mode across sheets: switch without committing. + let _ = app.session.run("sheet.activate", json!({"sheet": i})); + } else { + if app.editor.is_some() { + app.commit_edit(0, 0, false, false); + } + app.run_or_alert("sheet.activate", json!({"sheet": i})); + } + } + if resp.double_clicked() { + app.grid.renaming_tab = Some(i); + app.grid.rename_text = name.clone(); + } + // Drag to reorder. + if resp.drag_stopped() + && let Some(p) = ui.input(|inp| inp.pointer.interact_pos()) + { + let dx = p.x - r.center().x; + let steps = (dx / w).round() as i64; + if steps != 0 { + let to = (i as i64 + steps).max(0) as usize; + app.run_or_alert("sheet.move", json!({"sheet": i, "to": to})); + } + } + resp.context_menu(|ui| { + ui.set_min_width(180.0); + if ui.button("Insert Sheet").clicked() { + app.run_or_alert("home.insertSheet", json!({"before": i})); + } + if ui.button("Delete").clicked() { + app.run_or_alert("home.deleteSheet", json!({"sheet": i})); + } + if ui.button("Rename").clicked() { + app.grid.renaming_tab = Some(i); + app.grid.rename_text = name.clone(); + } + if ui.button("Move or Copy…").clicked() { + app.open_dialog("moveSheet", json!({"sheet": i})); + } + if ui.button("Duplicate").clicked() { + app.run_or_alert("sheet.move", json!({"sheet": i, "to": i + 1, "copy": true})); + } + ui.menu_button("Tab Color", |ui| { + let colors = app.session.active().map(|d| d.wb.theme.colors).unwrap_or_default(); + if let Some(c) = crate::widgets::color_palette(ui, &colors, "No Color") { + app.run_or_alert("sheet.tabColor", json!({"sheet": i, "color": c})); + ui.close(); + } + }); + ui.separator(); + if ui.button("Hide").clicked() { + app.run_or_alert("sheet.hide", json!({"sheet": i})); + } + if ui.button("Unhide…").clicked() { + app.run_or_alert("sheet.unhide", json!({})); + } + ui.separator(); + if ui.button("Protect Sheet…").clicked() { + app.open_dialog("protectSheet", json!({})); + } + if ui.button("Select All Sheets").clicked() { + ui.close(); + } + }); + } }); - }, - ); + }); + }); ui.add_space(6.0); if crate::widgets::icon_button(ui, Icon::Plus, t.text_dim, "New sheet (⇧F11)", vec2(26.0, 26.0)).clicked() { app.run_or_alert("home.insertSheet", json!({})); From c83f214c9d16cd145e971c63c6ac3dae9320c38a Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sun, 11 Oct 2026 13:00:27 -0400 Subject: [PATCH 83/83] Spanish, German and Czech strings for comment navigation (review fix-up for #286) Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/i18n/cs.rs | 6 ++++++ crates/ui-egui/src/i18n/de.rs | 6 ++++++ crates/ui-egui/src/i18n/es.rs | 6 ++++++ 3 files changed, 18 insertions(+) diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index f8605b5..0d01ba9 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -184,6 +184,12 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Gridlines" => "Mřížka", "Headings" => "Záhlaví", "Zoom" => "Lupa", + "Previous" => "Předchozí", + "Next" => "Další", + "Previous Comment" => "Předchozí komentář", + "Next Comment" => "Další komentář", + "Show All Notes" => "Zobrazit všechny poznámky", + "Convert to Comments" => "Převést na komentáře", "Checkbox" => "Zaškrtávací políčko", "Insert checkboxes in the selected cells" => "Vložit zaškrtávací políčka do vybraných buněk", "Sheet\nRight-to-Left" => "List zprava\ndoleva", diff --git a/crates/ui-egui/src/i18n/de.rs b/crates/ui-egui/src/i18n/de.rs index e0f3508..c5f7119 100644 --- a/crates/ui-egui/src/i18n/de.rs +++ b/crates/ui-egui/src/i18n/de.rs @@ -184,6 +184,12 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Gridlines" => "Gitternetzlinien", "Headings" => "Überschriften", "Zoom" => "Zoom", + "Previous" => "Zurück", + "Next" => "Weiter", + "Previous Comment" => "Vorheriger Kommentar", + "Next Comment" => "Nächster Kommentar", + "Show All Notes" => "Alle Notizen anzeigen", + "Convert to Comments" => "In Kommentare konvertieren", "Checkbox" => "Kontrollkästchen", "Insert checkboxes in the selected cells" => "Kontrollkästchen in die ausgewählten Zellen einfügen", "Sheet\nRight-to-Left" => "Blatt von rechts\nnach links", diff --git a/crates/ui-egui/src/i18n/es.rs b/crates/ui-egui/src/i18n/es.rs index fe6f368..7cf8c9a 100644 --- a/crates/ui-egui/src/i18n/es.rs +++ b/crates/ui-egui/src/i18n/es.rs @@ -185,6 +185,12 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Gridlines" => "Líneas de cuadrícula", "Headings" => "Encabezados", "Zoom" => "Zoom", + "Previous" => "Anterior", + "Next" => "Siguiente", + "Previous Comment" => "Comentario anterior", + "Next Comment" => "Comentario siguiente", + "Show All Notes" => "Mostrar todas las notas", + "Convert to Comments" => "Convertir en comentarios", "Checkbox" => "Casilla", "Insert checkboxes in the selected cells" => "Insertar casillas en las celdas seleccionadas", "Sheet\nRight-to-Left" => "Hoja de derecha\na izquierda",
1