Merge branch 'main' into pr/macros-ui

This commit is contained in:
Pranav
2026-10-25 23:54:09 -04:00
committed by GitHub
243 changed files with 10615 additions and 2041 deletions
+1 -1
View File
@@ -4,7 +4,7 @@
use std::collections::HashMap;
use gridcraft_core::{CellRef, RangeRef, Value, compare};
use gridcraft_model::*;
use gridcraft_model::{CfOperator, CfRule, CfValueKind, Color, CondFormat, PatternType, Sheet, Style, Underline, Workbook, default_icon_thresholds};
/// The conditional look of one cell.
#[derive(Clone, Debug, Default, PartialEq)]
+29 -25
View File
@@ -1454,6 +1454,34 @@ fn show_levels(s: &mut Session, p: &Json) -> Result<Json> {
/// Data ▸ Outline ▸ Settings: whether summary rows sit below their detail and summary columns
/// right of it. A changed direction moves each collapsed group's mark (+) to its new summary line.
/// Moves the collapsed marks of a line axis to the summary line on its new side (`on` is the new
/// setting, `was_before` the old one). A line that nested groups share keeps its mark on the outermost
/// group whose detail is all hidden, or the outermost when none is.
fn move_marks(map: &mut std::collections::BTreeMap<u32, LineInfo>, was_before: bool, on: bool) {
let mut marked: Vec<outline::OutlineGroup> =
outline::groups_with(map, was_before).into_iter().filter(|g| g.has_summary() && map.get(&g.summary()).is_some_and(|e| e.collapsed)).collect();
// A mark on a line that nested groups share moves with the outermost of them whose
// detail is all hidden, or with the outermost when none is.
let mut owner: std::collections::BTreeMap<u32, (bool, u8)> = std::collections::BTreeMap::new();
for g in &marked {
let key = (!g.collapsed, g.level);
let best = owner.entry(g.summary()).or_insert(key);
*best = (*best).min(key);
}
marked.retain(|g| owner.get(&g.summary()) == Some(&(!g.collapsed, g.level)));
for g in &marked {
if let Some(e) = map.get_mut(&g.summary()) {
e.collapsed = false;
}
}
for g in marked {
let g = outline::OutlineGroup { summary_before: !on, ..g };
if g.has_summary() {
map.entry(g.summary()).or_default().collapsed = true;
}
}
}
fn outline_settings(s: &mut Session, p: &Json) -> Result<Json> {
let si = target_sheet(s, p)?;
edit(s, |cx| {
@@ -1469,31 +1497,7 @@ fn outline_settings(s: &mut Session, p: &Json) -> Result<Json> {
if was_before != on {
continue;
}
let map = if rows { &mut sh.rows } else { &mut sh.cols };
let mut marked: Vec<outline::OutlineGroup> = outline::groups_with(map, was_before)
.into_iter()
.filter(|g| g.has_summary() && map.get(&g.summary()).is_some_and(|e| e.collapsed))
.collect();
// A mark on a line that nested groups share moves with the outermost of them whose
// detail is all hidden, or with the outermost when none is.
let mut owner: std::collections::BTreeMap<u32, (bool, u8)> = std::collections::BTreeMap::new();
for g in &marked {
let key = (!g.collapsed, g.level);
let best = owner.entry(g.summary()).or_insert(key);
*best = (*best).min(key);
}
marked.retain(|g| owner.get(&g.summary()) == Some(&(!g.collapsed, g.level)));
for g in &marked {
if let Some(e) = map.get_mut(&g.summary()) {
e.collapsed = false;
}
}
for g in marked {
let g = outline::OutlineGroup { summary_before: !on, ..g };
if g.has_summary() {
map.entry(g.summary()).or_default().collapsed = true;
}
}
move_marks(if rows { &mut sh.rows } else { &mut sh.cols }, was_before, on);
}
Ok(json!({"summaryBelow": sh.summary_below, "summaryRight": sh.summary_right}))
})
+1 -1
View File
@@ -941,7 +941,7 @@ fn from_csv(s: &mut Session, p: &Json) -> Result<Json> {
return Err(EngineError::Other("Nothing to import.".into()));
}
let name = str_param(p, "sheet").map(str::to_string).or_else(|| {
str_param(p, "path").and_then(|x| std::path::Path::new(x).file_stem().and_then(|f| f.to_str()).map(|f| f.chars().take(31).collect()))
str_param(p, "path").and_then(|x| std::path::Path::new(x).file_stem().and_then(std::ffi::OsStr::to_str).map(|f| f.chars().take(31).collect()))
});
let r = s.execute("home.insertSheet", json!({"name": name})).or_else(|_| s.execute("home.insertSheet", json!({})))?;
let w = rows.iter().map(Vec::len).max().unwrap_or(1).max(1);
+3 -3
View File
@@ -69,9 +69,9 @@ fn open(s: &mut Session, p: &Json) -> Result<Json> {
matches!(crate::io::FileKind::from_path(&name), Some(crate::io::FileKind::Ods | crate::io::FileKind::Xlsb | crate::io::FileKind::Xls))
|| matches!(gridcraft_xlsx::sniff(&bytes), gridcraft_xlsx::Format::Ods | gridcraft_xlsx::Format::Xlsb | gridcraft_xlsx::Format::Xls);
let title = if imported {
format!("{} (imported).xlsx", std::path::Path::new(&name).file_stem().and_then(|n| n.to_str()).unwrap_or("Book"))
format!("{} (imported).xlsx", std::path::Path::new(&name).file_stem().and_then(std::ffi::OsStr::to_str).unwrap_or("Book"))
} else {
std::path::Path::new(&name).file_name().and_then(|n| n.to_str()).unwrap_or("Book").to_string()
std::path::Path::new(&name).file_name().and_then(std::ffi::OsStr::to_str).unwrap_or("Book").to_string()
};
// An import has no save target: ordinary Save must ask for a new destination. Neither does a
// workbook decrypted with its password, so Save never replaces the encrypted original with
@@ -114,7 +114,7 @@ fn save(s: &mut Session, p: &Json) -> Result<Json> {
};
// A PDF is a print of the sheet (Page Setup applies), not a copy of the workbook: the
// document keeps its file and its unsaved changes.
if std::path::Path::new(&path).extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case("pdf")) {
if std::path::Path::new(&path).extension().and_then(std::ffi::OsStr::to_str).is_some_and(|e| e.eq_ignore_ascii_case("pdf")) {
return super::print::export_pdf(s, &json!({"path": path}));
}
let d = s.doc_mut()?;
+90 -33
View File
@@ -96,42 +96,73 @@ pub fn specs() -> Vec<CommandSpec> {
]
}
fn auto_sum(s: &mut Session, p: &Json) -> Result<Json> {
let func = str_param(p, "function").unwrap_or("SUM").to_ascii_uppercase();
let d = s.doc()?;
let sh = d.wb.active().ok_or(EngineError::NoDocument)?;
let sel = match str_param(p, "range") {
Some(_) => target_range(s, p)?,
None => d.selection.current(),
};
if !sel.is_single() {
// A block: totals below each column (and to the right when a blank column is included).
// Columns past the sheet's data get no total (a whole-row selection doesn't write 16,384
// formulas), and all totals are one edit (one undo step).
let Some(last_col) = sh.cells.used_range().map(|u| u.end.col.min(sel.end.col)) else { return ok() };
let last_row_blank = (sel.start.col..=last_col).all(|c| sh.value(CellRef::new(sel.end.row, c)).is_empty());
let (data_end_row, out_row) = if last_row_blank { (sel.end.row.saturating_sub(1), sel.end.row) } else { (sel.end.row, sel.end.row + 1) };
/// Whether a cell on the active sheet is empty.
fn blank(sh: &gridcraft_model::sheet::Sheet, row: u32, col: u32) -> bool {
sh.value(CellRef::new(row, col)).is_empty()
}
fn sum_formula(func: &str, from: CellRef, to: CellRef) -> String {
format!("={func}({})", RangeRef::new(from, to).a1())
}
/// Totals for a multi-cell selection as `(cell, formula)` writes. `None` when the sheet has no data.
fn selection_totals(sh: &gridcraft_model::sheet::Sheet, sel: RangeRef, func: &str) -> Result<Option<Vec<(CellRef, String)>>> {
// A selection: totals go in its empty last row or column, as Excel's do, and otherwise
// past it: to the right of a single row, below each column of anything taller. Columns
// past the sheet's data get no total (a whole-row selection doesn't write 16,384
// formulas), nor do rows past it, and all totals are one edit (one undo step).
let Some(used) = sh.cells.used_range() else { return Ok(None) };
if sel.start.row == sel.end.row { single_row_totals(sh, sel, used, func).map(Some) } else { block_totals(sh, sel, used, func).map(Some) }
}
/// One row: its total goes in its last cell when that is empty, else just after it.
fn single_row_totals(sh: &gridcraft_model::sheet::Sheet, sel: RangeRef, used: RangeRef, func: &str) -> Result<Vec<(CellRef, String)>> {
let row = sel.start.row;
let last_col = used.end.col.min(sel.end.col);
let end_col = if sel.is_full_rows() { last_col } else { sel.end.col };
let (data_end, out) = if end_col > sel.start.col && blank(sh, row, end_col) { (end_col - 1, end_col) } else { (end_col, end_col + 1) };
if out >= gridcraft_core::MAX_COLS {
return Err(EngineError::Other("There's no room to the right of the selection for the total.".into()));
}
Ok(vec![(CellRef::new(row, out), sum_formula(func, CellRef::new(row, sel.start.col), CellRef::new(row, data_end)))])
}
/// A block of rows: each column's total below it, and each row's total in an empty last column.
fn block_totals(sh: &gridcraft_model::sheet::Sheet, sel: RangeRef, used: RangeRef, func: &str) -> Result<Vec<(CellRef, String)>> {
let mut writes: Vec<(CellRef, String)> = Vec::new();
let last_col = used.end.col.min(sel.end.col);
// An empty last column takes each row's total; rows past the data get none.
let rows_end = sel.end.row.min(used.end.row);
let right_blank = !sel.is_full_rows() && sel.end.col > sel.start.col && (sel.start.row..=rows_end).all(|r| blank(sh, r, sel.end.col));
let data_end_col = if right_blank { sel.end.col - 1 } else { last_col };
let last_row_blank = (sel.start.col..=last_col).all(|c| blank(sh, sel.end.row, c));
let (data_end_row, out_row) = if last_row_blank { (sel.end.row.saturating_sub(1), sel.end.row) } else { (sel.end.row, sel.end.row + 1) };
// Each column's total, below it: unless only the last column is there to take totals.
if last_row_blank || !right_blank {
if out_row >= gridcraft_core::MAX_ROWS {
return Err(EngineError::Other("There's no room below the selection for the totals.".into()));
}
let writes: Vec<(CellRef, String)> = (sel.start.col..=last_col)
.map(|c| {
(CellRef::new(out_row, c), format!("={func}({})", RangeRef::new(CellRef::new(sel.start.row, c), CellRef::new(data_end_row, c)).a1()))
})
.collect();
let sheet = d.wb.active_sheet;
edit(s, |cx| {
for (c, f) in &writes {
let cell = super::edit::input_to_cell(f, sheet, *c, &mut cx.wb)?.unwrap_or_default();
cx.sheet_mut(sheet)?.set_cell(*c, cell);
cx.touch(sheet, *c);
}
Ok(())
})?;
return ok();
for c in sel.start.col..=data_end_col.min(last_col) {
writes.push((CellRef::new(out_row, c), sum_formula(func, CellRef::new(sel.start.row, c), CellRef::new(data_end_row, c))));
}
}
// Single cell: guess the range above, else to the left.
let at = sel.start;
if right_blank {
for r in sel.start.row..=data_end_row.min(rows_end) {
writes.push((CellRef::new(r, sel.end.col), sum_formula(func, CellRef::new(r, sel.start.col), CellRef::new(r, data_end_col))));
}
// Both empty: the corner takes the total of the whole block.
if last_row_blank {
writes.push((
CellRef::new(sel.end.row, sel.end.col),
sum_formula(func, CellRef::new(sel.start.row, sel.start.col), CellRef::new(data_end_row, data_end_col)),
));
}
}
Ok(writes)
}
/// The range a single cell's AutoSum guesses: the numbers above it, else the numbers to its left.
fn guessed_range(sh: &gridcraft_model::sheet::Sheet, at: CellRef) -> Option<RangeRef> {
let numeric = |c: CellRef| sh.value(c).is_number();
let mut range = None;
if at.row > 0 && (numeric(CellRef::new(at.row - 1, at.col)) || at.col == 0 || !numeric(CellRef::new(at.row, at.col - 1))) {
@@ -153,7 +184,33 @@ fn auto_sum(s: &mut Session, p: &Json) -> Result<Json> {
}
range = Some(RangeRef::new(CellRef::new(at.row, left), CellRef::new(at.row, at.col - 1)));
}
let text = match range {
range
}
fn auto_sum(s: &mut Session, p: &Json) -> Result<Json> {
let func = str_param(p, "function").unwrap_or("SUM").to_ascii_uppercase();
let d = s.doc()?;
let sh = d.wb.active().ok_or(EngineError::NoDocument)?;
let sel = match str_param(p, "range") {
Some(_) => target_range(s, p)?,
None => d.selection.current(),
};
if !sel.is_single() {
let Some(writes) = selection_totals(sh, sel, &func)? else { return ok() };
let sheet = d.wb.active_sheet;
edit(s, |cx| {
for (c, f) in &writes {
let cell = super::edit::input_to_cell(f, sheet, *c, &mut cx.wb)?.unwrap_or_default();
cx.sheet_mut(sheet)?.set_cell(*c, cell);
cx.touch(sheet, *c);
}
Ok(())
})?;
return ok();
}
// Single cell: guess the range above, else to the left.
let at = sel.start;
let text = match guessed_range(sh, at) {
Some(r) => format!("={func}({})", r.a1()),
None => {
s.ui_requests.push(crate::UiRequest::EditCell(Some(format!("={func}()"))));
+13 -2
View File
@@ -1317,9 +1317,20 @@ fn comment(s: &mut Session, p: &Json, threaded: bool) -> Result<Json> {
let sheet = target_sheet(s, p)?;
let at = cell_param_on(s, p, "cell", sheet)?.unwrap_or(s.doc()?.selection.active);
let text = str_param(p, "text").unwrap_or("").to_string();
let author = str_param(p, "author").map(str::to_string).unwrap_or_else(|| s.prefs.user_name.clone());
let named = str_param(p, "author").map(str::to_string);
let author = named.clone().unwrap_or_else(|| s.prefs.user_name.clone());
edit(s, |cx| {
cx.sheet_mut(sheet)?.comments.insert(
let comments = &mut cx.sheet_mut(sheet)?.comments;
// Writing a note on a cell that has one edits it, as Excel's Edit Note does: the box, the
// replies, the dates and who wrote it stay. A note replaces a comment and vice versa.
if let Some(c) = comments.get_mut(&at).filter(|c| c.threaded == threaded) {
c.text = text.clone();
if let Some(author) = named.clone() {
c.author = author;
}
return Ok(json!({"cell": at.a1()}));
}
comments.insert(
at,
Comment {
author: author.clone(),
+1 -1
View File
@@ -167,7 +167,7 @@ pub fn find_command(id: &str) -> Option<&'static CommandSpec> {
test_commands().iter().find(|c| c.id == id).copied()
}
static TEST_COMMANDS: std::sync::Mutex<Vec<&'static CommandSpec>> = std::sync::Mutex::new(Vec::new());
static TEST_COMMANDS: std::sync::Mutex<Vec<&CommandSpec>> = std::sync::Mutex::new(Vec::new());
/// Test-only: register an extra command so UI integration tests can prove a pending
/// keytip becomes live the moment its command lands (see crates/ui-egui/tests/keytips.rs).
+1 -1
View File
@@ -253,7 +253,7 @@ fn find_pivot(s: &Session, p: &Json) -> Result<(usize, usize)> {
}
fn area_param(cmd: &str, v: Option<&str>) -> Result<&'static str> {
match v.map(|x| x.to_ascii_lowercase()) {
match v.map(str::to_ascii_lowercase) {
Some(a) if a == "rows" || a == "row" => Ok("rows"),
Some(a) if a == "columns" || a == "column" || a == "cols" => Ok("columns"),
Some(a) if a == "values" || a == "value" || a == "data" => Ok("values"),
+1 -1
View File
@@ -617,7 +617,7 @@ pub fn expand_header_footer(spec: &str, cx: &HfContext) -> [HfSection; 3] {
i += rest.chars().count() + 1;
}
d if d.is_ascii_digit() => {
let digits: String = std::iter::once(d).chain(chars[i..].iter().copied().take_while(|c| c.is_ascii_digit())).take(3).collect();
let digits: String = std::iter::once(d).chain(chars[i..].iter().copied().take_while(char::is_ascii_digit)).take(3).collect();
i += digits.len() - 1;
if let Ok(v) = digits.parse::<f32>() {
sec.size = Some(v.clamp(1.0, 409.0));
+34 -20
View File
@@ -7,7 +7,10 @@ use gridcraft_formula::adjust::{Axis, Edit};
use gridcraft_model::{Anchor, AnchorMode, Sheet, Visibility};
use serde_json::{Value as Json, json};
use super::*;
use super::{
CommandSpec, Ctx, EngineError, PROTECTED, Result, Session, bad, bool_param, cell_param_on, cmd, edit, has_clipboard, has_doc, ok, parse_lines,
str_param, target_range, target_sheet, u32_param,
};
pub fn specs() -> Vec<CommandSpec> {
vec![
@@ -203,11 +206,13 @@ fn shift_sheet_features(sh: &mut Sheet, axis: Axis, at: u32, count: u32, insert:
for (o, a) in sh.shapes.iter_mut().zip(new_shapes) {
o.anchor = a;
}
for sp in sh.sparklines.iter_mut() {
if let Some(c) = map_cell(sp.cell) {
sh.sparklines.retain_mut(|sp| match map_cell(sp.cell) {
Some(c) => {
sp.cell = c;
true
}
}
None => false,
});
if let Some(pa) = sh.print.print_area {
sh.print.print_area = map_range(pa);
}
@@ -272,6 +277,29 @@ fn cancel_copy_mode(s: &mut Session) {
}
}
fn inherit_line_format(sh: &mut Sheet, axis: Axis, at: u32, count: u32) {
if at == 0 {
return;
}
let prev = if axis == Axis::Rows { sh.rows.get(&(at - 1)).copied() } else { sh.cols.get(&(at - 1)).copied() };
if let Some(info) = prev {
for i in at..at + count {
let map = if axis == Axis::Rows { &mut sh.rows } else { &mut sh.cols };
map.insert(i, gridcraft_model::LineInfo { hidden: false, outline: 0, collapsed: false, ..info });
}
}
}
fn check_push_off_sheet(s: &Session, sheet: usize, axis: Axis, at: u32, count: u32, limit: u32) -> Result<()> {
if let Some(u) = s.doc()?.wb.sheet(sheet).and_then(|sh| sh.used_range()) {
let last = if axis == Axis::Rows { u.end.row } else { u.end.col };
if last >= at && last as u64 + count as u64 >= limit as u64 {
return Err(EngineError::Other("To prevent possible loss of data, Excel cannot shift nonblank cells off of the worksheet.".into()));
}
}
Ok(())
}
fn insert_lines(s: &mut Session, p: &Json, axis: Axis) -> Result<Json> {
let (at, count) = lines(s, p, axis)?;
let limit = if axis == Axis::Rows { MAX_ROWS } else { MAX_COLS };
@@ -281,13 +309,7 @@ fn insert_lines(s: &mut Session, p: &Json, axis: Axis) -> Result<Json> {
}
check_lines_protection(s, p, axis, at, count, true)?;
let sheet = target_sheet(s, p)?;
// Refuse to push data off the sheet.
if let Some(u) = s.doc()?.wb.sheet(sheet).and_then(|sh| sh.used_range()) {
let last = if axis == Axis::Rows { u.end.row } else { u.end.col };
if last >= at && last as u64 + count as u64 >= limit as u64 {
return Err(EngineError::Other("To prevent possible loss of data, Excel cannot shift nonblank cells off of the worksheet.".into()));
}
}
check_push_off_sheet(s, sheet, axis, at, count, limit)?;
let out = edit(s, |cx| {
let name = cx.wb.sheet(sheet).map(|s| s.name.clone()).unwrap_or_default();
cx.protection_checked = true;
@@ -299,15 +321,7 @@ fn insert_lines(s: &mut Session, p: &Json, axis: Axis) -> Result<Json> {
}
shift_sheet_features(sh, axis, at, count, true);
// New lines take the format of the line before (Excel's "Format Same As Above").
if at > 0 {
let prev = if axis == Axis::Rows { sh.rows.get(&(at - 1)).copied() } else { sh.cols.get(&(at - 1)).copied() };
if let Some(info) = prev {
for i in at..at + count {
let map = if axis == Axis::Rows { &mut sh.rows } else { &mut sh.cols };
map.insert(i, gridcraft_model::LineInfo { hidden: false, outline: 0, collapsed: false, ..info });
}
}
}
inherit_line_format(sh, axis, at, count);
}
rewrite(cx, &name, &Edit::Insert { axis, at, count });
cx.structural = true;
+24 -24
View File
@@ -28,8 +28,10 @@ use gridcraft_core::{CellRef, RangeRef, Value};
use gridcraft_model::{Cell, DefinedName, Sheet, Workbook};
use serde_json::{Value as Json, json};
use super::check_arrays;
use super::whatif::Trial;
use super::*;
use super::{CommandSpec, Ctx, DocState, EngineError, Result, Session, bad, bool_param, cmd, edit, f64_param, has_doc, str_param};
use gridcraft_core::split_sheet;
const CMD: &str = "data.solver";
const REPORT_CMD: &str = "data.solverReport";
@@ -1705,6 +1707,26 @@ fn title(out: &mut Lines, kind: ReportKind, sheet: &str) -> u32 {
3
}
fn format_constraint_row(sh: &Sheet, c: &Constraint, cell: CellRef, v: f64, rhs: &[f64], k: usize) -> Vec<Value> {
let (formula, status, slack) = match (c.rel, &c.rhs) {
(Rel::Int, _) => (format!("{}=Integer", abs_ref(cell)), "Binding", 0.0),
(Rel::Bin, _) => (format!("{}=Binary", abs_ref(cell)), "Binding", 0.0),
(rel, rhs_cells) => {
let b = rhs.get(k).or_else(|| rhs.first()).copied().unwrap_or(f64::NAN);
let rhs_text = match rhs_cells {
Rhs::Cells(cells) => cells.get(k).or_else(|| cells.first()).map(|c| abs_ref(*c)).unwrap_or_default(),
_ => gridcraft_core::number_to_text(b),
};
let slack = (v - b).abs();
let binding = rel == Rel::Eq || slack <= PRECISION * (1.0 + b.abs());
let slack = if binding { 0.0 } else { slack };
(format!("{}{}{rhs_text}", abs_ref(cell), rel.text()), if binding { "Binding" } else { "Not Binding" }, slack)
}
};
let num = |v: f64| if v.is_finite() { Value::number(v) } else { Value::Error(gridcraft_core::CellError::Value) };
vec![Value::text(abs_ref(cell)), Value::text(cell_name(sh, cell)), num(v), Value::text(formula), Value::text(status), num(slack)]
}
fn answer_report(d: &DocState, sh: &Sheet, inp: &ReportInput) -> Lines {
let m = inp.model;
let mut trial = Trial::new(d);
@@ -1768,29 +1790,7 @@ fn answer_report(d: &DocState, sh: &Sheet, inp: &ReportInput) -> Lines {
Rhs::None => vec![],
};
for (k, (cell, v)) in c.lhs.iter().zip(&lhs).enumerate() {
let (formula, status, slack) = match (c.rel, &c.rhs) {
(Rel::Int, _) => (format!("{}=Integer", abs_ref(*cell)), "Binding", 0.0),
(Rel::Bin, _) => (format!("{}=Binary", abs_ref(*cell)), "Binding", 0.0),
(rel, rhs_cells) => {
let b = rhs.get(k).or_else(|| rhs.first()).copied().unwrap_or(f64::NAN);
let rhs_text = match rhs_cells {
Rhs::Cells(cells) => cells.get(k).or_else(|| cells.first()).map(|c| abs_ref(*c)).unwrap_or_default(),
_ => gridcraft_core::number_to_text(b),
};
let slack = (v - b).abs();
let binding = rel == Rel::Eq || slack <= PRECISION * (1.0 + b.abs());
let slack = if binding { 0.0 } else { slack };
(format!("{}{}{rhs_text}", abs_ref(*cell), rel.text()), if binding { "Binding" } else { "Not Binding" }, slack)
}
};
rows.push(vec![
Value::text(abs_ref(*cell)),
Value::text(cell_name(sh, *cell)),
num(*v),
Value::text(formula),
Value::text(status),
num(slack),
]);
rows.push(format_constraint_row(sh, c, *cell, *v, &rhs, k));
}
}
table(&mut out, r + 1, &["Cell", "Name", "Cell Value", "Formula", "Status", "Slack"], rows);
+2 -2
View File
@@ -527,9 +527,9 @@ fn ordinal_suffix(n: i64, suffix: &str) -> String {
/// `Item 007` → ("Item ", 7, "", 3); `3rd` style leading numbers → ("", 3, "rd", 1).
fn split_num(t: &str) -> Option<(String, i64, String, usize)> {
let chars: Vec<char> = t.chars().collect();
let end = chars.iter().rposition(|c| c.is_ascii_digit())?;
let end = chars.iter().rposition(char::is_ascii_digit)?;
let mut start = end;
while start > 0 && chars.get(start - 1).is_some_and(|c| c.is_ascii_digit()) {
while start > 0 && chars.get(start - 1).is_some_and(char::is_ascii_digit) {
start -= 1;
}
let digits: String = chars.get(start..=end)?.iter().collect();
+4 -6
View File
@@ -218,7 +218,7 @@ fn part_too_large_message(name: &str, part: &str, size: u64) -> String {
/// A CSV opens as a sheet named after the file (like Excel).
fn rename_first_sheet(wb: &mut Workbook, path: &str) {
let stem = std::path::Path::new(path).file_stem().and_then(|s| s.to_str()).unwrap_or("Sheet1");
let stem = std::path::Path::new(path).file_stem().and_then(std::ffi::OsStr::to_str).unwrap_or("Sheet1");
let clean: String = stem.chars().filter(|c| !matches!(c, ':' | '\\' | '/' | '?' | '*' | '[' | ']')).take(31).collect();
let clean = clean.trim_matches('\'').to_string();
// "History" is reserved (see `Workbook::check_sheet_name`): keep the default name instead.
@@ -235,7 +235,7 @@ pub fn save_bytes(wb: &Workbook, path: &str) -> Result<Vec<u8>> {
// An unknown or missing extension is refused rather than written as an XLSX under that name:
// a typo like `.cvs` would otherwise produce a file no other app recognises.
let Some(kind) = FileKind::from_path(path) else {
let name = std::path::Path::new(path).file_name().and_then(|n| n.to_str()).unwrap_or(path);
let name = std::path::Path::new(path).file_name().and_then(std::ffi::OsStr::to_str).unwrap_or(path);
return Err(EngineError::Other(format!("{name}: unknown file format. Save as .xlsx, .csv, .tsv, .json or .html.")));
};
save_bytes_as(wb, path, kind)
@@ -249,10 +249,8 @@ pub fn save_bytes_as(wb: &Workbook, path: &str, kind: FileKind) -> Result<Vec<u8
// the VBA project, so any other name saves without it, as Excel does.
// Macros travel in hidden names.
FileKind::Xlsx => {
let named = crate::macros::with_names(wb)?;
let book = std::path::Path::new(path).file_name().and_then(|n| n.to_str()).unwrap_or_default();
gridcraft_xlsx::write_xlsx_named(named.as_ref().unwrap_or(wb), gridcraft_xlsx::XlsxKind::from_path(path), book)
.map_err(|e| EngineError::Other(e.to_string()))
let book = std::path::Path::new(path).file_name().and_then(std::ffi::OsStr::to_str).unwrap_or_default();
gridcraft_xlsx::write_xlsx_named(wb, gridcraft_xlsx::XlsxKind::from_path(path), book).map_err(|e| EngineError::Other(e.to_string()))
}
FileKind::Xls => {
Err(EngineError::Other("Excel 97-2003 (.xls) is supported for import only. Save as .xlsx or another supported export format.".into()))
+16 -2
View File
@@ -289,6 +289,17 @@ impl DocState {
pub fn new(mut wb: Workbook, path: Option<String>, title: String, trust: Trust) -> DocState {
let mut calc = Calc::new();
calc.recalc_all(&mut wb);
Self::with_calc(wb, calc, path, title, trust)
}
/// A workbook as its file saved it: the dependency graph is built but nothing is
/// evaluated, so every value is the one the file holds until an edit reaches it. `trust`
/// is as for [`DocState::new`].
pub fn from_saved(wb: Workbook, path: Option<String>, title: String, trust: Trust) -> DocState {
let mut calc = Calc::new();
calc.rebuild(&wb);
Self::with_calc(wb, calc, path, title, trust)
}
fn with_calc(wb: Workbook, calc: Calc, path: Option<String>, title: String, trust: Trust) -> DocState {
let scripts_trusted = !cmd::extra::has_scripts(&wb);
let wb = Arc::new(wb);
let sel = Selection::at(wb.active().map(|s| s.view_active).unwrap_or_default());
@@ -668,8 +679,11 @@ impl Session {
let result = match result {
Ok(r) => r,
Err(p) => {
let msg =
p.downcast_ref::<&str>().map(|s| s.to_string()).or_else(|| p.downcast_ref::<String>().cloned()).unwrap_or_else(|| "panic".into());
let msg = p
.downcast_ref::<&str>()
.map(ToString::to_string)
.or_else(|| p.downcast_ref::<String>().cloned())
.unwrap_or_else(|| "panic".into());
// Restore the workbook and its history as they were, and drop the undo groups the
// command opened.
self.groups.retain(|g| g.id < first_group);
+4 -1
View File
@@ -11,7 +11,10 @@ use std::collections::{HashMap, HashSet};
use gridcraft_core::date::datetime_from_serial;
pub use gridcraft_core::quote_sheet;
use gridcraft_core::{CellError, CellRef, MAX_COLS, MAX_ROWS, RangeRef, Value, split_sheet};
use gridcraft_model::*;
use gridcraft_model::{
BorderLine, BorderStyle, Cell, Chart, Color, Fill, HAlign, NumFmt, PivotDateGroup, PivotField, PivotFilter, PivotFunc, PivotLayout, PivotShowAs,
PivotSort, PivotTable, PivotValue, Series, Style, StyleId, Workbook,
};
use crate::pivot_calc::CalcSet;
+1 -1
View File
@@ -2,7 +2,7 @@
//! `TableStyleLight1…21 / Medium1…28 / Dark1…11` names so files round-trip).
use gridcraft_core::{CellRef, RangeRef};
use gridcraft_model::*;
use gridcraft_model::{BorderLine, BorderStyle, Cell, Color, Fill, Formula, PatternType, Style, Table, Workbook};
/// (family, number) from a style name, e.g. `TableStyleMedium2` → ("Medium", 2).
pub fn parse_style_name(name: &str) -> (&'static str, u32) {
+128
View File
@@ -2000,6 +2000,69 @@ fn autosum_over_whole_rows_is_one_undo_step_within_the_data() {
assert_eq!(v(&s, "B6"), Value::Empty);
}
fn formula_at(s: &Session, a: &str) -> Option<String> {
s.doc().unwrap().wb.active().unwrap().cell(CellRef::parse(a).unwrap()).and_then(|c| c.formula.as_ref().map(|f| f.text.clone()))
}
#[test]
fn autosum_over_a_row_totals_it_to_the_right() {
// As in Excel: a row selected and AutoSum puts one total to its right, not a one-cell total
// under each of its cells.
let mut s = s();
s.execute("range.setValues", json!({"range": "B2", "values": [[1, 2, 3, 4]]})).unwrap();
s.execute("formulas.autoSum", json!({"range": "B2:E2"})).unwrap();
assert_eq!(formula_at(&s, "F2").as_deref(), Some("SUM(B2:E2)"));
assert_eq!(v(&s, "F2"), Value::Number(10.0));
assert!(gridcraft_core::RangeRef::parse("B3:F3").unwrap().iter().all(|c| v(&s, &c.a1()) == Value::Empty), "nothing under the row");
assert_eq!(s.doc().unwrap().undo.len(), 2, "one undo step");
// A row whose last cell is empty takes its total there.
s.execute("range.setValues", json!({"range": "B4", "values": [[5, 6, 7]]})).unwrap();
s.execute("formulas.autoSum", json!({"range": "B4:E4"})).unwrap();
assert_eq!(formula_at(&s, "E4").as_deref(), Some("SUM(B4:D4)"));
assert_eq!(v(&s, "F4"), Value::Empty);
// AVERAGE and the rest go the same way.
s.execute("formulas.autoSum", json!({"range": "B2:E2", "function": "average"})).unwrap();
assert_eq!(formula_at(&s, "F2").as_deref(), Some("AVERAGE(B2:E2)"));
}
#[test]
fn autosum_over_a_whole_row_totals_it_after_the_data() {
let mut s = s();
s.execute("range.setValues", json!({"range": "A2", "values": [[1, 2, 3]]})).unwrap();
s.execute("formulas.autoSum", json!({"range": "2:2"})).unwrap();
assert_eq!(formula_at(&s, "D2").as_deref(), Some("SUM(A2:C2)"));
assert_eq!(s.doc().unwrap().wb.active().unwrap().cells.used_range().unwrap().a1(), "A2:D2", "not out to the last column");
}
#[test]
fn autosum_fills_a_selections_empty_last_column_and_row() {
// As in Excel: an empty last column takes each row's total, an empty last row each column's,
// and with both the corner takes the total of the block.
let mut s = s();
s.execute("range.setValues", json!({"range": "A1", "values": [[1, 2], [3, 4]]})).unwrap();
s.execute("formulas.autoSum", json!({"range": "A1:C2"})).unwrap();
assert_eq!((formula_at(&s, "C1").as_deref(), formula_at(&s, "C2").as_deref()), (Some("SUM(A1:B1)"), Some("SUM(A2:B2)")));
assert_eq!((v(&s, "C1"), v(&s, "C2")), (Value::Number(3.0), Value::Number(7.0)));
assert!(gridcraft_core::RangeRef::parse("A3:C3").unwrap().iter().all(|c| v(&s, &c.a1()) == Value::Empty), "no totals under the rows");
s.execute("range.setValues", json!({"range": "E1", "values": [[1, 2], [3, 4]]})).unwrap();
let steps = s.doc().unwrap().undo.len();
s.execute("formulas.autoSum", json!({"range": "E1:G3"})).unwrap();
for (cell, formula, value) in
[("E3", "SUM(E1:E2)", 4.0), ("F3", "SUM(F1:F2)", 6.0), ("G1", "SUM(E1:F1)", 3.0), ("G2", "SUM(E2:F2)", 7.0), ("G3", "SUM(E1:F2)", 10.0)]
{
assert_eq!(formula_at(&s, cell).as_deref(), Some(formula), "{cell}");
assert_eq!(v(&s, cell), Value::Number(value), "{cell}");
}
assert_eq!(s.doc().unwrap().undo.len(), steps + 1, "one undo step");
// Neither empty: totals below each column, as before.
s.execute("range.setValues", json!({"range": "I1", "values": [[1, 2], [3, 4]]})).unwrap();
s.execute("formulas.autoSum", json!({"range": "I1:J2"})).unwrap();
assert_eq!((formula_at(&s, "I3").as_deref(), formula_at(&s, "J3").as_deref()), (Some("SUM(I1:I2)"), Some("SUM(J1:J2)")));
assert_eq!(v(&s, "K1"), Value::Empty);
}
#[test]
fn insert_and_delete_cells_never_shift_part_of_a_table() {
// Found by the command-sequence fuzzer: Delete Cells moved part of a table's cells (so two
@@ -3383,6 +3446,19 @@ fn absolute_objects_ignore_sort_and_structural_edits() {
assert_eq!(image_cell(&s, pinned), Some(CellRef::parse("A3").unwrap()));
}
/// Deleting a row removes the sparkline located on it; later ones shift up.
#[test]
fn deleting_a_row_removes_its_sparklines() {
let mut s = s();
s.execute("range.setValues", json!({"range": "A1", "values": [[1, 2, 3], [4, 5, 6], [7, 8, 9]]})).unwrap();
s.execute("insert.sparkline", json!({"range": "A2:C2", "location": "E2"})).unwrap();
s.execute("insert.sparkline", json!({"range": "A3:C3", "location": "E3"})).unwrap();
s.execute("home.deleteRows", json!({"rows": "2:2"})).unwrap();
let got: Vec<(String, String)> =
s.doc().unwrap().wb.active().unwrap().sparklines.iter().map(|sp| (sp.cell.to_string(), sp.source.clone())).collect();
assert_eq!(got, vec![("E2".to_string(), "A2:C2".to_string())]);
}
/// A "move but don't size" object follows its row across a sort but keeps its size.
#[test]
fn move_only_objects_follow_but_do_not_resize() {
@@ -3775,6 +3851,35 @@ fn notes_comments_and_links_survive_a_json_save() {
assert_eq!(after.cells, before.cells);
}
/// Writing a note or a comment on a cell that has one of the same kind edits its text: the
/// replies, the resolved state, the dates and the note's box stay. `comment` replaced the whole
/// thread, so editing a comment dropped its replies.
#[test]
fn editing_a_note_or_a_comment_keeps_its_thread() {
let mut s = s();
s.execute("review.newComment", json!({"cell": "B2", "text": "first", "author": "Ada"})).unwrap();
s.execute("review.replyComment", json!({"cell": "B2", "text": "reply", "author": "Bob"})).unwrap();
s.execute("review.resolveComment", json!({"cell": "B2", "resolved": true})).unwrap();
s.execute("review.newComment", json!({"cell": "B2", "text": "edited"})).unwrap();
s.execute("review.newNote", json!({"cell": "A1", "text": "note", "author": "Ada"})).unwrap();
s.execute("review.showNote", json!({"cell": "A1"})).unwrap();
s.execute("review.newNote", json!({"cell": "A1", "text": "changed"})).unwrap();
let d = s.doc().unwrap();
let sh = d.wb.active().unwrap();
let thread = &sh.comments[&CellRef::parse("B2").unwrap()];
assert_eq!((thread.author.as_str(), thread.text.as_str()), ("Ada", "edited"));
assert_eq!(thread.replies, vec![("Bob".to_string(), "reply".to_string())]);
assert!(thread.resolved && thread.threaded);
let note = &sh.comments[&CellRef::parse("A1").unwrap()];
assert_eq!((note.author.as_str(), note.text.as_str()), ("Ada", "changed"));
assert!(note.visible && !note.threaded);
// A note written over a comment replaces it.
s.execute("review.newNote", json!({"cell": "B2", "text": "plain"})).unwrap();
let d = s.doc().unwrap();
let replaced = &d.wb.active().unwrap().comments[&CellRef::parse("B2").unwrap()];
assert!(!replaced.threaded && replaced.replies.is_empty());
}
/// A cut moves the block's notes, hyperlinks and merged areas with it. The source's cells and
/// pictures were removed but the block's merges and notes were not, so one merged block became
/// two (an empty one left at the old place) and a note ended up on both the old and the new
@@ -4778,3 +4883,26 @@ fn a_script_cannot_switch_windows() {
mod macros;
mod seams;
#[test]
fn a_saved_workbook_keeps_its_values_until_an_edit_reaches_them() {
let mut s = s();
s.execute("cell.set", json!({"cell": "A1", "input": "2"})).unwrap();
s.execute("cell.set", json!({"cell": "B1", "input": "=A1*2"})).unwrap();
s.execute("cell.set", json!({"cell": "C1", "input": "=10"})).unwrap();
let mut wb = (*s.doc().unwrap().wb).clone();
// The values a file would have saved, not the ones the formulas give.
for (a, n) in [("B1", 99.0), ("C1", 7.0)] {
let sheet = std::sync::Arc::make_mut(&mut wb.sheets[0]);
let c = CellRef::parse(a).unwrap();
let mut cell = sheet.cells.get(c).unwrap().clone();
cell.value = Value::Number(n);
sheet.set_cell(c, cell);
}
let mut saved = Session::new();
saved.add_document(crate::DocState::from_saved(wb, None, "saved".into(), crate::Trust::FromFile));
assert_eq!(v(&saved, "B1"), Value::Number(99.0));
saved.execute("cell.set", json!({"cell": "A1", "input": "3"})).unwrap();
assert_eq!(v(&saved, "B1"), Value::Number(6.0));
assert_eq!(v(&saved, "C1"), Value::Number(7.0));
}